
Vue3 进阶:手动挂载组件与 Diff 算法深度解析
很多 Vue 开发者习惯了在 <template> 里写组件,但在开发 Message(全局提示)、Modal(模态框)或 Notification(通知)这类组件时,我们往往希望通过 JS 函数直接调用,而不是在每个页面都写一个 <MyMessage /> 标签。本文将带你深入 Vue3 底层,看看如何手动渲染组件。
1. 为什么需要手动挂载?
想象一下,如果你想弹出一个成功提示,哪种方式更优雅?
方式 A (声明式):
需要在每个页面的 template 里都写一遍组件,还要定义一个变量来控制显示隐藏。
<template>
<MyMessage :visible="showMsg" message="操作成功" />
<button @click="showMsg = true">点击</button>
</template>
方式 B (命令式):
直接在 JS 里调用,随用随调,完全解耦。
import { message } from 'my-ui'
function handleClick() {
message.success('操作成功')
}
显然,方式 B 是组件库的标准做法。但 Vue 的组件通常是渲染在父组件的模板里的,如何把它“凭空”变出来并挂载到 document.body 上呢?
这就需要用到 Vue3 暴露的两个底层 API:createVNode 和 render。
2. 核心 API 解密
createVNode:画图纸
在 Vue 中,一切皆 VNode(虚拟节点)。普通的 .vue 文件只是一个组件定义,它不是 DOM,也不是 VNode。我们需要用 createVNode 把它实例化。
import { createVNode } from 'vue'
import MyComponent from './MyComponent.vue'
// 这就像是拿着图纸 (MyComponent)
// 创建了一个具体的实例化对象 (vm)
// 第二个参数是 props
const vnode = createVNode(MyComponent, { title: 'Hello' })
render:施工队
有了 VNode,它还只是内存里的对象。我们需要 render 函数把它变成真实的 DOM 节点,并挂载到某个容器上。
import { render } from 'vue'
const container = document.createElement('div')
// 把 vnode 渲染到 container 盒子里
render(vnode, container)
// 最后把盒子放到 body 上
document.body.appendChild(container)
3. 实战:手写一个简单的 Message 函数
让我们来看看 packages/components/message/src/method.ts 是如何实现的。
第一步:创建容器与 VNode
import { createVNode, render } from 'vue'
import MessageConstructor from './message.vue'
export function message(options) {
// 1. 创建一个临时的 div 容器
const container = document.createElement('div')
// 2. 创建 VNode,并将 options 作为 props 传入
// 例如:createVNode(MessageConstructor, { message: '你好', type: 'success' })
const vnode = createVNode(MessageConstructor, options)
// 3. 将 VNode 渲染到 container 中
// 此时 container.firstElementChild 就是组件生成的真实 DOM
render(vnode, container)
// 4. 将真实 DOM 追加到 body
document.body.appendChild(container.firstElementChild!)
}
第二步:处理组件卸载
这就完事了吗?并没有。如果我们不处理销毁逻辑,这些 DOM 节点会一直堆积在 body 里,造成内存泄漏。
我们需要在组件内部发射一个 destroy 事件(比如在动画结束时),然后在外部监听它。
const vnode = createVNode(MessageConstructor, {
...options,
// 监听组件内部 emit('destroy')
onDestroy: () => {
// 移除 DOM
render(null, container) // 这一步会触发组件的 unmounted 钩子
}
})
4. 源码深潜:createVNode 和 render 到底干了啥?
对于好奇心强的同学,可能想知道:Vue 内部到底是怎么把这几行代码变成页面的?让我们用最通俗的伪代码来拆解一下。
4.1 createVNode:给节点“打标签”
createVNode 的核心任务不仅仅是创建一个对象,更是为了性能优化。它会根据你传入的类型,给 VNode 打上一个二进制标记(ShapeFlag)。
// 伪代码简化版
function createVNode(type, props, children) {
// 1. 定义 VNode 结构
const vnode = {
type, // 组件对象 或 'div' 标签名
props,
children,
component: null, // 稍后挂载组件实例
el: null, // 稍后挂载真实 DOM
shapeFlag: 0 // 核心:类型标记
}
// 2. 通过位运算打标记
if (typeof type === 'string') {
vnode.shapeFlag = ShapeFlags.ELEMENT // 这是一个 HTML 标签 (div, span)
}
else if (typeof type === 'object') {
vnode.shapeFlag = ShapeFlags.STATEFUL_COMPONENT // 这是一个 Vue 组件
}
return vnode
}
为什么要这么做?
Vue 的更新过程非常频繁。有了 shapeFlag,在后续的 Diff 过程中,Vue 就不需要每次都去猜“这是个啥”,直接看二进制位就知道怎么处理,速度极快。
4.2 render:万能的包工头
render 函数其实非常简单,它背后真正的干活主力是 patch 函数。
// 伪代码简化版
function render(vnode, container) {
if (vnode == null) {
// 如果传 null,说明要销毁
if (container._vnode) {
unmount(container._vnode) // 卸载旧节点
}
}
else {
// 如果有新 VNode,就开始“打补丁”
// 参数:(旧节点, 新节点, 容器)
patch(container._vnode || null, vnode, container)
}
// 记住这次渲染的 VNode,下次更新时它就是“旧节点”了
container._vnode = vnode
}
4.3 patch:分发任务
patch 是 Vue 渲染器的核心。它根据我们前面打的 shapeFlag,把任务分发给不同的处理函数。
function patch(n1, n2, container) {
if (n1 && !isSameVNodeType(n1, n2)) {
// 如果类型都不一样(比如从 div 变成了 span),直接卸载旧的
unmount(n1)
n1 = null
}
const { shapeFlag } = n2
if (shapeFlag & ShapeFlags.ELEMENT) {
// 这是一个 HTML 标签
processElement(n1, n2, container)
}
else if (shapeFlag & ShapeFlags.COMPONENT) {
// 这是一个 Vue 组件
processComponent(n1, n2, container)
}
}
4.4 深入 processComponent:组件是怎么跑起来的?
当 patch 发现这是个组件时,它会区分是“初次挂载”还是“更新”。
function processComponent(n1, n2, container) {
if (n1 == null) {
// 1. 挂载组件 (Mount)
mountComponent(n2, container)
}
else {
// 2. 更新组件 (Update)
updateComponent(n1, n2)
}
}
mountComponent 做的事情:
-
创建实例:
const instance = createComponentInstance(vnode)。
-
设置状态:初始化
props、slots,执行 setup() 函数。
-
建立副作用:创建一个
effect(响应式副作用),运行组件的 render 函数生成子树(subTree),并监听响应式数据变化。
4.5 深入 processElement:挂载与更新
当 patch 遇到 HTML 标签时,会根据 n1(旧节点)是否存在来决定是初始化还是更新。
1. 挂载 (Mount)
如果 n1 为 null,说明是初次渲染。Vue 会调用宿主环境的 API(如 document.createElement)创建真实 DOM,并将其插入到容器中。
function mountElement(vnode, container) {
// 1. 创建真实 DOM
const el = (vnode.el = hostCreateElement(vnode.type))
// 2. 处理 Props (Style, Class, Event)
for (const key in vnode.props) {
hostPatchProp(el, key, null, vnode.props[key])
}
// 3. 处理子节点 (递归 mount)
mountChildren(vnode.children, el)
// 4. 插入页面
hostInsert(el, container)
}
2. 更新 (Patch)
如果 n1 存在,Vue 就需要对比新旧节点,做最小量的 DOM 操作。
-
更新 Props:对比新旧 Props,修改变动的 Class、Style 或事件监听器。
-
更新 Children (核心 Diff):这是最复杂的部分。
4.6 核心 Diff 算法:Vue3 是如何“增删改移”的?
Vue3 采用的是快速 Diff 算法 (Quick Diff)。它的核心思想是:先处理两端容易对比的节点,最后再处理中间复杂的乱序部分。
我们通过一个具体的代码示例来模拟这个过程。
假设场景:
// 旧列表 (n1)
const oldChildren = [
{ key: 'A' }, { key: 'B' }, // 头
{ key: 'C' }, { key: 'D' }, { key: 'E' }, // 中间
{ key: 'F' }, { key: 'G' } // 尾
]
// 新列表 (n2)
const newChildren = [
{ key: 'A' }, { key: 'B' }, // 头 (不变)
{ key: 'E' }, { key: 'C' }, { key: 'D' }, { key: 'H' }, // 中间 (乱序 + 新增)
{ key: 'F' }, { key: 'G' } // 尾 (不变)
]
第一步:掐头(Sync from start)
Vue 会维护一个索引 i = 0,从头部开始向后遍历,如果 key 相同,直接 patch(更新属性),然后 i++。
let i = 0
const e1 = oldChildren.length - 1 // 旧列表尾部索引
const e2 = newChildren.length - 1 // 新列表尾部索引
// 1. 从头往后比
while (i <= e1 && i <= e2) {
const n1 = oldChildren[i]
const n2 = newChildren[i]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container) // 复用节点,更新 Props
i++
} else {
break // 遇到不同的 (C vs E),停下来
}
}
// 此时 i = 2,指向 C 和 E
第二步:去尾(Sync from end)
同样的逻辑,从尾部开始向前遍历。
// 2. 从尾往前比
while (i <= e1 && i <= e2) {
const n1 = oldChildren[e1]
const n2 = newChildren[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container)
e1--
e2--
} else {
break // 遇到不同的 (E vs H),停下来
}
}
// 此时 e1 = 4 (指向 E), e2 = 5 (指向 H)
此时的状态:
- 头部 A, B 已处理。
- 尾部 F, G 已处理。
-
剩下的烂摊子:
- 旧:
[C, D, E] (索引 2 到 4)
- 新:
[E, C, D, H] (索引 2 到 5)
第三步:处理新增与删除(简单情况)
如果预处理后,旧列表没了(i > e1),新列表还剩,说明全是新增。
如果新列表没了(i > e2),旧列表还剩,说明全是删除。
if (i > e1) {
if (i <= e2) {
// 旧的没了,新的还有 -> 挂载剩余的新节点
while (i <= e2) patch(null, newChildren[i++], container)
}
}
else if (i > e2) {
// 新的没了,旧的还有 -> 卸载剩余的旧节点
while (i <= e1) unmount(oldChildren[i++])
}
第四步:处理乱序(Unknown Sequence)
这是最复杂的情况(如我们的例子)。Vue 需要判断哪些节点移动了,哪些需要新建。
1. 构建新节点映射表与初始化
// 1. 构建新节点的 key 映射表
const keyToNewIndexMap = new Map()
for (let k = i; k <= e2; k++) {
keyToNewIndexMap.set(newChildren[k].key, k)
}
// 2. 待处理新节点数量
const count = e2 - i + 1
// 3. 记录新节点在旧列表中的位置(用于计算最长递增子序列)
const newIndexToOldIndexMap = new Array(count).fill(0)
2. 遍历旧节点:复用与删除
// 4. 遍历旧节点,寻找可复用的节点
for (let k = i; k <= e1; k++) {
const oldChild = oldChildren[k]
const newIndex = keyToNewIndexMap.get(oldChild.key)
if (newIndex === undefined) {
// 旧节点在新列表中找不到了 -> 删除
unmount(oldChild)
} else {
// 找到了!记录旧索引 + 1(防止 0 索引冲突)
// newIndex - i 是为了映射到从 0 开始的 count 数组中
newIndexToOldIndexMap[newIndex - i] = k + 1
// 进行递归比对
patch(oldChild, newChildren[newIndex], container)
}
}
/**
* 此时产生的映射关系图例:
*
* 索引 (i): 0 1 2 3 (对应新列表中的位置)
* 新节点 key: [E] [C] [D] [H]
* 旧索引 + 1: [5] [3] [4] [0] (对应 newIndexToOldIndexMap)
*
* 其中:
* - 0 代表 H 是新来的,需要挂载 (Mount)
* - 3, 4 是递增的序列 -> 这就是 LIS (最长递增子序列)
* - 5 打破了递增性 -> 说明 E 发生了移动
*/
💡 小白专属解释:
你可以把 newIndexToOldIndexMap 想象成一张 “寻人启事表”。
表格的长度就是新列表里乱序的人数(这里是 4 个:E, C, D, H)。
-
第 0 格 (E):表里写着
5。意思是:“我是旧列表里的第 4 号(5-1)人”。
-
第 1 格 (C):表里写着
3。意思是:“我是旧列表里的第 2 号(3-1)人”。
-
第 2 格 (D):表里写着
4。意思是:“我是旧列表里的第 3 号(4-1)人”。
-
第 3 格 (H):表里写着
0。意思是:“查无此人,我是新来的”。
Vue 看到这张表,就知道谁是从哪儿来的,谁是新来的。然后只要算出哪个序列是递增的(3 -> 4),就说明这几个人(C 和 D)的相对站位没变,可以不用动,省力气!
3. 计算最长递增子序列 (LIS)
Vue 使用一个算法算出 newIndexToOldIndexMap 中的最长递增子序列。这个序列里的节点,在旧列表和新列表里的相对顺序是一样的,所以不需要移动。
// 获取最长递增子序列的相对索引
// 传入: [5, 3, 4, 0] (忽略 0)
// 返回: [1, 2] (对应值 3, 4,即 C 和 D,它们在 newIndexToOldIndexMap 中的下标)
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
// j 指向 LIS 数组的末尾 (即最大索引)
let j = increasingNewIndexSequence.length - 1
4. 倒序遍历与移动 (Moving)
最后,我们从后往前遍历需要处理的新节点。
为什么倒序?因为 insert 操作需要一个参照节点 (Anchor)。从后往前遍历时,当前节点的后一个节点一定已经处理好了(要么是刚移动完的,要么是末尾固定的),可以放心地作为 Anchor。
// 遍历待处理的新节点 (倒序)
// k: 当前处理节点在乱序区间内的相对索引 (0 ~ count-1)
// i: 乱序区间的起始索引 (全局索引)
for (let k = count - 1; k >= 0; k--) {
// 1. 计算该节点在新列表中的真实全局索引
const nextIndex = i + k
const nextChild = newChildren[nextIndex]
// 2. 找锚点 (Anchor):就是它后面那个节点
// 如果 nextIndex + 1 超过了长度,说明它是最后一个,锚点是 null (插到容器末尾)
const anchor = nextIndex + 1 < newChildren.length ? newChildren[nextIndex + 1].el : null
if (newIndexToOldIndexMap[k] === 0) {
// ------------------------------------
// 情况 1: 标记是 0 -> 新增节点
// ------------------------------------
patch(null, nextChild, container, anchor)
} else if (j < 0 || k !== increasingNewIndexSequence[j]) {
// ------------------------------------
// 情况 2: 需要移动
// ------------------------------------
// 这里的 k 不在 LIS 里,说明位置不对,需要搬家
move(nextChild, container, anchor)
} else {
// ------------------------------------
// 情况 3: 命中 LIS -> 原地不动
// ------------------------------------
// k === seq[j]: 恭喜,这个节点在最长递增序列里
// 它的相对位置没变,不需要动 DOM,只需要让 LIS 指针前移
j--
}
}
💡 核心逻辑图解:
-
H (i=3): Map 值为 0 -> 新建,插到末尾。
-
D (i=2): 命中 LIS (seq[j]=2) -> 不动,
j--。
-
C (i=1): 命中 LIS (seq[j]=1) -> 不动,
j--。
-
E (i=0): 不在 LIS 里 -> 移动,插到 C 前面。
5. 源码级细节:为什么需要 Context?
你可能会发现我们的源码里有这么一行:
vnode.appContext = context || null
这是为了让动态挂载的组件能继承当前 App 的上下文。比如,你在主 App 里注册了 vue-router 或 i18n,如果不把 appContext 赋值给新的 VNode,那么在这个 Message 组件里就无法使用 useRouter() 或 $t()。
6. 总结
通过手动使用 createVNode 和 render,我们突破了 Vue 模板的限制,实现了能够动态创建、挂载、销毁的命令式组件。
这也是开发高级组件(如弹窗、抽屉、通知)的必经之路。
关键点复习:
-
createVNode(Component, props) 创建虚拟节点。
-
render(vnode, container) 将虚拟节点转化为真实 DOM。
-
render(null, container) 销毁组件,释放内存。