wangeditor源码分析

wangeditor官方文档

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

基础用法:

html 复制代码
<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>

createEditor 源码

docs/dev.md 可看到准备工作: 了解 slate.js、了解 vdom 和 snabbdom.js、了解 lerna。

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

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

js 复制代码
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代码位置packages/core/src/text-area/update-view.ts

  • 1 首先对textarea dom预处理,将editor.children处理生成newVnode
  • 2 通过 snabbdom 的 patch方法将 editor.children 的newVnode更新到textareaElem
ts 复制代码
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 代码位置packages/core/src/render/node2Vnode.ts 主要功能是根据 editor对象生成对应的vnode,可以看出也是一个深度优先遍历来处理节点

ts 复制代码
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的逻辑
  • 最终逻辑在 BaseButton,这也证实我们推测,渲染的按钮绑定的事件逻辑 menu.exec(editor, value)
ts 复制代码
 // ----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--------------
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

通过前面的 coreCreateToolbar 代码分析,我们知道主要是取 MENU_ITEM_FACTORIES[key]的数据进行menus的初始化。在项目的入口位置import './register-builtin-modules/index'执行registerModule,注册内置模块。

ts 复制代码
//-------------register-builtin-modules/index----------
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格式

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

ts 复制代码
export interface IModuleConf {
  // 1、注册菜单
  menus: Array<IRegisterMenuConf>

  // 2、渲染 modal -> view
  renderStyle: RenderStyleFnType
  renderElems: Array<IRenderElemConf>

  // 3、获取html的时候的处理
  styleToHtml: styleToHtmlFnType
  elemsToHtml: Array<IElemToHtmlConf>

  // 4、预处理html格式,parse html
  preParseHtml: Array<IPreParseHtmlConf>
  parseStyleHtml: ParseStyleHtmlFnType
  parseElemsHtml: Array<IParseElemHtmlConf>

  // 5、注册插件
  editorPlugin: <T extends IDomEditor>(editor: T) => T
}

写在最后!!!

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

  1. 文本标签 input 和 textarea它们都不能设置丰富的样式,于是我们采用 contenteditable 属性的编辑框,常规做法是结合 document.execCommand 命令对编辑区域的元素进行设置,这就类似于我们初代的前端代码原生js/jquery,更改页面效果通过对真实dom的增删改查;而wangeditorslate-react采用了一个新的思想,这就类似我们用 react/vue等框架开发页面,通过数据驱动的思想更改页面的元素。
  2. 分析 wangeditorslate-react源码我们可以看出两者功能类似,都是将 slate->createEditor()生成的editor对象转化为vnode,然后将虚拟dom挂载在带有 contenteditable 属性的节点上;slate-react是基于react,wangeditor是通过snabbdom.js,做到了与框架无关
  3. 菜单工具的原理无非是渲染各种按钮,给按钮绑定点击事件。事件用于更改编辑器区域的内容,它主要思路就是 通过 editor的api 对其children node更改

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

相关推荐
gnip20 小时前
链式调用和延迟执行
前端·javascript
SoaringHeart20 小时前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.20 小时前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu20 小时前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss20 小时前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师20 小时前
React面试题
前端·javascript·react.js
木兮xg20 小时前
react基础篇
前端·react.js·前端框架
ssshooter20 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘21 小时前
HTML--最简的二级菜单页面
前端·html
yume_sibai21 小时前
HTML HTML基础(4)
前端·html