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更改

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

相关推荐
永乐春秋14 分钟前
WEB攻防-通用漏洞&文件上传&js验证&mime&user.ini&语言特性
前端
鸽鸽程序猿16 分钟前
【前端】CSS
前端·css
ggdpzhk18 分钟前
VUE:基于MVVN的前端js框架
前端·javascript·vue.js
小曲曲1 小时前
接口上传视频和oss直传视频到阿里云组件
javascript·阿里云·音视频
学不会•2 小时前
css数据不固定情况下,循环加不同背景颜色
前端·javascript·html
EasyNTS3 小时前
H.264/H.265播放器EasyPlayer.js视频流媒体播放器关于websocket1006的异常断连
javascript·h.265·h.264
活宝小娜5 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点5 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow5 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o5 小时前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app