skip to content
Magren's Blog

记一次Vue render的踩坑

/ 8 min read

前言

在前几天做自己的玩具的时候,想要实现类似Element plus的Message组件,一样是通过方法调起组件,于是就用到了render💻,但是在销毁组件的时候遇到了一点小问题……

思路

  • Message组件,使用transition标签控制其挂载以及销毁动画
  • message.ts,使用createVNode创建VNode,使用render方法挂载到指定div
  • setTimeout倒计时设置销毁该Message组件
import Message from './base-message.vue'
import { createVNode, render } from 'vue'

// DOM容器
const div = document.createElement('div')// 创建一个dom容器节点div
document.body.appendChild(div) // 容器追加到body中
div.setAttribute('id', 'message-container') // 为dom添加一个唯一标识类

let isShow = false

let timer: number  // 定时器标识
export default ( type:string, message:string ) => {
  if(isShow){
    return
  }
  const vnode = createVNode(Message, { type, message})
  render(vnode, div) // 将虚拟dom添加到div容器中

  isShow = true

  clearTimeout(timer)
  timer = setTimeout(() => { //移除dom节点
    isShow = false
    const child = document.getElementById('base-message') //根据id获取到message组件
    if(child){
      div.removeChild(child) //移除message组件
    }

  }, 3000)
}

这个时候问题就来了,在第一次渲染的时候,Message组件确实展示出来了,在3s后也确实删除掉了对应的dom,但是后续都渲染不出来这个message组件🥲

过程

从render开始打断点,可以看到Vue提供的render方法,container是我们传入的要挂载的div,vnode是我们使用createVNode创建的虚拟dom,也可以看到这个时候代码走了patch方法。

const render: RootRenderFunction = (vnode, container, isSVG) => {
  if (vnode == null) {
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    patch(container._vnode || null, vnode, container, null, null, null, isSVG)
  }
  flushPreFlushCbs()
  flushPostFlushCbs()
  container._vnode = vnode
}

稍为看了下patch代码,根据对VNode的type判断以及断点,可以看到我们的代码走到了processComponent这个方法里,并且传了相关的参数

const patch: PatchFn = (
  n1, // 旧虚拟节点
  n2, // 新虚拟节点
  container,
  anchor = null, // 定位锚点DOM,用于往锚点前插入节点
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren // 是否启用 diff 优化
) => {
  // 新旧虚拟节点相同,直接返回,不做 Diff 比较
  if (n1 === n2) {
    return
  }

  // patching & not same type, unmount old tree
  // 新旧虚拟节点不相同(key 和 type 不同),则卸载旧的虚拟节点及其子节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    // 卸载旧的虚拟节点及其子节点
    unmount(n1, parentComponent, parentSuspense, true)
    // 将 旧虚拟节点置为 null,保证后面走整个节点的 mount 逻辑
    n1 = null
  }

  // PatchFlags.BAIL 标志用于指示应该退出 diff 优化
  if (n2.patchFlag === PatchFlags.BAIL) {
    // optimized 置为 false ,在后续的 Diff 过程中不会启用 diff 优化
    optimized = false
    // 将新虚拟节点的动态子节点数组置为 null,则不会进行 diff 优化
    n2.dynamicChildren = null
  }

  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text: // 处理文本
      processText(n1, n2, container, anchor)
      break
    case Comment: // 处理注释
      processCommentNode(n1, n2, container, anchor)
      break
    case Static: // 处理静态节点
      if (n1 == null) {
        // 挂载静态节点
        mountStaticNode(n2, container, anchor, isSVG)
      } else if (__DEV__) {
        // 更新静态节点
        patchStaticNode(n1, n2, container, isSVG)
      }
      break
    case Fragment: // 处理 Fragment 元素
      processFragment(
        n1,
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 处理 ELEMENT 类型的 DOM 元素
        processElement(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 处理组件
        processComponent(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.TELEPORT) {
        // 处理 Teleport 组件
        // 调用 Teleport 组件内部的 process 函数,渲染 Teleport 组件的内容
        ;(type as typeof TeleportImpl).process(
          n1 as TeleportVNode,
          n2 as TeleportVNode,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized,
          internals
        )
      } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
        // 处理 Suspense 组件
        // 调用 Suspense 组件内部的 process 函数,渲染 Suspense 组件的内容
        ;(type as typeof SuspenseImpl).process(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized,
          internals
        )
      } else if (__DEV__) {
        warn('Invalid VNode type:', type, `(${typeof type})`)
      }
  }

  // set ref
  // 设置 ref 引用
  if (ref != null && parentComponent) {
    setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
  }
}

在这个方法里边,n1是我们要挂载的div里边的VNode,这也是我第一次挂载跟后续挂载组件的不同之处,在第一次挂载组件的时候,n1是为null的,在后面我虽然删除了组件,但是那也是直接删除的真实dom,并没有删除掉里边的VNode,这样就导致直接走了updateComponent方法,即更新组件。在updateComponent方法里边,会根据新旧虚拟节点VNode上的属性、指令、子节点等判断是否需要更新组件,但无论怎样,离我想要的挂载新message组件都相去甚远😶‍🌫️

  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    n2.slotScopeIds = slotScopeIds
    if (n1 == null) {
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) { // 首次挂载时 判断当前要挂载的组件是否是 KeepAlive 组件
          // 激活组件,即将隐藏容器中移动到原容器中
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          isSVG,
          optimized
        )
      } else {
        // 不是 KeepAlive 组件,调用 mountComponent 挂载组件
        mountComponent(
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      }
    } else {
      updateComponent(n1, n2, optimized)
    }
  }

最后

根据render方法里也可以看到,如果需要卸载掉虚拟dom,我们只需要把vnode参数传null即可,然后让虚拟dom去更新真实dom,以此来卸载掉我们的message组件,而不是直接删除真实dom。

import Message from './base-message.vue'
import { createVNode, render } from 'vue'

// DOM容器
const div = document.createElement('div')// 创建一个dom容器节点div
document.body.appendChild(div) // 容器追加到body中
div.setAttribute('id', 'message-container') // 为dom添加一个唯一标识类

let isShow = false

let timer: number  // 定时器标识
export default ( type:string, message:string ) => {

  if(isShow){
    return
  }

  const vnode = createVNode(Message, { type, message})
  render(vnode, div) // 将虚拟dom添加到div容器中
  isShow = true
  clearTimeout(timer)
  timer = setTimeout(() => { //移除dom节点
    isShow = false
    render(null, div)
  }, 3000)
}

虽然这个问题不是什么大问题,直接参考Element plus的Message组件也可以发现问题在哪里并直接做修改,但是自己还是挺喜欢瞎折腾这个过程并且在这之后自己可以有一个比较明确的结果和认识😌