前言
"好记性不如烂笔头",本文章结合例子来带大家了解下vue3中的响应式原理,感兴趣的不妨继续往下阅读。
响应式原理
vue3的响应式原理,主要是利用 Proxy 来代理目标数据对象。通过代理,我们可以在对目标数据对象操作前做一些事情,例如:在获取对象的某个属性值的时候,把相应的函数存放起来,这个被保存起来的函数,我们不妨称之为"依赖函数";然后,在更新对象的这个属性值的之后,我们可以触发之前保存起来的"依赖函数"。这样一来,在数据更新时,我们就可以感知到数据的更新并且相应地做一些事情。
为了便于理解如何存放的依赖函数,放上一张图片更清晰直观:
图中,globalMap保存的是响应式对象,而响应式对象的每个key,又对应一个集合,用于保存依赖函数effect1, effect2, effect3, .....。结合图例,可以更好理解上面说的,例如,在依赖函数 effect1 中获取 obj1 的 key1 属性时,effect1 就会被存放起来,等 ojb1.key1 更新时,effect1 就会被触发。
实践
我们通过一个具体的简单例子来一步一步实现响应式原理。如下图所示,往输入框中输入内容,下方的输入框会相应展示输入内容。
Proxy使用
按照上述描述,利用 Proxy API代理对象,get 的时候收集依赖函数,set 的时候触发依赖函数,我们写出以下代码:
js
/** xxx/reactive/index.ts
* 响应式原理
*/
const PROXY_HANDLER = {
get(target: object, key: string){
const res: any = Reflect.get(target, key);
// TODO: 收集依赖
return res;
},
set(target: object, key: string, value: any){
const res = Reflect.set(target, key, value);
// TODO: 触发依赖
return res;
}
};
/**
* 创建响应式数据
* @param data 目标对象
* @returns 响应式对象
*/
export function reactive<T extends object>(data: T): T{
return new Proxy(data, PROXY_HANDLER) as T;
}
其中,TODO 注释,就是我们后续需要补充的操作:收集依赖和触发依赖
依赖收集/触发
如上面的图例所示,我们需要一个名为 globalMap 的数据结构来存放各个响应式数据,这里我们使用 WeakMap,相较于 Map,WeakMap 主要优势是当作为 key 值的对象销毁时,其在 WeakMap 中相应保存的值也会被销毁,性能上更好一些。感兴趣的朋友们可以自行搜索 WeakMap 的相关知识,这里不展开讲。此外,我们实际保存的,并不是依赖函数本身,而是依赖函数作为参数而创建出来的依赖实例。这里,我们用名为 ReactiveEffect 的类来接受依赖函数。其于此,我们需要一个变量,保存当前需要被收集的依赖实例,不妨名为 activeEffect。综上,我们得出以下代码:
js
/** xxx/effect/index.ts
* 依赖相关
*/
const globalMap = new WeakMap(); // 存放响应式对象
let activeEffect: ReactiveEffect | null; // 指向当前需要收集的依赖
// 接受依赖函数的类
export class ReactiveEffect {
fn: Func;
scheduler?: Func;
constructor(fn: Func, scheduler?: Func){
this.fn = fn;
this.scheduler = scheduler;
}
run(){
activeEffect = this;
const res: any = this.fn();
activeEffect = null;
return res;
}
}
接下来,我们实现具体的收集依赖以及触发依赖的函数,结合 Proxy,我们知道 get 和 set 函数都含有 target,key 这两个参数,而 target 指的是数据对象本身, key 指的是键名,正好有我们需要的数据。所以,我们的思路是:get函数触发的时候,把数据对象target存放到globalMap中,把依赖实例存放到key对应的集合中;set函数触发的时候也是一样,从globalMap中获取target对应的依赖映射,再从映射中获取key对应的依赖集合,把集合中的依赖都执行一遍即可:
js
// xxx/effect/index.ts
/**
* 收集依赖
* @param target 目标数据对象
* @param key 键名
*/
export function track(target: object, key: string): void{
if(!activeEffect) return;
// 1. 获取 target 对应的依赖映射
let effectMap = globalMap.get(target);
if(!effectMap) globalMap.set(target, ( effectMap = new Map() ));
// 2. 获取 key 对应的依赖集合
let effectSet = effectMap.get(key);
if(!effectSet) effectMap.set(key, ( effectSet = new Set() ));
// 3. 收集依赖
effectSet.add(activeEffect);
}
/**
* 触发依赖
* @param target 目标数据对象
* @param key 键名
*/
export function trigger(target: object, key: string): void{
// 1. 获取 target 对应的依赖映射
const effectMap = globalMap.get(target);
if(!effectMap) return;
// 2. 获取 key 对应的依赖集合
const effectSet = effectMap.get(key);
if(!effectSet) return;
// 3. 触发依赖
effectSet.forEach((effect: ReactiveEffect) => effect.scheduler ? effect.scheduler() : effect.run());
}
至此,收集依赖和触发依赖的逻辑就完成了,但是还差一样东西,那就是获取依赖函数的桥梁。因为上面的依赖收集和触发,都依赖 activeEffect,而该变量保存的是 ReactiveEffect 的实例对象,所以我们需要一个函数,把依赖函数提供给 ReactiveEffect ,从而创建出依赖实例对象,不妨把这个作为桥梁的函数命名为 effect:
js
// xxx/effect/index.ts
/**
* 生成依赖实例
* @param fn 依赖函数
* @param options 配置项对象
*/
export function effect(fn: Func, options?: EffectOptions){
const _effect: ReactiveEffect = new ReactiveEffect(fn, options?.scheduler);
_effect.run();
return _effect.run.bind(_effect);
}
现在,依赖相关的功能已经实现,Proxy 中的相关代码里的 TODO 事项,就可以补充完全:
响应式原理应用
写了那么多代码,接下来我们来实际应用下,看下具体效果如何。
首先,我们有这样一个页面结构:
id 为 reactiveInput 的输入框,用于接收我们的输入。id 为 effectInput 的输入框,用于展示我们的输入。
接下来,就是具体的验证逻辑了。先导入我们写好的 reactive 函数和 effect 函数,并且获取两个目标input输入框。
接着,创建响应式数据,并且监听 id 为 reactiveInput 的输入框的 change 事件,事件内更新响应式数据:
根据 console.log 语句,验证下响应式数据对应值是否发生改变:
可以看到,响应式数据发生相应改变,目前来说,符合预期。
接下来,就是最重要的部分了。声明一个名为 effectVal 的函数,内部逻辑就是把响应式数据赋值给 id 为 relativeInput 的输入框。然后把这个函数作为参数提供给 effect 函数执行:
接下来,就是验证响应式原理的时候了,我们改变上面输入框的内容,看看下方的输入框是否相应发生改变即可。
响应式原理概述
结合上面的例子以及写的代码,我们简单过一遍流程:
依赖收集流程
首先,我们使用 effect 函数,给 effect 函数提供了 effectVal 函数。effect函数执行时,会创建一个 ReactiveEffect 实例,之后会执行实例的 run 函数:
实例的 run 函数执行时,activeEffect 会把当前实例保存下来,接着执行提供的依赖函数,此处是 effectVal 函数:
依赖函数 effectVal 执行时,内部访问了响应式数据的某个属性(此处是 data.val):
访问响应式数据的属性,会触发 Proxy 中的 get 函数,从而依赖实例被收集起来:
收集的依赖实例,就是刚刚 activeEffect 保存的包含了 effectVal 函数的实例对象。至此,收集依赖流程走完了。
依赖触发流程
当我们通过输入框输入内容,改变响应式数据的某个属性值时(此处时,data.val)时:
该操作会触发 Proxy 的 set 函数,从而触发依赖:
触发依赖时,从globalMap中获取响应式对象对应的映射,在从映射中拿到键名对应的依赖集合,最后遍历集合,把每个依赖都触发一遍:
(图中的,scheduler忽略即可,后续讲到 computed 的实现时才用到,这里只关注 run 函数。)很明显,触发依赖执行实例的 run 函数时,会触发依赖函数,从而就达到了视图更新的目的。至此,依赖触发的流程也走完了。
总结
简单总结下,vue3的响应式原理,主要利用 Proxy API的代理能力,代理数据对象的get和set操作,在 get 函数触发的时候,即取值的时候,收集依赖;在 set 函数触发的时候,即更新值的时候,触发收集到的依赖,从而实现 数据更新 => 视图更新 的目的。
当然,get 和 set 只是响应式原理的其中一部分而已,毕竟还有属性的增、删等操作,感兴趣的朋友可可以自行查阅源码或相关文章看看其他情况的逻辑具体是怎样实现的。