编辑器初体验
最近一直在开发编辑器,在这里整理下整体思路,串一串知识点。
文章总共分三块:第一部分是编辑器开发的历史进程;第二是目前使用的slatejs的源码探索;第三部分是当前项目的设计以及遇到的一些问题。我不会介绍slatejs入门内容,那些去读它的官方文档就好,我记录了一些它的源码实现以及闪光点。
全文目录如下:
- 编辑器开发历史进程
- 编辑器原生能力
- 一代编辑器
- 二代编辑器
- 三代编辑器
- 另类选择
- slatejs设计以及源码赏析
- 闪光点
- 代码设计
- 源码解析
- slate-react
- 1.react渲染
- 2.初始化slate
- 3.内置plugin
- dom plugin
- 4.selection同步
- 5.浏览器兼容
- slate
- 1.初始化model
- 2.schema定义
- 3.normalize机制
- 项目开发与踩过的坑
- 项目设计
- 踩坑小计
- 不做任何假设
- 默认行为不一致
- arrow-left问题
- enter问题
- 边界case很多
- 插件权限过高
- 性能问题?
- 总结
编辑器开发历史进程
加入项目的时候组内某大佬已经选好了slatejs,自己还是趁机去了解了一下编辑器开发的演化过程,知晓下我们选择的框架的位置。
编辑器原生能力
编辑器开发是个很老的话题。浏览器默认就提供了很强的能力,主要是提供了 contenteditable 和 execCommand 两个原生api来支持编辑行为。
也就是说,你随意打开一个网站,把他的 div 设置成 contenteditable ,你就几乎完成了最简单版本编辑器的开发。
你可以随意编辑 div 内的内容,如上图选中一段文案后按下 command + b ,浏览器会把选中区加粗,cmd + z,浏览器会撤销刚才的操作。
而 execCommand 则提供了js操作编辑的能力。命令行输入:
document.execCommand('backColor', false, 'red')
你会发现选中区内容就会变成红色,如下图所示:
execCommand支持的功能很多,比如支持delete,insertHTML等,而且execCommand执行的行为都会收入浏览器的undo栈中,cmd+z的时候会进行撤回。
所以理论上我们只要开发一个toolbar配上execCommand就大功告成了。
不过所有命运赠送的礼物,早已在暗中标好了价格
一代编辑器
既然选择了依赖浏览器,那效果如何就得看浏览器大爷支持的好不好了。结论当然是不好。
首先是各浏览器对于 execCommand 里的命令的支持程度不一样。(可以查看你当前浏览器支持了哪些命令:链接)
其次他们的行为很不受控,比如快捷键 cmd+b 在 chrome 里面是增加了 b 便签,在 ie11 中是则增加了 strong 标签,而在safari里就不支持快捷键。
于是出现了最开始的一代编辑器,打着开箱即用的旗号,旨在处理各种浏览器兼容问题。比如UEditor、KindEditor、KissyEditor...等
我看了下比较有名的百度的UEitor的实现,以一次bold操作为例。
其实就是完全放弃了浏览器的execCommand,实现了自己的一套命令,自己定义了一个bold的命令。最终的操作其实就是新建了一个strong元素,然后插入进了dom中。
放弃了浏览器的execCommand,其实也就意味着放弃了浏览器自身的undo和redo栈。于是能看到UEditor自己实现了一套undo栈。
能看到他里面记录了一个undo的array,里面存了对应位置的content是什么。
当触发了undo操作的时候,直接重新设置innerHtml来恢复,确实解决了问题,但仅仅只是解决了,并没有规范化,而且可是时不时地还会出现bug,因为一直在手动操作dom,代价也比较高。
二代编辑器
这次进化的主要特点是编辑器开始依赖自定义数据模型,不再直接依赖dom,同时提供了极强的扩展能力。
这一阶段的框架库有ProseMirror,Quill.js,Draft.js,Slatejs。比如Quill.js定义了一个叫dela的格式,专门描述文档和变化。
draftjs自定义了schema的方式,不过它的schema结构是扁平化的,这点上slatejs支持的更好,支持嵌套结构。
同时很多编辑器框架放弃了开箱即用,更多提供的是一个乐高积木,而不是已经拼好的玩具车。
这一部分因为我后面会详细介绍slatejs实现,这里先不展开。
三代编辑器
二代编辑器的使用者们发现,国际化和移动端并不能很好的处理。
所以最早的google doc在2010年就把contenteditable也放弃了,完全自己接管了光标,可以查看链接。同样做了这种选择的还有iCloud Page,有道云笔记好像也是。这样子做代价很高,因为接手越多,意味着浏览器帮你做的越少。
这一代的我并没有做深入研究,因为他们并没有开源。我们其实也还没有到达这个阶段,有足够的人力和精力去踩坑。
另类选择
还有一些另类的实现,比如用canvas+textarea的情况:
实现就是一个canvas管绘制,然后一个绝对定位的textarea管光标。canvas处理鼠标事件,textarea处理键盘事件这样。总的来说其实没有太大差异,而且没有做的比较好的开源库,不深入研究了。
slatejs设计以及源码赏析
slatejs官方说他借鉴了Draftjs,Prosemirror和Quill的优点,算是集大成着。在这里我不会介绍slatejs的入门内容,那些直接读官方文档就好。我只提一些他设计的闪光点以及实现原理。
闪光点
- 插件是一等公民:插件是slatejs里面最重要的概念,以插件方式开放了一系列自定义的口子,甚至内核也是插件实现。插件的执行顺序依赖我们传入的插件列表的数组顺序。
- 仿造dom:slate本身可以理解为一个model,一个仿dom的模型,我们的操作实际在与这个虚拟dom交互,交互完走react的setState渲染流程,设计天然运行效率高
- 使用react渲染:可能现在看来很平常的一点,其实抽象出model与view的互通是编辑器开发的一次大进步。同时slatejs没有自建view层,而是拥抱了react
- immutable:不可变,实现undo,redo的法门
- schema以及normalize机制:非常优秀的机制,避免文档错乱的重要一环
- 源代码边界清晰:slate与slate-react负责的内容拆分清晰,下面会详细讲
代码设计
slate本身分成了两个包:slate与slate-react。他们之间解耦解的很好,先来描述下这两个包的职能边界(并没有找到文档描述,自己看源码总结的):
slate可以理解为一个model层,该数据结构是完全仿dom的。slate做的事情包括:
- 提供Block,Inline,Mark,Point,Selection,Range,Document等的model(虽然html5不再以block,inline的方式区分元素了,但我个人觉得这对slatejs来说影响不大,看看google doc的html结构就知道了,敢用新特性么...)
- 提供了丰富的command,分为几种
- 对当前selection的操作
- 更改selection的操作
- 对range片段的操作
- 对特定node的操作
- 对历史的操作
他还做了:
- 初始化model
- 定义了基础schema,可以看到和我们定义的方式一样,难怪作者说内核也是插件化实现的。
- 每次操作之后都会normalize(可能可以作为优化效率的点)
- 格式化操作
slate-react负责真实与浏览器进行的一系列交互以及对slate的调用,做的事情如下:
- react的渲染
- 初始化slate
- 内置plugin(下面会详细讲)
- selection与原生dom的同步
- 各种浏览器兼容
talk is cheap,show me the code
源码解析
看源码的目的主要是加深对slatejs的熟悉,深入了解他做了那些事,我们能通过他做到哪些事。同时打消内心存在的对他的不信任感,比如怕他扩展能力差等。
slate-react
1.react渲染
他负责了将json结构渲染成一个react树。首先最外面的是一个contenteditable的div。
里面就通过Node实现递归渲染,Node内部创建一个children,将当前节点定义的子节点再用Node或者Text渲染出来实现了递归。
2.初始化slate
我们在项目中引用的slate-react的editor就是一个react组件,他的内部有一个控制器,这个控制器是slate的editor的一个实例。 所有调用slate-react的editor的行为都会被转到slate的editor上。我们来看一下slate的editor的初始化过程:
将我们通过props传的plugin加上slate-react内置的plugin一起交给了slate的editor去初始化。plugin传给了editor的构造函数,再与slate内置的plugin合并,执行注册逻辑:
调用注册plugin的方法,如果是数组,就递归执行。其中有3种插件会分解成2个插件(因为分为了注册和调用两部分,注册方法需要提前执行),最终所有的方法就会被注册到middleware数组中:
初始化完毕之后,回到slate-react的渲染逻辑,执行一个run方法。
run方法是精髓方法,他会按照key,比如上述的'onConstruct'找到对应的middleware,然后按顺序执行middleware数组里的方法,会将next函数传进方法中:
这也是为什么我们在很多时候在写插件的时候需要调用next()的原因,就是为了让接下去的middleware可以继续执行,举个例子,比如我们的bold的插件,如果是bold类型,我们接受处理,否则调用next,让后面的middleware继续执行。
回到上面的执行onContruct,其实就是不断的调controller里对应的middleware的数组的方法,注册一遍query和command。
以后editor随意调用的一个命令,比如editor.insertBlock其实最终就会调用onComand。然后去所有的command遍历找insertBlock去执行这样。
3.内置plugin
slate-react主要内置了dom,render,query的3种plugin(我没提的表示不重要)。
• dom(处理了所有的用户行为的前置与后置,比如阻止默认快捷键bold以及实现backspace逻辑) • render(定义了默认渲染,就是block,inline,mark的兜底渲染) • query(定义了与dom相关的查找,比如通过path或者key去找dom,或者从dom去找slate的node(他的查找机制是通过渲染时我们透传的attribute进行查找的,这也导致了我们的dom上会有很多奇怪的属性,语雀就很干净))
稍微详细讲下dom plugin来理解下他的实现。
dom plugin
可以看到slate-react特意设置了顺序:前置→ 我们自己定义的插件 → 后置插件。这样一个事件发生的时候会按照顺序被执行。
我们拿onKeyDown事件举例:先看前置插件,禁掉了一些 key-board 快捷键导致的浏览器默认行为,比如加粗,斜体等等:
然后执行next就会运行我们定义的插件(执行逻辑我在前面slate的初始化讲过了),你可以选择自己接管后续行为,也可以不处理,继续执行next。 就会执行到后置插件,后置插件就是在还原一些浏览器默认行为:
比如拿backspace举例子,如果!collpased(collpased意思是鼠标的开始和结束在一个位置,也就是俗称的没有"拖蓝区域"),就会moveToStart,否则moveBackward。
moveToStart和moveBackward的真实实现是slate提供,slate-react只是调用slate的api。
4.selection同步
如上文,slate接管了浏览器原生的selection以便让我们在不接触dom的情况下控制selection。
slate-react负责将slate的selection与window的selection双向同步。didMount的时候给window注册事件,将原生的selection同步到slate的selection中。
didUpdate的时候将slate的selection同步给window。
里面的执行代码如下,native就是window.getSelection(),具体 api 是调用原生的 setBaseAndExent 来设置的。
5.浏览器兼容
这里我就没有细看,只在看代码过程中看到slate-react处理了安卓,普通的浏览器兼容的坑。包括之前组内某大佬修中文输入法的问题也是在这里处理的。
所以如果将来要做国际化或者踩输入法的坑的话,我们其实只需要在slate-react里面做文章就行。
slate
讲完slate-react,我们来看下他的内核slate的实现。从初始化流程来看:
1.初始化model
我们传入了一个整个文档的schema,追踪下对应的slate的虚拟dom是如何被初始化的:
document的fromJSON会对nodes进行初始化:
实现其实就是一个递归构建就行了,node分类调用各种类型的创建:
然后如果是还有nodes的,就会继续创建node:
2.schema定义
slate不但实现了schema,同时定义了基本的规则:
这里的写法与我们在外部写的插件的api相同,能解释作者说内核也是插件化的。
3.normalize机制
每次执行完一个command之后,slate都会执行normalize,不过做了优化,只对dirty的path进行格式化操作。
这个机制好赞,可以避免你的文档被弄脏了。我之前开发过一个报表制作系统,现在想想当时是缺少这么一层机制的,导致用户的报表结构会出现混乱,只能提供给用户一个从线上恢复的能力。
不过slatejs这样还是可能会有一点性能问题,可以用官方提供的withoutNormalizing来强制不执行进行优化。
项目开发与踩过的坑
组内某大佬早早地选好了slatejs,分工也比较明确。我主要参与编辑器开发,因此我这里只整理了编辑器相关内容。
项目设计
我们的项目结构我和组内某大佬讨论过很多次,也重构了很多次。最开始并不熟悉slatejs的内部设计,为了整理代码而整理,曾经定义过没有什么意义的结构。
对于把功能写在哪里也纠结过,比如为了支持markdown语法,markdown组件里面其实会掺杂很多其他组件的逻辑。组内某大佬最开始设计的下图的方案,事实证明确实是最好的(大佬牛逼)。尤其在需要抽包给另一位项目使用时越加发现。
核心就是完全拥抱slatejs的插件设计,将所有的组件逻辑内聚到plugin,对外提供command和query供第三方调用。第三方就是目前支持3种方式的行为,icon,markdown和keyboard行为,都会能够控制文档。
我们目前已经支持了如下的所有快捷键,而且是WYSIWYG的。
踩坑小计
不做任何假设
slatejs作者非常的执拗,对于schema不做任何的假设,即使反人类也绝不帮忙。
比如首行如果是标题,按回车肯定是期望能恢复成默认格式,可是作者并不会帮忙处理,https://github.com/ianstormtaylor/slate/issues/1031
因此我们只能自己写一个插件专门处理首行删除问题。
但是作者这么做还是很牛逼的,保持了core的绝对干净。也许我不会得到他的一些人性化帮助,但也不会被他提前做的假设限制住。
至少另外一个项目的首行就不允许删除格式了~
默认行为不一致
arrow-left问题
slatejs内部有一些行为和默认的浏览器不太一致。比如我在下面的selection位置按下arrow-left,应该发生什么?
正常浏览器行为是离开 link 区域,进入"test"文案区域内,我试了 google doc 以及语雀都是如此。而 slatejs 不是,他会到链接区域的offet为0的位置,而不会进入"test"文案区域内。看slatejs的代码会发现它是故意这么设计的。
这会导致什么问题呢?一方面与用户的默认行为不一致,一方面由于slatejs自身的schema检查以及normalize机制,按删除键会报错(官方标为了bug,还未修复,https://github.com/ianstormtaylor/slate/issues/2621)。
目前是在我们自己的插件内hack处理的,按arrow-left的时候帮他移到前一个区域的末尾。
enter问题
比如如下的清况按下enter键:
slate的变现如下图,光标居然没有下移....
也是只能自己在外部黑,目前很担心slatejs里面还有大量类似的bug行为存在。
边界case很多
之前添加了一个使用率很低的hr组件,没想到这个小组件居然耗了我差不多1天时间,因为处理了非常多的的边界行为:
- 当前block有内容时icon方式新增以及回退
- 当前block无内容时icon方式新增以及回退
- 当前block无内容mardown方式新增以及回退
- 全文无内容markdown方式新增以及回退
- 全文无内容icon方式新增
- hr内部enter,回退以及键盘输入操作
- hr下一行开头的回退操作
- blockquote,list这种block内部创建hr我会把hr创建在document那层
对于上述这些行为都有细微的处理差异,难度不高,可是还是比较繁琐。处理完我可以说我们hr这个组件的体验是比语雀好的~(绝大多数功能在还没有交互搞之前我们是照着语雀做的)
虽然现在编辑器大块的功能已经比较丰富了,但是距离稳定应该还有很多需要修复的地方,总有一些特殊操作能搞挂编辑器。包括编辑器的json被搞挂之后怎么办我们其实还没有考虑清楚。按我现在理解可能得把每一个组件的schema定义以及normalize方式定义的非常清楚能解80%的问题,同时我们可能得借助localstorage之类的做一些恢复的能力。
组内某大佬比较规范,他提过组件开发过程得补测试用例。这里得吹一波slatejs,slate这层数据结构的方式非常适合写测例。不过暂时还没空写...
插件权限过高
看到上面讲解的slatejs的执行顺序可以知道每个插件其实都具有最高权限,而且非常依赖执行顺序。也就是说别人一个不负责任的插件可以把你后面定义的行为全部截胡。最开始出于对它内部实现的不熟悉,很容易出现插件相互影响的情况。
但是这个问题我们既是受害者,也是受益者,得益于他插件的全控制能力,我们可以控制所有行为。
灵活性带来了功能的强大,同时增加了项目可能的复杂度风险。
(slate的插件权限好像是慢慢放大的,之前的权限还没现在这么大。)
对于这点我们并没有额外做限制,至少在完全熟悉之前不会做,但是目前我们在slate代码里添加了一个event trace功能,帮助我们开发定位问题。
通过在 console 添加 localStorage.debug = 'slate:editor:run' 可以看到你想处理的事件被哪个插件吞掉了。
性能问题?
加上了一堆功能之后,编辑器操作突然变卡。具体变现为一直按着 enter 或者 delete 页面会卡顿。
立刻怀疑是 slatejs 运行的问题,怀疑是我们定义了太多插件,顺序运行下来太过耗时。利用performance.now去排查,发现keydown在slatejs里面的执行时间只有20ms以内。
最终找到居然是toolbar的icon导致的,每次keyDown事件icon们都在计算自己的状态,同时渲染自己导致页面卡顿,于是给toolbar组件加了一层针对keydown的debounce。
虽然是虚惊一场,不过借此机会了解了一下slatejs的渲染也还是不错的。slatejs的计算全在js里面,不存在对于dom的任何操作,应该整体性能还是不差的。
再去看下语雀(养成习惯了,一出问题就会看下它是怎么处理的),果然它也做了同样的选择,在用户停止输入一段时间之后toolbar区域才会做出反应。
总结
语雀做的真好,牛逼。wiki提供插入目录的能力不错,编辑体验不是很好。
对于slatejs还没有嚼透,虽然整理了一遍清楚多了,不过还没有时间深入研究一些其他的编辑器方案进行对比。slatejs的selection部分有坑,还没踩平。
项目用到的其他的内容包括node技术以及协同技术还没有时间深入了解。期待下个月的总结吧~