Vue3 响应式原理最小实现
这篇文档不追求完整还原 Vue3 源码,而是聚焦面试里最常被问到的那条主线: Proxy 是如何劫持数据的,ref/reactive 有什么区别,以及 effect 是如何自动收集依赖和触发更新的。
一、核心要点速览
💡 核心考点
- Proxy 替代 Object.defineProperty:Vue3 使用 Proxy 拦截整个对象操作,性能更好且能检测新增/删除属性。
- ref vs reactive:
ref通过.value访问,适合基本类型;reactive直接返回代理对象,适合引用类型。 - effect 是入口:组件渲染时会创建 effect,执行时访问响应式数据自动完成依赖收集。
- track 负责收集:读取数据时调用
track(),把当前 effect 存入 targetMap。 - trigger 负责通知:修改数据时调用
trigger(),从 targetMap 取出相关 effects 并执行。
二、整体流程图
下面这张图专门对应本文的"最小实现版"代码,用来串起从模板到视图更新的完整链路:
可以先记住这两条主线:
- 初始化链路:
template -> render -> effect() -> track() -> 依赖收集 - 更新链路:
setter/trigger -> trigger() -> effect 重新执行 -> render -> 更新视图
三、核心实现代码(精简版)
/* ================= 依赖管理 ================= */
const targetMap = new WeakMap() // 存储所有响应式对象的依赖关系
let activeEffect = null // 当前正在执行的 effect
// 收集依赖
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep sp = 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 dep5p = depsMap.get(key)
if (dep) {
dep.forEach(effect => effect())
}
}
/* ================= effect(副作用函数) ================= */
function effect(fn, options = {}) {
const _effect = function(...args) {
try {
activeEffect = _effect
return fn(...args)
} finally {
activeEffect = null
}
}
_effect.options = options
// 立即执行一次(除非标记为 lazy)
if (!options.lazy) {
_effect()
}
return _effect
}
/* ================= ref(基础类型响应式) ================= */
class RefImpl {
constructor(rawValue) {
this._rawValue = rawValue
this._value = rawValue
this.dep = new Set() // 存储依赖此 ref 的 effects
}
get value() {
trackRefValue(this)
return this._value
}
set value(newValue) {
if (newValue !== this._rawValue) {
this._rawValue = newValue
this._value = newValue
triggerRefValue(this)
}
}
}
function trackRefValue(ref) {
if (activeEffect) {
ref.dep.add(activeEffect)
}
}
function triggerRefValue(ref) {
ref.dep.forEach(effect => effect())
}
function ref(value) {
return new RefImpl(value)
}
function isRef(r) {
return r instanceof RefImpl
}
function unref(ref) {
return isRef(ref) ? ref.value : ref
}
/* ================= reactive(对象响应式) ================= */
function reactive(target) {
return createReactiveObject(target)
}
function createReactiveObject(target) {
if (!isObject(target)) {
return target
}
const handler = {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
// 如果是 ref,自动解包(除了作为数组索引访问时)
if (isRef(res) && !Array.isArray(target)) {
return res.value
}
// 收集依赖
track(target, key)
// 如果返回值是对象,递归代理
return isObject(res) ? reactive(res) : res
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
// 只有值真正改变时才触发更新
if (oldValue !== value) {
trigger(target, key)
}
return result
},
deleteProperty(target, key) {
const hadKey = key in target
const result = Reflect.deleteProperty(target, key)
if (hadKey) {
trigger(target, key)
}
return result
}
}
return new Proxy(target, handler)
}
function isObject(value) {
return value !== null && typeof value === 'object'
}
/* ================= computed(计算属性) ================= */
class ComputedRefImpl {
constructor(getter) {
this._getter = getter
this._value = undefined
this._dirty = true
this.effect = effect(getter.bind(this), { lazy: true })
}
get value() {
if (this._dirty) {
this._value = this.effect()
this._dirty = false
}
// 收集对 computed 的依赖
trackRefValue(this)
return this._value
}
// 当依赖变化时,标记为脏
_onTrigger() {
this._dirty = true
}
}
function computed(getterOrOptions) {
let getter
if (typeof getterOrOptions === 'function') {
getter = getterOrOptions
} else {
getter = getterOrOptions.get
}
return new ComputedRefImpl(getter)
}
/* ================= watch(监听器) ================= */
function watch(source, cb, options = {}) {
let getter
// 处理不同的 source 类型
if (isRef(source)) {
getter = () => source.value
} else if (isReactive(source)) {
getter = () => traverse(source)
} else if (typeof source === 'function') {
getter = source
}
let oldValue, newValue
const job = () => {
newValue = effectRunner()
if (cb) {
cb(newValue, oldValue)
}
oldValue = newValue
}
const effectRunner = effect(getter, {
lazy: true,
scheduler: job // 使用调度器
})
// 立即执行一次
if (!options.immediate) {
oldValue = effectRunner()
} else {
job()
}
return () => {
// 清理函数(简化版)
}
}
function traverse(obj, seen = new Set()) {
if (!isObject(obj) || seen.has(obj)) {
return obj
}
seen.add(obj)
for (const key in obj) {
traverse(obj[key], seen)
}
return obj
}
function isReactive(obj) {
return obj && obj.__v_isReactive
}
/* ================= Vue 组件(迷你版) ================= */
class Vue {
constructor(options) {
this.$data = reactive(options.data || {})
this.$el = options.el
// 代理 data 到实例上
if (options.data) {
Object.keys(options.data).forEach(key => {
Object.defineProperty(this, key, {
get: () => this.$data[key],
set: v => (this.$data[key] = v)
})
})
}
// 处理 setup
if (options.setup) {
this._setupState = options.setup()
}
// 处理 computed
if (options.computed) {
this._initComputed(options.computed)
}
// 处理 watch
if (options.watch) {
this._initWatch(options.watch)
}
// 渲染 effect
if (options.render) {
this.$render = options.render
effect(() => {
const vnode = this.$render.call(this)
this._update(vnode)
})
}
}
_update(vnode) {
console.log('🖥️ 更新视图:', vnode)
}
_initComputed(computed) {
Object.keys(computed).forEach(key => {
const c = computed(key)
Object.defineProperty(this, key, {
get: () => c.value,
enumerable: true
})
})
}
_initWatch(watch) {
Object.keys(watch).forEach(key => {
watch(key, (newVal, oldVal) => {
console.log(`👀 watch ${key}:`, oldVal, '→', newVal)
})
})
}
}四、模板如何转成 Render
Vue3 同样不会直接执行模板字符串,而是先把模板编译成 render 函数。 不同的是,Vue3 的 render 函数会使用 _ctx 上下文来访问响应式数据。
1. 模板写法
<div>{{ count }}</div>2. 等价的 Render 思想
function render() {
return `<div>${this.count}</div>`
}这里最关键的是 this.count。 因为一旦访问它,就会触发 Proxy 的 get 拦截,而 get 内部正是 track() 收集 effect 的入口。
五、运行示例
结合上面的最小实现代码,可以这样模拟一次完整的初始化与更新过程:
const vm = new Vue({
data: {
count: 0,
price: 10
},
computed: {
total() {
return this.count * this.price
}
},
watch: {
count(newVal, oldVal) {
console.log('👀 watch count:', oldVal, '→', newVal)
}
},
render() {
return `count: ${this.count}, total: ${this.total}`
}
})
setTimeout(() => {
vm.count++
// 👉 watch 回调触发
// 👉 computed total 重新计算
// 👉 视图更新
}, 1000)这个示例里,第一次会触发初始化渲染;1 秒后修改 count,就会走一遍完整的更新链路。
六、执行流程拆解
1. 初始化阶段:effect 是怎么被收集进去的?
new Vue()
-> effect(() => render())
-> activeEffect = effect
-> 执行 render()
-> 访问 this.count
-> 触发 Proxy.get
-> track(target, 'count')
-> 将 activeEffect 存入 targetMap
-> activeEffect = null
-> _update() 渲染视图这一阶段的重点是:
- 先有当前 effect:
effect()执行前,先把activeEffect指向自己。 - 再访问响应式数据:
render()内部读取this.count,触发 Proxy 的get拦截。 - 最后完成依赖收集:
get内部调用track(),把当前 effect 存入targetMap。
所以本质上不是"模板绑定 effect",而是"Render 取值时,Proxy 自动把当前 effect 收集进去"。
2. 更新阶段:数据修改后为什么能更新视图?
vm.count = 1
-> 触发 Proxy.set
-> trigger(target, 'count')
-> 从 targetMap 取出相关 effects
-> 执行每个 effect
-> 重新执行 render()
-> 再次触发 get
-> 重新生成 vnode
-> _update() 更新视图这一阶段的重点是:
- Proxy.set 负责广播变化:数据一改,
trigger()就会执行。 - effect 负责重新渲染:收到通知后重新执行。
- Render 再跑一遍:于是新的数据被读到,新的视图结果被计算出来。
七、角色分工
1. targetMap 是干什么的?
- 它是一个
WeakMap,存储所有响应式对象的依赖关系。 - 结构:
targetMap -> target -> depsMap -> key -> dep(Set of effects) - 当属性变化时,它负责快速找到相关的 effects。
2. effect 是干什么的?
- effect 是"执行者"。
- 对于渲染场景,它负责重新执行
render,让视图拿到最新数据。 - 对于计算属性、用户
watch等场景,本质上也都是类似的观察者机制。
3. Proxy 做了什么?
- 它通过
get/set/deleteProperty等拦截器劫持整个对象的操作。 get负责依赖收集(调用track())。set负责派发更新(调用trigger())。
八、Vue2 vs Vue3 响应式对比
| 特性 | Vue2 (Object.defineProperty) | Vue3 (Proxy) |
|---|---|---|
| 实现方式 | 遍历对象属性,逐个定义 getter/setter | 直接代理整个对象 |
| 性能 | 初始化时需要递归遍历所有属性,大对象慢 | 懒代理,访问时才递归,性能好 |
| 新增属性 | ❌ 无法检测,需用 Vue.set | ✅ 天然支持 |
| 删除属性 | ❌ 无法检测,需用 Vue.delete | ✅ 天然支持 |
| 数组索引修改 | ❌ 需要特殊处理 | ✅ 天然支持 |
| Map/Set 支持 | ❌ 不支持 | ✅ 原生支持 |
| 内存占用 | 较高(每个属性都有 dep) | 较低(按需创建 dep) |
九、ref vs reactive 对比
| 特性 | ref | reactive |
|---|---|---|
| 适用类型 | 所有类型(基础类型 + 引用类型) | 仅引用类型(对象、数组) |
| 访问方式 | 需要 .value | 直接访问属性 |
| 替换整个对象 | ✅ 支持 | ❌ 会丢失响应性 |
| 模板中使用 | 自动解包,不需要 .value | 直接使用 |
| 内部实现 | 通过类实例的 getter/setter | 通过 Proxy 代理 |
| 推荐场景 | 基础类型、需要替换整个对象 | 对象/数组的属性修改 |
💡 最佳实践
- 优先使用 ref:统一心智模型,避免混淆。
- reactive 适合固定结构的对象:如表单数据、配置项等。
- 不要混用:避免在同一个组件中混合使用导致混乱。
十、面试高频回答
1. Vue3 为什么用 Proxy 替代 Object.defineProperty?
答: Proxy 有以下优势:
- 性能更好:不需要在初始化时递归遍历所有属性,而是访问时才代理(懒代理)。
- 功能更强:可以检测属性的新增和删除,支持 Map/Set 等数据结构。
- 数组支持完善:天然支持数组索引修改和 length 变化,无需特殊处理。
- 内存更省:按需创建依赖集合,而不是为每个属性都创建一个 dep。
2. ref 和 reactive 有什么区别?
答:
- ref 通过
.value访问,适合基础类型和需要替换整个对象的场景;内部通过类的 getter/setter 实现。 - reactive 直接访问属性,适合固定结构的对象;内部通过 Proxy 实现。
- 推荐优先使用 ref,保持统一的心智模型。
3. effect 是如何自动收集依赖的?
答: effect 执行前会把自身赋值给全局变量 activeEffect,然后执行传入的函数。函数内部访问响应式数据时会触发 Proxy 的 get 拦截,get 内部调用 track(),把 activeEffect 存入 targetMap。执行完成后将 activeEffect 置为 null。这样就完成了自动依赖收集。
4. 一句话概括整条链路?
答: effect 执行时收集依赖,数据修改时触发更新,effect 重新执行并更新视图。
十一、最终总结
初始化:
模板 -> render -> effect() -> Proxy.get -> track() -> 依赖收集 -> _update()
更新:
Proxy.set -> trigger() -> effect 重新执行 -> render -> _update()最需要记住的一句话是:
Vue3 通过 Proxy 劫持整个对象,effect 执行时自动收集依赖,数据修改时自动触发更新,完全不需要像 Vue2 那样遍历对象属性。