Web 编辑器选型
web 编辑器非常多,但是没自己使用过那是真不知道有多少深坑。这些编辑器大致可以分为两类:
- 纯文本编辑器:处理纯文本的编辑器
- 富文本编辑器:处理非纯文本的编辑器
二者实际的开发难度视需求而定,纯文本编辑器的开发难度可以比富文本编辑器开发难度高一个层级,比如高拓展性的所见即所得的 Markdown 编辑器 Typora,细节上面的东西远比纯粹的富文本编辑器难度高,体现在:非标准化的 Markdown 语法(严格换行,首行缩进,自定义语法),各种交互(mermaid,math等代码块,图片,音频,视频,文件等的交互)。
纯文本编辑器大多用于代码编写,真要做产品面向普通用户还是得选用富文本编辑器。obsidian 使用 codemirror 作为编辑器,一方面是因为 Markdown 本身就是纯文本文件,另一方面当时的宣传就是笔记界的 IDE,从最初的双栏编辑到所见即所得编辑,可能是历史的原因,依然有不少普通用户诟病。开源成熟的纯文本编辑器比较出名的就是 CodeMirror 和 monaco-editor,已经足够好用无需选型了,所以本文着重选型(吐槽)富文本编辑器。
富文本编辑器都是基于浏览器自身 contentEditable 属性实现的,共用了浏览器的光标和选区;对数据层进行了抽象,依赖 DOM 对内容进行渲染,所以也不聚焦于浏览器原理上的实现。同时,只聚焦于开源编辑器和开发体验好的编辑器,比较火的二次授权或部分授权的编辑器也会拿来讨论。
数据结构
数据结构一方面决定了如何去理解编辑器的设计,另一方面和一些功能实现和开发体验有关。比如一个常见的需求就是不依赖 web 环境而使用服务器环境去处理数据结构。过于复杂难以理解的数据结构除非能在服务器端初始化编辑器状态并进行操作,否则就失去了很多功能。
树状,简单的数据结构还有助于提升开发体验,比如 Postgres 支持存储二进制的 JSON 数据,可以原封不动的放入数据库存储并查询,比提取出来可搜索文本单独存储有一定的优势。此外,对于协作也有一定优势,有些 CRDT 库需要给数据结构进行建模,记录操作和历史记录,复杂的数据结构会大大提升开发难度。
在细节方面,数据结构的影响不可谓不大,下面拿实例说明。
lexical 编辑器实例数据结构:Lexical 的节点是通过 Map 存储的,这和 Slate、ProseMirror 的树状数据结构有本质差异,主要体现在单个节点修改的效率和内存占用上。Map 结构存储的内容能够很快增删改某个特定节点,而对于树状数据结构,为了保证数据是持久化,必须按照不可变数据的理念(Immutable)去生成一个新对象,造成内存占用增大的问题。相应地,由于存储 Map 的结构不能够很好地表达实际渲染出来 DOM 结果的层次,所以在每次渲染的时候,需要做一次协调(Reconcilation)去生成层次结构,它通过双重缓存实现单向数据流渲染。官网也是拿 React 来举例,数据结构和渲染具有很多相似性,吐槽 React 也不在少数,反正本来都是 facebook 开发的。
持久化存储数据结构:如下,可以看到是树状有冗余不易读的数据机构。
{
"root": {
"children": [
{
"children": [
{
"detail": 0,
"format": 0,
"mode": "normal",
"style": "",
"text": "正文",
"type": "text",
"version": 1
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "paragraph",
"version": 1,
"textFormat": 0,
"textStyle": ""
},
{
"children": [
{
"detail": 0,
"format": 1,
"mode": "normal",
"style": "",
"text": "加粗",
"type": "text",
"version": 1,
"$": {
"style": "text-shadow: 1px 1px 2px red, 0 0 1em blue, 0 0 0.2em blue;"
}
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "paragraph",
"version": 1,
"textFormat": 1,
"textStyle": ""
}
],
"direction": null,
"format": "",
"indent": 0,
"type": "root",
"version": 1,
"textFormat": 1
}
}
quill 是扁平的数据结构,即使是无序列表也是扁平的。
[
{
"insert": "无序列表"
},
{
"attributes": {
"bold": true
},
"insert": "加粗"
},
{
"attributes": {
"list": "bullet"
},
"insert": "\n"
},
{
"insert": "无序"
},
{
"attributes": {
"list": "bullet"
},
"insert": "\n"
}
]
slate 甚至是完全自定义的数据结构。prosemirror 按照 schema 进行定义,有若干限制(比如不允许多个text在一起,否则会自动合并,这些不是坏事)大体简洁和优雅。
{
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Example ',
},
{
type: 'text',
marks: [
{
type: 'bold',
},
],
text: 'Text',
},
],
},
],
}
同时 prosemirror 的 state 是能在服务器端运行的,写单元测试什么的时候方便很多。slate 则不能,lexical 需要借助 headless 的包实现。
拓展性
一般还没有编辑器能恰好满足所有开发需求,大多都是以插件式的编辑器框架存在。所以扩展性一般包含两个方面:
- 插件自身能做到的特性:比如装饰器,自定义语法,自定义渲染,支持多框架等
- 插件本身的开发开发体验:插件控制编辑器的操作,和外界交互(比如和 zustand 这些状态库交互),插件的管理
Prosemirror 是 Vanilla 开发的,所以理论上支持任何前端框架,但是得花点大力气。其衍生的 Remirror,Prosekit,TipTap 则在其上做了不少封装。Slate 则是和 React 绑定的,Plate 是其上的封装,但增加了相当多的包大小。
插件的管理也是一大痛点,为什么这么说呢?因为写一个富文本编辑器需要的插件实在太多了,也并不能封装在一个库内部。比如 prosemirror 我就见到过不少于三种管理方式。插件类型也非常多,内部状态控制,修饰,交互,导入导出,尤其是slash menu,auto complement 这样的弹窗和协作,都带来了一定的管理困难。prosemirror,slate 本身并没有提供一套插件管理方案,甚至slate加载插件的方式还相当丑陋。
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
这 with 插件套个十几个,两三个我都受不了,我都不知道说什么好。当然也可以自己封装一套,但开发体验大打折扣了。下面是prosekit的插件写法:
export function defineHeading(): HeadingExtension {
return union(
defineHeadingSpec(),
defineHeadingInputRule(),
defineHeadingKeymap(),
defineHeadingCommands(),
)
}
下面是tiptap插件的写法:就是暴露插件的生命周期
import { Mark } from '@tiptap/core'
const CustomMark = Mark.create(() => {
// Define variables or functions to use inside your extension
const customVariable = 'foo'
function onCreate() {}
function onUpdate() {}
return {
name: 'customMark',
onCreate,
onUpdate,
// Your code goes here.
}
})
下面是我比较喜欢的写法:
export default abstract class EditorNode {
editorOptions: EditorNodeOptions;
constructor(editorOptions?: EditorNodeOptions) {
this.editorOptions = editorOptions || {};
}
get type() {
return "node";
}
abstract get name(): string;
abstract get schema(): NodeSpec;
get markdownToken(): string {
return "";
}
get defaultNodeOptions(): Record<string, any> {
return {};
}
inputRules(_schema: Schema): InputRule[] {
return [];
}
keymaps(_schema: Schema): Record<string, Command> {
return {};
}
commands(_schema: Schema): Record<string, CommandFactory> | CommandFactory {
return {};
}
plugins(_schema: Schema): Plugin[] {
return [];
}
abstract parseMarkdown(): ParseSpec | undefined;
abstract toMarkdown(state: MarkdownSerializerState, node: Node): void;
}
优缺点都比较明显,你写成函数式吧,复杂的插件写得到处都是。用类的方式比较简洁统一,但是很多插件并不能用这种方式实现,需要引入外部状态,这又打破了统一。最后就是自定义语法用类组织,其它插件用函数组织。
slate,plate就不说了,同样存在没有统一的插件管理机制问题,看着都头大。
开发体验
参考1,整体而言,所有编辑器框架都没有做到开箱即用的地步,比如:实现slash menu,auto completement,tag, footnote语法等,有各种各样的问题。
比如你使用 prosemirror 开发自动补全的功能,由于prosemirror 是纯 js 开发的,你在使用前端框架时就得费力的去管理编辑器状态和整个框架状态。你的补全内容需要通过 props 的方式传入编辑器插件内,需要通知 react 组件打开菜单,这就涉及到两边双向通信问题。已有的一些开源项目,要么整体都用 react 封装(tiptap),要么都用 js 实现 (remirror),但都会有各种各样的小问题。用 react 封装就需要很多架构上的代码,也会降低编辑器性能,用 js 实现就得考虑样式,通信上的问题。碰到 react 时,这些框架有严重的过渲染,hook优化起来真头大,useEffect肉眼可见的事件绑定来解绑去你愣就没啥办法。
还有代码块高亮的问题,富文本编辑器用 highlight.js 或者 shiki.js 对代码块进行高亮,prosemirror 使用的是装饰器的方式在原代码上渲染一层,这种方式使得性能大大降低。我测试过 vidtor,prosemirror,slate这些编辑器,几百行代码写进去过后,输入一个字符就卡一下。Typora 则是将代码块放入纯文本编辑器 codemirror 中,十几万行代码都能轻松高亮和编辑,就是高亮的语法有限。
还有很多这样的例子,打磨得优秀的第三方开源编辑器轮子基本没有(我眼中的)。使用封装程度高的开源项目,修改起来太麻烦,只能按照已有的方式做,prosekit 的官网文档都有问题,标签语法识别有问题。slate中文输入有问题导致 plate 的中文输入也有一定问题。如果不是 KPI 项目,不建议用这些轮子,尤其是对性能要求和定制程度高的地方。
开源生态
目前我了解到的开源富文本编辑器基本是这样的:
- prosemirror
- remirror:不怎么维护了
- prosekit:remirror 开发者的新坑
- tiptap:商业气息浓厚,付费插件,拥抱 AI
- blocknote: 基于块的类 notion 富文本编辑器
- slate:理论上可以实现任意功能,你要说代码自然是一点都没有
- plate:功能挺多,但打包巨大,性能和代码质量存疑
- wangeditor: 富文本编辑器,口碑挺好,但是开发者没啥维护的动力了
- vidtor: 思源笔记在用的编辑器,高度封装化,需要配置一些 CDN … 如果功能已经符合需求,那是非常好用的
- lexical:实时协作拉胯,比封装了一层的 tiptap 还要重,缺乏装饰器,用的人不算多
上述印象仅是 lexical 靠部分资料获取的信息,其它三个我都实际上手并开发过 markdown 编辑器。尤其是 slate 和 plate,能吐槽一整天,给了我希望又一巴掌拍灭了。知乎上说 slate 有着极其优秀的 API,上当受骗了啥都得自己写。plate 不用啥都自己写了,整个项目又相当重,很多功能存在各种各样的小问题。
prosemirror 同样缺乏功能,得自己定制,但好在能抄的代码那是相当的多,花时间硬怼也能怼个差不多的东西出来。
总结
综上,对于能够满足自己需求的编辑器,那种高度封装的就很好,不建议用 slate, 真想做高度定制的编辑器,还得是 prosemirror。