2024 年 9 月,我花了三周做一个块编辑器。想法还是老一套:每个段落是一个块,可以拖动、可以重排,每个块还有自己的 AI 按钮用来重写。没什么新鲜的。Notion 做这件事已经很多年了。
我用 dnd-kit 做拖拽,每个块一个编辑器实例。Block 组件接收 id、index、content、onDelete、onContentChange,一个带五个参数的 regenerateContent 函数,整份块列表用来提供上下文,还有一个放在 ref 里的 AbortController 用来中断生成。它能跑。那一周有个提交的名字就叫 "never give up!!"。这就是留在记录里的东西。
六周之后,这些全被删掉了,取而代之的是一个带扩展的单一编辑器。
当你自己编排块的时候,会坏在哪里
问题不在拖拽。拖拽做得挺好。问题是其他所有东西,而且我是按这个顺序一个个发现的:
- 把内容同步进去。 每个块都有一个
useEffect,比较content !== editor.getHTML()来决定要不要调setContent。用字符串比较 HTML 来判断状态有没有变,正是那种看起来能用、直到不能用为止的做法。 - 跨块选择。 根本不存在。如果每个块都是自己的编辑器,用户就无法从一个段落的中间选到下一个段落的中间。没有局部修补的可能:要么是一份文档,要么是 N 份文档。
- 撤销。 每个实例都有自己的历史栈。Ctrl+Z 撤销的是光标所在块里的操作,而不是你最后做的那件事。没人会预期这个。
- 粘贴。 你把三个段落粘进一个块,它们就都待在那一个块里。要拆开它们,就是手工重新实现文档 schema 本来就会做的事。
这四件事,开始时我脑子里一件都没有。它们出现的顺序,正是我写下来的顺序。
重写比原型还短
10 月中旬,block/ 目录旁边已经并存着一个 block2/ 目录,里面只有三个文件:编辑器、工具栏和一个 hook。整个 hook 只有四十行:StarterKit、TextStyle、FontFamily、TextAlign、Highlight、Placeholder。数组里声明六个扩展,统一的历史记录、连续选择、带 schema 的粘贴就都有了。11 月又加了任务列表、代码块和按 URL 插图:每个都是一个 import 加数组里的一项。
目录名说明了一切。block2 不是重构。它是承认旁边那个已经救不回来了。
这个网站的 CSS 也一样。我曾有一个一千六百八十一行、全是自定义类的文件。改成组件之后,唯一活下来的是颜色 token:globals.css 里的一百一十三行。其余都是重新造轮子。
库永远占优的地方
这不是"用库就对了"。而是有一些具体的类别,问题比从外面看起来大得多,而且永远是同一批:
- 富文本编辑器。 选择、历史、粘贴和 schema 是同一个系统。你不能四选三。
- 认证和会话。 没人会感谢你亲手写的 refresh token。
- 日期和时区。 那个诡异的边界情况会在生产环境找上你,而不是在你的机器上。
- 样式。 自建的类系统一开始更干净,最后会变成一个文档更差的框架。
共同的信号是这个:如果难点不是第一屏,而是那些开始时看不见的部件之间的交互,那库赢。如果难点是你的业务逻辑,那就没有库可用。
我现在怎么决定
我把顺序反过来了。现在我从库开始,只有在它挡住我的时候才离开。
去年夏天我需要一个协作编辑器,就是这么解决的:从 PlateJS 的模板起步,上面加 Yjs。第一个提交之后三天,屏幕上就已经有其他用户的远程光标了。要是几个月前,我会从带五个 props 的 Block 开始。去年做一个 Markdown 笔记工具时,我连编辑器都没考虑过:CodeMirror,CSS 里 import 一下 highlight.js,然后干自己的事。
还有一种情况更快。2025 年 4 月我写了一个带工具的 agent,一个带上下文工具的 agent.ts。第二天就有一个提交把它删掉,直接用供应商的流式接口。从构建到删除不到二十四小时,而这是个好结果。
真正的代价
手写并不是浪费时间:我知道编辑器里粘贴是怎么回事,因为我曾经把它实现错过。这种知识不是读文档能买到的。
代价在于你什么时候发现。三周手写块,加上 block/ 和 block2/ 并存的六周,就是两个月。一个 3D 地图的第一版页面撑了十天,之后一个叫 map-v2 的分支把它整个替换掉;那里的代价是十天,不是两个月,因为我更早收手了。
两者的差别不在于我开始时懂多少。而在于我花了多久才接受:我懂的已经够了。
手写是一种昂贵的读文档方式。有时值得付这笔钱;不值得的是为同一件事付两次。
