记一次Vue render的踩坑

前言

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

思路

Message 组件，使用 transition 标签控制其挂载以及销毁动画

message.ts，使用 createVNode 创建 VNode，使用 render 方法挂载到指定 div

setTimeout 倒计时设置销毁该 Message 组件

Copy
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 方法。

Copy
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 这个方法里，并且传了相关的参数

Copy
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 组件都相去甚远😶‍🌫️

Copy
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。

Copy
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 组件也可以发现问题在哪里并直接做修改，但是自己还是挺喜欢瞎折腾这个过程并且在这之后自己可以有一个比较明确的结果和认识😌