Vue 原理三大子系统:编译时、响应式与运行时
目录
- Vue 原理的结构化理解:从三大子系统看“数据驱动视图”
- 为什么要分三大子系统?
- 一、编译时:预编译与优化(Compiler-Core)
- 二、响应式:数据变化的追踪系统(Reactivity)
- 三、运行时:渲染和调度的执行者(Runtime-Core)
- 三者如何协同?一条从模板到 DOM 的流水线
- 常见疑问(FAQ)
- 实战小抄(Cheat Sheet)
- 一句话记忆
- 通俗描述
Vue 原理的结构化理解:从三大子系统看“数据驱动视图”
“要理解 Vue 的原理,可以从它的三大核心子系统入手:编译时、响应式和运行时。三者协同工作,实现了 Vue ‘数据驱动视图’ 的核心目标。”
本文不求铺天盖地,只求讲清主线。你读完将能把 Vue 的工作流一句话讲清:模板先被编译,数据用响应式追踪,最后由运行时把差异精准打到真实 DOM 上。
为什么要分三大子系统?
- 编译时(Compiler-Core):静态分析与优化,让“渲染时少做事”。
- 响应式(Reactivity):建立“数据—副作用”的订阅关系,数据一变,副作用精准重跑。
- 运行时(Runtime-Core):执行渲染与更新,把“想要的界面”变成“真实的 DOM”。
这三个角色像拍电影:编译时是“分镜与预演”,响应式是“场记与通知器”,运行时是“导演带着摄影师开拍”。
一、编译时:预编译与优化(Compiler-Core)
职责:把模板(.vue
或 template
选项)编译成渲染函数(render()
)。
核心流程:
- 解析(Parse)→ 把模板字符串变成 AST(抽象语法树)
- 转换(Transform)→ 在 AST 上做静态分析与结构优化
- 静态提升(Static Hoisting)→ 把永不变化的节点提升到渲染函数外复用
- Patch 标志(Patch Flags)→ 给“动态部分”打标记,运行时可定向更新
- 代码生成(Codegen)→ 输出可执行的 JS 渲染函数
一句话:编译时提前“算清楚哪些会变、哪些不变”,并把“会变的地方”打上标签。
示例(思路演示,非实际产物):
<div><h1>静态标题</h1><p>{{ msg }}</p>
</div>
编译后(概念化展示):
function render(ctx) {// h 就是创建 VNode 的辅助函数// 静态的 <h1> 可能被静态提升(只创建一次)const _hoisted_h1 = h('h1', null, '静态标题')// 动态的 <p> 会带上 PatchFlag,例如 TEXTreturn h('div', null, [_hoisted_h1,h('p', /* PatchFlag: TEXT */ null, ctx.msg)])
}
有了 PatchFlag,运行时更新时就能“直奔主题”:只更新 p
的文本,不用糟蹋无辜的 h1
。
二、响应式:数据变化的追踪系统(Reactivity)
职责:把“数据”和“副作用(例如视图渲染)”绑在一起,数据一改,副作用就被精准通知。
Vue 3 用 Proxy
拦截读写,核心两个动作:
- 依赖收集(track,发生在 get)→ 谁在读我,我就记下谁
- 触发更新(trigger,发生在 set)→ 我变了,就通知被我记下的那些副作用
基本用法:
import { reactive, ref, effect, computed } from 'vue'const state = reactive({ count: 0 })
const double = computed(() => state.count * 2)effect(() => {// 读取 state.count,会被 trackconsole.log('副作用执行:', state.count)
})state.count++ // set → trigger → 让依赖它的 effect 重新执行
补充:
effect
内读取到哪些响应式数据,就会被这些数据“记住”。scheduler
(调度器)可把多次变更合并到微任务里,避免频繁重复更新。computed
会缓存结果,只有依赖变了才重新计算。watch
适合副作用逻辑与渲染解耦的场景(请求、日志、动画等)。
核心价值:精准更新。组件不会“因为父更了我就更”,而是“我依赖的数据真变了才更”。
三、运行时:渲染和调度的执行者(Runtime-Core)
职责:把渲染函数输出的 VNode 树挂到真实 DOM,并在数据更新时进行 Diff & Patch。
关键词:虚拟 DOM(VNode)
- 渲染函数返回的不是 DOM,而是用 JS 对象描述的“它应该长啥样”。
patch(oldVNode, newVNode)
会对比新旧 VNode,计算最小变更并修改真实 DOM。
工作流:
- 初次渲染:
app.mount()
→ 执行render()
产出 VNode →patch(null, vnode)
创建真实 DOM → 挂载 - 更新渲染:响应式触发副作用 → 重新执行
render()
得到新 VNode →patch(old, new)
diff 后定向更新 - 卸载:组件离场,运行时回收并移除对应 DOM 及副作用
运行时的“加速器”:
- Patch Flags:来自编译时的“路标”,直指动态点,避免全量 Diff。
- Keyed Diff:同层有序列表的高效对比,尽量复用节点。
- 调度(Scheduler):批量队列、异步刷新,减少无意义的中间状态。
- 特性能力:
Fragment
、Teleport
、Suspense
等,丰富渲染表达力。
三者如何协同?一条从模板到 DOM 的流水线
- 开发者写模板与逻辑
- 编译时把模板“提前想明白”,产出带 PatchFlag 的
render()
- 首次挂载:运行时执行
render()
,把 VNode 变成 DOM - 数据变化:响应式追踪到变更,通知相关副作用(通常是组件渲染 effect)
- 运行时重跑
render()
得到新 VNode,根据 PatchFlag 和 Diff 精准打补丁
这就是 Vue 的“数据驱动视图”:你改数据,它就改界面。
常见疑问(FAQ)
Q1:有了编译时,为什么还需要虚拟 DOM?
A:因为运行时仍需要从“理想界面”过渡到“真实 DOM”。编译时提供的是“指路牌”,运行时仍要执行更新动作,VNode 是可编程、可对比、可优化的中间层。
Q2:Vue 的响应式和 React 的 setState 有啥不同?
A:Vue 更偏“数据可观察”,通过 track/trigger
精准感知依赖;React 更偏“显式触发更新”,依赖父子 render 流程和 memo 化策略。两者目标一致:少做无用功。
Q3:不用模板、直接写 h()
会更快吗?
A:不一定。模板能让编译器做更多静态优化(静态提升、PatchFlag),手写 h()
需要你自己保证结构稳定与优化。
Q4:为什么有时我修改数据,视图“下一帧”才更新?
A:这源于调度器的批量异步刷新策略(微任务合并),用来避免同一轮事件循环里重复无效的多次渲染。
实战小抄(Cheat Sheet)
-
写模板时:
- 尽量保持结构稳定(少改列表 key、少动节点层级)
- 使用
key
明确列表节点身份 - 大块不变内容可组件化,利于静态提升与复用
-
写状态时:
- 基础状态用
reactive/ref
,派生值用computed
- 与渲染无关的副作用用
watch
(请求、缓存、日志) - 注意“只读源、只改源”的数据流,避免循环触发
- 基础状态用
-
性能调优:
- 定位“频繁变动点”和“庞大列表”
- 利用编译器产物:PatchFlag、
v-once
、v-memo
- 善用懒加载与拆分组件,降低一次渲染压力
一句话记忆
编译时“先想清楚”、响应式“盯紧数据”、运行时“动手精准改”。
通俗描述
把 Vue 想成一家“手摇奶茶店”:
- 编译时是“菜谱设计师”,先把菜单排好,哪些料固定、哪些能加料一目了然;
- 响应式是“店员的小本子”,你加了珍珠(数据一变),他立刻记下要多摇两下(副作用要重跑);
- 运行时是“调饮师+出杯口”,根据小本子精准加料、摇一摇、贴上标签端出来(Diff & Patch)。
最终你点的是“少冰三分糖加布丁”,端上来的就不会是“热拿铁加葱段”。这就是 Vue:改了数据,杯子里的世界就跟着变,而且只改对的那一口。