基于 slate.js 框架 wandEditor 富文本渲染及源码分析

概述

使用 editorjs 开发富文本编辑器,发现行内工具如加粗、斜体、删除线、颜色背景等操作要自己实现,现成的 document.execCommand API 有各种限制和体验问题,官方也说了有可能会废除(历史原因不能这么干)

调研了市面上的产品,slate.js 框架能实现工具栏的功能,做到与框架无关,不过要进行大量的二次开发,封装复杂,而 wandEditor 正好是基于 slate.js 实现,于是分析 wandEditor 源码实现,方便将 slate.js 集成到 editor.js ,记录了 wandEditor 渲染流程。

架构分析

wandEditor 使用 lerna 进行代码管理,底层依赖 slate.js 和 snabbdom 库实现

  1. slate.js 库负责对文本、节点内容处理,如文本操作(字体加粗、下划线、删除线、颜色、背景等)、元素操作(插入段落、标题、列表、表格、图片等)
  2. wandEditor 没有使用 slate-react 渲染 ,它与框架无关,可以使用在 Vue、React 项目,渲染使用 snabbdom,利用 patch diff 提升更新的性能

wandEditor 代码拆分到 packages 子模块下,方便扩展和维护

  1. core 是核心模块,包含了 TextArea 类,编辑器 editor 操作方法 、文本格式 formats、菜单操作 menus,是编辑器的核心逻辑
  2. editor 模块是打包的入口模块,项目引入使用 @wangeditor/editor 包也是这个模块,它在入口文件,将 basic-modules(段落、标题模块等集合)、代码高亮、列表、表格、 图片、视频这些分散的模块进行批量注册,使用 Boot 静态类进行统一管理,在编辑器实例化,将 Boot.plugins 插件传入 core 包注册使用

总的来说,editor 模块相当于出口,作为中间桥梁,将核心包 core 和其他功能模块的包很好的集成在一起,既方便代码管理,也能通过插件机制,很好的扩展其他功能模块。

开始使用

wandEditor 富文本将 编辑内容区域 和 工具栏 分成两步使用

  • 第一步:createEditor 方法创建 editor 编辑器实例对象, 同时渲染编辑器内容数据
  • 第二步:createToolbar 方法传入 editor 对象,实例化编辑器工具栏,调用 editor 对象的方法可以对文本进行加粗、斜体、删除线等操作

这样设计的好处,可以实现工具栏定制化,可有可无,满足更多的业务场景。同时通过 editor 对象保持工具栏和编辑器的数据同步和一致性

JS 复制代码
import { createEditor, createToolbar } from '@wangeditor/editor'

// editor 配置
const editorConfig = {}

const editor = createEditor({
  selector: '#editor-text-area',
  config: editorConfig,
  html: '<p>hello</p>',
  mode: 'simple'
})

const toolbar = createToolbar({
  editor,
  selector: '#editor-toolbar',
  config: {},
  mode: 'simple'
})

编辑器的页面渲染,是以数据驱动,数据存储到 editor.children

富文本渲染流程

注册内置模块

在入口文件,自动注册内置模块,将 basic-modules、list-module等分散的模块进行批量注册

JS 复制代码
// packages/editor/src/index.ts

import './register-builtin-modules/index'

引入文件后,自动执行

js 复制代码
// register-builtin-modules/index
import basicModules from '@wangeditor/basic-modules'
import wangEditorListModule from '@wangeditor/list-module'
import wangEditorTableModule from '@wangeditor/table-module'
import wangEditorVideoModule from '@wangeditor/video-module'
import wangEditorUploadImageModule from '@wangeditor/upload-image-module'
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)

调用 registerModule 注册函数,将模块数据添加到静态类 Boot

富文本实例化

执行函数 coreCreateEditor,内部调用 slate.js 框架的 createEditor 实例化编辑器,并返回 editor 对象

JS 复制代码
/**
 * 创建 editor 实例
 */
export function createEditor() {

  const editor = coreCreateEditor({
    selector,
    config: {
      ...globalConfig, // 全局配置
      ...config,
      hoverbarKeys: newHoverbarKeys,
    },
    content,
    html,
    plugins: Boot.plugins,
  })

  return editor
}

coreCreateEditor 执行操作

  • 调用 slate.js 库 createEditor() 函数实例化编辑器,并基于它提供的插件机制,做了很多扩展,类似洋葱模型
  • 将传入 html 转换为 json,存入 editor.children 数据渲染,content 字段优先级比 html 高
  • 实例化 TextArea,调用 textarea.changeViewState 渲染视图
js 复制代码
// packages/core/src/create/create-editor.ts

export default function coreCreateEditor (option) {
  const { selector = '', config = {}, content, html, plugins = [] } = option

  // 创建实例 - 使用插件
  let editor = withHistory(
    withMaxLength(
      withEmitter(withSelection(withContent(withConfig(withDOM(withEventData(createEditor()))))))
    )
  )
  if (selector) {
  // 处理配置
  const editorConfig = genEditorConfig(config)

  // 注册第三方插件
  plugins.forEach(plugin => {
    editor = plugin(editor)
  })

  // 初始化内容(要在 config 和 plugins 后面)
  if (html != null) {
    // 传入 html ,转换为 JSON content
    editor.children = htmlToContent(editor, html)
  }
  if (content && content.length) {
    editor.children = content // 传入 JSON content
  }
  if (editor.children.length === 0) {
    editor.children = genDefaultContent() // 默认内容
  }
  DomEditor.normalizeContent(editor) // 格式化,用户输入的 content 可能不规范(如两个相连的 text 没有合并)

  if (selector) {
    // 传入了 selector ,则创建 textarea DOM
    const textarea = new TextArea(selector)
    // 视图更新
    textarea.changeViewState() // 初始化时触发一次,以便能初始化 textarea DOM 和 selection

  return editor
}

批量注册的插件,遍历 plugins 数组函数,传入 editor 挂载或重写 编辑器实例对象方法

渲染视图

changeViewState 调用 updateView 方法,使用 editor.children 数据渲染富文本内容

注:editorSelectionToDOM 方法是在工具栏操作如加粗,它是数据驱动,修改了 editor.children 数据结构,派发change 事件 changeViewState 重新渲染了,要处理 editor.section 新的选中范围 range

JS 复制代码
// packages/core/src/text-area/TextArea.ts

changeViewState() {
  const editor = this.editorInstance

  // 更新 DOM
  // TODO 注意这里是否会有性能瓶颈?因为每次键盘输入,都会触发这里 ------ 可单独测试大文件、多内容,如几万个字
  updateView(this, editor)

  // 处理 placeholder
  handlePlaceholder(this, editor)

  // 同步选区(异步,否则拿不到 DOM 渲染结果,vdom)
  promiseResolveThen(() => {
    editorSelectionToDOM(this, editor)
  })
}

updateView 方法更新视图

  • 节点转化 vdom:通过遍历 editor.children,调用 node2Vnode 将数据转换为 vdom 虚拟DOM,children 深层递归处理
  • 首次渲染,创建编辑区域 dom 添加到页面,使用 patchFn 函数将 vdom 更新上去,存储 patch 函数减少初始化
  • 当重新渲染,取出缓存的 patch 和 旧的 vdom,使用 diff 进行更新
JS 复制代码
// 文件路径 packages/core/src/text-area/update-view.ts

/**
 * 获取 editor.children 渲染 DOM
 * @param textarea textarea
 * @param editor editor
 */
function updateView(textarea: TextArea, editor: IDomEditor) {
  const $scroll = textarea.$scroll
  const elemId = genElemId(textarea.id)

  // 生成 newVnode
  const newVnode = genRootVnode(elemId, readOnly)
  const content = editor.children || []
  newVnode.children = content.map((node, i) => {
    let vnode = node2Vnode(node, i, editor, editor)
    normalizeVnodeData(vnode) // 整理 vnode.data 以符合 snabbdom 的要求
    return vnode
  })

  let textareaElem
  let isFirstPatch = IS_FIRST_PATCH.get(textarea)
  if (isFirstPatch == null) isFirstPatch = true // 尚未赋值,也是第一次
  if (isFirstPatch) {
    // 第一次 patch ,先生成 elem
    const $textArea = genRootElem(elemId, readOnly)
    $scroll.append($textArea)
    textarea.$textArea = $textArea // 存储下编辑区域的 DOM 节点
    textareaElem = $textArea[0]

    // 再生成 patch 函数,并执行
    const patchFn = genPatchFn()
    patchFn(textareaElem, newVnode)

    // 存储相关信息
    IS_FIRST_PATCH.set(textarea, false) // 不再是第一次 patch
    TEXTAREA_TO_PATCH_FN.set(textarea, patchFn) // 存储 patch 函数
  } else {
    // 不是第一次 patch
    const curVnode = TEXTAREA_TO_VNODE.get(textarea)
    const patchFn = TEXTAREA_TO_PATCH_FN.get(textarea)
    if (curVnode == null || patchFn == null) return
    textareaElem = curVnode.elm

    patchFn(curVnode, newVnode)
  }
  //...
}

node2Vnode 方法将对象转换为 vnode,区分元素和文本,renderElement 负责元素渲染,renderText 负责文本渲染

JS 复制代码
// packages/core/src/render/node2Vnode.ts
/**
 * 根据 slate node 生成 snabbdom vnode
 */
export function node2Vnode(node, index, parent, editor) {
  let vnode: VNode
  if (Element.isElement(node)) {
    // element
    vnode = renderElement(node, editor)
  } else {
    // text
    vnode = renderText(node, parent, editor)
  }

  return vnode
}

元素渲染 renderElement

以段落为例,段落数据结构如下

js 复制代码
const paragraph = {
  type: 'paragraph',
  children: [{ text: '段落中的一行文本。' }],
}

getRenderElem 根据 type 类型获取对应段落渲染函数,段落的 children 使用 node2Vnode 递归处理,样式使用 renderStyle 处理

JS 复制代码
function renderElement(elemNode, editor): VNode {
  //...
  // 根据 type 生成 vnode 的函数
  const { type, children = [] } = elemNode
  let renderElem = getRenderElem(type)
  
  childrenVnode = children.map((child: Node, index: number) => {
    return node2Vnode(child, index, elemNode, editor)
  })
  
  // 创建 vnode
  let vnode = renderElem(elemNode, childrenVnode, editor)
  
  // 添加文本相关的样式,如 text-align
  if (!isVoid && !isInline) {
    // 非 void + 非 inline
    vnode = renderStyle(elemNode, vnode)
  }
  return vnode
}

在模块注册的时候,使用 registerRenderElemConf 添加到 RENDER_ELEM_CONF 对象上,getRenderElem 根据 type 获取

JS 复制代码
function getRenderElem(type: string): RenderElemFnType {
  const fn = RENDER_ELEM_CONF[type]
  return fn || defaultRender
}

段落渲染函数

js 复制代码
// packages/basic-modules/src/modules/paragraph/render-elem.tsx

function renderParagraph(
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
): VNode {
  const vnode = <p>{children}</p>
  return vnode
}

export const renderParagraphConf = {
  type: 'paragraph',
  renderElem: renderParagraph,
}

文本渲染 renderText

renderText 处理文本节点,主要是样式的处理,例如加粗、下划线、删除线,addTextVnodeStyle 实现

JS 复制代码
function renderText(textNode: SlateText, parent: Ancestor, editor: IDomEditor): VNode {
  //...
  // 根据 decorate 将 text 拆分为多个叶子节点 text[]
  const { decorate } = editor.getConfig()
  if (decorate == null) throw new Error(`Can not get config.decorate`)
  const path = DomEditor.findPath(editor, textNode)
  const ds = decorate([textNode, path])
  const leaves = SlateText.decorations(textNode, ds)

  // 生成 leaves vnode
  const leavesVnode = leaves.map((leafNode, index) => {
    // 文字和样式
    const isLast = index === leaves.length - 1
    let strVnode = genTextVnode(leafNode, isLast, textNode, parent, editor)
    strVnode = addTextVnodeStyle(leafNode, strVnode)
    // 生成每一个 leaf 节点
    return <span data-slate-leaf>{strVnode}</span>
  })

  // 生成 text vnode
  const textId = genTextId(key.id)
  const vnode = (
    <span data-slate-node="text" id={textId} key={key.id}>
      {leavesVnode /* 一个 text 可能包含多个 leaf */}
    </span>
  )

  return vnode
}

addTextVnodeStyle 会从注册的 RENDER_STYLE_HANDLER_LIST 样式数组中遍历,如果节点中有设置了样式属性,则添加该样式

JS 复制代码
/**
 * 给字符串增加样式
 * @param leafNode slate text leaf node
 * @param textVnode textVnode
 */
function addTextVnodeStyle(leafNode: SlateText, textVnode: VNode): VNode {
  let newTextVnode = textVnode

  RENDER_STYLE_HANDLER_LIST.forEach(styleHandler => {
    newTextVnode = styleHandler(leafNode, newTextVnode)
  })

  return newTextVnode
}

插件注册样式 registerStyleHandler 函数,在 Boot.registerRenderStyle 方法调用

JS 复制代码
/**
 * 注册处理文本样式的函数
 * @param fn 处理文本样式的函数
 */
export function registerStyleHandler(fn: RenderStyleFnType) {
  RENDER_STYLE_HANDLER_LIST.push(fn)
}

以文本处理 text-style 为例,根据节点是否有设置样式属性,使用标签包裹文本

JS 复制代码
// packages/basic-modules/src/modules/text-style/render-style.tsx
/**
 * 添加样式
 * @param node slate text
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  const { bold, italic, underline, code, through, sub, sup } = node as StyledText
  let styleVnode: VNode = vnode

  // color bgColor 在另外的菜单

  if (bold) {
    styleVnode = <strong>{styleVnode}</strong>
  }
  if (code) {
    styleVnode = <code>{styleVnode}</code>
  }
  if (italic) {
    styleVnode = <em>{styleVnode}</em>
  }
  if (underline) {
    styleVnode = <u>{styleVnode}</u>
  }
  if (through) {
    styleVnode = <s>{styleVnode}</s>
  }
  if (sub) {
    styleVnode = <sub>{styleVnode}</sub>
  }
  if (sup) {
    styleVnode = <sup>{styleVnode}</sup>
  }

  return styleVnode
}

渲染的流程图

相关推荐
德迅云安全-上官1 分钟前
服务器遭攻击如何快速响应?从技术原理到接入防御架构的实战指南
运维·服务器·架构
不一样的故事1262 分钟前
Prompt 工程(Prompt Engineering)
前端·人工智能·python
super_Omni3 分钟前
PDF/DOCX 解析为纯文本结构化字段关键词打分排序截断推给 HR
架构·开源软件
ZoeLandia5 分钟前
React08:自定义 Hook
开发语言·前端·react.js·typescript
2601_949970696 分钟前
msgpackr 跨端三方库仓颉适配全流程
前端·javascript·flutter
Software攻城狮16 分钟前
【uniapp和普通web 端开发的区别】
前端·uni-app·notepad++
IT_陈寒19 分钟前
Vue项目实战经验分享:动态组件在Keep-Alive下的缓存陷阱
前端·人工智能·后端
数智奔流31 分钟前
MiniMax的低价攻势,撞上“背题”疑云
前端·javascript·算法
刘老高32 分钟前
微服务架构实战:ShardingSphere Proxy + Canal 分库分表与 ES 同步全链路
elasticsearch·微服务·架构
2601_9540056232 分钟前
11 种触发 × 18 种行为:ricon 交互编排系统全解
前端·后端·物联网·数学建模·信息可视化·交互