Vue 响应式原理
响应式要解决的问题只有一句话:数据变了,怎么知道该更新哪块视图。 Vue2 和 Vue3 走了两条路:一个是「预先劫持每个属性」,一个是「访问时拦截整个对象」。
一、整体思路
数据变化 → 找到「谁用了这份数据」 → 通知它重新执行
所以核心是两件事:
1. 收集依赖(谁用了)
2. 触发更新(通知谁)数据变化 → 找到「谁用了这份数据」 → 通知它重新执行
所以核心是两件事:
1. 收集依赖(谁用了)
2. 触发更新(通知谁)Vue 把「依赖」具体化成 effect(副作用函数):组件的渲染函数、watch 的回调、computed 的求值函数,都是 effect。
二、Vue2:Object.defineProperty
核心:把属性改成 getter/setter
Vue2 通过 Object.defineProperty 将 data 的属性转为 getter/setter,实现数据劫持。
const obj = { name: '张三', age: 18 }
Object.keys(obj).forEach(key => {
let value = obj[key]
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 收集依赖:谁在读我,就把谁记下来
dep.depend()
return value
},
set(newValue) {
if (newValue === value) return
value = newValue
// 触发更新:通知所有读过我的地方
dep.notify()
}
})
})const obj = { name: '张三', age: 18 }
Object.keys(obj).forEach(key => {
let value = obj[key]
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
// 收集依赖:谁在读我,就把谁记下来
dep.depend()
return value
},
set(newValue) {
if (newValue === value) return
value = newValue
// 触发更新:通知所有读过我的地方
dep.notify()
}
})
})关键点:Object.defineProperty 只能劫持已知的、已存在的属性,这是它一切限制的根源。
发布订阅模式
Dep — 依赖收集器(每个属性一个)
class Dep {
constructor() {
this.subs = [] // 存放订阅者(Watcher)
}
depend() {
if (Dep.target) this.addSub(Dep.target)
}
addSub(watcher) {
if (!this.subs.includes(watcher)) this.subs.push(watcher)
}
notify() {
this.subs.slice().forEach(watcher => watcher.update())
}
}
Dep.target = null // 全局「当前正在执行的 watcher」class Dep {
constructor() {
this.subs = [] // 存放订阅者(Watcher)
}
depend() {
if (Dep.target) this.addSub(Dep.target)
}
addSub(watcher) {
if (!this.subs.includes(watcher)) this.subs.push(watcher)
}
notify() {
this.subs.slice().forEach(watcher => watcher.update())
}
}
Dep.target = null // 全局「当前正在执行的 watcher」Watcher — 订阅者(每个组件一个)
class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.getter = typeof expOrFn === 'function'
? expOrFn
: () => expOrFn.split('.').reduce((o, k) => o?.[k], vm)
this.cb = cb
this.value = this.get()
}
get() {
Dep.target = this // 标记:接下来读到的属性都算我依赖
const value = this.getter() // 触发 getter → 完成收集
Dep.target = null
return value
}
update() {
queueWatcher(this) // 不是立刻执行,先入队(批量更新)
}
run() {
const newVal = this.get()
if (newVal !== this.value) {
const oldVal = this.value
this.value = newVal
this.cb.call(this.vm, newVal, oldVal)
}
}
}class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
this.getter = typeof expOrFn === 'function'
? expOrFn
: () => expOrFn.split('.').reduce((o, k) => o?.[k], vm)
this.cb = cb
this.value = this.get()
}
get() {
Dep.target = this // 标记:接下来读到的属性都算我依赖
const value = this.getter() // 触发 getter → 完成收集
Dep.target = null
return value
}
update() {
queueWatcher(this) // 不是立刻执行,先入队(批量更新)
}
run() {
const newVal = this.get()
if (newVal !== this.value) {
const oldVal = this.value
this.value = newVal
this.cb.call(this.vm, newVal, oldVal)
}
}
}完整的数据劫持
function observe(obj) {
if (!obj || typeof obj !== 'object') return
Object.keys(obj).forEach(key => {
let value = obj[key]
observe(value) // 递归处理深层对象
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
dep.depend() // 收集:Dep.target 有值就记下
return value
},
set(newValue) {
if (newValue === value) return
value = newValue
observe(newValue) // 新值也要劫持
dep.notify() // 发布通知
}
})
})
}function observe(obj) {
if (!obj || typeof obj !== 'object') return
Object.keys(obj).forEach(key => {
let value = obj[key]
observe(value) // 递归处理深层对象
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
dep.depend() // 收集:Dep.target 有值就记下
return value
},
set(newValue) {
if (newValue === value) return
value = newValue
observe(newValue) // 新值也要劫持
dep.notify() // 发布通知
}
})
})
}数据代理
Vue 将 data 的属性代理到 vm 实例上,方便通过 this.xxx 直接访问。
class Vue {
constructor(options) {
this.$data = options.data
observe(this.$data)
Object.keys(this.$data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() { return this.$data[key] },
set(v) { this.$data[key] = v }
})
})
}
}class Vue {
constructor(options) {
this.$data = options.data
observe(this.$data)
Object.keys(this.$data).forEach(key => {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get() { return this.$data[key] },
set(v) { this.$data[key] = v }
})
})
}
}数组的响应式:重写七个方法
Object.defineProperty 无法监听数组索引和 length,所以 Vue2 直接重写数组原型上的七个变更方法:
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
const original = arrayProto[method]
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
const result = original.apply(this, args)
const ob = this.__ob__
ob.dep.notify() // 通知数组的依赖
return result
}
})
})const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
const original = arrayProto[method]
Object.defineProperty(arrayMethods, method, {
value: function mutator(...args) {
const result = original.apply(this, args)
const ob = this.__ob__
ob.dep.notify() // 通知数组的依赖
return result
}
})
})所以 arr[0] = x 不响应式,而 arr.splice(0, 1, x) 响应式 —— 因为只有后者走了被重写的方法。
Vue2 的三个限制与 $set 的原理
// 1. 新增/删除属性:defineProperty 没劫持过新 key
this.obj.newKey = 'x' // ❌ 非响应式
this.$set(this.obj, 'newKey', 'x') // ✅
delete this.obj.key // ❌
this.$delete(this.obj, 'key') // ✅
// 2. 通过索引改数组
this.list[0] = 'x' // ❌
this.$set(this.list, 0, 'x') // ✅(内部转成 splice)
// 3. 直接改 length
this.list.length = 0 // ❌
this.list.splice(0) // ✅// 1. 新增/删除属性:defineProperty 没劫持过新 key
this.obj.newKey = 'x' // ❌ 非响应式
this.$set(this.obj, 'newKey', 'x') // ✅
delete this.obj.key // ❌
this.$delete(this.obj, 'key') // ✅
// 2. 通过索引改数组
this.list[0] = 'x' // ❌
this.$set(this.list, 0, 'x') // ✅(内部转成 splice)
// 3. 直接改 length
this.list.length = 0 // ❌
this.list.splice(0) // ✅$set 做了三件事:给新属性补上 defineProperty 劫持 → 触发这个 key 的 dep → 触发对象本身的 ob.dep(因为 Vue.set 也可能作用于数组)。
三、Vue3:Proxy
核心:拦截整个对象
const handler = {
get(target, key, receiver) {
track(target, key) // 收集依赖
const res = Reflect.get(target, key, receiver)
// 懒递归:只有访问到嵌套对象时才把它变成响应式
return isObject(res) ? reactive(res) : res
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (!Object.is(oldValue, value)) trigger(target, key) // 触发更新
return result
},
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const result = Reflect.deleteProperty(target, key)
if (hadKey && result) trigger(target, key)
return result
},
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
}
const reactive = (obj) => new Proxy(obj, handler)const handler = {
get(target, key, receiver) {
track(target, key) // 收集依赖
const res = Reflect.get(target, key, receiver)
// 懒递归:只有访问到嵌套对象时才把它变成响应式
return isObject(res) ? reactive(res) : res
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (!Object.is(oldValue, value)) trigger(target, key) // 触发更新
return result
},
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const result = Reflect.deleteProperty(target, key)
if (hadKey && result) trigger(target, key)
return result
},
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
}
const reactive = (obj) => new Proxy(obj, handler)Reflect 的作用:保证拦截器里对原对象的操作行为与默认一致,并把 receiver 正确透传(涉及原型链与访问器时的 this 指向)。
Vue3 的依赖结构(重点)
Vue2 是「每个属性一个 Dep 数组」,Vue3 是三层 Map:
// targetMap: WeakMap<target, depsMap>
// depsMap: Map<key, dep>
// dep: Set<effect>
const targetMap = new WeakMap()
let activeEffect = null
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Set()))
dep.add(activeEffect)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) dep.forEach(effect => {
if (effect !== activeEffect) effect.scheduler ? effect.scheduler() : effect.run()
})
}// targetMap: WeakMap<target, depsMap>
// depsMap: Map<key, dep>
// dep: Set<effect>
const targetMap = new WeakMap()
let activeEffect = null
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Set()))
dep.add(activeEffect)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) dep.forEach(effect => {
if (effect !== activeEffect) effect.scheduler ? effect.scheduler() : effect.run()
})
}为什么用 WeakMap:target 被回收时,依赖记录自动消失,不会内存泄漏。
effect 与调度器
function effect(fn, scheduler) {
const e = {
run() {
activeEffect = e
const res = fn() // 执行时读到的属性都会 track 到 e
activeEffect = null
return res
},
scheduler
}
e.run()
return e
}function effect(fn, scheduler) {
const e = {
run() {
activeEffect = e
const res = fn() // 执行时读到的属性都会 track 到 e
activeEffect = null
return res
},
scheduler
}
e.run()
return e
}组件的渲染函数就是一个 effect。但它带 scheduler —— 数据变化时不是立刻重渲染,而是把任务塞进队列:
const queue = []
let flushing = false
function queueJob(job) {
if (!queue.includes(job)) queue.push(job)
queueFlush()
}
function queueFlush() {
if (flushing) return
flushing = true
// 关键:用微任务批量刷新,同一轮里的多次修改只渲染一次
Promise.resolve().then(flushJobs)
}
function flushJobs() {
queue.sort((a, b) => a.id - b.id) // 父组件先于子组件更新
for (const job of queue) job()
queue.length = 0
flushing = false
}const queue = []
let flushing = false
function queueJob(job) {
if (!queue.includes(job)) queue.push(job)
queueFlush()
}
function queueFlush() {
if (flushing) return
flushing = true
// 关键:用微任务批量刷新,同一轮里的多次修改只渲染一次
Promise.resolve().then(flushJobs)
}
function flushJobs() {
queue.sort((a, b) => a.id - b.id) // 父组件先于子组件更新
for (const job of queue) job()
queue.length = 0
flushing = false
}这就是 nextTick 的来源:你在一次事件里改了 10 个 ref,DOM 只在微任务刷新后更新一次。
四、ref / reactive / shallow 的区别
| API | 适用 | 实现 | 注意 |
|---|---|---|---|
reactive(obj) | 对象/数组 | Proxy | 解构会丢失响应式 |
ref(v) | 任意值(含基本类型) | RefImpl 类,内部 dep | 要用 .value(模板里自动解包) |
shallowRef / shallowReactive | 大对象、只关心整体替换 | 只代理第一层 | 深层变化不触发 |
toRef / toRefs | 从 reactive 取出单个属性 | 返回带 getter 的对象 | 解构后仍保持响应式 |
// ref 内部大致长这样
class RefImpl {
constructor(value) {
this._value = value
this.dep = new Set()
}
get value() {
track(this, 'value') // 依赖记录在 ref 自己身上
return this._value
}
set value(newVal) {
if (Object.is(newVal, this._value)) return
this._value = newVal
trigger(this, 'value')
}
}// ref 内部大致长这样
class RefImpl {
constructor(value) {
this._value = value
this.dep = new Set()
}
get value() {
track(this, 'value') // 依赖记录在 ref 自己身上
return this._value
}
set value(newVal) {
if (Object.is(newVal, this._value)) return
this._value = newVal
trigger(this, 'value')
}
}为什么 ref 要 .value:基本类型没法被 Proxy 代理,只能包一层对象;.value 的 getter/setter 就是劫持点。
五、computed 与 watch
computed:带缓存的 effect
function computed(getter) {
let value
let dirty = true // 脏标记:是否需要重算
const effect = { run() { value = getter() }, scheduler: () => { dirty = true } }
return {
get value() {
if (dirty) { // 只有依赖变了才重算
dirty = false
effect.run()
}
track({ computed }, 'value') // 让使用方依赖这个 computed
return value
}
}
}function computed(getter) {
let value
let dirty = true // 脏标记:是否需要重算
const effect = { run() { value = getter() }, scheduler: () => { dirty = true } }
return {
get value() {
if (dirty) { // 只有依赖变了才重算
dirty = false
effect.run()
}
track({ computed }, 'value') // 让使用方依赖这个 computed
return value
}
}
}缓存的关键就是 dirty:依赖没变就直接返回上次的值,不重新执行 getter。
watch vs watchEffect
watch(source, cb) | watchEffect(fn) | |
|---|---|---|
| 依赖收集 | 显式指定 source | 执行 fn 时自动收集 |
| 首次执行 | 默认不执行(immediate: true 才执行) | 立即执行一次 |
| 能否拿到旧值 | 能 | 不能 |
| 适合 | 需要在变化时做副作用(请求、打点) | 依赖不固定、只要跟着变 |
六、响应式丢失的三个场景
1. 解构 reactive
const state = reactive({ count: 0 })
const { count } = state // ❌ count 是普通数字,断了
const { count } = toRefs(state) // ✅ 仍是 refconst state = reactive({ count: 0 })
const { count } = state // ❌ count 是普通数字,断了
const { count } = toRefs(state) // ✅ 仍是 ref原因:解构取的是「值」,不是「属性的 getter」。
2. 替换整个 reactive 对象
let state = reactive({ a: 1 })
state = reactive({ a: 2 }) // ❌ 前面的引用失效(代理对象被换掉)
Object.assign(state, { a: 2 }) // ✅ 保持同一个代理let state = reactive({ a: 1 })
state = reactive({ a: 2 }) // ❌ 前面的引用失效(代理对象被换掉)
Object.assign(state, { a: 2 }) // ✅ 保持同一个代理3. 把响应式对象传进非响应式容器
const raw = { nested: reactive({ x: 1 }) } // raw 本身不是响应式
// 但 nested.x 变了仍会触发(因为 nested 是代理)const raw = { nested: reactive({ x: 1 }) } // raw 本身不是响应式
// 但 nested.x 变了仍会触发(因为 nested 是代理)七、Vue2 vs Vue3 对比
| 特性 | Object.defineProperty(Vue2) | Proxy(Vue3) |
|---|---|---|
| 监听方式 | 遍历属性逐个劫持 | 代理整个对象 |
| 新增/删除属性 | 无法监听,需 $set/$delete | 自动监听 |
| 数组索引/length | 无法监听,需重写七个方法 | 自动监听 |
| 嵌套对象 | 初始化时递归(慢) | 访问时懒递归(快) |
| 依赖结构 | 属性 → Dep → Watcher 数组 | target → key → Set<effect> |
| 更新方式 | 队列 + nextTick | 队列 + nextTick(同一套思路) |
| 兼容性 | IE9+ | 不支持 IE |
八、速查
| 问题 | 答案 |
|---|---|
| Vue2 用什么实现响应式 | Object.defineProperty 的 getter/setter |
| Vue2 为什么监听不到新增属性 | 新属性没被 defineProperty 劫持过,要 $set |
Vue2 为什么监听不到 arr[0]=x | 没走被重写的七个数组方法 |
| Vue3 用什么 | Proxy + Reflect |
| Vue3 依赖存哪 | WeakMap<target, Map<key, Set<effect>>> |
| 数据变了为什么只渲染一次 | 渲染 effect 带 scheduler,塞进微任务队列批量刷新 |
nextTick 是什么 | 等 DOM 更新完成后的回调,本质是那个微任务队列 |
| computed 为什么有缓存 | dirty 标记,依赖没变就不重算 |
ref 为什么要 .value | 基本类型无法被 Proxy 代理,包一层对象才有劫持点 |
| 解构 reactive 为什么丢响应式 | 解构取到的是值,不是 getter;用 toRefs |
Cao Note