Vue 原理的结构化理解:从三大子系统看“数据驱动视图”

“要理解 Vue 的原理,可以从它的三大核心子系统入手:编译时、响应式和运行时。三者协同工作,实现了 Vue ‘数据驱动视图’ 的核心目标。”

本文不求铺天盖地,只求讲清主线。你读完将能把 Vue 的工作流一句话讲清:模板先被编译,数据用响应式追踪,最后由运行时把差异精准打到真实 DOM 上。

为什么要分三大子系统?

  • 编译时(Compiler-Core):静态分析与优化,让“渲染时少做事”。
  • 响应式(Reactivity):建立“数据—副作用”的订阅关系,数据一变,副作用精准重跑。
  • 运行时(Runtime-Core):执行渲染与更新,把“想要的界面”变成“真实的 DOM”。

这三个角色像拍电影:编译时是“分镜与预演”,响应式是“场记与通知器”,运行时是“导演带着摄影师开拍”。


一、编译时:预编译与优化(Compiler-Core)

职责:把模板(.vuetemplate 选项)编译成渲染函数(render())。

核心流程:

  1. 解析(Parse)→ 把模板字符串变成 AST(抽象语法树)
  2. 转换(Transform)→ 在 AST 上做静态分析与结构优化
  3. 静态提升(Static Hoisting)→ 把永不变化的节点提升到渲染函数外复用
  4. Patch 标志(Patch Flags)→ 给“动态部分”打标记,运行时可定向更新
  5. 代码生成(Codegen)→ 输出可执行的 JS 渲染函数

一句话:编译时提前“算清楚哪些会变、哪些不变”,并把“会变的地方”打上标签。

示例(思路演示,非实际产物):

<div>
  <h1>静态标题</h1>
  <p>{{ msg }}</p>
</div>

编译后(概念化展示):

function render(ctx) {
  // h 就是创建 VNode 的辅助函数
  // 静态的 <h1> 可能被静态提升(只创建一次)
  const _hoisted_h1 = h('h1', null, '静态标题')
  // 动态的 <p> 会带上 PatchFlag,例如 TEXT
  return 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,会被 track
  console.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。

工作流:

  1. 初次渲染:app.mount() → 执行 render() 产出 VNode → patch(null, vnode) 创建真实 DOM → 挂载
  2. 更新渲染:响应式触发副作用 → 重新执行 render() 得到新 VNode → patch(old, new) diff 后定向更新
  3. 卸载:组件离场,运行时回收并移除对应 DOM 及副作用

运行时的“加速器”:

  • Patch Flags:来自编译时的“路标”,直指动态点,避免全量 Diff。
  • Keyed Diff:同层有序列表的高效对比,尽量复用节点。
  • 调度(Scheduler):批量队列、异步刷新,减少无意义的中间状态。
  • 特性能力:FragmentTeleportSuspense 等,丰富渲染表达力。

三者如何协同?一条从模板到 DOM 的流水线

  1. 开发者写模板与逻辑
  2. 编译时把模板“提前想明白”,产出带 PatchFlag 的 render()
  3. 首次挂载:运行时执行 render(),把 VNode 变成 DOM
  4. 数据变化:响应式追踪到变更,通知相关副作用(通常是组件渲染 effect)
  5. 运行时重跑 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-oncev-memo
    • 善用懒加载与拆分组件,降低一次渲染压力

一句话记忆

编译时“先想清楚”、响应式“盯紧数据”、运行时“动手精准改”。


通俗描述

把 Vue 想成一家“手摇奶茶店”:

  • 编译时是“菜谱设计师”,先把菜单排好,哪些料固定、哪些能加料一目了然;
  • 响应式是“店员的小本子”,你加了珍珠(数据一变),他立刻记下要多摇两下(副作用要重跑);
  • 运行时是“调饮师+出杯口”,根据小本子精准加料、摇一摇、贴上标签端出来(Diff & Patch)。

最终你点的是“少冰三分糖加布丁”,端上来的就不会是“热拿铁加葱段”。这就是 Vue:改了数据,杯子里的世界就跟着变,而且只改对的那一口。

更多推荐