你每天都在写 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 = fnfn() // 跑的时候读数据,自动登记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({ count: 0 })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 快的关键
编译器不只是翻译,还会"偷看"你的模板,提前发现哪些是静态的、哪些是动态的,给运行时留标记。
一句话:模板在编译时就能看到,所以"哪里会变"可以提前算出来,运行时就不用瞎猜了。 这就是 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 之间的距离,比想象中小得多。
夜雨聆风