Message组件和Vue3 进阶:手动挂载组件与 Diff 算法深度解析

Vue3 进阶:手动挂载组件与 Diff 算法深度解析

很多 Vue 开发者习惯了在 <template> 里写组件,但在开发 Message(全局提示)、Modal(模态框)或 Notification(通知)这类组件时,我们往往希望通过 JS 函数直接调用,而不是在每个页面都写一个 <MyMessage /> 标签。本文将带你深入 Vue3 底层,看看如何手动渲染组件

1. 为什么需要手动挂载?

想象一下,如果你想弹出一个成功提示,哪种方式更优雅?

方式 A (声明式)

需要在每个页面的 template 里都写一遍组件,还要定义一个变量来控制显示隐藏。

html 复制代码
<template>
  <MyMessage :visible="showMsg" message="操作成功" />
  <button @click="showMsg = true">点击</button>
</template>

方式 B (命令式)

直接在 JS 里调用,随用随调,完全解耦。

javascript 复制代码
import { message } from 'my-ui'

function handleClick() {
  message.success('操作成功')
}

显然,方式 B 是组件库的标准做法。但 Vue 的组件通常是渲染在父组件的模板里的,如何把它"凭空"变出来并挂载到 document.body 上呢?

这就需要用到 Vue3 暴露的两个底层 API:createVNoderender

2. 核心 API 解密

createVNode:画图纸

在 Vue 中,一切皆 VNode(虚拟节点)。普通的 .vue 文件只是一个组件定义,它不是 DOM,也不是 VNode。我们需要用 createVNode 把它实例化。

javascript 复制代码
import { createVNode } from 'vue'
import MyComponent from './MyComponent.vue'

// 这就像是拿着图纸 (MyComponent)
// 创建了一个具体的实例化对象 (vm)
// 第二个参数是 props
const vnode = createVNode(MyComponent, { title: 'Hello' })

render:施工队

有了 VNode,它还只是内存里的对象。我们需要 render 函数把它变成真实的 DOM 节点,并挂载到某个容器上。

javascript 复制代码
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

typescript 复制代码
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 事件(比如在动画结束时),然后在外部监听它。

typescript 复制代码
const vnode = createVNode(MessageConstructor, {
  ...options,
  // 监听组件内部 emit('destroy')
  onDestroy: () => {
    // 移除 DOM
    render(null, container) // 这一步会触发组件的 unmounted 钩子
  }
})

4. 源码深潜:createVNode 和 render 到底干了啥?

对于好奇心强的同学,可能想知道:Vue 内部到底是怎么把这几行代码变成页面的?让我们用最通俗的伪代码来拆解一下。

4.1 createVNode:给节点"打标签"

createVNode 的核心任务不仅仅是创建一个对象,更是为了性能优化。它会根据你传入的类型,给 VNode 打上一个二进制标记(ShapeFlag)。

typescript 复制代码
// 伪代码简化版
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 函数。

typescript 复制代码
// 伪代码简化版
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,把任务分发给不同的处理函数。

typescript 复制代码
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 发现这是个组件时,它会区分是"初次挂载"还是"更新"。

typescript 复制代码
function processComponent(n1, n2, container) {
  if (n1 == null) {
    // 1. 挂载组件 (Mount)
    mountComponent(n2, container)
  }
  else {
    // 2. 更新组件 (Update)
    updateComponent(n1, n2)
  }
}

mountComponent 做的事情:

  1. 创建实例const instance = createComponentInstance(vnode)
  2. 设置状态 :初始化 propsslots,执行 setup() 函数。
  3. 建立副作用 :创建一个 effect(响应式副作用),运行组件的 render 函数生成子树(subTree),并监听响应式数据变化。

4.5 深入 processElement:挂载与更新

patch 遇到 HTML 标签时,会根据 n1(旧节点)是否存在来决定是初始化还是更新。

1. 挂载 (Mount)

如果 n1null,说明是初次渲染。Vue 会调用宿主环境的 API(如 document.createElement)创建真实 DOM,并将其插入到容器中。

typescript 复制代码
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 操作。

  1. 更新 Props:对比新旧 Props,修改变动的 Class、Style 或事件监听器。
  2. 更新 Children (核心 Diff):这是最复杂的部分。

4.6 核心 Diff 算法:Vue3 是如何"增删改移"的?

Vue3 采用的是快速 Diff 算法 (Quick Diff) 。它的核心思想是:先处理两端容易对比的节点,最后再处理中间复杂的乱序部分

我们通过一个具体的代码示例来模拟这个过程。

假设场景:

javascript 复制代码
// 旧列表 (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++

javascript 复制代码
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)

同样的逻辑,从尾部开始向前遍历。

javascript 复制代码
// 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),旧列表还剩,说明全是删除

javascript 复制代码
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. 构建新节点映射表与初始化

javascript 复制代码
// 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. 遍历旧节点:复用与删除

javascript 复制代码
// 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 格 © :表里写着 3。意思是:"我是旧列表里的第 2 号(3-1)人"。
  • 第 2 格 (D) :表里写着 4。意思是:"我是旧列表里的第 3 号(4-1)人"。
  • 第 3 格 (H) :表里写着 0。意思是:"查无此人,我是新来的"。

Vue 看到这张表,就知道谁是从哪儿来的,谁是新来的。然后只要算出哪个序列是递增的(3 -> 4),就说明这几个人(C 和 D)的相对站位没变,可以不用动,省力气!

3. 计算最长递增子序列 (LIS)

Vue 使用一个算法算出 newIndexToOldIndexMap 中的最长递增子序列 。这个序列里的节点,在旧列表和新列表里的相对顺序是一样的,所以不需要移动

javascript 复制代码
// 获取最长递增子序列的相对索引
// 传入: [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。

javascript 复制代码
// 遍历待处理的新节点 (倒序)
// 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--
  }
}

💡 核心逻辑图解:

  1. H (i=3) : Map 值为 0 -> 新建,插到末尾。
  2. D (i=2) : 命中 LIS (seq[j]=2) -> 不动j--
  3. C (i=1) : 命中 LIS (seq[j]=1) -> 不动j--
  4. E (i=0) : 不在 LIS 里 -> 移动,插到 C 前面。

5. 源码级细节:为什么需要 Context?

你可能会发现我们的源码里有这么一行:

typescript 复制代码
vnode.appContext = context || null

这是为了让动态挂载的组件能继承当前 App 的上下文。比如,你在主 App 里注册了 vue-routeri18n,如果不把 appContext 赋值给新的 VNode,那么在这个 Message 组件里就无法使用 useRouter()$t()

6. 总结

通过手动使用 createVNoderender,我们突破了 Vue 模板的限制,实现了能够动态创建、挂载、销毁的命令式组件。

这也是开发高级组件(如弹窗、抽屉、通知)的必经之路。

关键点复习

  1. createVNode(Component, props) 创建虚拟节点。
  2. render(vnode, container) 将虚拟节点转化为真实 DOM。
  3. render(null, container) 销毁组件,释放内存。
相关推荐
计算机程序设计小李同学1 小时前
基于Web和Android的漫画阅读平台
java·前端·vue.js·spring boot·后端·uniapp
ゞ 正在缓冲99%…1 小时前
2025.12.17华为软开
java·算法
和你一起去月球1 小时前
动手学Agent应用开发(TS/JS 最简实践指南)
开发语言·javascript·ecmascript·agent·mcp
子午1 小时前
【2026原创】文本情感识别系统~Python+深度学习+textCNN算法+舆情文本+模型训练
python·深度学习·算法
Flash.kkl1 小时前
递归、搜索与回溯算法概要
数据结构·算法
s09071361 小时前
【MATLAB】多子阵合成孔径声纳(SAS)成像仿真——基于时域反向投影(BP)算法
算法·matlab·bp算法·合成孔径
Xの哲學1 小时前
Linux Workqueue 深度剖析: 从设计哲学到实战应用
linux·服务器·网络·算法·边缘计算
sin_hielo2 小时前
leetcode 3047
数据结构·算法·leetcode
JAI科研2 小时前
MICCAI 2025 IUGC 图像超声关键点检测及超声参数测量挑战赛
人工智能·深度学习·算法·计算机视觉·自然语言处理·视觉检测·transformer