简介:内容围绕 Vue3 与 TypeScript 的源码阅读与工程实践展开,适合已掌握前端基础、希望深入理解组合式API、响应式原理与静态类型约束的中级开发者,也适用于准备重构旧项目、搭建规范工程或完善组件体系的技术团队。压缩包共2002个文件、约63MB,以1042个md文档和803个js源码为主体,辅以json配置、html测试页面等,方便对照实现细节、验证运行行为并整理知识脉络。已有575人学习,说明其具备一定参考价值。资料覆盖Vue3基础与性能优化思路、TypeScript接口和泛型、Vuex/Pinia状态管理、Vue Router路由及UI组件库实践;通过阅读源码与配套说明,还能理解渲染机制、响应式依赖收集、组合式函数拆分与类型声明组织等关键环节,体会工程目录分层、模块拆分和类型约束在真实项目中的落地方式,积累排错方法和结构设计经验。
1. 从一个渲染次数失控的列表页说起
后台有个列表页,筛选条件一改,整个表格重渲染,我在 render 里打了个点,一屏刷了几百次,翻控制台翻到眼花也没定位到根因。后来硬着头皮去读 Vue3 的响应式源码,才把 track 与 trigger 的边界、effect 的调度顺序理清楚。这份 Vue3+TypeScript 源码学习资源就是把这条路径整理成可跑的东西:reactivity、runtime-core、compiler-core 几个核心包用 TypeScript 重写并逐段注释,配了可执行的单测,用代理对象、依赖图、vnode、patch、diff 这些实打实的机制讲清楚框架内部怎么运转。适合已经能写组合式 API、能配 Vite 工程,但没系统读过源码的人;完全没碰过 Vue3 的话,先写两周业务再回来更合适。
2. 工程骨架与技术选型:monorepo 下 packages 的职责边界
先看骨架再谈源码。这套资源保持了 Vue3 官方仓库那种 monorepo 的组织方式,一个包只干一件事,包与包之间靠 TypeScript 的路径别名互相引用,不让构建产物参与开发期联调。这样做的好处很直接:想调reactive的依赖收集,只需要保证@vue/reactivity这一个包能跑起来,不用把整条渲染链路都启动。
2.1 目录结构与模块依赖方向
根目录下packages/放着各个子包,pnpm-workspace.yaml把它们声明成工作区,pnpm install之后所有包互相之间可以用源码路径直达。目录形态大致如下:
packages/ ├── shared/ # 公共工具:isObject、hasChanged、ShapeFlags 位标志 ├── reactivity/ # 响应式核心:reactive、ref、computed、effect ├── runtime-core/ # 平台无关的渲染器:vnode、patch、diff、组件实例 ├── runtime-dom/ # 浏览器实现:DOM 节点操作、属性/事件挂载 ├── compiler-core/ # 编译器主流程:parse、transform、generate ├── compiler-dom/ # 浏览器端编译扩展:v-model、v-html 等指令转换 ├── compiler-sfc/ # 单文件组件解析:template/script/style 分块 └── vue/ # 对外入口:把 runtime-dom + compiler-dom 组装起来依赖方向是单向的,不能反过来引。shared在最底层,谁都可以依赖它;reactivity只依赖shared;runtime-core依赖reactivity和shared;runtime-dom依赖runtime-core;compiler-core依赖shared;compiler-dom依赖compiler-core;vue是唯一的聚合入口。这个方向一旦写反,比如让reactivity去 importruntime-core,构建时就会出现循环依赖,打包器报出来的错往往指向一个完全不相关的文件,查起来很痛苦。
shared里最值得一提的不是那些工具函数,而是ShapeFlags。vnode 的类型、子节点形态、是否带插槽,全靠一个整数做位运算判断,省掉了一堆instanceof和字符串比较:
// packages/shared/src/shapeFlags.ts export const enum ShapeFlags { ELEMENT = 1, // 0001 普通元素 FUNCTIONAL_COMPONENT = 1 << 1, // 0010 函数式组件 STATEFUL_COMPONENT = 1 << 2, // 0100 有状态组件 TEXT_CHILDREN = 1 << 3, // 1000 文本子节点 ARRAY_CHILDREN = 1 << 4, // 1 0000 数组子节点 SLOTS_CHILDREN = 1 << 5 // 10 0000 插槽子节点 }判断一个 vnode 是不是带数组子节点的元素,写成shapeFlag & ShapeFlags.ELEMENT && shapeFlag & ShapeFlags.ARRAY_CHILDREN,一个按位与就能出结果。读源码时看到这种判断不要跳过,它是理解patch分发的钥匙——后面processElement和processComponent的分流全靠它。
2.2 TypeScript 配置与源码路径直达
开发期不打包,靠tsconfig的paths把包名映射到源码入口。这一步配错,编辑器里能跳转但跑测试就报模块找不到,属于新手最容易卡住的地方:
{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "baseUrl": ".", "paths": { "@vue/shared": ["packages/shared/src/index.ts"], "@vue/reactivity": ["packages/reactivity/src/index.ts"], "@vue/runtime-core": ["packages/runtime-core/src/index.ts"], "@vue/runtime-dom": ["packages/runtime-dom/src/index.ts"], "@vue/compiler-core": ["packages/compiler-core/src/index.ts"] } } }strict: true建议保留,这套源码里大量使用可空类型和联合类型,比如ReactiveEffect | undefined、VNode | null,把严格模式关掉就体会不到类型收窄在源码里起的作用了。moduleResolution: Bundler是为了让import x from './foo'这种不带后缀的写法在tsc --noEmit时也能解析。
构建侧用 Rollup 或者 Vite 的 lib 模式都行,关键是每个包单独出一份产物,external里把其他@vue/*包排除掉,避免把整个框架打成一个巨型 bundle。我一般会在根package.json里挂几条脚本:
{ "scripts": { "dev": "vite", "build": "node scripts/build.js", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit -p tsconfig.json" } }dev启动一个示例页,用来手动验证渲染结果;test跑全量单测;typecheck单独做类型检查,不和测试混在一起,出问题时能快速分清是逻辑错还是类型错。
2.3 测试先行:每个模块都能独立验证
源码学习最怕“看懂了但跑不起来”。这套资源给每个核心模块都配了单测,packages/reactivity/__tests__/effect.spec.ts这种文件命名很规律。跑单个包用:
pnpm vitest run packages/reactivity写一个最小的 effect 用例长这样:
import { describe, it, expect, vi } from 'vitest' import { reactive, effect } from '../src' describe('reactivity/effect', () => { it('初始化时立即执行一次', () => { const fn = vi.fn(() => {}) effect(fn) // effect 不是惰性的,创建即执行 expect(fn).toHaveBeenCalledTimes(1) }) it('依赖变化时重新执行', () => { const state = reactive({ count: 0 }) const runner = effect(() => state.count) state.count++ // 第二次执行由 set 拦截器里的 trigger 触发 expect(runner.fn).toHaveBeenCalledTimes(2) }) })effect(fn)创建时立刻跑一次,这是响应式系统的既定行为,不是 bug,很多人第一次读源码会以为要手动run()。第二个用例验证的是完整闭环:reactive的get里track收集,set里trigger派发,中间任何一环断了这个断言都会挂。先让这两个用例绿了,再去看track内部的数据结构,理解成本会低很多。
3. 响应式系统拆解:Proxy、effect 与依赖图的三段式
响应式是这套源码里最值得逐行读的部分,因为它是整个框架的地基。ref、computed、watch、组件的重新渲染,全部建立在同一套依赖收集机制上。把这一章吃透,后面渲染器和编译器里那些“为什么这里要重新执行”的疑问会少一大半。
3.1 reactive 与 Proxy 拦截器:懒代理是关键
reactive的本质是给目标对象套一层Proxy,在get里收集依赖,在set里触发更新。真正的实现比想象中克制,没有递归遍历所有属性,而是访问到哪层代理哪层:
// packages/reactivity/src/reactive.ts export function reactive<T extends object>(target: T): T { // 已经是代理对象就直接返回,避免代理套代理 if (isReactive(target)) return target return createReactiveObject(target, mutableHandlers) } function createReactiveObject(target: object, baseHandlers: ProxyHandler<any>) { // 原始值不在这里处理,交给 ref if (!isObject(target)) return target return new Proxy(target, baseHandlers) }isReactive的判断依赖一个挂在代理对象上的标记位。源码里通常用一个不会被遍历到的 key,比如ReactiveFlags.RAW或ReactiveFlags.IS_REACTIVE,取值时直接返回true或原对象,ownKeys拦截器再把这类内部 key 过滤掉,避免它们出现在Object.keys的结果里。
核心逻辑在mutableHandlers:
const mutableHandlers: ProxyHandler<object> = { get(target, key, receiver) { const res = Reflect.get(target, key, receiver) // 依赖收集:把当前 activeEffect 挂到 target.key 名下 track(target, key) // 懒代理:只有真正读到嵌套对象时才递归包一层 if (isObject(res)) return reactive(res) return res }, set(target, key, value, receiver) { const oldValue = (target as any)[key] const result = Reflect.set(target, key, value, receiver) // 值真的变了才触发,避免相同值引发无意义的重渲染 if (hasChanged(value, oldValue)) { trigger(target, key) } return result } }Reflect.get的第三个参数receiver必须传,否则访问器属性里的this会指向原始对象而不是代理对象,响应式在 getter/setter 场景下会直接失效。这是读源码时特别容易忽略的一个细节,也是自己手写实现时的高频翻车点。
懒代理带来的收益很直观:一个有一千个字段的配置对象,只读了其中三个字段,就只有三个字段被代理。代价是嵌套层级越深,代理创建越晚,首次访问会有轻微开销——这也是 Vue3 相比 Vue2 在初始化大对象时性能更好的原因,Vue2 的defineProperty是递归遍历所有属性一次性全转。
3.2 effect 与 track/trigger:依赖图长什么样
依赖收集的数据结构是一个三层嵌套:WeakMap<target, Map<key, Set<effect>>>。用WeakMap是为了目标对象被回收时依赖记录能一起释放,不会有内存泄漏。
type Dep = Set<ReactiveEffect> const targetMap = new WeakMap<object, Map<any, Dep>>() // 全局当前正在执行的 effect,嵌套时靠栈恢复 let activeEffect: ReactiveEffect | undefined const effectStack: ReactiveEffect[] = [] export class ReactiveEffect<T = any> { deps: Dep[] = [] active = true constructor(public fn: () => T, public scheduler?: () => void) {} run() { if (!this.active) return this.fn() // 压栈:内层 effect 跑完要能把 activeEffect 还原成外层的 effectStack.push(this) activeEffect = this try { return this.fn() } finally { effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } } }track负责往图上加边:
export function track(target: object, key: unknown) { if (!activeEffect) return 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) // 反向记录:方便后续 cleanup 时把这个 effect 从所有 dep 里摘掉 activeEffect.deps.push(dep) }trigger负责按图派发:
export function trigger(target: object, key: unknown) { const depsMap = targetMap.get(target) if (!depsMap) return const dep = depsMap.get(key) if (!dep) return // 复制一份再遍历,防止 effect 内部再触发导致死循环 ;[...dep].forEach(effect => { if (effect === activeEffect) return if (effect.scheduler) effect.scheduler() else effect.run() }) }这里有两个容易看不懂的地方。第一,为什么要复制dep?因为 effect 执行时可能往同一个Set里新增依赖,边遍历边修改Set会触发“迭代期间集合被修改”的问题,复制一份是成本最低的规避方式。第二,scheduler是干嘛的?组件渲染用的 effect 带调度器,触发时不直接重渲染,而是把更新任务推进队列,由调度器统一批处理,这就是“同一轮里改十次只渲染一次”的来源。
3.3 ref 与 computed:为什么不用 Proxy
ref处理的是原始值,Proxy只能代理对象,所以ref走的是class加访问器:
class RefImpl<T> { private _value: T public dep?: Dep constructor(value: T) { // 对象类型的 ref 内部还是转交给 reactive,保持行为一致 this._value = isObject(value) ? reactive(value) : value } get value() { trackRefValue(this) return this._value } set value(newVal) { if (hasChanged(newVal, this._value)) { this._value = isObject(newVal) ? reactive(newVal) : newVal triggerRefValue(this) } } }trackRefValue和triggerRefValue内部还是调track/trigger,只是把this当目标对象、把value当 key。整个响应式系统只有一套依赖图,ref只是换了个入口。
computed的差异在于它是惰性的,并且带缓存:
class ComputedRefImpl<T> { private _dirty = true private _value!: T public dep = new Set<ReactiveEffect>() constructor(private getter: () => T) {} get value() { trackRefValue(this) // 只有脏了才重新计算,这是 computed 和 method 的本质区别 if (this._dirty) { this._dirty = false this._value = this.getter() } return this._value } }完整实现里,getter会被包成一个effect,并且通过scheduler把_dirty置回true。这样依赖变化时不是立刻重算,而是打个脏标记,等下次读取时才算。这个设计让computed在模板里被多次引用时只算一遍,也是它比在模板里直接调方法更划算的原因。读到这里可以顺手写个断言:连续读两次computed.value,getter只被调用一次,跑通了这个机制就真理解了。
4. 渲染器与编译器:vnode patch、diff 和 template 编译三阶段
响应式解决“数据变了通知谁”,渲染器解决“通知到了怎么改 DOM”,编译器解决“template 怎么变成能跑的渲染函数”。这三块串起来,Vue3 的运行时闭环才算完整。这一章把渲染器和编译器放在一起,因为shapeFlag、vnode这些概念在两边都要用,分开读反而更累。
4.1 patch 入口与 shapeFlag 分发
渲染器用工厂函数createRenderer把平台相关的 DOM 操作抽出去,runtime-dom传入真实 DOM 的实现,runtime-test可以传入纯对象实现,同一套逻辑跑在不同平台上:
// packages/runtime-core/src/renderer.ts export function createRenderer(options: RendererOptions) { const { createElement, insert, remove, patchProp } = options const patch = (n1, n2, container, anchor = null) => { // 同一个 vnode 直接跳过,短路掉大量无意义工作 if (n1 === n2) return // 类型不同就卸载旧的、挂载新的,不做复用 if (n1 && !isSameVNodeType(n1, n2)) { unmount(n1) n1 = null } const { type, shapeFlag } = n2 if (shapeFlag & ShapeFlags.ELEMENT) { processElement(n1, n2, container, anchor) } else if (shapeFlag & ShapeFlags.COMPONENT) { processComponent(n1, n2, container, anchor) } } return { patch, render } }isSameVNodeType比较的是type和key。类型不同就整棵子树重建,这一步是很多“组件状态莫名其妙丢了”的根源——父组件里写了v-if切换两个结构相似但类型不同的组件,Vue 不会复用实例,状态自然重置。想复用就得保证type一致,或者用v-show替代。
processElement内部再按n1是否存在分成挂载和更新两条路。挂载走mountElement,创建真实节点、递归处理子节点、最后insert到容器;更新走patchElement,先更新 props,再patchChildren处理子节点。整个流程没有任何魔法,就是一棵树的深度优先遍历。
4.2 子节点更新与最长递增子序列
patchChildren里最复杂的是“新旧子节点都是数组”这一支,也就是常说的 diff。实现用的是双端对比加最长递增子序列,目的是把节点移动次数压到最少:
// 简化后的核心步骤 const patchKeyedChildren = (c1, c2, container, parentAnchor) => { let i = 0 const l2 = c2.length let e1 = c1.length - 1 let e2 = l2 - 1 // 1. 从头同步,遇到 key 不同就停 while (i <= e1 && i <= e2 && isSameVNodeType(c1[i], c2[i])) { patch(c1[i], c2[i], container, parentAnchor) i++ } // 2. 从尾同步,处理尾部稳定的节点 while (i <= e1 && i <= e2 && isSameVNodeType(c1[e1], c2[e2])) { patch(c1[e1], c2[e2], container, parentAnchor) e1-- e2-- } // 3. 旧节点先走完 → 全是新增 if (i > e1) { const anchor = e2 + 1 < l2 ? c2[e2 + 1].el : parentAnchor while (i <= e2) { patch(null, c2[i], container, anchor) i++ } } // 4. 新节点先走完 → 剩下全是删除 else if (i > e2) { while (i <= e1) { unmount(c1[i]) i++ } } // 5. 中间乱序段:建 key 映射,求 LIS else { const s1 = i, s2 = i const keyToNewIndexMap = new Map() for (let j = s2; j <= e2; j++) { keyToNewIndexMap.set(c2[j].key, j) } const newIndexToOldIndexMap = new Array(e2 - s2 + 1).fill(0) for (let j = e1; j >= s1; j--) { const newIndex = keyToNewIndexMap.get(c1[j].key) if (newIndex === undefined) { unmount(c1[j]) // 新列表里没有,直接删 } else { newIndexToOldIndexMap[newIndex - s2] = j + 1 patch(c1[j], c2[newIndex], container, null) } } // 求最长递增子序列,序列里的节点不需要移动 const increasing = getSequence(newIndexToOldIndexMap) // 从后往前插,保证 anchor 正确 for (let j = increasing.length - 1; j >= 0; j--) { const index = increasing[j] // 命中 LIS 的节点保持原位 } } }newIndexToOldIndexMap里存的是“新节点在旧列表中的位置 + 1”,加 1 是为了让 0 表示“新节点,没有旧位置”。getSequence求的是这个数组的最长递增子序列,落在子序列里的节点相对顺序没变,可以原地不动,只移动不在子序列里的节点。这是 diff 里最像“算法题”的部分,但它的工程意义很实在:一个只有两处交换的列表,移动次数是 2 而不是全量重建。
key的重要性在这里体现得最直接。没有key时,diff 退化到按索引对比,列表头部插入一项会导致后面所有节点都被判定为“内容变了”,整片更新。加上稳定且唯一的key,才能走进 keyed 分支享受这套优化。
4.3 runtime-dom 与编译三阶段
runtime-dom提供的是平台实现,最典型的是patchProp,负责把 vnode 上的 props 落到真实 DOM:
// packages/runtime-dom/src/patchProp.ts export const patchProp = (el, key, prevValue, nextValue) => { if (key === 'class') patchClass(el, nextValue) else if (key === 'style') patchStyle(el, prevValue, nextValue) else if (isOn(key)) patchEvent(el, key, nextValue) else if (shouldSetAsProp(el, key)) patchDOMProp(el, key, nextValue) else patchAttr(el, key, nextValue) }事件名以on开头,会被转成小写并去掉前缀,挂到el._vei(vnode event invoker)上而不是直接addEventListener。原因是每次更新都重新绑定会堆积监听器,用一个 invoker 做中间层,更新时只替换 invoker 内部的value,监听器本身只绑一次。shouldSetAsProp判断某个属性该走 DOM property 还是 attribute,比如value、checked走 property,aria-*走 attribute,这个判断错了会导致输入框值不同步或者无障碍属性不生效。
编译器这边分三个阶段,baseCompile一个函数串起整条链路:
// packages/compiler-core/src/compile.ts export function baseCompile(template: string, options: CompilerOptions = {}) { // 1. parse:模板字符串 -> AST const ast = baseParse(template, options) // 2. transform:遍历 AST,处理指令、生成 codegenNode transform(ast, { ...options, nodeTransforms: [transformElement, transformText, ...(options.nodeTransforms || [])] }) // 3. generate:AST -> render 函数代码字符串 return generate(ast) }parse阶段用一个游标扫字符串,识别标签开始、属性、结束标签、文本、插值表达式,产出的 AST 节点上有type、tag、props、children这些字段。transform阶段是深度遍历,transformElement负责给元素生成codegenNode,transformText处理文本合并,指令在这里被替换成对应的运行时调用。generate阶段把 AST 拼回字符串,静态节点会被提取成_hoisted_1这样的常量,避免每次渲染重建:
const _hoisted_1 = { class: "title" } return (_openBlock(), _createElementBlock("div", null, [ _createElementVNode("h1", _hoisted_1, _toDisplayString(msg), 1 /* TEXT */) ]))_openBlock和_createElementBlock是编译优化的一部分,配合PatchFlags标记动态部分,运行时 diff 时可以直接跳过静态子树。读编译器源码时建议从parse的parseElement入手,再跟一遍transform的traverseNode,最后看generate的genNode,顺着这个顺序不容易迷路。
5. 避坑与排查:源码调试时最容易翻车的五个地方
源码跑不通,八成不是逻辑写错,而是踩了下面这几类坑。每条我都按“现象、原因、解决”拆开写,对着症状找就行。
5.1 解构 reactive 之后响应式失效
现象:const { count } = reactive({ count: 0 }),后续改count,模板里纹丝不动。
原因:解构取出来的是某一瞬间的原始值,Proxy的get拦截只发生在“从代理对象上取属性”这个动作上。解构之后count就是一个普通数字,跟代理对象已经没有联系了,track根本没机会收集依赖。
解决:要么保留对象引用写成state.count,要么用toRefs把每个属性转成ref,让解构出来的值本身带get value拦截。toRefs返回的每个ref的value访问器会转发到原对象上,读写都走代理。这个坑在组合式 API 里出现频率极高,尤其是从setup返回一个解构过的状态对象时。
5.2 ref 自动解包的边界判断错
现象:const msg = ref('hi'),模板里写{{ msg }}正常,但在setup里返回{ msg }之后,props.msg拿到的是RefImpl对象而不是字符串。
原因:自动解包只发生在两个位置——模板表达式里,以及reactive对象的顶层属性。props本身是浅层reactive,模板里访问props.msg会解包,但setup返回的对象经过组件代理层,行为有严格边界。一旦把ref塞进数组或者Map,解包就完全不生效了。
解决:不确定的地方统一显式写.value。需要解包又不想到处写.value,就用reactive包一层,或者用computed做一层转发。别指望“它应该会自动解包”,这类玄学问题查半小时是常态。
5.3 嵌套 effect 后 activeEffect 没恢复
现象:手写响应式实现时,内层effect执行完之后,外层依赖不再触发更新,或者内层依赖被错误地挂到了外层。
原因:activeEffect是全局单变量,内层effect.run()把它覆盖之后没有还原,导致后续track收集到了错误的 effect。官方实现用effectStack压栈、try/finally出栈解决,自己的实现里漏掉finally,出错时栈就乱了。
解决:run方法里必须用try/finally包住fn()调用,finally里pop并把activeEffect重置为栈顶。另外run里还要做一次依赖清理,把当前 effect 从旧的dep里摘掉再重新收集,否则条件分支切换时会产生陈旧依赖。
5.4 深层对象和数组 length 的触发时机误判
现象:state.list.length = 0之后模板没更新,或者直接改嵌套三层的对象,第一层没变的部分不重渲染。
原因:Proxy的set拦截里判断了hasChanged,值没变就不trigger。数组的length是个特殊情况,length变小会隐式删除元素,但源码里对length的处理要额外触发被删除索引的依赖。另外懒代理意味着只有访问过的深层对象才建立了依赖,没访问过的层级改了当然不会通知。
解决:数组清空用state.list = []或者splice(0),走正常的set路径。深层对象修改前先确认那一层已经被读过至少一次,依赖图里有记录才会触发。调试时可以在trigger里打个断点,看depsMap里到底有没有对应的 key,一目了然。
5.5 测试环境的 paths 与 DOM 实现不同步
现象:单测里import { reactive } from '@vue/reactivity'报模块找不到,或者跑runtime-dom相关用例时报document is not defined。
原因:vitest默认不读tsconfig的paths,需要装vite-tsconfig-paths插件或者在vitest.config.ts里手写resolve.alias。runtime-dom依赖真实 DOM,默认的node环境没有document。
解决:配置文件里加上别名和environment: 'jsdom',并把jsdom装进devDependencies:
// vitest.config.ts import { defineConfig } from 'vitest/config' import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ plugins: [tsconfigPaths()], test: { // runtime-dom 的用例需要 DOM,reactivity 的用例用 node 就够 environment: 'jsdom' } })改完之后pnpm vitest run还报错,先看是不是某个包自己的vitest.config.ts覆盖了根配置,monorepo 里配置文件的优先级经常被忽略。
6. 二次开发:给这套源码加一个自定义指令并验证
读到能改,才算真读懂。改造建议从侵入性最小的地方入手,自定义指令是个好选择,它横跨编译器和运行时,能把前面几章的知识串一遍。目标是在runtime-dom里加一个v-focus,元素挂载后自动聚焦,并且支持v-focus:lazy延迟到下一帧。
6.1 运行时侧的挂载点
指令的钩子在runtime-core的directives.ts里定义,invokeDirectiveHook在mountElement和patchElement的对应阶段被调用。加一个新钩子不需要改核心流程,只需要在runtime-dom里注册实现:
// packages/runtime-dom/src/directives/vFocus.ts import type { ObjectDirective } from '@vue/runtime-core' export const vFocus: ObjectDirective<HTMLElement> = { // mounted 在元素插入容器之后触发,此时能拿到真实 DOM mounted(el, binding) { const run = () => (el as HTMLInputElement).focus() if (binding.arg === 'lazy') { // 延迟一帧,等浏览器完成布局,避免聚焦时元素还没可见 requestAnimationFrame(run) } else { run() } }, // 元素被移除时清理可能存在的副作用 unmounted(el) { el.blur?.() } }mounted和unmounted是 Vue3 指令的标准钩子,区别在于mounted保证元素已经在文档里,可以安全地调用focus。binding.arg拿的是v-focus:lazy里冒号后面的部分,binding.value拿的是等号后面的表达式值。这个映射关系由编译器在transformElement阶段解析指令时塞进props,运行时再取出来,所以改完指令记得确认编译产物里的dirs数组有没有带上参数。
6.2 编译器侧的指令解析
v-focus属于自定义指令,编译器不需要为它写专门的 transform,transformElement会走通用的buildProps分支,把v-focus:lazy解析成{ name: 'focus', arg: 'lazy' }塞进dirs。但如果你想加的是v-model这种内置指令,就得在compiler-dom的transformModel里写转换逻辑,把指令改写成modelValue和onUpdate:modelValue两个 prop。
验证方式很直接:在示例页里写一个输入框,<input v-focus />,刷新看光标是否自动落进去;再加一个带v-if的场景,确认元素重新挂载时指令会再次触发。单测里用runtime-test的nodeOps断言focus被调用:
it('v-focus 在挂载时聚焦', () => { const el = document.createElement('input') const focusSpy = vi.spyOn(el, 'focus') vFocus.mounted(el, { arg: undefined } as any) expect(focusSpy).toHaveBeenCalled() })6.3 改造路径对照表
不同改造方向的切入点和验证方式差异很大,列一张表方便按需选择:
| 改造方向 | 涉及文件 | 验证方式 |
|---|---|---|
| 新增自定义指令 | runtime-dom/src/directives/ | 示例页手动验证 + 指令单测 |
| 扩展响应式 API | reactivity/src/ | 在__tests__里补 effect 断言 |
| 新增编译指令 | compiler-dom/src/transforms/ | 断言generate输出的代码字符串 |
| 换渲染平台 | 实现RendererOptions里的 nodeOps | 用runtime-test跑全量 patch 用例 |
动手前建议先把pnpm test跑到全绿,再开始改。我有一次跳过这步,直接在红色基线上加功能,最后排查了一个下午才发现是两个不相干用例的全局状态串了。从那以后我每次动源码前都强制走一遍全量测试和typecheck,确认基线干净再开分支,省下来的时间远比这一步多。希望帮到你。
本文还有配套的精品资源,点击获取