Vue 2 核心响应式与渲染原理全景拆解
本文聚焦于Vue 2的底层架构设计,梳理从数据劫持、依赖收集、异步队列更新,到生命周期衔接与
this.$nextTick机制的全流程。
文章目录
- Vue 2 核心响应式与渲染原理全景拆解
- 1. 响应式基石:`Object.defineProperty` 与 Getter / Setter
- Getter 与 Setter 的职责划分
- 为什么组件中的 `data` 必须是一个函数?
- 特殊场景补充:数组响应式与 `$set`
- 2. 依赖收集核心:`Dep` 响应式通讯录
- 简化版的实现模型
- 3. 视图异步更新机制(Async Queue)
- 核心步骤与去重逻辑
- 4. `Watcher` 说明
- 什么是 `Watcher`?
- 每一个属性都有 `Watcher` 吗?
- Vue 2 中一共有哪几类 `Watcher`?
- Watcher 的全局唯一 `id`
- `id` 在异步队列去重中的运用
- 5. `this.$nextTick` 的底层实现与 FIFO 顺序
- 并没有“反复横跳”
- 完整运行流
- 6. 生命周期的完整衔接
1. 响应式基石:Object.defineProperty与 Getter / Setter
Vue 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响应式通讯录
Dep(Dependence)在 Vue 2 中不是一个单纯的函数,而是一个类(Class)。它的主要作用是作为依赖管理器,连接响应式属性与视图观察者(Watcher)。
- 每个响应式属性都有一个专属的
Dep实例(保存在defineReactive的闭包作用域中)。 - 核心结构与方法:
subs数组:存储所有依赖当前属性的Watcher实例。depend():在getter中调用,将当前的Watcher加进subs列表。notify():在setter中调用,遍历subs数组,依次调用每个Watcher.update()。
概括来说:
data里每一个嵌套对象的每一个属性,在底层都有自己独立的一套getter、setter以及存放在闭包里的Dep实例,负责精细化控制属于它自己的update通知。
简化版的实现模型
// 全局属性,用于临时指向正在挂载/运行的 WatcherDep.target=null;classDep{constructor(){this.subs=[];// 订阅者列表 (Watcher 集合)}// 依赖收集depend(){if(Dep.target){this.subs.push(Dep.target);// 双向收集:Watcher 内部也会记录当前的 Dep 实例Dep.target.addDep(this);}}// 派发更新notify(){// 拷贝一份订阅者数组进行遍历constsubs=this.subs.slice();for(leti=0;i<subs.length;i++){subs[i].update();}}}// 属性数据劫持functiondefineReactive(obj,key,val){constdep=newDep();// 为每一个属性在闭包中绑定一个独立的 Dep 实例Object.defineProperty(obj,key,{enumerable:true,configurable:true,get(){if(Dep.target){dep.depend();// getter 触发,收集依赖}returnval;},set(newVal){if(newVal===val)return;val=newVal;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={};letwaiting=false;functionqueueWatcher(watcher){constid=watcher.id;if(has[id]==null){has[id]=true;queue.push(watcher);// 存入 Watcherif(!waiting){waiting=true;// 在当前宏任务的同步代码结束后,由微任务统一刷新队列nextTick(flushSchedulerQueue);}}}functionflushSchedulerQueue(){for(leti=0;i<queue.length;i++){constwatcher=queue[i];has[watcher.id]=null;watcher.run();// 真正开始重新渲染与 DOM 挂载}queue.length=0;waiting=false;}4.Watcher说明
什么是Watcher?
Watcher(观察者 / 订阅者)在 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:
- 渲染 Watcher(Render Watcher):
- 数量:每个组件实例只有一个。
- 作用:负责整个组件视图的重新渲染(执行
vm._update(vm._render()))。 - 属性与 Watcher 的关系:假设模板里用了 10 个数据属性(
a,b,c…),这 10 个属性对应的 10 个Dep里,存的都是同一个渲染 Watcher。
- 用户 Watcher(User Watcher /
watch选项):
- 数量:你写了几个
watch属性,就有几个。 - 作用:执行你在
watch: { foo(val) { ... } }中写的自定义回调函数。
- 计算属性 Watcher(Computed Watcher):
- 数量:你写了几个
computed,就有几个。 - 作用:内部维护一个
dirty标志位,用来实现计算属性的缓存机制。
Watcher 的全局唯一id
每一个Watcher实例都有一个全局唯一的id!
id是实现“异步更新队列去重(防抖)”的核心关键!在 Vue 2 内部,有一个自增的全局计数器:
letuid=0;// 全局自增 ID 计数器classWatcher{constructor(vm,expOrFn,cb,options){this.id=++uid;// 每一个 Watcher 诞生时,都会拿到一个递增且唯一的 idthis.deps=[];this.depIds=newSet();// ...}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,通知同一个渲染 Watcher(id: 1)。- Vue 再次执行
queueWatcher(watcher):
if(has[1]==null){...}// 发现 has[1] 已经是 true 了!直接跳过!正是因为每个Watcher有唯一的id,Vue 才能用极其高效的O(1)时间复杂度过滤掉重复的更新通知。
5.this.$nextTick的底层实现与 FIFO 顺序
this.$nextTick(cb)能够准确拿到最新 DOM 的核心原因在于:微任务队列的“先来后到(FIFO,先进先出)”执行顺序。
并没有“反复横跳”
在底层,Vue 维护了一个统一的回调函数队列callbacks:
constcallbacks=[];letpending=false;functionnextTick(cb){callbacks.push(cb);// 入队if(!pending){pending=true;// 开启锁定,保证当前 Tick 内只开启一次微任务注册// 使用微任务 API(如 Promise.then)在主线程空闲时派发Promise.resolve().then(flushCallbacks);}}functionflushCallbacks(){pending=false;// 解锁constcopies=callbacks.slice(0);callbacks.length=0;for(leti=0;i<copies.length;i++){copies[i]();}}完整运行流
假设有如下代码:
this.message='Hello World';// ① 修改数据this.$nextTick(()=>{// ② 手动挂载回调console.log(this.$el.textContent);});- 执行 ① 时:触发
setter,Vue 内部自动调用nextTick(flushSchedulerQueue)。
- 此时
callbacks队列为:[ flushSchedulerQueue ]。
- 执行 ② 时:手动调用
$nextTick,你的回调函数被推入队列。
- 此时
callbacks队列为:[ flushSchedulerQueue, userCallback ]。
- 主线程同步代码结束,开启微任务:
- 执行
callbacks[0](flushSchedulerQueue):Vue 重新渲染并把新节点直接同步写到了内存 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 钩子🚀 感谢阅读!想了解更多?
📖 我的博客网站 | 记录思考,分享干货
🏡 我的个人主页 | 关于我、开源项目