WangEditor中插入Vue组件

lxf2023-05-20 00:56:25

起因

领导突发奇想,想在富文本编辑器上插入一个画板,可以在上面操作一通还能保存数据和重新渲染,我们的技术栈是vue2,使用的富文本编辑器是WangEditor。

WangEditor提供了定义新元素的功能,不过插入的是原生html的vnode,不支持vue的vnode。一通研究过后,找到了解决方案,下面总结下开发经过。

准备阶段

这里使用一个CountBtn作为案例代码如下:

export default {
  props: {
    disabled: {
      type: Boolean
    },
    defaultValue: {
      type: String
    },
    updateValue: {
      type: Function
    },
  },
  data() {
    return {
      value: 0
    }
  },
  created() {
    console.log('111')
    this.value = parseInt(this.defaultValue)
  },
  methods: {
    addOne() {
      this.value += 1
      if (typeof this.updateValue === 'function') {
        this.updateValue(this.value)
      }
    }
  },
  beforeDestroy() {
    console.log('destroy')
  },
  render(h) {
    return <div>
    { this.value }
    <button onClick={() => this.addOne()}>+1</button>
  </div>
  }
}

按照官方教程,我们需要先定义一个slate node的数据结构:

{
  type: 'countbtn',
  vueValue: 0,
  children: [{ text: '' }]  // void 元素必须有一个 children ,其中只有一个空字符串,重要!!!
}

我们还需要一个菜单用来插入这个元素,按照官方的注册新菜单流程走一遍,我的菜单如下:

class MyButtonMenu {                       // JS 语法
  constructor() {
    this.title = 'countbtn' // 自定义菜单标题
    // this.iconSvg = '<svg>...</svg>' // 可选
    this.tag = 'button'
  }
  // 获取菜单执行时的 value ,用不到则返回空 字符串或 false
  getValue() {                              // JS 语法
    return false
  }
  // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
  isActive() {                    // JS 语法
    return false
  }
  // 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
  isDisabled() {                     // JS 语法
    return false
  }
  // 点击菜单时触发的函数
  exec(editor) {                              // JS 语法
    if (this.isDisabled(editor)) return
    editor.insertNode({
      type: 'countbtn',
      vueValue: 0,
      children: [{ text: '' }]
    })
  }
}

上面的准备好后,就开始定义新的元素了,下面按照官方定义新元素教程走一遍,我主要是讲解一下如何插入一个vue的组件,下面是基板代码:

import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { h } from 'snabbdom'

export default {
  editorPlugin: function (editor) {                        // JS 语法
    const { isInline, isVoid } = editor
    const newEditor = editor
    newEditor.isInline = elem => {
      const type = DomEditor.getNodeType(elem)
      if (type === 'countbtn') return true // 针对 type: attachment ,设置为 inline
      return isInline(elem)
    }
    newEditor.isVoid = elem => {
      const type = DomEditor.getNodeType(elem)
      if (type === 'countbtn') return true // 针对 type: attachment ,设置为 void
      return isVoid(elem)
    }
    return newEditor // 返回 newEditor ,重要!!!
  }, // 插件
  renderElems: [{
    type: 'countbtn', // 新元素 type ,重要!!!
    renderElem: function (elem, children, editor) {   
      const isDisabled = editor.isDisabled()
      const selected = DomEditor.isNodeSelected(editor, elem)
      const { vueValue } = elem
      // 元素 vnode
      // 重点在如何插入一个vue组件
      const attachVnode = h(
        // HTML tag
        'span',
        // HTML 属性、样式、事件
        {
          props: {
            contentEditable: false,
          }, // HTML 属性,驼峰式写法
          style: {
            display: 'inline-block',
            marginLeft: '3px',
            marginRight: '3px',
            border:
              selected && !isDisabled
                ? '2px solid var(--w-e-textarea-selected-border-color)'
                : '2px solid transparent',
            // borderRadius: '4px'
          }, // style ,驼峰式写法
          dataset: {
            vueValue
          },
        },
      )
      return attachVnode
    },
  }],
  elemsToHtml: [{
    type: 'countbtn', // 新元素的 type ,重要!!!
    elemToHtml: function (elem) {
      const vueValue = elem.vueValue
      // 生成 HTML 代码
      const html = `<span data-w-e-type="countbtn" data-vue-value="${vueValue}"></span>`
      return html
    },
  }, /* 其他元素... */],  // elemToHtml
  parseElemsHtml: [{
    selector: `span[data-w-e-type="countbtn"]`, // CSS 选择器,匹配特定的 HTML 标签
    parseElemHtml: function (domElem) {       
      const vueValue = domElem.getAttribute('data-vue-value') || ''
      const myResume = {
        type: 'countbtn',
        vueValue,
        children: [{ text: '' }], // void node 必须有 children ,其中有一个空字符串,重要!!!
      }
      return myResume
    },
  }]  // parseElemHtml
}

插入vue组件

现在重点在如何插入一个vue组件。

我一度想,要怎么把vue的节点插入到这个新的span标签里面,然后翻看了snabbdomh函数定义的,发现是可以定义生命周期hook的,其中有一个insert的hook,这不就是插入时回调吗?函数签名如下:

export declare type InsertHook = (vNode: VNode) => any;

所以,我们需要做的是,实例化一个vue的节点,并挂载在当前标签下面。

如何实例化一个vue组件,并拿到dom节点?我这里直接使用Vue的构造函数:

import Vue from 'vue'
import CountBtn from './CountBtn'

const instance = new Vue(CountBtn).$mount()

instance.$el

首先,怎么知道挂载的节点?snabbdomVNode中有一个elm属性,就是vnode实际的dom节点,我们只要把实例好的节点挂载到elm下面即可,省略其他的代码,只关注hook的部分:

import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { h } from 'snabbdom'
import Vue from 'vue'
import CountBtn from './CountBtn'

export default {
  renderElems: [{
    type: 'countbtn',
    renderElem: function (elem, children, editor) {
      const attachVnode = h(
        'span',
        {
          // 新代码
          hook: {
            insert(vnode) {
              const el = vnode.elm
              const instance = new Vue(CountBtn).$mount()
              el.innerHTML = ''
              el.appendChild(instance.$el)
            },
          }
        },
      )
      return attachVnode
    },
  }],
}

这个自定义节点删除的时候应该需要响应vue组件的$destroy方法。这里使用到destroy这个hook,签名如下:

export declare type DestroyHook = (vNode: VNode) => any;

这里我们需要insert的时候保存vue组件的实例,这里我是用了一个变量保存:

import { DomEditor, SlateTransforms } from '@wangeditor/editor'
import { h } from 'snabbdom'
import Vue from 'vue'
import CountBtn from './CountBtn'

export default {
  renderElems: [{
    type: 'countbtn',
    renderElem: function (elem, children, editor) {
      let instance // 新代码
      const attachVnode = h(
        'span',
        {
          hook: {
            insert(vnode) {
              const el = vnode.elm
              // 新代码
              instance = new Vue(CountBtn).$mount()
              el.innerHTML = ''
              el.appendChild(instance.$el)
            },
            // 新代码
            destroy(vnode) {
              if (instance) {
                instance.$destroy()
              }
            },
          }
        },
      )
      return attachVnode
    },
  }],
}

看似很完美,然后我在编辑器操作一通,添加几个节点,写一些文字,然后删除这些节点,发现我的组件并没有触发beforeDestroy的方法,这就表示我的组件并没有被销毁。

处理组件不被销毁的问题

然后我就renderElem这个方法debug亿下,发现,每次组件被点击,或有新元素插入,都会走一遍这个方法,由于snabbdom内部做了diff,原来的hook函数也被新的hook函数替换,但原来的节点并没有销毁,只是做了dom属性更新,所以没有执行insert这个hook,而新的destroy对应的instance变量没有做初始化,所以instanceundefined,之前的instance被丢失了。

现在要做的是,保存原来的instance,并通过一个renderElem不断被执行,但是一直不变的参数来与instance做映射。

我的目光看向了vnode.elm这个dom节点,这个不就是不会变的变量吗?要将两个object做关联,这里我使用了Map这个数据结构,但是会一直引用vnode.elm这个对象的地址,影响节点被GC回收,这里有两个解决方案

  1. destroy被调用时,主动将vnode.elm从map中移除
  2. 直接使用WeakMap,WeakMap不会劫持vnode.elm地址,不会影响GC处理vnode.elm

这里直接使用WeakMap:

const elMap = new WeakMap() // 新代码
export default {
  renderElems: [{
    type: 'countbtn',
    renderElem: function (elem, children, editor) {
      const attachVnode = h(
        'span',
        {
          hook: {
            insert(vnode) {
              const el = vnode.elm
              instance = new Vue(CountBtn).$mount()
              el.innerHTML = ''
              el.appendChild(instance.$el)
              elMap.set(el, instance) // 新代码
            },
            destroy(vnode) {
              // 新代码
              const instance = elMap.get(vnode.elm)
              if (instance) {
                instance.$destroy()
              }
            },
          }
        },
      )
      return attachVnode
    },
  }],
}

现在新增几个节点,点两下,可以触发vue组件的beforeDestroy生命周期了。

实现数据同步

组件可以挂载了,现在要做的是数据同步的问题

  1. 初始化时,slate node的数据传到vue组件
  2. vue组件更新时,同步修改slate node的值

第一点好实现,我们在vue组件实例化时,传入初始化数据就可以了:

export default {
  renderElems: [{
    type: 'countbtn',
    renderElem: function (elem, children, editor) {
      let instance
      const attachVnode = h(
        'span',
        {
          hook: {
            insert(vnode) {
              const el = vnode.elm
              instance = new Vue({
                ...CountBtn,
                // 新代码
                propsData: {
                  defaultValue: elem.vueValue
                }
              }).$mount()
              el.innerHTML = ''
              el.appendChild(instance.$el)
              elMap.set(el, instance)
            },
          }
        },
      )
      return attachVnode
    },
  }],
}

现在要解决,vue组件更新时,同步修改slate node的值的问题。首先vue组件的值更新后,需要通知外部做处理,这里我是传入一个updateValue函数用来callback,组件的值做了更新,调用这个updateValue这个方法就行。

然后就是怎么更新slate node的问题。

我先是想当然是直接修改elem这个对象,但是报错了,就是这个对象不给直接修改。

然后翻遍文档,发现了WangEditor提供了SlateTransforms.setNodes这个方法可以修改slate node,签名如下:

setNodes: <T extends Node>(editor: Editor, props: Partial<T>, options?: {
    at?: Location;
    match?: NodeMatch<T>;
    mode?: 'all' | 'highest' | 'lowest';
    hanging?: boolean;
    split?: boolean;
    voids?: boolean;
}) => void;

editor就是我们编辑器的实例,props就是要修改的属性,options是辅助我们查找对应的节点,其中options.at可以通过位置来修改对应位置的节点。

怎么获取对应节点的位置,然后又是一通查找文档,发现WangEditor提供了DomEditor.findPath的方法,签名如下:

findPath(editor: IDomEditor | null, node: Node): Path;

editor就是编辑器实例,node就是对应的slate node

马上动手:

export default {
  renderElems: [{
    type: 'countbtn',
    renderElem: function (elem, children, editor) {
      let instance
      const attachVnode = h(
        'span',
        {
          hook: {
            insert(vnode) {
              const el = vnode.elm
              instance = new Vue({
                ...CountBtn,
                propsData: {
                  defaultValue: elem.vueValue,
                  // 新代码
                  updateValue: value => {
                    const location = DomEditor.findPath(editor, elem)
                    SlateTransforms.setNodes(editor, {
                      vueValue: value
                    }, {
                      at: location
                    })
                  }
                }
              }).$mount()
              el.innerHTML = ''
              el.appendChild(instance.$el)
              elMap.set(el, instance)
            },
          }
        },
      )
      return attachVnode
    },
  }],
}

然后导出html看看,好使!最重要是,删掉节点,然后撤销,还是之前的数据。

总结

至此,这个需求算是搞完了。其实上面只是简化了我摸索的过程,一开始保存数据什么的都是走了偏方,后来发现问题才找到现在的方法,完整代码放在下面的仓库里面,供大家参考。

完整代码

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!