document.execCommand的探索

前端

前言

近期在github上看到一些编辑器相关的库,就顺势利用一点点时间探索了下一些编辑器和document.execCommand这个原生api(如果有对这个api不熟悉的可以看下相应的文档-MDN),简单的概括下就是允许运行命令来操纵可编辑内容区域(contenteditable)的元素。 本文主要介绍下这个api的不足替代的方案。

思考的方向

  • document.execCommand的缺陷
  • 当前主流编辑器对于document.execCommand的使用情况
  • 探索自定义命令的编辑器

execCommand存在的问题

想必都知道现在市面上很多编辑器(富文本编辑器)其核心的编辑能力都是基于这个api,但是这个api在我了解下存在两个比较大的问题:兼容性问题扩展性问题。

兼容性问题

document.execCommand,MDN明确声明,这是一个Obsolete的特性,浏览器厂商可以不再支持(是一个已经废弃的api)。

并且各个浏览器目前的兼容性都不好。下面是caniuse的兼容性:

从上面的兼容性来看,目前各大浏览器的兼容性也很差。比如现在市面上比较流行的编辑器QuillUEditor, wangEditor都是基于这个api去实现的。如果要将编辑器做到更高的层次,就必须突破这个节点。

扩展性问题

针对于document.execCommand所能提供的能力还是有点局限,不够已经能够满足大部分需求。但是如果用户需要扩展,就比较棘手,比如用户自定义一些行为等等。

编辑器的能力分析

现在主流的编辑器总的来说对应的是三种类型,不同的类型其展现的能力、可扩展性、复杂性都各不相同。如下表展示的,从L0 -> L1 -> L2也可以说是:站在浏览器的站在浏览器的头上站在浏览器的肩上站在浏览器的脚上,逐渐的脱离浏览器,并且向现代化靠近。典型的就是draft.js、slate。

类型描述代表优劣
L0
1. 基于浏览器的contenteditable富文本输入框
1. 使用document.execCommand操作命令
轻量级编辑器
典型代表:wangEditor
优:短时间内快速研发劣:可定制空间非常有限
L1
1. 基于浏览器的contentditable富文本输入框
1. 自主实现操作命令
典型代表:draft.js(初始化了一个编辑区)、TinyMCE等优:在浏览器的基础上,能满足大部分业务劣:无法突破浏览器本身的排版效果
L2
1. 自主实现富文本输入框
1. 只依赖少量浏览器API
Google Doc、其他的还有(Office Word Online、WPS文字在线版)优:都自己实现,可控度都掌握在开发者劣:技术难度大

Google Doc(可以参考:drive.googleblog.com/2010/05/wha…),由contentEditable转到了监听用户交互同时在DOM上通过div等标签绘制的方案。

document.execCommand使用案例

针对于document.execCommand的使用情况,以wangEditor为例子来分析。wangEditor核心的文件是command.ts来做命令的封装,下面展示一些关键的伪代码:

/**
* 执行操作的命令
* @param name name
* @param value value
*/
public do(name: string, value?: string | DomElement): void {
  // TODO
  
	switch (name) {
    case 'insertHTML':  // 插入HTML字符串
        this.insertHTML(value as string)
        break
    case 'insertElem':  // 插入DOM元素
        this.insertElem(value as DomElement)
        break
    default:
        // 默认 command 执行浏览器默认的指令
        this.execCommand(name, value as string)
        break
	}
  
  // TODO
}

/**
* 插入 html
* @param html html 字符串
*/
private insertHTML(html: string): void {
	// inserHTML 在IE下是没有的,需要兼容处理
  
  if(isNoIE) {
  	this.execCommand('insertHTML', html)
  } else {
		// 通过window.selection获取选取,然后利用insertNode来实现在IE下的insertHTML
  
  	range.deleteContents()
  	range.insertNode()
    
	}
}


复制代码

在插入元素时做了兼容,其余的都是按原生的浏览器api来实现,但是基于这个层面,如果用户想自定义一些事件或者行为,这个设计就显得很局限了。也就是没有突破编辑器的L0,到达L1的能力。

初步探索

基于上面的一些分析,了解了document.execCommand的不足和一些使用案例。是不是有人看到这里就会想:既然这个api废弃的并且兼容性不好,有没有可以替代的方案。当然是有的,比如浏览器api: Clipboard, 但是兼容性并不是很好,至少对IE是很不有好的。

那有没有其他方案,答案是肯定的。接下来就看下deckdeckgo这个库怎么去自主实现命令。带着这个目的一起探索下这个处于L1阶段的编辑器。

deckdeckgo

幻灯片的设计还挺新颖

官方介绍:DeckDeckGo - 用于演示的开源Web编辑器。以幻灯片的形式展示编辑器,这是一个国外的开源项目,其基于@stencil/core做的组件渲染、和事件的管理,并且有移动和PC端,基本的一些操作都涵盖了。可以归纳到L1能力的编辑器,其借助contentditable的能力,加上自定义的命令。

加粗

以加粗为例,下面是一个将加粗源码逻辑抽象出来的简单流程图:
说明下:

  1. 用户点击加粗按钮,触发component组件action-button内部的原生button点击事件
  2. 事件通过执行被装饰过的句柄emit外部传进来的props事件(对如何触发web component定义事件的可以自行了解下)
  3. 接下来就是一样的逻辑,依次往复,直到最外层的inline-editor调用了execCommand, 这个就是里面有两个句柄一个是:execCommandStyle(修改样式的自定义指令),另一个是:execCommandList。

注意: 这里的事件装饰是由@stencil/core提供的,感兴趣的可以自行去看下这个库。相比于Web component,@stencil/core内部JSX,渲染的性能会比较高。

execCommandStyle

这个函数的内容也不多,主要基于两个函数: updateSelection && replaceSelection, 一个是更新选区,一个是替换选区。

updateSelection

这个方法主要做的事情就是: 查找是否有选区是否有容器,容器上是否有对应的样式,有就直接更新样式。 在上面流程图可以看到detail下面有个style属性,会通过这个属性去判。下面是源码:

  
if (sameSelection && !DeckdeckgoInlineEditorUtils.isContainer(containers, container) && container.style[action.style] !== undefined) {
    await updateSelection(container, action, containers);

    return;
  }

async function updateSelection(container: HTMLElement, action: ExecCommandStyle, containers: string) {
  container.style[action.style] = await getStyleValue(container, action, containers);

  await cleanChildren(action, container);
}

getStyleValue做的事情很简单,就是获取对应的值。 内部做了判断样式继承的操作。

复制代码

updateSelection更新的方式,省略了一直创建dom,这就是document.execCommand会干的事情。 但是如果没有找到,又该怎么处理。就是replaceSelection的事情了。

replaceSelection

这个方法需要配合选区的range, 具体怎么配合看下,下面的伪代码:


async function replaceSelection(container: HTMLElement, action: ExecCommandStyle, selection: Selection, containers: string) {
  const range: Range = selection.getRangeAt(0);

  const fragment: DocumentFragment = range.extractContents();

  const span: HTMLSpanElement = await createSpan(container, action, containers); // 附加指令
  
  span.appendChild(fragment);

  await cleanChildren(action, span);  // 如果span下面有子元素,需要将所有子元素对应的action.style清空,因为要实现继承。
  await flattenChildren(action, span); // 打平span下,没有样式的子元素

  range.insertNode(span);
  selection.selectAllChildren(span);
}


复制代码

replaceSelection将选区的内容利用 range.extractContents() 剪切到文档碎片里,然后创建span,在创建span时,就将 指令 里面的 action.style & action.value设置到span上,再将文档碎片塞到span里,再做一些清理操作,最后insertNode到range里。

总结

期间有稍微了解了下draft.js,它并不是开箱即用的,只是提供了很多工具去创建编辑器。 其基于描述的形式,将html描述成一个数据结构,直接按照 React 的模式去做的,通过拦截光标和键盘等操作,然后更新到内部 immutable 的 state 上面,然后在 render 出来。通过 immutable 来提升渲染性能。个人觉得这个方式,编辑器可能会偏重,复杂度可能也会陡升。

对于tinyMCE看的不是很多,但是这个的思想以block为主,也是类似于draft.js,对一个元素进行抽象描述。

最后,这篇内容可能有点问题,见解可能还比较短浅,就当是抛砖引玉,如果文章写得哪里有问题,希望各位看官指点一二。


参考资料:

  1. caniuse.com/?search=doc…
  2. developer.mozilla.org/zh-CN/docs/…
  3. www.zhihu.com/question/40…
  4. github.com/deckgo/deck…
  5. github.com/tinymce/tin…
  6. github.com/facebook/dr…
分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改