在React项目里接入富文本编辑器,并不仅仅是把某个库的组件放进去然后绑定一个onChange。内容如何存储、光标如何受控、工具栏按钮怎样与编辑器状态同步、服务端如何清洗提交的HTML,这些都会直接决定功能的稳定性和后续维护成本。Draft.js、Slate.js、Quill是当下讨论度较高的三种方案,但很多团队在选择时只看到星标数量或Demo效果,没有结合自己的业务模型做判断。下面会从数据模型、React集成、扩展机制和迁移代价几个方面展开对比,帮助你在评论框、文档编辑、内容发布等不同场景下做出更合适的决定。

先明确一个前提:这三者并不是严格意义上的同一类产品。Draft.js更像是一个围绕不可变模型构建的编辑器框架,它把编辑器内部的所有操作都抽象成状态变更;Slate.js则把文档内容本身当作一棵可自定义的节点树,你几乎可以定义任意块级和行内元素;Quill则偏向开箱即用的富文本组件,默认提供完整工具栏和常用格式,开发者通过模块和格式扩展能力来满足额外需求。理解这个差异,比单纯比较性能或体积更重要。
内容模型:不可变状态、节点树与Delta操作集
Draft.js的核心是EditorState和ContentState。编辑器不会直接修改DOM,而是把一次输入或格式变更转换成新的EditorState对象。这种不可变数据流与React的setState机制天然契合,调试时也容易回溯状态。ContentState内部由block数组、实体映射和行内样式范围组成,例如一段加粗文本会被记录为带有BOLD样式范围的block。官方格式套件的RichUtils提供了toggleInlineStyle、toggleBlockType等方法,但它们只是帮助生成新的EditorState,真正的状态推进仍然由Editor组件完成。
这种设计也有明显代价。Draft.js对中文输入法、Android键盘和浏览器选区变化的兼容性处理并不完美,遇到React严格模式或并发更新时可能出现光标跳动。另一方面,项目已经进入低维护状态,新版本发布缓慢,使用它意味着需要自己兜底一部分浏览器兼容问题。如果业务只要求基础富文本且已有大量Draft.js历史代码,可以继续沿用;如果从零开始,不建议把新功能建立在长期不更新的框架上。
Slate.js的设计理念是把编辑器内容视作一棵节点树。每个段落、标题、列表项都可以是一个自定义Element节点,叶子节点才保存实际文本和标记。通过定义renderElement和renderLeaf函数,开发者可以精确控制任意节点的渲染结果。它的数据模型是可序列化的纯对象,比如一个标题节点可以表示为{ type: 'heading', level: 2, children: [{ text: '内容模型' }] }。这种自由度让Slate.js在文档类产品、Notion风格块编辑器、协同编辑等复杂场景里表现突出,但同时也要求开发者自己实现工具栏、快捷键、粘贴过滤等大量逻辑。
// Slate节点结构示例
const document = [
{
type: 'heading',
level: 2,
children: [
{ text: '内容模型' }
]
},
{
type: 'paragraph',
children: [
{ text: '这是一段普通文本,' },
{ text: '这部分加粗', bold: true },
{ text: '。' }
]
}
];
Quill的内容模型建立在Delta格式之上。Delta是一个扁平的操作数组,每个操作可以是一次insert、retain或delete,并可以附带attributes。这种格式很适合描述文档变更,也能直接用于协同编辑中的操作合并。一个简单的加粗文本在Delta中可能表示为{ insert: 'Hello', attributes: { bold: true } }。Quill的Parchment模块负责把Delta转换到DOM,并支持注册自定义格式。不过一旦需要突破块级结构、嵌套列表或表格等复杂布局,Quill的表达能力就会显得局促,往往要借助第三方模块或自己修改核心行为。
React集成方式与受控组件对比
在React组件中接入Draft.js,最基本的做法是维护一个editorState状态,并把它原样传给Editor组件。Editor本身是受控组件,onChange回调会返回最新的EditorState对象。下面是一个最小实现,编辑器初始为空,每次输入都会通过setEditorState触发重渲染。由于EditorState是不可变对象,React可以通过引用变化判断是否需要更新,性能表现尚可。
import React, { useState } from 'react';
import { Editor, EditorState } from 'draft-js';
function DraftEditor() {
const [editorState, setEditorState] = useState(EditorState.createEmpty());
return (
<div className="editor-wrapper">
<Editor editorState={editorState} onChange={setEditorState} />
</div>
);
}
如果需要在外部按钮中修改样式,可以调用RichUtils.toggleInlineStyle(editorState, 'BOLD'),并将返回的新状态更新到组件里。这种模式很清晰,但缺点是EditorState对象体积较大,频繁输入时如果同时做了不合理的selector或额外计算,容易引起卡顿。实际项目中通常需要用useMemo、useCallback或把编辑器隔离成独立组件来避免整页重渲染。
Slate.js的React集成更加灵活但也更复杂。Slate提供createEditor、Editable和Slate上下文组件。Editable负责渲染可编辑区域,而编辑器实例本身需要稳定引用。官方推荐使用useMemo创建一次editor,并通过useCallback缓存渲染函数。下面是一个基础示例,它定义了一个初始值并渲染一个只读段落。
import React, { useMemo, useState } from 'react';
import { createEditor } from 'slate';
import { Slate, Editable, withReact } from 'slate-react';
function SlateEditor() {
const editor = useMemo(() => withReact(createEditor()), []);
const [value, setValue] = useState([
{
type: 'paragraph',
children: [{ text: '在这里输入内容...' }],
},
]);
return (
<Slate editor={editor} value={value} onChange={setValue}>
<Editable />
</Slate>
);
}
Slate的受控方式与Draft类似,value是文档树,onChange在每次操作后触发。区别在于Slate把选区、操作历史也放在编辑器实例内部,而不是全部塞进React状态。这带来更好的可扩展性,但也意味着如果某个自定义组件没有正确使用React.memo,编辑时的渲染开销会成倍增加。官方文档建议所有Element和Leaf渲染函数都要用memo包裹,同时避免在渲染期间直接修改编辑器实例。
Quill与React结合时,主流做法是使用react-quill或自己包装。Quill的核心是非受控的,它内部维护DOM和状态。react-quill虽然暴露value和onChange,但它的受控实现本质上是通过对比前后值来决定是否更新编辑器内容,容易在输入法组合期间或异步状态下产生光标重置。更稳妥的做法是只在初始化和外部修改场景使用受控值,日常输入交给Quill自身处理。下面是一个基于react-quill的最小示例,注意这里省略了服务端清洗。
import React, { useState } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
function QuillEditor() {
const [value, setValue] = useState('');
return (
<ReactQuill theme="snow" value={value} onChange={setValue} />
);
}
这段代码看似简洁,但react-quill在每次onChange时都会触发React状态更新,如果父组件层级较深或存在大量兄弟组件,频繁输入可能引起不必要的渲染。实际项目中可以考虑用非受控方式配合ref,在需要提交时再读取内容,减少中间状态变化。此外,Quill的样式依赖CSS文件,需要单独引入snow或bubble主题。
工具栏定制与扩展成本
工具栏是富文本编辑器最常被定制的地方。Draft.js没有内置可见的工具栏组件,需要开发者自己用按钮和状态判断来搭建。例如要实现加粗按钮,需要检查editorState的当前行内样式,然后调用RichUtils切换样式。这种手工拼接方式灵活,但每个按钮都要处理active状态、鼠标按下防止焦点丢失等细节。下面是一个加粗按钮的典型实现。
import { EditorState, RichUtils } from 'draft-js';
function BoldButton({ editorState, onChange }) {
const isBold = editorState.getCurrentInlineStyle().has('BOLD');
const handleToggle = () => {
onChange(RichUtils.toggleInlineStyle(editorState, 'BOLD'));
};
return (
<button
onMouseDown={(e) => e.preventDefault()}
onClick={handleToggle}
className={isBold ? 'active' : ''}
>
加粗
</button>
);
}
这个例子中必须阻止按钮的mousedown默认行为,否则编辑器会失去焦点,选区也会丢失。对于链接、图片等实体类型,还需要编写实体装饰器和弹窗逻辑。因此Draft.js的扩展成本不低,只是它的扩展方式比较直接,不需要理解额外的插件系统。
Slate.js在工具栏定制上更为统一。你可以在renderElement函数里判断元素类型,渲染对应的块级元素和操作按钮;行内样式则通过editor.addMark或editor.removeMark来修改。所有命令都通过编辑器实例调用,不会直接操作浏览器selection。这种模式让复杂交互更容易维护,但前提是你已经理解Slate的node、path、range等概念。一个自定义的加粗按钮可以这样写:
import { useSlate } from 'slate-react';
import { Editor, Range } from 'slate';
function BoldButton() {
const editor = useSlate();
const isActive = editor.marks && editor.marks.bold === true;
const handleMouseDown = (e) => {
e.preventDefault();
if (isActive) {
Editor.removeMark(editor, 'bold');
} else {
Editor.addMark(editor, 'bold', true);
}
};
return (
<button onMouseDown={handleMouseDown}>加粗</button>
);
}
Slate的插件机制也鼓励把工具栏、快捷键、粘贴处理等封装成独立函数,通过withPlugin包装编辑器。例如withMarkdown可以增强粘贴行为,withHistory可以提供撤销重做。这样当多个插件需要协同工作时,组合顺序就很重要,调试起来也需要一定经验。好处是一旦封装完成,后续开发效率会明显高于手工拼接的Draft.js方案。
Quill的工具栏定制相对简单。通过theme配置中的toolbar选项可以控制内置按钮,例如只保留加粗、斜体、列表和链接。如果需要自定义格式,可以通过Quill.import引入Parchment进行注册。以下是一个在React中配置基础工具栏的示例。
const modules = {
toolbar: [
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
],
};
function QuillWithToolbar() {
return (
<ReactQuill theme="snow" modules={modules} />
);
}
Quill的短板在于当需求超出内置格式和模块时,你需要深入Parchment的Blot注册机制。比如要实现一个自定义提醒块或特定样式的代码块,除了编写Blot,还要处理从DOM到Delta的转换。与Slate直接操作节点树相比,Quill的扩展路径更加隐式,调试成本也会更高。因此如果预计未来会有大量块级自定义需求,Quill并不是首选。
序列化、安全清洗与迁移风险
富文本编辑器最终要落地到数据存储。Draft.js默认导出的是ContentState的JSON结构,包含blocks、entityMap等信息。这种结构适合在Draft.js体系内恢复编辑器状态,但不利于跨端展示或与其他编辑器互通。通常需要借助draft-js-export-html或自己写转换函数,把ContentState转成HTML。要注意导出的HTML只是普通字符串,不能直接作为React的dangerouslySetInnerHTML输入,必须经过服务端清洗以去除脚本和事件属性。
Slate.js的文档树天然就是JSON,可以直接存储到数据库。渲染到静态页面时,可以自己实现一个只读渲染器,遍历节点并输出对应HTML或React元素。由于节点结构由自己定义,序列化和反序列化规则也完全可控。不过这意味着你需要在前后端约定好节点格式,并在版本升级时处理旧文档迁移。对于一款文档产品来说,这种可控性是优点;对于一个简单的评论框来说,则显得有些过度设计。
Quill输出的是Delta对象,也可以直接通过getHTML或getText获取HTML和纯文本。Delta适合保存操作历史,方便做协同编辑或版本对比。服务端若只保存HTML,会丢失部分操作信息,但仍能满足大多数内容展示需求。无论哪种方案,用户提交的HTML都不能直接渲染。需要使用DOMPurify或类似工具在服务端做白名单过滤,移除script、iframe、事件属性,并限制图片和链接协议。尤其是链接,必须确保只允许http、https或mailto,避免javascript:等危险形式。
从迁移角度看,Draft.js到其他库的迁移成本最高,因为它的EditorState和实体系统与具体实现耦合较深。Slate.js虽然数据结构自由,但不同大版本间API变动较大,升级需要阅读迁移指南。Quill相对稳定,但深度自定义后替换核心会越来越难。综合来看,如果只是快速实现评论、备注、基础文章编辑,Quill仍是成本最低的选择;如果要做Notion风格的块编辑器、复杂文档系统或需要精细控制粘贴和快捷键,Slate.js更适合;Draft.js则更多出现在历史项目中,新项目建议谨慎选择。