【原创】简易富文本编辑器实现原理初探

最近在工作上遇到一个使用自定义编辑器的需求。虽然市面上有很多优秀的开源或非开源的富文本编辑器可供选择,诸如:UEditor、CKEditor,以及随React而生的Draft-js等。但是结合项目中的使用场景——只需要支持文本内容输入、插入表情、插入图片、提到(Mention)功能以及指定代码块即可,通过调研后综合比较得出以下结论:UEditor、CKEditor都比较重量,使用在该场景完全是大材小用,投入产出不成正比;唯一可以基本支持该需求的就只有基于Draft-js进行二次开发,理由如下图:

首先,draft-js足够底层,轻量。其次,有现成的MentionEmojiImage插件可以支持大部分业务需求。

但是,通过深度调研后发现Draft-js并不能完全满足我们的需要,例如:

  • Mention插件并不支持输入输出结构的定制,使用不够灵活
  • 插入自定义代码块功能需要不小的二次开发成本
  • Draft-js为React而生,前端技术发展极快,后续底层技术栈如果需要升级,此处将是一个大坑

再加上,本人主观的一点点好奇心——想一探富文本编辑器的实现原理,所以最终还是决定使用原生JS自己开发一个简易富文本编辑器来解决业务问题,下面就跟大家分享一下开发过程中的一些收获。

通常我们在网页里面输入内容通常会使用<input/><textarea/>控件,二者的区别在于<input/>只支持单行输入,而<textarea/>则支持多行输入及内容滚动。而二者的弊端也非常明显:支持纯文本输入,不能给内容增加样式,不能输入自定义表情和图片等富媒体内容。

那么这时候富文本编辑器的诉求就产生了。我们知道,HTML5提供了contenteditable属性来控制一个元素是否支持内容修改,其实在此之前html中还有另一个元素可以支持内容可编辑——那就是iframe标签。只要在脚本中执行iframe.contentWindow.document.body.contentEditable = true,就可以让该iframe标签可以编辑。但是,随着前端的快速发展,以及HTML5的逐渐普及,使用iframe实现富文本编辑器的方式正在渐渐退出舞台,而由contenteditable属性取而代之。

有了前面这些知识,现在要在页面中实现一个编辑器简直太方便了:<div id="J_Editor" contenteditable></div>这样就可以在这个div标签内编辑内容了。来看一个动画演示:

但这远远不够,这离我们的目标太远了。接着我们先对这个基本编辑功能进行一点点升级——增加一个支持现有内容写入编辑器的功能。

既然,编辑器是由一个div元素构成,而我们知道要给一个html元素填充内容,最简单的就是我们可以给这个元素的innerHTML属性赋值:editor.innerHTML = "Hello world!"。但是,现在我们还有另一种方法来实现相同的功能——document.execCommand方法,结合insertHtmldelete命令,以及SelectionRange对象。来看一个实例:

好了,这样一来,一个最原始的编辑器就完成了,但是我们说了,我们还需要支持插入表情和图片。其实不管是自定义表情,还是图片我们都可以统一当做图片来处理,因为在页面中都是通过img标签来体现。那么接下来,我们就来看看如何实现在编辑器中插入图片。其实,和innerHtml类似,document.execCommand方法还提供了insertImage命令。那么我们就依样画葫芦,直接看演示:

这样,我们插入图片的功能就实现了。但是这里有个问题——使用insertImage插入的图片没有标示,如果是从Web端发送到客户端上的自定义表情,解析的时候无法区分是表情,还是图片。如图:

因此,在特殊场景下,我们有必要对插入的图片做区分,那么使用insertImage就不能满足我们的需求了,然而,转念一想,其实img标签就是一个html元素,那我们是不是同样可以使用insertHtml命令进行操作呢?

这样一来,只要插入表情和插入图片写入img表情的type属性不同,我们就能轻松做出辨别了。至此,编辑器的功能又得到了进一步的强化,还剩下一个拦路虎——Mention功能。

Mention功能,常见于IM业务,用于在群聊中显示“xxx 提到了你”。而我们通常使用过程中,一般都是从编辑器中输入“@”符号开始,选择你要提醒的人。而对于界面交互而言,就是要在编辑器内容发生变化的时候检测光标前面是否是“@”符号,从而触发选择人的逻辑。分析至此我们就可以开始编写代码了:

好了,到这里我们的目标功能好像都完成了。但是,真的就完成了吗?

其实,并没有,真心实践敲代码并测试的码友应该会发现一个比较明显的问题:当出现被@的对象后,先点击其他地方,然后再点击要@的人,此时插入编辑器的内容会默认被插入到编辑器开始的地方,而不是前一次编辑结束的位置。其实,并不止@这里有问题,前面插入内容、图片和表情的地方同样会有这个问题。只要是先让编辑器失去焦点,然后再插入内容,就会被放到编辑器的开头。

显然,这体验并不好,也不符合使用者的习惯,这样的编辑器拿去给用户使用,估计产品经理是会让你见不到明天的太阳的。那么怎么办呢?

我的解决办法是:当编辑器失去焦点的时候,存储当前的光标位置,而当编辑器获得焦点的时候,判断是否有被存储的光标,有则设置到对应位置,否则默认在编辑器开始位置,具体代码如下:

至此,基本功能就已经完成了,但是还有很多优化的空间,比如:插入图片的尺寸控制、@应该作为一个整体块处理,但是目前仍可以部分编辑、部分删除,且如果@块有特殊样式,在删掉一半再往后编辑的时候还会影响新增内容的样式等等。

由于篇幅问题,后面的问题如何处理就不再继续扩展了,有时间再通过新的文章进行升级。