Vue 3 源码解析(2)reactive 的实现(上)
本系列的文章 demo 存放于我的 Github 仓库,推荐大家下载和调试,进而加深理解。
一. 最简单的响应式示例
1.1 Proxy
Vue 3 抛弃了 Object.defineProperty
,改用 Proxy
来作为观察响应式对象变更的底层接口。 这是因为对于具有多个属性的对象来说,Proxy
只需调用一次便能监听到全部属性的变动,而 Object.defineProperty
每次调用时只能监听单个属性,需要通过遍历对象属性来达成完整的监听能力,效率很低:
/** Proxy 示例 **/
var obj = {
name: 'VaJoy',
country: 'China'
}
const observedObj = new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
console.log(`Getting ${key}...`);
return res
},
set(target, key, newVal, receiver) {
const res = Reflect.set(target, key, newVal, receiver);
console.log(`Setting ${key}...`);
return res
}
});
observedObj.name = 'Tom';
observedObj.country = 'USA';
// Setting name...
// Setting country...
/** Object.defineProperty 示例 **/
var obj = {
name: 'VaJoy',
country: 'China'
}
let value = obj.name;
Object.defineProperty(obj, 'name', {
get() {
console.log('Getting name...');
return value
},
set(newVal) {
console.log('Setting name...');
value = newVal;
}
});
obj.name = 'Tom';
obj.country = 'USA';
// Setting name...
上方
Object.defineProperty
只监听和打印出了name
属性的变动。 如果你对Proxy
还不熟悉,请先阅读相关的介绍文档。
1.2 简单的示例
利用 Proxy
的特性,我们可以实现一个最简单的响应式示例:
let viewEffect = () => {};
export const setViewEffect = (fn) => {
viewEffect = fn;
fn();
}
export function reactive(target) {
return new Proxy(target, {
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
viewEffect();
return res;
},
})
}
在该示例中,我们通过 Proxy
拦截了目标对象的属性赋值行为,让其在对属性进行赋值时,触发 viewEffect
函数。
viewEffect
则通过对外的 setViewEffect
接口,被重写为视图层和对象属性交互的副作用函数:
setViewEffect(() => {
div.innerText = obj.msg || ''
})
因此当 obj.msg
被赋值为 Bye bye.
时,Proxy
会拦截并触发被用户重新定义了的 viewEffect
函数的执行。
副作用(Side Effects)函数:会与外部可变状态进行交互的函数。
二. Track 和 Trigger
上述的示例存在很多问题,例如当用户每次重新调用 setViewEffect
接口时,viewEffect
函数都会被覆盖,导致之前定义过的副作用函数都失效了。
另外用户所定义的副作用函数,和响应式对象的属性没有任何映射关系,任何属性的改变都会触发副作用函数的执行,这也是很不合理的。
Vue 3 中使用了观察者模式来实现响应式事件的收集和分发,其收集、分发的行为由 track
和 trigger
方法实现:
const targetMap = new WeakMap();
// 追踪、收集依赖(会在 Proxy 的 get 拦截属性中调用该方法)
const track = (target, key) => {
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()));
}
dep.add(viewEffect);
}
// 触发(会在 Proxy 的 set 拦截属性中调用该方法)
const trigger = (target, key, value) => {
const depsMap = targetMap.get(target);
if (!depsMap) {
// 未被追踪过
return
}
const viewEffects = depsMap.get(key);
viewEffects.forEach(effectFn => effectFn());
}
track
会在响应式对象的属性被访问时,将该对象作为键存入名为 targetMap
的 WeakMap
容器中,对应值是一个以被访问属性为键,以属性被访问时的副作用函数的去重集合为值的 Map
对象。下图演示了两个响应式对象,在该 WeakMap
中存放的情况(红色的属性表示被访问过):
在响应式对象的属性被修改时,trigger
会从 targetMap
容器中检索到该属性先前被收集到的副作用函数集合,通过遍历该集合来执行每个副作用函数,即可达成响应式的效果。
我们将 track
和 trigger
放入 Proxy
中的 get / set
拦截器内,来完善先前的 reactive
函数:
const proxyMap = new WeakMap();
const targetMap = new WeakMap();
let viewEffect = () => { };
export const setViewEffect = (fn) => {
viewEffect = fn;
fn();
}
export function reactive(target) {
const existingProxy = proxyMap.get(target);
if (existingProxy) {
// 已被代理过,直接返回缓存的代理对象
// 避免重复被代理
return existingProxy
}
const proxy = new Proxy(
target,
baseHandlers
);
proxyMap.set(target, proxy);
return proxy
}
const baseHandlers = {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, key);
return res;
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
trigger(target, key);
return res;
}
}
// 追踪
const track = (target, key) => {
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()));
console.log(`Key "${key}" is traced...`);
}
dep.add(viewEffect);
}
// 触发
const trigger = (target, key) => {
const depsMap = targetMap.get(target);
if (!depsMap) {
// 未被追踪过
return
}
const viewEffects = depsMap.get(key);
console.log(`Trggering effects Functions of key "${key}"...`);
viewEffects.forEach(effectFn => effectFn());
}
注意此处我们还定义了一个名为 proxyMap
的 WeakMap
,作为响应式对象的缓存容器。这是因为同一个对象不需要被重复代理。
WeakMap
的键名所引用的对象都是弱引用(即垃圾回收机制不将该引用考虑在内),一旦该对象在WeakMap
外部的引用被清除了,WeakMap
里对应的该对象的键值对会同步被移除(无需手动从WeakMap
中删除)。这是 Vue 采用WeakMap
作为对象缓存容器类型的原因。
三、功能完善
3.1 嵌套属性处理
Proxy
只能拦截最外层属性的变更,若代理对象存在类型也是 Object
的属性值时,该属性对象的子属性将无法被同步代理到。
为了让存在多层属性的对象,其嵌套属性均可被正确地代理到,我们只需要在 get
拦截器中新增一个判断 —— 若被访问的属性对应值的类型为 Object
,将该属性值传给 reactive
方法进行递归调用:
// 略...
const baseHandlers = {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, key);
// 新增 - 类型为对象的嵌套属性处理
if (isObject(res)) {
return reactive(res);
}
return res;
},
set(target, key, value, receiver) {
// 略...
}
}
// 略...
3.2 完善拦截器
Proxy
所支持的拦截操作共有十来种,目前我们只对其中的 set
和 get
做了拦截。为了实现完整的响应式能力,还需增加对 has
、deleteProperty
、ownKeys
的拦截处理。
3.2.1 has 和 deleteProperty 拦截器
Proxy
的 has
拦截器可以拦截 key in target
的操作,我们需要对该操作做依赖收集(调用 track
);而 deleteProperty
拦截器可以拦截移除对象属性的 delete
操作,我们需要在该阶段触发先前收集到的依赖函数(调用 trigger
):
/** 新增 has 拦截处理 **/
has(target, key) {
const res = Reflect.has(target, key);
// 依赖收集
track(target, key);
return res;
},
/** 新增 deleteProperty 拦截处理 **/
deleteProperty(target, key) {
const hadKey = hasOwn(target, key);
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
trigger(target, key); // 调用依赖(收集到的副作用函数)
}
return res
}
3.2.2 ownKeys 拦截器
Proxy
的 ownKeys
方法会拦截对象自身属性的读取操作(如 for...in
),因此和 get
方法类似,我们需要在该阶段对依赖进行收集(调用 track
)。
但这里存在一个问题 —— ownKeys
方法只接收目标对象参数,不同于其它拦截器还有对象键名作为第二个参数。这是因为 ownKeys
所拦截的操作,都是遍历对象属性的方法(例如 Object.keys()
),即直接访问了全部可枚举的对象键名,而非单一的键名。
然而在对依赖进行收集时,却需要有一个凭证(例如对象键名)来将副作用函数存入 depsMap
容器才行。
此问题的解决方法也很简单 —— 可以通过 Symbol
方法生成一个唯一值,来作为 ownKeys
拦截阶段收集依赖、存入 depsMap
的凭证:
const ITERATE_KEY = Symbol();
const baseHandlers = {
// ...
ownKeys(target) {
track(target, ITERATE_KEY); // 统一使用该 Symbol 符号作为凭证
return Reflect.ownKeys(target);
}
}
前面提到了,ownKeys
所拦截的操作,会悄悄访问对象全部的可枚举属性,但它并不会触发 get
拦截器的执行。这意味着每个属性单拎出来,在 depsMap
中是没有存储任何该属性对应的依赖的。
这会导致用户修改对象属性时,ownKeys
阶段所收集的依赖无法被触发。
对此 Vue 的解决方案是,在 trigger
方法中,判断当前的操作是否添加属性或删除属性的操作,若是则提取在 ownKeys
阶段所收集到的依赖函数,压入当前阶段的副作用函数栈中去一并触发。
我们进一步完善 trigger
函数:
const trigger = (target, key, type) => { // 新增 type 参数
const depsMap = targetMap.get(target);
// deps 用于存放从 depsMap 取出的依赖函数
let deps = [];
// 只有 SET / ADD / DELETE 操作才存在 key
if (key !== void 0) {
deps.push(depsMap.get(key));
}
// 取出 ownKeys 拦截阶段收集的依赖
switch (type) {
case 'add':
case 'delete':
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY));
}
break;
}
let viewEffects = [];
for (const dep of deps) {
if (dep) {
viewEffects.push(...dep)
}
}
viewEffects = new Set(viewEffects); // 去重
viewEffects.forEach(effectFn => {
effectFn && effectFn()
});
}
调用 trigger
的地方需要传入新增的 type
参数:
const isIntegerKey = (key) =>
isString(key) &&
key !== 'NaN' &&
key[0] !== '-' &&
'' + parseInt(key, 10) === key;
const baseHandlers = {
// ...
set(target, key, value, receiver) {
// 判断当前操作属于“新增”还是“修改”
const hadKey = isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key);
const res = Reflect.set(target, key, value, receiver);
trigger(target, key, hadKey ? 'set' : 'add'); // 新增操作类型参数
return res;
},
deleteProperty(target, key) {
const hadKey = hasOwn(target, key);
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
trigger(target, key, 'delete'); // 传入 'delete'
}
return res
}
}
3.3 完善数组能力
3.3.1 trigger 部分的完善
上述的应用示例存在一个问题 —— 如果我们将被代理的对象更换为数组,再为数组增/删元素时,无法触发 ownKeys
阶段收集到的依赖函数:
import { setViewEffect, reactive } from './reactive.js';
const div = document.querySelector('div');
// 更改为数组
const arr = reactive(['a', 'b', 'c']);
setViewEffect(() => {
let html = '';
for(let s in arr) { // 触发 ownKeys 拦截器
html += arr[s];
}
div.innerText = html;
});
setTimeout(() => {
// 无法触发 ownKeys 阶段收集到的依赖 :(
arr[3] = 'd'
}, 2000);
解决上方的问题挺简单,在 trigger
方法中新增处理,对于添加类型的操作,如果传入的属性为数组索引,则取出 ownKeys
阶段收集的依赖。
具体调整如下:
const trigger = (target, key, type) => {
// ...
switch (type) {
case 'add':
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY));
} else if (isIntegerKey(key)) { // 若数组被修改的属性为索引字符串
deps.push(depsMap.get(ITERATE_KEY)) // 也取出 ownKeys 阶段收集的依赖
}
break;
// ...
}
// ...
}
这么修改后,之前的问题示例就能按预期正常运作了(div
会在两秒后显示 abcd
)。你可以点击这里进行在线调试。
对于数组而言,使用 length
来替代 ITERATE_KEY
,是个更好的选择。
这是因为 ownKeys
拦截阶段所收集的依赖,除了需要辐射到数组的每一个元素,也需要辐射到 length
(即 length
的变更也需要触发 ownKeys
的副作用函数)。
如果直接使用 length
,来作为把依赖存入 depsMap
的凭证,会更省事:
const baseHandlers = {
ownKeys(target) {
// 数组改用 length 为凭证
const key = isArray(target) ? 'length' : ITERATE_KEY;
track(target, key);
return Reflect.ownKeys(target);
},
// ...
}
const trigger = (target, key, type) => {
// ...
switch (type) {
case 'add':
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY));
} else if (isIntegerKey(key)) {
deps.push(depsMap.get('length')) // 更换凭证为 length
}
break;
// ...
}
// ...
}
另外我们还需考虑数组的 length
属性被外部直接修改了的场景,例如 arr.length = 2
。
这种情况下,一方面需要获取以 length
为凭证的依赖,一方面由于大于等于 length
的索引值所对应的数组元素被移除了,也需要获取这些被移除元素的依赖:
const trigger = (target, key, type, newValue) => { // 新增 newValue 参数
// ...
if (key === 'length' && isArray(target)) {
// 注意 Map 的 forEach 参数为 (MapValue, MapKey)
depsMap.forEach((dep, key) => {
// newValue 在这里为用户赋予 length 的新值
if (key === 'length' || key >= newValue) {
// 当含有以 length 为凭证的依赖,
// 或以被移除元素的索引值为凭证的依赖时,取出该依赖
deps.push(dep)
}
})
}
// ...
}
3.3.2 修复递归追踪问题
目前我们基本完善了 reactive
对数组的能力,但还存在一个容易被忽略的 bug —— 如果在副作用函数中调用了 push
等方法,会导致调用栈溢出。
这是因为 push
方法在修改数组前,会先隐式地访问 length
属性,导致 length
被追踪,接着将新元素压入数组时,又会触发新增元素索引的 set
拦截, 进而触发 length
收集到的副作用函数,副作用函数中又调用了 push
,导致程序进入了死循环。
import { setViewEffect, reactive } from './reactive.js';
let arr = reactive(['a', 'b', 'c']);
setViewEffect(() => {
// Uncaught RangeError: Maximum call stack size exceeded
arr.push('d');
});
会触发同样问题的还有 pop
、shift
、unshift
和 splice
方法,事实上这些方法都是更改数组的方法,它们的目的并不是为了获取属性。故当外部调用了这些方法时,是不应该做任何依赖收集的。
我们需要在 get
拦截器中,对这些方法做单独的处理,让它们绕过 track
。
💡
pop
、shift
、unshift
和splice
统称为数组的栈方法,它们在执行时都会隐式地去访问和改变数组的length
属性。以push
为例,你可以点击这里查看其执行流程。
我们尝试让这些方法在被 get
拦截时直接返回结果,不再调用 track
方法:
const arrayMethods = ['push', 'pop', 'shift', 'unshift', 'splice'];
const baseHandlers = {
get(target, key, receiver) {
const targetIsArray = isArray(target);
if (targetIsArray && arrayMethods.includes(key)) {
// 尝试直接返回,不再调用 track 方法
return Reflect.get(target, key, receiver);
}
const res = Reflect.get(target, key, receiver);
track(target, key);
// ...
},
// ...
}
但你会发现,此举仅仅是限制了对方法名的追踪,当这些方法执行时,依旧会触发对 length
属性的访问和追踪。
正确的处理是重写这些方法,让它们在执行的过程中暂停 track
的能力:
function createArrayInstrumentations(target) {
const instrumentations = {};
// 重写原生方法,让它们在执行时,暂停追踪
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
instrumentations[key] = function (...args) {
pauseTracking(); // 暂停 track
const res = target[key].apply(this, args);
resetTracking(); // 恢复 track
return res
}
})
return instrumentations // { push(...args){...}, pop(...args){...}, ... }
}
const baseHandlers = {
get(target, key, receiver) {
const targetIsArray = isArray(target);
const arrayInstrumentations = createArrayInstrumentations(target);
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
const res = Reflect.get(target, key, receiver);
track(target, key);
// ...
},
// ...
}
其中的 pauseTracking
和 resetTracking
将分别用于暂停/恢复 track
的能力,它们在 Vue 中的实现较简单:
// 标志变量,决定 track 方法是否应当追踪
export let shouldTrack = true;
const trackStack = [];
export function pauseTracking() {
trackStack.push(shouldTrack)
shouldTrack = false
}
export function resetTracking() {
const last = trackStack.pop()
shouldTrack = last === undefined ? true : last
}
const track = (target, key) => {
// 在 track 中判断是否应当追踪
if (!shouldTrack) return;
// ...
}
该代码段定义了一个统一判断是否应当执行追踪的标志变量 shouldTrack
,track
方法在执行时会先判断该变量,如果为 true
才执行追踪。
另外这里的 trackStack
用于存取 shouldTrack
的状态,方便在未来更复杂的场景下调度 track
的执行。
重写后就能在副作用函数中正常调用 push
等方法了:
let arr = reactive(['a', 'b', 'c']);
setViewEffect(() => {
// 不再陷入死循环
arr.push('d');
div.innerText = arr.reduce((p, c) => (p + c))
});
但会发现通过 push
插入的新元素,如果对其进行修改,副作用函数不会执行:
let arr = reactive(['a', 'b', 'c']);
setViewEffect(() => {
div.innerText = arr.reduce((p, c) => (p + c))
});
arr.push('d');
arr[3] = null; // 不会执行副作用函数
这是因为 push
方法在执行时暂停了依赖收集,在 set
拦截阶段执行副作用函数时会绕过 track
的执行,导致新元素的索引无法被追踪。
实质上我们只希望数组的栈方法在执行时,屏蔽掉对其自身的追踪即可,那么在副作用函数执行前我们应该恢复 track
的能力:
const trigger = (target, key, type, newValue) => {
// ...
viewEffects.forEach(effectFn => {
shouldTrack = true; // 副作用执行前要恢复 track 的能力
effectFn && effectFn()
});
}
3.3.3 toRaw
细心的读者可能会发现,在 get
拦截器中,每次拦截数组都需要传入 target
给 createArrayInstrumentations
方法来生成一个 arrayInstrumentations
对象。这种处理方法性能较低,这么写的原因只是为了支持动态传入 target
:
/** get 拦截器 **/
const baseHandlers = {
get(target, key, receiver) {
const targetIsArray = isArray(target);
// 动态传入 target 参数
const arrayInstrumentations = createArrayInstrumentations(target);
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
// ...
},
// ...
}
/** createArrayInstrumentations **/
function createArrayInstrumentations(target) {
const instrumentations = {};
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
instrumentations[key] = function (...args) {
pauseTracking();
// target 不能换成 this,否则会陷入死循环
const res = target[key].apply(this, args);
resetTracking();
return res
}
})
return instrumentations
}
在 createArrayInstrumentations
方法中,this
是 target
被 Proxy
代理后的响应式对象,如果我们将 target
换成 this
,那么 this[key]
不再是原生的数组原型方法,而是被重写后的方法,其执行时会陷入自我递归调用的死循环。
这意味着在 createArrayInstrumentations
中获取未被代理的原生对象,是必要的事情。
但我们依旧可以选择一种取巧的方式,来实现不传参也能获取原生的 target
的功能 —— 通过 get
拦截器中的属性规则匹配,从响应式对象自身获取。
改动如下:
/** get 拦截器 **/
get(target, key, receiver) {
// 新增属性匹配规则来获取原生引用
if (key === "__v_raw" && proxyMap.get(target)) {
return target;
}
const targetIsArray = isArray(target);
// ...
},
新增规则后,只需调用响应式对象的 __v_raw
属性,即可获取其原生引用。
我们再封装一个 toRaw
方法,方便统一调用:
function toRaw(observed) {
const raw = observed && observed["__v_raw"];
return raw ? toRaw(raw) : observed;
}
如此一来,我们便可轻松地将 createArrayInstrumentations
方法的调用移出 get
拦截器,避免了在 get
拦截阶段的重复创建:
/** get 拦截器 **/
const baseHandlers = {
get(target, key, receiver) {
if (key === "__v_raw" && proxyMap.get(target)) {
return target;
}
const targetIsArray = isArray(target);
// 此处不再需要调用 createArrayInstrumentations 方法
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
// ...
},
// ...
}
/** createArrayInstrumentations **/
function createArrayInstrumentations() { // 不再需要传入 target
const instrumentations = {};
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
instrumentations[key] = function (...args) {
pauseTracking();
// 通过 toRaw 获取原生数组
const res = toRaw(this)[key].apply(this, args);
resetTracking();
return res
}
})
return instrumentations
}
// 从 get 拦截器移到外面,只会执行一次
const arrayInstrumentations = createArrayInstrumentations();
3.3.4 检索方法处理
有时候我们会使用数组的 includes
、indexOf
、lastIndexOf
方法,来检索数组中是否包含某些内容,当传入的参数是一个对象,甚至是被代理过的响应式对象时,会导致非预期的结果:
import { setViewEffect, reactive } from './reactive.js';
const div1 = document.querySelector('#div-1');
const div2 = document.querySelector('#div-2');
const obj = {a: 1};
const observedObj = reactive(obj);
const arr = ['a', obj, 'c'];
const observedArr = reactive(arr);
const arr2 = ['a', observedObj, 'c'];
const observedArr2 = reactive(arr2);
// 下方都返回了 false,预期应该是 true
div1.innerText = 'arr 中是否包含了 obj 对象:' + observedArr.includes(obj);
div2.innerText += 'arr2 中是否包含了 obj 响应式对象:' + observedArr2.includes(observedObj);
我们可以使用 toRaw
方法,将调用数组检索接口的代理对象,以及传入的响应式参数,都转为其原生引用,从而解决此问题。
改动如下:
function createArrayInstrumentations() {
const instrumentations = {};
// 重写数组检索方法
['includes', 'indexOf', 'lastIndexOf'].forEach(key => {
instrumentations[key] = function (...args) {
const arr = toRaw(this);
for (let i = 0, l = this.length; i < l; i++) {
track(arr, "get", i + '');
}
const res = arr[key](...args);
if (res === -1 || res === false) {
// 匹配失败时,有可能是传入参数也属于响应式对象
// 将参数转为原生引用,再匹配一次
return arr[key](...args.map(toRaw));
} else {
return res;
}
};
});
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
// ...
})
return instrumentations
}
可以点击这里在线调试修改后的代码。
💡 未被代理过的原生对象,和经
Proxy
代理后的响应式对象,二者是不一样的,如果直接使用响应式对象,去调用原生对象的方法,可能会导致问题。这是reactive
响应式接口的重要知识点。
四、代码优化
4.1 减少冗余的 trigger 调用
如果现在尝试给一个响应式对象属性重复赋值,会发现即使赋予的值都是相同的,每次赋值都会触发副作用函数的执行:
const obj = reactive({ a: 1 });
setViewEffect(() => {
console.log(obj.b);
});
// 会打印三个 2
obj.b = 2; obj.b = 2; obj.b = 2;
如果副作用函数较为复杂,那么这种冗余的多次调用是很耗性能的。 更合理的方式是判断属性值是否真的变动了(对比赋值前后的内容),如果确实被改变了,再调用副作用函数。
对此,我们需要在 set
拦截方法中新增判断:
set(target, key, value, receiver) {
let oldValue = target[key]; // 新增
// 新旧值都有可能属于响应式对象
// 将它们都转为原生引用,方便做对比
value = toRaw(value);
oldValue = toRaw(oldValue);
const hadKey = isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key);
const res = Reflect.set(target, key, value, receiver);
if (!hadKey) {
trigger(target, key, 'add', value)
} else if (hasChanged(value, oldValue)) { // 新增判断
trigger(target, key, 'set', value)
}
return res;
},
其中工具方法 hasChanged
的实现如下:
export const hasChanged = (value, oldValue) =>
!Object.is(value, oldValue)
Object.is
是 ES6 语法糖,可用来比较两个值是否严格相等,与严格比较运算符(===
)的行为基本一致,但更符合 Same-value equality 的理念(例如两个NaN
相比的结果应为true
)。
此时如果再给响应式对象的属性重复赋值,如果赋予的值是相同的,则只会触发一次 trigger
,即只会执行一次副作用函数,进而提升了程序性能。
4.2 模块解耦
目前我们已经实现了一个功能基本完备的 reactive
接口,只是各个功能的代码块都写在了一起,这并不利于项目的阅读和维护。
我们可以把模块解耦处理,将其拆分为 reactive.js
、baseHandlers.js
、effect.js
三个小模块。
鉴于这是简单的代码梳理工作,故不在本文赘述,读者可以点击这里获取最终的代码。
转载自:https://juejin.cn/post/7124614170745995277