资讯动态

Vue3+TypeScript源码学习:响应式、渲染器与编译器拆解

发布时间:2026/10/26 12:18:52 来源:尧图企业网站定制
简介内容围绕 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 的调度顺序理清楚。这份 Vue3TypeScript 源码学习资源就是把这条路径整理成可跑的东西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只依赖sharedruntime-core依赖reactivity和sharedruntime-dom依赖runtime-corecompiler-core依赖sharedcompiler-dom依赖compiler-corevue是唯一的聚合入口。这个方向一旦写反比如让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 reactiveT extends object(target: T): T { // 已经是代理对象就直接返回避免代理套代理 if (isReactive(target)) return target return createReactiveObject(target, mutableHandlers) } function createReactiveObject(target: object, baseHandlers: ProxyHandlerany) { // 原始值不在这里处理交给 ref if (!isObject(target)) return target return new Proxy(target, baseHandlers) }isReactive的判断依赖一个挂在代理对象上的标记位。源码里通常用一个不会被遍历到的 key比如ReactiveFlags.RAW或ReactiveFlags.IS_REACTIVE取值时直接返回true或原对象ownKeys拦截器再把这类内部 key 过滤掉避免它们出现在Object.keys的结果里。核心逻辑在mutableHandlersconst mutableHandlers: ProxyHandlerobject { 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依赖图长什么样依赖收集的数据结构是一个三层嵌套WeakMaptarget, Mapkey, Seteffect。用WeakMap是为了目标对象被回收时依赖记录能一起释放不会有内存泄漏。type Dep SetReactiveEffect const targetMap new WeakMapobject, Mapany, Dep() // 全局当前正在执行的 effect嵌套时靠栈恢复 let activeEffect: ReactiveEffect | undefined const effectStack: ReactiveEffect[] [] export class ReactiveEffectT 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为什么不用 Proxyref处理的是原始值Proxy只能代理对象所以ref走的是class加访问器class RefImplT { 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 ComputedRefImplT { private _dirty true private _value!: T public dep new SetReactiveEffect() 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.valuegetter只被调用一次跑通了这个机制就真理解了。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._veivnode event invoker上而不是直接addEventListener。原因是每次更新都重新绑定会堆积监听器用一个 invoker 做中间层更新时只替换 invoker 内部的value监听器本身只绑一次。shouldSetAsProp判断某个属性该走 DOM property 还是 attribute比如value、checked走 propertyaria-*走 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. generateAST - render 函数代码字符串 return generate(ast) }parse阶段用一个游标扫字符串识别标签开始、属性、结束标签、文本、插值表达式产出的 AST 节点上有type、tag、props、children这些字段。transform阶段是深度遍历transformElement负责给元素生成codegenNodetransformText处理文本合并指令在这里被替换成对应的运行时调用。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 的用例需要 DOMreactivity 的用例用 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: ObjectDirectiveHTMLElement { // 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属于自定义指令编译器不需要为它写专门的 transformtransformElement会走通用的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/示例页手动验证 指令单测扩展响应式 APIreactivity/src/在__tests__里补 effect 断言新增编译指令compiler-dom/src/transforms/断言generate输出的代码字符串换渲染平台实现RendererOptions里的 nodeOps用runtime-test跑全量 patch 用例动手前建议先把pnpm test跑到全绿再开始改。我有一次跳过这步直接在红色基线上加功能最后排查了一个下午才发现是两个不相干用例的全局状态串了。从那以后我每次动源码前都强制走一遍全量测试和typecheck确认基线干净再开分支省下来的时间远比这一步多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑