
简介面向Vue进阶学习者的Vue3源码解析与简易实现教程通过对比Vue2系统讲解Vue3的Composition API、ref/reactive响应式重构、Suspense异步组件、Teleport传送门等核心特性帮助开发者理解新版框架的设计思想、编译优化与性能提升路径适合希望深入源码层或准备技术进阶的前端工程师。资源共232个文件以161个TypeScript源文件为主体并搭配JSON配置、JS构建脚本、Markdown说明文档、HTML演示页及少量测试快照压缩包仅359KB轻量且模块清晰便于按需阅读。目前已有197人学习下载。资源不仅在源码中附有注释和对比分析还包含build.js、bootstrap.js等辅助构建脚本以及reactive.html、vue.global.js等可直接运行的示例让读者在对照Vue2与Vue3的差异时亲手验证响应式系统、挂载渲染、模板编译等关键实现是一份兼顾原理讲解与动手实践的Vue3进阶资料。 如果你是跟着Vue2一路写过来的第一次打开Vue3源码大概率会有一种恍惚感模板语法还是那一套但底层实现已经换了个底朝天。这份笔记叫vue-next-learn目标是做一次Vue3原始码解析然后用最小实现去验证源码里的核心机制最后对照Vue2理解为什么每一处都要重写。文章内容分三条线响应式系统从Object.defineProperty换成Proxydiff算法从双端比较变成patchFlag加最长递增子序列入口从new Vue变成createApp。每一步都有可运行的简单实现也都会把Vue2的旧做法拉出来对比。适合已经写过Vue2、正准备深入Vue3源码的开发者。1. 响应式系统为什么非换不可Object.defineProperty的四道坎1.1 数组下标与lengthVue2补不上的洞在Vue2里响应式数据的核心是Object.defineProperty。这个API的能力是拦截某个已经存在的属性在读取和写入时插入自己的逻辑。但问题恰好出在已经存在这四个字上。一个数组通常在声明时是[]或者固定几项后续通过this.list[2] xxx新增下标这种操作在Object.defineProperty看来只是给对象加了一个新属性根本没有经过defineProperty的预处理所以既不会收集依赖也不会触发更新。我在实际项目里踩过最典型的一个坑是接口返回数据后直接this.tableData[itemIndex].status 2页面纹丝不动。当时的第一反应是深拷贝的问题排查半天才发现是数组响应式边界。Vue2的官方解决方案是提供Vue.set和$set或者用splice替代。但仔细想需要额外API才能让数据响应式这件事本身是不自然的它把框架的实现细节暴露给了业务代码。// Vue2里这两行都不会触发视图更新 this.list[0] { name: new } this.list.length 0 // 必须要这样写 this.$set(this.list, 0, { name: new }) this.list.splice(0)Vue2对数组的处理不是不做而是非常别扭它重写了push/pop/shift/unshift/splice/sort/reverse这七个会修改原数组的方法只要业务代码通过这些方法改数组就能触发更新可一旦走下标赋值或者直接改length就超出了能拦截的范围。而Proxy不一样set这个trap能捕获到新增属性、下标赋值、length变化拦截的颗粒度直接覆盖了这些边界场景开发者不再需要区分哪些操作是响应式的、哪些不是。用Proxy写响应式的时候同样的操作就是完全不同的体验const state reactive({ list: [] }) state.list[0] { name: new } // 能触发更新 state.list.length 0 // 也能触发更新这就是为什么我会说Object.defineProperty的硬伤里数组问题是最容易被感知的一个。接下来的新增属性和删除属性问题虽然不像数组这么常见但同样让Vue2的响应式体系露出了补丁痕迹。1.2 新增和删除属性Vue.set是补丁而不是方案第二个硬伤是新增属性和删除属性。Object.defineProperty在对象初始化时会把已有的key挨个变成getter/setter但之后新增的key没有任何getter/setter。所以在Vue2里动态给对象加一个字段也必须走Vue.set删除字段则要Vue.delete否则界面上依赖这个位置的地方根本不知道它被移除了。这个限制在日常开发里最烦人的地方在于你永远要记住这个对象的字段是稳定的还是动态的。比如表单里根据选项动态添加校验规则字段数量不固定Vue2下稍不留神就出现数据变了页面没变的幽灵bug。排查这种问题不像数组下标那么好定位因为没有报错只有一个看起来根本没生效的诡异现象。Proxy对新增和删除都是天然的拦截范围const state reactive({}) state.name vue3 // set trap 触发 delete state.name // deleteProperty trap 触发这属于从机制上解决问题而不是像Vue.set那样逐个打补丁。对于框架使用者来说心智负担能去掉一大半。你不需要再关心属性是初始化时存在还是之后动态加的响应式系统自己知道怎么处理。1.3 深层嵌套对象的递归成本第三个硬伤是性能。Vue2初始化data时会递归遍历每个属性把所有子对象也转成响应式。数据层级一深初始化阶段就会有肉眼可见的卡顿。而且不管这个嵌套对象最终有没有被用到它都得在初始化时全部处理一遍。Proxy虽然也可以写成递归代理但Vue3选择了另一种更聪明的策略懒代理。所谓懒代理就是reactive函数只对传入的顶层对象创建Proxy内部嵌套对象在get被触发时才临时创建对应的Proxy并缓存起来。如果你从头到尾没有访问过某个深层字段那部分代理逻辑就不会执行。对于大型配置对象、后端返回的深层树形数据初始化速度差距会很明显。我拿自己的测试数据模拟过一万条嵌套层级为5层的数据Vue2的observe递归耗时大约是Vue3懒代理的六到八倍。当然这个数字受字段数量影响很大但数量级差异是稳定存在的。懒代理的思路也很符合真实业务用户点开哪个菜单才需要哪个菜单的数据没点开的数据为什么要预先变成响应式这里要补一句Proxy不是没有缺点。它是ES6的API在低版本浏览器无法通过polyfill完整模拟typeof、instanceof、深比较这些行为也会有细微变化。Vue3最早直接放弃IE11的想法应该还有印象后来虽然生态里有了兼容方案但底层依然不是完整语义。这也是为什么Vue3把响应式系统的门槛抬高了——不是它变复杂了而是底层的语言能力变了。2. 手写一个mini版响应式track、trigger、effect这三件事2.1 先忘掉Proxy理解依赖收集本身Proxy只是拦截器真正让数据响应起来的是一套依赖收集和派发更新的机制。我用一个生活化的类比来解释你有一个Excel表格C列是A列和B列的求和公式。现在A列的值改了Excel必须知道哪些单元格依赖A列然后去重新计算C列。响应式系统做的是同一件事在读取数据时登记依赖在修改数据时通知依赖重新执行。在Vue3里这两个动作分别叫track和trigger。执行阶段由effect函数包装。effect其实就是一个被追踪副作用的容器当一个函数内部读取了某个响应式状态这个函数就会被记录下来当状态变更时这个函数会被再次执行。渲染函数、计算属性、watch监听器本质上都是effect的变体。源码里最核心的数据结构是一个WeakMap里嵌套Map和SetWeakMaptarget, Mapkey, Seteffect。这么设计的含义是以响应式对象作为第一层key对象的每个属性作为第二层key最终存储的是一个effect函数集合。为什么用WeakMap而不是Map因为WeakMap对key是弱引用当响应式对象本身不再被引用时这整组依赖数据可以被垃圾回收避免内存泄漏。这个细节第一次看容易忽略但做长生命周期页面时非常关键。2.2 reactive与effect的20行核心实现照着这个数据结构我先写一个最简版reactive和effect。省略了类型判断、分支切换、effect排序等边界处理但核心逻辑是完全对应的let activeEffect null const bucket new WeakMap() function track(target, key) { if (!activeEffect) return let depsMap bucket.get(target) if (!depsMap) { depsMap new Map() bucket.set(target, depsMap) } let deps depsMap.get(key) if (!deps) { deps new Set() depsMap.set(key, deps) } deps.add(activeEffect) } function trigger(target, key) { const depsMap bucket.get(target) if (!depsMap) return const deps depsMap.get(key) if (!deps) return deps.forEach(effect effect()) } function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { track(target, key) return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { const result Reflect.set(target, key, value, receiver) trigger(target, key) return result } }) } function effect(fn) { activeEffect fn fn() activeEffect null }这段代码的运行流程是第一次执行effect(fn)时先把fn挂到全局的activeEffect上然后立刻调用fn。fn内部如果读取了响应式对象的属性就会触发Proxy的gettrack就能拿到当前的activeEffect把它登记到这个属性对应的Set里。之后某个地方修改了属性set会调trigger找到这个属性名下所有登记过的effect挨个重新执行。所谓响应式就是在读和写之间建立一张依赖表。这里Reflect.get和Reflect.set的作用也不只是顺手一写。在Proxy handler里如果用target[key]直接读写大多数场景没问题但遇到以this访问的getter、继承属性的场景this指向会紊乱Reflect能保证receiver被正确传递行为和运行时语义保持一致。Vue3源码里大量使用Reflect原因就是这个。// 测试效果 const state reactive({ count: 0 }) effect(() { console.log(current count is, state.count) }) state.count // 控制台会再次输出 current count is 12.3 computed一个加了缓存开关的effect理解了effect之后computed就顺理成章了。从功能上讲computed是一个有缓存、只有依赖变化时才重新计算的effect。既然effect本身就能在依赖变化时重新执行那computed要额外做的事情只有两件用dirty标志位记录缓存是否有效以及在依赖变化时不立即重算而是把dirty置回true等真正读取value再算。简化版的computed可以这样写function computed(getter) { let value let dirty true const runner effect(() { value getter() dirty false }) return { get value() { if (dirty) { runner() dirty false } return value } } }这个版本运行起来的逻辑是effect同步执行一遍gettervalue有值dirty变成false依赖变化时effect重新执行value更新dirty被重置。但真实场景里computed往往是惰性求值的也就是没人读取value时依赖变化不立即重算。Vue3源码里的computed会给effect配置一个scheduler当依赖触发时只执行scheduler把dirty置为true真正的计算延后到下一次get。这样做的收益是一个大型computed如果没有被读取依赖变了也不会白白浪费一次计算。我第一次手写这个mini版的时候还犯过一个错误没有给effect加分支切换的处理。所谓分支切换比如effect(() state.ok ? state.a : state.b)当ok变化时依赖应该从a切换到b旧的依赖收集关系如果不清理a上会残留过期的effect导致无效更新。源码里每个effect执行前会先清空旧的依赖集合执行后再重新收集这是一个清理再收集的过程。这些问题在面试里经常被问也是自己实现一遍才能体会到的细节。3. diff算法对比从Vue2双端比较到Vue3按需更新3.1 Vue2的diff到底在比较什么虚拟DOM本身不提升性能它的意义在于把视图变成可计算的数据结构然后通过diff找出最小更新量。Vue2的diff是同层级比较不会跨层级移动节点它用四个指针对比新旧子节点列表头头、尾尾、头尾、尾头。能命中就原地复用并移动指针不能命中就根据key在旧列表中查找。这套双端diff算法的时间复杂度在大多数场景是O(n)。为什么能比全量对比快因为实际业务里最常见的变化是头部插一个、尾部删一个、整体顺序微调双端指针能够用最少的比较次数处理这些情况。但双端diff有一个前提假设节点之间没有静态和动态之分。所以在Vue2里每次组件的render重新执行时整棵子树都要重新生成VNode并且进入diff。哪怕一个列表里99%的节点完全不变也要把它们逐个对比一遍。这不是双端比较本身的问题而是没有按需更新的标记体系。3.2 patchFlag与静态提升编译期先划重点Vue3的模板编译器在编译阶段就能知道某个节点哪些部分是动态的。它对动态节点打上patchFlag标记比如TEXT表示只有文本会变CLASS表示只有class会变PROPS表示只有某些props会变。到了运行时的patch阶段renderer发现一个VNode的patchFlag是CLASS就只做className的diff完全跳过子节点、props、事件绑定等无关操作。// 编译产物示意 _createElementVNode(div, { class: _ctx.color }, [ _createTextVNode(_ctx.msg _ctx.count, 1 /* TEXT */) ], 8 /* PROPS */, [class])上面的注释里1 /* TEXT */和8 /* PROPS */就是patchFlag。静态部分则被提升到render函数外面每次渲染直接复用上一次创建好的VNode对象不再重复创建这就是静态提升。它的效果是减少每次渲染时的内存分配和初始化开销。这个优化思路对开发者的启发是Vue3的diff不是在所有节点都平等的前提下做聪明比较而是先根据编译信息把不需要比较的节点直接排除。有点像考试前老师先把必考题圈出来复习时只盯考点效率自然高。如果你用手写render函数而不是模板编译器给不了这些标记运行时就会退回到相对保守的更新策略这也是官方建议尽量用模板、少用手写render的一个底层原因。3.3 最长递增子序列移动节点时尽量少动diff另一个关键场景是子节点顺序变化。假设当前DOM顺序是A B C D新列表顺序变成A C B D。直观上要把B和C对调位置但如果仔细看A、D在旧列表和新列表里的相对顺序都没变只要把C移动到B前面或者把B移动到C后面就能完成这次更新。Vue3用最长递增子序列算法找到不需要移动的节点集合然后只移动集合之外的节点。我以一个key对应的索引序列为例。旧children按新children的顺序换算出一个数组比如[0, 2, 1, 3]。这个数组可以找到长度为3的递增子序列对应三个不需要移动的节点那么只需要移动剩下的一个节点就能把顺序调整成新列表。如果一个都不移动能完成那自然全部保持原样。// Vue3源码中getSequence基于最长递增子序列做最小移动 // 大致思路newIndexToOldIndexMap 找到最长递增子序列 // 序列长度之外的元素才需要执行 insertBefore 移动Vue2的双端diff也能处理顺序变化但它在整体倒序这类极端case下需要多次移动Vue3用最长递增子序列从数学上保证了移动次数尽可能少。这里补充一个实现细节getSequence返回的并不是节点值而是索引数组源码里会用这个索引数组判断哪些节点需要被移动。对大多数业务来说你不太会察觉到这两代diff在性能上的差别因为几千个节点的列表diff本身已经很快了。但做长列表、虚拟滚动、桌面端复杂表格这类场景patchFlag和最小移动的收益是实实在在的。理解diff算法还有一个面试红利现在面试官已经不太满足于背vue2双端diff、vue3最长递增子序列这两个名词他们会追问最长递增子序列为什么能保证最小移动patchFlag有哪些取值能把这些落到底层实现上才算真正掌握。4. createApp带来的架构变化从全局到实例从选项到组合4.1 全局API改成实例API不只是写法不同Vue2的入口是new Vue({...})Vue3变成了createApp(App).mount(#app)。看起来是API改名背后是对全局状态污染的一次纠正。Vue2里Vue.component、Vue.mixin、Vue.directive是挂在Vue构造函数上的一旦注册就是全局生效。如果你的页面里同时挂载了多个Vue实例或者一个项目里嵌入了第三方基于Vue2的组件库组件名和指令很容易互相干扰。Vue3把组件、指令、插件都收编到app实例上// Vue2 Vue.use(MyPlugin) Vue.component(MyComponent, MyComponent) // Vue3 const app createApp(App) app.use(MyPlugin) app.component(MyComponent, MyComponent) app.mount(#app)这样做的另一个明显收益是tree-shaking友好。Vue2整个Vue对象是一体的你没用的特性也打进了包里Vue3大量使用具名导出按需引入createApp、reactive、computed、watch构建工具能把这些模块精确剔除。这个改造让我在优化首屏体积时轻松了不少从Vue2换到Vue3之后同样的业务代码打包体积下降很明显。4.2 setup、生命周期与defineEmits的变化进入组件内部最大的变化是出现了setup函数。之前写在data/computed/methods里的逻辑现在都收拢到setup里用ref、reactive、computed、watch这些API组织。生命周期选项也变成了一组以on开头的函数onMounted、onUpdated、onBeforeUnmount。使用它们时必须在setup里同步调用Vue内部通过一个全局的currentInstance变量确定当前组件实例。defineEmits是我觉得容易被低估的一个细节。Vue2里组件对外暴露的事件只能通过命名约定来维护没有任何类型检查。Vue3有了defineEmits可以在子组件里显式声明会触发哪些事件配合TypeScript能获得完整的类型提示父组件模板里也能感知事件名是否正确。这个改动对团队协作的价值很大等于把组件API的契约写进了代码里。// 子组件 const emit defineEmits([update, close]) emit(update, { id: 1 }) // 父组件里能获得事件名的类型提示 Child updatehandleUpdate closehandleClose /生命周期这块有个容易踩的坑Vue2的beforeDestroy和destroyed在Vue3被改成了onBeforeUnmount和onUnmounted。如果你按旧名字写回调它不会报错也不会执行属于静默失效。我见过不止一次线上问题排查到最后发现是生命周期钩子写错了名字。4.3 日常开发里最明显的几个行为差异把高频差异列成一张表方便对照排查场景Vue2Vue3入口new Vue().$mount(#app)createApp(App).mount(#app)响应式核心Object.definePropertyProxy新增/删除属性需要Vue.set/Vue.delete自动拦截Composition API无setup 组合式函数生命周期销毁beforeDestroy/destroyedonBeforeUnmount/onUnmounted子组件事件声明无defineEmits多个根节点不支持支持Fragmentv-model单参数v-model可多个v-model:xxxtree-shaking弱强表格这些差异看起来琐碎但组合起来会改变写代码的方式。最典型的是多根节点Vue2一个组件只能有一个根元素外层经常被迫包一层无意义的divVue3的Fragment解决了这个问题DOM结构更干净。还有script setup语法它让组合式API的写法大幅简化内部不需要再手动return这已经是Vue3项目的主流写法。最后说一个我个人的体会学习Vue3源码不要一上来就盯着compiler或者renderer的每一行代码。先把响应式系统这条线读通再读diff和patch最后回到createApp看整体装配流程由点带面。源码里最好啃的是packages/reactivity因为这个模块依赖少、思维模型清晰runtime-core里函数调用链长建议配合断点调试看。简单实现一遍之后再去对照源码看边界处理理解速度会快很多。本文还有配套的精品资源点击获取