前言
在vue3中,通过reactive可以创建响应式数据,其实现核心是通过Proxy创建代理对象,使用或更改该代理对象会触发相应的方法如get,set,再通过get,set方法里的逻辑处理并与Reflect搭配使用实现响应式,要想明白其实现原理,需要先来了解一下Proxy和Reflect。
Proxy和Reflect介绍
代码示例:
javascript
//const proxy = new Proxy(target, handler)
//例:
const handler = {
// receiver指的是getter或setter的this值,也就是触发操作的对象,在这里指的是代理对象proxy_obj
get(target,key,receiver){
console.log('该值被读取了,读取的原对象是',target,'|读取的属性是',key,'|触发操作的对象是',receiver)
return Reflect.get(target,key,receiver)
},
set(target,key,value,receiver){
console.log('该值被设置了,设置的原对象是',target,'|设置的属性是',key,'|设置的值是',value,'|触发操作的对象是',receiver)
const result = Reflect.set(target,key,value,receiver)
return result
}
}
const proxy_obj = new Proxy({a:1},handler)
proxy_obj.a ++
/*
输出:
该值被读取了,读取的原对象是 { a: 1 } |读取的属性是 a |触发操作的对象是 { a: 1 }
该值被设置了,设置的原对象是 { a: 1 } |设置的属性是 a |设置的值是 2 |触发操作的对象是 { a: 1 }
*/
- Proxy是JavaScript中原生的构造函数,其会返回一个代理对象,我们在vue中执行const obj = reactive({name:'aaa'})时,obj就是这个代理对象 ,我们一般操作的也是obj,而不是直接操作其原对象{name: 'aaa'}。其固定传入两个参数,一个是target,也就是我们需要被代理的对象,在上面的例子中,被代理的对象就是{a:1};另一个是handler,是一个用于定义拦截代理对象的get,set操作后的执行逻辑的对象。
- Reflect是ES6提供的一个内置对象,提供一组静态方法以标准化、函数式的方式操作对象,其与Porxy方法一一对应,所以大部分场景都是两者搭配使用。比如proxy中有get,set和deleteProperty等方法,对应的在Reflect中也有Reflect.get,Reflect.set和Reflect.deleteProperty,用来获取,设置,删除指定对象的某个属性。
以Reflect.get(target, key, receiver)为例,返回的是target对象的key属性的值,那为什么不直接使用target[key]呢,原因是Reflect.get中的receiver代表了属性访问的上下文,也就是其this指向,直接使用target[key]则其this只能是target。当然还有proxy和Reflect语义一致等其他好处,这里就不展开了。
reactive核心原理
设计模式
reactive的实现采用了发布-订阅 的设计模式,以我们生活中的例子为例,微信公众号中公众号就是一个发布者,当我们去订阅(关注)这个公众号时,我们就成了订阅者,公众号那边就会记录下是谁关注了它,这个过程称为依赖收集 ,当有文章更新时,它会把文章推送给所有订阅者,这个过程就称为派发更新。那我们将这个原理映射到代码中:
js
const reactive_obj = reactive({
name: 'watermelon',
age: 18
})
function reactive(target){
const handler = {
get(target, key, receiver){
// 收集依赖
track(target, key)
return Reflect.get(target, key, receiver)
}
set(target, key, value, receiver){
const result = Reflect.set(target, key, value, receiver)
// 派发更新
trigger(target, key);
return result;
}
}
return new Proxy(target,handler)
}
数据存储结构
在探究track和trigger的具体实现前,先来了解一下实现响应式所使用的的数据存储结构。
- 首先最外层是通过一个targetMap(weakMap类型)存储所有响应式对象,其key是原对象target,也就是上面代码中的{ name: 'watermelon', age: 18 },其值是一个depsMap(Map类型)。
- depsMap的key是target里的属性,也就是示例中的name、age,其值是dep(set类型),用于存储某个属性的依赖列表。
targetMap使用weakMap类型是为了当其不再被引用时能够被垃圾回收,防止内存泄漏。

存储结构示意图
具体实现
副作用函数:在当前语境下,副作用函数是指依赖"依赖响应式数据"的函数,当数据发生改变时会重新执行,以实现自动更新,常见的有render(模板渲染函数),watch的回调函数等(在后面的代码实现中用effectFn模拟)。
- 执行流程:当vue创建一个组件实例时,会先调用一个包装了render的副作用函数注册器effect,在其内部会有一个副作用函数的包裹函数wrapperedEffect,这个wrapperedEffect用于执行副作用函数以及一些逻辑处理,其会先记录下当前活跃的副作用函数(通过activeEffect),再执行副作用函数effectFn,由于用到了响应式数据,此时会触发其getter,并执行track,在track内则可将这个activeEffect进行依赖收集。随后,执行state.age++后,会触发其setter,并执行其trigger,遍历其依赖列表进行派发更新,此时会输出更新后的数据。
- 依赖收集:按照前面的响应式的数据存储结构,将原对象target,访问的属性key,属性的依赖列表dep,按照(target,(key,dep))的结构存储在targetMap中。
- 派发更新:派发更新会通过要修改的原对象和其属性,得到(target,(key,dep))中的dep,也就是依赖列表,然后遍历执行依赖列表里的副作用函数,从而实现自动更新的效果。
- 在实际实现中,trigger里不是直接遍历执行的,为避免
effect
执行时又触发trigger
导致无限循环,Vue 会用Set
去重,将其推入异步队列并在微任务中执行。- activeEffect在实际实现中是栈结构,为支持嵌套
effect
,避免父子effect
覆盖。
js
let activeEffect = null
const targetMap = new WeakMap();
const state = reactive({
name: 'watermelon',
age: 18
});
function effect(fn) {
const wrappedEffect = () => {
try {
activeEffect = wrappedEffect;
return fn();
} finally {
activeEffect = null;
}
};
wrappedEffect();
return wrappedEffect;
}
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
dep.forEach(effectFn => {
effectFn();
});
}
}
const effectFn = () => {
console.log(`${state.name} is ${state.age}`);
}
effect(effectFn);
补充
上面就是reactive实现的核心原理了,当然在reactive内部会多一些处理,比如边界处理等。下面是一些reactive源码实现的补充,可以当做一些拓展。
1. reactive只能代理对象
在调用reactive创建响应式对象后,vue会进行判断,如果传入的值不是对象,如number,string等,会直接返回该值(开发环境下会在控制台输出错误提示)。
js
if (!isObject(target)) {
if (__DEV__) {
warn(
`value cannot be made reactive'}: ${String(target)}`,
)
}
return target
}
2.对shallow和readonly类型数据的处理
概念
- shallow:只代理第一层属性,不递归处理嵌套属性
- readonly:只读,禁止修改对象,尝试修改会警告
在reactive中的处理
- 在get函数中的处理(核心):
- 对shllow类型的处理:在普通的对象中,如果其属性还嵌套着对象,还会对其递归的响应式处理,如{a:1,b:{c:2}},在reactive内部会继续对属性b执行reactive(b),但如果是shallow类型,则会直接返回b;此外,shallow类型还会影响到ref的解包,比如const b = ref(2);const obj = {a:1,b},如果obj是个普通对象,会直接返回b.value,但如果obj是shallow类型,则不会进行解包,而是直接返回ref类型的b。
- 对readonly类型的处理:如果是reaonly类型,在get中不会进行依赖收集,因为其是只读的,不会被修改,也就不必收集依赖了;并且,在遇到嵌套对象时,如{a:1,b:{c:2}},在内部其会返回readonly(b),而不是reactive(b)。
js
// baseHandlers.ts
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
// 1. 依赖收集:只有非 readonly 才 track
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
const res = Reflect.get(target, key, receiver)
// 2. ref 解包:只有非 shallow 才自动解包
if (isRef(res)) {
return res.value
}
// 3. 浅层判断:如果是 shallow,直接返回,不递归
if (shallow) {
return res
}
// 4. 深层递归:如果不是 readonly,递归 reactive;否则递归 readonly
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
}
}
3. reactive的解构
在reactive中,是不支持响应式解构的,如果你将其属性解构出来,会使其失去响应式,需要通过toRefs方法恢复响应式。
js
const state = reactive({ count: 0, name: 'watermelon' })
// 此时你直接使用state是属性是没有响应式的
// 需要用 toRefs 把每个属性变成 ref
const { count, name } = toRefs(state)
count.value++ // 这样才会触发更新
4.其他方法:deleteProperty,has,ownKeys
在handler中的方法除了上面提到的get和set两个方法外,还有deleteProperty,has和ownKeys。
- deleteProperty:拦截
delete obj.key
,会在方法内执行删除操作和trigger - has:拦截
key in obj
,会在方法内部执行track - ownKeys:拦截
Object.keys()
、for...in
,会在方法内部执行track
5.对于集合类数据类型的处理
像上面使用get/set进行拦截的处理方法适用于Object和Array,对于集合类的数据如Map
Set
WeakMap
WeakSet
,其内部是使用get劫持其原型方法实现响应式的。那为什么不能像上面一样,直接使用get/set呢?原因在于集合类是通过方法来执行操作的,比如const map = new Map();map.set('count', 1) ,这不是对map的属性进行修改,无法触发set拦截器。所以vue的处理思路是:
- 用
get
拦截所有属性访问 - 当你访问
map.set
时,我返回一个包装过的set
方法 - 这个包装方法在调用前后插入
track
和trigger