本文已参与「新人创作礼」活动,一起开启掘金创作之路
本篇文章中我们会进一步扩展和优化我们的reactivity模块,主要包括以下几点:
- 实现
readonly,同时会重构reactive.ts的代码,将重复性代码抽离复用 - 实现
isReactive和isReadonly,用于判断对象类型 - 优化
stop,修复一个严重的bug,并根据TDD的思想,在通过单元测试后及时对代码进行重构 - 处理嵌套的
reactive和readonly,当对象中嵌套对象时让嵌套的对象也具有响应式的特性 - 实现
shallowReadonly,也就是浅只读对象,相当于没有嵌套的readonly - 实现
isProxy,用于判断对象是否是reactive或readonly的
1. 实现 readonly
readonly就是只读版本的reactive,也就是说不能触发set操作,那么就无法触发依赖,从而没必要进行依赖收集了,因此功能很简单,就是简单地进行一下代理,并且在执行set操作的时候会给用户警告,提示不允许set操作
先编写单元测试
// src/reactivity/tests/readonly.spec.ts
describe('readonly', () => {
it('happy path', () => {
const foo = { bar: 1 };
const observed = readonly(foo);
expect(observed).not.toBe(foo);
expect(observed.bar).toBe(1);
});
});
然后直接复制reactive的代码,将依赖收集和触发依赖的代码去掉,并且set中不进行真正的修改操作,只返回一个true
import { track, trigger } from './effect';
export function reactive(raw) {
return new Proxy(raw, {
get(target, key) {
const res = Reflect.get(target, key);
// 依赖收集
track(target, key);
return res;
},
set(target, key, value) {
const res = Reflect.set(target, key, value);
// 触发依赖
trigger(target, key);
return res;
},
});
}
export function readonly(raw) {
return new Proxy(raw, {
get(target, key) {
const res = Reflect.get(target, key);
return res;
},
set(target, key, value) {
return true;
},
});
}
现在能够通过单元测试了
1.1 重构 reactive.ts
可以发现,reactive和readonly的代码有很多相似的部分,可以考虑进行重构优化,首先抽离一下get,可以创建一个高阶函数createGetter,用于返回get函数,并且能够根据isReadonly来返回相应的get
import { track, trigger } from './effect';
function createGetter(isReadonly = false) {
return function get(target, key) {
const res = Reflect.get(target, key);
if (!isReadonly) {
// 依赖收集
track(target, key);
}
return res;
};
}
export function reactive(raw) {
return new Proxy(raw, {
get: createGetter(),
set(target, key, value) {
const res = Reflect.set(target, key, value);
// 触发依赖
trigger(target, key);
return res;
},
});
}
export function readonly(raw) {
return new Proxy(raw, {
get: createGetter(true),
set(target, key, value) {
return true;
},
});
}
为了代码的统一性,也可以抽离一个createSetter函数
import { track, trigger } from './effect';
function createGetter(isReadonly = false) {
return function get(target, key) {
const res = Reflect.get(target, key);
if (!isReadonly) {
// 依赖收集
track(target, key);
}
return res;
};
}
function createSetter() {
return function set(target, key, value) {
const res = Reflect.set(target, key, value);
// 触发依赖
trigger(target, key);
return res;
};
}
export function reactive(raw) {
return new Proxy(raw, {
get: createGetter(),
set: createSetter(),
});
}
export function readonly(raw) {
return new Proxy(raw, {
get: createGetter(true),
set(target, key, value) {
return true;
},
});
}
至于readonly的set由于逻辑不一样,因此就不使用createSetter了,直接保持原样即可,目前单元测试仍然能够通过,说明我们的重构没问题
但是还有可以重构的地方,不难发现,reactive和readonly的Proxy的第二个构造函数的对象内容都是get和set,因此可以考虑抽历成一个handlers
创建src/reactivity/baseHandlers.ts
import { track, trigger } from './effect';
function createGetter(isReadonly = false) {
return function get(target, key) {
const res = Reflect.get(target, key);
if (!isReadonly) {
// 依赖收集
track(target, key);
}
return res;
};
}
function createSetter() {
return function set(target, key, value) {
const res = Reflect.set(target, key, value);
// 触发依赖
trigger(target, key);
return res;
};
}
export const mutableHandlers = {
get: createGetter(),
set: createSetter(),
};
export const readonlyHandlers = {
get: createGetter(true),
set(target, key, value) {
return true;
},
};
现在的reactive.ts就简洁多了
import { mutableHandlers, readonlyHandlers } from './baseHandlers';
export function reactive(raw) {
return new Proxy(raw, mutableHandlers);
}
export function readonly(raw) {
return new Proxy(raw, readonlyHandlers);
}
再仔细看看,还有东西能够抽离!
reactive和readonly中都是return new Proxy,是一种低代码重复,为了让其语义更加明确一些,可以抽离成一个函数,并且起一个见名知意的函数名,提高代码可读性
import { mutableHandlers, readonlyHandlers } from './baseHandlers';
export function reactive(raw) {
return createActiveObject(raw, mutableHandlers);
}
export function readonly(raw) {
return createActiveObject(raw, readonlyHandlers);
}
function createActiveObject(raw: any, baseHandlers) {
return new Proxy(raw, baseHandlers);
}
至此我们整个reactive.ts重构完成了,单元测试依旧能够通过,重构没啥问题
2. 实现 isReactive 和 isReadonly
2.1 isReactive
isReactive是一个函数,作用是判断传入的对象是否是一个响应式对象,也就是是否是一个被reactive包裹过的对象
还记得前面重构的时候我们封装了一个createGetter函数吗?因为它能够区分reactive和readonly,并且我们稍后还需要实现isReadonly,因此我们会在createGetter中实现isReactive和isReadonly的功能
但为了屏蔽细节,我们仍应当封装单独的isReactive函数,而不是让用户调用createGetter
可以这样想:
- 访问响应式对象的属性时,会触发
get get是通过createGetter创建的,因此我们可以考虑在isReactive中访问某一个属性is_reactive- 如果是
reactive对象,那么访问这个属性能够触发到get - 于是我们就可以在
createGetter中判断访问的key是否是is_reactive - 是的话结合上
createGetter的isReadonly参数就可以判断当前的响应式对象是reactive还是readonly了
讲了这么多,可能还是不能理解,我们直接看代码吧!直接看代码可能会更好理解一些,首先编写单元测试:
// src/reactivity/tests/reactive.spec.ts
import { isReactive, reactive } from '../reactive';
describe('reactive', () => {
it('happy path', () => {
const original = { foo: 1 };
const observed = reactive(original);
// observed 和 original 应当是两个不同的对象
expect(observed).not.toBe(original);
expect(observed.foo).toBe(1);
// isReactive
expect(isReactive(observed)).toBe(true);
expect(isReactive(original)).toBe(false);
});
});
然后就可以去实现了,根据刚刚的思路描述,我们首先在reactive.ts中写一个isReactive函数
// src/reactivity/reactive.ts
export function isReactive(value) {
return value['is_reactive'];
}
这个函数很简单,就是直接去访问传入的对象的is_reactive属性,那么如果它是响应式对象,就会被我们的代理对象的baseHandlers中的get拦截,于是就可以在get里面结合createGetter的isReadonly判断它是reactive对象还是readonly对象
// src/reactivity/baseHandlers.ts
function createGetter(isReadonly = false) {
return function get(target, key) {
+ // isReactive
+ if (key === 'is_reactive') {
+ return !isReadonly;
+ }
const res = Reflect.get(target, key);
if (!isReadonly) {
track(target, key);
}
return res;
};
}
当访问的key是is_reactive时,说明调用的是isReactive函数进行判断的,那么我们只需要明确它不是一个readonly的getter就可以知道它一定是reactive对象了,所以直接返回!isReadonly即可
那么现在就算是完成isReactive了吗?不知道,得跑跑单元测试才知道
pnpm run test reactive --watch
可以看到,并没有通过单元测试,哦!原来是对普通对象的判断上出了问题,这也就体现了单元测试的重要性!
因为我们编写代码的时候可能会忽略对普通对象的判断,事实上我们真的忽略了,而单元测试就能够很好地提醒我们去完善我们的代码
那么现在思考一下,如果是对普通对象调用isReactive的话该怎么办呢?普通对象也就意味着不会触发我们的get拦截,而普通对象上面又没有is_reactive这一属性,因此如果是undefined的时候,我们直接返回false即可,也就是说要进行undefined到boolean的转换,可以直接用Boolean构造函数去强制转换,也可以用一个简洁的办法 -- !!
!!能够将一个元素转成对应的布尔值,第一个感叹号用于转成布尔值,但是由于也有取反的意思,因此得到的布尔值是相反的,那么我们再加一个感叹号进行取反,就可以得到正确的布尔值了
比如js中的undefined如果转成布尔值的话,大家都知道,会是false,但是我们需要先加一个感叹号,才能将其转成布尔值,但是加了一个感叹号的话就变成true了,因为感叹号同时还有取反的意思,所以再加一个感叹号即可变为正确的布尔值,也就是说!!undefined === false,等价于Boolean(undefined) === false
export function isReactive(value) {
- return value['is_reactive'];
+ return !!value['is_reactive'];
}
现在单元测试就通过啦!
在通过了单元测试的基础上,我们立马就要考虑一下代码是否可以进行优化,要及时重构,不要以为通过了测试就完事了,还要考虑到后期的维护性
我觉得有两点明显可以优化的地方:
- 不应该去访问
is_reactive属性,因为如果普通对象上也有这样一个属性的话那不就误判成普通对象也是reactive对象了吗?明显是不对的 is_reactive在多isReactive和createGetter闭包中的get函数中有使用到,而我们目前是直接硬编码成'is_reactive'字符串的,这会带来两个问题- 如果后期要修改这个属性名,就需要修改所有使用到它的地方,重复性工作量大,且容易遗漏
- 如果后期又有新的函数要使用到这个属性名,可能会有拼写错误的问题,而这是一个索引属性名,在编写的时候并不能知道它会不会报错,只有运行时才知道,不利于后续新人进来维护
首先我们来重构第一个点,为了属性名重复,可以使用ES6的新基本数据类型 -- Symbol,它可以保证属性名不重复,其实也可以定义一个大概率不会被普通用户使用的属性名,比如__v_isReactive这样的属性名,这也是vue3源码中的方案,但是我认为不能避免用户真的给普通用户定义了__v_isReactive这样一个属性名,因此我觉得还是用Symbol最靠谱,交给js底层去保证属性名不重复
至于第二个点,可以考虑定义枚举去使用属性名,但是由于typescript枚举不支持存储Symbol类型,因此我的方案是直接导出Symbol常量
import { mutableHandlers, readonlyHandlers } from './baseHandlers';
+ // ReactiveFlags
+ export const isReactiveSymbol = Symbol();
export function reactive(raw) {
return createActiveObject(raw, mutableHandlers);
}
export function readonly(raw) {
return createActiveObject(raw, readonlyHandlers);
}
export function isReactive(value) {
- return !!value['is_reactive'];
+ return !!value[isReactiveSymbol];
}
function createActiveObject(raw: any, baseHandlers) {
return new Proxy(raw, baseHandlers);
}
+ import { isReactiveSymbol } from './reactive';
function createGetter(isReadonly = false) {
return function get(target, key) {
// isReactive
- if (key === 'is_reactive') {
+ if (key === isReactiveSymbol) {
return !isReadonly;
}
const res = Reflect.get(target, key);
if (!isReadonly) {
track(target, key);
}
return res;
};
}
单元测试仍然没有问题,很好,说明重构没有影响到原来实现的功能
2.2 isReadonly
有了前面的经验,直接依葫芦画瓢即可实现 首先是编写单元测试
// src/reactivity/tests/readonly.spec.ts
describe('readonly', () => {
it('happy path', () => {
const foo = { bar: 1 };
const observed = readonly(foo);
expect(observed).not.toBe(foo);
expect(observed.bar).toBe(1);
+ expect(isReadonly(observed)).toBe(true);
+ expect(isReadonly(foo)).toBe(false);
});
});
然后是实现isReadonly函数
// src/reactivity/reactive.ts
export function isReadonly(value) {
return !!value[isReadonlySymbol];
}
最后是修改createGetter,添加对isReadonly的判断
function createGetter(isReadonly = false) {
return function get(target, key) {
// isReactive
if (key === isReactiveSymbol) {
return !isReadonly;
+ } else if (key === isReadonlySymbol) {
+ return isReadonly;
+ }
const res = Reflect.get(target, key);
if (!isReadonly) {
track(target, key);
}
return res;
};
}
单元测试通过,isReadonly功能实现!
3. 优化 stop 功能
修改一下之前我们的stop的单元测试
it('stop', () => {
// 将 dep 中的依赖清空
let dummy;
const foo = reactive({ bar: 1 });
const runner = effect(() => {
dummy = foo.bar;
});
foo.bar = 2;
expect(dummy).toBe(2);
// stop effect
stop(runner);
- foo.bar = 3;
+ foo.bar++;
expect(dummy).toBe(2);
// stopped effect should still be manually callable
runner();
expect(dummy).toBe(3);
});
现在却无法通过测试了
明明都是将
foo.bar修改为3,为什么之前能够成功stop,而现在却依然触发了依赖呢?
我们将foo.bar++拆开,本质上就是foo.bar = foo.bar + 1,涉及到了get和set操作,而原本的foo.bar = 3只涉及set操作
由于上一次执行完effect后,它内部的activeEffect是指向它本身的,即便执行完副作用函数后,也依然是指向它本身,虽然我们通过stop移除了依赖,但是由于触发了get操作,导致activeEffect指向的effect被重新收集起来了,相当于stop被还原了,因此set的时候依然会触发依赖
要解决这个问题,可以考虑用一个shouldTrack全局变量进行标记,并修改track函数,只当shouTrack为true时才会进行依赖收集
+ let shouldTrack // 是否应当收集依赖
export function track(target, key) {
// target -> key -> deps
let depMaps = targetMap.get(target); // key -> deps 的映射
if (!depMaps) {
// 不存在时需要初始化
depMaps = new Map();
targetMap.set(target, depMaps);
}
let dep = depMaps.get(key);
if (!dep) {
dep = new Set(); // dep 存放 target.key 的所有依赖函数
depMaps.set(key, dep);
}
if (!activeEffect) return;
+ if (!shouldTrack) return;
// 依赖收集 -- 将当前激活的 fn 加入到 dep 中
dep.add(activeEffect);
// 反向收集 effect 给 dep
activeEffect.deps.push(dep);
}
那么shouldTrack应该在哪里进行赋值呢?应当在run方法中修改
如果一个副作用函数没有被stop,也就是处于active状态的时候,就在执行副作用函数之前打开shouldTrack,而执行完毕之后要及时关闭shouldTrack;
如果副作用函数已经被stop,由于shouldTrack在每次执行完毕后都会被置为false,即便触发了get,导致执行track,也会被shouldTrack阻止,不会进行依赖收集
run() {
if (!this.active) {
// 已经被 stop 能来到这里都是手动执行 runner 才会进来的
return this._fn();
}
// 处于 active 状态
shouldTrack = true; // 打开 track 开关
activeEffect = this; // run 被调用时将当前 effect 对象标记为激活状态
const result = this._fn();
// reset -- 将 shouldTrack 关闭
shouldTrack = false;
return result;
}
现在单元测试就通过了!
好了趁热打铁,基于
TDD的思想,我们要立马看看代码有没有可以重构优化的地方
track函数中对activeEffect和shouldTrack的判断位置可以提前到函数开头,因为如果不需要进行依赖收集的话根本没必要走targetMap -> depsMap -> deps这一查询步骤
export function track(target, key) {
+ // 判断是否要进行依赖收集
+ if (!activeEffect) return;
+ if (!shouldTrack) return;
// target -> key -> deps
let depMaps = targetMap.get(target); // key -> deps 的映射
if (!depMaps) {
// 不存在时需要初始化
depMaps = new Map();
targetMap.set(target, depMaps);
}
let dep = depMaps.get(key);
if (!dep) {
dep = new Set(); // dep 存放 target.key 的所有依赖函数
depMaps.set(key, dep);
}
- if (!activeEffect) return;
- if (!shouldTrack) return;
// 依赖收集 -- 将当前激活的 fn 加入到 dep 中
dep.add(activeEffect);
// 反向收集 effect 给 dep
activeEffect.deps.push(dep);
}
为了让这两个判断语句更加易读,可以将它们放到一个函数中,起一个见名知意的函数名,然后在函数中进行判断
那么难点就在这里了,这样的一个函数名该起什么好呢?我们可以回想一下这两个判断语句的作用是什么,不就是用来判断当前的副作用函数是否需要收集起来吗?那么就可以命名为isTracking,表示当前的副作用函数是否处于被tracking的状态
export function track(target, key) {
- // 判断是否要进行依赖收集
- if (!activeEffect) return;
- if (!shouldTrack) return;
+ // 不是被 track 的状态则不需要进行依赖收集
+ if (!isTracking()) return;
// target -> key -> deps
let depMaps = targetMap.get(target); // key -> deps 的映射
if (!depMaps) {
// 不存在时需要初始化
depMaps = new Map();
targetMap.set(target, depMaps);
}
let dep = depMaps.get(key);
if (!dep) {
dep = new Set(); // dep 存放 target.key 的所有依赖函数
depMaps.set(key, dep);
}
// 依赖收集 -- 将当前激活的 fn 加入到 dep 中
dep.add(activeEffect);
// 反向收集 effect 给 dep
activeEffect.deps.push(dep);
}
+ /**
+ * @description 当前副作用函数 effect 对象是否处于被 track 状态
+ */
+ function isTracking() {
+ return shouldTrack && activeEffect !== undefined;
+ }
- 如果
activeEffect已经在deps中了,则不需要再执行add操作
export function track(target, key) {
// 不是被 track 的状态则不需要进行依赖收集
if (!isTracking()) return;
// target -> key -> deps
let depMaps = targetMap.get(target); // key -> deps 的映射
if (!depMaps) {
// 不存在时需要初始化
depMaps = new Map();
targetMap.set(target, depMaps);
}
let dep = depMaps.get(key);
if (!dep) {
dep = new Set(); // dep 存放 target.key 的所有依赖函数
depMaps.set(key, dep);
}
// 依赖收集 -- 将当前激活的 fn 加入到 dep 中
+ if (dep.has(activeEffect)) return; // 已经在 dep 中则无需再 add
dep.add(activeEffect);
// 反向收集 effect 给 dep
activeEffect.deps.push(dep);
}
cleanupEffect删除deps中所有的dep后,存储的都是一些空的Set对象,其实是没必要存储的,所以还可以将deps清空 --deps.length = 0即可
function cleanupEffect(effect) {
effect.deps.forEach((dep: any) => dep.delete(effect));
+ // deps 中所有的 dep 清空后,deps 数组中没必要存储空的 dep Set 对象了
+ effect.deps.length = 0;
}
4. 嵌套 reactive 和 readonly
当响应式对象中有对象时,我们也希望它是响应式的,这点该怎么做呢?递归!递归地将属性也变成响应式的就可以了,首先写一下单元测试
it('nested reactives', () => {
const original = {
nested: {
foo: 1,
},
array: [{ bar: 2 }],
};
const observed = reactive(original);
expect(isReactive(observed.nested)).toBe(true);
expect(isReactive(observed.array)).toBe(true);
expect(isReactive(observed.array[0])).toBe(true);
});
然后想一下该到哪里添加递归的逻辑呢?从单元测试就可以看出来,我们的重点是触发了响应式对象的get拦截,那么理应在get中进行,而get又是通过createGetter创建的,所以我们观察一下createGetter闭包是否有可以操作的地方:
function createGetter(isReadonly = false) {
return function get(target, key) {
// isReactive
if (key === isReactiveSymbol) {
return !isReadonly;
} else if (key === isReadonlySymbol) {
return isReadonly;
}
const res = Reflect.get(target, key);
if (!isReadonly) {
track(target, key);
}
+ if (isObject(res)) {
+ return reactive(res);
+ }
return res;
};
}
加一步判断,如果访问的是对象类型的属性,则不是直接返回该属性,而是将它用reactive包裹后再返回,这里isObject是一个通用函数,用于判断传入的变量是否是object类型,因此放在src/shared/index.ts中编写
export const isObject = (val) => val !== null && typeof val === 'object';
现在单元测试就通过了,那么同样的思路,实现一下嵌套的readonly吧,首先是单元测试:
it('should make nested value readonly', () => {
const original = { foo: 1, bar: { baz: 2 } };
const wrapped = readonly(original);
expect(wrapped).not.toBe(original);
expect(isReadonly(wrapped)).toBe(true);
expect(isReadonly(original)).toBe(false);
expect(isReadonly(wrapped.bar)).toBe(true);
expect(isReadonly(original.bar)).toBe(false);
});
然后是实现,修改一下前面的isObject(res)条件中的处理逻辑即可,根据isReadonly决定要用readonly还是用reactive去包装res
function createGetter(isReadonly = false) {
return function get(target, key) {
// isReactive
if (key === isReactiveSymbol) {
return !isReadonly;
} else if (key === isReadonlySymbol) {
return isReadonly;
}
const res = Reflect.get(target, key);
if (!isReadonly) {
track(target, key);
}
if (isObject(res)) {
- return reactive(res);
+ return isReadonly ? readonly(res) : reactive(res);
}
return res;
};
}
5. 实现 shallowReadonly
shallowReadonly是没有nested value的readonly,也就是不会嵌套地将readonly的对象属性也变成readonly,首先创建一个单元测试来描述一下它的功能
// src/reactivity/tests/shallowReadonly.spec.ts
describe('shallowReadonly', () => {
it('should not make non-reactive properties reactive', () => {
const props = shallowReadonly({ n: { foo: 1 } });
expect(isReadonly(props)).toBe(true);
expect(isReadonly(props.n)).toBe(false);
});
});
接下来要到reactive.ts中创建一个shallowReadonly函数
export function shallowReadonly(raw) {
return createActiveObject(raw, shallowReadonlyHandlers);
}
然后去实现一下shallowReadonlyHandlers
export const shallowReadonlyHandlers = extend({}, readonlyHandlers, {
get: shallowReadonlyGet,
});
因为shalloReadonlyHandlers的set和readonlyHandlers是一样的,为了让代码更加简洁,我们可以直接复用readonlyHandlers,但是用shalloReadonlyGet去覆盖原本的get
现在就要实现shallowReadonlyGet即可
const shallowReadonlyGet = createGetter(true, true);
这里我们给createGetter传入了第二个参数,这个参数表示是否是shallow的,如果是shallow则直接返回访问的属性,不需要递归去处理
- function createGetter(isReadonly = false) {
+ function createGetter(isReadonly = false, shallow = false) {
return function get(target, key) {
// isReactive
if (key === isReactiveSymbol) {
return !isReadonly;
} else if (key === isReadonlySymbol) {
return isReadonly;
}
const res = Reflect.get(target, key);
if (!isReadonly) {
track(target, key);
}
+ if (shallow) {
+ return res;
+ }
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res);
}
return res;
};
}
现在shallowReadonly就算实现完成了,单元测试可以通过了
6. 实现 isProxy
isProxy用于检测对象是否是reactive或者readonly,这功能一看就知道怎么写了,无非就是直接调用isReactive和isReadonly,然后用逻辑或把它们串起来就行了,功能虽然简单,但还是要先写单元测试!
// src/reactivity/tests/reactive.spec.ts
it('happy path', () => {
const original = { foo: 1 };
const observed = reactive(original);
// observed 和 original 应当是两个不同的对象
expect(observed).not.toBe(original);
expect(observed.foo).toBe(1);
// isReactive
expect(isReactive(observed)).toBe(true);
expect(isReactive(original)).toBe(false);
+ // isProxy
+ expect(isProxy(observed)).toBe(true);
+ expect(isProxy(original)).toBe(false);
});
// src/reactivity/reactive.ts
export function isProxy(value) {
return isReactive(value) || isReadonly(value);
}
单元测试通过