
1. 项目概述我为什么花大力气啃VUE原理前阵子团队做技术分享让我讲Vue的响应式原理我翻了一堆源码、看了好几篇源码分析最后发现真正把Vue原理讲清楚光靠背概念没用得把reactive、ref、effect、computed这套核心逻辑串起来才算入门。这篇博文就是我当时整理出来的精华版适合正在学Vue、准备面试、或者用了两年Vue但只知道API没看过原理的兄弟。先说清楚原理这东西能帮你解决什么问题排查诡异BUG、优化性能、理解Vue和React的底层差异以及应付大厂的深挖式面试全都靠它。你对原理理解越深写代码时就越清楚某行代码后面发生了什么而不是靠经验试错。现在很多人学Vue原理直接一头扎进源码读了好几遍仍然一头雾水因为源码经过了大量的编译、类型推导、边界处理被工程化的代码包裹得严严实实。我建议换一条路——先理解设计思路再跟着核心链路的简化实现走一遍最后回到源码里验证。这样学下来你会发现自己不仅能读懂源码还能顺手仿写一个小Vue这才是把原理真正吃透了。我的学习路线分四层先把整套体系摆出来第一层响应式系统的核心闭环包括reactive、ref、effect、computed、依赖收集、派发更新第二层组件的渲染与更新流程包括render、虚拟DOM、patch、diff、key、nextTick第三层编译优化和运行时结合包括模板编译、静态标记、Block树第四层Vue 2与Vue 3的差异对比以及高频面试题背后的原理下面每一层都会拆开讲手写代码部分我会放一个最小可运行的实现让你能直接复制看到效果。2. 响应式系统的核心闭环从一张“依赖图”说起要理解Vue原理最核心的就是它的响应式系统。Vue 3的响应式系统可以简单理解成三件东西一份被观察的数据reactive和ref、一个观察者effect这个副作用函数、一张依赖表track和trigger维护的映射关系。这三者构成了一个闭环数据变了观察者知道自动重新执行。这个设计思路用大白话说就是你声明一个数据告诉Vue哪些函数用到了它当数据变化时Vue把这些函数重新跑一遍。是不是很像Excel里的公式联动单元格B1填了A11A1变了B1就自动重算。Vue的响应式系统就是一套自带公式联动逻辑的JavaScript引擎。2.1 为什么Vue 3用Proxy替代了Object.definePropertyVue 2的响应式是通过Object.defineProperty给对象的每个属性设置getter和setter来实现的。这个方案的痛点大家应该都有体会新增或删除对象属性时界面不会自动更新必须调用Vue.set或this.$set通过下标修改数组元素界面不会更新需要借助splice等方法触发对深层对象要递归遍历对象层级越深初始化时一次性递归的开销越大动态新增的属性没有响应式能力需要额外处理Vue 3改用Proxy对整个对象进行代理访问对象任意属性都会先经过get和set拦截天然支持新增属性、删除属性、下标修改数组。而且Proxy的代理是惰性的访问到对象内部时才对子对象做响应式包装不像Vue 2初始化时就递归到底这在白屏性能和内存占用上都更友好。这里有个容易忽略的细节Proxy的get拦截同时承担了两个任务一是返回属性值二是把正在运行的effect记录下来。set拦截也干了双份活儿先把值写进去再通知依赖这个属性的effect重新执行。这么设计的好处是整个响应式核心不需要额外维护一套Watcher列表依赖关系是被“读”这个动作动态建立的非常灵活。2.2 手写响应式核心从零实现reactive和ref先看一个最精简的reactive实现。我不追求完整源码只保留主链路方便理解const targetMap new WeakMap() let activeEffect null function track(target, key) { if (!activeEffect) return 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()) } } function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const result Reflect.get(target, key, receiver) track(target, key) return result }, set(target, key, value, receiver) { const result Reflect.set(target, key, value, receiver) trigger(target, key) return result } }) } let id 0 function effect(fn) { const wrap () { activeEffect wrap fn() activeEffect null } wrap.id id wrap() return wrap }ref的实现思路也不复杂它对外只暴露一个value属性内部用reactive包装一个{ value: 初始值 }对象。这样基础类型也能被走通get/set拦截function ref(value) { const raw reactive({ value }) return raw }这里有个关键点需要说明我在track和trigger里忽略了多个边界条件比如effect嵌套、分支切换、派发更新时的死循环保护。真实工程里这些都要处理但它们连带理解主链路反而加重负担。先记住这个简化版再看源码就有骨架了。2.3 依赖收集为什么用WeakMap加Map加Set三层结构很多初学者看到targetMap、depsMap、dep这一层层的数据结构就晕了。我画不出来图但你脑子里想三个容器就够了最外层WeakMap键是原始对象target值是该对象对应的depsMap中间层Map键是属性名key值是该属性对应的dep集合最内层Set存放所有依赖这个属性的effect函数为什么最外层一定用WeakMap因为WeakMap的键是弱引用当原始对象被垃圾回收时整个依赖表里的对应项也能被自动回收不会造成内存泄漏。这是一个非常优雅的设计Vue不再需要手动告诉它“这个对象不用了把依赖清掉”引擎层就帮你处理了。中间层的Map和Set都是为了去重。用Map按属性名隔离依赖修改a属性时不会触发b属性的effect。用Set则保证了同一个effect不会被重复记录避免一次依赖收集导致同一函数被执行多次。去重的意义在你连续访问同一个响应式属性时体现得特别明显——没去重的话一次渲染就可能让effect在依赖表里出现上百次内存和性能都会出问题。2.4 computed的实现思路懒执行和缓存两个关键词computed在Vue里长得像一个属性用起来也像一个属性但实现上其实是“带缓存且懒执行的effect”。什么叫懒执行就是computed的函数体不会在定义时立刻执行而是等到有人读取它的value时才计算结果。什么叫带缓存就是依赖数据没变时多次读取computed会直接返回上次的结果不重复计算。我用一个极简版本展示这个思路function computed(getter) { let value let dirty true const runner effect(() { value getter() dirty false }, { lazy: true }) return { get value() { if (dirty) runner() return value } } }dirty标记是整个computed的灵魂。首次读取时dirty为true执行一次getter之后依赖没变化dirty还是false直接返回value。一旦依赖的响应式数据发生变化副作用函数会重新执行并把dirty置为true下一次读取再重算。这个模式在源码里还会设计调度器让effect更新时通过调度器触发而不是同步执行进一步控制刷新时机。我实战中强烈建议复杂派生数据、模板里重复使用的计算表达式一律用computed不要用methods。因为methods每次渲染都重新执行computed只有依赖变化时才算一次。在数据比较大的列表过滤、级联选择等场景这个性能差异非常明显。3. 模板编译渲染流程从模板字符串到页面更新响应式系统解决了数据变化后“通知谁”的问题但Vue真正把页面渲染出来靠的是另一条链路模板编译和虚拟DOM。模板先被编译成render函数render函数执行后产出虚拟DOM虚拟DOM再被patch到真实DOM上。很多只会用Vue的同学根本不知道模板背后还有编译这一步。实际上你在template里写的结构Vue最终会交给编译器处理生成一个JavaScript函数。比如一个简单的div{{ msg }}/div编译后的render函数大致长这样function render(_ctx) { return createElementVNode(div, null, toDisplayString(_ctx.msg), 1) }那个神奇的1就是PatchFlags表示这个元素的文本可能动态变化编译器和运行时看到这个标记就知道更新时只需要比对textContent不需要递归整个子树。这个“静态标记”机制是Vue 3比Vue 2渲染效率更高的核心原因之一。3.1 render函数、虚拟DOM与真实DOM的三角关系虚拟DOM我习惯把它叫“真实DOM的结构说明书”。它是一个普通的JavaScript对象描述了一棵DOM树该长什么样节点类型、属性、子节点。因为它是纯对象计算和比较都发生在内存里远比直接操作真实DOM便宜。真实DOM的操作有多贵做过大量DOM插入、删除的同学都有体会——每一次layout和paint都在消耗性能。渲染流程是render函数执行产出新的虚拟DOM然后和上一次的虚拟DOM做diff对比找出差异最后最小化地更新到真实DOM。中间不做diff直接全量替换也可以但性能就全毁了。所以diff算法是虚拟DOM方案的核心竞争力。初次渲染时没有旧虚拟DOM直接把虚拟DOM递归创建成真实DOM挂载到容器。更新时新旧两颗虚拟DOM树做对比尽可能复用节点。这就是“数据变了视图跟着变”的完整闭环。3.2 虚拟DOM与diff算法key到底解决了什么问题diff算法网上文章海了去了但核心就一句话同层比较、逐层递归、标记复用。为什么只同层比较因为跨层移动节点的概率极低Vue选择用O(n)的复杂度换取可接受的更新精度而不是追求O(n^3)的完美最小编辑距离。key的作用在这个算法里至关重要。没有key时diff算法只能按顺序挨个比较新旧节点列表中间插入一个节点会导致后续所有节点都被判定为“类型相同但内容不同”然后逐个就地复用、修改内容。有key时diff算法能通过key精确识别哪些节点是同一个哪些节点是新增哪些节点是被删除从而实现精准的插入和移动。这里我要强调一个开发中常见的坑在v-for里用数组下标index作为key。如果列表只在末尾追加问题不大一旦涉及中间插入、排序、删除用index当key会导致组件状态错乱。你的input框输入了内容列表一排序内容跟着绑到了另一行上。正确做法是用数据中唯一且稳定的业务ID实在没有就用内容生成的hash值。3.3 nextTick的实现思路为什么数据变了DOM不是马上更新Vue更新DOM是异步的。你在代码里连续修改三次响应式数据Vue不会每改一次就更新一次DOM而是把这三个更新合并到同一个“微任务队列”里统一执行一次。这样的好处是避免重复渲染性能更优。nextTick就是在DOM更新完成后执行回调的API。它的实现原理不复杂把回调推到一个队列里然后用Promise.resolve().then()把队列里的任务放到微任务阶段执行。源码里还会做降级处理优先用Promise不支持的话降级到MutationObserver再不行就是setTimeout。理解了这个原理你就明白为什么在改了数据后立刻读DOM拿到的还是旧值。解决办法就是async function updateSomething() { state.value new await nextTick() // 此时DOM已经更新完成 }nextTick是Vue面试的高频题也是实际调试时排查“DOM没更新”问题的第一入口。排查这类问题最标准的思路是先确认数据是否真的变了再确认是否在nextTick之后读DOM最后检查是否有手动操作把异步队列搞乱了。4. 聊聊我踩过的坑Vue原理在实战中的高频痛点原理学完最终还是要靠实战消化。我总结几个自己在项目中真实踩过的坑每一个都和原理有强关联。理解了原理这些坑基本都能提前绕开。4.1 用reactive包基础类型页面纹丝不动这个坑我已经见了好几次了。有人写const state reactive(hello)然后改state world页面没反应。原因很简单reactive的底层是ProxyProxy只能代理对象你传一个字符串进去它包装完了还是字符串并没有拦截能力。正确的做法是用ref或者用reactive包一个{ value: hello }对象。新手最容易混淆的就是ref和reactive的使用边界。我的经验法则是基础类型、场景单一的独立变量一律用ref对象、数组、复杂嵌套结构用reactive或者ref再包一层都行。在模板里Vue会自动解包在逻辑里记得区分.value和直接访问。4.2 数组元素更新不触发视图的旧习惯Vue 2时代大家习惯用splice触发数组更新到了Vue 3如果还习惯性用this.$set就会报错说方法不存在。因为Proxy代理了整个数组直接通过索引赋值、修改length、调用push、pop等方法都会被set拦截到响应式天然支持。这也是Vue 3把Vue 2那么多麻烦约束一并干掉的直观体现。不过有一个小点要注意如果你是给一个已经存在的响应式数组整体替换为新数组比如state.list newArr那完全没问题。但如果你修改的是嵌套对象里尚未被代理过的子对象属性Vue 3的惰性代理会在访问时自动补上不存在Vue 2那种“新增属性不响应”的情况。4.3 依赖收集的“不收集”问题为什么有时候effect不执行有一种情况是你在effect里读取了响应式数据但读取动作发生在异步回调里比如setTimeout或者Promise.then里。这时候activeEffect是nulltrack函数收集不到依赖数据变化时光标找不到订阅者自然不执行更新。这个问题在真实的业务里很常见尤其是配合路由参数、接口回调去读取数据时。明白了依赖收集的时机你就会知道把读取数据的操作放在effect函数同步执行区里或者用watch来管理这类异步依赖。源码层面Vue提供了watchEffect来简化这类场景它就是自动追踪同步执行的副作用异步阶段读取不会被追踪。4.4 修改了对象但不重新赋值视图为什么不更新有一种典型场景const state reactive({ user: { name: 张三 } }) state.user { name: 李四 } // 这是新增了一个新对象并整体替换视图会更新 state.user.name 王五 // 这是修改原对象属性视图也会更新两种都能更新。真正不更新的是Vue 2里的历史遗留场景——给reactive对象强行新增一个顶层不存在的key比如state.age 30。Vue 3里这个是支持的因为Proxy会拦截到set。但如果你存储这个对象的引用在它被替换后再通过旧引用修改属性那确实不会触发视图更新因为你的视图已经绑定到新对象上了。这类问题的排查思路很简单打开Vue Devtools看视图依赖的数据节点确认当前绑定的是哪个对象引用。我见过太多人对着没更新的视图困惑很久最后发现是对象被整体替换自己却还在旧引用上做文章。5. Vue vs React原理差异引发的开发体验差异这个话题基本是面试必问也是理解Vue原理后自然浮现出的对比。Vue和React最本质的区别在于更新粒度Vue是“数据驱动视图”依赖收集精确到组件内每个响应式属性React是“状态驱动视图”状态变了整个组件函数重跑再做协调。5.1 响应式模型 vs 不可变数据模型Vue的可变响应式数据意味着你可以直接修改对象的属性框架帮你精确知道哪个组件用到了这个属性。React强调不可变数据每次状态更新都要生成一个新对象框架比较前后差异后决定渲染结果。Vue的更新路径短数据变到视图变化的链路可控性强。React的Fiber架构解决的是“长时间渲染阻塞主线程”的问题它把渲染拆分成可中断的任务块。Vue 3没有采用Fiber因为它的细粒度依赖追踪天然把更新范围控制得更小大部分场景下不需要中断渲染这种重型武器。这不是谁优谁劣而是选型哲学不同。5.2 模板编译优化 vs JSX的灵活性Vue的模板语法是受限但可优化的编译器可以在编译期做静态标记、缓存事件处理函数、跳过不会变化的子树。React的JSX本质上就是用JavaScript表达UI灵活度更高可以在render里写任意逻辑但代价是运行时需要做更多的工作才能判断哪里变了。对中后台系统、表单密集型的项目Vue模板的自动优化能带来不小的收益。对复杂交互、需要强类型推导和灵活组合的组件体系React的生态和心智模型也有自己的优势。选择取决于团队熟悉度和项目类型而不是“谁更先进”。5.3 双向绑定 vs 单向数据流v-model的不同实现路径Vue的v-model是语法糖编译后其实是modelValue加onUpdate:modelValue的组合。React没有这个语法糖你得手动写value和onChange。Vue的双向绑定在表单场景写得很快但复杂表单的状态管理容易失控React的单向数据流在复杂场景下更可控但代码量明显更多。Vue 3里v-model还能用在组件上自定义modelValue和update:modelValue的命名一个组件可以定义多个v-model分别对应不同的属性。这个机制理解透了对封装组件库特别有帮助比如封装一个既需要值又需要开关状态的组件多个v-model就让调用方代码非常清爽。6. 高频面试题背后的原理串讲分享的最后一部分我把搜索结果里出现频率最高的几个面试题串起来讲一下。这些题目看答案容易但你没理解原理面试官稍微追问两句就露馅了。6.1 为什么data必须是一个函数这个问题的答案是组件是可复用的如果data是对象所有组件实例共享同一个数据引用改一个全变。data是函数每个实例执行一遍返回全新的对象各自独立。原理在于JavaScript的对象是引用赋值而不是拷贝赋值。Vue在初始化时会执行data.call(vm)拿到每个实例自己的数据对象。注意根实例的data可以是一个对象因为它不会被复用。但组件选项里的data必须用函数这是Vue对组件约束的体现。如果你不遵守新版Vue直接会在控制台给你警告。6.2 computed和watch的区别到底是什么目标是同一个问题但手段完全不同。computed依赖的是响应式计算它有缓存、有懒执行适合“根据已有数据派生出一个新值”的场景。watch是观察模式它监听指定数据源数据变化时执行副作用函数适合“数据变化后需要发起异步操作、调用接口、手动操作DOM”这类不纯场景。很多新手会问我能不能用watch计算一个派生值能但你会丢掉缓存的优势并且代码会变得啰嗦。更严重的问题是在watch里修改同一份被观察数据可能把自己带入死循环。Vue会检测watch回调里对监听数据的二次修改并给出警告这个警告背后的机制其实还是trigger和effect之间的循环检测。6.3 Vue 3的编译优化到底优化了什么传统虚拟DOM的更新总是要做整棵树的diffVue 3通过编译器的静态分析把动态部分用PatchFlags标记出来。更新时只更新被标记的动态节点跳过静态子树。这个概念被称作“靶向更新”。还有一个优化是Block Tree。编译器把模板的结构拆成一个个block动态节点都挂在所属block的直接children上。更新时直接按索引比对block内的动态节点不需要再递归整棵子树。这也是为什么Vue 3的模板性能上限会高于Vue 2。对于手写render函数或者JSX这些优化就享受不到了因为编译器看不到你的静态结构。这是框架取舍的一部分——灵活性和优化空间往往不可兼得。6.4 v-for和v-if为什么不能在同一层级使用v-for的优先级高于v-if。Vue 3里同时使用两者编译结果会变成外层循环内层判断每一项是否满足条件。这个写法最大的问题是v-if根本没能阻止v-for的循环执行数据量大的时候白白跑了一遍循环再判断每一项要不要渲染。如果有人想通过v-if提前终止循环那是不可能的因为v-for已经跑了。推荐的做法是用计算属性先过滤列表再v-for遍历过滤后的结果或者把v-if挪到子组件里让条件判断发生在组件内部。理解这个原理面试时你就可以直接说清楚为什么不能这样写而不只是背一条规矩。6.5 自定义v-model和原生v-model的实现差别原生的v-model在不同表单元素上编译出来的内容不一样。比如input的text类型编译成value加input事件checkbox编译成checked加change事件。这些都是编译器在背后做的差异化处理。组件上的v-model是父组件绑定了modelValue和onUpdate:modelValue子组件通过props接收value并通过emit提交update事件。理解了这条链路你就能自写一个标准的v-model组件。defineProps([modelValue]) const emit defineEmits([update:modelValue]) function onChange(e) { emit(update:modelValue, e.target.value) }原理连起来看v-model根本不是“魔法”只是一层约定俗成的语法糖协议。7. 复盘与扩展我怎么用这套原理反哺日常开发到这里整套Vue原理的主干就算串完了。从响应式到渲染再到编译优化最后到面试题背后的机制我建议你顺着这条线再做两件事第一打开Vue 3的源码找到reactivity包的reactive.ts和effect.ts把今天说的核心逻辑对应到真实代码上第二自己用原生Proxy手写一个包含reactive、ref、effect、computed的最小实现并且在浏览器里跑起来。写代码时我给你留一个小挑战——给effect增加调度器选项然后在computed里利用调度器实现真正的缓存更新逻辑。这一步做完你对响应式系统的理解会比看十篇源码分析文章都深。我自己的体会是学原理最有价值的不是背诵概念应付面试而是回头看自己在项目中写过的每一行Vue代码都能解释清楚它背后发生了什么。比如性能优化时你知道什么情况下用v-memo能减少diff开销排查BUG时你知道该去看依赖收集还是派发更新而不是漫无目的地console.log。最后分享一个扩展方向既然你理解了响应式核心完全可以跳出Vue框架用同样的思想写一个极简的状态管理库或者给非Vue项目做数据绑定。响应式设计本身是一种通用的编程思想不局限于某一个框架。当你有了这个意识Vue原理就不再是单纯的知识点而是你构建更复杂系统时的一块稳固基石了。