2025-12-24 08:00:00
三年前写过两篇《每周轮子》系列文章,讲如何从零实现一个日常都在用的 npm 包。其中的乐趣就是看看自己实现和别人有什么不一样,同时也能开阔视野。
现在迎来第三篇,这次我们将目光转向 React Server Components(说到转,旧手机可以找…
毕竟众所周知,这个月初 Next.js 圈子大型翻车现场,先后爆出几个 CVE,最出名的当属 CVE-2025-55182,据闻我们的大善人 Cloudflare 两周崩两次也是因为它,逼得我几次在群里嘴臭。
(PHP 有一个很经典的漏洞叫作 PHP Object Injection)
当我知道这个 CVE 时,上 fofa 随便挑选一位幸运倒霉蛋进行测试,很容易就利用上了:
结果没过几天,又来一个 CVE-2025-55183,这是一个会泄露 RSC 组件源码的漏洞,说到源码泄露,这就要引出本文的主角了:server-only。
几乎所有最佳实践都告诉我们:在服务端代码顶部加上 import 'server-only',可以避免源码泄露。
今天突发奇想,想看看它是怎么实现的。
按照惯性思维,如果让我实现一个 server-only,我第一反应是写个运行时检查。既然代码不能在浏览器跑,那我就检测 window 对象嘛。
// 我脑补的 server-only
if (typeof window !== "undefined") {
throw new Error("❌ 严重安全错误:服务端模块泄露到了客户端!");
}
这看起来很合理,对吧?
但这完全是错的。
Dan 在 How imports work in RSC 里讲得很清楚:哪怕你声明 'use server',但只要构建工具(Webpack/Turbopack)看到你 import 了,它就会把代码打包进去。
也就是说,用上面这种方式,虽然页面报错了,但你的服务端代码依然存在于浏览器下载的 JS 文件里。只要别人右键查看源码,依然泄露。
典型“脱裤子放屁”——甚至更离谱,因为你觉得你安全了。
既然 Runtime 检查太晚了,我们必须在 Build Time(构建时)拦截它。
那 server-only 是怎么做的,如果你点开它的 npm 页面,你会发现它什么都没有,没有 README.md 甚至没有 git repo,你甚至都不知道它的作者是谁。
然后我打开 code 一看就这么简单:
server-only
├── index.js
├── empty.js
└── package.json
(这不比传说中的 is-odd 还简陋?)
里面的文件就是:
// index.js
throw new Error(
"This module cannot be imported from a Client Component module. " +
"It should only be used from a Server Component."
);
// empty.js
// 没错这个就是空的
细究之后才发现,这是一种毒药模式,简单说就是利用 Node.js 的条件导出实现一个精分 npm 包:
{
"name": "server-only",
"description": "This is a marker package to indicate that a module can only be used in Server Components.",
"files": ["index.js", "empty.js"],
"main": "index.js",
"exports": {
".": {
"react-server": "./empty.js",
"default": "./index.js"
}
}
}
react-server 是什么?很明显它不是 Node.js 的规范,而是 React 团队定义的一种规范,代表了 RSC 的运行时环境。
这也是给所有第三方库用的一种标准,所以在 Next.js (或者其他 RSC 框架) 的构建时,它实现会跑两条 Pipelines:
当 server-only 这个包被服务端引入,它就是一个空文件,被客户端引入它就报错,这就是毒药。
我在研究过程中,顺藤摸瓜看了下 Next.js 仓库里的 Issue #71071,发现事情还没这么简单。
这个包最早的出处来自这个 PR,其实这个包只是 Next.js 内部使用,所以它什么都没有。
你可能会问:如果我只引入了类型呢?
// ClientComponent.tsx
import type { UserType } from "./db-schema"; // 引用了 server-only 的文件
结论是:安全的。现代构建工具足够聪明,import type 会在编译阶段被移除,根本不会触发 server-only 的解析逻辑。
但是,如果你写成这样:
import { UserType, dbInstance } from "./db-schema";
即使你在代码里完全没用到 dbInstance,只是想用一下 UserType,构建工具依然会去解析这个文件,然后触发 server-only 的报错。
以前我们开发一个 npm 包,package.json 里写个 main 和 module 就完事了。但在 RSC 时代,如果你开发的库(比如一个数据库 ORM 客户端)不希望被误用到前端,你必须手动加上 react-server 的导出条件。
但如果你很不幸使用了一个没跟上节奏的 npm 包,那你得自觉地贴上 import 'server-only'。
2025-12-18 09:29:00
Etherpad 是一款基于 Node.js 的开源实时协作编辑器,能让很多人同时在线编辑,团队用它写文章、新闻稿、会议记录或者待办事项都很方便。跟 Google Docs 比,它最大的优点是数据可以自己管,扩展性也很强。
本文不重复官方文档里那些基础的东西,而是结合实际,好好讲讲怎么开发一个集成了“富文本、组件化内容、多渠道发布”的插件。
Etherpad 大概长这样:
Etherpad 用的是全栈 JavaScript,靠 Socket.io 做实时通信,还用 OT 算法解决多人同时编辑的冲突。
前端 (jQuery + Ace2):页面用 jQuery 搭,编辑器核心是用 iframe 装着的 Ace2(基于 contentEditable 做的),负责接收输入,生成 Changeset。
后端 (Node.js + UeberDB):负责管 WebSocket 连接,合并 Changeset,然后广播出去。
插件系统 (Hook):系统在关键的地方(比如 padInitToolbar, getLineHTMLForExport)留了 Hook 让你加东西。
官方建议一个功能搞一个 npm 包,但如果要深度定制,这样做太麻烦了。建议建一个插件合集(比如叫 ep_plugins)。
我做的这个插件集合主要有三件事:
<ep-*>)”来放复杂的内容(题图卡片、主题卡片、往期阅读卡片、公众号关注卡片、腾讯视频、文章目录</ep-*>等等),在编辑器里看着就像可以编辑的文本,导出或者发布的时候再解析成 HTML。设计思路:
color=#f13b03、url=https://...),然后在 Ace 渲染的时候转成 CSS class 或者 DOM 结构,这样编辑起来更顺手。<ep-*> 标签留着;然后用 marked 和自定义扩展把 <ep-*> 变成不同渠道的</ep-xx></ep-xx> HTML。开发行内样式(拿“字体颜色”举例)
先在工具栏注册按钮,然后监听下拉框的变化,调用 documentAttributeManager 写入数据。
// static/js/index.js
// 1. 监听工具栏初始化 (Hook: postToolbarInit)
exports.postToolbarInit = (hook, context) => {
const toolbar = context.toolbar;
// 注册下拉框变化事件
toolbar.registerCommand("fontColor", value => {
const ace = context.ace;
ace.callWithAce(
ace => {
// 给当前选区打上 color 属性
ace.ace_setAttributeOnSelection("color", value);
},
"fontColor",
true
);
});
};
Etherpad 默认不认识 color 属性,需要我们告诉它如何渲染。
// static/js/index.js
// 2. 属性转 Class (Hook: aceAttribsToClasses)
exports.aceAttribsToClasses = (hook, context) => {
// 如果属性名是 color,生成 .color__#xxxxxx 的 class
if (context.key === "color") {
return [`color__${context.value.replace("#", "")}`];
}
};
// 3. 注入 CSS 样式 (Hook: aceInitInnerdocbodyHead)
// 注意:样式必须注入到 ace_inner iframe 中
exports.aceInitInnerdocbodyHead = (hook, context) => {
return [
`
<style>
/* 动态匹配所有颜色 class */
[class*="color__"] { display: inline; }
/* 这里通常需要动态生成 CSS,或者使用 CSS 变量方案 */
.color__f13b03 { color: #f13b03; }
</style>
`,
];
};
对于题图、目录、视频等复杂内容,我们使用 自定义标签(Custom Tags) 作为载体。
为了让系统能识别 <ep-toc> 或 <ep-url>,我们需要扩展 marked 解析器。这是整个组件化系统的基石。
<details open>
<summary><strong>build-marked-extension.js</strong></summary>
const cheerio = require("cheerio");
/**
* 创建 marked 自定义扩展,实现自定义 block token
* @param {string} name token 名字
* @param {string} tagName 标签名字
* @param {Function} renderer 渲染器
* @returns
*/
function buildCustomBlockTokenExtension(name, tagName, { renderer }) {
return {
name,
level: "block",
tokenizer(src) {
const rule = new RegExp(
`^<ep-${tagName}\\b[^>]*>\\n([\\s\\S]*?)\\n<\\/ep-${tagName}>`
);
const match = rule.exec(src);
if (match) {
const $ = cheerio.load(`<body>${match[0]}</body>`);
const attrs = getAllAttributes($(`body > ep-${tagName}`).get(0));
const token = {
type: name,
raw: match[0],
text: match[1].trim(),
tokens: [],
attrs,
};
this.lexer.blockTokens(token.text, token.tokens);
return token;
}
return undefined;
},
renderer,
};
}
/**
* 创建 marked 自定义扩展,实现自定义 inline token
* @param {string} name token 名字
* @param {string} tagName 标签名字
* @param {Function} renderer 渲染器
* @returns
*/
function buildCustomInlineTokenExtension(name, tagName, { renderer }) {
return {
name,
level: "inline",
start(src) {
return src.match(new RegExp(`<ep-${tagName}>`))?.index;
},
tokenizer(src) {
const rule = new RegExp(
`^<ep-${tagName}\\b[^>]*>((?:(?!<\\/ep-${tagName}>)[\\s\\S])*?)<\\/ep-${tagName}>`
);
const match = rule.exec(src);
if (match) {
const $ = cheerio.load(`<body>${match[0]}</body>`);
const attrs = getAllAttributes($(`body > ep-${tagName}`).get(0));
return {
type: name,
raw: match[0],
text: match[1].trim(),
tokens: this.lexer.inlineTokens(match[1].trim()),
attrs,
};
}
return undefined;
},
renderer,
};
}
/**
* 使用自定义标签包裹
* @param {string} tagName
* @param {string} content
* @param {object} attrs
* @returns
*/
function useCustomTag(tagName, content, attrs = {}) {
const contentText = content ? `\n${content}\n` : "";
if (Object.keys(attrs).length) {
const attrsText = Object.entries(attrs)
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
return `<ep-${tagName} ${attrsText}>${contentText}</ep-${tagName}>`;
}
return `<ep-${tagName}>${contentText}</ep-${tagName}>`;
}
const getAllAttributes = function (node) {
const attributes =
node.attributes ||
Object.keys(node.attribs).map(name => ({
name,
value: node.attribs[name],
}));
return attributes.reduce((acc, cur) => {
return {
[cur.name]: cur.value,
...acc,
};
}, {});
};
module.exports = {
buildCustomBlockTokenExtension,
buildCustomInlineTokenExtension,
useCustomTag,
};
</details>
利用上面的构建器,我们可以快速定义一个目录组件的渲染逻辑。
// 注册 TOC 扩展
const { marked } = require("marked");
const { buildCustomBlockTokenExtension } = require("./build-marked-extension");
const tocExtension = buildCustomBlockTokenExtension("toc", "toc", {
renderer(token) {
// token.text 内容示例:"🧩:: 第一节标题\n🔍:: 第二节标题"
const items = token.text.split("\n").filter(Boolean);
const html = items
.map(line => {
const [emoji, text] = line.split("::");
return `<div class="toc-item"><span>${emoji}</span><a>${text}</a></div>`;
})
.join("");
return `<section class="toc-container">${html}</section>`;
},
});
// 加载扩展
marked.use({ extensions: [tocExtension] });
这是最复杂的模块:将 Pad 的 AText 数据转换为“增强版 Markdown”,再渲染为 HTML。
我们需要编写转换器,遍历 AText 的 attribs,将 color 属性还原为 <ep-color> 标签。
<details>
<summary><strong>get-pad-markdown-document.js (点击展开)</strong></summary>
const Changeset = require("ep_etherpad-lite/static/js/Changeset");
const padManager = require("ep_etherpad-lite/node/db/PadManager");
const { CUSTOM_TAGS } = require("../config");
const { correctLink } = require("./index");
const getCloseableTags = apool => {
const normalTags = ["**", "*", ["<u>", "</u>"], "~~"];
const normalProps = ["bold", "italic", "underline", "strikethrough"];
const customAttrs = [
CUSTOM_TAGS.COLOR,
CUSTOM_TAGS.HIGHLIGHT,
CUSTOM_TAGS.FONT_SIZE,
CUSTOM_TAGS.URL,
CUSTOM_TAGS.IMAGE_CAPTION,
];
const customProps = [];
apool.eachAttrib((k, v) => {
if (customAttrs.includes(k)) {
if (v !== "false") {
customProps.push([k, v]);
}
}
});
const props = [...normalProps.map(p => [p, true]), ...customProps];
const tags = [
...normalTags.map(tag => {
const tags = Array.isArray(tag) ? tag : [tag, tag];
const [open, close] = tags;
return {
open,
close,
};
}),
...customProps.map(([k, v]) => ({
open: `<ep-${k} ${k}="${v}">`,
close: `</ep-${k}>`,
})),
];
const anumMap = {};
props.forEach(([propName, propValue], i) => {
const propTrueNum = apool.putAttrib([propName, propValue], true);
if (propTrueNum >= 0) {
anumMap[propTrueNum] = i;
}
});
return { props, tags, anumMap };
};
const getMarkdownFromAtext = (pad, atext) => {
const apool = pad.apool();
const textLines = atext.text.slice(0, -1).split("\n");
const attribLines = Changeset.splitAttributionLines(
atext.attribs,
atext.text
);
const { tags, props, anumMap } = getCloseableTags(apool);
props.forEach((propName, i) => {
const propTrueNum = apool.putAttrib([propName, true], true);
if (propTrueNum >= 0) {
anumMap[propTrueNum] = i;
}
});
const headingtags = [
"# ",
"## ",
"### ",
"#### ",
"##### ",
"###### ",
" ",
];
const headingprops = [
["heading", "h1"],
["heading", "h2"],
["heading", "h3"],
["heading", "h4"],
["heading", "h5"],
["heading", "h6"],
["heading", "code"],
];
const headinganumMap = {};
headingprops.forEach((prop, i) => {
let name;
let value;
if (typeof prop === "object") {
[name, value] = prop;
} else {
name = prop;
value = true;
}
const propTrueNum = apool.putAttrib([name, value], true);
if (propTrueNum >= 0) {
headinganumMap[propTrueNum] = i;
}
});
const getLineMarkdown = (text, attribs) => {
const propVals = [false, false, false];
const ENTER = 1;
const STAY = 2;
const LEAVE = 0;
// Use order of tags (b/i/u) as order of nesting, for simplicity
// and decent nesting. For example,
// <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>
// becomes
// <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>
const taker = Changeset.stringIterator(text);
let assem = Changeset.stringAssembler();
const openTags = [];
const emitOpenTag = i => {
openTags.unshift(i);
assem.append(tags[i].open);
};
const emitCloseTag = i => {
openTags.shift();
assem.append(tags[i].close);
};
const orderdCloseTags = tags2close => {
for (let i = 0; i < openTags.length; i++) {
for (let j = 0; j < tags2close.length; j++) {
if (tags2close[j] === openTags[i]) {
emitCloseTag(tags2close[j]);
i--;
break;
}
}
}
};
// start heading check
let heading = false;
let deletedAsterisk = false; // we need to delete * from the beginning of the heading line
const iter2 = Changeset.opIterator(Changeset.subattribution(attribs, 0, 1));
if (iter2.hasNext()) {
const o2 = iter2.next();
// iterate through attributes
Changeset.eachAttribNumber(o2.attribs, a => {
if (a in headinganumMap) {
const i = headinganumMap[a]; // i = 0 => bold, etc.
heading = headingtags[i];
}
});
}
if (heading) {
assem.append(heading);
}
const urls = _findURLs(text);
let idx = 0;
const processNextChars = numChars => {
if (numChars <= 0) {
return;
}
const iter = Changeset.opIterator(
Changeset.subattribution(attribs, idx, idx + numChars)
);
idx += numChars;
while (iter.hasNext()) {
const o = iter.next();
let propChanged = false;
Changeset.eachAttribNumber(o.attribs, a => {
if (a in anumMap) {
const i = anumMap[a]; // i = 0 => bold, etc.
if (!propVals[i]) {
propVals[i] = ENTER;
propChanged = true;
} else {
propVals[i] = STAY;
}
}
});
for (let i = 0; i < propVals.length; i++) {
if (propVals[i] === true) {
propVals[i] = LEAVE;
propChanged = true;
} else if (propVals[i] === STAY) {
propVals[i] = true; // set it back
}
}
// now each member of propVal is in {false,LEAVE,ENTER,true}
// according to what happens at start of span
if (propChanged) {
// leaving bold (e.g.) also leaves italics, etc.
let left = false;
for (let i = 0; i < propVals.length; i++) {
const v = propVals[i];
if (!left) {
if (v === LEAVE) {
left = true;
}
} else if (v === true) {
propVals[i] = STAY; // tag will be closed and re-opened
}
}
const tags2close = [];
for (let i = propVals.length - 1; i >= 0; i--) {
if (propVals[i] === LEAVE) {
// emitCloseTag(i);
tags2close.push(i);
propVals[i] = false;
} else if (propVals[i] === STAY) {
// emitCloseTag(i);
tags2close.push(i);
}
}
orderdCloseTags(tags2close);
for (let i = 0; i < propVals.length; i++) {
if (propVals[i] === ENTER || propVals[i] === STAY) {
emitOpenTag(i);
propVals[i] = true;
}
}
// propVals is now all {true,false} again
} // end if (propChanged)
let { chars } = o;
if (o.lines) {
chars--; // exclude newline at end of line, if present
}
let s = taker.take(chars);
// removes the characters with the code 12. Don't know where they come
// from but they break the abiword parser and are completly useless
s = s.replace(String.fromCharCode(12), "");
// delete * if this line is a heading
if (heading && !deletedAsterisk) {
s = s.substring(1);
deletedAsterisk = true;
}
assem.append(s);
} // end iteration over spans in line
const tags2close = [];
for (let i = propVals.length - 1; i >= 0; i--) {
if (propVals[i]) {
tags2close.push(i);
propVals[i] = false;
}
}
orderdCloseTags(tags2close);
}; // end processNextChars
if (urls) {
urls.forEach(urlData => {
const startIndex = urlData[0];
const url = urlData[1];
const urlLength = url.length;
processNextChars(startIndex - idx);
assem.append(`[${url}](`);
processNextChars(urlLength);
assem.append(")");
});
}
processNextChars(text.length - idx);
// replace &, _
assem = assem.toString();
assem = assem.replace(/&/g, "\\&");
// this breaks Markdown math mode: $\sum_i^j$ becomes $\sum\_i^j$
assem = assem.replace(/_/g, "\\_");
return assem;
};
// end getLineMarkdown
const pieces = [];
// Need to deal with constraints imposed on HTML lists; can
// only gain one level of nesting at once, can't change type
// mid-list, etc.
// People might use weird indenting, e.g. skip a level,
// so we want to do something reasonable there. We also
// want to deal gracefully with blank lines.
// => keeps track of the parents level of indentation
const lists = []; // e.g. [[1,'bullet'], [3,'bullet'], ...]
for (let i = 0; i < textLines.length; i++) {
const line = _analyzeLine(textLines[i], attribLines[i], apool);
let lineContent = getLineMarkdown(line.text, line.aline);
// If we are inside a list
if (line.listLevel) {
// do list stuff
let whichList = -1; // index into lists or -1
if (line.listLevel) {
whichList = lists.length;
for (let j = lists.length - 1; j >= 0; j--) {
if (line.listLevel <= lists[j][0]) {
whichList = j;
}
}
}
// means we are on a deeper level of indentation than the
// previous line
if (whichList >= lists.length) {
lists.push([line.listLevel, line.listTypeName]);
}
if (line.listTypeName === "number") {
pieces.push(
`\n${new Array(line.listLevel * 4).join(" ")}1. `,
lineContent || "\n"
); // problem here
} else {
pieces.push(
`\n${new Array(line.listLevel * 4).join(" ")}* `,
lineContent || "\n"
); // problem here
}
} else {
// outside any list
const context = {
line,
lineContent,
apool,
attribLine: attribLines[i],
text: textLines[i],
};
lineContent = getLineMarkdownForExport(context);
pieces.push("\n", lineContent, "\n");
}
}
return pieces.join("");
};
// 参考 getLineHTMLForExport 的实现,返回自定义的 Markdown 内容
function getLineMarkdownForExport(context) {
const img = analyzeLineForTag(context.attribLine, context.apool, "img");
const customImg = analyzeLineForTag(
context.attribLine,
context.apool,
"customImg"
);
if (img) {
return ``;
}
if (customImg) {
return ``;
}
return context.lineContent;
}
function analyzeLineForTag(alineAttrs, apool, tag) {
let result = null;
if (alineAttrs) {
const opIter = Changeset.opIterator(alineAttrs);
if (opIter.hasNext()) {
const op = opIter.next();
result = Changeset.opAttributeValue(op, tag, apool);
}
}
return result;
}
const _analyzeLine = (text, aline, apool) => {
const line = {};
// identify list
let lineMarker = 0;
line.listLevel = 0;
if (aline) {
const opIter = Changeset.opIterator(aline);
if (opIter.hasNext()) {
let listType = Changeset.opAttributeValue(opIter.next(), "list", apool);
if (listType) {
lineMarker = 1;
listType = /([a-z]+)([12345678])/.exec(listType);
if (listType) {
/* eslint-disable-next-line prefer-destructuring */
line.listTypeName = listType[1];
line.listLevel = Number(listType[2]);
}
}
}
}
if (lineMarker) {
line.text = text.substring(1);
line.aline = Changeset.subattribution(aline, 1);
} else {
line.text = text;
line.aline = aline;
}
return line;
};
const getPadMarkdown = async (pad, revNum) => {
const atext =
revNum == null ? pad.atext : await pad.getInternalRevisionAText(revNum);
return getMarkdownFromAtext(pad, atext);
};
const formatMarkdown = markdown => {
return markdown
.split("\n")
.map(e => {
/**
* 格式化 list 缩进
*/
if (e.trim().startsWith("- ")) {
const text = e.trim();
if (text.includes("([")) {
return correctLink(text);
}
return text;
}
if (e.trim().startsWith("* -")) {
return e.trim().replace("* -", "-");
}
// 解决链接嵌套问题
if (e.startsWith("### ")) {
return `### ${correctLink(e.split("### ").pop())}`;
}
if (e.includes("([")) {
return correctLink(e);
}
return e;
})
.join("\n");
};
module.exports = async function getPadMarkdownDocument(padId, revNum) {
let res = await getPadMarkdown(await padManager.getPad(padId), revNum);
res = formatMarkdown(res);
return res;
};
// copied from ACE
const _REGEX_WORDCHAR = new RegExp(
[
"[",
"\u0030-\u0039",
"\u0041-\u005A",
"\u0061-\u007A",
"\u00C0-\u00D6",
"\u00D8-\u00F6",
"\u00F8-\u00FF",
"\u0100-\u1FFF",
"\u3040-\u9FFF",
"\uF900-\uFDFF",
"\uFE70-\uFEFE",
"\uFF10-\uFF19",
"\uFF21-\uFF3A",
"\uFF41-\uFF5A",
"\uFF66-\uFFDC",
"]",
].join("")
);
const _REGEX_URLCHAR = new RegExp(
`([-:@a-zA-Z0-9_.,~%+/\\?=&#;()$]|${_REGEX_WORDCHAR.source})`
);
const _REGEX_URL = new RegExp(
"(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt)://|mailto:)" +
`${_REGEX_URLCHAR.source}*(?![:.,;])${_REGEX_URLCHAR.source}`,
"g"
);
// returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...]
const _findURLs = text => {
_REGEX_URL.lastIndex = 0;
let urls = null;
let execResult;
// eslint-disable-next-line no-cond-assign
while ((execResult = _REGEX_URL.exec(text))) {
urls = urls || [];
const startIndex = execResult.index;
const url = execResult[0];
urls.push([startIndex, url]);
}
return urls;
};
</details>
在多渠道发布时,marked.use() 会污染全局实例。如果渠道 A 需要 iframe 视频,渠道 B 只需要链接,必须进行扩展隔离。
// 每次渲染前重置扩展
const { marked } = require("marked");
function renderForChannel(markdown, channelExtensions) {
// 1. 获取默认扩展
const defaults = marked.defaults.extensions || {
renderers: {},
childTokens: {},
};
// 2. 动态合并当前渠道需要的扩展
const newExtensions = { ...defaults, ...channelExtensions };
// 3. 强制重置 marked 配置 (HACK)
marked.setOptions({ extensions: newExtensions });
return marked.parse(markdown);
}
协作编辑时,用户经常造出 [text]([inner](url)) 这种非法 Markdown,导致解析崩溃。
// utils/index.js
/**
* 修复嵌套链接:[text]([inner](url)) -> [text](url)
*/
function correctLink(markdownText) {
const pattern = /\[(.+)\]\(\[(.+)\]\((.+)\)\)/g;
return markdownText.replace(pattern, "[$1]($3)");
}
/**
* HTML 清洗:移除多余的 P 标签
*/
const removePTag = html => {
return html.replace(/<p>/g, "").replace(/<\/p>/g, "");
};
/**
* 链接还原:将 Markdown 链接转为纯文本 (用于生成纯文本目录)
*/
function convertLinksToText(markdownText) {
return markdownText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1");
}
module.exports = { correctLink, removePTag, convertLinksToText };
在 Client 端开发时,切记 Ace 运行在嵌套 iframe 中。
// 获取 inner editor 的 body
const $innerBody = $('iframe[name="ace_outer"]')
.contents()
.find('iframe[name="ace_inner"]')
.contents()
.find("body");
// 绑定事件必须穿透
$innerBody.on("click", "a", function (e) {
// ...
});
如果你在插件中处理图片上传,Express 默认的限制会导致 413 错误。
// 在 hook 'expressCreateServer' 中配置
exports.expressCreateServer = (hookName, args, cb) => {
const app = args.app;
// 调大限制到 50mb
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
cb();
};
2025-12-10 09:46:00
过去几年,我的主力机一直是那台 14 英寸的 M1 Pro,虽然也有 dotfiles 仓库,通过手工软链来同步,但充其量只是为了版本控制。
直到我入手了 Mac mini M4,才有了要同步两台设备配置的需求。因为我已经不止一次:在 A 机上装了某个命令行工具,到了 B 机又得重新安装、重新配置……折腾几次后,还是得找一种方案。
我的目标很简单:只要在任意一台机器上安装新软件或修改配置,其他设备可以快速做到 1:1 同步。
核心就两条命令:
以下全是细节。
最终核心就三板斧:
仓库结构示例如下:
dotfiles/
├── .mackup.cfg
├── mackup/ # mackup 导出的所有配置
├── mackup/brew-formulae.txt
├── mackup/brew-casks.txt
├── bin/macup → ../mackup-backup.sh
├── bin/macdown → ../mackup-restore.sh
├── bin/xxx # 任何可执行文件,会自动软链到 ~/.bin
├── mackup-backup.sh
├── mackup-restore.sh
└── init.sh # 新机器第一步运行,自动软链 .mackup.cfg
.mackup.cfg 示例:
[storage]
engine = file_system
path = /Users/4ark/projects/dotfiles
directory = mackup
[applications_to_sync]
Bash
Charles
Cursor
claude-code
dig
git-hooks
homebrew
Htop
Itsycal
custom-kitty
nvm
PicGo
Pnpm
ripgrep
SourceTree
yazi
Zsh
Mercurial
p10k
vim
neovim
ssh
starship
[applications_to_ignore]
adium
2025-11-04 08:00:00
如果在 2025 年,你还没有在工作中借助 AI,要么你的水平已经超越 AI,要么你就是被 AI 代替的部分。大多数人都不是前者,也不愿成为后者。如何高效、可靠地利用 AI,是每位开发者的必修课。
我个人的探索大致经历了以下几个阶段,从最初的简单补全,到如今的规范驱动开发——OpenSpec。
洪荒时代:AI 仅作“补全” • 编辑器里集成 Copilot 插件,或者 ChatGPT 网页窗口复制粘贴,AI 只能做最基础的代码补全,效率提升有限。
集成时代:对话式编辑 • Cursor 等工具将聊天窗口直接搬进 IDE,上下文无缝传递,减少复制粘贴的步骤。
增强时代:MCP 协议崛起 • Model Context Protocol 赋予 AI Agent 文件读写、命令执行、API 调用能力,自动化水平大幅跃升。
智能时代:自主驱动 • AI Agent 能理解复杂任务,分解步骤,自主调用工具链完成工作,不再是简单的问答助手,而是智能协作伙伴。
尽管 AI 功能越来越强,但在团队协作中,仍面临几大痛点:
过去,我常让 AI Agent 先输出思路再动手,但每次改动都像黑盒测试,改错了只能重来。
这也是大家遇到的问题,所以最近出现了一系列工具:spec-kit、OpenSpec 等。
“每次改动都是一个提案”
• 在动手之前,先形成结构化的 proposal.md,明确 Why / What / How / Impact,我们 Review 通过后再执行。
规范驱动、约定可持久化 • 所有决策、设计、验收标准都以文件形式保存在仓库里,新工具或新同事都能快速“秒懂”项目规范。
通过将项目规范、架构决策、功能需求以结构化文档记录,OpenSpec 保证了上下文一致性、变更可追溯,也让 AI Agent 在任何时刻都能准确执行。
这样一来,我们从指令层的操作者,变成了规范层的把关者,放权给 AI Agent 干更多“脏活累活”。
下面以“撰写本篇文章”为例,演示 OpenSpec 的完整流程。
# 检查 Node.js 版本(需要 >= 20.19.0)
node --version
# v24.11.0
npm install -g @fission-ai/openspec@latest
cd my-project
openspec init
openspec/project.md、openspec/AGENTS.md、openspec/specs/、openspec/changes/ 等目录结构验证初始化成功,只需要执行:
openspec list
# No active changes found.
然后让 AI Agent 帮你完善项目背景:
Populate your project context:
"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"
向 AI Agent 提需求:
“在
src/content/blog下新建《OpenSpec 使用心得》文章,包含:
- 引言:为何要拥抱 AI
- AI 演进阶段
- 现有困境与解决方案
- OpenSpec 流程
- 个人实践心得”
AI Agent 自动生成 openspec/changes/complete-openspec-article/proposal.md、tasks.md、相应 specs/。
proposal.md 包含了提案的核心信息:
## Why
现有的 `src/content/blog/2025-11-04-openspec.md` 文章内容不完整,
需要补充完整的内容以完成一篇关于 OpenSpec 使用心得的博客文章。
## What Changes
- 完成博客文章 `src/content/blog/2025-11-04-openspec.md` 的写作
- **保留现有内容**:不删除已有的引言、演进阶段和困境解决方案部分
- **文章要求**:
- 确保内容准确性
- AI 工具演进阶段需准确描述
- 精简整篇文章篇幅...
- 统一名词使用...
## Impact
- 受影响文件:`src/content/blog/2025-11-04-openspec.md`
tasks.md 列出了详细的实施任务清单:
## 1. 内容创作
- [ ] 1.1 完成第一部分:引言:拥抱 AI 工具
- [ ] 1.2 完成第二部分:AI 工具的演进阶段
- [ ] 1.2.1 以段落形式描述四个阶段...
- [ ] 1.3 完成第三部分:现有困境与解决方案
- [ ] 1.4 完成第四部分:OpenSpec 的特点与使用流程
- [ ] 1.4.1 准确介绍 spec-kit 和 OpenSpec 工具...
- [ ] 1.4.5 使用案例:完成这篇文章提案本身...
specs/blog-content/spec.md 定义了规范要求:
## ADDED Requirements
### Requirement: OpenSpec 使用心得博客文章
博客系统 SHALL 包含一篇完整的关于 OpenSpec 使用心得的文章...
#### Scenario: 文章内容完整性
- **WHEN** 用户访问博客文章页面
- **THEN** 文章应包含所有五个主要部分...
#### Scenario: 文章精炼度
- **WHEN** 文章完成
- **THEN** 应遵循以下原则:
- 去除所有冗余和重复内容...
- 使用案例部分需要详细描述...
我在对话中提出细化或优化建议,AI Agent 即刻更新提案,直到内容满足需求,再进入“实施”阶段。
更新提案,介绍完现有的困境,以及 spec-kit 和 openspec 这类工具的优势以后,就要开始一个使用案例,我就以本次我是如何使用 openspec 帮我完成这个提案本身,去写这篇文章。
更新提案,文章里面有几个部分需要调整:
- AI 工具的演进阶段,是不是足够准确
- 精简整篇文章,去除冗余和重复内容
跟 AI Agent 说:
实施这个提案
它会自动帮你执行这个指令:openspec apply complete-openspec-article
归档这个提案
对应这个指令:openspec archive complete-openspec-article --yes
提案及规范文件被归档到 openspec/changes/archive/2025-11-04-complete-openspec-article/,形成完整可追溯的记录。
2025-10-28 08:00:00
起因是一个以微信小程序为主开发的 uni-app 应用,在编译到 App 端后,出现了各种样式问题。
为了更好地理解这个问题,让我们创建一个最小化的示例项目:
pnpm create uni@latest # 什么都不加
一个经典的嵌套组件,用于测试三个端组件样式表现:
这里可以看到,每个组件都开启了 style scoped,此时三个端的表现是一致的,没有任何问题:
然而,在实际开发中,我们经常会遇到这样一种情况:由于启用了 style scoped,往往会倾向于使用简单的 class 命名,比如大量使用 container 这样的通用类名。让我们看一个具体示例:
在这种情况下,三个端的表现会出现明显的差异:
这是 bug?先说结论,这是 Vue 的 feature:
使用 scoped 后,父组件的样式将不会渗透到子组件中。不过,子组件的根节点会同时被父组件的作用域样式和子组件的作用域样式影响。这样设计是为了让父组件可以从布局的角度出发,调整其子组件根元素的样式。
可前面说了,我们是以微信小程序为主进行开发,看到这种情况还是会非常懵逼。
所以,这就不得不先看一下 uni-app 在不同端是如何实现样式隔离的。
App 端的实现方式其实与我们熟悉的 Vue 浏览器端机制一样:它们都是通过为每个标签动态添加 [data-v-scopeId] 属性来实现样式的作用域隔离。
而在微信小程序端,由于小程序的 CSS 不支持属性选择器,uni-app 采用了一种变通方案:为每个标签添加带有 [data-v-scopeId] 的 class 来实现隔离效果。
下面这张图可以帮助我们直观地理解三个端的 HTML 结构差异:
这里我们可以留意到,子组件的根元素会附带上父组件的 scopeId,所以父组件可以影响它的样式。
而在微信小程序,因为多了一层 <components/comp-child> 这样的东西,scopeId 并不在真正的组件根元素,所以它的表现会与其余两个端不一致。
因为我们还是希望 App 端可以与微信小程序端的表现保持一致,此时有两条路可以走:
反正我们的目的只有一个:减少开发时的心智负担。
对于第一条路子,实现方式非常简单粗暴,既然 Vue 只针对单根组件会有这个 feature,那我们强制所有组件变成多根节点就好了,简单说就是实现一个 Vite 插件,利用 transform 钩子往 Vue 组件顶层插入一个 <Fragment /> 元素,这样立马可以让 App 和 H5 的表现与微信小程序保持一致。
然而这样会损失 Vue 特地带来的便利,正如官方文档所说,这个 feature 会无形中减少很多布局实现上的麻烦,因此最终还是决定让微信小程序能够对齐另外两端的实现。
根据前面的 HTML 结构图可以看到,微信小程序主要是因为多了一层节点(虚拟节点),是不是只要把它干掉就好了?
说干就干,在 uni-app 文档中指出,只要在 Vue 组件中配置 virtualHost: true 并且配合 mergeVirtualHostAttributes 即可合并组件虚拟节点外层属性:
然而,此时 HTML 结构中 scopeId 虽然已经放到组件的根节点上,但样式表现仍然没有发生变化,这是因为 uni-app 默认给微信小程序组件设置的样式隔离是 apply-shared,还需要把 styleIsolation 改成 shared 才能使其影响其它组件。
对应到本文例子就是要给 comp-parent.vue 添加:
defineOptions({
options: {
virtualHost: true,
styleIsolation: "shared", // [!code ++]
},
});
修改以后,微信小程序上的表现已经完全与其余两端一致:
这个方案的好处在于,可以让微信小程序组件无限接近 Vue 组件的表现,除了样式透传,还包括可以属性透传,比如 id、class、style、v-show 等。
我写了一个 Vite 插件自动注入这个 virtualHost: true 配置,有兴趣可以看:https://github.com/gd4Ark/vite-plugin-uni-virtual-host
其实到这里三端表现已经基本一致,但在微信小程序还有一些细微的差别,主要问题是出在样式优先级。
还是上面那个例子,在页面级组件去覆写子组件样式:
由于微信小程序加载 css 顺序的问题,表现与其余两端不一致:
这问题看起来是无解的,只能手动识别这种情况,并添加 !important 提高样式权重。
2025-10-15 08:00:00
在过去两年里,我接手维护了多个原生语法开发的微信小程序项目。由于新项目均采用 uni-app 开发,这些原生项目无法复用在 uni-app 生态积累的工具库、业务组件和 Hooks 等基础设施。
为了解决技术栈割裂的问题,探索并实践了多种混合开发方案,本文将分享相关的技术方案与实践经验。
或许会好奇,为什么不直接选择用 uni-app 对项目进行全面重构呢?
其实,除了精力有限,更重要的原因在于:
综上,采用混合开发的渐进式方案,无疑更加高效且具性价比。目前项目主要面临两类需求场景:
uni-app 官方文档提供了几种与原生小程序混合开发的技术方案:
然而,这些方案无法直接满足目前的场景。鉴于涉及的项目较多,决定设计一套更具通用性和可扩展性的混合开发方案,以方便快速适配应用。
这种方案核心思路是:将原生小程序页面搬到 uni-app 项目的构建产物中,并注册页面。
为了提升开发效率,需要将这个过程自动化。所以,开发 Vite 插件来做这件事情最适合不过了。
假设项目结构如下:
.
├── src/ # uni-app 项目主目录
│ ├── pages/ # uni-app 页面
│ ├── components/ # uni-app 组件
│ └── ...
├── miniprogram/ # 原生微信小程序代码目录
│ ├── components/ # 需要复用的原生组件
│ ├── pages/ # 需要复用的原生页面
│ └── ...
├── vite.config.ts # Vite 配置文件
└── ...
该 Vite 插件需要实现以下核心功能:
伪代码实现,大概流程就是这样:
function uniWxCopyPlugin(options) {
let publicBasePath = '' // Vite 的 base 配置
let configPath = '' // 最终输出目录
let isDev = false // 是否开发环境
return {
name: 'vite-plugin-uni-wx-copy',
// Vite 配置解析完成后执行
configResolved(config) {
// 判断是否为微信小程序环境
if (config.define['process.env.UNI_PLATFORM'] !== '"mp-weixin"') {
return
}
publicBasePath = config.base
isDev = config.mode === 'development'
},
// 构建完成后执行
writeBundle(options) {
const p = options.dir // 构建输出目录
// 如果没有输出目录或 base 配置,则不执行
if (!p || !publicBasePath) {
return
}
// 计算最终输出目录
configPath = resolve(publicBasePath, p)
// 1. 复制原生组件到共享目录
copy(
'miniprogram/components' ->
configPath + '/shared/components'
)
// 2. 复制页面
copy(
'miniprogram/pages/index' ->
configPath + '/pages/index'
)
// 3. 替换页面中的组件引用路径
replace(
file: configPath + '/pages/**/*.json',
from: '/components/',
to: '/shared/components/'
)
// 4. 开发模式下监听文件变化
if (isDev) {
watch('miniprogram/**/*', (changedFile) => {
if (changedFile.includes('pages/')) {
copy(
changedFile ->
configPath + '/' + changedFile
)
}
else if (changedFile.includes('components/')) {
copy(
changedFile ->
configPath + '/shared/' + changedFile
)
replace(
file: configPath + '/pages/**/*.json',
from: '/components/',
to: '/shared/components/'
)
}
})
}
}
}
}
有了这个插件,在每个项目通过配置,就能快速实现混合开发:
import uni from "@dcloudio/vite-plugin-uni";
import { defineConfig } from "vite";
import uniWxCopy from "vite-plugin-uni-wx-copy";
export default defineConfig({
plugins: [
uni(),
uniWxCopy({
rootDir: "../miniprogram",
// 复制共享资源
copy: [
{
sources: ["components", "static", "utils"],
dest: "shared/",
shared: true,
},
],
// 主包页面
pages: ["pages/index", "pages/page1", "pages/page2"],
// 分包配置
subPackages: [
{
root: "subpackages",
pages: ["detail"],
},
],
// 重写 app.json 以添加全局组件
rewrite: [
{
file: "app.json",
write: code => {
const appJson = JSON.parse(code);
appJson.usingComponents = {
...appJson.usingComponents,
"app-btn": "/shared/components/app-btn/app-btn",
};
return JSON.stringify(appJson, null, 2);
},
},
],
}),
],
});
还需要解决一个关键问题:原生小程序页面与 uni-app 主体之间的状态共享,包括环境配置、用户信息等运行时数据。
由于不同项目的业务场景各不相同,这里采用了一种可定制的状态共享方案:
getApp() 作为跨技术栈的通信桥梁,在 uni-app 项目中实现状态管理和更新的核心逻辑getApp() 提供的统一接口举个例子,将原本的鉴权方法改成通过 getApp 使用 uni-app 项目提供的:
// auth.getUserInfo( -> auth.getApp().getUserInfo(
{
replaceRules: {
from: /auth.getUserInfo\(/g,
to: 'getApp().getUserInfo(',
files: [
...pages.map(page => path.join(configPath, page, '**/*.js')),
...shared.sources.map(dir => path.join(configPath, shared.dest, dir, '**/*.js')),
],
},
}
再举个例子,动态修改原生项目的开发环境:
{
rewrite: [
{
file: "shared/config/index.js",
write: code => {
// eslint-disable-next-line no-param-reassign
code = code.replace(
/export const DEV =(.+)/,
`export const DEV = ${mode === "development" ? "true" : "false"}`
);
return code;
},
},
];
}
有兴趣可以看看这个插件:vite-plugin-uni-wx-copy
这种方案核心思路是:将 uni-app 项目的构建产物集成到原生小程序项目中,并注册页面。
没错,就是与上面的方案反过来,这也是得益于 uni-app 项目提供了一个打包方式:混合分包。
简单说就是将一个 uni-app 项目打包成小程序的一个分包,满足以下场景:
假设目录结构如下:
.
├── miniprogram/ # 原生微信小程序项目目录
│ ├── app.js
│ ├── app.json
│ └── ...
└── uni-app-project/ # uni-app 项目目录
├── src/
├── vite.config.ts
└── ...
在 uni-app 项目的 package.json 中配置分包构建命令(根据情况决定是否需要多个分包):
{
"scripts": {
"dev": "run-p 'dev:**'",
"build": "run-p 'build:**'",
"dev:pkg-a": "uni -p pkg-a --subpackage=pkg-a",
"build:pkg-a": "uni build -p pkg-a --subpackage=pkg-a",
"dev:pkg-b": "uni -p pkg-b --subpackage=pkg-b",
"build:pkg-b": "uni build -p pkg-b --subpackage=pkg-b"
},
"uni-app": {
"scripts": {
"pkg-a": {
"title": "pkg-a",
"env": {
"UNI_PLATFORM": "mp-weixin"
},
"define": {
"MP-PKG-A": true
}
},
"pkg-b": {
"title": "pkg-b",
"env": {
"UNI_PLATFORM": "mp-weixin"
},
"define": {
"MP-PKG-B": true
}
}
}
}
}
在 uni-app 项目的 pages.json 中使用条件编译配置分包页面:
{
"pages": [
// #ifdef MP-PKG-A
{
"path": "pages/index/index"
},
// #endif
// #ifdef MP-PKG-B
{
"path": "pages/detail/index"
}
// #endif
]
}
该 Vite 插件需要实现以下核心功能:
伪代码实现,大概流程就是这样:
function uniSubpackageCopyPlugin(options) {
return {
name: "vite-plugin-uni-subpackage-copy",
// 在其他插件之后执行
enforce: "post",
// 构建完成后执行
async writeBundle(output) {
// 1. 如果配置了重写规则,先处理文件重写
if (options.rewrite) {
for (const rule of options.rewrite) {
// 读取文件
const content = readFile(rule.file);
// 使用重写函数处理内容
const newContent = rule.write(content);
// 写入新内容
writeFile(rule.file, newContent);
}
}
// 2. 使用 rsync 将分包文件从 uni-app 构建目录同步到原生项目
rsync({
from: options.subpackageDir, // uni-app 分包构建目录
to: options.rootDir, // 原生项目目录
// 保持文件属性,递归同步,压缩传输
flags: "avz",
// 删除目标目录中源目录没有的文件
delete: true,
});
},
};
}
在 uni-app 项目的 vite.config.ts 中配置插件:
import uni from "@dcloudio/vite-plugin-uni";
import { defineConfig } from "vite";
import uniSubpackageCopy from "vite-plugin-uni-subpackage-copy";
export default defineConfig({
plugins: [
uni(),
process.env.UNI_PLATFORM === "mp-weixin" &&
uniSubpackageCopy({
rootDir: "../miniprogram",
subpackageDir: process.env.UNI_SUBPACKAGE,
}),
],
});
有兴趣可以看看这个插件:vite-plugin-uni-subpackage-copy