# 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)