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) 销毁组件,释放内存。
相关推荐
全栈前端老曹几秒前
【MongoDB】Node.js 集成 —— Mongoose ORM、Schema 设计、Model 操作
前端·javascript·数据库·mongodb·node.js·nosql·全栈
zheyutao38 分钟前
字符串哈希
算法
A尘埃1 小时前
保险公司车险理赔欺诈检测(随机森林)
算法·随机森林·机器学习
低代码布道师1 小时前
Next.js 16 全栈实战(一):从零打造“教培管家”系统——环境与脚手架搭建
开发语言·javascript·ecmascript
一位搞嵌入式的 genius1 小时前
深入 JavaScript 函数式编程:从基础到实战(含面试题解析)
前端·javascript·函数式
choke2331 小时前
[特殊字符] Python 文件与路径操作
java·前端·javascript
大江东去浪淘尽千古风流人物1 小时前
【VLN】VLN(Vision-and-Language Navigation视觉语言导航)算法本质,范式难点及解决方向(1)
人工智能·python·算法
wqq63108552 小时前
Python基于Vue的实验室管理系统 django flask pycharm
vue.js·python·django
Deng9452013142 小时前
Vue + Flask 前后端分离项目实战:从零搭建一个完整博客系统
前端·vue.js·flask
Hello.Reader2 小时前
Flink 文件系统通用配置默认文件系统与连接数限制实战
vue.js·flink·npm