vue的响应式原理深度解读
这篇文章将深入探讨 Vue 的响应式原理。面试官若只听到 “Vue2 用 Object.defineProperty,Vue3 用 Proxy” 就算回答完成了,那只是门面——真正能打动人的是你把两者的“能力边界 / 规范映射 / 设计权衡 / 源码机制”讲清楚,并且把 Vue2/3 的依赖收集与调度流程串起来。
一、defineProperty 和 Proxy
要理解 Vue 的响应式,首先必须透彻理解其赖以实现的两种 JavaScript API。
1. 内部方法(internal methods)
ECMAScript 把对象操作抽象成一组内部方法,常见的有:
[[Get]](读取 obj[p])
[[Set]](赋值 obj[p] = v)
[[DefineOwnProperty]](Object.defineProperty(obj, p, desc))
[[Delete]](delete obj[p])
[OwnPropertyKeys]](Object.keys/for...in/Reflect.ownKeys)
[[Has]](in)
[[GetPrototypeOf]]、[[SetPrototypeOf]]、[[IsExtensible]]、[[PreventExtensions]]
等
调用相关的 [[Call]]
(函数调用)与 [[Construct]](new)
要点:所有 JS 代码的各种操作(点读写、in、Object.keys、delete、Object.defineProperty、函数调用等)最终对应到这些内部方法之一。
2. defineProperty
Object.defineProperty
是 ES5 提供的方法,它可以精确地为一个对象添加或修改属性,并允许开发者定义该属性的元信息(enumerable
, configurable
)和访问描述符(get
, set
)。
核心机制:
get
: 当访问该属性时,此函数会被调用。set
: 当该属性被赋值时,此函数会被调用。
Vue2 中的实现思路:
Vue2 通过递归遍历 data
函数返回对象的所有属性,将每个属性都转换为 getter
和 setter
。
// 极简的 Vue2 响应式原理模拟
function defineReactive(obj, key, val) {// 递归处理嵌套对象if (typeof val === 'object' && val !== null) {observe(val);}const dep = new Dep(); // 每个属性都拥有自己的依赖管理器Object.defineProperty(obj, key, {enumerable: true,configurable: true,get() {// 依赖收集:如果当前有 Watcher(例如渲染 Watcher),则将其加入依赖if (Dep.target) {dep.depend();}return val;},set(newVal) {if (newVal === val) return;val = newVal;// 触发更新:通知所有依赖的 Watcher 进行更新dep.notify();}});
}function observe(obj) {if (typeof obj !== 'object' || obj == null) return;// ... 遍历 obj 的每个 key,执行 defineReactive
}
缺陷:
- 无法检测对象属性的添加或删除:由于
Object.defineProperty
是在初始化时对已有属性进行拦截,后续新增的属性(obj.newProperty = ...
)或删除的属性(delete obj.property
)无法被追踪。这就是为什么 Vue2 需要Vue.set
和Vue.delete
这些 API 的原因。 - 数组拦截需要黑客手段:无法直接拦截数组的索引操作(
arr[index] = newValue
)和length
的变化。Vue2 通过重写数组的 7 个变更方法(push
,pop
,shift
,unshift
,splice
,sort
,reverse
)来实现响应式,这在实现上显得有些“补丁”感。 - 性能开销:初始化时需要递归遍历整个对象,将每一层属性都转化为
getter/setter
,如果对象嵌套很深,性能开销较大。
3. Proxy
Proxy
是 ES6 引入的全新概念,用于创建一个对象的代理,从而实现对目标对象基本操作的拦截和自定义。它不是定义属性的特性,而是直接代理整个对象。
核心机制:
Proxy
提供了一系列“捕捉器”(Trap),如 get
, set
, deleteProperty
, has
等,可以拦截对代理对象的几乎所有操作。
优势:
- 全面的拦截能力:可以拦截包括属性添加、删除在内的更多操作,从根本上解决了
Object.defineProperty
的先天不足。 - 原生数组支持:完美拦截对数组的任何操作,包括索引赋值和
length
修改。 - 更好的性能:Proxy 代理整个对象,无需在初始化时递归遍历所有属性。只有在某个属性被访问时,才会递归代理其内部值(惰性代理),这在性能和内存占用上更有优势。
- 更简洁的 API:无需额外的
Vue.set
/Vue.delete
API,操作方式更符合 JavaScript 原生习惯。
// 极简的 Vue3 响应式原理模拟 (reactive)
function reactive(obj) {if (typeof obj !== 'object' || obj == null) return obj;const handler = {get(target, key, receiver) {const res = Reflect.get(target, key, receiver);track(target, key); // 依赖收集// 惰性代理:只有当访问的是对象时,才返回它的 reactive 版本return typeof res === 'object' ? reactive(res) : res;},set(target, key, value, receiver) {const oldValue = target[key];const result = Reflect.set(target, key, value, receiver);if (result && 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 (result && hadKey) {trigger(target, key); // 触发更新}return result;}};return new Proxy(obj, handler);
}
简单总结一下,
defineProperty
只能对已有的具体属性通过get
/set
描述符做拦截,因而对复杂或嵌套对象必须递归遍历每个属性来代理,导致性能开销大;而且它的描述符只有读写拦截,无法捕获属性的新增、删除或数组的原生变异方法。Vue 2 的做法是加一层中间层:通过原型链改写数组方法、提供$set/$delete
等工具并在必要时包装属性来弥补defineProperty
的缺陷。相比之下,Proxy
能直接拦截对象的底层/基本操作(如 get、set、deleteProperty、ownKeys 等),不需要对每个属性递归代理,从而能更全面、自然地处理新增、删除和数组方法等场景。
那么,明明Proxy是2015年出的,而Vue2是2016年有的,为什么 Vue2 不采用 Proxy?
答案很简单:浏览器兼容性。Vue2 的开发始于2016 年,其设计目标需要覆盖包括 IE9+ 在内的主流浏览器。而 Proxy
是一个无法被 polyfill 的 API(它的功能无法用旧的 JavaScript 语法完全模拟)。为了最广泛的适用性,尤大大也就选择了 Object.defineProperty
这条更稳妥但更具挑战的道路。Vue3 诞生时,现代浏览器的普及度已足够高,使其可以毫无顾虑地拥抱 Proxy
这一更先进的方案。
二、Vue 的响应式系统
在讲解Vue响应式系统之前,我们先来思考一个问题,什么是响应式?是MVVM架构(数据驱动视图)?还是Proxy的数据代理?
这些说法都不准确,简单来说,响应式就是数据和函数得关联,数据变化,关联的函数重新运行
Vue 的响应式系统本质是一个自动化依赖追踪和派发更新的引擎。其核心目标是:当数据变化时,能自动、精准地找到依赖于这些数据的代码(副作用),并通知它们重新执行。
1. Vue2 的响应式系统:基于 [[Get]]
/[[Set]]
的精密模拟
Vue2 的核心挑战在于:仅凭 Object.defineProperty
(只能拦截 [[Get]]
和 [[Set]]
)来模拟一个完整的响应式代理。其架构围绕三大核心部件构建,形成了一个精巧的发布-订阅模型。
核心部件深度解析:
-
Observer
(观察者):- 职责:将一个普通的 JavaScript 对象转换为每个层级的属性都是响应式的对象。
- 实现机制:递归地遍历对象的所有自有属性(
Object.keys(obj)
),对每个属性执行defineReactive
函数。对于数组,Vue2 选择重写其 7 个变更方法(push
,pop
,shift
,unshift
,splice
,sort
,reverse
),在这些方法被调用时手动通知更新。这是一种对无法拦截[[DefineOwnProperty]]
(用于设置数组长度和索引)的妥协方案。
-
Dep
(依赖管理器):- 职责:为每个响应式属性管理一个依赖列表(订阅者列表)。它是连接数据和
Watcher
的桥梁。 - 实现机制:每个
Dep
实例有一个subs
数组,用于存储订阅它的所有Watcher
。它提供depend()
方法来收集当前活跃的Watcher
,以及notify()
方法来通知所有订阅者更新。 - 关键细节:
Dep
类有一个全局唯一的静态属性Dep.target
,它指向当前正在计算的Watcher
(例如正在渲染的组件)。这是一个巧妙的设计,避免了将Watcher
实例在函数间传递。
- 职责:为每个响应式属性管理一个依赖列表(订阅者列表)。它是连接数据和
-
Watcher
(观察者):- 职责:代表一个副作用,如组件的渲染函数、
computed
属性或用户定义的watch
。它负责执行副作用,并在执行过程中收集依赖。 - 实现机制:
- 实例化时,它会将自身设置为
Dep.target
。 - 然后执行其
getter
函数(例如,执行组件的render
函数)。 - 在执行
getter
的过程中,必然会访问响应式数据,从而触发数据的[[Get]]
内部方法,即我们定义的getter
拦截器。 - 在数据的
getter
拦截器中,会检查Dep.target
是否存在,如果存在,则调用当前属性dep
的depend()
方法,将当前Watcher
(Dep.target
)添加到自己的订阅列表中。同时,Watcher
也会记录这个dep
,形成双向关系。 getter
执行完毕,Watcher
将Dep.target
恢复为上一个状态(可能是另一个Watcher
,因为有嵌套计算的情况),完成依赖收集。- 当数据变化触发
setter
,最终会调用dep.notify()
,遍历subs
中的所有Watcher
,调用它们的update
方法。Watcher
的update
方法通常会将其放入一个异步队列(nextTick)中,等待所有同步数据变更完成后批量执行,避免不必要的重复计算和渲染。
- 实例化时,它会将自身设置为
- 职责:代表一个副作用,如组件的渲染函数、
完整流程与内部方法映射:
- 初始化 (
observe
): 遍历对象,对每个属性调用Object.defineProperty
,重写其[[Get]]
和[[Set]]
行为。 - 依赖收集 (
render
): 组件渲染 -> 创建渲染Watcher
-> 设置Dep.target
-> 执行render()
-> 触发[[Get]]
->getter
拦截器调用dep.depend()
->Watcher
记录dep
。 - 派发更新 (
data change
): 修改数据 -> 触发[[Set]]
->setter
拦截器调用dep.notify()
-> 通知所有Watcher
->Watcher
排队更新 -> 下一个事件循环执行getter
(重新渲染)并重新收集依赖(因为模板中用到的数据可能变化了)。
Vue2 的局限性根源(从内部方法看):
由于其实现基于 Object.defineProperty
,它只能拦截 [[Get]]
和 [[Set]]
。对于 [[DefineOwnProperty]]
(属性添加)、[[Delete]]
(属性删除)、[[OwnPropertyKeys]]
(for...in
)等操作无能为力,必须借助 Vue.set
/Vue.delete
等外部 API 来“手动”触发更新,破坏了 JavaScript 的操作原生性。
2. Vue3 的响应式系统:基于完整内部方法拦截的原生代理
Vue3 利用 Proxy
能拦截几乎所有内部方法的特性,构建了一个更强大、更直观的响应式系统。其核心概念从“属性-Dep-Watcher”演变为“目标-效果-依赖映射”。
核心概念深度解析:
-
reactive()
:- 职责:创建对象的深度响应式代理。
- 实现机制:直接
new Proxy(target, baseHandlers)
。baseHandlers
中定义了get
,set
,deleteProperty
,has
,ownKeys
等捕捉器,对应拦截[[Get]]
,[[Set]]
,[[Delete]]
,[[HasProperty]]
,[[OwnPropertyKeys]]
等内部方法。惰性代理:在get
trap 中,如果获取的值是对象,再递归地调用reactive()
将其也转为代理。
-
ref()
:- 职责:为基本类型值创建一个响应式引用,也可用于持有对象引用(替换
.value
会触发更新)。 - 实现机制:创建一个包装对象
{ value: ... }
,然后对这个包装对象调用reactive()
。在模板和reactive
对象中访问时会自动解包(触发[[Get]]
并返回.value
的值)。
- 职责:为基本类型值创建一个响应式引用,也可用于持有对象引用(替换
-
effect
(副作用):- 职责:替代 Vue2 的
Watcher
,是依赖追踪和执行的单元。组件的render
、computed
、watch
和watchEffect
都是基于effect
实现的。 - 实现机制:
- 执行
effect(fn)
时,会设置一个全局的activeEffect
。 - 立即执行传入的副作用函数
fn
。 - 在
fn
执行过程中,访问响应式数据会触发 Proxy 的get
trap。 - 在
get
trap 中,调用track(target, key)
建立依赖关系。
- 执行
- 职责:替代 Vue2 的
-
track()
(追踪) 与trigger()
(触发):- 职责:替代 Vue2 中分散在各个属性
dep
中的逻辑,集中管理整个应用的依赖关系图。 - 实现机制:
- 数据结构:依赖关系存储在一个嵌套的全局数据结构中:
targetMap: WeakMap<Target, Map<Key, Set<Effect>>>
WeakMap
的键是原始对象target
,值是一个Map
。这个Map
的键是target
的属性key
,值是一个Set
,里面存储了所有依赖于target[key]
的effect
。 track(target, key)
: 如果当前有activeEffect
,则将它添加到targetMap[target][key]
对应的Set
中。同时,effect
也会记录它所有的依赖Set
,用于清理失效依赖。trigger(target, key, type)
: 根据target
和key
从targetMap
中找到对应的effect
集合,并执行它们。type
参数(如 ‘ADD’, ‘DELETE’)对于优化for...in
和Array
的迭代操作至关重要。
- 数据结构:依赖关系存储在一个嵌套的全局数据结构中:
- 职责:替代 Vue2 中分散在各个属性
完整流程与内部方法映射:
- 初始化:
const state = reactive({...})
-> 创建 Proxy 代理。 - 依赖收集 (
setup
/render
): 组件setup -> 创建渲染effect
-> 执行渲染函数 -> 访问state.key
-> 触发[[Get]]
->get
trap -> 调用track(state, 'key')
-> 将当前activeEffect
(渲染effect) 存入targetMap[state]['key']
的Set
中。 - 派发更新: 修改
state.key = newValue
-> 触发[[Set]]
->set
trap -> 调用trigger(state, 'key', 'SET')
-> 从targetMap
中找到所有依赖于state['key']
的effect
-> 将它们调度执行(异步队列)-> 重新运行effect
(重新渲染)-> 重新追踪依赖。
Vue3 的优势体现:
- 全面性:直接拦截
[[Delete]]
(deleteProperty
trap) 和[[DefineOwnProperty]]
(ownKeys
trap 和set
trap 配合),无需Vue.delete
,操作即响应。 - 性能:惰性代理减少初始化开销;依赖关系存储在全局
WeakMap
中,管理更高效;Proxy
本身由浏览器原生实现,速度更快。 - 强大能力:可响应式地支持
Map
,Set
,WeakMap
,WeakSet
等集合类型,因为它们的所有方法(如map.get
,map.set
,set.add
) 也最终映射到内部方法,可被 Proxy 捕获。
Vue 的响应式核心在于依赖的自动追踪与更新触发。Vue2 基于
Object.defineProperty
,通过递归拦截对象的get
和set
来工作:在get
中收集依赖(将当前 Watcher 存入属性独有的 Dep 类),在set
中通知 Dep 去触发所有 Watcher 更新。这套方案需要为每个属性创建 Dep,无法原生拦截数组和属性增删,存在先天不足。
而 Vue3 的精妙之处在于全面拥抱 ES6 的Proxy
。Proxy 能拦截对象几乎所有的操作(包括get
,set
,deleteProperty
,has
等),实现了真正的“代理”。其依赖收集(track
)与更新触发(trigger
)机制也因此变得更加统一和高效:所有依赖关系被集中存储在一个全局的WeakMap
数据结构中,不再需要为每个属性实例化 Dep。当通过 Proxy 访问属性时,在get
陷阱中调用track
建立当前活跃的 effect(副作用,相当于 Watcher)与目标属性间的映射;当修改属性时,在set
等陷阱中调用trigger
,根据全局依赖表精准找出所有需要重新执行的 effect。这使得 Vue3 获得了原生支持数组和属性增删、惰性代理、更优性能等巨大优势,响应式系统也变得更为强大和优雅。
三、Vue3 的进阶响应式 API
在Vue3中除了ref和reactive之外,还提供了更多细粒度的响应式API,像什么shallowReactive、shallowRef、toRef、toRefs,这些API的出现得归功于Proxy,正因为它可以拦截对象得基本操作,所以才能让Vue3实现更灵活的响应式系统。
shallowReactive
:只代理对象的第一层属性,深层属性将保持原始状态。适用于性能敏感、不需要深度监听的大对象。shallowRef
:不代理.value
指向的对象。如果.value
是一个对象,其内部变化不会触发响应。toRef
/toRefs
:为了解决在解构reactive
对象时会丢失响应性的问题。toRef
:为reactive
对象的某个属性创建一个ref
,这个ref
会保持对其源属性的响应式连接。toRefs
:将reactive
对象的每个属性都转换为一个ref
。这在从组合式函数返回响应式对象时非常有用,方便使用者解构而不会失去响应性。
四、依赖收集与副作用
1. 核心基础:Effect 与依赖追踪(Effect / track / trigger)
- 要点:响应式的核心是副作用函数(effect与依赖追踪机制:读时收集依赖(track),写时触发更新(trigger)。运行中的 effect 被标记为
activeEffect
,track
把它注册到targetMap[target][key]
的dep
(Set);trigger
从该dep
里取出所有 effect 并调度执行(直接或通过 scheduler)。
- 下面通过一段极简的伪代码来看它是如何实现的:
const targetMap = new WeakMap();
let activeEffect = null;function effect(fn, options = {}) {const runner = () => {cleanup(runner);activeEffect = runner;fn();activeEffect = null;};runner.deps = []; runner.scheduler = options.scheduler;if (!options.lazy) runner();return runner;
}function track(target, key) {if (!activeEffect) return;let depsMap = targetMap.get(target) || new Map();let dep = depsMap.get(key) || new Set();dep.add(activeEffect);activeEffect.deps.push(dep);targetMap.set(target, depsMap);
}function trigger(target, key) {const depsMap = targetMap.get(target);if (!depsMap) return;const dep = depsMap.get(key);if (dep) dep.forEach(effect => effect.scheduler ? effect.scheduler(effect) : effect());
}function cleanup(effect) {effect.deps.forEach(dep => dep.delete(effect));effect.deps.length = 0;
}
track
在读时收集,trigger
在写时触发;effect 带scheduler
用于实现批量/异步调度与去重。
2. watchEffect(自动依赖 + 立即执行 + 可停止)
- 要点:立即执行传入函数,自动收集其在运行期间读取到的所有响应式依赖;依赖变化时重新运行。常用于副作用(日志、订阅、同步到外部)。返回
stop
停止并 cleanup。 - 伪码 / 使用骨架:
function watchEffect(fn, options={}) {const runner = effect(fn, { scheduler: () => queueJob(runner) }); // 不 lazy,默认立即 runreturn () => stop(runner); // stop 执行 cleanup 并移除依赖
}
- 异步与取消:通过
onInvalidate
(在 effect 内部注册 cleanup)来取消旧异步操作:框架在 effect 下次触发前会先调用已有的 cleanup。 - 面试要点:
watchEffect
=立即执行 + 自动依赖收集;不返回 oldValue,适合“不关心哪个依赖变了,只要有变化就做事”的场景。
3. watch(显式 source、惰性、old/new、onInvalidate、flush)
-
要点:
watch(source, cb, { immediate?, deep?, flush? })
:先将source
规范化为 getter(函数 / ref / reactive),创建 lazy effect(lazy: true
)。- 当依赖变化时由 scheduler 调用 job:在 job 中执行 runner 得到
newVal
,并把oldVal
传给用户回调cb(newVal, oldVal, onInvalidate)
;oldVal
更新为 newVal。 immediate: true
:注册时先执行一次 cb(old 可为 undefined 或初始化值)。deep: true
:强制遍历收集深依赖(成本高)。flush
控制回调时机(sync
/pre
/post
)。
-
伪代码:
function watch(source, cb, options = {}) {const getter = typeof source === 'function' ? source : () => traverse(source, options.deep);let oldValue, cleanup;const job = () => {const newValue = runner();if (cleanup) cleanup(); // optional cleanup before cbcb(newValue, oldValue, (fn)=> cleanup = fn);oldValue = newValue;};const runner = effect(getter, { lazy: true, scheduler: () => queueJob(job) });if (options.immediate) job(); else oldValue = runner();return () => stop(runner);
}
- onInvalidate 模式:用户在 cb 中调用
onInvalidate(fn)
注册清理函数,框架会在下一次 job 执行前调用它以作废旧异步。
watch
适合“需要 old/new 值或精确控制副作用时机”的场景;它本质是一个 lazy 的 effect + 用户级 scheduler。
4. computed(lazy + cached + 可被依赖)
- 要点:
computed
是一个带缓存的 lazy effect:内部用effect(getter, { lazy: true, scheduler })
;首次读取时运行并缓存结果;依赖变化时 scheduler 将dirty = true
并trigger
出去,但 不会立即重算,下次读取时才重算。computed 也可有 setter。 - 伪代码:
function computed(getterOrOptions) {const hasSetter = typeof getterOrOptions === 'object' && getterOrOptions.set;const getter = typeof getterOrOptions === 'function' ? getterOrOptions : getterOrOptions.get;let value, dirty = true;const dep = new Set(); // consumers of this computedconst runner = effect(getter, {lazy: true,scheduler: () => {if (!dirty) {dirty = true;triggerComputed(dep); // notify consumers that computed changed}}});return {get value() {trackComputed(dep); // allow outer effects to depend on this computedif (dirty) { value = runner(); dirty = false; }return value;},set value(v) { if (hasSetter) getterOrOptions.set(v); else throw Error; }};
}
computed = “读时计算、依赖变化时打标记(dirty)、外部读取触发实际计算”;它是实现性能优化(避免重复计算)的关键工具。