富文本编辑器最难的部分从来不是工具栏,而是同一份内容同时存在于三个地方:ProseMirror 的文档树、序列化出来的 Markdown 字符串、React 组件里的表单状态。这篇以博客后台的文章编辑器为例,说明哪一份该当事实来源、编辑器实例怎么活、自动保存如何与乐观锁配合,以及输入法和撤销栈为什么总在「受控」时出问题。
1. 把编辑器当受控组件,会同时坏掉三件事
最常见的写法是把编辑器包成普通的受控输入框:
// 错误示范:value 变化就 setContent
useEffect(() => {
editor?.commands.setContent(value);
}, [editor, value]);
这段代码会制造三个连锁问题:
- 光标跳动:每次输入触发
onUpdate→ 父组件setState→ 新value传回 →setContent重建文档,选区被重置到开头或末尾。中文输入时尤其明显。 - 撤销栈清空:
setContent对 ProseMirror 来说是一次外部替换,历史记录被截断,用户按Cmd+Z撤不回去。 - 输入法被打断:拼音候选还没上屏,文档就被替换,候选窗可能直接关闭。
更隐蔽的是死循环:setContent 会触发 onUpdate,onUpdate 又更新 value,如果比较逻辑不严格,就是一秒几十次渲染。
结论不是「受控组件不好」,而是编辑器文档是命令式对象,不该由 React 每次渲染来同步。React 管的应该是「保存用的字符串」和「脏标记」,而不是 ProseMirror 的内部状态。
2. 内容模型:contentType 决定哪一份是原文
先看后端怎么处理内容字段:
func (s *ArticleService) applyContentMeta(article *model.Article) {
// content_html 只服务 API 消费者/RSS;前台 Astro 仍按 content_type 渲染原文。
if article.ContentType == "markdown" {
article.ContentHtml = markdown.RenderHTML(article.Content)
} else {
article.ContentHtml = article.Content
}
}
这段代码把职责划得很清楚:
| 字段 | 角色 | 谁负责写 |
|---|---|---|
content |
作者原文,唯一的编辑对象 | 编辑器序列化后提交 |
contentType |
原文格式,markdown 或 html |
创建时确定,更新时不随意改 |
contentHtml |
派生结果,给 API 消费者和 RSS 用 | 服务端在保存时重算 |
summary / wordCount / readingTime |
由原文派生的元信息 | 服务端计算 |
contentHtml 不是第二份事实来源,它只是缓存得比较久的投影。前台 Astro 的取用顺序也印证了这一点:
const body = article.contentHtml || renderContent(article.content, article.contentType);
有 contentHtml 就直接用,没有才回退到浏览器端渲染。两条路径都从 content 或它的投影出发,不存在「编辑器存 HTML、发布用 Markdown」的分叉。
contentType 是格式判别器,不是编辑器模式。历史文章可能是 HTML,后台编辑器却只认 Markdown,所以仓库里有一条 content_migration.go 负责把 HTML 转成 Markdown 并重算 contentHtml。在转换完成之前,不要用 Markdown 编辑器去打开 HTML 文章:content 会按 Markdown 解析,而 Markdown.configure({ html: false }) 又把原始 HTML 关掉了,标签会变成正文文本。
3. 实例生命周期:非受控 + key 重挂载
仓库里的编辑器没有 value,只有一个初始值:
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit,
Image,
Markdown.configure({ html: false, tightLists: true }),
],
// tiptap-markdown 的 content 接收 Markdown 并自行解析
content: initialMarkdown,
onUpdate: ({ editor }) => {
const storage = editor.storage as unknown as {
markdown: { getMarkdown(): string };
};
onChangeRef.current(storage.markdown.getMarkdown());
},
});
四个设计点值得展开:
content只在创建时读一次。之后无论父组件怎么渲染,编辑器都不会被重置。这正是「非受控」的含义:初始值由外部给,运行期归编辑器。onChangeRef避免闭包过期。如果直接把onChange写进useEditor的依赖数组,每次父组件渲染都会重建编辑器,撤销栈和光标全丢。用 ref 保存最新回调,既拿到新闭包,又不重建实例。immediatelyRender: false。Tiptap 在 SSR 环境下不能立即渲染,这个选项让首帧返回null,客户端再挂载。仓库里对应的是if (!editor) return <div>编辑器加载中…</div>。Markdown.configure({ html: false, tightLists: true })。html: false禁止 Markdown 里的原始 HTML 直接进文档,减少 XSS 面;tightLists让列表序列化时不留多余空行,避免每次保存都产生无意义的 diff。
切换文章时,父组件用 key={articleId ?? 'new'} 强制重建编辑器,把新的 initialMarkdown 交给新实例。这里有个容易忽略的边界:ArticleEditor 本身是路由组件,从 /content/articles/1/edit 跳到 /content/articles/2/edit 时,React Router 复用的是同一个组件实例,initializedRef.current 仍然是 true,负责灌表单的 effect 直接返回。RichEditor 因为 key 变了会重建,但 initialMarkdown 还是旧文章的内容。稳妥的写法是在路由层给编辑器组件加 key={articleId},或者在 articleId 变化时显式重置表单和 initializedRef。
4. 脏标记与 2 秒防抖
自动保存的实现是一个依赖表单的 effect:
useEffect(() => {
if (!articleId || !initializedRef.current || conflict) return;
const timer = window.setTimeout(async () => {
if (!dirtyRef.current) return;
setSaveState('saving');
try {
const result = await autosaveArticle(articleId, {
...form,
expectedUpdatedAt: baselineRef.current,
});
baselineRef.current = result.updatedAt;
dirtyRef.current = false;
setSaveState('saved');
} catch (err) {
if (err instanceof ApiError && err.code === 409) {
setSaveState('conflict');
setConflict(err.message);
} else {
setSaveState('error');
}
}
}, 2000);
return () => window.clearTimeout(timer);
}, [form, articleId, conflict]);
这是标准的「effect 清理即防抖」:每次 form 变化都会清掉上一个定时器,重新计时 2 秒,只有连续 2 秒没有输入才真正发请求。dirtyRef 是必要的:effect 在挂载、articleId 变化和 conflict 变化时都会重跑,没有它,打开一篇文章两秒后就会先发一次空保存;conflict 进依赖则是为了在冲突后彻底停掉自动保存。
2 秒的取舍:太短会把每个停顿都变成请求,服务端压力大,冲突概率也高;太长则崩溃或关页面时丢的编辑更多。仓库的 autosave 接口只更新内容字段,不创建版本、不改状态,所以这个频率是安全的。
但这段代码有两个真实的竞态:
其一,响应覆盖了新编辑。 await 期间用户继续输入,update() 把 dirtyRef 置为 true;响应回来后无条件 dirtyRef.current = false,这批新输入就被标记成「已保存」,直到下一次输入才重新触发。修法是加一个编辑版本号:
const editVersionRef = useRef(0);
// update() 里
editVersionRef.current += 1;
// 发请求前
const versionAtStart = editVersionRef.current;
// 响应后
if (editVersionRef.current === versionAtStart) {
dirtyRef.current = false;
}
其二,请求可能重叠。 如果一次 autosave 超过 2 秒还没返回,定时器会再发一次;两个请求携带同一个 expectedUpdatedAt,后到的那个很可能被服务端判成 409,或者两个响应以相反顺序更新 baselineRef。加一个 savingRef 做互斥,或者在保存完成且 dirtyRef 仍为 true 时主动再排一次,都是可行方案。
还有一个接口层的细节:AutosaveArticleRequest 里没有 slug、password、publishedAt,自动保存会忽略这些字段。所以改 slug 之后不能只等自动保存,必须走手动保存的 updateArticle。这是有意的边界:slug 变更影响 URL 和 SEO,不该在用户没察觉时发生。
5. 乐观锁:expectedUpdatedAt 与 409
多标签页同时编辑同一篇文章是必然场景。后端的做法是比对基线时间:
func checkExpectedUpdatedAt(article *model.Article, expected *time.Time) error {
if expected == nil || article.UpdatedAt.Equal(*expected) {
return nil
}
return api.ErrConflict.
WithMessage("内容已被他人修改,请刷新后重试").
WithData(article.UpdatedAt.Format(time.RFC3339Nano))
}
前端维护一个 baselineRef:打开文章时写入详情里的 updatedAt,每次保存成功后替换成响应里的新 updatedAt。自动保存和手动保存都带上它,服务端只在这份时间仍然等于当前记录时才落库,否则返回 409。
结果只有两种:200 返回新的 updatedAt,基线前移、脏标记清除;409 则把 conflict 置为 true,停掉自动保存,并显示一条带「刷新页面」的横幅。这个选择比「自动合并」保守,但正确:编辑器没有做 CRDT,服务端也没有字段级合并,任何自动覆盖都可能丢别人的内容。用户点击刷新后重新加载详情,initializedRef 随组件重建而复位,拿到新的基线继续编辑。
注意 expectedUpdatedAt 必须来自服务端返回的 updatedAt 字符串,不能在客户端用 new Date() 造一个近似值。时间精度、时区和序列化格式(RFC3339Nano)任何一处不同,都会让本来不冲突的保存变成 409。
6. 输入法、撤销栈与光标
这一节是踩坑清单,代码不多,但每个都真实存在:
- 中文输入法:
onUpdate在组合输入过程中也会触发,序列化出的是拼音中间态。这不影响最终结果,但如果每次onUpdate都发请求,服务端会收到一堆半成品。防抖本身就是第一道保护;不要在compositionstart期间做setContent之类的反向同步。 - 撤销栈:重建编辑器、
setContent、以及把content放进useEditor依赖,都会清空历史。仓库用key在切换文章时重建,是「有意重置」;如果每次保存后也重建,用户就会发现自己刚写的段落撤不回去了。 - 光标跳动:根因几乎都是「父组件把新值写回编辑器」。只要坚持非受控,只在首次挂载时传
content,这个问题就不会出现。 - Markdown 往返损失:
html: false会把原始 HTML 转义成文本;代码块的围栏语言虽然能被CodeBlock的language属性解析并序列化,但仓库工具栏只调用toggleCodeBlock(),没有语言选择器,新建代码块默认language: null,序列化出来就是没有语言标记的围栏,发布后高亮会退化。要保住语言,给工具栏加语言选择,或者提供「源码模式」让作者直接写围栏。 - 表格、脚注等扩展:Markdown 能表达但编辑器没装的节点,会在往返中被降级。扩展列表就是内容模型的一部分,改它等于改契约,要和发布端渲染一起验证。
7. 发布时,Markdown 与 HTML 谁说了算
新建文章时,仓库在 createArticle 的入参里固定写 contentType: 'markdown',正文来自 form.content。
更新已有文章时则不传 contentType,只传表单字段和 expectedUpdatedAt。这个不对称是有意的:历史 HTML 文章被 Markdown 编辑器打开再保存时,不应该被悄悄改写成 Markdown;格式转换是迁移逻辑的职责,不是保存按钮的副作用。
发布动作本身也只做状态流转,不碰内容:
async function togglePublish() {
const saved = await saveManual();
if (!saved || isNew) return;
const target = status === 'published' ? 'draft' : 'published';
await transitionArticle(saved.articleId, target);
}
先 saveManual 再 transitionArticle,保证了「发布的内容就是最后一次保存的内容」。如果反过来先改状态再保存,构建流水线可能读到旧版本,静态站就会发布一份过期的 HTML。
所以这条链路的事实来源只有一条:
- 编辑器把 ProseMirror 文档序列化成 Markdown;
content以 Markdown 存储,contentType标记格式;- 服务端在内容变化时用同一份
content重算contentHtml、字数和阅读时长,摘要为空时再从正文生成; - 发布只改状态,构建端按
contentType渲染,contentHtml作为 API 消费者的便捷输出。
任何试图让前端「顺便把 HTML 也存一份」的做法,都会让第 3 步出现两个输入,最终变成两套渲染结果对不上。
8. 小结
Tiptap 双写的关键不是「选 Markdown 还是 HTML」,而是承认三种状态的所有权不同:ProseMirror 文档归编辑器,Markdown 字符串归表单,contentHtml 归服务端派生。
实例用非受控 + key 重建,保存用脏标记 + 防抖,冲突用 expectedUpdatedAt 显式暴露给用户,发布前强制 flush 一次手动保存。这套边界不花哨,但它能保证「用户看到的」「数据库里的」「静态站构建出来的」是同一份内容。
下一篇换到接口层:如何从这份后端的 OpenAPI 直接生成 TypeScript SDK,让字段改名在编译期而不是在用户点发布时爆炸。




评论
加载中…