Slate.js-⾰命性的富⽂本编辑框架
相信很多同学即便没有接触过富⽂本编辑领域,也⼀定听说过【富⽂本编辑是天坑,千万不要碰】的说法——是的,富⽂本编辑是天坑,但能很好地帮助你。下⾯会介绍富⽂本编辑的复杂度所在,以及 Slate 的解决⽅式。
背景
富⽂本编辑领域和常规的前端开发相⽐,有个⾮常微妙的区别:在这个领域⾥,最流⾏的解决⽅案往往是相当【重】的。为什么在⼀贯推崇【越轻越好】的前端社区,轻量级的编辑器没有成为主流呢?这要从编辑器的实现原理说起。
在浏览器中,实现富⽂本编辑的原理⼤致可分为下⾯这三种:
1. 在 <textarea> 上定位各种样式。这是 Facebook 早期评论系统所使⽤的。
2. 实现⾃⼰的布局引擎,连闪烁的光标都是通过 <div> 控制的。这是 Google Docs 所使⽤的。
3. 使⽤浏览器原⽣的 ContentEditable 编辑模式。这是绝⼤多数现有富⽂本编辑器所使⽤的。
三种⽅案中,第⼀种连加粗、斜体等操作都很难⽀持,已经基本弃⽤;第⼆种的⼯作量⾮常巨⼤,只有⾕歌、微软这样能够⾃⼰造浏览器的巨头才能玩得好;对于最后⼀种,如果你不了解 contenteditable,你可以打开任意⼀个⽹站,在它的 <body> 标签⾥加上这个属性,然后看看它是怎样变⾝为⼀个华丽的编辑器的?看起来这个⽅式⽐前两种都要靠谱许多,浏览器已经替你处理好了快捷键、撤销栈、光标、输⼊法、兼容性…很体贴啊!
ContentEditable 之殇
她那时候还太年轻,不知道所有命运赠予的礼物,早已在暗中标好了价格。
——茨威格《断头王后》
天下没有免费的午餐,ContentEditable 也不例外。Medium Editor 的作者写过⼀篇⽂章,介绍了 。⽂中的批评可以归结为⼀句话,即ContentEditable 的数据结构和⾏为缺乏⼀致性。
⽐如,对⼀句【喜迎⼗九⼤】,下⾯的⼏种 HTML 表⽰是完全等效的:
<!--正常-->
<p>喜迎<b>⼗九⼤</b></p>
<!--分离的 b 标签-->
<p>喜迎<b>⼗</b><b>九⼤</b></p>
<!--嵌套的 b 标签-->
<p>喜迎<b><b>⼗九⼤</b></b></p>
<!--空的 b 标签-->
<p>喜迎<b>⼗</b><b></b><b>九⼤</b></p>
<!--span 代替 b 标签-->
jquery框架原理<p>喜迎<span >⼗九⼤</span></p>复制代码
它们虽然看上去⼀样,但对它们的编辑⾏为会产⽣显著的区别。⽽在使⽤ ContentEditable 时,浏览器经常会⾃动插⼊这些垃圾标签。
再⽐如,对于⼀句【喜迎⼗九⼤】,⼀次简单的换⾏操作可能产⽣这样的结果:
<p>喜迎<br/>⼗九⼤</p> <!--插⼊ br 标签-->
<p>喜迎</p></p>⼗九⼤</p> <!--分割 p 标签-->复制代码
不同浏览器哪怕对于简单的换⾏操作,其⾏为也是存在各种分歧的。这样⼀来,在 Chrome 中编辑的⽂档,在 Firefox 中打开继续编辑后,就很有可能出现 bug,⽽这些 bug 并不是简单的样式问题,⽽是会破坏数据结构的恶性 bug。
社区中有不少所谓的【超轻量级编辑器】,它们⼏乎就只是 ContentEditable 加了⼀层美化的壳。这种编辑器基本完全依赖浏览器的原⽣⾏为,不会顾及 ContentEditable 对数据结构的破坏,基于它们去实现⾼级的编辑功能是⼗分困难的。如果抱着【轻量的东西更漂亮】的思路选择它们,决定前请务必三思。
是应⽤,是类库,还是框架?
另⼀个在富⽂本编辑领域较为尴尬的问题,是编辑器的定位。⼀般⽽⾔,前端领域接触的各种项⽬,不外乎以下三种:
应⽤ Application
应⽤泛指包含了界⾯和交互逻辑的项⽬,⽐如各种管理后台系统。
类库 Library
类库提供 API 供⽤户调⽤来开发应⽤,但并不影响应⽤的代码架构,⽐如 jQuery 和 React:
jQuery: The Write Less, Do More, JavaScript Library
React is a JavaScript library for building user interfaces.
也许不少同学对 React 有【全家桶】的偏见,在这⾥再强调⼀遍,React 本⾝仅仅是个视图层,需要和许多类库结合,才能⽤于开发应⽤。
框架 Framework
框架同样提供 API,但它对应⽤代码有很强的侵⼊性,需要⽤户按照框架的⽅式,提供代码供框架运⾏。Vue 和 Angular 都是典型的框架:
Vue.js - The Progressive
JavaScript Framework
AngularJS — Superheroic JavaScript MVW Framework
那么,富⽂本编辑器属于上⾯的哪⼀种呢?每个编辑器项⽬都会说⾃⼰的定位是 Editor,但 Editor 是
应⽤、是类库、还是框架呢?许多主打【开箱即⽤】的编辑器,已经集成了许多样式和交互逻辑,实际上已经是⼀个应⽤了。
这⾥的问题在于,应⽤的定制性是最差的。因⽽,在需要定制不同的编辑体验时,许多【开箱即⽤】的编辑器很难通过简单的配置来满⾜需求。这时,往往需要使⽤各种奇技淫巧,或再学习⼀套编辑器⾃⾝笨拙的插件机制。
Vue 和 Angular 这样的框架,在易⽤性上是有⼝皆碑的。那么,富⽂本编辑领域,有没有这样的框架呢?有的,并且 Slate 还不是第⼀个。对编辑器有所了解的同学可能知道,Facebook 出品的 就是⼀个这样的编辑框架,能让你使⽤ React 技术栈定制⾃⼰的编辑器。既然Draft.js 已经⾮常出⾊,那么 Slate 与之相⽐,有什么创新之处呢?⽽对于上⽂中 ContentEditable 的各种问题,Slate ⼜是如何解决的呢?让我们来看看吧。
介绍 Slate
Slate 并⾮⼀个编辑器应⽤,⽽是⼀套在 React 和 Immutable 的坚实基础上,⽤于操作富⽂本数据的强⼤框架。基于 Slate 实现⼀个富⽂本编辑器,只相当于使⽤ React(视图层)+ Immutable(数据层)开发⼀个普通 Web 应⽤。下图中展⽰了⼀个基于 Slate 实现的编辑器架构,数据的流动⾮常简单易懂:
editor-arch
图中,左侧视图层的 Toolbar ⼯具栏和 Editor 内的各种 Node 都是纯粹的 React 组件,右侧的模型层则⼤量应⽤了 Slate 所提供的⽀持。下⾯,我们简单介绍⼀下这个架构中的⼏个关键⾓⾊。
Immutable,迄今最理想的数据结构
我们知道,JS 对象的属性是可以随意赋值的,也就是 mutable 可变的。⽽相对地,不可变的数据类型不允许随意赋值,每次通过Immutable API 的修改,都会⽣成⼀个新的引⽤。
看起来这并不算什么,和每次修改都全量复制⼀份数据⽐起来并没有什么区别。但 Immutable 的强⼤之处,在于不同引⽤之间,相同的部分是完全共享的。这也就意味着,对⼀棵基于 Immutable 的复杂⽂档树,即便只改变了某⼀⽚叶⼦节点,也会⽣成⼀棵新树,但这棵新树除了那⼀⽚叶⼦节点外,所有内容都是和原有的树共享的。
这和富⽂本编辑有什么关系呢?我们知道,编辑器的【撤销】其实是⼀个难度⾮常⼤的功能,许多定制了撤销功能的编辑器,很容易出现撤销前后的状态不⼀致的情况。但有了 Immutable 后,每次编辑都会⽣成⼀个全新的编辑器状态,只需简单地在不同状态之间切换,就能轻松地实现撤销和重做操作。并且,Immutable 也完全⽀持复杂的嵌套来表达⽂档的树形结构。可以说,Immutable 天⽣适合
⽤于实现富⽂本编辑的模型层。在 Slate 和 Draft.js 中,富⽂本数据就是对 Immutable 的⼀层封装,从⽽⾃带了对撤销操作的⽀持,不需额外编码实现。在这⽅⾯,Slate 相⽐ Draft.js 的⼀个重要加分项是它⽀持嵌套的数据结构,对表格等复杂内容的编辑提供了良好的⽀持。
React,迄今最合适的视图层
说到 Immutable 就不能不提 React,⽬前 这个不可变数据的 JS 库就是 Facebook ⾃⼰实现的,并且⼀开始引⼊ Immutable 的⽬的也不是为了撤销,⽽是为了优化 React 应⽤的性能。可以说,Immutable 和 React 有着天⽣的默契。
那么,为什么我们需要 React 呢?⽬前,除了 Slate 和 Draft.js 外⼏乎所有的编辑器⽅案,在需要定制编辑节点(如公式、图表等)时,要么需要接触和 DOM 紧密耦合的编辑器插件概念,要么只能使⽤编辑器内置的功能。这种做法在学习成本和效率上都不是最优的。
设想⼀下,如果编辑器中的编辑内容,全部都能以 React 组件的形式(如标题⽤ Heading 组件,段落⽤ Paragraph 组件等)来实现,那么富⽂本编辑的门槛还会这么⾼吗?从 Immutable 数据映射到⼀个个 React 组件,是已经在许多 Web 应⽤中经历过考验的成熟模式。⽽在这种架构下,ContentEditable 那些令⼈望⽽⽣畏的问题也能得到很好的解决:只需要为 React 组件增加 contentEditable 属性,⽽后对各种按键、点击等事件 preventDefault,由框架决定事件对 Immutable
的变换,最后⽣成新状态按需触发重绘即可!
这种⽅案下,实现⼀个编辑器不再需要精通 DOM 的专家,难度⼤⼤降低了。即便像本⽂作者这样仅仅熟悉 React,对前端只有⼀年多经验的普通开发者,也有能⼒开发⾃⼰的编辑器了。在此稍微夹带⼀些私货:
在富⽂本编辑领域,React + Immutable 这种在全局粒度全量地更改状态,⽽后按需更新组件的⽅案,⽐起 Vue 这样基于依赖追踪细粒度地更新组件的⽅案,是更有优势的。Vue 直接 mutate 数据的⽅式在原理上并不利于实现撤销与回退,并且函数式组件 VNode 的 API 也没有 React 这么直观易⽤(Vue 2.5 有改善,但差距仍然存在)。⽬前,Vue 社区还没有类似的框架出现,这个场景也是 React 技术栈相⽐ Vue 的⼀个闪亮之处。
不过,Draft.js 和 Slate 都实现了对 React 的⽀持。虽然 Slate 定制节点的 API 更⽅便⼀些,但这也不是决定性的优势。那么 Slate 的特殊之处⼜哪呢?
Slate,迄今最灵活的 Controller
从前⾯的介绍中,我们看到相当多创新之处都是来⾃ Draft.js 的。那么,Slate ⼜有什么独特之处呢?
Draft.js 有 Immutable 作为 Model,有 React 作为 View,但在使⽤它实现编辑器的过程中,你可能会
感觉这⽐起⼀般的应⽤开发来,负担还是有些沉重,或者说少了⼀点什么东西。嗯,这个东西也许就是你熟悉的 Controller。
即便在前端轮⼦满天飞的今天,UI 应⽤的架构 MVC 也不会过时,⽽是演化为了 MVVM 甚⾄ M-V-Whatever 的架构。编辑器应⽤同样是个 UI 应⽤,我们同样需要⼀种机制,将 Model 和 View 连接起来。
这可能不是 Draft.js 的闪光之处,它的⽂档变换 API 使⽤起来⽐较沉重,并且对 EditorState 的修改存在着较多限制。⽽ Slate 则提供了更加灵活的概念,来连接 Model 与 View。我们简单介绍⼀下 Slate 中编辑操作发⽣时的处理流程:
1. ⽤户在编辑器光标所在的 Node 内按键,触发事件。
2. 根据按键的键值,分发不同的 Change,如换⾏、加粗等。
3. Change 修改 State,⽣成新 State。
4. 新 State 经过 Schema 校验后,渲染到编辑器内,按需更新相应的 Node。
整个流程中最核⼼的机制可概括为⼀个公式:state.change().change(),Change 是⼀个⾮常优雅的 A
PI,所有的变换都是都通过 Change 对象实现的。⽐如,⽤户先插⼊了⽂本,⼜删除了另⼀个段落,这时对⽂档的变更就可以抽象为:
state.change().insertText().deleteBlock()复制代码
每个操作都是链式调⽤!在协同编辑的场景下,来⾃不同⽤户的操作其实也可以归结为这样对 State 的链式调⽤,这也让基于 Slate 实现协同编辑成为了可能。另⼀⽅⾯,每⼀个 Change 链式调⽤中的 API 都可实现为纯函数,⽽后通过 Slate 的 call API 来链式执⾏,这也让编写⾃⼰的 Change 并添加单元测试成为了可能。
这种优雅地处理编辑操作的⽅式,使得 Slate 能够更简单地将 Model 与 View 连接起来,实现对富⽂本数据的复杂操作。另外,Slate ⽀持⾃定义对状态的 Schema 校验规则,可以添加⼀些形如【第⼀个节点必须是 Heading 节点】或者【图⽚节点必须包含 src 属性】的校验规则,并对异常数据进⾏过滤。
当然,Slate 中并没有 Controller 的概念,不过实际上,基于 Slate 编写的富⽂本编辑 Change 操作,和编写传统 MVC 应⽤中Controller 逻辑的体验有些接近。换句话说,Slate 把编写复杂操作逻辑的难度,降低到了编写 Change 函数的⽔平。在这⼀点上,Slate 的架构是⼗分易⽤的。
总结
在富⽂本编辑领域,Slate 是⼀个后起之秀。不过在推出迄今的短短⼀年内,它的社区贡献者数量已经和 Draft.js 甚⾄ Vue 接近,达到了百⼈级别。并且,它的 Issue 和 PR 处理⽐ Draft.js 更加及时,作者对新想法也更加开放,迭代更加活跃。
Slate 的许多核⼼特性是从其他优秀编辑器项⽬中借鉴的,如其 Immutable 数据层与框架理念来⾃ Draft.js、Schema 与 Change 概念来⾃ ProseMirror 等。虽然它的许多闪光点单独看来并⾮独树⼀帜,但在宏观层⾯上做到了博采众长(听起来和 Vue 有些接近?)。⽬前它还处于快速的迭代中,对有兴趣参与的同学,成为贡献者的机会很多哦。
Resources

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。