news 2026/10/26 12:18:41

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3+TypeScript源码学习:响应式、渲染器与编译器拆解

简介:内容围绕 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/示例页手动验证 + 指令单测
扩展响应式 APIreactivity/src/在__tests__里补 effect 断言
新增编译指令compiler-dom/src/transforms/断言generate输出的代码字符串
换渲染平台实现RendererOptions里的 nodeOps用runtime-test跑全量 patch 用例

动手前建议先把pnpm test跑到全绿,再开始改。我有一次跳过这步,直接在红色基线上加功能,最后排查了一个下午才发现是两个不相干用例的全局状态串了。从那以后我每次动源码前都强制走一遍全量测试和typecheck,确认基线干净再开分支,省下来的时间远比这一步多。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 12:18:25

一句话让AI远程排查服务器:开源终端如何实现SSH排障的“点允许”授权

1. 从「一句话排障」说起&#xff1a;这个项目到底在解决什么问题服务器出问题的时候&#xff0c;最怕的不是问题本身有多复杂&#xff0c;而是排查过程被卡在沟通和权限上。我做了这么多年运维和开发支持&#xff0c;见过太多次这样的场景&#xff1a;业务方在群里喊「网站打不…

作者头像 李华
网站建设 2026/10/26 12:17:27

Haiku 5.5:推理成本断崖下降75%的工程化临界点

1. 一个被低估的“成本革命”&#xff1a;Haiku 5.5 不是又一款小模型&#xff0c;而是推理经济模型的临界点 你有没有算过一笔账&#xff1a;当一个API调用从0.0003美元降到0.000075美元&#xff0c;表面看只是省了两分钱——但如果你每天处理10万次请求&#xff0c;一个月就是…

作者头像 李华
网站建设 2026/10/26 12:15:46

摄影测量DLT:六控制点反算相机参数与VC++实现

简介&#xff1a;面向摄影测量、三维重建与无人机航测方向的学习者&#xff0c;这份VC工程实现了直接线性变换&#xff08;DLT&#xff09;算法&#xff0c;用于把二维像点坐标与物方三维坐标关联起来&#xff0c;完成投影中心、内参等参数的线性求解&#xff0c;适合已具备一定…

作者头像 李华
网站建设 2026/10/26 12:15:08

AI角色一致性实战:ControlNet与InstantID参数标定

做角色图的人基本都绕不开一个坎&#xff1a;同一张脸&#xff0c;第一张出图很稳&#xff0c;换个姿势还行&#xff0c;一旦换场景、换服装、换镜头&#xff0c;脸就飘了&#xff0c;出来的像是"同一个演员的不同替身"。Stable Diffusion 把画面质量拉到可用水平之后…

作者头像 李华
网站建设 2026/10/26 12:15:08

随机森林温度预测模型优化:从特征工程到时序验证

干温度预测这行当的人大概都有同感&#xff1a;模型跑起来不难&#xff0c;难的是把它调到“敢用”的程度。我最早接的一版逐小时气温预测&#xff0c;用的是最朴素的随机森林&#xff0c;特征就塞了几个滞后项&#xff0c;评估出来 MAE 1.8℃&#xff0c;团队里没人觉得有问题…

作者头像 李华
网站建设 2026/10/26 12:15:08

AI辅助前端开发实践:效率提升与挑战

1. 项目背景与动机去年底ChatGPT的爆火让我意识到&#xff0c;AI技术已经不再是实验室里的玩具&#xff0c;而是真正能改变工作流程的生产力工具。作为从业8年的前端工程师&#xff0c;我一直在思考&#xff1a;这些AI能力到底能在多大程度上提升我们的开发效率&#xff1f;它们…

作者头像 李华