ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Slate构建块级富文本编辑器:从选型到踩坑全复盘

基于Slate构建块级富文本编辑器:从选型到踩坑全复盘 简介面向需要移动端富文本编辑能力的前端开发者hero-editor是基于Slate v0.58.3构建的WYSIWYG编辑器源码包。其设计采用插件化架构所有插件与编辑器通信均通过消息通道适合在触控环境下运行包内还提供Slate内容的序列化器与渲染器便于内容持久化与跨端展示。压缩包内含83个文件体积约744KB核心为58个JavaScript源码文件另有JSON配置、测试快照、Webpack与Babel构建配置等层级清晰便于定位核心逻辑。已有391人学习/下载适合希望深入Slate插件机制、移动端编辑器方案选型或二次开发的工程师。通过阅读源码和预置的playground、Storybook示例可以快速掌握加粗、斜体、列表、提及、占位符等插件的注册写法并利用JsonViewer、logger等工具调试序列化流程。 第一次跑通 hero-editor 那天我正被一堆堆“富文本编辑器方案选型”文档淹没。起因很简单团队要做一款知识库产品普通输入框扛不住需求而现成的编辑器要么内容模型锁死要么二次开发成本高得离谱。最后我把宝押在了 Slate 上在它之上从零搭了一套 WYSIWYG 编辑器也就是后来内部一直叫的 hero-editor。这篇文章就把整个选型、搭建和踩坑过程完整复盘一遍给同样准备在 Slate 上自建编辑器的朋友一个参考。1. 为什么最终选了 Slate一段编辑器选型的“考古”记录1.1 需求一夜之间从“加粗”变成了“块编辑器”刚开始需求文档里只有三个字能编辑。我心想这还不简单找一个开箱即用的富文本编辑器套上去就行。结果需求评审会上被问了一圈你们支不支持嵌套列表支持 / 不支持。表格里能不能再嵌一个表格不能。粘贴过来的文档能不能自动规整成统一格式得再做。整场会开下来我意识到这已经不是“富文本编辑”了而是“块级文档编辑器”。这类产品的核心诉求是内容模型可编程。你得能自定义“块类型”比如标题、段落、列表、引用块、代码块甚至未来的卡片和表格你得能约束某块里只能放哪些子节点你还得能在未来支持协同编辑时不至于重构数据层。传统用 contenteditable 加 innerHTML 的方案按下回车都可能出现跨浏览器行为不一致的问题更别提做自定义结构和协同了。1.2 市面编辑器的一锅乱炖Draft、Quill、TipTap、Slate当时我花了一个周五下午把主流的几个方案拉了个对比表Draft.jsReact 生态的老牌方案内容模型基于不可变数据但迭代节奏偏慢很多能力要从 block 层面自己拼而且内置的粘贴处理和图片上传体验都比较原始。Quill好用Delta 模型也很优雅但自定义“块”的深度有限要做出文档树级别的嵌套结构很痛苦。TipTap基于 ProseMirror开箱即用功能丰富但它的 schema 和插件体系更像是一个“半成品框架”面向深入定制时ProseMirror 本身的上手门槛其实非常高。Slate没有内置工具栏没有内置表格甚至没有一个完整的“内容模型”。它只给你一棵树、一堆操作和一套可插拔的函数体系一切由你定义。对比表拉完我反而松了口气。Draft 和 Quill 玩的是“给你一辆完整的车”Slate 玩的是“给你底盘和发动机车壳自己焊”。我要的就是后者因为需求变化太快我不想到第五个月发现某个交互框架压根不支持一切还得推翻重来。1.3 hero-editor 想解决的场景hero-editor 的定位很明确面向结构化文档编辑器主要场景包括多级标题、有序/无序列表、引用块、代码块、链接以及后续要加的任务列表和嵌套表格。操作习惯沿用 Notion 的斜杠命令输入/弹出块类型菜单选中后插入对应块。这个定位下我不需要富文本编辑器领域里“一揽子”的能力但我需要每一层结构都听我的。Slate 的数据模型是纯 JSON渲染层完全可以由 React 组件接管。它不会替我做决定但也不会拦着我自己做决定。2. 先把核心心智模型掰开树、操作与渲染2.1 编辑器模型为什么不能是一棵字符串树Slate 里的一切都是节点编辑器本身是 Editor 节点下面挂若干 Element 节点每个 Element 下面可以继续挂 Element 或者 Text。Text 是叶子真正保存字符内容并且可以用 marks 记录样式。整个文档就是一棵嵌套 JSON 树类似这样{ type: paragraph, children: [ { text: 这是一段文本 } ] }为什么不能直接存 HTML 字符串因为 HTML 字符串“能看不能算”。要判断某个节点是第几层的列表项要追踪光标落在哪个块的第几个字符后面要在协同场景里对字符串做合并——这些都是算法要处理的问题。字符串结构没有稳定的“路径”概念而 JSON 树结构有任一节点都能用[0, 1, 0]这样的路径定位到比如“根节点第 1 个子节点的第 2 个子节点的第 1 个字符”。这棵树的收益在做嵌套列表时体现得最明显。列表项里再套列表项在 HTML 里无非是li里塞ul但如果你想在编辑过程中自由升降级、合并拆分字符串结构根本没法做精确操作。树结构天然支持路径定位所以 Slate 做这类变换只需要改节点层级。2.2 Operation 不是回调是编辑器的“审计日志”第一次看 Slate 源码时我一度被 Operation 这个概念搞得头晕。后来我用一个比喻把它理清了你每一次打字、删除、回车、加粗Slate 都会把它翻译成一组最小粒度的“操作记录”像银行流水一样逐条写入 editor 的历史里。// 在光标处插入文本会生成类似这样的 operation { type: insert_text, path: [0, 0], offset: 3, text: 新 }这种设计带来的直接好处是撤销和重做不再是“拍照快照”而是把操作记录倒着回放一遍。另一个好处是协同编辑每个用户的每次修改都能拆成操作序列只要把操作广播给其他人再各自应用就能实现基本协同。虽然 hero-editor 第一版还没做协同但这个模型让我们后续接入 OT 或 CRDT 时不用推翻数据层。如果你要做的只是写一个带加粗、斜体的编辑器Operation 可能确实只有insert_text、remove_text、set_node这几类。但一旦切换到块级编辑你会发现 split_node拆节点、merge_node合并节点、move_node移动节点的调用频率会非常高。理解 Operation 能让你在 debug 时一眼看出问题出在哪一步而不是面对一团混乱的状态。2.3 从 value 到 DOM 的那一跳Slate 并不会自动把 JSON 树画在页面上。它只负责提供一个编辑器实例、维护操作、触发更新真正的渲染由你写 React 组件完成。渲染入口是renderElement和renderLeaf两个函数它们分别对应 Element 节点和 Text 叶子的渲染。这个“渲染完全由用户接管”的设计第一眼看有点反直觉但实际操作后会发现特别爽。我可以在 renderElement 里用div的样式区分段落用h2渲染标题用pre渲染代码块甚至给每个块套上可拖拽的把手。const renderElement (props) { switch (props.element.type) { case heading: return h2 {...props.attributes}{props.children}/h2 case code-block: return pre {...props.attributes}code{props.children}/code/pre default: return p {...props.attributes}{props.children}/p } }注意props.attributes和props.children是必须透传的。前者负责绑定供 Slate 操作的 DOM 属性后者承载 Slate 在各节点之间编排的文本内容。漏掉任何一个都会出现内容能显示但无法编辑或者渲染错乱的诡异 bug。3. hero-editor 首版从零到一的搭建过程3.1 初始化别在依赖版本上踩坑Slate 的版本距今经历过好几次破坏性更新特别是 0.5x 到 0.6x、0.6x 到 0.7x 的迁移很多 API 直接换名字。我的建议是直接锁定当前稳定主版本不要参照网上一两年前的旧教程硬抄。初始化代码不多核心是创建编辑器实例并组合各种能力import { createEditor, Transforms, Editor } from slate import { withReact } from slate-react import { withHistory } from slate-history const editor useMemo( () withHistory(withReact(createEditor())), [] )withReact负责把 React 合成事件和 Slate 的数据模型连接起来比如告诉编辑器光标落在哪个路径、发生点击时如何更新选中区。withHistory提供撤销重做能力。这两个高阶函数是最基础的组合后续还可以自己写一个withHero在里面做自定义逻辑。这一步值得反复提醒的是编辑器实例要稳定。如果你在 React 渲染过程中每次都新建一个 editor 实例会导致选区丢失、状态错乱。用useMemo或useState的初始化函数保存它。3.2 组合式布局把命令和 UI 解耦hero-editor 的 UI 分成上下两个区域顶部是工具栏包含粗体、斜体、标题、列表按钮编辑区内建了斜杠菜单。我不想让工具栏按钮的逻辑和 React 组件耦合在一起于是定了一个约定所有操作都封装成纯函数入参是 editor 对象函数内部用 Slate 的 Transforms API 修改内容。function toggleBold(editor) { const marks Editor.marks(editor) const isActive marks?.bold true if (isActive) { Editor.removeMark(editor, bold) } else { Editor.addMark(editor, bold, true) } }工具栏组件只负责调用这些函数并按需高亮当前激活状态。代码块、标题、列表这些块级操作也遵循同样的模式用Transforms.setNodes设置块类型或者在列表场景用Transforms.wrapNodes和Transforms.unwrapNodes包裹和解除包裹。3.3 首版功能标题、粗体、列表、链接如何塞进同一棵树首版我圈定了五个块类型paragraph、heading、list-item、blockquote、code-block。每个类型的结构不一样但都能放进同一棵树上段落最简单children 里只放 Text。标题给 Element 加一个level属性渲染时根据 level 渲染成 h1/h2/h3。列表项用 list-item 作为容器内部再挂一个 list 类型的 Element 作为嵌套层实现有序/无序列表的层级嵌套。引用块和代码块都是单一 Element区别在 renderElement 里渲染成不同标签。链接用的是 inline 元素不是块类型。当时为了处理它我在 renderLeaf 之外加了一个 renderInline 的逻辑。链接结构大概是{ type: link, url: https://example.com, children: [{ text: 示例链接 }] }初次跑通所有基础功能大概花了一个周末。真正让我头皮发麻的是从“能编辑”到“编辑体验像样”的过程。4. 常规“假需求”在 Slate 里变成真难题光标、粘贴与序列化4.1 光标和选区Range 不是选文字是选树路径在 contenteditable 世界里光标位置可以直接通过浏览器的 Selection 对象拿。但在 Slate 里选区被抽象为 Range它记录的不是 DOM 节点而是树的路径和字符偏移。{ anchor: { path: [1, 0], offset: 3 }, focus: { path: [1, 2], offset: 5 } }这种设计的优势是选区不会被 DOM 结构变化影响。比如你选中一块文本并把它加粗底层会拆分 Text 节点DOM 结构完全变了但 Slate 层的 Range 依然有效。劣势也很明显当你做“光标在空列表项里按回车”这类操作时需要手动处理路径变化。空列表项里如果没有文本节点光标就会变成“无家可归”的状态。我的处理方案是保证所有块 Element 的 children 至少有一个 Text 节点用[]空文本占位。这个约定虽然笨但能避免绝大多数光标丢失的 bug。4.2 粘贴 HTML 的清洗与映射“从网页复制内容粘贴进编辑器”这个需求看着很小做起来却是纯体力活。浏览器剪贴板里是一整段 HTML而且各家浏览器产生的片段结构完全不一样。我就遇到过从 Word 粘贴进来的内容带着一层层span style...包裹缩进全是用 margin 和 padding 模拟出来的。我没选择自己手写一套 HTML 解析器而是用了slate-html系列的序列化工具同时做了一个白名单策略粘贴进来时先走 DOMParser对照白名单把不认识的标签一律降级为段落保留 p、h1-h3、ul、ol、li、a、code、pre、strong、em。其余标签全部剥离样式属性只保留有限的几个颜色、字号、行高一律丢弃。注意粘贴处理里最容易翻车的是 lists。网页里的 ul/li 和 Slate 的 list-item 不完全等价需要把嵌套的ul结构逐个递归转换成树上的 list 节点否则粘贴完层级全乱。4.3 序列化协议为什么最终选了自定义 JSON HTML 双轨hero-editor 的内容最终要存进后端给其他服务消费。我一开始想直接存 HTML结果被产品的“跨端一致性”需求拦住如果服务端也要解析内容HTML 这种格式很容易在解析器之间产生歧义。最后我定了一套双轨协议存储用自定义 JSON也就是 Slate 的文档结构。它忠实保留块类型、嵌套关系、marks方便程序化处理和后续版本升级。对外展示用 HTML。每次保存前把 JSON 序列化成干净、语义化的 HTML提供给详情页、邮件、站外分享等场景。序列化代码并不复杂核心就是一个递归遍历function serializeNode(node) { if (Text.isText(node)) { return node.bold ? strong${node.text}/strong : node.text } switch (node.type) { case heading: return h${node.level}${node.children.map(serializeNode).join()}/h${node.level} case list: return node.ordered ? ol${node.children.map(serializeNode).join()}/ol : ul${node.children.map(serializeNode).join()}/ul default: return p${node.children.map(serializeNode).join()}/p } }JSON 转 HTML 这条路走通之后内容可以稳定落库也能稳定展示。真正让我意识到问题严重的是上线前的实测期。5. 实测翻车复盘中文输入法、嵌套列表和重渲染5.1 输入法组合输入导致的光标瞬移产品是中文环境所以中文输入法是高频场景。首版实测的时候我发现用拼音输入法打长句子经常打到一半光标跳到段落开头整段输入顺序错乱。排查链路是这样的先是打开 Slate 的 debug 面板观察每次 onChange 的 value发现输入法还在 composition 阶段时Slate 就会把编辑器 value 更新成“未完成的拼音字母”然后等我敲定候选字时浏览器又触发了另一个 DOM 更新。两个更新竞争同一个选区Slate 在数据层已经落后于 DOM于是光标被推回起点。解决方案分两步。第一步在 onKeyDown 里判断e.nativeEvent.isComposing组合输入过程中不执行任何快捷键类和块级类操作。第二步在 Slate 的 onChange 里做一层防御如果当前正处于 composition 中只更新 UI 不更新受控 value等 compositionend 结束后再统一提交。这个改造是 hero-editor 开发中最值的投入之一。如果产品只面向英文用户这个问题大概率不会暴露但只要你做中文编辑IME 的坑基本上绕不开。5.2 嵌套列表的缩进操作路径计算不能拍脑袋嵌套列表的第一个版本我的处理逻辑是按 Tab 缩进时把当前列表项包进上一个列表项的 children 里成为它的子列表按 ShiftTab 时再把它从父列表里抽出来恢复到上一层的兄弟节点。听起来简单实际写起来全是路径越界。原因在于包裹和反包裹会改变列表结构导致后续节点路径全部失效。比如第 2 个列表项被包进第 1 个列表项后原来第 3 个列表项的路径从[1, 2]变成了[1, 1, 1, 0]如果你继续按原路径操作必然报错。这类问题不能靠“操作完后重新获取一次路径”硬碰硬因为操作是一个连续过程。更稳的做法是优先利用 Slate 的Editor.after、Editor.before这类 API 获取动态路径每次变换前都重新定位。同时我给列表加了一层 normalize 约束不允许出现“空列表项”不允许出现两个同一级的连续同类型列表而不合并不允许在列表项里直接塞非块级节点。这些规则写进withNodeNormalization每执行完一次操作就自动检查一遍树结构。它像是一个自动清扫员最大程度避免脏数据被存进 JSON。5.3 大文档下的全量重渲染hero-editor 上线前压测塞了一篇 2 万字的长文进去发现输入几百字后光标开始明显变卡每敲一个字都要等几十毫秒。排查后发现问题一方面出在“每个 Text 节点都是整块文本”一旦你中途加粗几个字Slate 会把 Text 节点拆成三个节点但每个节点仍然很长。渲染时没有任何 memo 优化React 只能把所有子节点重新渲染一遍。优化方向有两个。第一个是给 renderElement 和 renderLeaf 的组件加 memo只有 props 变化才触发重新渲染。第二个是把长文本节点按固定长度切成多段每段不超过 1000 字符减少单节点重渲染的开销。对于大部分内容平台来说这两个优化已经足够。如果以后要做超大文档我会考虑再引入虚拟列表只渲染可视区域内的节点。但为了第一版项目这两个措施把输入卡顿降到了可接受范围。5.4 上线前后的两个小结论踩完这些坑我对自己提了两条约束。第一不要在 Slate 和 DOM 之间做“隐式同步”所有状态变化都以 Slate 数据层为准。遇到奇怪 bug 先查数据层不要想着去改 DOM因为你不可能永远算准浏览器会把 DOM 变成什么样。第二早期就要定义 normalize 规则。编辑器是给人打字的但不是所有人都按你的预期打字。粘贴过来的异常结构、脚本注入的脏内容、历史数据里的老格式都需要 normalize 兜底。越早建立规则脏数据越少。第二版 hero-editor 我已经开始考虑两个方向把斜杠菜单的块类型声明改成注册表模式后续加新块类型不用改核心代码把序列化层抽成独立包让服务端渲染也能直接调用。这些改动都建立在同一个前提上——编辑器数据模型足够清晰所有功能都围绕树结构和操作展开。这也是当初选 Slate 最大的回报。本文还有配套的精品资源点击获取
返回列表