如果你感觉一个功能设计起来非常复杂,自己都无法简单的说明白,那很可能是你的设计方向出错了!
背景
所谓 L1 能力,即弃用浏览器自带的 execCommand ,自己来实现富文本样式操作。
我从 4.3 开始陆续业余时间调研,目前已有 2-3 周,分享一些调研记录。
- 先开始写 demo ,写了一部分
- 调研经典的开源作品:Quill Slate ProseMirror
PS:目前只是一个中间阶段,会继续做调研、写 demo 。所以本次分享也会比较零散,而且内容较多。
写 demo
【重要提醒】这部分作为一个了解,demo 的一些设计是不合理的,后面我会有更正,不要被带歪了!!!
基于两个前提
- 拆分 model 和 view ,使用 vdom 渲染页面
- 抽象 selection 和 range
抽象 model
【注意】model 即数据模型,有时候也被称为 state content doc 等,我们这里统称为 model 。
定义 IBlockNode IIlineNode ,最顶层只能是 IBlockNode,下面才能是 IInlineNode
model 就是 blockNode 的数组。(这些参考 slate)
完全基于 DOM 结构的抽象,很好理解。【注意】文本节点必须被 span 包裹,否则 model 不好修改。
window.content1 = [
{
selector: 'p',
children: [
{
selector: 'span',
children: ['欢迎使用 ']
},
{
selector: 'a',
props: {
href: 'https://www.wangeditor.com/',
target: '_blank'
},
children: [
{
selector: 'span',
children: ['wangEditor']
}
]
},
{
selector: 'span',
children: [' 富文本编辑器']
}
]
},
{
selector: 'p',
children: [
{
selector: 'span',
children: ['欢迎使用 ']
},
{
selector: 'b',
children: [
{
selector: 'span',
children: ['wangEditor']
}
]
},
{
selector: 'span',
children: [' 富文本编辑器']
}
]
},
{
selector: 'p',
children: [
{
selector: 'img',
props: {
src: 'http://www.wangeditor.com/imgs/logo.jpeg',
alt: 'logo'
}
}
]
}
]
复制代码
vdom 和 diff patch 使用的是 github.com/snabbdom/sn…
抽象 selection range
参考的 Quill 的设计
interface IVRange {
offset: number
length: number
}
复制代码
计算规则:
- 开始为 0
- 到文本则 +textContent.length
- 遇到图片等非文本,则 +1
- 遇到 block 节点(p li td 等),也要 +1 —— 重要,否则无法区分是 N 行末尾,还是 N+1 行的开始
有两个重要的功能(都会涉及到 DOM 树的深度遍历)
- 选区变化时,要能根据原生的 range 计算出 vRange
- 要能根据 vRange 设置编辑器真实的选区
selection.updateSelection({ offset: 5, length: 0 })
command
基本思路
- 调用 command 修改 model
- model 修改 view
- 重置 selection range
抽象基础的 command,可扩展 custom-command
- nsertInlineNodes 如加粗、斜体等
- replaceText 输入和删除文字
- replaceBlockNode 如设置标题、list
- removeNode
- deleteText
- insertBlockNodes
- replaceInlineNodes 如取消 link
- deleteBlockNodes
- ……(还未考虑全,其实基础 command 应该是可枚举的才可以,否则就乱套了)
获取选中的节点
例如,要把 p 设置为 h1 ,或者要对一段文本加粗,你得知道你选中了那几个节点(model 中的节点)
所以,要根据 vRange 和 model ,找出选中的节点,以及他们的上级节点。
即代码中的 content.selectedNodeAndParentsArr ,这是一个二维数组
private selectedNodeAndParentsArr: Array<Array<IBlockNode | IInlineNode>> = []
复制代码
如,你点击选择了一个 p ,它就是 [ [p, span] ]
(文本必须被 span 包裹)
如,你拖拽选择了一个 p 内的文本、加粗文本、文本,它就是 [ [p, span], [p, b, span], [p, span] ]
知道了选中的节点,就可以对这些节点进行修改,即修改了 model 。
修改 model ,更新视图
- 执行相关的命令,如
command.do('bold', true)
command.do('color', 'red')
- 各个命令的代码,转化节点,如加粗时把
<span>x</span>
转换为<b><span>x</span></b>
- 执行基础命令,修改 model ,更新视图,更新 selection 和 range —— 【注意】对于加粗等文本格式操作,因为选区的问题,会非常复杂!!!
关于 contenteditable
想自己绘制光标,后放弃
一开始想要自己绘制光标,不用 contenteditable
- 通过 range.getClientRects()可以获得选区的位置
- 在该位置绘制一个 absolute div ,内部 append 一个闪烁的 div 和 input
- 监听 input keydown ,然后执行文本输入、删除,回车,光标的上下变化
后来放弃,因为拖拽选择时,无法同时 focus input ,也就无法监听到 keydown 。
后来经过调研,自己绘制光标的编辑器,都需要使用 iframe (作为第三方 lib 我不想用 iframe),有些甚至需要自己绘制选区(有道云笔记??)
看过 proseMirror 作者的一个演讲视频,他也说这种方式将来带很多 bug 。
决定使用 contenteditable
经过调研,经典的编辑器 Quill slate proseMirror tinyMCE 等都使用 contenteditable 。所以选择它,方向应该不会错。
不过也有一些反对的声音:
- 最经典的就是 why-contenteditable-is-terrible(中文翻译)
- 还有有道云笔记的一片分享说:contenteditable 需要劫持 keydown 来修改 model ,万一劫持不到,就会导致 view 被偷偷修改 —— 按理说 view 必须通过 model 生成,不能自己修改。
我一开始不想用 contenteditable 也是基于上述原因。但目前看来只能做一个取舍。
所以,最后决定使用 contenteditable
关于 mutation observer
依据我当初想弃用 contenteditable 的想法,就得自己处理文本。我设想的方式是:
- 监听 input keydown (会考虑防抖)
- 修改 model ,更新视图,更新 selection
内容的改动,分为两种类型
但使用 contenteditable 之后,编辑区域的修改就变的开放了,在想去劫持 keydown ,范围就会很大,很容易漏掉什么。
所以,我就看到这这张图。图中说,使用 mutation observer 来监听编辑器的改动。
于是我就把编辑器的修改内容的操作分为两类:
- 外部调用 API ,例如 js 执行加粗、标题 command
- 编辑区域内部的改动,因为是 contenteditable ,修改的是开放的,键盘可随便输入、删除、换行等
第二类,我可以用 mutation observer 来监听,不用再去劫持各种 keydown
mutation observer 无法监听所有的改动
但很快我就发现,当 contenteditable 编辑区域,对一个文本回车换行时,mutations 的监听结果是反人类的!!!
我花费了两天的业余时间,尝试去解读 mutation observer 对于回车换行的处理,但是没搞定。
两天之后我就放弃了,不是知难而退,而是这种情况,即便真的废力气解决了,那也是一个很复杂的设计。
好的设计应该是简单的,合理的,易读的,一点即通的。
大家可以自己试试。
看 Quill 源码,mutation observer 只能监听文本改动
Quill 只用 mutation observer 监听文本改动。其他的 enter delete 等,都还是劫持 keydown ,修改 model 。
mutations.length === 1 && mutations[0].type === 'characterData'
复制代码
demo 止于回车换行的问题
在回车换行的问题上我想了很久,但是最终都没有解决方案。demo 也就写到这里为止了。
<p>
<span>aaa</span>
<b><span>bbb</span></b>
<a href="xxx">
<i><span>ccc</span></i>
</a>
</p>
<p>
<span>ddd</span>
</p>
复制代码
例如上面的 html 结构,如果要随便在一个地方换行,model 该如何处理?
如果要拖拽选中一段,再回车换行,model 又该如何处理?—— 这些都不是当前的设计能轻松解释的。
所以,我断定,一定是我的设计反向出了问题。
调研经典的开源作品
调研其他产品,应该同时多看几个,可以相互弥补。因为只看一个你不可能看懂 100% 。
我花了几天业余时间看了看 Quill slate proseMirror ,从主流程上大概了解了,但还需要进一步探索细节。
这三款编辑器,都有一些共同的设计特点,也让我认识到自己之前设计的一些误区。这几点都非常重要!!!
- 不是修改 model ,而是重新生成(不可变数据,如用 immer)。副作用会变得不可控,越大越乱。
- command 不会直接修改 model ,而是 command -> operation -> model -> vdom & patchView
- model 并不是 DOM 结构的样子,而是扁平化甚至线性化,这样才能更好的进行 range 操作
调研其他作品的关键是什么?
精力有限,请务必抓住核心、抓住主要矛盾。
- 它的重要概念和数据结构,如 Quill 的 Delta
- 它从 command 到最终 view 渲染的完整流程,以及各个中间阶段
不可变数据
利于拆分模块,降低复杂度
写纯函数,无副作用,利于测试 —— 重要
但要考虑性能,所以要使用合适的工具 immer (immutable.js 不建议使用,API 学习成本高)
Operation 的价值
原子操作,例如 juejin.cn/post/691712…
operation 应该是最底层的、可枚举、不可扩展的。
一个 command 可能会包含多个 operation 。例如拖蓝选中一段文字,点击回车。这个 command 就包含多个 Operation :先删除、再拆分节点。
协同编辑依赖于原子操作,需要把 operation 传递给 peers ,然后做合并。
如常见的 OT 算法,不过这块我还没开始调研。
撤销操作,需要将 operation 反转,然后重新 apply ,如这里的 inverse
函数。
【注意】如果考虑到协同编辑,撤销操作就能简单粗暴的去覆盖编辑器内容,而是“只能撤销自己的、不能撤销别人的”。所以,需要找出自己的 operation 然后反转。
model 的扁平化
上图是 proseMirror 的文档,slate 也是这样的。即,所有的文本,无论是 B I U link color bgColor fontSize 等,全部都是平铺的。这也是我刚开始做 demo 没想到的。
Quill 更狠,直接做成了一个线性的模型,用线性结构表示树结构。下文再说。
[
{
type: 'p',
attrs: {},
children: [
{
type: 'text',
text: 'aaa'
},
{
type: 'text',
text: 'bbb',
marks: { bold: true }
},
{
type: 'text',
text: 'ccc',
marks: { bold: true, em: true },
style: { color: 'red' }
},
{
type: 'text',
text: 'ddd',
marks: { bold: true, em: true, link: 'xxxx' }
}
]
},
{
// ...
}
]
复制代码
model 扁平化之后(即文本没有了 <b> <i> <u> <a> <span>
节点和嵌套的层级关系),就发生了质的变化:
model 即 node 树,树的深度最大就是 3 (table tr td),这样遍历起来会很快
- 容易计算 range
{ offset, length }
(有层级,计算是非常麻烦的,很容易出错) - 容易修改文本样式,如随便选中文本,加粗、设置颜色等,文本的选区很随意的
- 容易拆分节点,如回车换行(上文重点提到的)其实就是 splitNode,非常简单
- 容易 clean 和 merge (如果有层级,那这一步是非常复杂的)
- text 节点如果内容为空,则清除掉
- 两个相邻的 text 节点,属性相同时要 merge
model -> view
model 既然不是和 DOM 结构完全对应,那就无法直接渲染为 vdom 。
例如 { bold: true }
到底渲染为 <b>
还是 <strong>
?
再例如,当 bold em
同时存在时,渲染为 <b><em>
还是 <em><b>
?谁包裹谁?
所以,model 渲染为 vdom 的中间,还有一个 parser 。
quill 中是 formats 。slate 直接甩手给了用户,自己写 React 组件去实现。proseMirror 目前我还没搞清楚~
而且,model 就一定渲染为 html 吗?能否渲染为 markdown 呢?
Quill.js
content 数据结构
基于 OT 模型 { retain, insert, delete }
(可以有 attributes
)
对 selection 的抽象
quill 的内容是基于文字的,图片、视频等 embed 也可以看作是一个特殊的文字。
- 一个文字占据一个长度单位
- 一个 embed 占据一个长度单位
- block 占据一个长度单位 —— 否则分不清 N 行尾,还是 N+1 行头
所以,quill 把 selection 抽象为简单的 { index, length }
content 的线性结构【重要】
这里的 content 即上述的 model 。上述的 model 是 node 树,文字内容做扁平化处理。
而 quill 的 content 是线性结构(即数组),它能用线性结构最终渲染出 DOM —— 这是一个很伟大的设计!
而且,它天然就是 OT 模型的,天然就支持协同编辑。
线性结构,更加容易基于 range 操作。如:修改文本属性、插入删除文本、回车换行等。
可以和上文的 node 结构比较一下。
content 如何最终表示 DOM 结构呢?它如何表示 <p> <ul><li> <b>
等?参考 demo
// demo 中加入以下代码,以随时查看 content
document.body.addEventListener('click', () => {
console.log('contents', editor.getContents())
})
复制代码
可以看出 \n
占据了重要角色,quill 用 \n
来表达一个 block 的结束。
它还以表达 table ,更加复杂一点,但一个道理。
delta 即 operation
demo 演示 codepen.io/quill/pen/d… ,内容改变生成 delta ,然后生成新 content
delta 也是基于 OT 模型 { retain, insert, delete }
(可以有 attributes
),这一点它和 content 一样。但两者不要混了。
- content 表示编辑器当前的内容
- delta 表示一个内容的变化,它就是 operation 。一次改动,可能会有多个 delta 。
- 【注意】content 并不是直接 concat delta ,要经过转换计算的。如 delta 可能有 delete 而 content 只有 insert
即,quill 支持协同编辑器,OT 数据模型是基础,而 delta 就是具体的实现者。
parchment 即 vdom
content 是 OT 模型的,无法直接渲染为 DOM ,所以还需要两步
- formats ,如 bold link image 如何渲染
- parchment blots(即 vdom 和 vnode)
quill 主流程大致是:command/format/textChange -> 生成 delta[] -> 重新计算 content -> 根据 foremats 重新生成 parchment -> 渲染 DOM 。
Slate.js
数据模型 model
Quill 是基于文字的线性结构,Slate 是基于 node 的树形结构。
不过,文本节点也是经过扁平化的。
Selection 和 Range
Quill 是基于文字的线性结构,Slate 是基于 node 的树形结构。
Quill 使用 { index, length } 来抽象 range ,而 Slate 就不适合与这种方式。
- Path 找到具体的节点
- Point 确定该具体的位置,包括 Path 和 offset
- 再通过两个 Point ( anchor focus )表示 Range
其实,Slate 如果非要用 { index, length }
表示也没问题,也能算出来。但是有了 Path 就会计算的更快一些,Pach 更加适合与这种 node 树结构。
9 种 opreation
Quill 的 delta 是 OT 数据模型,属性只有三个 { remain, insert, delte }
slate 用 9 种 operation ,它是为了适合于 node 树结构。
- node 相关的 6 个
- insert_node
- merge_node
- move_node
- remove_node
- set_node
- split_node
- text 相关的 2 个
- insert_text
- remove_text
- selection 相关的 1 个
- set_selection
而且,每个 operation 都能找到它的反操作,便于撤销(上文讲过)。
renderElement renderLeaf 相当于 Quill 的 formats
slate 只是一个编辑器的 controller ,view 它不管。开发者自己去写。
主流程
主流程 customCommand -> Transform.xxx(editor, …) -> editor.apply(operation) -> 重新生成 model -> React 渲染
- Transform 相当于一些 base command ,在此基础上再去扩展自己的 custom command
- 每一个 Transform 函数内,都有可能:
- 执行其他 Transform
- 生成至少一个 Operation ,然后 editor.apply(operation)
- editor.apply 内部,会用 immer 来生成不可变数据
proseMirror
感觉 proseMirror 非常的抽象、难懂。但它具有一定的江湖地位,肯定有很多值得学习之处。
不过我目前看的还太少,只看了一天,所以没法写太多内容。
不过,看它的数据结构、内容修改的流程,和上述的主要思路都是对应的。
model 是 node 树结构,和 slate 类似
不仅仅是图文
虽然图文编辑是基础,绝不能仅考虑图文。设计要全面、闭环。
考虑什么?
- 在 model 中的数据格式和结构
- 如何渲染到 vdom 和 DOM
- range 如何表示
- operation 的类型是否能满足
- 是否支持协同编辑
embed
我此前对 embed 的误解
我之前疑问,所有复杂的东西都可以作为 embed ,在之前的博客里,体现过这一点。
后来我慢慢发现,我搞错了方向。
embed 和是不是复杂没关系,它仅和文字有关系。
复杂的东西可以单独搞,但那是另外一件事儿,不是 embed 。
embed 一定是非文本的
图片是最典型的 embed ,它有一些原始数据 { href, alt, ... }
,最后渲染为一个非文字的“块”,且这个“块”是不可编辑、不可再分、不可拆解的。
例如,视频、音频、公式等。
所以,table codeBlock 都不是 embed 。
复杂的文本组织形式
- table(如合并单元格)
- codeBlock
这俩最重要,其他的还没想到。
目前我已经有一点点调研的结果,仅限于 Quill 。其他编辑器还需要再调研对比一下。
未来计划
L1 编辑器内核非常复杂,还需要再继续慢慢调研。接下来我会:
- 深入到 Quill slate 和 proseMirror
- 广泛了解其他编辑器 tinyMCE CKEditor editor.js 等
- 详细学习一下协同编辑,否则 operation 无法详细的设计