Vue2 响应式原理深度剖析
2026/3/20大约 9 分钟
Vue2 响应式原理深度剖析:揭开数据驱动的神秘面纱
作为一名使用 Vue 多年的开发者,我一直认为理解响应式原理是从"会用"到"精通"的分水岭。本文将带你深入 Vue2 的响应式系统,从原理到实现,彻底搞懂数据驱动视图的本质。
什么是响应式?
简单来说,响应式就是数据变化时,视图自动更新。
// 原生 JS
const obj = { count: 0 }
document.getElementById('app').innerHTML = obj.count
obj.count = 1 // 视图不会自动更新
// Vue
data() {
return { count: 0 }
}
this.count = 1 // 视图自动更新!
Vue 是如何做到的?答案就在 Object.defineProperty。
Object.defineProperty 基础
这是 Vue2 响应式的核心 API:
const obj = {};
Object.defineProperty(obj, "name", {
// 属性值
value: "张三",
// 是否可枚举(for...in、Object.keys)
enumerable: true,
// 是否可配置(删除、修改描述符)
configurable: true,
// 是否可写
writable: true,
});
// 或者使用 getter/setter
Object.defineProperty(obj, "age", {
get() {
console.log("读取 age");
return this._age;
},
set(newVal) {
console.log("设置 age:", newVal);
this._age = newVal;
},
});
手写简易响应式系统
让我们一步步实现一个简化版的 Vue 响应式系统。
第一步:数据劫持
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log(`读取 ${key}: ${val}`);
return val;
},
set(newVal) {
if (newVal === val) return;
console.log(`设置 ${key}: ${val} → ${newVal}`);
val = newVal;
},
});
}
// 递归遍历对象
function observe(obj) {
if (typeof obj !== "object" || obj === null) {
return;
}
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
// 递归处理嵌套对象
observe(obj[key]);
});
}
// 测试
const data = {
name: "张三",
info: {
age: 25,
},
};
observe(data);
data.name; // 读取 name: 张三
data.name = "李四"; // 设置 name: 张三 → 李四
data.info.age = 26; // 设置 age: 25 → 26
第二步:依赖收集
光有数据劫持还不够,我们需要知道"谁"在使用这个数据,数据变化时通知"谁"。
// Dep:依赖收集器
class Dep {
constructor() {
this.subs = []; // 存储所有订阅者
}
// 添加订阅者
addSub(watcher) {
this.subs.push(watcher);
}
// 通知所有订阅者更新
notify() {
this.subs.forEach((watcher) => watcher.update());
}
}
// 全局变量,用于标记当前正在执行的 Watcher
Dep.target = null;
// Watcher:订阅者
class Watcher {
constructor(vm, expr, cb) {
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 获取初始值时触发依赖收集
this.value = this.get();
}
get() {
// 将当前 Watcher 设为全局 target
Dep.target = this;
// 访问数据,触发 getter,完成依赖收集
const value = this.vm[this.expr];
// 清空 target
Dep.target = null;
return value;
}
update() {
const newValue = this.vm[this.expr];
const oldValue = this.value;
if (newValue !== oldValue) {
this.value = newValue;
this.cb(newValue, oldValue);
}
}
}
第三步:完整实现
function defineReactive(obj, key, val) {
// 每个属性都有一个 Dep 实例
const dep = new Dep();
// 递归处理嵌套对象
observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 依赖收集:如果当前有 Watcher,将其添加到 dep
if (Dep.target) {
dep.addSub(Dep.target);
}
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
// 新值也需要响应式处理
observe(newVal);
// 派发更新
dep.notify();
},
});
}
function observe(obj) {
if (typeof obj !== "object" || obj === null) {
return;
}
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
});
}
// 简化的 Vue 类
class SimpleVue {
constructor(options) {
this._data = options.data();
// 数据响应式
observe(this._data);
// 代理 data 到实例上
Object.keys(this._data).forEach((key) => {
Object.defineProperty(this, key, {
get() {
return this._data[key];
},
set(newVal) {
this._data[key] = newVal;
},
});
});
// 模拟编译过程:为模板中使用的数据创建 Watcher
if (options.watch) {
Object.keys(options.watch).forEach((key) => {
new Watcher(this, key, options.watch[key]);
});
}
}
}
// 测试
const vm = new SimpleVue({
data() {
return {
message: "Hello",
count: 0,
};
},
watch: {
message(newVal, oldVal) {
console.log(`message 变化: ${oldVal} → ${newVal}`);
},
count(newVal, oldVal) {
console.log(`count 变化: ${oldVal} → ${newVal}`);
},
},
});
vm.message = "World"; // 输出: message 变化: Hello → World
vm.count++; // 输出: count 变化: 0 → 1
Vue2 响应式的局限性
1. 无法检测对象属性的添加/删除
export default {
data() {
return {
user: {
name: "张三",
},
};
},
methods: {
addEmail() {
// ❌ 不会触发视图更新
this.user.email = "test@example.com";
// ✅ 正确做法
this.$set(this.user, "email", "test@example.com");
// 或
this.user = { ...this.user, email: "test@example.com" };
},
removeAge() {
// ❌ 不会触发视图更新
delete this.user.age;
// ✅ 正确做法
this.$delete(this.user, "age");
},
},
};
原因:Object.defineProperty 只能劫持已存在的属性,新增属性没有被 observe。
2. 无法检测数组索引和长度变化
export default {
data() {
return {
items: ["a", "b", "c"],
};
},
methods: {
updateItem() {
// ❌ 不会触发视图更新
this.items[0] = "x";
this.items.length = 1;
// ✅ 正确做法
this.$set(this.items, 0, "x");
// 或
this.items.splice(0, 1, "x");
},
},
};
原因:虽然数组的索引可以用 Object.defineProperty 劫持,但考虑到性能问题(数组可能很长),Vue 选择不这样做。
Vue2 对数组方法的处理
Vue 对数组的变异方法做了特殊处理:
// Vue 内部对这些方法进行了重写
const arrayMethods = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
// 简化版实现
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
(method) => {
arrayMethods[method] = function (...args) {
// 调用原始方法
const result = arrayProto[method].apply(this, args);
// 获取新增的元素
let inserted;
switch (method) {
case "push":
case "unshift":
inserted = args;
break;
case "splice":
inserted = args.slice(2);
break;
}
// 对新增元素进行响应式处理
if (inserted) {
observe(inserted);
}
// 触发更新
this.__ob__.dep.notify();
return result;
};
}
);
所以使用这些方法是可以触发视图更新的:
this.items.push("d"); // ✅ 触发更新
this.items.splice(0, 1); // ✅ 触发更新
this.items.sort(); // ✅ 触发更新
异步更新队列
Vue 的 DOM 更新是异步的,这是一个重要的性能优化。
为什么要异步更新?
export default {
data() {
return { count: 0 };
},
methods: {
increment() {
this.count++;
this.count++;
this.count++;
// 如果每次修改都同步更新 DOM,会更新 3 次
// 使用异步更新,只会更新 1 次
},
},
};
事件循环与 nextTick
// Vue 的更新流程
数据变化
↓
setter 触发
↓
dep.notify() 通知所有 Watcher
↓
Watcher 加入异步队列(去重)
↓
在 nextTick 中批量执行更新
↓
DOM 更新完成
$nextTick 使用场景
export default {
data() {
return {
show: false,
message: "",
};
},
methods: {
async showInput() {
this.show = true;
// ❌ 此时 DOM 还没更新,input 不存在
this.$refs.input.focus();
// ✅ 在 DOM 更新后执行
this.$nextTick(() => {
this.$refs.input.focus();
});
// ✅ 使用 async/await
await this.$nextTick();
this.$refs.input.focus();
},
updateMessage() {
this.message = "Hello";
// ❌ 直接获取的是旧 DOM
console.log(this.$el.textContent);
// ✅ 获取更新后的 DOM
this.$nextTick(() => {
console.log(this.$el.textContent);
});
},
},
};
nextTick 实现原理
// 简化版 nextTick 实现
const callbacks = [];
let pending = false;
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
copies.forEach((cb) => cb());
}
function nextTick(cb) {
callbacks.push(cb);
if (!pending) {
pending = true;
// 优先使用微任务
if (typeof Promise !== "undefined") {
Promise.resolve().then(flushCallbacks);
} else if (typeof MutationObserver !== "undefined") {
// 备选方案
const observer = new MutationObserver(flushCallbacks);
const textNode = document.createTextNode("1");
observer.observe(textNode, { characterData: true });
textNode.data = "2";
} else {
// 最后使用宏任务
setTimeout(flushCallbacks, 0);
}
}
}
计算属性的实现原理
计算属性具有缓存特性,只有当依赖变化时才会重新计算。
// 简化版 computed 实现
class ComputedWatcher {
constructor(vm, key, getter) {
this.vm = vm;
this.getter = getter;
this.dirty = true; // 标记是否需要重新计算
this.value = undefined;
this.dep = new Dep();
// 创建一个特殊的 Watcher
this.watcher = new Watcher(
vm,
() => this.getter.call(vm),
() => {
// 依赖变化时,标记为脏
if (!this.dirty) {
this.dirty = true;
this.dep.notify(); // 通知依赖此计算属性的 Watcher
}
},
{ lazy: true } // 延迟执行
);
}
get() {
// 如果是脏的,重新计算
if (this.dirty) {
this.value = this.watcher.get();
this.dirty = false;
}
// 收集依赖
if (Dep.target) {
this.dep.addSub(Dep.target);
}
return this.value;
}
}
使用示例:
export default {
data() {
return {
firstName: "张",
lastName: "三",
};
},
computed: {
// fullName 依赖 firstName 和 lastName
// 只有它们变化时,fullName 才会重新计算
fullName() {
console.log("计算 fullName");
return this.firstName + this.lastName;
},
},
mounted() {
console.log(this.fullName); // 计算 fullName,输出: 张三
console.log(this.fullName); // 不会重新计算,直接返回缓存
console.log(this.fullName); // 不会重新计算,直接返回缓存
this.firstName = "李"; // 标记 dirty = true
console.log(this.fullName); // 计算 fullName,输出: 李三
},
};
侦听器的实现原理
watch 和 computed 都是基于 Watcher 实现的,但用途不同。
// watch 的几种形式
export default {
data() {
return {
question: "",
user: { name: "张三" },
};
},
watch: {
// 1. 方法形式
question(newVal, oldVal) {
this.getAnswer();
},
// 2. 配置对象形式
question: {
handler(newVal, oldVal) {
this.getAnswer();
},
immediate: true, // 立即执行一次
deep: false,
},
// 3. 深度监听
user: {
handler(newVal) {
console.log("user 变化了");
},
deep: true, // 递归监听对象所有属性
},
// 4. 监听对象的特定属性
"user.name"(newVal) {
console.log("name 变化了");
},
},
};
deep 监听的代价
// deep: true 会递归遍历对象所有属性
// 触发每个属性的 getter,收集依赖
// 性能开销较大
// 如果只需要监听特定属性,用字符串路径更高效
watch: {
// ❌ 性能差:监听整个对象
user: {
handler() {},
deep: true
},
// ✅ 性能好:只监听需要的属性
'user.profile.settings.theme'(newVal) {
// ...
}
}
响应式最佳实践
1. 提前声明数据结构
// ❌ 不好:后续添加属性需要用 $set
data() {
return {
user: {}
}
}
this.$set(this.user, 'name', '张三')
// ✅ 好:提前声明完整结构
data() {
return {
user: {
name: '',
age: 0,
email: '',
address: null
}
}
}
2. 使用不可变数据模式
// ❌ 直接修改
this.list.push(item);
// ✅ 返回新数组(某些场景下更清晰)
this.list = [...this.list, item];
// ❌ 直接修改对象
this.user.name = "新名字";
// ✅ 返回新对象(便于追踪变化)
this.user = { ...this.user, name: "新名字" };
3. 避免深层嵌套
// ❌ 层级过深
data() {
return {
app: {
modules: {
user: {
settings: {
preferences: {
theme: 'dark'
}
}
}
}
}
}
}
// ✅ 扁平化
data() {
return {
userPreferences: {
theme: 'dark'
}
}
}
4. 冻结不需要响应的数据
// 大量静态数据不需要响应式
data() {
return {
// 使用 Object.freeze 跳过响应式处理
cities: Object.freeze([
{ code: 'BJ', name: '北京' },
{ code: 'SH', name: '上海' },
// ... 大量数据
])
}
}
5. 合理使用 $forceUpdate
// 在极少数情况下,Vue 无法自动检测到变化
// 可以使用 $forceUpdate 强制更新
// ⚠️ 不推荐,应该先检查为什么响应式不生效
this.$forceUpdate();
// 更好的做法是找到根本原因并修复
调试技巧
1. Vue Devtools
安装 Vue Devtools 浏览器扩展,可以:
- 查看组件层级和数据
- 追踪数据变化
- 调试 Vuex 状态
2. 打印响应式对象
// 响应式对象直接 console.log 看不到原始值
console.log(this.user); // 显示 Proxy 或 Observer
// ✅ 使用 JSON 转换
console.log(JSON.parse(JSON.stringify(this.user)));
// ✅ 或使用展开运算符
console.log({ ...this.user });
3. 检查是否响应式
import Vue from "vue";
// 检查对象是否是响应式的
Vue.observable(obj); // 返回响应式对象
// 在组件中
console.log(this.$data); // 查看响应式数据
总结
Vue2 的响应式系统基于 Object.defineProperty 实现,核心概念包括:
- 数据劫持:通过 getter/setter 拦截数据读写
- 依赖收集:Dep 收集所有使用该数据的 Watcher
- 派发更新:数据变化时通知所有 Watcher 更新
- 异步队列:批量处理更新,优化性能
了解这些原理后,你就能:
- 理解
$set、$delete为什么必要 - 知道数组变异方法为什么能触发更新
- 正确使用
$nextTick获取更新后的 DOM - 优化大型应用的响应式性能
下一篇文章,我们将进入 Vue3 的世界,看看它如何用 Proxy 彻底解决这些问题。
参考资料
- Vue2 源码:https://github.com/vuejs/vue
- 《深入浅出 Vue.js》
- Vue2 官方响应式原理文档