
开头Vue核心(二)这个标题说实在的刚看到的时候我愣了一下。网上聊Vue的教程千千万但真正把“核心”讲透的其实不多。大多教你怎么写组件、怎么配路由、怎么上状态管理却很少解释背后的那套机制到底是怎么转起来的。我在这行干了十几年从jQuery时代一路走过来Vue 2刚出的时候就在项目里用了后来Vue 3带着Composition API横空出世我也算是一路踩坑踩过来的。这篇博文我打算换个思路不教你怎么写业务代码专门聊聊Vue背后的几大核心机制——响应式系统的底层原理、虚拟DOM和diff的来龙去脉、模板编译那点事以及Vue 2到Vue 3到底改了什么、为什么这么改。适合那些已经能熟练写Vue组件但总觉得“哪里不太对劲”“搞不懂原理”的朋友来看。看完之后再去写代码你会有一种从“照着抄”到“我知道自己在干嘛”的感觉。1. 响应式系统从Object.defineProperty到Proxy的本质区别1.1 什么是数据驱动视图响应式系统到底解决什么问题前端框架的核心理念就是“数据驱动视图”。说白了数据变了页面就得跟着变你不用手动去操作DOM。响应式系统就是实现这个“自动跟着变”的后台机制。Vue 2和Vue 3的响应式原理有个根本区别Vue 2用的是Object.defineProperty来拦截对象属性的读写Vue 3用的是ES6的Proxy。这个差异不是小事它决定了整个框架的能力边界。先说Vue 2。Object.defineProperty的优点是对ES5环境的兼容性很好但它有个天然的缺陷只能拦截单个属性的读取与写入。也就是说你给一个对象定义响应式属性必须一个个属性去定义。所以Vue 2在初始化数据时会递归遍历整个data对象给每个属性都加上getter和setter。这就会带来几个问题对象新增属性不是响应式的所以Vue 2才提供了Vue.set或this.$set这个API来手动添加响应式属性数组的索引操作比如arr[0] xxx没法被拦截所以Vue 2重写了数组的7个方法push、pop、shift、unshift、splice、sort、reverse深层对象的递归遍历在初始化时有明显的性能开销Vue 3改用Proxy之后这些问题基本都被解决了。Proxy拦截的不是“对象上的某一个属性”而是“对对象的整个操作”。不管是读取任何一个属性、写入任何一个属性、删除属性、还是调用in操作符都可以被统一拦截。而且Proxy的拦截是惰性的——你没访问到那个嵌套对象时它不会被递归处理这样初始化性能也更好。这里我打个比方Object.defineProperty就像你在一栋楼里每个房间门口都安排一个保安你想监控谁就得在他门口站着而Proxy就像你在整栋楼的大门口安排了一个总闸任何人进出都要过这道闸。效率和信息量完全不是一个量级。1.2 依赖收集与派发更新的完整链路响应式系统光能拦截数据读写还不够它还必须搞清楚两件事谁在用这些数据、数据变了该通知谁。这就是“依赖收集”和“派发更新”机制。我拆开来说。你在组件里写computed: { fullName() { return this.firstName this.lastName } }当fullName这个计算属性第一次被访问时它内部的this.firstName会触发firstName的getter。这个getter就会把“当前正在计算的这个函数”登记为firstName的依赖。怎么登记每个属性都维护一个依赖集合在Vue 3里叫作dep内部是一个Set结构把使用到这个属性的副作用函数比如组件的渲染函数、计算属性函数、侦听器都装进去。等到firstName被修改时setter触发它就去遍历自己那个依赖集合逐个通知它们“你依赖的数据变了请重新执行”。这个机制就是Vue高性能的关键只有真正被模板或计算属性使用到的数据才会建立依赖关系。没被使用的数据你改一万次它也不会触发任何更新。Vue 3里这个机制的实现代码相对清晰一些核心的类我简单列一下// Vue 3响应式核心的极简示意 let activeEffect: ReactiveEffect | null null class ReactiveEffect { constructor(public fn: () void) {} run() { activeEffect this this.fn() activeEffect null } } function track(target, key) { if (activeEffect) { let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } let dep depsMap.get(key) if (!dep) { dep new Set() depsMap.set(key, dep) } dep.add(activeEffect) } } function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (dep) { dep.forEach(effect effect.run()) } }这里有个细节值得注意track函数的执行条件是“当前存在活跃的副作用函数”。也就是说读取数据这个动作本身不会被收集只有在“某个副作用函数执行过程中”读取的数据才会被收集。这个设计很精妙它保证了依赖的准确性。1.3 响应式系统的常见陷阱与实操避坑这个机制理解透了很多开发中遇到的“怪问题”就都有了解释。我挑几个高频的坑来聊。第一个坑直接给响应式对象添加新属性。Vue 2的解决方案是用this.$setVue 3虽然Proxy能拦截新增属性但如果你用reactive创建的是一个普通对象新增属性倒没问题如果你用了ref包裹一个对象然后给ref.value新增属性也没问题。真正容易踩坑的是你用reactive创建的对象在模板中直接渲染新增属性本地开发环境一切正常一旦上了生产环境万一遇到冻结对象之类的情况还是会出问题。稳妥的做法是尽量在设计数据结构时就预留好所有字段而不是动态添加。第二个坑数组的响应式。Vue 2重写了数组方法但arr[0] xxx这种索引赋值依然无法触发更新。虽然Vue 3用Proxy解决了这个问题但很多团队还在维护Vue 2的老项目这个坑仍然真实存在。第三个坑解构丢失响应式。这个好多新手都上过当。const { count } toRefs(props)还是const { count } props前者是响应式的后者不是。原因是解构出来的count就是一个普通变量拷贝的是值不是引用。同理你在setup里从reactive对象解构出某个属性这个属性也会丢失响应性。第四个坑深层对象的性能。Vue 2初始化data时递归遍历所有层级如果一个对象层级特别深初始化就会卡顿。Vue 3的Proxy虽然是惰性拦截但如果你在代码里频繁访问深层嵌套属性也会产生一定的proxy层层包裹开销。这算是一个折中实践中并不会有多大影响但追求极致性能的场景需要注意。说实话理解响应式原理之后很多面试题都能迎刃而解更重要的是排查线上问题会快很多。很多组件明明数据变了但视图没变第一反应就是去看数据是否是响应式的、依赖是否被正确追踪而不是盲目加key或者强制刷新。2. 虚拟DOM与diff算法框架为什么需要这么个中间层2.1 虚拟DOM解决了什么问题它和真实DOM是什么关系直接操作真实DOM是昂贵的。浏览器的DOM操作会触发重排reflow和重绘repaint频率一高页面就开始卡。但有经验的开发者都知道并不是所有DOM操作都慢慢的是那些无脑触发大面积重排的操作。虚拟DOM的初衷就是尽量减少对真实DOM的操作次数。虚拟DOM是一个普通的JavaScript对象用它可以描述一个DOM节点的类型、属性和子节点。比如// 一个描述 div classappspanhello/span/div 的虚拟DOM const vnode { type: div, props: { class: app }, children: [ { type: span, props: {}, children: hello } ] }每次数据变化时框架先创建一份新的虚拟DOM树再和上一次的虚拟DOM树对比找出差异最后把差异一次性批量更新到真实DOM上。这样避免了多整页重建也把多次小更新合并成一次大更新。为了避免重复创建VNodeVue 3里还实现了VNode的复用机制。同一个组件如果渲染了多次它们的VNode是可以共享的。这个细节很多教程没讲但它对框架性能有一定贡献。2.2 Vue 2的双端diff与Vue 3的快速diff对比diff算法是整个虚拟DOM的核心。Vue 2和Vue 3的diff策略有很大不同。Vue 2的双端diff核心思路是在新旧两个子节点列表的头尾各放一个指针从两端同时向中间收敛比较新旧头节点、新旧尾节点以及交叉节点尝试找到可以复用的节点然后进行移动或删除。这种算法在处理“头尾移动”这种场景时效率很高因为不需要遍历整个列表。Vue 3的快速diff在此基础上做了升级。它先做前置和后置的相同节点处理再处理中间区域。对于中间区域的节点它用了一个Map来做key到索引的映射然后基于最长递增子序列来最小化移动次数。这个过程用到了贪心加二分查找时间复杂度优化到了O(nlogn)。我举个例子来说明两种算法的差异。假设你有一个列表[A, B, C, D]更新后变成[C, D, A, B]。Vue 2的双端diff会通过头尾指针的移动发现头节点A和旧尾节点D不匹配旧头节点A和新尾节点B匹配于是移动了节点做了几次操作之后完成。Vue 3的快速diff会先处理前置相同节点没有后置相同节点A、B和旧列表的A、B匹配但位置不同所以也不会直接跳过然后进入中间区域处理。最终两种算法都能正确更新但Vue 3在大型列表中的移动次数往往更少。用表格对比一下对比维度Vue 2双端diffVue 3快速diff核心思路头尾指针向中间收敛尽可能复用节点前置/后置处理 最长递增子序列时间复杂度O(n)起步有优化空间最坏情况O(nlogn)列表移动场景头尾交叉时表现不错稳定有序的大列表表现更好实现复杂度相对简单相对复杂适用场景大多数常规列表大型列表、高性能要求场景我个人体验是日常业务中两者差异感知并不明显。真正的差异在几千上万个节点的大列表滚动场景里才会体现出来。所以不需要因为性能差异去纠结哪个版本更好更重要的还是在于框架的整体一致性。2.3 key的真正作用为什么不要用index作为key很多初学者不理解为什么列表渲染的时候要用key以及为什么不能用index当key。有人会说“我用了之后功能没问题啊”。其实从diff的角度非常容易解释。key的作用是标识一个节点的身份。diff进行到复用节点这一步时它会优先判断key是否相同。如果key相同就认为两个节点是同一个组件或同一种元素只需要更新属性如果key不同那就直接放弃复用创建新节点替换旧节点。用index做key的问题在于当列表顺序变化比如排序、在头部插入时索引对应的项也变了。原本在第一个位置的组件A现在变成了组件B但它们的key都是0diff就会认为这是同一个组件于是只是更新属性而不是更换组件。这会带来很多隐蔽的问题比如输入框内容错乱、组件内部状态没被重置、动画错乱等。最典型的场景是一个列表里有输入框你往头部插入一条数据index会整体后移每个输入框都“保持”了自己原来输入的内容但看起来内容却整体错位了。用id或者业务上唯一标识作为key就没有这个问题。这里再补充一点Vue 3里如果没有设置key框架会用节点的索引作为默认key而且这个默认key是“就地复用”的它并不会强制组件重新创建。所以为了性能还是为了状态正确性都建议在列表循环里写key。关于key的注意事项还有一些经验key必须是稳定、唯一的不要用随机数否则每次渲染创建的key都不同会导致组件频繁销毁重建key只在列表循环场景有必要其他地方加key没有实际价值只会增加diff的负担如果列表里只有一个元素有没有key问题都不大但保持习惯就好3. 模板编译从template字符串到真实DOM的完整工序3.1 编译的三阶段parse、transform、generate模板编译是Vue把template字符串变成渲染函数的过程。这个过程分成三个阶段parse解析、transform转换、generate生成。parse阶段编译器会把模板字符串解析成AST抽象语法树。AST就是一棵描述模板结构的树每个节点包含标签名、属性、指令、子节点等信息。比如div classapp span{{ msg }}/span /div解析出来的AST大致长这样{ type: 1, tag: div, attrs: [{ name: class, value: app }], children: [ { type: 1, tag: span, children: [ { type: 2, expression: msg, content: {{ msg }} } ] } ] }transform阶段编译器对AST进行一些额外的处理。Vue 3在这里做了很多优化工作比如标记静态节点、动态节点生成patchFlag补丁标记这样在运行时diff的时候可以直接跳过静态内容只对比有标记的动态部分。generate阶段编译器把处理好的AST生成渲染函数代码。这个函数在运行时会被执行返回VNode树最终渲染成真实DOM。要求是理解这三个阶段的作用对于排查问题很有帮助。比如你发现某个模板写错了但编译没报错、运行却表现异常就得回到编译层面去找问题又比如你在模板里用了还没定义的变量编译能不报错但运行时会报错这也是编译期和运行期分离的表现。3.2 静态标记与PatchFlag的优化思路Vue 3的编译优化有一个很核心的设计patchFlag。它做的事情是在编译阶段就分析出模板中哪些地方是静态的、哪些地方是动态的然后在生成的代码里记录这些信息。比如这样一个模板div span静态文字/span span{{ count }}/span /div编译后第二个span节点会带上一个patchFlag标记它的文本内容可能变化。运行时diff只需要对带标记的节点做检查静态节点直接跳过。对于动态绑定属性的节点也会打上不同类别的flag比如PROPS表示属性可能需要更新。这使得Vue 3在更新性能上比Vue 2有了质的飞跃。与之配套的还有一个Block Tree的概念。编译器会把模板拆分成一个个Block每个Block内部动态节点会被收集到一个数组里。这样diff的时候只需要对比这个“动态节点数组”就行了不用重新对比整棵树。这个优化思路特别适合那些“页面主体基本不变、只有局部数据在变”的场景。比如管理后台的详情页、电商详情页的图文模块这些页面往往有大量静态内容Vue 3的静态标记让它们的更新成本变得很低。但要注意这个优化对完整版Vue运行时编译器有效对预编译的场景比如使用构建工具模板预编译效果一样。如果是运行时编译比如通过new Vue直接传template编译发生在浏览器端性能会差一些这也提醒我们尽量使用单文件组件.vue和预编译的方式。3.3 运行时编译与预编译的取舍Vue框架有两种编译模式完整版vue.esm.js和运行时版vue.runtime.esm.js。完整版自带了编译器可以在浏览器运行时把template字符串编译成渲染函数运行时版不包含编译器只支持渲染函数。在浏览器里编译模板是有性能代价的所以生产环境推荐使用预编译。在使用单文件组件和构建工具时模板会在打包阶段就被编译成渲染函数打包后的代码里不会有模板字符串运行时只需执行渲染函数生成VNode即可。这里给一句话总结能用构建工具预编译就尽量不要用运行时编译后者在编译阶段会消耗额外的浏览器资源也会让包体积变大。4. 组件机制与通信方案从设计意图看组件到底怎么回事4.1 组件实例的生命周期是响应式系统与DOM更新的调度中枢组件是Vue应用的基本单元。一个组件实例从创建到销毁会经历一系列生命周期钩子beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、beforeUnmount、unmountedVue 3的叫法。有些初学者不理解为什么要有这么多生命周期钩子正常理解就是“在组件不同阶段插入你自己的逻辑”。但从底层来看这些钩子实际上是框架调度流程的关键节点。比如组件首次挂载时会执行mount流程创建组件实例、初始化数据、设置响应式系统、生成渲染函数、创建VNode、挂载到真实DOM。整个过程会有多个可插入的钩子点。Vue 3的Composition API里生命周期钩子改成了函数式写法比如onMounted(() {})。这些函数必须在setup期间调用目的是把回调函数注册到当前组件实例上。这里有一个很容易踩的坑如果你在setTimeout或异步回调里调用onMountedVue 3会报警告因为组件实例上下文已经丢失。另外要说的是生命周期钩子也是响应式系统的一部分。beforeUpdate和updated这两个钩子是在组件发生更新时被调用的。更新的触发方式本质上就是组件的渲染函数执行时访问的响应式数据发生变化触发依赖更新进而调度组件重新渲染。4.2 组件通信的几种方式与适用场景Vue组件通信方式很多新人容易迷。我按使用频率和适用场景分一下类props / emits父组件向子组件传数据、子组件向父组件发事件。这是官方推荐的基础方式适合层级关系简单、通信逻辑不复杂的场景v-model语法糖本质就是props emit的组合。适合实现组件内部可控的双向绑定provide / inject跨层级传递数据适合深层嵌套的父组件向多个后代组件共享数据比如主题配置、用户信息。注意provide/inject不是响应式的除非你传入的是ref或reactive对象EventBus / mitt跨组件通信适合非父子关系的平级组件。Vue 3已经不再提供$on、$off常见做法是引入第三方事件库Vuex / Pinia状态管理库适合中大型应用全局共享状态。Vue 3生态里Pinia越来越成为主流模板refsref属性获取组件实例或DOM节点适合需要直接操作子组件时的场景但不要滥用我个人的体会是通信方案选型要遵循“能少用就少用”的原则。小型应用里只要props和emits就能解决90%的需求。很多项目一上来就全局状态管理结果团队里每个人写代码都往store里放数据代码越来越难维护。4.3 v-model的底层本质v-model是Vue一个非常有特色的语法糖它让表单元素和组件都能轻松实现双向绑定。它的本质其实很简单input v-modelsearchText /等价于input :valuesearchText inputsearchText $event.target.value /对于组件而言v-model绑定的是组件的modelValueprop和update:modelValue事件。这个设计从Vue 3开始统一了之前Vue 2中v-model默认绑定的prop是value事件是input。Vue 3支持一个组件上使用多个v-model只需要指定参数名比如v-model:titlepageTitle。这里有个实用技巧如果要实现一个封装了输入框的组件可以用defineModel宏来快速实现双向绑定。这个宏是Vue 3.4之后引入的它简化了“定义一个prop然后在某个事件里emit更新”的过程。理解v-model的本质能帮你避开一个经典坑在子组件里直接修改props并在模板上展示这个prop值结果发现本地修改立即生效但父组件的状态没变下次父组件重新渲染又把子组件的值覆盖回去。这就是因为你改的是“传进来的值”不是“改变传进来的值的手段”。5. Vue 2到Vue 3的核心演进不只是API变了5.1 Composition API到底解决了什么问题Composition API是Vue 3最重要的API变革。很多从Vue 2过来的朋友第一反应是“为什么不直接沿用Options API”其实这个设计合理得很。Options API把代码结构拆成了data、methods、computed、watch几个固定的区块。当组件小的时候这个拆法清晰易懂但当组件大起来一个问题相关的逻辑会被拆散到data、methods、computed、watch各个区块中。比如一个搜索功能data里存了searchText和searchResultsmethods里有handleSearch和resetSearchcomputed里有hasResultwatch里有对searchText的侦听。这些逻辑散布在Options API的各个区块里维护起来非常累。Composition API把这些逻辑聚合在一起你可以在setup函数里把某个功能相关的数据、计算属性、侦听器、生命周期钩子全部写在一块。这样代码的“内聚性”大幅提升重构和复用也更容易。Vue官方还提供了useXxx这种“组合式函数”的模式把一坨相关逻辑抽离成独立函数这是Options API完全做不到的。我在迁移项目的时候有一个明显的感受用Options API写的2000行组件迁移到Composition API之后往往能瘦身到1200行左右而且每个功能块都更容易被独立理解和测试。5.2 生命周期与全局API的变化Vue 3的另一个大变化是全局API的调整。Vue 2里的Vue.component、Vue.mixin、Vue.prototype等在Vue 3里被替换成了一套链式调用的app对象// Vue 2的做法 Vue.component(my-component, MyComponent) Vue.prototype.$http http // Vue 3的做法 const app createApp(App) app.component(my-component, MyComponent) app.config.globalProperties.$http http这个改动的好处是多个Vue实例可以共存不会互相污染全局配置。Vue 2是单例的如果你在页面上手动创建了多个Vue根实例它们的全局配置是共享的方法很容易互相覆盖。Vue 3的app实例互相隔离组件注册、全局配置都只对当前app实例生效。生命周期的映射也有所调整。以卸载为例Vue 2的beforeDestroy和destroyed在Vue 3中改名成了beforeUnmount和unmounted。捕获错误时errorCaptured、renderError虽然语义上保留了但Vue 3提供了更完善的app.config.errorHandler来统一处理。如果是从Vue 2迁移到Vue 3最常见的工作就是把全局API调用方式改成app实例的方式把filterVue 3删除了过滤器改成函数调用把$on/$off改掉。这个成本其实不小所以很多老项目都停在了Vue 2版本。5.3 虚拟DOM与框架演进方向看完Vue 2到Vue 3的演进我们能隐约看出前端框架的发展方向编译时做更多优化、运行时做更少的无用功。Vue 3的编译优化静态标记、Block Tree、patchFlag本质上是把“哪些地方可能变”这件事提前到了编译期。运行时只需要按图索骥处理那些确实需要更新的动态部分。React也在做类似的事情例如React Compiler尝试在编译期自动记忆组件减少重复渲染。对开发者而言认知上有一个重要的转变框架的性能优化越来越依赖编译期分析所以写模板的时候要尽量“老实”——不要做太多动态操作不要把所有内容都写成动态渲染让编译器有机会做静态标记。我不太建议去背性能优化策略表而是推荐你带着“编译期到底看到了什么”的视角去写模板。比如一个纯静态的页面骨架编译器能很轻松地标记它为静态VNode而如果你在模板里到处写函数调用、动态表达式编译器就无法优化运行时代价自然就上去了。6. 常见问题与排查技巧实录6.1 响应式失效排查数据改了视图没变这类问题出现的频率极高。我的排查步骤通常是这样第一步先确认数据本身是不是响应式的。用reactive创建的对象或者ref创建的变量都应该能被isReactive或isRef检测到。如果你用的是普通对象或基本类型变量那自然没有响应性。第二步确认修改的方式是否被响应式系统监听。Vue 2时代新增属性、索引赋值都是经典的失效场景Vue 3虽然Proxy能拦截大部分操作但如果你用一个新对象整体替换了reactive对象的某个属性可能也会因为失去依赖追踪而失效。第三步确认你修改数据的地方是否在副作用函数的执行上下文中。比如说你在某一个异步回调里修改了ref的值这个异步回调不是由渲染函数触发的修改后依然会触发更新吗答案是会因为ref的更新机制不依赖“谁在修改”它只需要“修改动作本身能够触发对应依赖的更新”。第四步确认是不是组件没触发重新渲染。有的场景下数据修改确实被追踪到了但组件的render函数并没有被执行原因可能是你修改的响应式数据和“组件真正访问的数据”不是同一个。这个情况特别容易发生在使用props解构并传给computed时。6.2 组件重新渲染的无效成本依赖追踪的范围组件重渲染其实是有成本的。如果组件的渲染函数里访问了很多响应式数据那这些数据中任何一个变化都会导致整个组件重新渲染。所以一些库会提供shouldUpdate、memo等机制来减少重渲染。Vue 3的响应式系统本身已经足够精确但如果你在一个大组件里把整个store都引入渲染函数那任何store变化都会带动这个组件更新。实践中我的建议是组件拆分细一点粒度小一点。把需要频繁动态变化的部分拆成子组件让父组件的渲染尽量“静态化”。这不是框架的问题是设计的问题。6.3 编译检查与运行时错误速查这里整理一个常见问题速查表问题现象可能原因排查建议页面白屏且控制台报Failed to resolve component组件定义名称和导入名称不匹配或组件未注册检查组件名拼写和注册方式模板里写undefined能通过编译但运行时报错数据初始值未设定给data/ref设置合理的初始值列表渲染顺序错乱或输入框内容错位key用了index而不是业务标识改用稳定唯一keycomputed没有响应更新访问了非响应式数据或者依赖了被解构的props确保依赖的是响应式对象不直接解构props监听器频繁触发watch没有配置deep或者多个数据源同时触发分析监听范围配置合理的选项组件卸载后仍触发异步状态更新异步回调在组件卸载后才完成在unmounted钩子里清理或使用onScopeDispose这些问题的共性教训是遇到问题先把“响应式链路”画出来再把“编译产物”过一遍往往就能定位到具体原因。不要一上来就怀疑框架有bugVue在这方面是久经考验的。我做前端这么多年Vue前前后后用了两个大版本。刚开始用Vue 2的时候我就发现一个很有意思的现象如果你完全理解了响应式系统和组件的生命周期很多框架层面看起来“玄学”的问题其实都是可推演的。所谓“Vue核心”说到底就是理解数据流在组件里的传递路径、数据变化如何被追踪、组件如何被创建和销毁。把这些搞清楚了写代码的自信程度完全不一样。最后分享一个小技巧排查响应式问题时可以在项目里临时开启Vue 3的app.config.performance true它会输出组件更新的性能追踪。再看浏览器性能面板的User Timing你能直观看到哪些组件在频繁更新、每个更新花了多少时间。这比靠猜要快得多。对于准备深入研究Vue源码的人来说我建议你从reactive的实现开始读它是最独立也最能体现设计思想的一部分。读完再看编译和运行时调度整条链路就会豁然开朗。