wangeditor官方文档

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

  • 基础用法:
 1<script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
 2<script>
 3const { createEditor, createToolbar } = window.wangEditor
 4
 5const editorConfig = {
 6    placeholder: 'Type here...',
 7    onChange(editor) {
 8      const html = editor.getHtml()
 9      console.log('editor content', html)
10      
11    }
12}
13
14const editor = createEditor({
15    selector: '#editor-container',
16    html: '<p><br></p>',
17    config: editorConfig,
18    mode: 'default', 
19})
20
21const toolbarConfig = {}
22
23const toolbar = createToolbar({
24    editor,
25    selector: '#toolbar-container',
26    config: toolbarConfig,
27    mode: 'default', 
28})
29</script>
  • 源码结构:

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

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

 1import { createEditor, Descendant } from 'slate'
 2
 3export default function (option: Partial<ICreateOption>) {
 4  const { selector = '', config = {}, content, html, plugins = [] } = option
 5
 6  
 7  let editor = withHistory(
 8    withMaxLength(
 9      withEmitter(withSelection(withContent(withConfig(withDOM(withEventData(createEditor()))))))
10    )
11  )
12  ...
13  
14  plugins.forEach(plugin => {
15    editor = plugin(editor)
16  })
17  editor.children = ...
18
19  if (selector) {
20    
21    const textarea = new TextArea(selector)
22    EDITOR_TO_TEXTAREA.set(editor, textarea)
23    TEXTAREA_TO_EDITOR.set(textarea, editor)
24    textarea.changeViewState() 
25  } 
26  return editor
27}

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

  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
 1function updateView(textarea: TextArea, editor: IDomEditor) {
 2
 3  const elemId = genElemId(textarea.id)
 4  
 5  const newVnode = genRootVnode(elemId, readOnly)
 6  const content = editor.children || []
 7  newVnode.children = content.map((node, i) => {
 8    let vnode = node2Vnode(node, i, editor, editor)
 9    normalizeVnodeData(vnode) 
10    return vnode
11  })
12
13  ...
14  if (isFirstPatch) {
15    
16    const $textArea = genRootElem(elemId, readOnly)
17    $scroll.append($textArea)
18    textarea.$textArea = $textArea 
19    textareaElem = $textArea[0]
20
21    
22    const patchFn = genPatchFn()
23    patchFn(textareaElem, newVnode)
24
25    
26    IS_FIRST_PATCH.set(textarea, false) 
27    TEXTAREA_TO_PATCH_FN.set(textarea, patchFn) 
28  } else {
29    
30    const curVnode = TEXTAREA_TO_VNODE.get(textarea)
31    const patchFn = TEXTAREA_TO_PATCH_FN.get(textarea)
32    if (curVnode == null || patchFn == null) return
33    textareaElem = curVnode.elm
34
35    patchFn(curVnode, newVnode)
36  }
37
38 
39    textareaElem = getElementById(elemId)
40  ...
41
42  EDITOR_TO_ELEMENT.set(editor, textareaElem) 
43  NODE_TO_ELEMENT.set(editor, textareaElem)
44  ELEMENT_TO_NODE.set(textareaElem, editor)
45  TEXTAREA_TO_VNODE.set(textarea, newVnode) 
46}

node2Vnode

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

 1export function node2Vnode(node: Node, index: number, parent: Ancestor, editor: IDomEditor): VNode {
 2  
 3  NODE_TO_INDEX.set(node, index)
 4  NODE_TO_PARENT.set(node, parent)
 5
 6  let vnode: VNode
 7  if (Element.isElement(node)) {
 8    
 9    vnode = renderElement(node as Element, editor)
10  } else {
11    
12    vnode = renderText(node as Text, parent, editor)
13  }
14
15  return vnode
16}
17
18function renderElement(elemNode: SlateElement, editor: IDomEditor): VNode {
19  ...
20  const { type, children = [] } = elemNode
21  let childrenVnode
22  if (isVoid) {
23    childrenVnode = null 
24  } else {
25    childrenVnode = children.map((child: Node, index: number) => {
26      return node2Vnode(child, index, elemNode, editor)
27    })
28  }
29
30  
31  let vnode = renderElem(elemNode, childrenVnode, editor)
32  ...
33  return vnode
34}

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

coreCreateToolbar

  • createToolbar的主要逻辑是coreCreateToolbar
  • coreCreateToolbar主要逻辑是 new Toolbar()
  • 实例化 Toolbar主要就是生成 菜单元素并挂载在 传入的 selector节点,此外一个重要功能就是*注册菜单功能,我们主要看看注册单个 toolbarItem的逻辑
  • 最终逻辑在 BaseButton,这也证实我们推测,渲染的按钮绑定的事件逻辑 menu.exec(editor, value)
 1 
 2  class Toolbar {
 3      $box: Dom7Array
 4      private readonly $toolbar: Dom7Array = $(`<div class="w-e-bar w-e-bar-show w-e-toolbar"></div>`)
 5      private menus: { [key: string]: MenuType } = {}
 6      private toolbarItems: IBarItem[] = []
 7      private config: Partial<IToolbarConfig> = {}
 8
 9      constructor(boxSelector: string | DOMElement, config: Partial<IToolbarConfig>) {
10        this.config = config
11        this.$box = $box
12        const $toolbar = this.$toolbar
13        $toolbar.on('mousedown', e => e.preventDefault(), { passive: false }) 
14        $box.append($toolbar)
15        
16        promiseResolveThen(() => {
17          
18          this.registerItems()
19
20          
21          this.changeToolbarState()
22
23          
24          const editor = this.getEditorInstance()
25          editor.on('change', this.changeToolbarState)
26        })
27      }
28
29      private registerSingleItem(key: string, container: GroupButton | Toolbar) {
30        const editor = this.getEditorInstance()
31        const { menus } = this
32        let menu = menus[key]
33
34        if (menu == null) {
35          
36          const factory = MENU_ITEM_FACTORIES[key]
37          menu = factory()
38          menus[key] = menu
39        } 
40
41        const toolbarItem = createBarItem(key, menu, inGroup)
42        this.toolbarItems.push(toolbarItem)
43
44        
45        BAR_ITEM_TO_EDITOR.set(toolbarItem, editor)
46
47       ...
48        toolbar.$toolbar.append(toolbarItem.$elem)
49      }
50      
51      ...
52  }
53  
54  export function createBarItem(key: string, menu: MenuType, inGroup: boolean = false): IBarItem {
55  if (tag === 'button') {
56    const { showDropPanel, showModal } = menu
57    if (showDropPanel) {
58      barItem = new DropPanelButton(key, menu as IDropPanelMenu, inGroup)
59    } else if (showModal) {
60      barItem = new ModalButton(key, menu as IModalMenu, inGroup)
61    } else {
62      barItem = new SimpleButton(key, menu, inGroup)
63    }
64  }
65  if (tag === 'select') {
66    barItem = new Select(key, menu as ISelectMenu, inGroup)
67  }
68  return barItem
69}
70
71
72
73class SimpleButton extends BaseButton {
74  constructor(key: string, menu: IButtonMenu, inGroup = false) {
75    super(key, menu, inGroup)
76  }
77  onButtonClick() {
78    
79    
80  }
81}
82
83
84abstract class BaseButton implements IBarItem {
85  readonly $elem: Dom7Array = $(`<div class="w-e-bar-item"></div>`)
86  protected readonly $button: Dom7Array = $(`<button type="button"></button>`)
87  menu: IButtonMenu | IDropPanelMenu | IModalMenu
88  private disabled = false
89
90  constructor(key: string, menu: IButtonMenu | IDropPanelMenu | IModalMenu, inGroup = false) {
91    this.menu = menu
92    const { tag, width } = menu
93
94    
95    const { title, hotkey = '', iconSvg = '' } = menu
96    const { $button } = this
97    if (iconSvg) {
98      const $svg = $(iconSvg)
99      clearSvgStyle($svg) 
100      $button.append($svg)
101    } else {
102      $button.text(title)
103    }
104    addTooltip($button, iconSvg, title, hotkey, inGroup) 
105    if (width) {
106      $button.css('width', `${width}px`)
107    }
108    $button.attr('data-menu-key', key) 
109    this.$elem.append($button)
110
111    
112    promiseResolveThen(() => this.init())
113  }
114
115  private init() {
116    this.setActive()
117    this.setDisabled()
118
119    this.$button.on('click', e => {
120      e.preventDefault()
121      const editor = getEditorInstance(this)
122      editor.hidePanelOrModal() 
123      if (this.disabled) return
124
125      this.exec() 
126      this.onButtonClick() 
127    })
128  }
129
130
131  private exec() {
132    const editor = getEditorInstance(this)
133    const menu = this.menu
134    const value = menu.getValue(editor)
135    menu.exec(editor, value)
136  }
137
138  
139  abstract onButtonClick(): void
140
141  private setActive() {
142    const editor = getEditorInstance(this)
143    const { $button } = this
144    const active = this.menu.isActive(editor)
145
146    const className = 'active'
147    if (active) {
148      
149      $button.addClass(className)
150    } else {
151      
152      $button.removeClass(className)
153    }
154  }
155  private setDisabled() {...}
156  changeMenuState() {
157    this.setActive()
158    this.setDisabled()
159  }
160}

registerModule

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

 1
 2basicModules.forEach(module => registerModule(module))
 3registerModule(wangEditorListModule)
 4registerModule(wangEditorTableModule)
 5registerModule(wangEditorVideoModule)
 6registerModule(wangEditorUploadImageModule)
 7registerModule(wangEditorCodeHighlightModule)
 8
 9
10function registerModule(module: Partial<IModuleConf>) {
11  const {
12    menus,
13    renderElems,
14    renderStyle,
15    elemsToHtml,
16    styleToHtml,
17    preParseHtml,
18    parseElemsHtml,
19    parseStyleHtml,
20    editorPlugin,
21  } = module
22
23  if (menus) {
24    menus.forEach(menu => Boot.registerMenu(menu))
25  }
26  if (renderElems) {
27    renderElems.forEach(renderElemConf => Boot.registerRenderElem(renderElemConf))
28  }
29  if (renderStyle) {
30    Boot.registerRenderStyle(renderStyle)
31  }
32  if (elemsToHtml) {
33    elemsToHtml.forEach(elemToHtmlConf => Boot.registerElemToHtml(elemToHtmlConf))
34  }
35  if (styleToHtml) {
36    Boot.registerStyleToHtml(styleToHtml)
37  }
38  if (preParseHtml) {
39    preParseHtml.forEach(conf => Boot.registerPreParseHtml(conf))
40  }
41  if (parseElemsHtml) {
42    parseElemsHtml.forEach(parseElemHtmlConf => Boot.registerParseElemHtml(parseElemHtmlConf))
43  }
44  if (parseStyleHtml) {
45    Boot.registerParseStyleHtml(parseStyleHtml)
46  }
47  if (editorPlugin) {
48    Boot.registerPlugin(editorPlugin)
49  }
50}
51
52  
53export function registerMenu(
54  registerMenuConf: IRegisterMenuConf,
55  customConfig?: { [key: string]: any }
56) {
57  const { key, factory, config } = registerMenuConf
58  const newConfig = { ...config, ...(customConfig || {}) }
59
60  MENU_ITEM_FACTORIES[key] = factory
61
62  
63  registerGlobalMenuConf(key, newConfig)
64}

module格式

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

 1export interface IModuleConf {
 2  
 3  menus: Array<IRegisterMenuConf>
 4
 5  
 6  renderStyle: RenderStyleFnType
 7  renderElems: Array<IRenderElemConf>
 8
 9  
10  styleToHtml: styleToHtmlFnType
11  elemsToHtml: Array<IElemToHtmlConf>
12
13  
14  preParseHtml: Array<IPreParseHtmlConf>
15  parseStyleHtml: ParseStyleHtmlFnType
16  parseElemsHtml: Array<IParseElemHtmlConf>
17
18  
19  editorPlugin: <T extends IDomEditor>(editor: T) => T
20}

module menus格式

富文本编辑器 系列文章:

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

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

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

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

个人笔记记录 2021 ~ 2025