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 层:
- 编译层:
compiler-core/compiler-dom - 运行时内核:
runtime-core - 平台实现:
runtime-dom
其中 runtime-core 主要负责:
- 定义
VNode及其更新规则 - 创建组件实例并维护组件上下文
- 执行
setup、处理props/slots/emit - 建立组件渲染副作用
- 实现
patch、组件挂载与更新 - 管理生命周期钩子
- 维护任务调度与批量更新
它故意不直接写死 DOM API,所以内部大量逻辑都会依赖“宿主能力”:
createRenderer({
insert,
remove,
patchProp,
createElement,
createText,
})
这也是 Vue 能做自定义渲染器的根本原因。
2. 从入口看主流程:页面第一次挂载发生了什么?
如果你写的是:
createApp(App).mount('#app')
源码主流程可以按下面这条链理解:
runtime-dom基于 DOM API 组装宿主能力- 调用
createRenderer生成渲染器 createApp把根组件包装成根VNoderender(vnode, container)进入patch- 发现是组件节点,走
processComponent - 创建组件实例,执行
setup - 建立组件更新函数
update - 首次执行
update,产出子树subTree - 再次
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 | 插槽内容 |
setupState | setup() 返回的状态 |
proxy | 模板/渲染函数访问的代理对象 |
render | 当前组件的渲染函数 |
subTree | 本组件上一次渲染得到的子 VNode 树 |
effect | 组件级响应式副作用 |
update | 组件更新入口 |
isMounted | 是否已完成首次挂载 |
面试里如果被追问“组件更新为什么知道从哪儿开始比?”答案通常就是:
- 因为实例上保存了上一次的
subTree - 下一次 render 会生成新的
subTree - 然后用
patch(oldSubTree, newSubTree)递归比对
4. setupComponent 干了什么?
组件实例创建后,不会立刻渲染,而是先完成组件初始化。
这一步通常会做:
- 初始化
props - 初始化
slots - 处理有状态组件 / 函数组件
- 创建 public proxy
- 执行
setup - 处理
setup返回值 - 最终确认
render
这里最容易被问到两个点。
4.1 setup 返回对象和返回函数有什么区别?
- 返回对象:对象会并入
setupState,供模板或 render 使用 - 返回函数:这个函数会直接作为组件 render
所以 setup 既能“提供状态”,也能“直接接管渲染”。
4.2 为什么模板里能直接访问 setup 返回值?
因为模板执行时访问的不是裸实例,而是 instance.proxy。这个代理会按优先级去找:
setupStatedataprops- 公开属性,如
$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 都立刻跑组件更新,而是:
- 让 effect 的
scheduler接管执行时机 - 把组件更新任务塞进队列
- 队列做去重
- 在一个微任务里统一 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-core 和 runtime-dom 的区别?
标准答法:
runtime-core是平台无关的运行时内核,负责组件、VNode、patch、调度器。runtime-dom是浏览器平台实现,提供 DOM 的插入、删除、属性更新等宿主能力。- 两者通过
createRenderer解耦。
10.2 组件更新的最短主链是什么?
标准答法:
- 响应式数据变更
- 触发组件 render effect
- 调度器把 update 放入队列
- flush 时重新执行 render
- 得到新旧
subTree 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。