Vue 2 响应式原理
大白话解释: Vue 2 的响应式就像给每个数据属性"装监控"。当你读取数据时(getter),Vue 记录"谁在用这个数据";当你修改数据时(setter),Vue 通知所有用到这个数据的地方"数据变了,该更新了"。
为什么要理解响应式原理?
- 排查"数据变了页面没更新"的问题:比如直接给对象添加新属性、修改数组下标,Vue 检测不到
- 避免常见踩坑:知道哪些操作能触发更新,哪些不能
- 性能优化:理解依赖收集机制,避免不必要的更新
Object.defineProperty 的局限:
- 只能监听已有的属性:新增属性需要
$set,删除需要$delete - 无法监听数组下标:
arr[0] = 'new'不触发更新 - 无法监听数组长度:
arr.length = 0不触发更新
Vue 2 基于 Object.defineProperty 实现数据响应式。理解其工作原理和局限性,是排查「数据变了页面没更新」问题的关键。
💡 Vue 3 改用
Proxy。Vue 3 的响应式系统基于 ES6 的Proxy代理对象,无需递归遍历属性,能直接监听属性的新增/删除、数组索引变化等,彻底解决了Object.defineProperty的所有局限。核心 API 变为reactive()和ref()。
核心机制
整体流程
new Vue() 初始化
↓
遍历 data 中所有属性
↓
Object.defineProperty 劫持每个属性的 getter/setter
↓
getter 中:收集依赖(Dep.depend)—— 谁在用这个数据
setter 中:触发更新(Dep.notify)—— 通知所有依赖重新渲染Dep(依赖管理器)
每个响应式属性都有一个 Dep 实例,负责收集和通知依赖。
// 简化版 Dep
class Dep {
constructor() {
this.subs = [] // 订阅者列表
}
addSub(sub) {
this.subs.push(sub) // 将 watcher 加入订阅列表
}
depend() {
if (Dep.target) {
Dep.target.addDep(this) // 当前 watcher 订阅这个 dep,建立双向关系
}
}
notify() {
// 数据变化时,通知所有订阅者(watcher)更新
this.subs.forEach((sub) => sub.update())
}
}Watcher(订阅者)
每个组件实例对应一个渲染 Watcher,computed 和 watch 也各自创建 Watcher。
// 简化版 Watcher
class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.deps = []
Dep.target = this // 标记当前正在求值的 watcher
this.getter = expOrFn
this.value = this.get() // 执行 getter,触发属性的 dep.depend()
Dep.target = null
}
get() {
// 执行 getter(如渲染函数),触发属性的 getter,从而收集依赖
return this.getter.call(this.vm)
}
addDep(dep) {
this.deps.push(dep) // 记录依赖了哪些 dep
dep.addSub(this) // 双向记录:dep 也记录这个 watcher
}
update() {
queueWatcher(this) // 异步更新,放入队列
}
}defineReactive(核心函数)
// 简化版 defineReactive
function defineReactive(obj, key, val) {
const dep = new Dep()
// 递归处理嵌套对象
if (typeof val === 'object' && val !== null) {
observe(val)
}
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 收集依赖
dep.depend()
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
// 新值也是对象,递归观察
if (typeof newVal === 'object' && newVal !== null) {
observe(newVal)
}
// 触发更新
dep.notify()
},
})
}Object.defineProperty 的局限
1. 无法检测新增属性
// ❌ 新增属性不是响应式的
this.user.email = '[email protected]' // 数据改了,视图不更新
// ✅ 方案一:$set
this.$set(this.user, 'email', '[email protected]')
// ✅ 方案二:提前在 data 中声明
data() {
return {
user: { name: '', email: '' } // 预先声明
}
}2. 无法检测删除属性
// ❌ 删除属性不触发更新
delete this.user.email
// ✅ $delete
this.$delete(this.user, 'email')
// ✅ 或用 lodash
import { omit } from 'lodash-es'
this.user = omit(this.user, ['email'])3. 无法检测数组索引修改
// ❌ 直接设置索引不是响应式的
this.list[0] = 'new value' // 数据改了,视图不更新
// ✅ 方案一:$set
this.$set(this.list, 0, 'new value')
// ✅ 方案二:splice
this.list.splice(0, 1, 'new value')4. 无法检测数组长度修改
// ❌ 不触发更新
this.list.length = 0
// ✅ 用 splice
this.list.splice(0)数组的响应式处理
Vue 2 重写了以下数组方法,在调用时会触发视图更新:
push() pop() shift() unshift()
splice() sort() reverse()为什么 Vue 2 要重写这些方法?
Object.defineProperty 无法监听数组索引的变化(arr[0] = 'new' 不触发更新)。Vue 2 的解决方案是:重写上述 7 个会修改原数组的方法,在方法内部手动触发依赖更新。这些方法被称为"变异方法"(mutation methods),因为它们直接修改原数组而非返回新数组。
// 这些操作都会触发更新
this.list.push({ id: 3, name: '王五' })
this.list.splice(1, 1)
this.list.sort((a, b) => a.id - b.id)
this.list.reverse()为什么 filter/map/concat 需要重新赋值?
// ❌ filter 返回新数组,原数组没变
this.list.filter(item => item.active) // 原 list 不变,不触发更新
// ✅ 重新赋值
this.list = this.list.filter(item => item.active)💡
push、splice等方法是修改原数组,Vue 能拦截;filter、map是返回新数组,需要替换引用。
异步更新队列
Vue 2 的 DOM 更新是异步的。数据变化后,DOM 不会立即更新,而是将更新放入一个队列,在同一个事件循环中的所有数据变化完成后,统一批量更新。
export default {
data() {
return { count: 0 }
},
methods: {
updateCount() {
this.count = 1
// ❌ DOM 还是旧的
console.log(this.$el.textContent) // 0
this.count = 2
this.count = 3
// 三次赋值只触发一次 DOM 更新(批量优化)
// ✅ 等待 DOM 更新完成
this.$nextTick(() => {
console.log(this.$el.textContent) // 3
})
},
},
}$nextTick 原理
// 内部优先使用微任务(Promise.then > MutationObserver),降级为宏任务(setImmediate > setTimeout)
// 注意:注释中 Promise.then 优先级最高是因为微任务执行时机早于宏任务,
// 且在 Vue 2.5+ 中明确优先使用 Promise.then 而非 MutationObserver
const callbacks = []
let pending = false
function nextTick(cb) {
callbacks.push(cb)
if (!pending) {
pending = true
Promise.resolve().then(() => {
const copies = callbacks.slice()
callbacks.length = 0
copies.forEach((fn) => fn())
pending = false
})
}
}深度监听的性能问题
data() {
return {
deepObj: {
a: { b: { c: { d: 1 } } },
list: new Array(1000).fill({ name: '' }),
},
}
},
watch: {
// ❌ deep: true 会递归遍历所有属性,性能开销大
deepObj: {
handler(newVal) { console.log('变化了') },
deep: true,
},
// ✅ 精确监听某个深层属性
'deepObj.a.b.c.d'(newVal) { console.log('变化了') },
// ✅ 只关心引用变化,不关心内部变化
deepObj(newVal) { console.log('引用变了') },
}手写简易响应式系统
// 完整的简化版响应式实现
const targetMap = new WeakMap()
let activeEffect = null
// 依赖收集
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
deps.add(activeEffect)
}
// 触发更新
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
if (deps) {
deps.forEach((effect) => effect())
}
}
// 响应式代理
function defineReactive(obj, key) {
let val = obj[key]
const dep = new Set()
Object.defineProperty(obj, key, {
get() {
if (activeEffect) dep.add(activeEffect)
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
dep.forEach((fn) => fn())
},
})
}
// 副作用函数
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
// 使用示例
const state = { count: 0 }
defineReactive(state, 'count')
effect(() => {
console.log('count is:', state.count)
})
state.count = 1 // 输出: count is: 1
state.count = 2 // 输出: count is: 2常见坑点速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 新增属性页面不更新 | defineProperty 无法检测 | this.$set(obj, key, value) |
| 删除属性页面不更新 | defineProperty 无法检测 | this.$delete(obj, key) |
| 数组某项改了页面没变 | 索引赋值不触发 | splice() 或 $set |
| 对象深层属性不更新 | 只劫持第一层 | deep: true 或 $set |
| data 中未声明的属性 | 初始化时未被劫持 | 提前在 data 中声明所有属性 |
| 页面不更新但数据变了 | DOM 异步更新 | this.$nextTick() |
调试技巧
// 监听响应式依赖变化
this.$watch(
'someData',
(newVal, oldVal) => {
console.log('变化:', oldVal, '→', newVal)
},
{ deep: true }
)
// 使用 Vue Devtools 查看组件数据
// 在浏览器中打开 Vue Devtools → 选择组件 → 查看 data/computed/watch
// 检查属性是否响应式
console.log(Object.getOwnPropertyDescriptor(this.$data, 'count'))
// 如果有 getter/setter,说明是响应式的