深刻浅出contenteditable富文本编辑器

富文本编辑器一直是前端领域的一个天坑,但若不是深刻接触编辑器开发的工程师,却不必定清楚富文本编辑器到底坑在哪里,做为有幸和编辑器打了一年交道的前端,今天来聊聊Web富文本编辑器的那些事。前端

一般当咱们拿到一个带有富文本编辑器的需求时,咱们首先要理清这个需求的使用场景,而后咱们能够为这些具体的业务场景选择一款合适的开源富文本编辑器,进行定制开发react

看看目前市面上咱们能够选择的开源编辑器的实现方式,大体分为两种:算法

第一种是基于THML DOM的Contenteditable属性来实现,表明如UEditor、tinyMec、Quillcanvas

这是使用最久的传统富文本编辑器实现方式,这种实现方式的优点很明显,contenteditable是浏览器Dom的一个原生属性,值为true时表示该元素变为可编辑状态。所以原生就直接支持不少内容编辑操做,包括光标位移、内容选择的行为、键盘事件(如方向键控制光标)等等,甚至是富文本编辑所须要用到的绝大部分实现(document.execCommand跨域

这些原生支持使得性能和输入体验都很是棒,在此基础之上进行二次开发看起来至关容易,辅以iframe技术,能够将编辑器放在一个独立的docment对象下,与页面的document对象分离浏览器

缺点也很是要命,以why-contenteditable-is-terrible为表明的文章,几乎说明了一切,总结下来无非是:浏览器兼容性差、用户行为难以控制、难以抽象编辑器内的视图逻辑关系并将它们映射到代码模型中(试想一下你要抽象一个变化规则不可掌控的可变Dom结构的逻辑关系)、光标(选区)的视觉位置与逻辑位置可能不吻合安全

第二种是基于自定义Model的实现,表明如:draft.js、trix服务器

这种实现方式,简单的来讲就是定义一套编辑器内部使用的数据结构(model),与用户在编辑器内所见的Dom视图相映射;经过捕获用户的操做行为,由原先的直接操做Dom,改成更新数据结构状态,再将更新后的状态映射至视图的方式,来实现编辑器的所见即所得,显然操做行为对数据结构的更新是很是可控的微信

这是一种十分先进的编辑器设计理念,它几乎抛弃了contenteditable的特性,这也意味着contenteditable所带来的反作用都消失了数据结构

这种实现方式的另外一个好处在于,它能够适用于多人在线协做的业务场景。因为用户操做实际影响的是内部的数据结构,且每次操做产生的结果都被控制在必定范围内,能够较为容易的经过diff算法来合并短期内的屡次修改。

看起来这显然是一个比contenteditable编辑器更好的选择

遗憾的是目前这种实现方式的开源编辑器可供选择的并很少,实际状况中可能并不能知足全部的开发场景,好比draft.js只能基于react,而如trix这样相对小众的项目在国内则有些水土不服(别问我怎么知道的),若是你目前使用的不是react或者就想要一个开箱即用的编辑器去作定制,又没有条件本身造个轮子,在不须要考虑多人协做场景的状况下,咱们依然能够从contenteditable编辑器上寻求突破


回过头来看看contenteditable编辑器,现实状况其实也没有那么糟糕,毕竟这是使用最为普遍的一种实现方式,拥有大量的实践,这些成熟的开源项目早已为咱们提供了解决方案

来看看它们是怎么作的吧:

以国内熟知的UEditor为例(也是微信公众号所用的编辑器),它的核心提供了这么几样东西

dtd规则:用来规定编辑器内的dom嵌套规则,和过滤方法搭配使用,避免出现<span><p>xxx</p></span>

uNode对象:根据HTML DOM抽象而成的文档模型对象,抽象了dom的属性和层级关系,保留了一些dom操做的方法(与第二种实现方式的自定义model相似),将编辑器内容的HTML映射过来以后能够很方便的执行规则过滤,如剔除冗余属性和非白名单标签等

Range对象:光标和选区的信息对象,记录了 当前光标(选区)的开始、结束边界的容器节点和偏移量以及当前光标(选区)的闭合状态,还提供了一系列对光标(选区)操做的API

EventBase:提供注册、销毁和触发自定义事件监听器的方法,用来生成一些钩子

execCommand指令集:document.execCommand加强版,执行指令的通用接口,富文本格式操做的核心,提供了一系列指定命令的执行和状态查询方法(如对选区内容执行字体加粗命令、查询当前选区内容是否处于加粗状态)

undoManager:撤销重作的堆栈,记录内容变化过程

domUtils:Dom操做方法集

能够利用上面这些核心方法组合出一些实用的工具,好比在UEditor中很是重要的过滤规则体系,就是利用了eventBase与uNode的组合实现的(经过对eventbase封装了注册规则的方法和执行过滤的方法,参数就是根据编辑器内容的dom转化而来的uNode对象,基于该对象执行具体的过滤)

整个UEditor正是围绕着这些核心方法构建的,而且在此基础上提供了大量的API以便开发者进行定制化的开发,显然做为一个contenteditable编辑器它已经足够成熟了

但在实际的生产环境中,面对不一样的产品需求咱们依然须要处理一些棘手的状况

固定结构内容

一个常见的场景是,固定结构内容,好比图片与图片注释

这就是一个典型的固定结构内容,编辑器中出现了一个不可更改的固定搭配,即图片后面必须跟着注释输入框

来看看要实现这个需求须要考虑哪些要问题

  1. 图片和注释元素必须一对一
  2. 图片和注释元素的位置顺序不能改变
  3. 光标不容许插入到固定结构中间
  4. 光标能够定位在注释元素里
  5. 注释元素里只能放纯文本

contenteditable编辑器的设计原则之一是编辑器内的一切内容皆可自由编辑,而固定结构元素某种程度上违背了这一原则,这会带来不少问题,用户有太多方法能够破坏你预设的结构

一种常见的解决方案是将固定结构的元素包裹在一个不可编辑元素内,并为其中的可交互元素独立设置交互事件(好比点击输入、粘贴内容过滤)

但这还不够,有几个问题:

  1. 编辑器中存在不可编辑元素,会有浏览器兼容性的问题,如火狐浏览器下光标没法正确移动甚至没法删除这个元素
  2. 两个不可编辑器的块级元素在相邻位置时,光标没法插入中间,退格键也会同时删除多个
  3. 复制粘贴这个内容,结构可能会错乱
  4. 其余操做也可能会破坏结构

为了解决上述问题,就须要劫持用户的光标操做(鼠标点击、方向键、退格键),同时设立一套结构规则来检查当前结构是否有错乱

预览一下效果

简而言之,就是经过劫持,判断光标是否处于不可编辑元素的最近位置,符合条件时,用自定义行为代理浏览器默认的选择、删除、复制剪切等行为,再经过对光标移动事件(onSelectionChange)的监听,检查内容中的固定结构是否符合规则(如两个不可编辑元素之间必须至少存在一个用于插入光标的空行标签等)

面对固定结构内容,根据不一样的使用场景,能够有两种解决方案,

对于结构简单但须要进行交互的场景,就像图片注释那样,可使用前面提到的contenteditable=false+行为劫持+过滤规则的方式实现

对于结构较为复杂但不须要进行交互或交互场景较为简单的状况,则可使用canvas来实现

使用canvas的好处是不用担忧结构问题,这彻底就是一张图片,若是在文章发布后须要其余交互也能够在详情页将之转化为正常的DOM结构,缺点是生成的图片须要上传至图片服务器这会占用额外的存储资源

另外一个须要考虑的问题是在safari浏览器下若是画布上有其余域过来的图片,就算设置了容许跨域也会被safari的安全策略block[SecurityError (DOM Exception 18): The operation is insecure.],这就可能须要使用本地占位图来解决

能够根据实际状况来选择解决方案

光标

除此以外,UE也存在一些做为contenteditable编辑器的通病,一个最多见的问题就是光标的视觉位置与逻辑位置的问题

试想有这么一段标红的粗体文本

当咱们将光标放在这段文字的开头,咱们会发现,光标的实际位置有4种可能

  • |<p><span ...
  • <p>|<span class="font-color-red-01">...
  • <p><span class="font-color-red-01">|<strong>...
  • <p><span class="font-color-red-01"><strong>|text content

尽管视觉上的表现没有什么区别,但光标在不一样位置时用户进行某些操做就会产生不一样的结果

本来咱们只是想用退格键将标题上移一行,但因为光标位置在<h1>|...</h1>的位置上,结果将标题的格式也给清空了

解决方法也很简单,仍是 劫持=>判断=>代理,这也是编辑器对光标进行严格控制的通用解决方案

撤销重作堆栈

撤销重作堆栈也是一个问题,正常状况下undoManager会按照一个最小时间段自动记录每一次的内容变化,以便用户撤销回上一步的状态,但这也会带来一些问题,试想一个这样的场景

咱们从本地插入一张图片,这张图片最终须要上传到服务器上,因此咱们先在编辑器内插入了一个占位图,而后开始上传本地图片,等服务器返回了正确的图片地址后,再将正确的图片元素替换到占位图所在的位置上,顺便为图片添加图片注释的组件

那么 (插入占位图 => 上传图片 => 替换占位图 => 添加附加组件)就是一个完整的事件流,若是undoManager单独记录了这个事件流中每个步骤,当用户执行撤销操做的时候就会出现问题

所以咱们须要为自动记录设置一个暂停开关,这样就能够控制undoManager的记录时机

生命周期钩子

为了使编辑器更加稳定,咱们还能够经过eventBase来设计某些事件的生命周期钩子

好比能够分发撤销、重作操做完成先后的回调来作一系列额外的处理,也能够对图片上传的过程分发钩子函数


富文本编辑器的话题其实远不止上面这些,好比如何优雅的与编辑器内元素进行交互,如何由State驱动Dom,如何作移动端的适配,表格操做等等,每一点均可以深刻探讨,篇幅有限,这里就再也不展开


总结一下,基于contenteditable编辑器稳定可靠的定制开发要注意的几个点


  1. 严格控制内容(格式规则检查、内容输入和输出过滤)
  2. 严格控制光标(劫持、检查、代理)
  3. 控制撤销重作堆栈
  4. 为一些关键操做添加生命周期钩子
相关文章
相关标签/搜索