在vue3中实现富文本--利用defineCustomElement来快速实现富文本组件(五)

1,679 阅读7分钟

一、技术点引入

上一篇文章主要讲了如何处理range选区问题,目前富文本的编辑功能已经比较完善了,但是我们的操作很多都是基于创建态来完成的,编辑完成的内容我们该如何保存,这就是这一篇文章要讨论的点。

富文本编辑器内容保存可以通过获取innerHTML来直接保存,或者是遍历dom树来保存一个json对象;而我们的自定义元素是一个内部状态的vue组件,如果我们单纯的将dom节点保存下来,下次在复用的时候会破坏掉自定义元素内部的状态,重新编辑功能将变得不可用,一些依赖js来展示效果的自定义元素也会直接无法使用,所以我们需要保存自定义元素的数据,下次重新展示时将保存的数据重新交给自定义元素,来实现自定义元素重现之前的场景。

按照这个思路,自定义元素应该要向外提供一个方法,每当外部调用这个方法就可以获取到自定义元素希望保存的值,下次在新建自定义元素时将保存的参数直接传递给他就能实现重现上次保存的场景

根据上面的思路,我想在buildComponent.ts文件中实现这个目标,也就是在新的对象继承自定义元素的时候新建一个getProps方法挂载到this上,通过这个方法去拿到所有的希望传递的值。可是这里有一个难点,那就是如何才能拿到内部希望导出的值?

由于我使用了vue3的语法糖,我初步想通过defineExpose导出我想保存的值;然而在buildInlineComponentClass/buildBlockComponentClass两个方法中能够拿到的只有this实例、传递进来的ParentClass以及,将他们打印,分别为:

image.png 可以看到并没有暴露在外的属性。

这个问题是解决获取数据的最大难点。

然后我从源码入手,观察这个自定义元素实例化的过程。首先,他的定义自定义元素的方法为:

function defineCustomElement(options, hydrate2) {
  const Comp = defineComponent(options);
  class VueCustomElement extends VueElement {
    constructor(initialProps) {
      super(Comp, initialProps, hydrate2);
    }
  }
  VueCustomElement.def = Comp;
  return VueCustomElement;
}

他是继承自VueElement,然后我再去看VueElement是怎么做的;在VueElement中有一个方法_createVNode,他在创建vndoe:

  _createVNode() {
    const vnode = createVNode(this._def, extend({}, this._props));
    if (!this._instance) {
      vnode.ce = (instance) => {
        this._instance = instance;
        instance.isCE = true;
        if (true) {
          instance.ceReload = (newStyles) => {
            if (this._styles) {
              this._styles.forEach((s) => this.shadowRoot.removeChild(s));
              this._styles.length = 0;
            }
            this._applyStyles(newStyles);
            this._instance = null;
            this._update();
          };
        }
        const dispatch = (event, args) => {
          this.dispatchEvent(
            new CustomEvent(event, {
              detail: args
            })
          );
        };
        instance.emit = (event, ...args) => {
          dispatch(event, args);
          if (hyphenate(event) !== event) {
            dispatch(hyphenate(event), args);
          }
        };
        let parent = this;
        while (parent = parent && (parent.parentNode || parent.host)) {
          if (parent instanceof _VueElement) {
            instance.parent = parent._instance;
            instance.provides = parent._instance.provides;
            break;
          }
        }
      };
    }
    return vnode;
  }

可以看到它把实例化后的vnode挂载到了this._instance上。在buildInlineComponentClass函数中的connectedCallback生命周期钩子中打印一下this._instance,结果为:

image.png 可以看到我们想要导出的元素都exposed里了。

接下来就很简单了,我们要做的就是以下几步:

  1. 每个组件将需要保存的数据放到defineExpose中,其中key必须和props里面的可key保持一致,这样能做到新建自定义元素时能够直接将这些元素传递进去而不用做任何转换
  2. buildInlineComponentClass/buildBlockComponentClass中的connectedCallback钩子中给this新建一个getProps方法,专门用来获取要保存的数据
  3. 在RichText类中新建一个静态方法,它能够将dom转换为一个虚拟dom的json对象
  4. 在RichText类中新建一个静态方法,它能够将虚拟dom的json对象转换为真实dom
  5. 修改initRootNode方法,支持用户传递一个虚拟dom的json对象用以初始化富文本

二、代码实现

先展示buildComponent.ts的代码:

export function buildInlineComponentClass(ParentClass: CustomElementConstructor) {
  class InlineComponent extends ParentClass {
    constructor(initialProps) {
      super(initialProps)
    }
    connectedCallback() {
      super.connectedCallback && super.connectedCallback()
      console.log('this._instance--->', this._instance);
      

      // 获取vue实例
      const vueInstance = this._instance
      this.getProps = getProps.bind(vueInstance)

      // 处理原生撤回时重复生成clone-btn的问题,要判断是否已经存在.v__close-btn'
      if (this.shadowRoot && !this.shadowRoot.querySelector('.v__close-btn')) {
        const closeBtn = document.createElement('a')
        closeBtn.style.marginLeft = '3px'
        closeBtn.innerText = '×'
        closeBtn.classList.add('v__close-btn')
        // 如果父级为container,则一起删除
        closeBtn.addEventListener('click', () => {
          const parent = this.parentElement
          if (parent && parent.getAttribute('data-element-container') !== null) {
            parent.parentElement?.removeChild(parent)
          } else {
            this.parentElement?.removeChild(this)
          }
        })
        this.shadowRoot.appendChild(closeBtn)
      }
    }
  }
  return InlineComponent
}

export function buildBlockComponentClass(ParentClass: CustomElementConstructor) {
  class BlockComponent extends ParentClass {
    constructor(initialProps) {
      super(initialProps)
    }
    connectedCallback() {
      super.connectedCallback && super.connectedCallback()

      // 获取vue实例
      const vueInstance = this._instance
      this.getProps = getProps.bind(vueInstance)

      if (this.shadowRoot && !this.shadowRoot.querySelector('.v__config-container')) {
        const configContainer = document.createElement('div')
        configContainer.classList.add('v__config-container')
        const closeBtn = document.createElement('a')
        closeBtn.textContent = '×'
        closeBtn.classList.add('close-btn')
        // 如果父级为container,则一起删除
        closeBtn.addEventListener('click', () => {
          const parent = this.parentElement
          if (parent && parent.getAttribute('data-element-container') !== null) {
            parent.parentElement?.removeChild(parent)
          } else {
            this.parentElement?.removeChild(this)
          }
        })
        configContainer.appendChild(closeBtn)
        this.shadowRoot.insertBefore(configContainer, this.shadowRoot.firstChild)
        const style = document.createElement('style')
        style.textContent = `
                .v__config-container {
                    width: 100%;
                    height: 24px;
                    background-color: black;
                    opacity: .5;
                    transition: .3s;
                }
                
                .close-btn{
                    cursor: pointer;
                    font-size: 18px;
                    color: white;
                    float: right;
                    line-height: 18px;
                    padding: 3px;
                    width: 24px;
                    box-sizing: border-box;
                    text-align: center;
                }
                `
        this.shadowRoot.appendChild(style)
      }
    }
  }
  return BlockComponent
}

function getProps() {
  const obj = {}
  Object.keys(this.exposed).forEach((prop) => {
    if (this.exposed[prop].value) {
      obj[prop] = this.exposed[prop].value
    } else if (typeof this.exposed[prop] === 'function') {
      obj[prop] = this.exposed[prop]()
    } else {
      obj[prop] = this.exposed[prop]
    }
  })
  return {
    origin: this.exposed,
    props: obj
  }
}

其中主要是getProps函数,根据不同的情况来拿到对应的值

然后是RichText类:

  constructor(
    parent: HTMLElement,
    vdom: RichTextVirtualDOM | string = '',
    mode: 'edit' | 'show' = 'edit'
  ) {
    this.mode = mode
    this.initRootNode(parent, vdom)
  }
    private initRootNode(parent: HTMLElement, vdom: RichTextVirtualDOM | string) {
    if (typeof vdom === 'string') {
      // 如果为字符串则将他装起来
      this.rootElement = document.createElement('div')
      this.rootElement.appendChild(document.createTextNode(vdom))
    } else if (vdom) {
      console.log(RichText.parse2DOM(vdom))

      this.rootElement = RichText.parse2DOM(vdom) as HTMLElement
    } else {
      this.rootElement = document.createElement('div')
    }

    this.rootElement.contentEditable = 'true'

    parent.appendChild(this.rootElement)
    document.addEventListener('selectionchange', () => {
      // 获取当前聚焦元素
      let activeEl = document.activeElement
      // shadow dom中的聚焦元素需要获取
      //不断地通过shadowroot.activeElement与自定义元素列表比对,观察是否焦点还在内部
      while (activeEl?.shadowRoot && getCustomComponents()[activeEl.nodeName.toLocaleLowerCase()]) {
        const childActiveEl = activeEl.shadowRoot.activeElement
        // 只有当childActiveEl存在且聚焦的为自定义元素才继续找,不然就锁定到这一层就好了,方便获取selection
        if (childActiveEl && getCustomComponents()[childActiveEl.nodeName.toLocaleLowerCase()]) {
          activeEl = childActiveEl
        } else {
          break
        }
      }
      this.focusEl = activeEl as HTMLElement
    })
  }
    // 将dom转换为虚拟dom,重点就是递归的获取自定义元素的props
  public static jsonize(dom: HTMLElement) {
    const virtualDOM: RichTextVirtualDOM = {
      nodeName: dom.nodeName,
      nodeType: dom.nodeType,
      isCustomEl: !!getCustomComponents()[dom.nodeName.toLocaleLowerCase()],
      attrs: {},
      children: [],
      textContent: '',
      props: {}
    }
    const attrs = dom.attributes
    // 如果是自定义元素,则调用自定义元素的getProps方法获取props
    if (virtualDOM.isCustomEl) {
      virtualDOM.props = dom.getProps().props
      for (let i = 0; i < attrs.length; i++) {
        const attrName = attrs[i].name
        virtualDOM.attrs[attrName] = attrs[i].value
      }
    }
    // 如果是元素节点,则往下找子节点
    else if (dom.nodeType === Node.ELEMENT_NODE) {
      for (let i = 0; i < attrs.length; i++) {
        const attrName = attrs[i].name
        virtualDOM.attrs[attrName] = attrs[i].value
      }
      virtualDOM.children = Array.from(dom.childNodes).map((child) =>
        this.jsonize(child as HTMLElement)
      )
    }
    // 如果是文本节点,则直接获取内容
    else if (dom.nodeType === Node.TEXT_NODE) {
      virtualDOM.textContent = dom.textContent || ''
    }
    return virtualDOM
  }

  // 将虚拟dom转换为真实dom
  public static parse2DOM(virtualDOM: RichTextVirtualDOM, isAncestor = true) {
    let dom: Node
    // 自定义函数就new,传递mode
    if (virtualDOM.isCustomEl) {
      const CustomComp = getCustomComponents()[virtualDOM.nodeName.toLocaleLowerCase()]
      dom = new CustomComp.Constructor({ ...virtualDOM.props, mode: this.mode })
      // 设置属性
      Object.keys(virtualDOM.attrs).forEach((name) => {
        ; (dom as HTMLElement).setAttribute(name, virtualDOM.attrs[name])
      })
    } else {
      // 原生节点先判断节点类型,根据类型来创建节点
      if (virtualDOM.nodeType === Node.ELEMENT_NODE) {
        dom = document.createElement(virtualDOM.nodeName)
        // 原生元素则将子节点塞进去
        virtualDOM.children.forEach((child) => {
          dom.appendChild(this.parse2DOM(child, false))
        })
        // 设置属性
        Object.keys(virtualDOM.attrs).forEach((name) => {
          ; (dom as HTMLElement).setAttribute(name, virtualDOM.attrs[name])
        })
      } else if (virtualDOM.nodeType === Node.TEXT_NODE) {
        dom = document.createTextNode(virtualDOM.textContent)
      } else {
        dom = document.createTextNode('')
      }
    }
    // 如果一开始就是文本节点,则代表需要套一个元素
    if (isAncestor && virtualDOM.nodeType === Node.TEXT_NODE) {
      const textContainer = document.createElement('div')
      textContainer.appendChild(dom)
      return textContainer
    } else {
      return dom
    }
  }

其中主要就是通过判断元素类型来决定该像一个原生dom元素来保存还是按照一个自定义元素的方式来保存props

接下来是对自定义元素做一些改造, CustomLink.ce.vue:

<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps({
  href: String,
  text: String
})
const hrefRef = ref(props.href || ' ')
const textRef = ref(props.text || props.href || '  ')

const updateText = (e: Event) => {
  textRef.value = (e.target as HTMLLinkElement).textContent || ''
}

// 组件向外暴露的变量在这里定义,这是运行时能拿到的
defineExpose({
  href: hrefRef,
  text: textRef
})
</script>
<template>
  <a contenteditable="true" class="link" :href="hrefRef" target="_blank" @input="updateText">{{
    textRef
  }}</a>
</template>
<script lang="ts">
// 组件静态属性要在这里定义,这是导出时能拿到的
export default {
  name: 'custom-link',
  type: 'inline'
}
</script>

<style lang="less" scoped>
.link {
  outline: 0px solid transparent;
}
</style>

当元素嵌套了富文本的话,元素应该导入RichText类中的静态jsonize方法,此处以RText.ce.vue举例:

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RichText } from '@/utils/richText/RichText'
import { type RichTextVirtualDOM } from '@/types/customComponent'

const props =
    defineProps<{
        content?: RichTextVirtualDOM
    }>()



const contentRef = ref<HTMLDivElement | null>(null)
onMounted(() => {
    if (contentRef.value && props.content) {
        contentRef.value.innerHTML = ''
        contentRef.value.appendChild(RichText.parse2DOM(props.content))
    }
})

defineExpose({
    content() {
        if (contentRef.value) {
            return RichText.jsonize(contentRef.value)
        }
    }
})
</script>

<template>
    <div ref="contentRef">
        <div contenteditable="true"></div>
    </div>
</template>
<script lang="ts">
export default {
    name: 'r-text',
    type: 'block',
    // 在这里显示声明
    nestable: true,
}
</script>

组件只要满足导出导入的键名一致,值的类型一致,就能够很好的实现复用,下面是结果展示:

image.png 就能够根据这个虚拟dom来复现上次保存的内容了。

三、总结

这篇文章主要讲解了如何将富文本内容保存下来并复现,主要就是通过defineExpose来将要保存的变量抛出,在connected钩子中用比较侵入式的办法拿到instance并从中拿到导出的元素,然后包裹成一个方法挂载到this上,最后给RichText类加上两个静态方法负责将内容转换为json对象,以及将json对象转换为真实dom。