行业资讯

Vue 2 核心响应式与渲染原理全景拆解

发布时间:2026/8/5 19:05:07
Vue 2 核心响应式与渲染原理全景拆解 Vue 2 核心响应式与渲染原理全景拆解本文聚焦于Vue 2的底层架构设计梳理从数据劫持、依赖收集、异步队列更新到生命周期衔接与this.$nextTick机制的全流程。文章目录Vue 2 核心响应式与渲染原理全景拆解1. 响应式基石Object.defineProperty 与 Getter / SetterGetter 与 Setter 的职责划分为什么组件中的 data 必须是一个函数特殊场景补充数组响应式与 $set2. 依赖收集核心Dep 响应式通讯录简化版的实现模型3. 视图异步更新机制Async Queue核心步骤与去重逻辑4. Watcher 说明什么是 Watcher每一个属性都有 Watcher 吗Vue 2 中一共有哪几类 WatcherWatcher 的全局唯一 idid 在异步队列去重中的运用5. this.$nextTick 的底层实现与 FIFO 顺序并没有“反复横跳”完整运行流6. 生命周期的完整衔接1. 响应式基石Object.defineProperty与 Getter / SetterVue 2 在初始化时利用Object.defineProperty将data中定义的普通属性改写为具有拦截能力的访问器属性Accessor Properties。Getter 与 Setter 的职责划分getter取值拦截器在读取数据属性如模板渲染或计算属性取值时触发。核心任务触发依赖收集Dep.depend()记录当前是哪个Watcher在读取该数据。setter赋值拦截器在给数据重新赋值如this.count 2时触发。核心任务触发派发更新Dep.notify()通知所有依赖该属性的Watcher数据发生了改变。为什么组件中的data必须是一个函数在 Vue 组件定义中data必须写成返回对象的函数// 正确写法data(){return{count:0}}根本原因引用隔离组件本质上是一个可复用的 VueClass 实例。如果data是一个纯粹的对象多个组件实例就会共享同一块内存地址。修改其中一个组件的data会直接污染其他实例。Vue 的处理机制每一个组件在实例化时Vue 会执行data()函数生成一份全新的独立对象然后再对其进行响应式劫持。使用函数能够返回全新独立对象的根本原因在于函数的闭包/作用域机制结合对象的字面量创建使得每次函数运行都会在内存堆中申请一块独立的新空间。这相当于工厂的流水线模具——函数是“模具”每次调用函数就是“压制一次”生产出来的都是物理上互相独立的新产品。特殊场景补充数组响应式与$set数组的重写拦截出于性能开销考虑Vue 2 没有对数组的每一个索引去定义getter/setter。而是拦截重写了数组的 7 个变异原型方法push,pop,shift,unshift,splice,sort,reverse。在调用这些方法时除了执行原生操作还会手动调用数组本身持有的dep.notify()派发更新。新增/删除属性与this.$set由于Object.defineProperty无法侦测动态添加或删除的属性Vue 2 提供了this.$set(target, key, value)其底层会调用defineReactive补建响应式拦截并显式触发target.__ob__.dep.notify()通知视图更新。2. 依赖收集核心Dep响应式通讯录DepDependence在 Vue 2 中不是一个单纯的函数而是一个类Class。它的主要作用是作为依赖管理器连接响应式属性与视图观察者Watcher。每个响应式属性都有一个专属的Dep实例保存在defineReactive的闭包作用域中。核心结构与方法subs数组存储所有依赖当前属性的Watcher实例。depend()在getter中调用将当前的Watcher加进subs列表。notify()在setter中调用遍历subs数组依次调用每个Watcher.update()。概括来说data里每一个嵌套对象的每一个属性在底层都有自己独立的一套getter、setter以及存放在闭包里的Dep实例负责精细化控制属于它自己的update通知。简化版的实现模型// 全局属性用于临时指向正在挂载/运行的 WatcherDep.targetnull;classDep{constructor(){this.subs[];// 订阅者列表 (Watcher 集合)}// 依赖收集depend(){if(Dep.target){this.subs.push(Dep.target);// 双向收集Watcher 内部也会记录当前的 Dep 实例Dep.target.addDep(this);}}// 派发更新notify(){// 拷贝一份订阅者数组进行遍历constsubsthis.subs.slice();for(leti0;isubs.length;i){subs[i].update();}}}// 属性数据劫持functiondefineReactive(obj,key,val){constdepnewDep();// 为每一个属性在闭包中绑定一个独立的 Dep 实例Object.defineProperty(obj,key,{enumerable:true,configurable:true,get(){if(Dep.target){dep.depend();// getter 触发收集依赖}returnval;},set(newVal){if(newValval)return;valnewVal;dep.notify();// setter 触发派发更新}});}3. 视图异步更新机制Async Queue当你连续修改多个数据属性时如this.a 1; this.b 2;Vue 2绝不会立即去重新渲染页面而是将更新推入一个异步更新队列进行批量处理。核心步骤与去重逻辑去重入队queueWatcher数据改动触发setter→ \to→调用dep.notify()→ \to→触发watcher.update()。Watcher收到更新通知后先通过专属的id检查自己是否已在队列中。如果在则忽略如果不在则推入全局的queue数组。合并触发即便在单次事件循环中修改了 100 个变量由于同一个组件对应的是同一个渲染 Watcher更新队列里始终只有这个 Watcher 的单一记录。在微任务中清空队列Vue 内部调用nextTick(flushSchedulerQueue)注册一个微任务。待主线程同步代码全部执行完毕后事件循环进入微任务阶段批量遍历queue只执行一次Render 与 Diff 过程。constqueue[];lethas{};letwaitingfalse;functionqueueWatcher(watcher){constidwatcher.id;if(has[id]null){has[id]true;queue.push(watcher);// 存入 Watcherif(!waiting){waitingtrue;// 在当前宏任务的同步代码结束后由微任务统一刷新队列nextTick(flushSchedulerQueue);}}}functionflushSchedulerQueue(){for(leti0;iqueue.length;i){constwatcherqueue[i];has[watcher.id]null;watcher.run();// 真正开始重新渲染与 DOM 挂载}queue.length0;waitingfalse;}4.Watcher说明什么是WatcherWatcher观察者 / 订阅者在 Vue 2 中是一个类Class。如果说Dep是“通讯录/发布者”那么Watcher就是真正干活的“订阅者/执行者”。当数据发生改变Dep.notify()喊了一声“数据变了”收到通知并真正去跑代码计算、去更新 DOM 节点的就是Watcher。每一个属性都有Watcher吗答案是没有绝不是每个属性都有 Watcher。Dep是“属性级别”的每一个属性在闭包中都有一个专属的Dep实例1个属性 1个 Dep。Watcher是“表达式/组件级别”的Watcher只有在需要监听变化并执行回调/渲染的地方才会创建。Vue 2 中一共有哪几类Watcher在 Vue 2 组件运行期间一共只有以下3 种Watcher渲染 WatcherRender Watcher数量每个组件实例只有一个。作用负责整个组件视图的重新渲染执行vm._update(vm._render())。属性与 Watcher 的关系假设模板里用了 10 个数据属性a,b,c…这 10 个属性对应的 10 个Dep里存的都是同一个渲染 Watcher。用户 WatcherUser Watcher /watch选项数量你写了几个watch属性就有几个。作用执行你在watch: { foo(val) { ... } }中写的自定义回调函数。计算属性 WatcherComputed Watcher数量你写了几个computed就有几个。作用内部维护一个dirty标志位用来实现计算属性的缓存机制。Watcher 的全局唯一id每一个Watcher实例都有一个全局唯一的idid是实现“异步更新队列去重防抖”的核心关键在 Vue 2 内部有一个自增的全局计数器letuid0;// 全局自增 ID 计数器classWatcher{constructor(vm,expOrFn,cb,options){this.iduid;// 每一个 Watcher 诞生时都会拿到一个递增且唯一的 idthis.deps[];this.depIdsnewSet();// ...}addDep(dep){if(!this.depIds.has(dep.id)){this.depIds.add(dep.id);this.deps.push(dep);// 双向收集Watcher 记录属性的 Dep用于组件销毁和清除无效依赖}}}id在异步队列去重中的运用当你写了如下代码this.firstName张;this.lastName三;firstName改变触发它的Dep通知渲染 Watcher假设id: 1。Vue 执行queueWatcher(watcher)if(has[watcher.id]null){// 检查 has[1] 是否存在has[watcher.id]true;// 标记 has[1] truequeue.push(watcher);// 把 id: 1 的渲染 Watcher 推入队列}lastName改变触发它的Dep通知同一个渲染 Watcherid: 1。Vue 再次执行queueWatcher(watcher)if(has[1]null){...}// 发现 has[1] 已经是 true 了直接跳过正是因为每个Watcher有唯一的idVue 才能用极其高效的O(1)时间复杂度过滤掉重复的更新通知。5.this.$nextTick的底层实现与 FIFO 顺序this.$nextTick(cb)能够准确拿到最新 DOM 的核心原因在于微任务队列的“先来后到FIFO先进先出”执行顺序。并没有“反复横跳”在底层Vue 维护了一个统一的回调函数队列callbacksconstcallbacks[];letpendingfalse;functionnextTick(cb){callbacks.push(cb);// 入队if(!pending){pendingtrue;// 开启锁定保证当前 Tick 内只开启一次微任务注册// 使用微任务 API如 Promise.then在主线程空闲时派发Promise.resolve().then(flushCallbacks);}}functionflushCallbacks(){pendingfalse;// 解锁constcopiescallbacks.slice(0);callbacks.length0;for(leti0;icopies.length;i){copies[i]();}}完整运行流假设有如下代码this.messageHello World;// ① 修改数据this.$nextTick((){// ② 手动挂载回调console.log(this.$el.textContent);});执行 ① 时触发setterVue 内部自动调用nextTick(flushSchedulerQueue)。此时callbacks队列为[ flushSchedulerQueue ]。执行 ② 时手动调用$nextTick你的回调函数被推入队列。此时callbacks队列为[ flushSchedulerQueue, userCallback ]。主线程同步代码结束开启微任务执行callbacks[0]flushSchedulerQueueVue 重新渲染并把新节点直接同步写到了内存 DOM 节点上。执行callbacks[1]userCallback在此回调里读取 DOM由于前一步内存 DOM 已经被更新因此可以直接读取到最新结果。6. 生命周期的完整衔接响应式系统与异步更新机制并非孤立运行而是贯穿在 Vue 组件的各个生命周期钩子中[ new Vue() 实例创建 ] │ ▼ beforeCreate 钩子 │ ┌─────────┴─────────┐ │ 初始化 Inject/ │ │ Data/Props/Method │ -- 针对 data 递归调用 defineReactive │ 转换 Getter/Setter │ 挂载对应的 Dep 实例 └─────────┬─────────┘ ▼ created 钩子 -- 此时数据已具备响应式但还没挂载 DOM │ beforeMount 钩子 │ ┌─────────┴─────────┐ │ 创建 Render Watcher│ -- 首次执行 Render触发属性的 getter │ 并执行首次渲染过程 │ 把 Render Watcher 收集进 Dep 的 subs 数组中 └─────────┬─────────┘ ▼ mounted 钩子 -- 真实 DOM 首次渲染完成 │ ┌─────────┴─────────┐ │ 数据改变 (Setter)│ -- 触发 dep.notify()将 Watcher 推入异步更新队列 (queueWatcher) └─────────┬─────────┘ ▼ beforeUpdate 钩子 -- 异步队列准备刷新视图前触发 │ ┌─────────┴─────────┐ │ 执行 Watcher.run() │ -- 生成新 VNode通过 Diff 算法对比映射并更新真实 DOM └─────────┬─────────┘ ▼ updated 钩子 -- 视图更新完成 │ beforeDestroy 钩子 -- 销毁前移除监听器、子组件及 Watcher │ destroyed 钩子 感谢阅读想了解更多 我的博客网站 | 记录思考分享干货 我的个人主页 | 关于我、开源项目