wangeditor源码分析

1,531 阅读8分钟

wangeditor官方文档

前言

我们项目使用的是 wangeditor,它是国内一个开发者开源的,功能基本足够,样式主流。

基础用法:

<script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
<script>
const { createEditor, createToolbar } = window.wangEditor

const editorConfig = {
    placeholder: 'Type here...',
    onChange(editor) {
      const html = editor.getHtml()
      console.log('editor content', html)
      // 也可以同步到 <textarea>
    }
}

const editor = createEditor({
    selector: '#editor-container',
    html: '<p><br></p>',
    config: editorConfig,
    mode: 'default', // or 'simple'
})

const toolbarConfig = {}

const toolbar = createToolbar({
    editor,
    selector: '#toolbar-container',
    config: toolbarConfig,
    mode: 'default', // or 'simple'
})
</script>

源码思路

  • wangeditor底层基于slate和snabbdom.js。通过将slate->createEditor()生成的editor对象转化为vnode,然后借助snabbdom.jsapi将vnode挂载在带有 contenteditable 属性的节点上;
  • 工具栏 功能就是通过操作 editor对象 的api更改 vnode的结构,进而操作编辑器的内容;
  • module模块 主要预定义editor.children、html与vnode的转换规则
  • 结构:

editor是入口位置,core实现基础的编辑器功能,其他文件夹是编辑器扩展的插件功能
image.png

image.png

createEditor 源码

createEditor主要逻辑是执行 coreCreateEditorcoreCreateEditor的代码位置 packages/core/src/create/create-editor.ts:

import { createEditor, Descendant } from 'slate'

export default function (option: Partial<ICreateOption>) {
  const { selector = '', config = {}, content, html, plugins = [] } = option

  // 创建实例 - 使用插件
  let editor = withHistory(
    withMaxLength(
      withEmitter(withSelection(withContent(withConfig(withDOM(withEventData(createEditor()))))))
    )
  )
  ...
  // 注册第三方插件
  plugins.forEach(plugin => {
    editor = plugin(editor)
  })
  editor.children = ...

  if (selector) {
    // 传入了 selector ,则创建 textarea DOM
    const textarea = new TextArea(selector)
    EDITOR_TO_TEXTAREA.set(editor, textarea)
    TEXTAREA_TO_EDITOR.set(textarea, editor)
    textarea.changeViewState() // 初始化时触发一次,以便能初始化 textarea DOM 和 selection
  } 
  return editor
}

可以看出这里很重要的的几步:

  1. 创建editor实例,这里用到了slate的功能,withEventData...withSelection都是对editor实例属性的扩展;

  2. 注册第三方插件,对应packages下面其他6个文件夹:基础模块、代码高亮、列表、table、上传图片、视频。后面继续解读。

  3. 创建实例TextArea,将editor对应的vnode挂载在textArea dom上

    初次以及后面内容变动,调用textarea.changeViewState,该方法主要执行 updateView(this, editor)方法

updateView

updateView代码位置packages/core/src/text-area/update-view.ts

  • 3.1 首先对textarea dom预处理,将editor.children处理生成newVnode
  • 3.2 通过 snabbdom 的 patch方法将 editor.children 的newVnode更新到textareaElem
function updateView(textarea: TextArea, editor: IDomEditor) {

  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
  })

  ...
  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)
  }

 
    textareaElem = getElementById(elemId)
  ...

  EDITOR_TO_ELEMENT.set(editor, textareaElem) // 存储 editor -> elem 对应关系
  NODE_TO_ELEMENT.set(editor, textareaElem)
  ELEMENT_TO_NODE.set(textareaElem, editor)
  TEXTAREA_TO_VNODE.set(textarea, newVnode) // 存储 vnode
}

node2Vnode

node2Vnode 代码位置packages/core/src/render/node2Vnode.ts 主要功能是根据 editor对象生成对应的vnode,可以看出也是一个深度优先遍历来处理节点

export function node2Vnode(node: Node, index: number, parent: Ancestor, editor: IDomEditor): VNode {
  // 设置相关 weakMap 信息
  NODE_TO_INDEX.set(node, index)
  NODE_TO_PARENT.set(node, parent)

  let vnode: VNode
  if (Element.isElement(node)) {
    // element
    vnode = renderElement(node as Element, editor)
  } else {
    // text
    vnode = renderText(node as Text, parent, editor)
  }

  return vnode
}

function renderElement(elemNode: SlateElement, editor: IDomEditor): VNode {
  ...
  const { type, children = [] } = elemNode
  let childrenVnode
  if (isVoid) {
    childrenVnode = null // void 节点 render elem 时不传入 children
  } else {
    childrenVnode = children.map((child: Node, index: number) => {
      return node2Vnode(child, index, elemNode, editor)
    })
  }

  // 创建 vnode
  let vnode = renderElem(elemNode, childrenVnode, editor)
  ...
  return vnode
}

createToolbar源码

菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域的内容,在阅读源码前,我们大概可以推测它主要思路就是 通过 editor的api 对其children node更改

coreCreateToolbar

  • createToolbar的主要逻辑是coreCreateToolbar
  • coreCreateToolbar主要逻辑是 new Toolbar()
  • 实例化 Toolbar主要就是生成 菜单元素并挂载在 传入的 selector节点,此外一个重要功能就是*注册菜单功能,我们主要看看注册单个 toolbarItem的逻辑
    menus的数据来自 MENU_ITEM_FACTORIES,后面我们分析该数据。并根据该数据结构key的不同,调用createBarItem 渲染不同的 工具栏 类型
 // ----Toolbar--------------
  class Toolbar {
      $box: Dom7Array
      private readonly $toolbar: Dom7Array = $(`<div class="w-e-bar w-e-bar-show w-e-toolbar"></div>`)
      private menus: { [key: string]: MenuType } = {}
      private toolbarItems: IBarItem[] = []
      private config: Partial<IToolbarConfig> = {}

      constructor(boxSelector: string | DOMElement, config: Partial<IToolbarConfig>) {
        this.config = config
        this.$box = $box
        const $toolbar = this.$toolbar
        $toolbar.on('mousedown', e => e.preventDefault(), { passive: false }) // 防止点击失
        $box.append($toolbar)
        // 异步,否则拿不到 editor 实例
        promiseResolveThen(() => {
          // 注册 items
          this.registerItems()

          // 创建完,先模拟一次 onchange
          this.changeToolbarState()

          // 监听 editor onchange
          const editor = this.getEditorInstance()
          editor.on('change', this.changeToolbarState)
        })
      }

      private registerSingleItem(key: string, container: GroupButton | Toolbar) {
        const editor = this.getEditorInstance()
        const { menus } = this
        let menu = menus[key]

        if (menu == null) {
          // 缓存中没有,则创建
          const factory = MENU_ITEM_FACTORIES[key]
          menu = factory()
          menus[key] = menu
        } 

        const toolbarItem = createBarItem(key, menu, inGroup)
        this.toolbarItems.push(toolbarItem)

        // 保存 toolbarItem 和 editor 的关系
        BAR_ITEM_TO_EDITOR.set(toolbarItem, editor)

       ...
        toolbar.$toolbar.append(toolbarItem.$elem)
      }
      
      ...
  }
  
  
  // ----createBarItem--------------
  export function createBarItem(key: string, menu: MenuType, inGroup: boolean = false): IBarItem {
  if (tag === 'button') {
    const { showDropPanel, showModal } = menu
    if (showDropPanel) {
      barItem = new DropPanelButton(key, menu as IDropPanelMenu, inGroup)
    } else if (showModal) {
      barItem = new ModalButton(key, menu as IModalMenu, inGroup)
    } else {
      barItem = new SimpleButton(key, menu, inGroup)
    }
  }
  if (tag === 'select') {
    barItem = new Select(key, menu as ISelectMenu, inGroup)
  }
  return barItem
}

SimpleButton

前面我们分析 通过menus数据的不同 类型,渲染不同的 工具栏 类型,这里我们看看 SimpleButton 代码。
它继承 BaseButton,这也证实我们推测,渲染的按钮绑定的事件逻辑 menu.exec(editor, value)。后面我们在分析 menu的结构吧

//----SimpleButton--------------
class SimpleButton extends BaseButton {
  constructor(key: string, menu: IButtonMenu, inGroup = false) {
    super(key, menu, inGroup)
  }
  onButtonClick() {
    // menu.exec 已经在 BaseButton 实现了
    // 所以,此处不用做任何逻辑
  }
}

//----BaseButton--------------
abstract class BaseButton implements IBarItem {
  readonly $elem: Dom7Array = $(`<div class="w-e-bar-item"></div>`)
  protected readonly $button: Dom7Array = $(`<button type="button"></button>`)
  menu: IButtonMenu | IDropPanelMenu | IModalMenu
  private disabled = false

  constructor(key: string, menu: IButtonMenu | IDropPanelMenu | IModalMenu, inGroup = false) {
    this.menu = menu
    const { tag, width } = menu

    // 初始化 dom 
    const { title, hotkey = '', iconSvg = '' } = menu
    const { $button } = this
    if (iconSvg) {
      const $svg = $(iconSvg)
      clearSvgStyle($svg) // 清理 svg 样式(扩展的菜单,svg 是不可控的,所以要清理一下)
      $button.append($svg)
    } else {
      $button.text(title)// 无 icon 则显示 title
    }
    addTooltip($button, iconSvg, title, hotkey, inGroup) // 设置 tooltip
    if (width) {
      $button.css('width', `${width}px`)
    }
    $button.attr('data-menu-key', key) // menu key
    this.$elem.append($button)

    // 异步绑定事件 
    promiseResolveThen(() => this.init())
  }

  private init() {
    this.setActive()
    this.setDisabled()

    this.$button.on('click', e => {
      e.preventDefault()
      const editor = getEditorInstance(this)
      editor.hidePanelOrModal() // 隐藏当前的各种 panel
      if (this.disabled) return

      this.exec() // 执行 menu.exec
      this.onButtonClick() // 执行其他的逻辑
    })
  }


  private exec() {
    const editor = getEditorInstance(this)
    const menu = this.menu
    const value = menu.getValue(editor)
    menu.exec(editor, value)
  }

  // 交给子类去扩展
  abstract onButtonClick(): void

  private setActive() {
    const editor = getEditorInstance(this)
    const { $button } = this
    const active = this.menu.isActive(editor)

    const className = 'active'
    if (active) {
      // 设置为 active
      $button.addClass(className)
    } else {
      // 取消 active
      $button.removeClass(className)
    }
  }
  private setDisabled() {...}
  changeMenuState() {
    this.setActive()
    this.setDisabled()
  }
}

registerModule

通过我们分析menu取自 MENU_ITEM_FACTORIES[key],它的初始化在项目的入口位置import './register-builtin-modules/index'执行registerModule,注册内置模块module

basicModules.forEach(module => registerModule(module))
registerModule(wangEditorListModule)
registerModule(wangEditorTableModule)
registerModule(wangEditorVideoModule)
registerModule(wangEditorUploadImageModule)
registerModule(wangEditorCodeHighlightModule)


function registerModule(module: Partial<IModuleConf>) {
  const {
    menus,
    renderElems,
    renderStyle,
    elemsToHtml,
    styleToHtml,
    preParseHtml,
    parseElemsHtml,
    parseStyleHtml,
    editorPlugin,
  } = module

  if (menus) {
    menus.forEach(menu => Boot.registerMenu(menu))
  }
  
  if (renderElems) {
    renderElems.forEach(renderElemConf => Boot.registerRenderElem(renderElemConf))
  }
  if (renderStyle) {
    Boot.registerRenderStyle(renderStyle)
  }
  
  if (elemsToHtml) {
    elemsToHtml.forEach(elemToHtmlConf => Boot.registerElemToHtml(elemToHtmlConf))
  }
  if (styleToHtml) {
    Boot.registerStyleToHtml(styleToHtml)
  }
  
  if (preParseHtml) {
    preParseHtml.forEach(conf => Boot.registerPreParseHtml(conf))
  }
  if (parseElemsHtml) {
    parseElemsHtml.forEach(parseElemHtmlConf => Boot.registerParseElemHtml(parseElemHtmlConf))
  }
  if (parseStyleHtml) {
    Boot.registerParseStyleHtml(parseStyleHtml)
  }
  
  if (editorPlugin) {
    Boot.registerPlugin(editorPlugin)
  }
}

  
export function registerMenu(
  registerMenuConf: IRegisterMenuConf,
  customConfig?: { [key: string]: any }
) {
  const { key, factory, config } = registerMenuConf
  const newConfig = { ...config, ...(customConfig || {}) }

  MENU_ITEM_FACTORIES[key] = factory

  // 将 config 保存到全局
  registerGlobalMenuConf(key, newConfig)
}

module格式如下 Partial<IModuleConf>,取IModuleConf的部分属性,分为5类作用,下面分别分析

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

  // 2、定义 editor.children 如何生成 vnode
  renderStyle: RenderStyleFnType
  renderElems: Array<IRenderElemConf>

  // 3、editor.getHtml 返回的html内容
  styleToHtml: styleToHtmlFnType
  elemsToHtml: Array<IElemToHtmlConf>

  // 4、editor.setHtml 返回的editor.children内容
  preParseHtml: Array<IPreParseHtmlConf>
  parseStyleHtml: ParseStyleHtmlFnType
  parseElemsHtml: Array<IParseElemHtmlConf>

  // 5、注册插件,扩展editor对象
  editorPlugin: <T extends IDomEditor>(editor: T) => T
}

module menus格式

menus是一个数组类型,每一项 是一个 IRegisterMenuConf 接口类型,定义如下。

  • 路径:createToolbar -> createBarItem
  • 注册: Boot.registerMenu(menu) -> MENU_ITEM_FACTORIES
  • 作用: 上面 生成 createToolbar 的时候,定义它的样式和点击功能
export interface IRegisterMenuConf {
  key: string
  factory: () => IButtonMenu | ISelectMenu | IDropPanelMenu | IModalMenu
  config?: { [key: string]: any }
}

export interface IButtonMenu extends IBaseMenu {
  /* 其他属性 */
}

interface IBaseMenu {
  readonly title: string
  readonly iconSvg?: string
  readonly hotkey?: string // 快捷键,使用 https://www.npmjs.com/package/is-hotkey
  readonly alwaysEnable?: boolean // 永远不 disabled ,如“全屏”

  readonly tag: string // 'button' | 'select'
  readonly width?: number // 设置 button 宽度

  getValue: (editor: IDomEditor) => string | boolean // 获取菜单相关的 val 。如是否加粗、颜色值、h1/h2/h3 等
  isActive: (editor: IDomEditor) => boolean // 是否激活菜单,如选区处于加粗文本时,激活 bold
  isDisabled: (editor: IDomEditor) => boolean // 是否禁用菜单,如选区处于 code-block 时,禁用 bold 等样式操作

  exec: (editor: IDomEditor, value: string | boolean) => void // button click 或 select change 时触发
}

module renderStyle

  • 引用路径: updateView -> vnode=node2Vnode(..) -> renderElement -> renderStyle
  • 注册: Boot.registerRenderElem -> RENDER_ELEM_CONF
  • 作用:定义 editor.children 如何生成 vnode
export type RenderStyleFnType = (node: Descendant, vnode: VNode) => VNode

module styleToHtml

  • 引用路径: editor.getHtml -> elemToHtml ->
  • 注册: Boot.registerStyleToHtml(styleToHtml) -> STYLE_TO_HTML_FN_LIST
  • 作用:editor.getHtml 返回的html内容
export type styleToHtmlFnType = (node: Descendant, elemHtml: string) => string

module preParseHtml

  • 引用路径: editor.setHtml -> htmlToContent -> parseElemHtml
  • 注册: Boot.registerPreParseHtml -> PRE_PARSE_HTML_CONF_LIST
  • 作用:editor.setHtml 返回的editor.children内容
export interface IPreParseHtmlConf {
  selector: string // css 选择器,如 `p` `div[data-type="xxx"]`
  preParseHtml: ($node: DOMElement) => DOMElement
}

module editorPlugin

  • 路径: editor = plugin(editor)
  • 注册: Boot.registerPlugin -> Boot.plugins
  • 作用:更改扩展 Editor对象的属性
export interface IDomEditor extends Editor {
  // data 相关(粘贴、拖拽等)
  insertData: (data: DataTransfer) => void
  setFragmentData: (data: Pick<DataTransfer, 'getData' | 'setData'>) => void

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

  // 内容处理
  handleTab: () => void
  getHtml: () => string
  getText: () => string
  getSelectionText: () => string // 获取选区文字
  getElemsByTypePrefix: (typePrefix: string) => ElementWithId[]
  getElemsByType: (type: string, isPrefix?: boolean) => ElementWithId[]
  getParentNode: (node: Node) => Ancestor | null
  isEmpty: () => boolean
  clear: () => void
  dangerouslyInsertHtml: (html: string, isRecursive?: boolean) => void
  setHtml: (html: string) => void

  // dom 相关
  id: string
  isDestroyed: boolean
  isFullScreen: boolean
  focus: (isEnd?: boolean) => 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
  getEditableContainer: () => DOMElement

  // selection 相关
  select: (at: Location) => void
  deselect: () => void
  move: (distance: number, reverse?: boolean) => void
  moveReverse: (distance: number) => void
  restoreSelection: () => void
  getSelectionPosition: () => Partial<IPositionStyle>
  getNodePosition: (node: Node) => Partial<IPositionStyle>
  isSelectedAll: () => boolean
  selectAll: () => void

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

  // undo redo - 不用自己实现,使用 slate-history 扩展
  undo?: () => void
  redo?: () => void
}

应用: 自定义扩展新功能

通过对上面 module 结构的分析,我们就可以很轻松自定义扩展新功能了

注册新菜单 官网示例

  • 第一,定义菜单 class
import { IButtonMenu, IDomEditor } from '@wangeditor/editor'

class MyButtonMenu implements IButtonMenu {   // TS 语法
    constructor() {
        this.title = 'My menu title' // 自定义菜单标题
        this.tag = 'button'
    }

    // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
    isActive(editor: IDomEditor): boolean {  // TS 语法
        return false
    }

    // 点击菜单时触发的函数
    exec(editor: IDomEditor, value: string | boolean) {   // TS 语法
        if (this.isDisabled(editor)) return
        editor.insertText(value) // value 即 this.value(editor) 的返回值
    }

}
  • 第二 ,注册菜单到 wangEditor
import { Boot } from '@wangeditor/editor'
const menu1Conf = {
  key: 'menu1', // 定义 menu key :要保证唯一、不重复(重要)
  factory() {
    return new YourMenuClass() // 把 `YourMenuClass` 替换为你菜单的 class
  },
}
Boot.registerMenu(menu1Conf)
  • 第三 ,插入菜单到工具栏,createToolbar的时候传入
toolbarConfig.toolbarKeys = [
    // 菜单 key
    'headerSelect',

    // 分割线
    '|',

    // 菜单 key
    'bold', 'italic',

    // 菜单组,包含多个菜单
    {
        key: 'group-more-style', // 必填,要以 group 开头
        title: '更多样式', // 必填
        iconSvg: '<svg>....</svg>', // 可选
        menuKeys: ["through", "code", "clearStyle"] // 下级菜单 key ,必填
    },
    // 继续配置其他菜单...
    'menu1'
]


const toolbar = createToolbar({
    editor,
    selector: '#toolbar-container',
    config: toolbarConfig,
    mode: 'default', // or 'simple'
})

注册插件到 wangEditor

可以 劫持编辑器事件和操作

import { IDomEditor ,Boot } from '@wangeditor/editor'

function withBreakAndDelete<T extends IDomEditor>(editor: T): T {   // TS 语法
    const { insertBreak, deleteBackward } = editor // 获取当前 editor API
    const newEditor = editor

    // 重写 insertBreak 换行
    newEditor.insertBreak = () => {
        // if: 是 ctrl + enter ,则执行 insertBreak
        insertBreak()
        // else: 则不执行换行
        return
    }

    // 重写 deleteBackward 向后删除
    newEditor.deleteBackward = unit => {
        // if: 某种情况下,执行默认的删除
        deleteBackward(unit)
        // else: 其他情况,则不执行删除
        return
    }

    // 重写其他 API ...

    // 返回 newEditor ,重要!
    return newEditor
}


Boot.registerPlugin(withBreakAndDelete)

定义新元素

  1. 定义一个 节点数据结构 {type:'',children;[]}
  2. 通过 Boot.registerRenderElem 自定义 editor.children 如何生成 vnode
  3. 通过 Boot.registerElemToHtml 自定义editor.getHtml() 获取的 HTML
  4. 通过 Boot.registerParseElemHtml自定义editor.setHtml() 获取的 editor.children

写在最后!!!

富文本编辑器 系列文章:

  1. 富文本editor
  2. wangeditor源码分析
  3. slate源码解读

通过对编辑器源码的解读,我学会了很多新思想,下面总结一下

  • model层:slate管理了一个 immer不可变类型的 nodes元素、selection选区 slate editor
  • view层:wangeditorslate-react利用 slate editor的数据来渲染视图
  1. 文本标签 input 和 textarea它们都不能设置丰富的样式,于是我们采用 contenteditable 属性的编辑框,常规做法是结合 document.execCommand 命令对编辑区域的元素进行设置,这就类似于我们初代的前端代码原生js/jquery,更改页面效果通过对真实dom的增删改查;而wangeditorslate-react采用了一个新的思想,这就类似我们用 react/vue等框架开发页面,通过数据驱动的思想更改页面的元素。

  2. MV:分析 wangeditorslate-react源码我们可以看出两者功能类似,都是将 slate editor 转化为vnode,然后将vnode挂载在带有 contenteditable 属性的节点上;slate-react是基于react,wangeditor是通过snabbdom.js,做到了与框架无关

  3. VM:菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域slate editor的内容,它主要思路就是 通过 editor的api 对其nodes元素、selection选区更改

欢迎关注我的前端自检清单,我和你一起成长