最近迷上了富文本编辑器!

·  阅读 2684

写在前面

image.png 遥想刚入行的时候在一个媒体类的互联网单位,既然是媒体单位,文案的在线编辑,自然而然成为了一个不可绕过的坎

于是web端的富文本编辑器进入了我的世界,要知道富文本这个东西号称前端清华(这是一个戏称,意思是富文本比较难),刚入行是个彩笔的我,那简直是前端黑盒,于是,年轻干劲十足的我开始正式宣告要攻克富文本。

当时我们的系统还在用又重有大的ueditor,由于媒体行业的特殊属性,富文本中需要大量视频音频图盘等内容的处理,所以必须要二次开发,加入一些自定义功能。

于是我们对着这不维护的编辑器,做了恶心的二次开发。虽然能用,用户体验可真是无语凝噎

接着,我就开始了折腾之路,我花了两周时间调研了市面上比较成熟的编辑器,比如,Draft.jsQuill.jsProseMirror、这些优秀的后起之秀,在比如TinyMCECKEditor,这些老牌厂商,以及我们的国产开源 wangEditor

虽然这些编辑器都能做到开箱即用,但是他们都有个通病,都是英文文档,读起来晦涩难懂,很多业务上需要的功能不知如何实现。

wangeditor虽然是中文文档,但是由于在当时,他自身的定位是做一个简单易用的富文本,拓展性有局限,复杂的业务场景无法满足,比如需要自定义视频上传,并且可以支持视频视频针截取、图片裁剪等功能,

于是我们选用了tinymce,首先因为他有很多自定义的拓展功能,在社区中也有人维护这一个中文文档,使用人数也众多

接下来就开始了几年的与富文本的不死不休

富文本深入理解

上回说道,我们虽然选择了tinymce ,但是他由于是全程英文,导致想要从内而外去了解的时候,无从下手,并且当时他们的代码全程找不到几行注释只在关键地方有些标注,还是英文,实在晦涩难懂。

于是想起了咱们的国产之光,wangeditor ,因为他们的功能类似,原理指定也类似,区别的地方其实就是设计思路的一些差别

在开始wangeditor 的心路历程之前,我们先要明确一定,富文本编辑器之所以可以编辑,其实是得益于 HTML contenteditable 属性 正是由于有了它我们才能实现在标签里面编辑内容。这是实现一个富文本的根本

wangeditor

wangeditor 从第三个版本开始我基本也都看过,见证了他一步步的从一js到ts 的重构、从重视拓展性到到面向对象再到现在社区流行的函数式、从必须兼容ie到抛弃兼容ie 、从传统的webpack 的工程化构建到 rollpu +monorepo的工程化构建。可以说wangeditor的折腾历史,就是整个前端领域的发展史。

并且在v5的版本中抛弃历史的包袱,新开一个项目,不再带着镣铐去做架构设计,对于像vdom 等一些成熟的方案的应用,让我耳目一新。

接下来就重点解析一下我我对wangeditor v4和v5版本的一下了解。

V4

刚开始读v4的时候最最高兴的就是他每个方法都有注释,至少咱知道从哪里入手,然后就是经典的面向对象设计。也正式这种设计,让我对面向对象,关注度分离这些概念在脑海中有了具体印象。

在开始之前,我们现在介绍一下什么叫面向对象

面向对象

面向对象编程(OOP),是一种设计思想或者架构风格

他的本质用大佬的话来解释就是:OOP应该体现一种网状结构,这个结构上的每个节点“Object”只能通过“消息”和其他节点通讯。每个节点会有内部隐藏的状态,状态不可以被直接修改,而应该通过消息传递的方式来间接的修改。

正是这种网状结构,才能使得面向对象能够支持庞大复杂的系统,将每个功能点去拆分,实现解耦。这也是他流行的原因。

提起面向对象就不得不提及 封装继承多态

  • “封装”,是想把一段逻辑/概念抽象出来做到相对独立
  • “继承”,是希望通过两个对象的关系来实现代码的复用
  • 多态,是指让一组Object表达同一概念,并展现不同的行为

利用这三点,就能处理大型复杂系统的设计和实现,但是当然他也不是万能的

比如

我一个数据,需要不断的转化和清洗成另外一个格式,那么显然面向对象是不适用的。

那么在我们的富文本中,显然面向对象是适用的。

image-20211103162452222.png

如上图这些单独的功能其实就是网状结构的一个节点,所以wangeditor 是可以用面向对象解决的。

设计思路解析

wangeditor 的设计的可读性是非常高的,整体的设计架构就是利用面向对象的思想,将一个个的功能对象,组合成富文本的功能,主要实现以下功能对象

  • Editor 总编辑器功能的对象
  • Command document.execCommand 的功能封装
  • Text 编辑区域 的一些初始化(包括时间绑定等)
  • Menus 菜单栏 的一些初始化
  • Change 编辑器 change 事件相关
  • History 历史记录相关
  • Mutation MutationObserver 的封装
  • DomElement 封装 DOM 操作

就是通过以上的这些功能对象的组合就呈现了我们现在所看到的开箱即用的富文本编辑器

那么借用当前思路我们在日常的开发中,如果有可以使用oop的场景,是不是也可以像他一样,将各个部分的功能整理归纳,通过组合、继承以及嵌套的方式去快速搭建你的应用呢?

ok,我们的v4就到了这里,接下来开始我们的重头戏,v5

V5

最近在拜读v5的源码,还还整理规划了v5的执行流程的思维导图,当然还没整理完毕,先贴上来

image-20211103170710848.png

如果有感兴趣的同路人,想要的,可以在评论区吆喝一声,留下联系方式,俺等都整理完成了,双手奉上。

在正式介绍v5之前我们需要介绍另一个富文本编辑器Slate

Slate

Slate是一个 完全 可定制的富文本编辑器框架。

他主要做的事情是这三点

  • 它是一个「非常轻量」的解决方案:Slate.js 几乎没有集成任何功能,只是提供了一个插件扩展机制给开发者去实现想要的功能。蝇量级的内核方便读者对编辑器设计 “见微知著”。
  • 它是「视图无关」的:Slate.js 定义了一套脱离 UI 实现的数据模型,考虑到我们不是要再学习一遍 React 或者 Vue,这也能让我们让脱离 UI 的繁文缛节,聚焦到编辑器的模型设计上。
  • 它为「协同编辑」所设计:因为网络条件、客户端硬件、应用架构的限制,早期的一些 Web 富文本编辑器并没有考虑到多人实时协同,Slate.js 的模型设计天然就亲和协同编辑,通过学习 Slate.js,我们也能借道了解基础的多人协作文档工作原理。

以上是大佬们的总结,也是slate 的优势,其实Slate.js的过人之处是提供了一个视图无关的内核 slate-core ,,在我看来,他就是提供了一个编辑器相关的对象,里面保存许多编辑器相关的功能函数,并且可以通过插件的形式拓展当前这个配置对象的功能。这种设计着实跟传统的编辑器大相径庭,这也是v5版本对他情有独钟的根本原因。

他本身的插件机制,也是的v5在插件拓展的设计上不用费劲脑筋。

研究其原理之后,恍然大悟,顶级的技术果然就是这么简单且直接!

// 假设有个配置对象
const edit = {
  edit: () => {
    console.log(1)
  },
}
// 假设有个历史记录插件
function History(e) {
  // 对当前edit 做拓展
  // 利用从右向左执行
  e.edit = function () {
    const edit = e.edit
    console.log(0)
    edit()
  }
  return e
}
const Edit = History(edit)
复制代码

他的第二个好处就是将视图和模型分离,这样就能基于模型,去自己实现渲染视图

这就给v5在他提供内核的基础上去自己实现view 的渲染,从而造出一个开箱即用的编辑器

好,接下来正式跟大家共同学习一下v5内部,首先我准备将v5分为几步学习

  • v5的使用
  • v5的工程化相关
  • v5的内部设计思路
  • v5一些我们日常开发中可借鉴的点

v5的使用

v5延续了v4的优良传统,同样的也是开箱即用,我们只需要在使用需要的地方初始化当前的工具条编辑区即可,具体初始化方法请参考文档,我们就不在赘述,在这里我们说一下自定义配置项这块。 在v5中是非常重视用户的自定义配置,在源码中,他会对用户的配置和默认配置做一个合并,生成最终的配置,这里我就介绍一下一些我们可配置的点

editorConfig

editorConfig是整体需要传入的配置 他里面主要是一些回调回调方法。

比如 onchange ,onblur,onfocus 等等 ,如果需要改变默认的工具条配置则需要传入MENU_CONF

MENU_CONF

MENU_CONF是我们用户配置的地方,通过他我们能将传入的用户配置和默认配置合并

展望

有可能将来还能拓展用户自定义插件等内容,是的用户有更多的可定制空间

v5的工程化相关

  • monorepo

在2021年的今天,monorepo的多包管理方式流行的今天,v5也是紧跟潮流,他将整个编辑器 拆分为:core模块(核心模块)、editor模块(主模块)、basic-modules(默认的常用menu)、code-highlight(代码高高亮)、list-module(列表)、table-module(表格)、upload-image-module(图片上传)、video-module(视频上传) 等模块

  • rollup

    整体项目使用rollup 构建,减少代码的体积的同时,也增加了可读性(用过webpack的都懂,那个启动器函数写的鬼都看不懂)

    增加了umd 和esmodule 的两种包类型

  • css预处理器

    less 的预处理器解决方案,配合svg 的图标

  • 代码规范

    eslint+prettier的双重保障

  • 模块化

    使用 TypeScript 开发,提供完整的类型定义文件

v5的内部设计思路

基于 lerna ,拆分多个 packages,v5的整体设计,作者大佬已经总结过了,我就不在赘述请大家看图

image-20211104180323792.png 这里我就大概简述一下我认为设计的好的地方

1、函数式的代码风格

从v4到v5 能明显的感觉到函数成了一等公民,这也与像vue3这类优秀的开源项目不谋而合。通过不同函数的组合,减少项目中由于引用类型带来的副作用

2、鲜明的模块划分,实现功能解耦

在我解释,我理解的为什么这么设计之前,先介绍一下各个模块的作用和功能

core

整个编辑器最重要的模块就是core模块,他主要承担了,slate 做不到的部分 ——也就是视图层的渲染

具体core 的核心逻辑,作者大佬也总结过了,如有需要请看图,不再赘述

image-20211104180411134.png

我主要想说的是为什么v5为什么要使用vdom?

在理解值之前我们先看看v4的逻辑

在v4 中主要就是利用MutationObserver 去监听dom树的改变,从而触发编辑器的功能

接下来我们用简写的代码来描述一下v4中核心的设计

class Mutation {
  constructor(fn) {
    this.callback = mutations => {
      fn(mutations, this)
    }
    this.observer = new MutationObserver(this.callback)
}
//通过继承对来调用MutationObserver 的能力
 class Change extends Mutation {
  constructor() {
    super((mutations, observer) => {
      // 触发改变
      this.save()
    })
  }
  private save() {
    this.emit()
  }
​
  public emit() {
    // 触发编辑器的一些功能和回调
  }
}
复制代码

在以上代码中,我们发现整个v4是有自己的一套渲染规则,并且没有模型整个概念,他的所有的操作都是深深的绑定在当前这个富文本中,无法抽离

而由于v5是基于slate, 所有完美的继承了slate 优点,将模型和视图分离,就可以随意的选用选用现有的效率比较高的view 渲染器去做视图的渲染,在v5中就是用了和vue2同款的snbbdom

回归到我们的问题。我觉得(有可能不对)v5中之所以使用snbbdom 的原因有两点

1、基于slate, 能拿到Slate 的数据模型 ,用最小的成本利用现有渲染器去渲染dom,并且能通过操作menu等功能修改vdome从而渲染视图

2、模型视图分离是一个趋势,也是一个更高的抽象思想,能让代码的架构更加清晰,便于理解。

basic modules

这个模块其实就是一些常见menu 功能的汇总,比如文字大小,颜色,设置标题等

这一块的内容其实就是以插件的形式加载,本身的这种机制,指的v5在拓展性上大大加强

他的原理也很简单,主要就是利用一个全局的加载器,将插件的各个功能挂载到当前的编辑器实例上,请看代码

// 保存相关处理函数
ARR_LIST = []
// 注册器
function registerModule(modele) {
  const { setHtml } = modele
  ARR_LIST.push(setHtml)
}
let modele = {
  setHtml(vdom) {
    //假设插入html 逻辑,调用vdom渲染dom
  },
}
registerModule(modele)
// 假设有某些menu 需要setHtml直接取出执行即可
ARR_LIST.forEach(setHtml => {
  setHtml(vdom)
})
复制代码

code-highlightlist-moduletable-moduleupload-image-modulevideo-module

这些模块与basic 类似,只是功能复杂一点,并且基于很多插件,单独成为一个模块

editor

这个模块就很简单了,就是一个对外的工厂,封装了core 和各个模块,暴露给用户

3、beforeinput 的使用

**beforeinput**表示可编辑的dom在被修改前触发

虽然这个事件,还有着兼容问题,但是,在vue都抛弃ie的今天,兼容问题还是问题吗?

至于为什么要弃用MutationObserver而选用beforeinput

在理解之前我们先来说一下监听dom 变化的发展史

DOM 变化技术方案的演化史

早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。

直到 2000 年的时候引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。

采用 Mutation Event 解决了实时性的问题,因为 DOM 一旦发生变化,就会立即调用 JavaScript 接口。但也正是这种实时性造成了严重的性能问题,因为每次 DOM 变动,渲染引擎都会去调用 JavaScript,这样会产生较大的性能开销。

为了解决了 Mutation Event 由于同步调用 JavaScript 而造成的性能问题,从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。

MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。

ok回归正题,说回之前的原因

我觉得(可能有错)有两点原因:

  • 1、MutationObserver虽然很优秀,但是他的监听如果数据发生变化还需要比较,拿不到最新的变化值,并且还会产生很多无用的节点,不适合slate 的体系
  • 主动操作menu修改样式等无需监听,直接更改slate 的数据模型即可

举个例子:

 <div contenteditable id="input">
      <div>sdfasdf</div>
    </div>
// 选择需要观察变动的节点
    const targetNode = document.getElementById('input');
​
    // 观察器的配置(需要观察什么变动)
    const config = {
      subtree: true,
      childList: true,
      attributes: true,
      attributeOldValue: true,
      characterData: true,
      characterDataOldValue: true,
    };
​
    // 当观察到变动时执行的回调函数
    const callback = function(mutationsList, observer) {
      // Use traditional 'for loops' for IE 11
      debugger
      console.log(mutationsList)
    };
    // 创建一个观察器实例并传入回调函数
    const observer = new MutationObserver(callback);
    // 以上述配置开始观察目标节点
    observer.observe(targetNode, config);
复制代码

上述代码中就能监听id为input 的值的改变,而他会返回

image-20211105163741954.png

如上的数据结构,如果在同一行敲一个字符,不能拿到直接的值,而beforeinput 他除了能拿到当前改变的值,还能通过inputType知道当前输入的类型

 <div contenteditable id="input">
      <div>sdfasdf</div>
    </div>   
const input = document.querySelector('#input');
    const log = document.getElementById('values');
    function blod() {
      input.childNodes[1].style.color = 'red'
    }
    input.addEventListener('beforeinput', updateValue);
    function updateValue(e) {
      console.log(e)
    }
复制代码

代码如上,e的打印如下

image-20211105164109391.png

而我们监听到输入之前的事件之后,就能根据不听的类型调用不同的slate 的api 改变slate 的内部数据模型,在触发回调函数,触发path 渲染dom

下面用个小例子复现一下流程

 
<div contenteditable id="input"></div>
​
// 模拟slate 的数据结构
    var obj = [{
      type: "header1",
      children: [
        {
          type: 'span',
          children: [
            {
              text: "好好学习天天向上"
            }
          ]
        }
      ]
    }]
​
    // 创建编辑器
    function createEditor(option) {
      const { data, selector } = option
      //挂载函数
      function mountElement(vnodes, container) {
        vnodes.forEach((VNodeChild) => {
          const { type, text } = VNodeChild;
          if (text) {
            container.innerHTML = text
          } else {
            if (type === 'header1') {
              var el = VNodeChild.el =      Ïdocument.createElement('h1');
            }
            if (type === 'span') {
              var el = VNodeChild.el = document.createElement('span');
            }
            mountElement(VNodeChild.children, el);
            container.appendChild(el);
          }
​
        });
      }
      const editor = {
        mount() {
          const container = document.querySelector(selector)
          mountElement(data, container)
          container.addEventListener('beforeinput', this.changeViewState)
        },
        changeViewState(e) {
          console.log(e)
          //这里调用slate的api改变数据模型 
          this.updateView()
        },
        updateView(this, editor) {
          // 然后使用vdom更新视图
        }
      }
      return {
        ...editor,
        children: data
      }
    }
    createEditor({
      data: obj,
      selector: '#input'
    }).mount()
复制代码

看了如上代码,你会发现高端的食材就是往往需要最简单的烹饪方式

v5一些我们日常开发中可借鉴的点

前面扯了这么多,最后一部分其实才是我们应该重点学习的精髓,一切的研究都是为了学以致用,接下来我们分别从编程方面。架构方面,工程方面。分别吸收一下,这也是一个靠谱的程序员必备的三大素质。

编程方面

所谓编程方面,其实考察的就是我们写一段代码的优雅程度

比如看源码的同志们,会发现,源码中用了大量的weakMap 去保存一些状态,不是因为weakMap 是es6的新语法,而是他保持了对键名所引用的对象的弱引用,如果这个对象不用了,可以被GC ,所以他的出现一定解决了一个实际问题。

在比如 源码中注册事件的部分,将时间名字和事件对应的方法放在一个对象内就像这样

const eventConf = {
  beforeinput: handleBeforeInput,
  blur: handleOnBlur,
  focus: handleOnFocus,
  click: handleOnClick,
  compositionstart: handleCompositionStart,
  compositionend: handleCompositionEnd,
  compositionupdate: handleCompositionUpdate,
  keydown: handleOnKeydown,
  keypress: handleKeypress,
  copy: handleOnCopy,
  cut: handleOnCut,
  paste: handleOnPaste,
  dragover: handleOnDragover,
  dragstart: handleOnDragstart,
  dragend: handleOnDragend,
  drop: handleOnDrop,
}
复制代码

在注册的时候,我们不用一个绑定,只需要一个封装方法就能做到

forEach(eventHandlerConf, (fn, eventType) => {
      $textArea.on(eventType, event => {
        fn(event, this, editor)
      })
    })
复制代码

不但代码精简,而且目录清晰,其实,代码中还有甚多比如闭包的用法,

高阶函数的用法,在此不在赘述

架构方面

所谓架构方面,就是为了让代码分层,具有清晰的结构,和解耦的功能

架构方面,其实需要,其实是需要功底的,比如说,在上文中提到的怎样设计,视图更新,怎样设计模型同步,插件机制怎样设计,怎样开放封闭,这样运用设计模式来巧妙解决问题,怎样判断那种编程思想合适,他都需要很长时间的摸索而形成的,还是需要多看源码、

在这举个例子,就是v5的插件机制,在上面也提到过,插件机制我认为是遵循了开闭原则,大大增加了系统的拓展性,以及功能的解耦

工程方面

所谓工程方面,就是将当前的项目通过工具链(webpack,babel等),搭建成为一个可以便捷开发工程项目。

在v5中,learn +rollup 的方案其实就是现在的工程化主流,具体配置还需要读各个工具链的文档

但是v5中可以借鉴的就他提供了很多的文件的解决方案,是我们在项目中可以参考的,比如;样式的解决方案,svg 的解决方案,ts 的解决方案,构建生产包的解决方案,他其实都可以作为我们在当前项目中的参考。不至于,从头搭建一个工程项目时无从下手。

编辑器选型推荐

说了这么多,还是回到正题,在自己的项目中,编辑器我们应该怎么选?

因为大部分人都不会研究源码,他也不关心底层的实现逻辑

1、首先如果你的业务及其复杂,需要定制很多自定义功能,那么slate无疑是首选,但是前提是你要自己去实现view 层,并且有这个开发能力

2、如果项目小而美,只需要用富文本的常用功能,不需要定制,那么其实可选择的范围还是比较宽泛的,基本现在市面上所有的开箱即用的富文本都适合你比如 Draft.js Prosemirror QuillTinyMCECKEditor,都能满足

3、如果你想要文档清晰 wangeditor 、和TinyMce 都是可以的,ckeditor 文档实在是难啃就不推荐了

4、如果你想要开箱即用,并且功能丰富,文档可读,ueditor ,和tinymac 都可以,不过优先原则后者吧,因为前者不维护了,并且太重了

5、如果你是个中国的源码爱好者,那wangeditor v4和v5是非常值得研究的,当然,要说最值得研究的还是当属vue.....

分类:
前端
分类:
前端