# WangEditorUseVueCmp **Repository Path**: xxbiji/wang-editor-use-vue-cmp ## Basic Information - **Project Name**: WangEditorUseVueCmp - **Description**: WangEditor自定义节点中中插入Vue组件 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 0 - **Created**: 2023-05-19 - **Last Updated**: 2024-12-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # WangEditor中插入Vue组件 ## 起因 领导突发奇想,想在富文本编辑器上插入一个画板,可以在上面操作一通还能保存数据和重新渲染,我们的技术栈是vue2,使用的富文本编辑器是[WangEditor](https://www.wangeditor.com/)。 WangEditor提供了[定义新元素](https://www.wangeditor.com/v5/development.html#%E5%AE%9A%E4%B9%89%E6%96%B0%E5%85%83%E7%B4%A0)的功能,不过插入的是原生html的vnode,不支持vue的vnode。一通研究过后,找到了解决方案,下面总结下开发经过。 ## 准备阶段 这里使用一个CountBtn作为案例,代码如下: ```jsx 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
{ this.value }
} } ``` 按照官方教程,我们需要先定义一个`slate node`的数据结构: ```js { type: 'countbtn', vueValue: 0, children: [{ text: '' }] // void 元素必须有一个 children ,其中只有一个空字符串,重要!!! } ``` 我们还需要一个菜单用来插入这个元素,按照官方的[注册新菜单](https://www.wangeditor.com/v5/development.html#%E6%B3%A8%E5%86%8C%E6%96%B0%E8%8F%9C%E5%8D%95)流程走一遍,我的菜单如下: ```js class MyButtonMenu { // JS 语法 constructor() { this.title = 'countbtn' // 自定义菜单标题 // this.iconSvg = '...' // 可选 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: '' }] }) } } ``` 上面的准备好后,就开始定义新的元素了,下面按照官方[定义新元素](https://www.wangeditor.com/v5/development.html#%E5%AE%9A%E4%B9%89%E6%96%B0%E5%85%83%E7%B4%A0)教程走一遍,我主要是讲解一下如何插入一个vue的组件,下面是基板代码: ```js 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 = `` 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`标签里面,然后翻看了`snabbdom`的`h`函数定义的,发现是可以定义生命周期`hook`的,其中有一个`insert`的hook,这不就是插入时回调吗?函数签名如下: ```js export declare type InsertHook = (vNode: VNode) => any; ``` 所以,我们需要做的是,实例化一个vue的节点,并挂载在当前标签下面。 如何实例化一个vue组件,并拿到dom节点?我这里直接使用`Vue`的构造函数: ```js import Vue from 'vue' import CountBtn from './CountBtn' const instance = new Vue(CountBtn).$mount() instance.$el ``` 首先,怎么知道挂载的节点?`snabbdom`的`VNode`中有一个`elm`属性,就是vnode实际的`dom`节点,我们只要把实例好的节点挂载到`elm`下面即可,省略其他的代码,只关注hook的部分: ```js 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,签名如下: ```js export declare type DestroyHook = (vNode: VNode) => any; ``` 这里我们需要insert的时候保存vue组件的实例,这里我是用了一个变量保存: ```js 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`变量没有做初始化,所以`instance`是`undefined`,之前的`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: ```js 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组件实例化时,传入初始化数据就可以了: ```js 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`,签名如下: ```js setNodes: (editor: Editor, props: Partial, options?: { at?: Location; match?: NodeMatch; mode?: 'all' | 'highest' | 'lowest'; hanging?: boolean; split?: boolean; voids?: boolean; }) => void; ``` `editor`就是我们编辑器的实例,`props`就是要修改的属性,`options`是辅助我们查找对应的节点,其中`options.at`可以通过位置来修改对应位置的节点。 怎么获取对应节点的位置,然后又是一通查找文档,发现`WangEditor`提供了`DomEditor.findPath`的方法,签名如下: ```js findPath(editor: IDomEditor | null, node: Node): Path; ``` `editor`就是编辑器实例,`node`就是对应的`slate node`。 马上动手: ```js 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看看,好使!最重要是,删掉节点,然后撤销,还是之前的数据。 ## 总结 至此,这个需求算是搞完了。其实上面只是简化了我摸索的过程,一开始保存数据什么的都是走了偏方,后来发现问题才找到现在的方法,完整代码放在下面的仓库里面,供大家参考。 [完整代码](https://gitee.com/xxbiji/wang-editor-use-vue-cmp)