前言
本篇文章,旨在说明 effect 的作用,即通过执行传入的回调函数,触发代理对象的 get(取值) 和 set(赋值),以实现对响应式数据的依赖收集和触发更新。
相关文章:
effect 解析
关于 effect 的解析,这里仅以其核心功能为主,不会过多考虑其细节。
参数
effect 函数接受两个参数:第一个是函数(简称 fn),第二个是对象(简称 options)。
- fn 函数,默认会先执行一次,而后当数据状态变化时,就重新执行,其实就是触发更新。
const { effect, reactive } = VueReactivity;
const data = reactive({ name: '哈哈', age: 18 });
// 通过 effect 收集 age 属性
effect(() => {
document.getElementById('app').innerHTML = data.age;
});
// 1 秒后将 age 的值改为 100
setTimeout(() => {
data.age = 100;
}, 1000);
传入 effect 的回调函数 fn,在执行后,会触发响应式对象 data 的 get 函数,这时就可以用一个函数——track,对访问的属性 age 进行收集。
而后,当我们修改 age 的值时,它就会触发 data 的 set 函数,这时就可以用一个函数——trigger,来更新属性 age 的值。
- options 对象中存在若干个属性,可让开发人员调用
effect时,做一些其它的操作。例如,scheduler这个属性,它能让开发人员自己决定如何进行数据更新。
const { effect, reactive } = VueReactivity;
const data = { name: '哈哈', age: 18};
const state = reactive(data);
let waiting = false;
const runner = effect(() => {
document.getElementById('app').innerHTML = state.age;
}, {
scheduler() {
console.log('scheduler-执行');
if(!waiting) {
waiting = true;
setTimeout(() => {
runner();
waiting = false;
}, 1000);
}
}
});
state.age = 1000;
state.age = 2000;
state.age = 3000;
在代码中,改写了三次 state.age,因此 scheduler 函数会被执行三次。但是,我们只想更新它最后一次,即最终结果的渲染 state.age = 3000。这样的话,我们就需要利用 scheduler 函数,在其内部写入逻辑代码来加以控制。
返回一个函数
effect 会返回一个 run 函数,这个函数可以让用户手动执行渲染。同时,run 函数上还挂载了 effect 的实例对象。
const { effect, reactive } = VueReactivity;
const data = { name: '哈哈', age: 22};
const state = reactive(data);
const runner = effect(() => {
document.getElementById('app').innerHTML = state.age;
},{});
console.log(runner, 'runner');
// 停止依赖收集
runner.effect.stop();
state.age = 90;
setTimeout(() => {
state.age = 100;
// 调用 runner 渲染
runner();
}, 2000);
调用 effect 时,会默认执行一次传入的函数 fn,所以初始值是22。当执行 runner.effect.stop() 时,则会停止依赖收集,因此 age 就不会被渲染,页面上的数字不会从 22 变为 90。
两秒后,由于 age 已改为 100,同时又调用了 runner() 进行渲染,所以页面数字变为 100。
相互嵌套
effect 函数可以层层嵌套,你可以将其看成一个树形结构。
const { effect, reactive } = VueReactivity;
const obj = { name: '哈哈', age: 18 };
const data = reactive(obj);
effect(() => { // parent = null activeEffect = e1
// name -> e1
data.name = '你好';
effect(() => { // parent = e1 activeEffect = e2
data.age = 200; // age -> e2
effect(() => { // parent = e2 activeEffect = e3
data.name = '测试'; // name -> e3
});
});
// age -> e1
data.age = 20;
});
每一个 effect 函数,在调用后,都会创建一个 effect 的实例。在源码中,activeEffect 变量,存的是当前正在执行的 effect 的实例,parent 存的是其父级的 effect 实例。因此,每一个 effect 函数中包含的需要渲染的属性,都有着其相对应的 activeEffect。
另外,每个当前的 effect 执行完后,需要将当前的 effect 实例——activeEffect,变成其父级的 effect 实例,而当前 effect 实例的父级——parent,则置为空。
这种实现方式和栈的工作原理(后进先出)相同,只是实现上有所差异。
effect 实现
创建 reactivity / src / effect.ts 文件模块,编写相应代码。
创建 effect 实例对象
// reactivity/src/effect.ts
export let activeEffect = undefined; // effect实例对象
// effect 类
export class ReactiveEffect {
// 控制依赖收集,激活状态(true)收集,非激活状态(false)不收集,默认为 true
public active = true;
// 当前 effect 实例对象的父级 effect 实例对象
public parent = null;
// deps 用于存储代理对象的各个属性所对应的 effect 实例对象
public deps = [];
// 构造函数
// ts 语法中,public fn,就相当于 this.fn = fn
constructor(public fn, public scheduler) {}
run() {
// 若是非激活状态,则不需要进行依赖收集,仅执行函数即可
if (!this.active) {
return this.fn();
}
// 依赖收集的核心:就是将当前的 effect 实例和将要渲染的属性关联到一起
try {
// 保存当前的 effect 实例,以及其父级 effect 实例
this.parent = activeEffect;
activeEffect = this;
// fn 就是传入 effect 的函数, 这里又传入了 ReactiveEffect 类中
// 当调用 fn 即执行 this.fn() 时,因其内部会对代理对象进行访问或修改,
// 所以,会触发代理对象的 get 或 set 函数。因此,我们可以通过这两个函数
// 可进行依赖收集,同时,也能够获取被导出的全局变量 activeEffect,即 effect 实例
return this.fn();
} finally {
// 执行完成后,进行重置。就是把当前的 effect 实例,变成其父级的 effect 实例
// 而当前 effect 实例的父级,则置为空。
activeEffect = this.parent;
this.parent = null;
}
}
}
// 响应式函数——effect
export function effect(fn, options) {
const scheduler = options ? options.scheduler : null; // 调度函数是否存在
const _effect = new ReactiveEffect(fn, scheduler); // 创建响应式实例对象
const runner = _effect.run.bind(_effect); // 绑定 this 指向
_effect.run(); // 默认执行一次
runner.effect = _effect; // 将 effect 挂载到 runner 函数上
return runner;
}
当执行 effect 函数时,就会通过 new ReactiveEffect(fn) 创建一个响应式实例 _effect ,而后调用 _effect.run() 实现依赖收集。
实例上的 run 方法,是实现响应式的关键所在,具体请参考代码中的注释。
依赖收集和数据更新
当我们执行 effect 时,其内部就会执行传入给它的函数 fn,若是 fn 内部有访问或修改代理对象(即响应式数据),那么就会触发其 get 或 set 函数。因此,我们需要在 createGetter 和 createSetter 这两个函数中,分别做依赖收集和数据更新。
- 在 createGetter 函数中调用
track函数,以实现依赖收集。
// reactivity / src / baseHandler.ts
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
// 省略...
// 依赖收集
track(target, 'get', key);
// Reflect.get 方法允许你从一个对象中取属性值。
const result = Reflect.get(target, key, receiver);
// 省略...
};
}
属性的依赖收集,需要在获取属性值之前完成,所以 track 要在 Reflect.get 之前调用。另外,为了代码可读性,这里仅贴出代码关键部分,大家可根据小编的上一篇文章或vue3 源码对比阅读。
- 在
createSetter函数中调用trigger函数,以实现数据更新。
// reactivity / src / baseHandler.ts
function createSetter(shallow = false) {
return function set(target, key, value, receiver) {
// 省略...
// Reflect.set 方法允许你在对象上设置属性。它返回一个 Boolean 值表明是否成功设置属性。
const result = Reflect.set(target, key, value, receiver);
// 是同一个对象,才能执行触发操作
if (target === toRaw(receiver)) {
// hadKey 为 false,表示对象执行新增操作,否则就是在修改。
// 这样判断,是为了区分当前对象执行的操作(新增或修改),以便明确如何触发。
if (!hadKey) {
trigger(target, 'add', key, value, oldValue);
} else if (hasChanged(value, oldValue)) {
trigger(target, 'set', key, value, oldValue);
}
}
// 省略...
};
}
属性值的更新,需要在设置属性值之后完成,所以 trigger 要在 Reflect.set 之后调用。另外,为了代码可读性,这里仅贴出代码关键部分,大家可根据小编的上一篇文章或vue3 源码对比阅读。
实现 track 和 trigger 函数
在 reactivity / src / effect.ts 中,定义并导出 track、trigger 函数。
// reactivity/src/effect.ts
// targetMap => { target: { key: new Set() } },
// 其中 target 是一个 Map,其中存着每个属性(key) 对应的 Set。
// WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行
const targetMap = new WeakMap();
// 依赖收集
// activeEffect -> effect 实例对象
// 每个属性(key),记录下其对应的 activeEffect 对象(可以有多个,但要避免重复),
// 每个 activeEffect 对象,记录下其收集过的属性(可以有多个,但要避免重复),
// 这种多对多的双向记录,便于清理不需要的对应关系。
export function track(target, type, key) {
// 当前 effect 实例对象是否存在
if (!activeEffect) return false;
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()));
}
trackEffects(dep);
}
export function trackEffects(dep) {
if (activeEffect) {
let shouldTrack = !dep.has(activeEffect);
if (shouldTrack) {
dep.add(activeEffect); // 记录属性对应的 activeEffect
activeEffect.deps.push(dep); // 记录 activeEffect 收集的属性
}
}
}
// 触发更新
export function trigger(target, type, key, value, oldValue) {
const depsMap = targetMap.get(target);
// 触发的对象不存在。例如,target 没有被收集过。
if (!depsMap) return false;
// 获取属性对应的 effect 实例对象
let effects = depsMap.get(key);
if (effects) {
triggerEffects(effects);
}
}
export function triggerEffects(effects) {
// 对于引用类型的对象,不要进行关联。执行之前,拷贝一份副本,用副本执行操作,以免造成死循环,导致栈溢出。
effects = new Set(effects);
effects.forEach(effect => {
// 避免同一个 effect 重复调用,不然将导致栈溢出
if (effect.scheduler) {
effect.scheduler(); // 若用户传入了调度函数,则进行调用
} else {
effect.run();
}
});
}
导入 track 和 trigger 函数
在 reactivity / src / baseHandler.ts 文件模块中,导入依赖收集和触发更新的函数:track、trigger。
import { track, trigger } from './effect';
关于数组方法 includes、indexOf, lastIndexOf 的收集问题
由于,我们在 createGetter 函数中,对数组做了特殊处理。就是下面这段代码。
// reactivity / src / baseHandler.ts
const targetIsArray = isArray(target);
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver);
}
当我们通过 includes、indexOf 和 lastIndexOf 访问数组时,会被拦截住,不会继续往下执行,也就是说无法完成依赖收集。
所以,我们需要修改 createArrayInstrumentations 代码,其实就是添加上 track 方法,完成对属性依赖的收集。
// reactivity / src / baseHandler.ts
function createArrayInstrumentations() {
// 省略...
['includes', 'indexOf', 'lastIndexOf'].forEach(key => {
// this -> 原数组的代理对象,args -> 传入数组方法中的参数
instrumentations[key] = function (this, ...args) {
// 通过 toRaw 返回原数组,避免死循环导致栈溢出
const arr = toRaw(this);
// 依赖收集
for (let i = 0, l = this.length; i < l; i++) {
track(arr, 'get', i + '');
}
// 通过数组的原生方法调用,倘若参数 args 是响应式的,则要用 toRaw 将其还原为原始对象,然后再调用。
const res = arr[key](...args);
// 'includes' 如果找到匹配的字符串则返回 true,否则返回 false
// 'indexOf' 和 'lastIndexOf' 如果没有找到匹配的字符串则返回 -1
if (res === -1 || res === false) {
return arr[key](...args.map(toRaw));
} else {
return res;
}
};
});
// 省略...
}
测试
倘若,你已完成上述代码,那么就已经实现了一个简易的 effect。下面,是测试案例。
测试案例:
const { effect, reactive } = VueReactivity;
const obj = { name: '哈哈', age: 18 };
const data = reactive(obj);
effect(() => {
document.getElementById('app').innerHTML = `${data.name},${data.age}`;
});
setTimeout(() => {
data.age = 101;
}, 1000);
结果展示:
结束
关于 effect,本篇文章在实现方面,可能存在叙述不详细的情况。因此,希望同学们还是跟着代码,实现一遍,只有写了,才能更好地理解代码以及注释部分。