资讯动态

Vue响应式原理与MVVM架构:从依赖收集到视图更新的完整链路

发布时间:2026/10/14 6:13:04 来源:尧图企业网站定制
1. 先从架构说起MVVM在Vue里的真实落地点很多前端同行第一次接触MVVM这个词是在 Vue 的官方文档里。但我得先泼一盆冷水Vue 并不是教科书意义上的 MVVM 完整实现它只是借用了这个架构模式的核心思想。理解这一点我们后面聊的所有小细节才有地基。MVVM 三个字母分别指 Model、View、ViewModel。Model 是数据层View 是视图层ViewModel 是连接两者的桥梁。理论上ViewModel 要负责把 Model 的状态映射到 View同时把 View 上的交互事件转换成对 Model 的更新操作。这个桥梁是不是足够薄、足够通用直接决定了架构的优雅程度。Vue 的落地方案有点特别它没有一个独立暴露出来的 ViewModel 类而是把响应式数据 模板编译器 虚拟DOM渲染器这三个东西打包在一起当你在 new Vue({...}) 或 createApp({...}) 里写下一堆配置时那个实例本身就充当了 ViewModel 的角色。更准确地说Vue 实例是 ViewModel 的壳壳里面装的是响应式系统Model 的代理和渲染系统View 的生成器。有个小细节值得注意在早期的 Vue 版本里曾经有过 vm.$data、vm.$options 这种把配置拆开的访问方式后来逐步收敛成 vm 本身直接代理 $data 里的属性。你现在写this.name能拿到数据不是因为 name 从 Model 层复制到了 ViewModel而是因为 Vue 在初始化时用Object.defineProperty或 Proxy在 vm 实例上做了一层转发代理。转发才是理解 MVVM 的关键词数据没有搬家只是换了个门牌号。1.1 ViewModel 在 Vue 里其实是协调者如果非要在 Vue 的源码里找一个最接近 ViewModel 语义的东西我倾向于认为是组件的渲染 watcher。每个组件实例在挂载时会创建一个渲染 watcher它负责两件事读取模板中用到的所有响应式数据然后触发一次渲染。正是这个渲染 watcher 把 Model 和 View 绑在了一起。所有组件内的计算属性、侦听器、渲染函数本质上都是这种协调者的不同形态渲染 watcher页面长什么样由它协调computed watcher派生数据怎么算由它协调user watcher数据变化后执行什么回调由它协调this.$nextTick之所以存在也和协调者有关——同一个事件循环里多次改数据渲染 watcher 只排队执行一次避免频繁重复渲染。这就是 MVVM 思想在工程层面的具体化不是数据结构一种而是数据变了需要通知谁这件事被统一管理了。1.2 一个最小例子看懂三个角色template div p{{ message }}/p input v-modelmessage / /div /template script export default { data() { return { message: hello } } } /script这个看似简单的例子里面已经完整出现了 MVVM 的三个角色Modeldata()返回的message字段纯数据不关心 DOMViewtemplate 里的p标签和input框纯界面ViewModel组件实例内部的响应式代理 v-model 指令的监听逻辑input 每输入一个字符就触发 message 的 settersetter 再通知渲染 watcher渲染 watcher 再去更新p标签的文本内容这就是为什么一个资深的 Vue 面试官特别爱问v-model 是语法糖吗——因为答案里的:valueinput正是在描述 ViewModel 如何手动连接 View 和 Model。理解到这个层面就不容易把 v-model 理解成什么黑魔法了。2. 响应式系统的细颗粒度文档没写清楚的执行细节Vue 的响应式系统是整个框架的心脏也是 MVVM 里数据驱动视图最核心的引擎。很多开发者用了一两年 Vue遇到数据变了页面没变这类问题还是需要靠猜。这里我把几个最容易踩坑的执行细节拆开讲。2.1 依赖收集究竟发生在哪一步响应式系统的工作流可以概括成四个字收集-通知。但收集到底发生在什么时候很多文档讲得含糊。实际上依赖收集发生在读取响应式属性值的时候。Vue 2 是getter里做的Vue 3 是Proxy的get拦截器里做的。关键在于只有当全局存在一个当前正在计算的 watcher时收集才有意义。流程是这样的组件渲染时渲染 watcher 被标记为当前活跃 watcher渲染函数开始执行模板里的{{ message }}触发了对message的 gettergetter 里检查到存在活跃 watcher就把这个 watcher 登记进message的依赖列表渲染函数执行完毕活跃标记清除之后每次message的 setter 触发就遍历依赖列表逐个通知 watcher 去更新。哪个属性被模板读了它才需要建立依赖没被读过的属性改了也不会触发重渲染。这就是为什么v-if里没用到的属性不会被收集也是为什么Object.freeze()冻结的数据数组改动不会触发更新——因为被冻结的对象无法定义响应式 getter/setter依赖收集体系根本无法建立。2.2 Vue 2 的数组拦截一个无奈但务实的设计Vue 2 最大的响应式局限是数组。它没有用defineProperty去劫持数组的每个索引而是重写了数组原型上 7 个会改变自身的方法push、pop、shift、unshift、splice、sort、reverse。为什么不能像对象一样对索引做劫持性能是主因。一个 10 万项的数组如果每项都包一层 getter/setter初始化开销和内存占用都非常难看。而且实际业务里我们很少像对象访问属性那样频繁读数组的指定下标大多数场景是遍历。但这就带来一个让无数人困惑的坑const vm new Vue({ data() { return { items: [1, 2, 3] } } }) // 这种赋值不会触发更新 vm.items[0] 4 // 这种增加长度也不会触发更新 vm.items.length 0原因就是items[0] 4直接绕过被重写的数组方法Vue 无法感知变化。正确的写法是vm.$set(vm.items, 0, 4)或用splice。Vue 3 改用 Proxy 后这个限制天然消失可以精确拦截索引赋值但它对Map、Set等新的集合类型又引入了额外的响应式视野问题——你的size属性、get方法返回值都需要单独做处理。2.3 $set 到底做了什么$set这个 API 的存在恰恰暴露了响应式系统必须提前存在的约束。Vue 2 场景下调用vm.$set(target, key, value)如果 target 是数组就调用target.splice(key, 1, value)因为 splice 是被拦截过的如果 target 是对象且 key 已经存在并是响应式的则直接target[key] value因为已经有了 getter/setter改了能触发如果 target 是对象但 key 不存在则调用defineReactive给新 key 手动创建响应式绑定再触发一次更新第三步是最核心的。Vue 无法提前给一个尚未出现的 key 做响应式绑定所以必须在运行时实地补上。这也是需要记住的一个原则响应式系统本质上是先声明、后使用的模型动态添加的属性不在体系内。Vue 3 里用reactive()创建的是 Proxy 代理新增属性天然能被 get/set 拦截所以$set被移除了。但要注意ref的.value赋值语义以及如果用Object.assign(reactiveObj, newProps)这种批量赋值方式虽然能触发但在某些场景下还是推荐用解构替换的方式避免整个对象被替换引发多余渲染。2.4 对象的递归劫持与性能代价Vue 2 里data()返回的对象会把所有属性深度遍历一遍每个嵌套对象都调用observe方法递归设置 getter/setter。这个递归发生在初始化阶段意味着对象层级越深初始化越慢。而 Vue 3 的 Proxy 是懒代理取值时才判断该值是否需要继续代理性能分布均匀多了。这个小细节直接反映在工程感受上用 Vue 2 开发时把大对象塞进 data 后页面首次加载明显卡顿用 Vue 3 后要好很多。如果你还在维护 Vue 2 项目遇到了大对象初始化慢的问题可以考虑让纯展示用的大数据对象不做响应式化用Object.freeze冻结掉或者放到created钩子之后再挂到this上避免 data() 返回时的初始化递归。2.5 computed 的缓存是怎么来的computed 是 MVVM 里派生状态的典型实现。它的缓存逻辑很多新手看不懂为什么 computed 不是每次读取都重新计算关键在于 computed 内部维护了两个标志位dirty和依赖收集列表。首次读取 computed 时会计算真实值并把结果缓存下来同时收集它读过的响应式依赖。当被依赖的数据变化时不是重新计算而是先把 dirty 置为 true真正等到下一次读取 computed 时才重新计算。这个延迟计算的设计非常聪明如果渲染阶段根本没用这个 computed数据变了也不会有无谓的计算开销。相比之下methods 每次渲染都会执行没有缓存性能特性完全不同。所以能用 computed 的地方不要用 methods这是数据结构层面就能做的优化。3. 模板编译与虚拟DOM从源码字符串到页面元素中间发生了什么Vue 的模板看着像 HTML但它不是被浏览器直接解析的。模板在运行时经历了模板字符串 - 编译成 render 函数 - 执行 render 函数生成虚拟DOM - patchDiff 到真实DOM。这条链路里的细节决定了你对key、v-if/v-for优先级、插槽机制等问题的理解深度。3.1 模板编译的产物是一段可执行的 JavaScript很多人以为模板到最后是通过 innerHTML 插入的这是误解。Vue 模板会被编译成一个 render 函数它的返回值是虚拟DOM描述对象。// 模板p classtitle{{ message }}/p // 编译产物大致长这样 function render() { return createVNode(p, { class: title }, this.message) }这段函数能拿到当前实例的this.message是因为响应式系统在 render 执行期间把 message 收集成了依赖。之后 message 一变render 重新执行得到一个新的虚拟DOM树。模板里的指令如 v-if、v-for、v-model在编译阶段都会变成对应代码v-if变成条件判断语句v-for变成循环语句v-model变成value赋值与onChange事件监听的组合{{ }}变成文本子节点的解析Vue 3 编译时会创建toDisplayString调用这意味着所有模板表达式都是在编译阶段被解析为 JavaScript 表达式的而不是运行时的某种字符串求值。3.2 虚拟DOM比的是什么虚拟DOM对象通常长这样{ type: div, props: { id: app }, children: [ ... ] }patch 算法在更新时首先对比新旧两个虚拟节点的 type 是否一致。如果 type 不同直接整块替换type 相同则对比 props、key、children。这个先比结构再比属性的策略减少了真实DOM操作但并不能做到零操作——它减少的是创建/销毁节点的次数而不是更新属性的次数。一个常见的误区是虚拟DOM一定比直接操作DOM快。比较结论要看场景小规模高频更新直接手动操作 DOM 可能更快但面对复杂状态驱动的界面虚拟DOM提供了可预测的、声明式的更新路径收益在可维护性和复杂状态同步上而不在绝对性能上。3.3 key 到底修复了什么v-for 里要求写 key几乎所有文档都只说提升性能。但更本质的原因是key 让虚拟DOM能准确识别同一个节点。没有 key 时Vue 依据索引进行同位置复用这意味着当你把第一项从列表中删掉时原本第二项的内容会先被绑定到第一项的 DOM 上再去更新它——整个过程看起来复用了很多 DOM代价是会额外执行一系列属性更新甚至可能把输入框里用户输入的内容错误地保留下来。有 key 后Vue 可以识别出第二项其实还是同一身份的节点直接移动而非重建。key 的价值主要体现在状态一致性上性能只是副产品。关于 key 的选择也有讲究不要用数组下标作为 key更不要用随机数。key 应该是业务上唯一且稳定的标识否则每次渲染 key 都变等于没有 key。3.4 编译时的静态标记Vue 3 提速的秘密Vue 3 的编译器引入了一个概念叫静态提升与Block tree模板中不变的静态节点会被提升到 render 函数外部只创建一次动态部分会打上标记patchFlag告诉运行时这个节点的这类属性需要更新其它属性直接复用这意味着 Vue 3 的 diff 不再是一棵树的整体遍历而是直接跳过标记为静态的部分只去 patch 有动态标记的节点。这是为什么同样规模的项目Vue 3 在运行时开销上明显优于 Vue 2 的原因之一。这个细节离业务其实并不远如果你经常写静态内容与动态内容混排的长列表编译优化带来的收益是非常直接的。4. 异步更新与生命周期数据变了DOM 为什么没立即变这是每个 Vue 开发者迟早会遇到的问题改完数据后马上拿 DOM拿到的还是旧值。这不是 Bug而是框架刻意的设计。4.1 渲染 watcher 的队列机制数据变更时setter 触发依赖通知渲染 watcher 被推入一个队列。Vue 不会立刻执行队列而是把所有变更都收集完成后在下一个 tick微任务统一刷新。this.message a this.message b this.message c // DOM 只会被更新一次值为 c如果改一次数据就同步渲染一次那连续改三次就触发了三次完整 diff多数是浪费。先把所有变更都累积好再一次性渲染代价更小。这也是 MVVM 里状态变化到视图更新之间有一个调度层的原因。4.2 nextTick 的微观机制要在数据修改后立刻拿到最新 DOM就得用this.$nextTick(callback)或 Vue 3 里的nextTick()。它的实现核心是把传入的回调包装成微任务优先Promise.resolve().then排在渲染 watcher 的更新队列之后执行——于是回调执行时DOM 已经是最新了。这里有个实际经验nextTick 只保证DOM 更新完成不保证子组件也全部 ready。如果你需要在子组件完全挂载后拿到它的数据用生命周期mounted更可靠或者用带watch监听相应派生的方式。4.3 父子组件更新顺序父子组件的渲染更新顺序遵循父先更新子后更新在更新阶段挂载阶段则是子先挂载父后挂载。这个差异很多新手容易混。创建/挂载父 created - 子 created - 子 mounted - 父 mounted更新父 beforeUpdate - 子 beforeUpdate - 子 updated - 父 updated原因是父组件的 render 函数执行时会生成子组件的虚拟节点随后才触发子组件的创建或更新流程。理解这个顺序你就能解释为什么在父组件 mounted 里this.$refs.child.xxx能拿到子组件实例因为子已挂载完成也能解释某些场景下你在父级 beforeUpdate 里给子组件传新 prop子组件的数据还没同步——因为它的 render 还没跑。5. 手写一个迷你响应式系统把 MVVM 的链路彻底串通我总是建议想真正理解 MVVM 的人亲手写一个最小实现。不用完整复刻 Vue只要把数据变了视图自动更新这五个字做出来你对整套架构的理解就完全不一样了。先定义三个角色// 1. Dep依赖收集器每个响应式属性都有一个 Dep class Dep { constructor() { this.subscribers new Set() } depend() { if (currentWatcher) { this.subscribers.add(currentWatcher) } } notify() { this.subscribers.forEach(watcher watcher.update()) } } // 2. 全局当前 watcher 标记 let currentWatcher null class Watcher { constructor(effect) { this.effect effect this.value this.get() } get() { currentWatcher this const value this.effect() currentWatcher null return value } update() { // 实际工程里这里要排队这里先直接执行 this.value this.get() } } // 3. 响应式化使用 ProxyVue 3 思路 function reactive(target) { const depMap new WeakMap() return new Proxy(target, { get(obj, key) { let dep depMap.get(obj) if (!dep) { dep new Dep() depMap.set(obj, dep) } dep.depend() return Reflect.get(obj, key) }, set(obj, key, val) { const result Reflect.set(obj, key, val) const dep depMap.get(obj) if (dep) dep.notify() return result } }) }然后只需要绑定一个渲染 watcherconst state reactive({ count: 1 }) new Watcher(() { document.getElementById(app).textContent state.count })这三段代码已经完整包含了响应式的全部核心链路读取时收集依赖修改时通知 watcherwatcher 的副作用里读取了哪些属性你再次修改就会自动触发。5.1 与真实 Vue 实现的差异识别这个迷你实现距离 Vue 还有很远但差异点反而能帮助理解Vue 的 watcher 更新是有队列和去重的不会每次 notify 都直接执行避免同一轮循环多次变更造成重复渲染Vue 的响应式支持嵌套实际工程里对象层级往往比较复杂Vue 有完整的生命周期和错误处理watcher 执行时抛错不会炸掉整个应用Vue 的 render 不是 document 操作而是虚拟DOM的创建与 diff顺着这些差异去看真实源码你会发现其实框架没有神秘的部分只是在工程级完善上做了大量细节。6. 实战视角排查思路、性能着眼点和 MVVM 的边界很多细节只有在实战踩坑时才有体感。这一节我把最常见的场景、排查链路和处理建议一并给出。6.1 遇到数据变了页面没更新时的排查顺序如果维护 Vue 项目时遇到这个经典问题我的排查思路是固定的先确认数据是不是响应式的是否在 data() 里声明是不是通过$set或reactive之外的方式动态添加的新属性再确认依赖是否被收集模板/渲染中是否真的读取了这个数据有没有可能在 v-if 为 false 的分支里看更新队列是否被阻塞有没有在 watcher 或计算属性里死循环有没有手动调用$nextTick但回调里读的还是旧值检查是否有覆盖Object.assign或ref newObj这种方式是否把整个响应式对象替换掉了替换后原始引用关系还在吗最后检查 DOM 和异步是不是在异步回调里改了数据但回调本身没有正确绑定 this 上下文按这个顺序排查绝大多数问题都能定位到根因而不是靠瞎试。6.2 与响应式相关的性能注意点尽量把数据拆分细字段粒度越小依赖范围越小无谓触发越少能用 computed 就少用 watchcomputed 有缓存watch 是副作用v-for配合key要稳定唯一别用 index列表项太深太复杂时考虑拆分组件让 Vue 能更精准地定位更新范围Vue 3 里如果对象不需要响应式比如静态配置用markRaw标记避免代理额外开销大数据列表可以用函数式组件或虚拟滚动方案不要无脑全量渲染6.3 MVVM 的边界和 Vue 3 带来的演化MVVM 也不是万能的。它的 ViewModel 层为了让视图自动更新引入了响应式追踪带来了学习成本和调试成本追求完全数据驱动时很容易把业务逻辑堆到 computed 和 watch 里反而难以维护。Vue 3 引入的 Composition API组合式 API本质上是对 ViewModel 层的一种重构不再是按选项拆分代码而是按业务逻辑组织代码。它没有改变 MVVM 的大结构但让你在构建复杂组件时可以像搭积木一样把一小块响应式数据 一小段副作用组合成一个可复用的 hook。所以我的态度是MVVM 是一个帮助理解 Vue 的入门地图但真正到了大型项目中更重要的是响应式原理、更新机制和组件设计。这三样吃透了架构层面的取舍会轻松很多。

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

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

免费获取报价 →
↑