乐于分享
好东西不私藏

Vue3 源码一览通

Vue3 源码一览通

你每天都在写 Vue 组件,但你有没有想过:写完的 .vue 文件,浏览器是怎么把它变成页面上的东西的?

这篇文章不堆术语,从你已经会的东西出发,一层层拆开 Vue3 的源码。每一步只回答一个问题:它为什么这样设计?


先看全局:Vue3 就两个部门

不管源码有多少万行,干的事情就两件:

响应式部门:盯着你的数据,数据一变,马上喊"有人变了!"渲染部门:听到喊声,算出页面哪里要改,动手改 DOM

所有 Vue3 源码都在干这两件事。后面每一步,都是给这张图填细节。


第一步:mount 做了什么

每个 Vue 项目都有这两行:

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

createApp 像开店前准备好营业执照——拿到了 app 对象,但还没开始营业。

mount 是按下启动键。它干三件事:

1.把根组件变成一份"设计图"(vnode)2.按设计图造出真实 DOM,塞进页面3.把设计图存档,下次更新时拿来对比

就像盖房子:先画设计图,按图施工,最后把图纸存档——下次改建时拿旧图纸比一比,看哪些墙要拆、哪些要加。


第二步:数据变了,页面怎么自己变了

你写 count++,页面上数字自动变了。Vue 怎么做到的?

想想微信公众号:你关注了一个公众号,不需要每隔5分钟去刷新看有没有新文章——它发了新文章,会主动推给你。

Vue 的响应式一模一样:

组件渲染时读了 count → 等于"关注"了它count 被修改 → Vue 查记账本,主动通知关注过它的组件组件重新渲染

核心代码就二十行:

let activeEffect = null       // 当前正在跑的函数const deps = new Map()        // 记账本:谁关注了谁function effect(fn) {         // "关注" + 执行  activeEffect = fn  fn()                        // 跑的时候读数据,自动登记  activeEffect = null}function track(obj, key) {       // 读数据时:登记关注  if (!activeEffect) return      // 没有函数在读,不登记  let map = deps.get(obj) || {}  // 取出 obj 的账本(首次是空对象)  // ① 该属性的名单:没有就新建,有直接用(||= 等于"没值就赋值")  // ② .add(activeEffect):把当前正在跑的函数写进名单,Set 去重  // ③ 行首 ;:防 JS 把上一行和这行连读成一句而报错  ;(map[key] ||= new Set()).add(activeEffect)  deps.set(obj, map)             // 账本写回去(首次新建过,得存好)}function trigger(obj, key) {   // 写数据时:推送通知  // ① deps.get(obj):取出 obj 的账本;没人读过它就是 undefined  // ② ?.[key]:再取该属性的名单;?. 是可选链——前面是空就不继续,整行不报错  // ③ ?.forEach(fn => fn()):名单存在才遍历,把登记过的函数挨个叫一遍  deps.get(obj)?.[key]?.forEach(fn => fn())}function reactive(target) {   // 给数据套一层拦截  return new Proxy(target, {    get(t, key) { track(t, key); return t[key] },    set(t, key, val) { t[key] = val; trigger(t, key); return true }  })}

试一下:

const state = reactive({ count0 })effect(() => console.log('当前值:', state.count))  // 当前值: 0state.count = 5   // 当前值: 5  ← 什么都没做,函数自己重跑了

Vue3 的 @vue/reactivity 就是这套逻辑的完整工业版,但骨架就三个词:读时登记、写时通知、Proxy 拦截


Proxy 和 defineProperty 有什么区别

Vue2 用 Object.defineProperty,Vue3 换成了 Proxy。为什么要换?

defineProperty 是给每个属性单独装监控——你得提前把所有属性遍历一遍,逐个改造。后来新增的属性?管不着。这就是 Vue2 里 this.obj.newKey = 1 不响应的原因,得用 $set 补。

Proxy 是给整个对象装监控——不管你后面加什么属性,只要碰这个对象,它都拦得住。

一句话:defineProperty 是给每个房间装摄像头,Proxy 是在大门口装一个保安。大门口一个保安,进进出出都逃不过。


第三步:虚拟 DOM——为什么要多一层

你可能会想:数据变了,直接改 DOM 不行吗?为什么要搞个"虚拟 DOM"?

因为直接改 DOM,你得自己算"从现状到目标,哪些要改"。虚拟 DOM 让你只说"页面应该长这样","怎么改"交给 Vue 算。

vnode 就是一个普通 JS 对象,描述"这里该有什么样的标签":

{  type: 'div',  props: { class: 'box' },  children: ['你好']}

vnode 是设计图,patch 是施工队——拿到设计图,把对应的真实 DOM 造出来或改好。


第四步:patch——找不同,改最少

数据变了,新的 vnode 出来了。拿它和旧的 vnode 对比,算出最少改几个 DOM 节点。这个过程叫 patch

就像玩"找不同":两张图摆一起,只改不一样的地方。

列表更新:为什么要写 :key

列表更新最复杂。比如 [A, B, C] 变成 [B, C, A]

没有 key:Vue 只能按下标对比。第0位 A→B、第1位 B→C、第2位 C→A——三个位置内容全不一样,三个全要改内容有 key:Vue 认出"A、B、C 都还在,只是 A 挪到了最后"——B、C 原地不动,只挪 A 这一个节点,不用改内容

所以列表一定要写 :key。没有 key,Vue 只能暴力全改;有 key,才能"认人",只动该动的。

Vue3 的列表 diff 分三步:从头对齐相同的 → 从尾对齐相同的 → 中间乱序的,用算法找出"哪些不用动"。最后一步的意思:一排书只有几本放错了,你只需要动那几本,其余不碰。


第五步:编译器——模板怎么变成代码

你写的是 <template>,浏览器跑的是 JS 函数。中间有个翻译过程:

模板 → 解析(parse) → AST → 转换(transform) → 生成 → render 函数

这个过程在 Vite 打包时就完成了。浏览器拿到的已经是 JS,不是模板字符串。

编译器帮了什么忙——这是 Vue3 比 Vue2 快的关键

编译器不只是翻译,还会"偷看"你的模板,提前发现哪些是静态的、哪些是动态的,给运行时留标记。

优化
干什么
类比
静态提升
纯静态节点只创建一次,反复复用
设计图的固定部分只画一次
patchFlag
标记"这个节点只有 class 可能变"
只批改错题,不重写整本作业
Block Tree
只遍历会变的节点,跳过静态子树
只检查有问题的房间,不搜整栋楼

一句话:模板在编译时就能看到,所以"哪里会变"可以提前算出来,运行时就不用瞎猜了。 这就是 Vue3 比 Vue2 快的核心原因。


第六步:组件——把所有零件拼起来

最后把响应式和渲染器拼在一起,看一个组件完整的一生:

1.组件挂载 → 把 render 函数包进 effect2.effect 第一次跑 → render 产出 vnode → patch 成真实 DOM3.数据变了 → trigger → effect 排队等执行4.微任务里批量执行 → render 重跑 → 新旧 vnode 对比 → 只改变化的部分

关键在于:组件的 render 被包在 effect 里。所以数据一变,effect 就被通知,组件就重新渲染。然后靠 patchFlag 精确定位到具体 DOM 节点——不用整棵树重建。

更新不能乱来——批量执行

你连续改 10 个数据,组件不应该重渲染 10 次。Vue 的做法:把更新任务攒进队列,等本轮同步代码跑完后,微任务里一次性执行。

这就像手机通知栏:一个 App 一晚上推了 8 条消息,通知栏只折叠成一条。数据变了 10 次,组件只渲染 1 次。

nextTick 就是"队列执行完之后"那个时间点。因为渲染是异步批量的,你同步读 DOM 拿到的是旧值,await nextTick() 等的就是队列刷完。

生命周期挂在哪里

onMounted(fn) 不是魔法。它就是把 fn 塞进一个数组,等渲染器在对应时机调用。这就是为什么生命周期只能在 setup 里调——出了 setup,当前组件实例就找不到了。


从哪开始读源码

packages/  reactivity/       ← 从这开始!响应式核心  runtime-core/     ← 渲染器(patch、diff、组件)  compiler-core/    ← 编译器(模板 → render)  runtime-dom/      ← 浏览器专属(DOM 操作)  vue/              ← 汇总出口

建议顺序:先读 reactivity(理解响应式)→ 再读 runtime-core/renderer.ts(理解 patch)→ 最后读 compiler-core(理解编译优化)

带着问题读,别从头啃到尾:

activeEffect 什么时候被赋值?patch 遇到不同类型的节点怎么分支?一个 {{ count }} 从模板到 render 经历了什么?

动手题:把上面的二十行响应式代码抄下来跑一遍。再给它加上调度器(一个队列 + Promise.resolve().then)。你会发现,你的迷你框架和 Vue3 之间的距离,比想象中小得多。