Vue 2 核心响应式与渲染原理全景拆解
更新: 8/5/2026字数: 0 字 时长: 0 分钟
本文聚焦于 Vue 2 的底层架构设计,梳理从数据劫持、依赖收集、异步队列更新,到生命周期衔接与
this.$nextTick机制的全流程。

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通知。
简化版的实现模型
// 全局属性,用于临时指向正在挂载/运行的 Watcher
Dep.target = null;
class Dep {
constructor() {
this.subs = []; // 订阅者列表 (Watcher 集合)
}
// 依赖收集
depend() {
if (Dep.target) {
this.subs.push(Dep.target);
// 双向收集:Watcher 内部也会记录当前的 Dep 实例
Dep.target.addDep(this);
}
}
// 派发更新
notify() {
// 拷贝一份订阅者数组进行遍历
const subs = this.subs.slice();
for (let i = 0; i < subs.length; i++) {
subs[i].update();
}
}
}
// 属性数据劫持
function defineReactive(obj, key, val) {
const dep = new Dep(); // 为每一个属性在闭包中绑定一个独立的 Dep 实例
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
if (Dep.target) {
dep.depend(); // getter 触发,收集依赖
}
return val;
},
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 过程。
const queue = [];
let has = {};
let waiting = false;
function queueWatcher(watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher); // 存入 Watcher
if (!waiting) {
waiting = true;
// 在当前宏任务的同步代码结束后,由微任务统一刷新队列
nextTick(flushSchedulerQueue);
}
}
}
function flushSchedulerQueue() {
for (let i = 0; i < queue.length; i++) {
const watcher = 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 内部,有一个自增的全局计数器:
let uid = 0; // 全局自增 ID 计数器
class Watcher {
constructor(vm, expOrFn, cb, options) {
this.id = ++uid; // 每一个 Watcher 诞生时,都会拿到一个递增且唯一的 id
this.deps = [];
this.depIds = new Set();
// ...
}
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] = true
queue.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:
const callbacks = [];
let pending = false;
function nextTick(cb) {
callbacks.push(cb); // 入队
if (!pending) {
pending = true; // 开启锁定,保证当前 Tick 内只开启一次微任务注册
// 使用微任务 API(如 Promise.then)在主线程空闲时派发
Promise.resolve().then(flushCallbacks);
}
}
function flushCallbacks() {
pending = false; // 解锁
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 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 钩子