跳到主要内容

Vue Runtime-Core源码原理

如果面试官问你 runtime-core 是什么,最稳的答法不是“Vue 运行时”,而是:

  • runtime-core 是 Vue 3 平台无关的运行时内核
  • 它负责组件实例、渲染流程、VNode diff、调度器、生命周期、错误处理这些“框架骨架”
  • 真正和浏览器 DOM 打交道的是 runtime-dom

也就是说,runtime-core 解决的是“组件应该怎么运行”,runtime-dom 解决的是“把运行结果落到浏览器里”。

0. 面试速答(30 秒版 TL;DR)

  • runtime-core 是 Vue 3 的运行时中枢,核心链路是:创建组件实例 -> 执行 setup -> 建立渲染副作用 -> 生成新 VNode 树 -> patch 更新旧树
  • 它内部最关键的几个概念是:VNode、组件实例 instance、组件渲染副作用 effect、调度器 scheduler
  • Vue 组件更新不是“数据一变立刻改 DOM”,而是 先触发响应式 effect,再把组件更新任务放进队列,最后批量 flush
  • runtime-core 的性能抓手主要有 4 个:组件级 effect、调度去重、Block Tree、Patch Flags
  • 平台适配通过 createRenderer(options) 完成,所以 Vue 不只能跑在浏览器,也能跑在自定义渲染目标上。

先记主链路:响应式数据变化 -> 组件 effect 重调度 -> render -> 新 VNode -> patch -> 宿主更新

1. 先搞清边界:runtime-core 到底管什么?

可以把 Vue 3 拆成 3 层:

  1. 编译层compiler-core / compiler-dom
  2. 运行时内核runtime-core
  3. 平台实现runtime-dom

其中 runtime-core 主要负责:

  • 定义 VNode 及其更新规则
  • 创建组件实例并维护组件上下文
  • 执行 setup、处理 props / slots / emit
  • 建立组件渲染副作用
  • 实现 patch、组件挂载与更新
  • 管理生命周期钩子
  • 维护任务调度与批量更新

它故意不直接写死 DOM API,所以内部大量逻辑都会依赖“宿主能力”:

createRenderer({
insert,
remove,
patchProp,
createElement,
createText,
})

这也是 Vue 能做自定义渲染器的根本原因。

2. 从入口看主流程:页面第一次挂载发生了什么?

如果你写的是:

createApp(App).mount('#app')

源码主流程可以按下面这条链理解:

  1. runtime-dom 基于 DOM API 组装宿主能力
  2. 调用 createRenderer 生成渲染器
  3. createApp 把根组件包装成根 VNode
  4. render(vnode, container) 进入 patch
  5. 发现是组件节点,走 processComponent
  6. 创建组件实例,执行 setup
  7. 建立组件更新函数 update
  8. 首次执行 update,产出子树 subTree
  9. 再次 patch(subTree),最终落到元素挂载

可以用下面这段伪代码记:

const vnode = createVNode(App)

render(vnode, container) {
patch(null, vnode, container)
}

patch(n1, n2) {
if (n2 是组件) {
processComponent(n1, n2)
} else if (n2 是元素) {
processElement(n1, n2)
}
}

3. 组件实例是 runtime-core 的核心数据结构

很多源码问题,最后都能落回组件实例 instance

一个组件实例通常至少会维护这些状态:

字段作用
vnode当前组件对应的 VNode
type组件定义本身
props解析后的入参
attrs未声明为 props 的属性
slots插槽内容
setupStatesetup() 返回的状态
proxy模板/渲染函数访问的代理对象
render当前组件的渲染函数
subTree本组件上一次渲染得到的子 VNode 树
effect组件级响应式副作用
update组件更新入口
isMounted是否已完成首次挂载

面试里如果被追问“组件更新为什么知道从哪儿开始比?”答案通常就是:

  • 因为实例上保存了上一次的 subTree
  • 下一次 render 会生成新的 subTree
  • 然后用 patch(oldSubTree, newSubTree) 递归比对

4. setupComponent 干了什么?

组件实例创建后,不会立刻渲染,而是先完成组件初始化。

这一步通常会做:

  1. 初始化 props
  2. 初始化 slots
  3. 处理有状态组件 / 函数组件
  4. 创建 public proxy
  5. 执行 setup
  6. 处理 setup 返回值
  7. 最终确认 render

这里最容易被问到两个点。

4.1 setup 返回对象和返回函数有什么区别?

  • 返回对象:对象会并入 setupState,供模板或 render 使用
  • 返回函数:这个函数会直接作为组件 render

所以 setup 既能“提供状态”,也能“直接接管渲染”。

4.2 为什么模板里能直接访问 setup 返回值?

因为模板执行时访问的不是裸实例,而是 instance.proxy。这个代理会按优先级去找:

  1. setupState
  2. data
  3. props
  4. 公开属性,如 $attrs$slots

所以你在模板里写的 count,最终其实是被 proxy 转发解析出来的。

5. 真正驱动组件更新的是“组件级 effect”

这一段是 runtime-core 最关键的主线。

组件不是“手动 render 一次就结束”,而是会创建一个响应式副作用:

instance.update = effect(function componentUpdateFn() {
if (!instance.isMounted) {
const subTree = renderComponentRoot(instance)
patch(null, subTree, container)
instance.subTree = subTree
instance.isMounted = true
} else {
const nextTree = renderComponentRoot(instance)
patch(instance.subTree, nextTree, container)
instance.subTree = nextTree
}
}, scheduler)

你可以重点记住两件事:

  • 首次执行:生成子树并挂载
  • 后续执行:生成新子树并和旧子树比较

所以 Vue 组件更新的本质不是“直接修改 DOM”,而是:

响应式系统通知组件 effect 重新执行,而 effect 重新执行会产出新的 VNode 树。

6. 为什么数据变很多次,组件通常只更新一次?

因为 runtime-core 里有调度器。

如果没有调度器,下面代码会导致 3 次同步 render:

state.count++
state.count++
state.count++

Vue 的做法不是每次 trigger 都立刻跑组件更新,而是:

  1. 让 effect 的 scheduler 接管执行时机
  2. 把组件更新任务塞进队列
  3. 队列做去重
  4. 在一个微任务里统一 flush

这就是为什么:

  • 多次同步修改通常只触发一次视图更新
  • nextTick() 本质上是“等当前这轮队列 flush 完”

7. patch 为什么能同时处理元素、组件、文本、Fragment?

因为 VNode 上带了足够多的类型信息。

patch 会先按节点类型分派:

  • 文本节点
  • 注释节点
  • 静态节点
  • Fragment
  • 普通元素
  • 组件

对元素节点,再继续区分:

  • 首次挂载还是更新
  • 文本子节点还是数组子节点
  • 是否带 patchFlag
  • 是否处于 block 动态子节点优化路径

所以 patch 不是一个“单一算法”,而是一组基于 VNode 类型分发的处理流程。

8. 性能优化为什么能落到 runtime-core

很多人只记得“Vue 3 更快”,但答不出快在哪一层。更准确的说法是:

8.1 Patch Flags

编译器会告诉运行时某个节点“到底哪里可能变化”。

于是更新时不用全量对比,而是定向更新:

  • 只改文本
  • 只改 class
  • 只改 style
  • 只改 props 中的某几个键

8.2 Block Tree

编译器会把动态节点组织进 block。这样组件更新时,运行时可以直接命中“动态后代列表”,跳过大量稳定静态节点。

8.3 组件级 effect

Vue 不是一改数据就从根组件暴力重跑,而是谁依赖谁更新。组件 effect 是更新边界。

8.4 调度队列

合并更新、去重、按顺序 flush,减少重复 render。

一句话总结:

  • 编译器负责提前告诉运行时哪里会变
  • runtime-core 负责把这些提示转成更少的运行时开销

9. 生命周期钩子为什么能在正确时机触发?

runtime-core 会在组件挂载、更新、卸载的关键节点调用生命周期队列。

常见理解方式:

  • beforeMount:真正挂载前
  • mounted:子树 patch 完后
  • beforeUpdate:更新前
  • updated:新子树 patch 完后
  • beforeUnmount / unmounted:卸载前后

这些钩子并不是“神奇自动运行”,而是组件更新函数在不同阶段显式调用。

10. 高频面试题怎么答

10.1 runtime-coreruntime-dom 的区别?

标准答法:

  • runtime-core 是平台无关的运行时内核,负责组件、VNode、patch、调度器。
  • runtime-dom 是浏览器平台实现,提供 DOM 的插入、删除、属性更新等宿主能力。
  • 两者通过 createRenderer 解耦。

10.2 组件更新的最短主链是什么?

标准答法:

  1. 响应式数据变更
  2. 触发组件 render effect
  3. 调度器把 update 放入队列
  4. flush 时重新执行 render
  5. 得到新旧 subTree
  6. patch 差量更新 DOM

10.3 为什么 nextTick 能拿到更新后的 DOM?

因为它等的是当前这轮 job 队列和后置回调执行完成。组件 DOM 更新本身就发生在这轮 flush 里。

11. 常见误区

  • 误区 1:Vue 更新等于“数据变化直接改 DOM” 实际上中间至少隔着 effect、scheduler、render、patch 这几层。
  • 误区 2:runtime-core 只负责组件,不负责元素 不对。元素 patch 逻辑也在运行时内核里,只是具体 DOM 操作由宿主实现提供。
  • 误区 3:组件更新一定从父到子完全重渲染 Vue 3 会结合组件边界、patch flags、动态子树做裁剪,不是全量暴力更新。
  • 误区 4:setup 执行完就结束了 真正长期驱动页面的是后面建立起来的组件级 render effect。

12. 速记要点

  • 一句话runtime-core 就是 Vue 3 的“组件运行内核”。
  • 一条主链setup -> render effect -> subTree -> patch
  • 一个关键缓存:实例上的旧 subTree
  • 一个关键机制:scheduler 合并更新。
  • 一个高频边界runtime-core 管流程,runtime-dom 管 DOM。