📝 wangEditor 新版本(WIP...) 功能和体验介绍

3,960 阅读20分钟

更新

wangEditor V5 已正式发布,查看官网

前言

新版本功能目前已经开发完,但尚未发布,代码还没有开源。待内部测试完成,即发布体验版、代码开源。
本文主要介绍一下新版本的功能升级、体验升级,以及我的一些思考总结。等后面有了阶段性的进展,我再来给大家分享。

产品的整体架构设计,在上一篇文章中已经介绍过了,没有太大变化。只做了一些属性 API 添加,再就是细节的代码重构。

当前状态

基本功能已经开发完了,现在刚开始组织内部测试。大家可以通过这个 demo 体验一下。但请注意:

  • 可能还有很多 bug 和浏览器兼容性问题。如你发现了严重的 bug ,可给我留言。
    • 不再支持 IE 浏览器
    • 暂不支持移动端,先把 PC 浏览器(chrome safari firefox edge QQBrowser 等)支持好
  • demo 是内部测试地址,随时可能调整和改变。如果看不了,可以给我留言。

image.png

内部测试将包含以下几个步骤:

  • 用户功能测试。即终端用户可直接鼠标、键盘操作的功能,如:编辑器区域的输入、删除,样式操作,各个菜单等。我整理了一个表格,目前包含 68 个功能点,主要测试可用性和浏览器兼容性
  • 配置和 API 测试。用户文档(尚未开放)中所有的安装、配置、API、扩展性,用于 Vue 和 React 。
  • 目前已积累且关闭的近 4000 个 github issue ,全部在回归测试一遍。把该踩的坑都踩一遍。

未来工作

虽然基本功能已经开发完,但开发工作尚未结束,例如:

  • 修复测试时的 bug ,code review 等,这会占用很大工作量
  • i18n 多语言
  • 单元测试和 e2e 测试
  • CI/CD 流程
  • Vue3 组件(目前只支持了 Vue2 ,Vue3 待发布体验版之后,再开发,会很快做出来)

另外,还有很多文档需要写

  • 开发文档
  • 用户文档(目前只写了一部分,还需要继续补充)

到这里,大家可能有一个疑问:为何到现在还没写单元测试呢?不是测试先行或者并行吗? 我来解释一下:

  • 大家说的“测试先行或者并行”一般是在框架和基础确定、纯业务开发的情况,如我们日常工作的开发;还有就是模式和用法确定、只剩下功能实现的情况,如 Vue3 在开发时就测试并行。
  • 而 wangEditor 新版本是一个探索性的项目,一边设计、一边试错,一边重构。今天写的 API 和 class ,明天可能就重构消失了,所以没法提前写单元测试。只能等待功能、配置和 API 都稳定之后,再来补充 —— 当然,写单元测试时,有可能还会去重构一部分代码,抽离逻辑,让它方便测试。

近期目标

当前是 7 月下旬,按照目前的工作量预估,还需要两个月的时间。其实主要看测试、修复 bug 的顺利程度。

所以,我目前的计划是:争取在 10.1 之前发布一个公测版本,保证没有严重、明显的 bug 和兼容性问题。

持久战

距离我上一篇文章 基于 slate.js(不依赖 React)设计富文本编辑器 已经过去快 2 个月了。当时我以为 2 周左右即可开发完基本功能,但现实很残酷。

我统计了一下,目前代码仓库已经超过了 2w 行代码,可见我这两个月的工作量是非常大的。
另外,大家也都清楚,编辑器是一个复杂度非常高的产品,所以这 2w 行代码可不是流水账一样写出来的,期间还有大量的设计、思考和重构。

它本质上就是一个持久战,其他国外的优秀富文本编辑器,开发周期也是按“年”来计算的,短期出不来东西。
所以,我在规划接下来的目标时,也没有那么激进,我会努力推进,但也要尊重事实,急不得。

这里我很庆幸自己当时选择了 slate.js (不依赖 React)作为内核,slate.js 是一个非常优秀的 core ,现在用起来非常爽,它可做了整整 4 年。
否则,我可能要花很长时间开发一个 core ,看不到任何 UI 的成果。而且做出来不一定就比 slate.js 好。

PS:并不是用 slate.js 作为 core 就很简单,依然很难,依然工作量很大。读这篇文章你应该就能体会到。

价值和优势

既然需要耗费那么大的经历,我为何要做它呢?也不挣钱。如果真把这些工作量换算成工资,那可真是不少钱。
很多人可能只知道点赞、加油,但不理解这个问题。

wangEditor 在很多年之前一开始的时候,是出于我个人的兴趣爱好和技术追求。但做到现在,特别是近期需要花费这么大精力去开发它,就不能单凭兴趣爱好和技术追求了 —— 否则成本也太高了。

我之所以会继续做它,是因为我看到了它的价值,或者看到它确实能解决很多用户问题。
而有价值的东西,迟早都会以某种方式变现的

换句话说,如果现在真的有一个产品,跟我预想中的差不多,而且已经成熟且流行起来。那我就不会以现在这种方式来做了。

其他富文本编辑器的问题

大家能看到的其他富文本编辑器,如老牌的 ueditor kindeditor ,国外开源的 proseMirror slate quill draft ,以及非绝对开源的 tinyMCE CKEditor ,等等还有很多不是那么知名的。

你看着很多,感觉自己的选择范围很广。但如果你真的是富文本编辑器的用户,你会发现它们都存在各种个样的情况。这些情况不能叫做问题,但却会影响你的开发效率、开发成本、产品稳定性和扩展性。

  • 技术老旧,依然使用 document.execCommand
  • 中文不友好(英文很溜,或者需要英文场景的请忽略~)
  • 只是一个 core ,功能都需要二次开发
  • 有框架约束,如 slate draft
  • 无官方 Vue React 等框架
  • 踩的坑不够多,不够稳定(如新产品、小众产品)

以上问题,随便哪一个拿出来,都够你喝几壶。有这种体会的朋友,欢迎留言评论~

wangEditor 解决这些问题

创造价值就是解决用户的问题。wangEditor 新版本就是本着解决问题做的,而不仅仅是兴趣爱好、技术追求。这也是为何 wangEditor 要基于 slate.js 为内核,而不是非要自研内核。

对比上述问题,wangEditor 新版本的优势在于:

  • 弃用 document.execCommand ,升级为 L1 能力(未来还会支持协同编辑)
  • 详细的中文文档、QQ 群,issue 处理流程
  • 包含常见的所有功能,拿来就用,无需二次开发 (除非你有一些特殊需要)
  • 无框架约束,可以用到任何地方(jQuery Vue React)
  • 官方提供 Vue React 等组件,下文会有介绍
  • 用户量大,踩过足够多的坑

使用

基本使用

wangEditor 新版本的使用将非常简单,通过以下代码可以体会一下:

<!-- 工具栏和编辑器强制分离,如何布局用户可自由发挥 -->
<div id="toolbar-container"></div>
<div id="editor-container"></div>
// 安装,或 CDN 引入(尚未发布,此处先忽略)

// 编辑器配置
const editorConfig = {}
editorConfig.placeholder = '请输入内容'
editorConfig.onChange = (editor) => {
    // 当编辑器选区、内容变化时,即触发
    console.log('content', editor.children)
    console.log('html', editor.getHtml())
}

// 创建编辑器
const editor = wangEditor.createEditor({
  textareaSelector: '#editor-container',
  config: editorConfig,
  content: [{ type: 'paragraph', children: [{ text: 'hello wangEditor' }] }], // 初始化内容
  mode: 'default' // 或者 'simple'
})
// 创建工具栏
const toolbar = wangEditor.createToolbar({
  editor, // 传入 editor ,以便让工具栏知道对应哪个编辑器
  toolbarSelector: '#toolbar-container',
  config: { /* toolbar config */ },
  mode: 'default' // 或者 'simple'
})

工具栏可选

以上代码中 toolbar 是可选的,不需要可以不写。
即,没有工具栏,编辑器照样能正常使用。

自由控制 UI

相比于当前 V4 版本,新版本将不在帮用户生成 DOM ,用户可自定义 DOM 以实现自由的 UI 样式。例如:

  • 工具栏放在下面
  • 想要自定义 width height border 等
  • 想要自定义 z-index
  • 想要实现工具栏 fixed 到顶部
  • 想要模仿腾讯文档、语雀文档的编辑器

image.png

配置和 API

wangEditor 支持丰富的配置、菜单和 API ,上述代码中没发一一演示,下文会有介绍。
你也可以去看看各个 demo 的源代码。

升级重点

功能增强

升级为 L1 能力

新版本弃用了 document.execCommand API,升级为 L1 能力(有的叫 L2 ),彻底告别了 execCommand 那些莫名其妙的 bug 。

基于 slate 的数据模型,未来会探索协同编辑器。目前已经有了一些解决方案,如 slate-yjsslate-collaborative

50+ 菜单

当前内置了 50+ 菜单,你可以自定义这些菜单的显示/隐藏,排序和分组。

image.png

可以在 demo 页执行 editor.getAllMenuKeys() 来查看上图。

55+ APIs

新版本定义了很多 API 进行各个方面的操作,如 config 、内容处理、DOM 操作、selection 、自定义事件等等。在 demo 页执行 editor 即可看到所有的 API 。

/**
 * Editor 接口
 */
export interface IDomEditor extends Editor {
  // event data 处理(粘贴、拖拽等),内部使用
  insertData: (data: DataTransfer) => void
  setFragmentData: (data: DataTransfer) => void

  // config 相关 API
  getConfig: () => IEditorConfig
  getMenuConfig: (menuKey: string) => ISingleMenuConfig
  getAllMenuKeys: () => string[]
  alert: (info: string, type: AlertType) => void

  // 内容处理 API
  handleTab: () => void
  getHtml: (withFormat?: boolean) => string
  getText: () => string
  getSelectionText: () => string
  getHeaders: () => { id: string; type: string; text: string }[]
  getParentNode: (node: Node) => Ancestor | null

  // dom 相关 API
  id: string
  isDestroyed: boolean
  isFullScreen: boolean
  focus: () => void
  isFocused: () => boolean
  blur: () => void
  updateView: () => void
  destroy: () => void
  scrollToElem: (id: string) => void
  showProgressBar: (progress: number) => void
  hidePanelOrModal: () => void
  enable: () => void
  disable: () => void
  isDisabled: () => boolean
  toDOMNode: (node: Node) => HTMLElement
  fullScreen: () => void
  unFullScreen: () => void

  // selection 相关 API
  select: (at: Location) => void
  deselect: () => void
  restoreSelection: () => void
  getSelectionPosition: () => Partial<IPositionStyle>
  getNodePosition: (node: Node) => Partial<IPositionStyle>

  // 自定义事件 API
  on: (type: string, listener: ee.EventListener) => void
  off: (type: string, listener: ee.EventListener) => void
  once: (type: string, listener: ee.EventListener) => void
  emit: (type: string) => void

  // undo redo
  undo: () => void
  redo: () => void
}

配置增强

相比于当前的 V4 版本,配置增加了

  • 生命周期 onCreated onDestroyed onChange
  • 悬浮菜单配置,如选中文字、选中链接、选中图片、选中表格、选中代码块
  • 字数限制 maxLength onMaxLength
/**
 * editor config
 */
export interface IEditorConfig {
  customAlert: (info: string, type: AlertType) => void

  onCreated?: (editor: IDomEditor) => void
  onChange?: (editor: IDomEditor) => void
  onDestroyed?: (editor: IDomEditor) => void

  onMaxLength?: (editor: IDomEditor) => void
  onFocus?: (editor: IDomEditor) => void
  onBlur?: (editor: IDomEditor) => void

  // edit state
  scroll: boolean
  placeholder?: string
  readOnly: boolean
  autoFocus: boolean
  maxLength?: number

  // 悬浮菜单栏 menu
  hoverbarKeys?: Array<IHoverbarConf>
}

可以在 demo 页执行 editor.getConfig() 看到所有配置

image.png

配置拆分

当前 V4 版本的配置都会混合在一起的,而新版本的配置是拆分为

  • 编辑器配置(上文已说)
  • 工具栏配置
  • 各个菜单配置(下文再说,内容有点多)

工具栏配置,主要配置工具栏的菜单。接口定义如下:

/**
 * toolbar config
 */
export interface IToolbarConfig {
  toolbarKeys: Array<string | IMenuGroup>
  excludeKeys: Array<string> // 排除哪些菜单
}

可以在 demo 页执行 toolbar.getConfig() 查看当前配置。

image.png

各个菜单的配置

当前 V4 版本,所有菜单的配置都在一起,如 editor.config.colors 配置颜色。当菜单过多时,这种方式就比较混乱了。所以新版本将菜单配置单独拆分出来。

// 编辑器配置,其中 MENU_CONF 存储各个菜单的配置
const editorConfig = { MENU_CONF: {} }
editorConfig.placeholder = '请输入内容'

// 各个菜单的配置
editorConfig.MENU_CONF['color'] = {
  colors: ['#000', '#333', '#999', '#ccc']
}
editorConfig.MENU_CONF['fontSize'] = {
  fontSizeList: ['12px', '16px', '24px', '40px']
}

// 最后创建编辑器

各个菜单的 key 可以通过 editor.getAllMenuKeys() 查看,上文已说。各个菜单的配置内容,可以通过 editor.getMenuConfig(menuKey) 来查看。如查看上传图片菜单的配置:

image.png

安全性增强

既然弃用了 document.execCommand 就不能直接操作 DOM 了。又基于 slate 的数据模型,所以使用 vdom 进行视图更新,我们是基于 snabbdom.js 来做 vdom patchView

使用 vdom 本身就可以过滤一部分 XSS 工具。另外代码中还对直接输出 html 的部分做了特殊字符的过滤,目前没有发现 XSS 的风险。

体验增强

要做“好用”的编辑器,要看功能、稳定性,还要看体验。

hoverbar 悬浮菜单

目前内置的 hoverbar 有几种:

  • 选中文字
  • 链接
  • 图片
  • 表格
  • 代码块

image.png

hovarbar 也是 editor config 的一部分,可以支持配置、扩展。在 demo 可以查看配置

image.png

select 型菜单

像设置标题、字号、字体、行高等菜单,使用 select 类型,类似于 html 中的 <select>

image.png

menu group 菜单组

如果像把某些菜单折叠起来,可以使用菜单组。

image.png

配置也非常简单,可以自定义 icon title ,然后选择 menuKey 即可。

{
  key: 'group-more-style', // 要以 group 开头
  title: '更多样式',
  iconSvg: '<svg>...</svg>',
  menuKeys: ['through', 'code', 'clearStyle'], // 要折叠的菜单 key
}

菜单 disabled

例如,当光标在代码块中时,很多文本操作的菜单都是 disabed 的状态。因为代码块中的文本不可自行加样式。

image.png

这个规则可以在创建 menu 时自行定义,编辑器会在选区变化时,自动更新所有菜单的 disabled 。

// 文本样式(如 bold italic 等)的禁用规则
isDisabled(editor: IDomEditor): boolean {
  if (editor.selection == null) return true

  const [match] = Editor.nodes(editor, {
    match: n => {
      const type = DomEditor.getNodeType(n)

      if (type === 'pre') return true // 代码块
      if (Editor.isVoid(editor, n)) return true // void node

      if (mark === 'bold') {
        // header 中禁用 bold
        if (type.startsWith('header')) return true
      }

      return false
    },
    universal: true,
  })

  // 命中,则禁用
  if (match) return true
  // 未命中,则不禁用
  return false
}

代码高亮

新版本默认自带代码高亮功能,而且有 20 种编程语言可选择,不用再做任何加工。

分词是基于 prism.js 实现的,随着文字编辑,实时判断是否高亮。操作体验比 v4 要好很多。而且,输出的 html 引入 prism.js 即可显示代码高亮,实用非常方便。

有兴趣的可以参考 slate 代码高亮的 demo ,其中用到了 slate decorate 功能,非常赞。

规范化内容

富文本编辑器的内容,有时可能带来操作的问题。例如,用户从一开始就插入了一个代码块,此时想要在代码块上方再输入文字,怎么办?

image.png

我们的解决方案是:规范化内容 —— 如果代码块出现在第一个位置,就在前面强制加一个空 <p> 。问题就解决了。

类似于这样的问题还有很多,我们已经做了很多处理,可能还有不完善的,测试时再继续补充。

PS:有人可能会较真:往代码块前面加一个空 <p> 这不就不符合用户预期了吗?—— 这个我承认,不是 100% 符合用户预期。但如果我们从问题、解决方案、用户体验、成本、复杂度...这几个角度全面考虑,这个方案是不是最优的呢?

扩展性增强

slate 是基于插件机制的,wangEditor 新版本是基于 module 扩展机制的。即出了插件之外,module 还包括其他的功能,是一个功能模块的组合。

export interface IModuleConf {
  // 注册菜单
  menus: Array<IRegisterMenuConf>

  // 渲染 modal -> view
  renderTextStyle: RenderTextStyleFnType
  renderElems: Array<IRenderElemConf>

  // to html
  textStyleToHtml: TextStyleToHtmlFnType
  textToHtml: TextToHtmlFnType
  elemsToHtml: Array<IElemToHtmlConf>

  // slate 插件
  editorPlugin: <T extends IDomEditor>(editor: T) => T
}

虽然产出的 wangEditor 包含很多内置的菜单和功能,但它内部就是将各个 module 拼接组合产出的。

// basic-modules 基本功能
import '@wangeditor/basic-modules/dist/css/style.css'
import basicModules from '@wangeditor/basic-modules'

// list-module
import '@wangeditor/list-module/dist/css/style.css'
import wangEditorListModule from '@wangeditor/list-module'

// table-module
import '@wangeditor/table-module/dist/css/style.css'
import wangEditorTableModule from '@wangeditor/table-module'

// video-module
import '@wangeditor/video-module/dist/css/style.css'
import wangEditorVideoModule from '@wangeditor/video-module'

// upload-image-module
import '@wangeditor/upload-image-module/dist/css/style.css'
import wangEditorUploadImageModule from '@wangeditor/upload-image-module'

// code-highlight
import '@wangeditor/code-highlight/dist/css/style.css'
import { wangEditorCodeHighlightModule } from '@wangeditor/code-highlight'

import registerModule from './register'

// 注册各个功能模块
basicModules.forEach(module => registerModule(module))
registerModule(wangEditorListModule)
registerModule(wangEditorTableModule)
registerModule(wangEditorVideoModule)
registerModule(wangEditorUploadImageModule)
registerModule(wangEditorCodeHighlightModule)

// 最终产出 wangEditor.js

所以,wangEditor 新版本天生就是具备扩展性的,可以继续扩展自己定义的 module 。
这些后面会在用户文档中写明。

可直接用于 Vue React

目前只有 Vue React 组件,其他框架像 svelte ng 等,发布之后再根据用户需求添加。

用于 Vue

目前只有 Vue2 组件,Vue3 组件会在发布之前开发完。

<div>
    <div>
        <button @click="insertText">insert text</button>
    </div>
    <div style="border: 1px solid #ccc;">
        <Toolbar :editorId="editorId" :defaultConfig="toolbarConfig" :mode="mode"/>
    </div>
    <div style="border: 1px solid #ccc; margin-top: 10px;">
        <Editor
            :editorId="editorId"

            :defaultConfig="editorConfig"
            :defaultContent="defaultContent"
            :mode="mode"

            @onCreated="onCreated"
            @onChange="onChange"
            @onDestroyed="onDestroyed"
            @onMaxLength="onMaxLength"
            @onFocus="onFocus"
            @onBlur="onBlur"
            @customAlert="customAlert"
        />
    </div>
</div>
import Vue from 'vue'
import { Editor, Toolbar, getEditor, removeEditor } from '@wangeditor/editor-for-vue' // 尚未发布

export default Vue.extend({
    components: { Editor, Toolbar },
    data() {
        return {
            //【特别注意】
            // 1. editorId Toolbar 和 Editor 的关联,要保持一致
            // 2. 多个编辑器时,每个的 editorId 要唯一
            editorId: 'w-e-1001',

            toolbarConfig: { /* 工具栏配置 */ },
            defaultContent: [],
            editorConfig: {
                placeholder: '请输入内容...',
                // 其他编辑器配置
                // 菜单配置
            }
            mode: 'default' // or 'simple'
        },
        curContent: []
    },

    methods: {
        onCreated(editor) {
            console.log('onCreated', editor)
        },
        onChange(editor) {
            console.log('onChange', editor.children)
            this.curContent = editor.children
        },
        onDestroyed(editor) {
            console.log('onDestroyed', editor)
        },
        onMaxLength(editor) {
            console.log('onMaxLength', editor)
        },
        onFocus(editor) {
            console.log('onFocus', editor)
        },
        onBlur(editor) {
            console.log('onBlur', editor)
        },
        customAlert(info: string, type: string) {
            window.alert(`customAlert in Vue demo\n${type}:\n${info}`)
        },

        insertText() {
            // 获取 editor 实例,即可执行 editor API
            const editor = getEditor(this.editorId)
            if (editor == null) return
            if (editor.selection == null) return

            // 在选区插入一段文字
            editor.insertText('一段文字')
        },
    },

    // 及时销毁 editor
    beforeDestroy() {
        const editor = getEditor(this.editorId)
        if (editor == null) return

        // 销毁,并移除 editor
        editor.destroy()
        removeEditor(this.editorId)
    }
})

用于 React

下面代码演示了 Hook 中的使用,class Component 的使用会在文档中说明,代码也会开源出来。

import React, { useState, useEffect } from 'react'
import { IDomEditor, IEditorConfig, IToolbarConfig, SlateDescendant } from 'wangEditor@next' // 尚未发布
import { Editor, Toolbar } from '@wangeditor/editor-for-react' // 尚未发布

function ReactEditor() {
    // 存储 editor 实例
    const [editor, setEditor] = useState<IDomEditor | null>(null)
    // 存储 editor 的最新内容(json 格式)
    const [curContent, setCurContent] = useState<SlateDescendant[]>([])

    // 工具栏配置
    const toolbarConfig: Partial<IToolbarConfig> = { /* 工具栏配置 */ }

    // editor 配置
    const editorConfig: Partial<IEditorConfig> = {}
    editorConfig.placeholder = '请输入内容...'
    editorConfig.onCreated = (editor: IDomEditor) => {
        // 记录 editor 实例,重要 !
        // 有了 editor 实例,就可以执行 editor API
        setEditor(editor)
    }
    editorConfig.onChange = (editor: IDomEditor) => {
        // editor 选区或者内容变化时,获取当前最新的的 content
        setCurContent(editor.children)
    }
    // 其他编辑器配置
    // 菜单配置

    // 及时销毁 editor ,重要!!!
    useEffect(() => {
        return () => {
            if (editor == null) return
            editor.destroy()
            setEditor(null)
        }
    }, [])

    return (
        <React.Fragment>
            <div style={{ border: '1px solid #ccc'}}>
                {/* 渲染 toolbar */}
                <Toolbar editor={editor} defaultConfig={toolbarConfig} mode="default"/>
            </div>
            <div style={{ border: '1px solid #ccc', marginTop: '10px' }}>
                {/* 渲染 editor */}
                <Editor defaultConfig={editorConfig} defaultContent={[]} mode="default"/>
            </div>
        </React.Fragment>
    )
}

export default ReactEditor

遇到的问题

世界上只有两种软件:1. 没人用的软件;2. 很多 bug 的软件;

开发过程中当然遇到了很多问题,下面是印象比较深的两个问题。(近期的,之前的问题都有点忘了)

拼音输入的问题

全选,输入拼音

slate 本身就有拼音输入的问题,可能是外国人不用拼音,所以它们不觉得是一个问题 —— 对,未发现的 bug 那就不能叫 bug 。

例如你现在访问 slate example ,focus 到编辑器,ctrl + a 全选,然后直接输入拼音。它就会出现问题。
但这个问题在 wangEditor 新版本中已经解决。

当然,有可能还有其他拼音输入相关的 bug ,如果你发现了,可给我留言。

maxLength

编辑器的输入使用 beforeinput 事件来劫持,而这无法阻止拼音输入法。但这个问题还是要解决的,就得使用其他的方法。

第一,在 compositionStart 时记录下 Range 节点的 text

function handleCompositionStart(e: Event, textarea: TextArea, editor: IDomEditor) {
  // 记录下 dom text ,以便触发 maxLength 时使用
  if (selection && Range.isCollapsed(selection)) {
    const domRange = DomEditor.toDOMRange(editor, selection)
    const curText = domRange.startContainer.textContent || ''
    EDITOR_TO_TEXT.set(editor, curText)
  }
}

第二,在 compositionEnd 时,判断是否触发 maxLength ,如果触发就直接 DOM 操作,还原 Range 节点的 text

export function handleCompositionEnd(e: Event, textarea: TextArea, editor: IDomEditor) {
  const { data } = e

  // 检查 maxLength
  //【注意】这里只处理拼音输入的 maxLength 限制,英文、数组的限制,在 editor.insertText 中处理
  if (DomEditor.checkMaxLength(editor, data)) {
    const domRange = DomEditor.toDOMRange(editor, selection)
    domRange.startContainer.textContent = EDITOR_TO_TEXT.get(editor) || '' // 如果触发 maxLength ,则还原 Range 节点的 text
    textarea.changeViewState() // 重新定位光标
    return
  }

  // 未触发 maxLength ,则插入文字
  Editor.insertText(editor, data)
}

toHtml 的纠结

新建、编辑一篇文章,肯定需要发布展示它。toHtml 就是把编辑器的内容,转换为 html ,然后展示到页面上。

新版本编辑器的内容是基于 slate 数据模型的,是一个 json 格式的数据,当然你也可以通过编辑器获取 html 。

const editorConfig = {}
editorConfig.onChange = (editor) => {
    console.log('content', editor.children) // 获取 content
    console.log('html', editor.getHtml()) // 后去 html
}
// 创建编辑器
const editor = wangEditor.createEditor({
  textareaSelector: '#editor-container',
  config: editorConfig,
  content: [], // 默认内容
})

既有 content 又有 html ,那该如何存储?如何发布展示呢?

  • 只存储 html —— 那无法再次编辑了,因为创建编辑器时 content 不能传入 html —— 无法编辑,就直接 pass 了
  • 只存储 content —— 那你在发布展示时如何转为 html 呢?
  • 同时存储 content 和 html —— 数据冗余了,这本质就是一个数据,两种不同形式而已

对于这个问题,我纠结了很多天,最终也没能找到一个完美的方案。但还是提供了可行的方案。

只存储 content

可以再次编辑内容,数据也不会冗余。只剩下一个问题:如何将 content 转换为 html ?

我提供了一个解决方案:

  • 前端 js 渲染
  • 服务端 nodejs SSR 渲染(不支持其他语言和技术栈)
// 自己从服务端或这数据库获取 content
const content = await getContentFromDatabaseOrServer()

// 创建编辑器,只传入 content 即可
const editor = wangEditor.createEditor({ content })

const html = editor.getHtml()
const text = editor.getText()

// 将 html 或者 text 渲染到页面

适用于以下情况

  • 使用 nodejs SSR 服务端渲染
  • 前端渲染,但并不考虑至极的 js 体积优化(如 PC 单页面)
  • 显示页面和编辑页面是同一个 SPA ,此时 wangEditor js 只加载一次

同时存储 content 和 html

如果你不适用于上述情况,那就只能同时存储 html 和 content 。出了数据冗余之外,其他问题都很简单。
再次编辑使用 content ,发布显示使用 html 。不限制技术栈,不限制前端渲染还是 SSR ,都行。

不过需要注意一点:这两者最好通过一个 http 请求来处理,否则可能出现数据不一致的问题。如只存储了 content 却没存储 html(bug,断网,断电等)

const editor = wangEditor.createEditor({ ... })

// 也可以通过 onChange 把 content html 实时同步到 textarea ,再提交表单

// 点击保存按钮
$('#button-save').on('click', () => {
    // 1. 获取 content 和 html
    const contentStr = JSON.stringify(editor.children)
    const html  = editor.getHtml()

    // 2. 拼接 content 和 html ,中间插一个间隔字符串(不易重复的,自己定义即可),如 '<<split-symbol-a7qlu>>'
    const contentAndHtml = contentStr + '<<split-symbol-a7qlu>>' + html

    // 3. 提交 contentAndHtml 到服务端,需要你自行处理
    //    服务端再根据 '<<split-symbol-a7qlu>>' 来拆分 content 和 html ,分别保存

    //【注意】这里要拼接 content 和 html 一次性提交到服务端,是为了避免数据不同步的问题
    // 例如,因为程序 bug ,突然断网、断电等情况,只提交成功了 content 却未提交成功 html
})

是否要等待新版本发布?

如果近期就使用,那就不用等新版本了。上文说了,这是一个持久战,等新版本正式发布,估计也得今年冬天了。
可以先使用 V4 版本,到时候升级 V5 成本不会很高,我会控制好这块。

总结

富文本编辑器是一个复杂度非常高的软件,这个我早有体会,而这次的新版本升级体会更深了。
同时,我参与其中也是收获很多,特别是对于软件架构和设计的思考。这是写业务代码永远都体会不到的。

也正是因为它复杂度高,成本大,工期长,才有足够的技术壁垒。
还是那句话:价值,最终都会变现。

最后,欢迎感兴趣的小伙伴一起加入开发,可以加入 qq 群 然后私聊群主。要求条件:

  • 熟悉 ts
  • 熟悉 slate.js 的使用和原理
  • 熟悉 vdom 原理,熟悉 snabbdom.js 的用法,
  • 熟悉 Vue 或者 React
  • 熟悉 webpack 或者 rollup