简介:Vue3 与 TypeScript 的工程实践资料包,面向已掌握基础语法、希望从源码与实例层面理解 Composition API、响应式机制和类型系统的前端开发者。包内共 2002 个文件,以 1042 个 Markdown 笔记和 803 个 JavaScript 源码及示例为主体,辅以 147 个 JSON 配置与少量 HTML 测试页面,压缩包约 63MB,整体按主题分目录组织,便于按模块检索。内容涵盖 Vue3 渲染性能优化要点、用 Composition API 组织与复用组件逻辑的方式,以及在项目中通过 Interface、泛型与自定义类型约束数据结构、保证响应式数据准确性的做法;同时延伸至 Vuex 与 Pinia 的状态集中管理、Vue Router 的页面导航组织,以及 Ant Design Vue 等组件库的集成思路。已有 575 人学习,适合想对照源码与笔记补齐工程化细节、提升类型安全意识的中级前端开发者。
1. 先跑通再读码:从 effect.ts 的断点开始,而不是从第一行
拿到一份 Vue3 + TypeScript 的源码学习包,最常见的翻车路径是从packages/reactivity/src/effect.ts第一行逐行往下读。读到track里那一堆分支开始走神,合上电脑只剩一句“Proxy 拦截了 get”,下次打开还是从头再来。这份资源的用法正好相反:它用 TypeScript 的类型定义把响应式、编译器、运行时三块内容串起来,先让你在一次真实的断点里看到依赖是怎么被收集、又是怎么被触发的,再回头解释实现为什么必须这么写。它适合已经能用 Vue3 写业务、但说不清ref和reactive在类型层面为什么表现不一致的开发者;如果连setup都没写过几遍,直接读源码只会更糊涂。下面按“先能调试、再拆内核、最后验证”的顺序把它拆开。
2. 包结构与构建链路:把断点打进 src 而不是 dist
读源码第一件体力活不是阅读,而是让调试器停在你以为的那一行。我见过太多人对着dist里的压缩产物下断点,看到的是变量名被替换成e、t、n的代码,然后得出“源码没法调”的结论。源码包的价值恰恰在于它可以被构建成带 sourcemap 的产物,再被一个几十行的复现脚本引用,这样断点、调用栈、闭包变量全都能对上原始文件。第 2 章先把这条链路打通,后面三章才有地方落脚。
2.1 monorepo 的包边界决定你的阅读顺序
这类资源通常是一个 workspace 仓库,几十个源文件被拆进若干包,包之间的依赖是一张单向图而不是网状。理解这张图比理解任何单个函数都重要,因为它直接告诉你:哪些包可以脱离浏览器单独跑,哪些包必须和 DOM 绑在一起才能观察。
| 包 | 职责 | 依赖方向 | 建议投入 |
|---|---|---|---|
| reactivity | 响应式内核,reactive / ref / effect / computed | 只依赖 shared | 第一优先,2 到 3 天 |
| compiler-core | 模板 AST、转换阶段、代码生成 | 依赖 shared | 第二优先 |
| runtime-core | 组件实例、渲染器、调度器 | 依赖 reactivity、shared | 第三优先 |
| runtime-dom | DOM 平台实现、事件与属性 patch | 依赖 runtime-core | 按需查阅 |
| shared | 通用工具函数与 PatchFlags 常量 | 无 | 随用随查 |
依赖方向决定了引入成本。reactivity的输入是一段普通对象,输出是普通值,不碰 DOM、不碰组件实例,一个node脚本就能跑完一个完整流程,所以它是唯一适合“单点打穿”的包。而runtime-core里的渲染器动辄两三千行,它把响应式、虚拟节点 diff、调度队列压在同一段代码里,新手直接啃必然迷路。我的建议是先在reactivity里把track/trigger的闭环看清楚,再带着“渲染函数本质上是一个大的 effect”这个前提去读渲染器,最后回头读编译器,看模板怎么被翻译成那个 effect。
还有一个容易被忽略的点:聚合入口包vue并不是核心,它只是把runtime-dom和compiler-dom拼到一起,顺便做了一层兼容导出。很多人从这个入口往里追,追三层还在转发函数,耐心就耗光了。
2.2 用 pnpm workspace 搭一个可断点的环境
先把依赖装成和作者一致的状态,再决定怎么构建。下面三条命令是这条链路的最小闭环,顺序不要换。
# 1) 按 lock 文件安装,保证 workspace 内部包是软链而不是重复副本 pnpm install --frozen-lockfile # 2) 只构建需要的包,并强制保留 sourcemap;跳过压缩阶段 pnpm build reactivity runtime-core --sourcemap # 3) 用 node 跑一个最小复现脚本,--inspect-brk 让进程停在第一行 node --inspect-brk ./scripts/min-repro.mjs--frozen-lockfile的作用是禁止安装过程改写 lock 文件,避免你本地解析出的版本和作者不同,从而读到一份“看起来一样、行为不一样”的代码。--sourcemap是整条链路的关键开关,默认的生产构建会把 sourcemap 关掉或剥离,断点只能落在 bundle 级别;打开之后,Chrome DevTools 或编辑器里的断点才会精确映射回.ts原文件。--inspect-brk和--inspect的差别在于前者会在第一行暂停,给你时间把调试器挂上去,否则脚本三秒跑完你什么都抓不到。
复现脚本本身不用复杂,只要能触发一次依赖收集和一次依赖触发就够。
// scripts/min-repro.mjs —— 直接引用构建产物,路径按仓库实际结构调整 import { reactive, effect } from '../packages/vue/dist/vue.runtime.esm-bundler.js' const state = reactive({ count: 0 }) // 在这里下断点:观察全局 activeEffect 从 null 变成 ReactiveEffect 实例的瞬间 effect(() => { console.log('run:', state.count) }) state.count++ // 断点应停在 trigger 内部:取出依赖集合、逐个执行 effect.run这里选esm-bundler构建而不是 global 构建,原因是 ESM 版本保留了模块边界,调试器的调用栈会显示reactivity/src/effect.ts而不是一个匿名闭包;global 构建把全部代码塞进一个立即执行函数,sourcemap 的映射粒度会粗很多,栈里经常只剩(anonymous)。文件名带bundler的那份还会把__DEV__之类的编译期开关留成可注入形式,方便你在脚本里手动打开开发期警告,很多“源码里明明有这段分支却没执行”的困惑就来源于这些开关。
如果你用编辑器而不是浏览器调试,还需要把模块路径指回源码,否则右键“跳转到定义”永远落在.d.ts上。
// tsconfig.json —— 让 tsserver 直接跳进 src,而不是编译后的声明文件 { "compilerOptions": { "baseUrl": ".", "paths": { "@vue/reactivity": ["packages/reactivity/src/index.ts"], "@vue/runtime-core": ["packages/runtime-core/src/index.ts"] }, "types": ["node"] } }baseUrl是paths生效的前提,少了它整段配置静默失效。映射目标要写src/index.ts而不是dist/*.d.ts,这是“能跳定义”和“只能看签名”的分水岭:跳到src你能看到函数体、闭包和注释,跳到.d.ts你只能看到类型。types: ["node"]是为了让process.env.NODE_ENV这类判断在编辑器里不报红,源码里到处是这种守卫,缺了它满屏波浪线,很容易误以为自己改坏了什么。
2.3 从单测入口进,比从 index.ts 进快得多
每个包下面通常都有__tests__目录,一个*.spec.ts就是一份带断言的最小复现,作者已经帮你把边界条件写好了。与其自己构造场景,不如直接跑别人写好的用例,再把断点打进去。
# 只跑 effect 相关的用例,-t 按用例名过滤,避免整个文件几百个 case 一起跑 pnpm vitest run packages/reactivity/__tests__/effect.spec.ts -t "should run" # 需要停在用例内部时,把调试端口透传给 node node --inspect-brk ./node_modules/vitest/vitest.mjs run packages/reactivity/__tests__/effect.spec.tsrun表示非 watch 模式,跑完就退出,适合配合断点;不加run会进入监听状态,断点触发一次之后进程还挂着,容易误判“代码没执行”。-t的匹配是按用例名做子串匹配,所以名字要写够独特,写"should"会把大半个文件都拉起来,断点被无关用例反复命中,调试体验极差。
提示:单测文件里的断言本身就是结论。先读断言,知道这段代码“应该产出什么”,再进实现看“怎么产出”,比反过来省一半时间。
3. 响应式内核:40 行骨架与泛型签名的对应关系
响应式是整份资源里最值得投入的部分,因为它的结构极其干净:一个存储依赖的容器、一个记录当前正在执行的副作用、两个动作(收集与触发)。难点不在算法,而在 TypeScript 的类型签名——ref的.value为什么在模板里不用写、reactive返回的类型为什么把嵌套的ref全部摊平,这些看起来玄学的东西,答案都在类型定义里,而不是在运行时逻辑里。
3.1 用 40 行复刻 track / trigger 的闭环
在打开源码之前,先自己写一遍骨架。写完再对照,你会发现差异主要在边界处理上,主干逻辑几乎一致。
// mini-reactive.ts —— 只保留主干,用来对照源码结构 type Dep = Set<ReactiveEffect> const targetMap = new WeakMap<object, Map<PropertyKey, Dep>>() let activeEffect: ReactiveEffect | undefined class ReactiveEffect { constructor(public fn: () => void) {} run() { activeEffect = this // 进入时把自己挂成"当前副作用" try { return this.fn() // 执行期间所有 get 都会被 track 捕获 } finally { activeEffect = undefined // 必须还原,否则嵌套 effect 会串味 } } } function track(target: object, key: PropertyKey) { if (!activeEffect) return // 不在任何 effect 里读属性,直接跳过 let depsMap = targetMap.get(target) if (!depsMap) targetMap.set(target, (depsMap = new Map())) let dep = depsMap.get(key) if (!dep) depsMap.set(key, (dep = new Set())) dep.add(activeEffect) // 建立 key -> effect 的反向索引 } function trigger(target: object, key: PropertyKey) { const dep = targetMap.get(target)?.get(key) if (!dep) return dep.forEach(effect => effect.run()) // 简化版:不做调度、不去重、不判自触发 } function reactive<T extends object>(target: T): T { return new Proxy(target, { get(obj, key, receiver) { track(obj, key) return Reflect.get(obj, key, receiver) }, set(obj, key, value, receiver) { const ok = Reflect.set(obj, key, value, receiver) trigger(obj, key) return ok } }) }这段骨架里有三个设计点值得盯着看。第一是WeakMap套Map套Set的三层结构:最外层用WeakMap是为了让被代理对象被回收时依赖表跟着释放,如果用Map,一个临时对象只要被 effect 读过一次就永远留在内存里。第二是try / finally里对activeEffect的还原,真实实现用的是副作用栈而不是单个变量,因为effect内部再嵌套effect时,内层执行完必须回到外层的“当前副作用”,单变量做不到这一点。第三是trigger里的省略项,真实实现要处理调度器(scheduler)、要避免 effect 在执行中再次触发自己导致死循环、还要按effect !== activeEffect做过滤,这三件事加起来就是骨架和源码之间那一百多行的差距。
参数层面需要留意的只有run的返回值。骨架里返回了this.fn()的结果,真实实现同样如此,因为computed依赖这个返回值做缓存;如果你在改造时把它丢了,computed那部分读起来就会莫名其妙。
3.2 ref 与 reactive 的类型签名差在哪
运行时上,ref和reactive是两套实现:前者包一个RefImpl实例,用访问器属性value做拦截;后者用Proxy拦截整个对象。但真正让人困惑的是类型,因为类型决定了你在编辑器里看到的行为,而编辑器里的行为和运行时并不总是一一对应。
// 摘自声明文件的结构,用于对照理解,非完整定义 interface Ref<T = any> { value: T } type UnwrapRef<T> = T extends Ref<infer V> ? UnwrapRefSimple<V> : T extends object ? { [K in keyof T]: UnwrapRef<T[K]> } : T function ref<T>(value: T): Ref<UnwrapRef<T>> function reactive<T extends object>(target: T): UnwrapNestedRefs<T>Ref<T>里只有一个value属性,这是它能被自动解包的前提:模板编译时会把count改写成count.value,编译器只需要判断“这个标识符的类型是不是带value的引用类型”。而reactive返回的是UnwrapNestedRefs<T>,这个类型工具做的事情是递归地把对象内部所有Ref摊平成它包裹的原始类型,所以reactive({ n: ref(0) })之后state.n是number而不是Ref<number>,因为读取n时 Proxy 的get里做了isRef(res) ? res.value : res的判断。
这里有个反直觉的地方:reactive的类型摊平是递归的,但递归在循环引用处会出问题,类型系统没法无限展开一个自我引用的结构,真实实现里用了一组UnwrapRefSimple、BaseTypes、Builtin的分支把数组、Map、Set、Date 这些内置类型挡在递归之外。读这段类型定义时不要试图一次看懂全部条件分支,正确的姿势是拿一个具体例子去试:写const s = reactive({ a: ref(1), b: { c: ref('') } }),把鼠标悬停在s上,看推导出来的展开类型,再回类型定义里找是哪个分支命中的。
shallowRef与ref的签名几乎一样,差别只在返回值不递归解包;readonly走的是DeepReadonly<T>,它把每一层都加上readonly修饰。这几个类型工具放在一起看,能很直观地理解“运行时做多少,类型就描述多少”这个原则。
3.3 用类型报错反查实现路径
类型不只是文档,它还是一把探针。把错误的类型传进去,编译器报错的位置会直接告诉你实现走了哪条分支。
import { reactive, readonly, shallowRef } from '@vue/reactivity' const raw = { nested: { count: 1 } } const state = readonly(raw) // 报错:无法为只读属性赋值 —— 说明 DeepReadonly 已经递归到了 nested 这一层 state.nested.count = 2 // 报错:Ref<{ count: number }> 上不存在 value 之外的属性 const s = shallowRef({ count: 1 }) s.count第一条报错能验证readonly的递归深度,如果只在外层加readonly,state.nested.count = 2不会报错,所以报错本身就证明了实现确实做了深度遍历(运行时的readonly同样是深层 Proxy,两者是对齐的)。第二条报错说明shallowRef不会像ref那样把对象内部再包一层,.value拿到的是原对象,这也解释了为什么给shallowRef传一个大对象时性能更好——它只代理了value这一个访问器。
我一般的做法是:每读完一个类型工具,就故意写一段会报错的代码,把报错信息抄到笔记里。等下次业务里遇到“为什么这里推导出来是any”,翻笔记往往比翻源码快。
4. 编译器与运行时的衔接:模板表达式怎么变成可推导的类型
响应式解决的是“数据变了怎么通知”,编译器解决的是“模板怎么写才能变成通知数据的代码”。这两块在源码里是分开的两个包,但在实际运行时会合流:编译产物里读的每一个响应式属性,都会变成一次track。把这条线打通,你才算真正看懂了“模板里写个变量,页面就会更新”这句话。
4.1 parse / transform / generate 三段式在源码里的落点
编译器主干是三个纯函数,输入输出都是数据结构,没有副作用,非常适合单独调用观察。
// compile-demo.ts —— 直接把模板编译成渲染函数源码字符串 import { compile } from '@vue/compiler-dom' const { code, ast } = compile( `<div :id="uid">{{ msg }}</div>`, { mode: 'module', // 生成 ESM 导出,而不是 new Function 的字符串 prefixIdentifiers: true, // 模板表达式里的变量加 _ctx. 前缀 hoistStatic: true // 开启静态节点提升 } ) console.log(code)mode: 'module'决定产物形态:function模式生成一段可以被new Function直接执行的字符串,module模式生成标准的 ESM 代码,后者可以被打包工具继续处理,也是现代构建链路上的默认选择。prefixIdentifiers: true会让模板里的msg变成_ctx.msg,这一步是把“模板作用域”翻译成“JavaScript 作用域”的关键,没有它,编译产物里的变量名和组件实例对不上,运行时根本拿不到值。hoistStatic: true会把纯静态的节点提到渲染函数外面只创建一次,这是性能优化里最直观的一条。
三段式的边界很清晰:parse只做词法和语法分析,产出一棵模板 AST,节点类型用数字常量标记(元素、文本、插值、指令各占一个);transform遍历 AST,插件式地往节点上挂codegenNode,静态提升、指令转换、v-if分支合并都在这一步;generate只负责把codegenNode拼成字符串,它不判断语义。读源码时如果发现某个逻辑“不知道该归哪一段”,先看它是在修改 AST 还是在拼字符串,答案立刻就出来了。
ast这个返回值值得单独调试一次。把ast打印出来,你会看到每个元素节点上挂着props、children、loc,loc里记录了源码的起止位置和行列号——这就是开发环境下模板报错能精确指到你写的第几行的原因。
4.2 静态提升与 patchFlag 是怎么判定的
编译器在transform阶段给动态节点打上patchFlag,运行时 diff 靠这个数字决定“要不要比较这个节点的子内容”。理解这套标记,等于理解了 diff 的性能边界。
| 标记 | 含义 | 运行时行为 |
|---|---|---|
| TEXT | 只有文本内容是动态的 | 只比对文本,跳过属性 |
| CLASS | class 是动态的 | 只比对 class |
| PROPS | 有动态属性但无动态 key | 走完整 props 比对 |
| FULL_PROPS | 存在动态 key 的属性 | 无法静态优化,全量比对 |
| NEED_PATCH | 需要走 patch 流程 | 调用对应元素的 patch 方法 |
| DYNAMIC_SLOTS | 插槽内容动态 | 跳过静态插槽优化 |
标记会在相邻位上进行按位或运算组合,所以一个节点可能同时带TEXT | CLASS。判定逻辑在transform的各个插件里:分析属性的绑定类型,能静态确定的值不打标记,v-bind="obj"这种展开写法直接给FULL_PROPS。有一个容易踩的边界:<div :id="a" :class="b">和<div v-bind="obj">的优化结果完全不同,前者能精确到单个属性,后者只能全量比对——这也是为什么在性能敏感的长列表里,展开写法要慎用。
4.3 模板类型检查工具拿到的到底是什么
TypeScript 本身不认识模板,.vue文件里的<template>对tsc来说就是一段普通标签。模板类型检查工具的做法是:把模板编译成一段虚拟的 TypeScript 代码,再交给tsc做类型推导,报错时通过位置映射把错误信息还原到模板的行列上。
// 虚拟文件的大致形态,帮助理解类型从哪来 const _component = { props: { msg: { type: String as PropType<string>, required: true } }, setup(__props: { msg: string }) { return (_ctx: any, _cache: any) => { // 模板里写 {{ msg }},这里读的就是 __props.msg,类型来自上面的 PropType return msg } } }所以defineProps<{ msg: string }>()能推导出模板里的msg是string,靠的不是什么魔法,而是宏被编译成了带泛型参数的函数调用,类型沿调用链传下去。反过来,如果你在模板里访问一个setup没返回、props也没声明的变量,报错信息会指向虚拟文件的位置,看起来像一个不存在的文件——这是正常的,别去找那个文件,去看模板里对应的表达式即可。
注意:模板类型检查依赖编译期生成的元信息,
v-for里变量的类型、作用域插槽的参数类型都在这层生成。如果某个变量的类型莫名其妙变成any,八成是编译阶段没能把它识别成绑定,而不是类型工具本身有问题。
5. 避坑与排查:读这份源码时最容易翻车的五个点
这一节全是血泪经验,每条都对应一个真实发生过的翻车现场。读源码的成本大部分不在理解,而在这些看起来像环境问题、实际是认知偏差的地方。
现象:断点打上去是空心圆,提示 “Breakpoint set but not yet bound”。原因:编辑器把断点打在了.ts源文件上,但 node 实际执行的是构建产物,sourcemap 没有生成或被关闭了,映射链断了。 解决:确认构建命令里带了--sourcemap,检查产物同目录下是否存在.map文件;如果用的是 node 直接跑脚本,加--enable-source-maps让 node 加载映射表,否则 node 自己不会去读.map。
现象:改了源码里的console.log,重新跑脚本毫无反应。原因:脚本import的是dist里的构建产物,改src不改产物等于没改;workspace 的软链让node_modules里那份看起来像源码,其实是构建输出。 解决:要么每次改完重跑构建(观察模式下用tsc -w或构建工具的 watch),要么在调试阶段直接用路径别名把 import 指向src/index.ts,让运行时直接加载 TS(需要ts-node或带 TS 转译的加载器)。
现象:reactive对象解构之后页面不更新了。原因:track建立的依赖是挂在target + key上的,解构const { count } = state只是把当前值读出来赋给一个普通变量,后续count的读取完全不经过 Proxy,自然不会触发依赖收集。 解决:从源码角度理解之后,业务上的处理是保持对象调用,或者用toRefs/toRef把属性转成带get value的引用;toRefs的实现就是给每个 key 建一个访问器,读的时候仍然回到原对象,所以链路没断。
现象:在effect里读了ref.value,修改ref.value却不触发。原因:RefImpl的依赖挂在dep这个独立集合上,和reactive的targetMap是两套存储;如果你读的是shallowRef内部的嵌套对象属性,那个属性压根没被代理,属于“读了个寂寞”。 解决:先确认读的是.value而不是对象本身;再确认这个 ref 是不是shallowRef。调试时分别对RefImpl的get value和set value下断点,看哪一侧没进。
现象:照着文档看编译产物,和自己跑出来的代码对不上。原因:compile的mode、prefixIdentifiers、hoistStatic三个选项组合会产生差异极大的输出,文档示例通常省略了选项说明;另外不同版本的编译产物在patchFlag命名上也有调整。 解决:调用compile时把选项显式写全,并且在调试脚本里打印code的原始字符串而不是格式化后的版本;判断逻辑差异时以你自己跑出来的产物为准,别硬套记忆里的示例。
6. 进阶验证:用最小复现加单测把结论钉死
读到第 5 章,容易产生一种“我都懂了”的错觉。检验方式只有一个:把结论写成断言,跑通它。我一般的套路是三级验证,越往后越硬。
| 验证层级 | 做法 | 能证明什么 |
|---|---|---|
| 打印观察 | 在关键函数里打日志、断点看闭包 | 代码路径走没走通 |
| 最小复现 | 单独写脚本引用源码,构造单一场景 | 结论是否依赖具体环境 |
| 单测断言 | 用测试框架跑,断言执行次数和结果 | 结论是否稳定、可回归 |
打印观察最省事,但它证明不了稳定性——你看到一次正确的执行顺序,可能是因为这次恰好没有并发,也可能是因为你手动改了某处状态。最小复现把它往前推一步:脱离业务代码,只留必要输入。单测断言是最后一层,它把“我看到”变成“它必须”,以后改源码、换版本,跑一遍就知道有没有被破坏。
下面这段断言是我验证track次数时用的模板,核心是数“副作用执行了几次”,而不是数日志。
import { describe, it, expect, vi } from 'vitest' import { reactive, effect, stop } from '@vue/reactivity' describe('effect 的依赖收集边界', () => { it('只读取一个属性时,修改其它属性不应触发重跑', () => { const state = reactive({ a: 1, b: 2 }) const spy = vi.fn(() => state.a) // 只访问 a const runner = effect(spy) expect(spy).toHaveBeenCalledTimes(1) // 首次执行 state.b++ // 访问 b 不等于收集了 b expect(spy).toHaveBeenCalledTimes(1) state.a++ // 访问过 a,才会被触发 expect(spy).toHaveBeenCalledTimes(2) stop(runner) // 手工停止,验证依赖已被清理 state.a++ expect(spy).toHaveBeenCalledTimes(2) }) })这段断言的关键在最后三行:stop之后spy的调用次数不再增长,说明依赖集合里的这个 effect 被移除了。如果你手上的版本在stop之后仍然触发,那大概率是清理逻辑的分支和你读的那段源码不是同一版本——这就是单测的价值,它逼你去确认版本,而不是凭记忆下结论。
还有一个小技巧:验证分支覆盖时,用state.b++这类“读了但没改对”的操作,比直接改a更能暴露问题。trigger在找不到对应依赖集合时应当直接返回,如果它误触发了整个 target 的所有依赖,第一次state.b++就会让spy多跑一次。很多人读完源码觉得“这里肯定有判断”,但从来没验证过判断真的生效。
最后说一个我自己踩过的坑。我早期读源码有个坏习惯:看到优化分支就跳过,只读主流程,觉得“优化不影响理解”。结果在一次排查里,我笃定某个属性变更会触发重渲染,实际却没触发,查了半天才发现是编译期打了patchFlag,运行时只比对了文本,属性压根没进 diff 流程。从那以后我每次对某个结论有把握的时候,都强制走一遍“类型检查 + 单测断言”这两步,把“我觉得”换成“它必须”。源码学习资源真正的价值不在于它写得多全,而在于它给了你一套可以反复验证的结构,你照着跑通的每一条断言,都是以后排查线上问题的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取