响应式系统的作用与实现(二)
这章主要是介绍非原始值的响应式方案。
1.理解Proxy和Reflect:
Vue3的响应式数据是基于Proxy实现的,那么我们非常有必要了解Proxy和Refelct。
参考资料:阮一峰的 官方参考资料
简单来说,Proxy代理对象可以拦截我们对原对象的操作,即属性值的增删改查。Reflect反射对源对象的属性进行操作。
可能这样说还是有点抽象,看个简单的例子理解下:
const p = new Proxy(obj, {
// 拦截读取操作
get() {/* */ },
// 拦截设置操作
set() {/* */ }
})
const obj = { foo: 1 }
// 直接读取
console.log(obj.foo); // 1
// 使用Reflect.get 读取
console.log(Reflect.get(obj, 'foo')); // 1
前面我们实现的响应式代码,其实有一个问题,没有用Reflect.get和Reflect.set进行读取和设置:
const p = new Proxy(obj, {
get(track, key) {
track(track, key);
// 没有使用Reflect.get完成读取
return target[key];
},
set(target, key, newVal) {
// 没有使用Reflect.set完成设置
target[key] = newVal;
trigger(target, key)
}
})
借助一个例子来分析一下,如果不借助Reflect来进行读取和设置,会出现的情况:
const obj = {
foo: 1,
get bar() {
return this.foo
}
}
effect(() => {
console.log(p.bar);
})
p.foo++;
- 当effect副作用函数执行时,读取
p.bar
的属性,然后执行getter
函数,函数内部通过this.foo
读取了foo属性值。 - 所以我们想要的结果应该是当我们修改
p.foo
的值也能触发响应,重新执行副作用函数,但是当我们修改p.foo
的值,副作用函数并没有重新执行。 - 问题就出在bar里的访问器函数
getter
中的this指向的是 obj,而不是代理对象p,所以此时不会发生响应。这时就需要借助Reflect.get来解决。 - 简单来说上面出现的原因就是,当我们通过代理对象p访问 p.bar,触发get拦截函数执行,里面通过 target[key]返回属性值 ,等价于 obj.bar,故此时的this指向obj。
解决:通过代理对象get拦截函数的第三个参数receiver,它代表谁在读取属性,可以把它简单理解为函数调用中的this。(修改后即this指向代理对象p)
const p = new Proxy(obj, {
get(track, key) {
track(track, key, receiver);
// 修改这部分代码
return Reflect.get(target, key, receiver)
},
set(target, key, newVal, receiver) {
target[key] = newVal;
trigger(target, key, receiver)
}
})
2.JavaScript对象及Proxy的工作原理:
常常听到 ”JavaScript中一切皆对象“,那到底什么是对象呢?查阅ECMAScript规范发现,在JS中对象分为两种:常规对象 和 异质对象。任何不属于常规对象的对象都是异质对象,在了解它们两个之前,我们先来了解对象的内部方法和内部槽。
在JavaScript中,函数其实也是对象,如果我们给出一个对象obj,如何区分它是普通对象还是函数?实际上在JS中,对象的实际语义是由对象的内部方法指定的,内部方法指的是当我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于JS使用者来说是不可见的。
举个例子:当我们执行 obj.foo
访问对象属性时,引擎内部调用 get
这个内部方法来读取属性值。
对象必要的内部方法:
- 由上图可知,一个对象必须部署11个必要的内部方法,还有两个额外的必要方法,即:
那么,我们就可以回答前面的问题了:
- 如何区分一个对象是普通对象还是函数?一个对象在什么情况下才能作为函数调用?
- 即通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法
[[call]]
,普通对象则不会。 - 如果一个对象需要作为函数调用,这个对象就必须部署内部方法
[[call]]
。
内部方法具有多态性,即不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。比如Proxy对象和普通对象都部署了 [[get]]
这个内部方法,但它们的逻辑却不同。
了解完这些之后,就可以了解什么是常规对象,什么是异质对象了。
满足以下三点要求就是常规对象(了解即可):
- 对于上图中国列出的内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义实现;
- 对于内部方法 [[Call]],必须使用 ECMA 规范 10.2.1 节给出的定义实现;
- 对于内部方法 [[Construct]],必须使用 ECMA 规范 10.2.2 节给出的定义实现。
不符合这三点的就是异质对象了(比如Proxy)。
接下来就来具体看一下Proxy对象,既然Proxy是对象,那它本身也部署了上面必要的内部方法,当我们通过代理对象访问属性值时,引擎会调用部署在对象p上的内部方法 [[Get]]
。Proxy代理对象和普通对象的区别在于对 内部方法 [[Get]]
的实现上(多态性)。具体的区别在于当我们创建代理对象时如果没有指定对应的拦截函数(get()拦截),那么当我们通过代理对象访问属性值时,代理对象的内部方法就调用原始对象的 [[Get]]
来获取属性值。
以上便是代理透明性质。创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。关于Proxy对象部署的所有内部方法等感兴趣可以在mdn上看看。
3.如何代理Object:
上面的都介绍完之后,我们将着手实现响应式数据。在响应式系统中,"读取"是一个很宽泛的概念,下面来看一下普通对象所有可能的读取操作。
- 访问属性:obj.foo
- 判断对象或原型上是否存在给定的key: key in obj
- 使用for…in循环遍历对象: for(const key in obj) {}
下面开始逐步讨论如何拦截这些读取操作,首先是对于属性的读取,例如obj.foo,这可以通过get拦截函数实现。但是对于后面两种该如何实现呢?
我们先来看in操作符的对应拦截,该如何实现?先来看看它的描述:
关键点在于第6步,in操作符的运算结果是通过调用一个叫做 HasProperty
的抽象方法得到的,它的操作如图所示:
在第3步中,可以看到hasProperty
抽象方法的返回值是调用对象的内部方法 [[HasPropery]]
得到的,而这个内部方法可以在上面第二节的图中找到,它对应的拦截函数叫has,这样我们就可以通过has拦截函数实现对in操作符的代理:
const obj = { foo: 1 }
const p = new Proxy(obj, {
get(track, key) {
track(track, key);
return Reflect.has(target, key)
}
})
这样,就解决了如果在副作用函数中通过in操作符操作响应式数据时,就能够建立依赖关系了。
再来看看如何拦截 for ...in
循环,由于这部分规范太多,因此省略了,感兴趣的佬们可以自己去看下~ 来看下关键的部分:
for...in
头部的执行规则:
上图中第6步的第c子步骤:关键点在于 EnumerateObjectProperties(obj)
,它是一个抽象方法,该方法返回一个迭代器对象,如下代码:
// 它是一个generator函数,接收一个参数obj,obj就是被for...in循环遍历的对象
function* EnumerateObjectProperties(obj) {
const visitd = new Set();
// 关键点在于:使用Reflect.ownKeys(obj)来获取只属于对象自身拥有的键
for (const key of Reflect.ownKeys(obj)) {
if (typeof key === "symbol") continue;
const desc = Reflect.getOwnPropertyDescriptor(obj, key);
if (desc) {
visitd.add(key);
if (desc.enumerable) yield key;
}
}
const proto = Reflect.getPrototypeOf(obj);
if (proto === null) return;
for (const protoKey of EnumerateObjectProperties(proto)) {
if (!visitd.has(protoKey)) yield protoKey;
}
}
看上面的代码和注释里的关键部分,如何拦截for...in
其实就是使用ownKeys拦截函数来拦截Reflect.ownKeys操作:
const obj = { foo: 1 };
const ITERATE_KEY = Symbol();
const p = new Proxy(obj, {
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, ITERATE_KEY);
// 拦截ownKeys操作即可间接拦截 for...in 循环
return Reflect.ownKeys(target);
}
})
解释上面使用track函数进行追踪的时候,将 ITERATE_KEY
作为追踪的key的原因:
- ownKeys拦截函数跟get/set拦截函数不同,在get/set中,我们可以得到具体操作的key,但是在ownKeys中,我们只能拿到目标对象的target。
- 其实很直观,在读写属性值时,我们能够知道当前正在操作哪一个属性,所以只需要在该属性与副作用函数之间建立联系即可。
- 而ownKeys用来获取一个对象所有属于自己的键值,这个操作明显不与任何具体的键进行绑定,因此只能够构建唯一的key作为标识,即
ITERATE_KEY
。
既然追踪的是 ITERATE_KEY
,那么触发响应时也应该触发它才行:
trigger(target,ITERATE_KEY);
但是什么情况下,对数据的操作需要触发与 ITERATE_KEY
相关联的副作用函数重新执行?
先来看一段代码:
const obj = { foo: 1 };
const p = new Proxy(obj, {/* */ })
effect(() => {
for (const key in p) {
console.log(key);
}
})
副作用函数执行后,与 ITERATE_KEY
之间建立响应联系,接下来为对象p添加新的属性bar:
p.bar = 2;
由于对象p原本只有foo属性,因此for...in
循环只会执行一次。现在为它添加了新的属性bar,循环会执行两次。也就是说当为对象添加新属性时,会对for...in
循环产生影响,所以需要触发与 ITERATE_KEY
相关联的副作用函数重新执行,但是目前我们实现的还做不到这一点,当添加p.bar时副作用函数没有重新执行,看一下现在的set拦截函数实现:
const p = new Proxy(obj, {
// 省略get...
set(target, key, newVal, receiver) {
const res = Reflect.set(target, key, newVal, receiver);
trigger(target, key);
return res;
}
})
根据上面的代码进行分析:当为对象p添加新的bar属性时,触发set拦截函数执行。此时set拦截函数接收到的key 就是 “bar” ,因此最终调用 trigger
函数时只触发了与 "bar"相关联的副作用函数重新执行。但根据前面的介绍, for…in循环是在副作用函数与 ITERATE_KEY
之间建立联系,这和"bar"一点关系也没有,所以当我们 尝试执行 p.bar = 2;
时,并不能正确地触发响应。解决方案:
function trigger(target, key, type) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
const effectsToRun = new Set();
// 将与key相关联的副作用函数添加到 effectsToRun
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
if (type === "ADD") {
// 取得与 ITERATE_KEY 相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY);
// 将与 ITERATE_KEY 相关联的副作用函数添加到 effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
}
effectsToRun.forEach(effectFn => { // 修改这部分
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
const p = new Proxy(obj, {
// 省略get...
set(target, key, newVal, receiver) {
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
// Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
const res = Reflect.set(target, key, newVal, receiver);
// 将type作为第三个参数传递给 trigger函数
trigger(target, key, type);
return res;
},
// 省略别的拦截函数
})
解释:这里需要用到type类型的原因,可以解决不必要的内存开销,在set拦截函数内部区分操作的类型,到底是添加新属性还是设置已有属性。(因为添加新属性 for.in 循环两次,修改一个属性的值,for…in 循环一次)
至此,对象的增改查代理就搞定了,还差一个删除操作的代理。
delete p.foo
,老样子先看规范的重点部分:
由第5步中的d子步骤可知,delete操作符的行为依赖 [[delete]]
内部方法,查看上面的图可知,该内部方法可以使用 deleteProperty拦截
:
const p = new Proxy(obj, {
deleteProperty(target, key) {
// 检查被操作的属性是否是对象自己的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
// 使用 Reflect.deleteProperty 完成属性的删除
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
// 只有当被删除的属性是对象自己的属性,并且删除成功时才触发更新
trigger(target, key, 'DELETE');
}
return res;
}
})
并且由于删除操作使得对象的键变少,会影响 for...in
循环的次数,所以当操作类型为 'DELETE’时,触发与 TERATE_KEY
相关联的副作用函数重新执行,下面这段代码中,比之前仅添加了 type === 'DELETE’判断,使得删除属性操作能够触发与之相对应的副作用函数重新执行:
function trigger(target, key, type) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
const effectsToRun = new Set();
// 将与key相关联的副作用函数添加到 effectsToRun
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
// 当操作类型为 ADD 或 DELETE时,需要触发与ITERATE_KEY相关联的副作用函数重新执行
if (type === "ADD" || type === 'DELETE') {
// 取得与 ITERATE_KEY 相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY);
// 将与 ITERATE_KEY 相关联的副作用函数添加到 effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
}
effectsToRun.forEach(effectFn => { // 修改这部分
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
4.合理地触发响应:
经过上一节的学习,规范得代理对象就完成了,在这个过程中处理了很多边界条件。比如操作的类型到底是"ADD"还是"SET",或者是其他操作类型,从而正确地触发响应。
-
来看我们面临的第一个问题,当值没有发生变化时,不需要触发响应才对,所以我们需要在set拦截函数里面进行修改。即调用trigger函数触发相应之前,检查值是否真的变化了。
const p = new Proxy(obj, { set(target, key, newValue, receiver) { // 先获取旧值 const oldValue = target[key]; const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'; const res = Reflect.set(target, key, value, receiver); // 比较新值与旧值 if (oldValue !== newValue) { trigger(target, key, type) } return res; }, // 省略其它拦截函数... })
-
第二个问题,当重新设置相同的值时也不应该触发响应。
-
但是仅进行全等比较是有缺陷的,这体现在NaN的处理上:
NaN === NaN; // false NaN !== NaN; // true
-
所以在新值和旧值不全等的情况下,保证它们都不是NaN:
const p = new Proxy(obj, { set(target, key, newValue, receiver) { // 先获取旧值 const oldValue = target[key]; const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'; const res = Reflect.set(target, key, value, receiver); // 比较新值与旧值,当它们不全等且都不是NaN的时候才触发响应 if (oldValue !== newValue && (oldValue === oldValue || newValue === newValue)) { trigger(target, key, type) } return res; }, // 省略其它拦截函数... })
这样就解决了NaN的问题。接下来,我们讨论一种从原型上继承属性的情况,但是先让我们来封装一个 reactive函数,该函数接收一个对象作为参数,并返回为其创建的响应式数据。
-
5.封装reactive函数:
后续详细的mini-vue代码我会放到仓库或者博客中。
目录结构:
utils包下:
// utils/index.js
export function isObject(target) {
return typeof target === 'object' && target !== null;
}
export function hasChanged(newValue, oldValue) {
return newValue !== oldValue && (newValue === newValue || oldValue === oldValue);
}
export function isArray(target) {
return Array.isArray(target);
}
reactivity包下:
// reactivity/reactive.js
import { hasChanged, isObject, isArray } from '../utils/index.js';
import { track, trigger, effect } from './effect.js';
const ITERATE_KEY = Symbol();
const proxyMap = new WeakMap();
export function reactive(target) {
// 判断传进来的target是否符合要求
if (!isObject(target)) {
return target;
}
// 特例一:当一个对象被reactive多次 reactive(reactive(obj))
if (isReactive(target)) {
return target;
}
// 特例二:let a = reactive(obj) , b = reactive(obj)
if (proxyMap.has(target)) {
return proxyMap.get(target);
}
const proxy = new Proxy(target, {
get(target, key, receiver) {
if (key === '__isReactive') {
return true;
}
track(target, key);
const res = Reflect.get(target, key, receiver);
// 特例四:深层对象代理
return isObject(res) ? reactive(res) : res;
},
set(target, key, value, receiver) {
const oldLength = target.length;
// 特例三:当值未发生改变时,不重复触发(通过hasChanged方法来判断)
const oldValue = target[key];
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
// Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
const res = Reflect.set(target, key, value, receiver);
if (hasChanged(value, oldValue)) {
trigger(target, key, type);
// 特例五:数组
if (isArray(target) && hasChanged(oldLength, target.length)) {
trigger(target, 'length', type,target.length);
}
}
return res;
},
deleteProperty(target, key) {
// 检查被操作的属性是否是对象自己的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
// 使用 Reflect.deleteProperty 完成属性的删除
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
// 只有当被删除的属性是对象自己的属性,并且删除成功时才触发更新
trigger(target, key, 'DELETE');
}
return res;
},
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, ITERATE_KEY);
// 拦截ownKeys操作即可间接拦截 for...in 循环
return Reflect.ownKeys(target);
}
});
proxyMap.set(target, proxy);
return proxy;
}
export function isReactive(target) {
return !!(target && target.__isReactive);
}
// // reactivity/effect.js
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i];
// 将effectFn从依赖集合中移除
deps.delete(effectFn);
}
// 最后将数组进行重置
effectFn.deps.length = 0;
}
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
const effectStack = [];
export function effect(fn, options = {}) {
const effectFn = () => {
// 调用cleanup 函数完成清除工作
cleanup(effectFn);
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
const res = fn();
// 执行完毕后,出栈并还原activeEffect之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res; // 新增
};
// 将 options 挂载到 effectFn 上
effectFn.options = options;
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 只有非lazy的时候才执行副作用函数
if (!options.lazy) { // 新增
// 执行副作用函数
effectFn();
}
return effectFn; // 新增
}
// 存储副作用函数的桶
const bucket = new WeakMap();
export function track(target, key) {
if (!activeEffect) {
return;
}
// 根据target从桶中取得depsMap,它也是一个Map类型: key -->effects
let depsMap = bucket.get(target);
// 如果depsMap不存在,那么新建一个 Map 与 target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 根据key从depsMap中取得 deps(对应着:key --> effects),它是一个Set类型
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 添加到桶里
deps.add(activeEffect);
// deps就是与当前副作用函数存在联系的依赖集合,将其添加到数组中
activeEffect.deps.push(deps);
}
export function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
effectsToRun.forEach(effectFn => { // 修改这部分
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
其实还有不少可以完善的地方,但是做到这一步也够了,感兴趣的佬们可以去github上看看源码。
6.浅响应与深响应:
接下来介绍 reactive
和 shallowReactive
的区别,其实上面实现的代码已经是深响应了,具体可以参考特例四,return isObject(res) ? reactive(res) : res;
,这样就解决了深层对象代理。
但是有些情况我们可能不希望对象是深响应的,这时就有了浅响应,即只有对象的第一层属性是响应的,我们可以通过一个布尔值来指定是否创建浅/深响应式对象,默认值为false,然后对代码进行封装:
import { hasChanged, isObject, isArray } from '../utils';
import { track, trigger } from './effect';
const ITERATE_KEY = Symbol();
const proxyMap = new WeakMap();
// 封装createReactive函数,接收一个参数 isShallow,代表是否浅响应,默认为false,即非浅响应
export function createReactive(target, isShallow = false) {
// 判断传进来的target是否符合要求
if (!isObject(target)) {
return target;
}
// 特例一:当一个对象被reactive多次 reactive(reactive(obj))
if (isReactive(target)) {
return target;
}
// 特例二:let a = reactive(obj) , b = reactive(obj)
if (proxyMap.has(target)) {
return proxyMap.get(target);
}
const proxy = new Proxy(target, {
get(target, key, receiver) {
if (key === '__isReactive') {
return true;
}
track(target, key);
const res = Reflect.get(target, key, receiver);
// 如果是浅响应,直接返回原始值
if (isShallow) {
return res;
}
// 特例四:深层对象代理
return isObject(res) ? reactive(res) : res;
},
set(target, key, value, receiver) {
const oldLength = target.length;
// 特例三:当值未发生改变时,不重复触发(通过hasChanged方法来判断)
const oldValue = target[key];
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
// Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
const res = Reflect.set(target, key, value, receiver);
if (hasChanged(value, oldValue)) {
trigger(target, key, type);
// 特例五:数组
if (isArray(target) && hasChanged(oldLength, target.length)) {
trigger(target, 'length', type, target.length);
}
}
return res;
},
deleteProperty(target, key) {
// 检查被操作的属性是否是对象自己的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
// 使用 Reflect.deleteProperty 完成属性的删除
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
// 只有当被删除的属性是对象自己的属性,并且删除成功时才触发更新
trigger(target, key, 'DELETE');
}
return res;
},
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, ITERATE_KEY);
// 拦截ownKeys操作即可间接拦截 for...in 循环
return Reflect.ownKeys(target);
}
});
proxyMap.set(target, proxy);
return proxy;
}
export function reactive(obj) {
return createReactive(obj);
}
export function shallowReactive(obj) {
return createReactive(obj, true);
}
export function isReactive(target) {
return !!(target && target.__isReactive);
}
可以看到,把代码抽离出来的好处,把对象创建的工作封装到一个新的函数createReactive
中,然后就可以轻松实现 reactive
以及 shallowReactive
函数了:
export function reactive(obj) {
return createReactive(obj);
}
export function shallowReactive(obj) {
return createReactive(obj, true);
}
看到这里不得不喊出口号,妙啊!(怎么我就想不出来…)
7.浅只读与深只读:
我们希望一些数据是只读的,当用户修改只读数据时,会收到一条警告信息,这样就实现了对数据的保护。
readonly只读函数本质也是对数据对象的代理,我们同样可以使用 createReactive
函数来实现,我们为 createReactive
函数增加第三个参数 isReadonly
,完整代码如下:
- 主要就是为函数添加参数,默认值为false,并在
set
和deleteProperty
里面添加if判断。 - 并且因为这个数据是只读的,则意味着任何方式都无法修改它,我们没有必要为只读数据建立响应联系,所以当在副作用函数中读取一个只读属性的值时,不需要调用track函数追踪响应,所以需要修改get拦截函数。
- 还要实现深只读,我们在特例四里面修改。
export function createReactive(target, isShallow = false, isReadonly = false) {
// 判断传进来的target是否符合要求
if (!isObject(target)) {
return target;
}
// 特例一:当一个对象被reactive多次 reactive(reactive(obj))
if (isReactive(target)) {
return target;
}
// 特例二:let a = reactive(obj) , b = reactive(obj)
if (proxyMap.has(target)) {
return proxyMap.get(target);
}
const proxy = new Proxy(target, {
get(target, key, receiver) {
if (key === '__isReactive') {
return true;
}
// 非只读的时候才需要建立响应联系
if (!isReadonly) {
track(target, key);
}
const res = Reflect.get(target, key, receiver);
// 如果是浅响应,直接返回原始值
if (isShallow) {
return res;
}
// 特例四:深层对象代理 并且判断是否只读(深只读)
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res);
} else {
return res;
}
},
set(target, key, value, receiver) {
// 如果是只读的,则打印警告信息并返回
if (isReadonly) {
console.warn(`属性 ${key}是只读的`);
return true;
}
const oldLength = target.length;
// 特例三:当值未发生改变时,不重复触发(通过hasChanged方法来判断)
const oldValue = target[key];
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
// Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
const res = Reflect.set(target, key, value, receiver);
if (hasChanged(value, oldValue)) {
trigger(target, key, type);
// 特例五:数组
if (isArray(target) && hasChanged(oldLength, target.length)) {
trigger(target, 'length', type, target.length);
}
}
return res;
},
deleteProperty(target, key) {
if (isReadonly) {
console.warn(`属性 ${key}是只读的`);
return true;
}
// 检查被操作的属性是否是对象自己的属性
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
// 使用 Reflect.deleteProperty 完成属性的删除
const res = Reflect.deleteProperty(target, key);
if (res && hadKey) {
// 只有当被删除的属性是对象自己的属性,并且删除成功时才触发更新
trigger(target, key, 'DELETE');
}
return res;
},
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, ITERATE_KEY);
// 拦截ownKeys操作即可间接拦截 for...in 循环
return Reflect.ownKeys(target);
}
});
proxyMap.set(target, proxy);
return proxy;
}
基于上面的,我们就可以实现readonly
深只读函数了:
export function readonly(obj) {
return createReactive(obj, false, true);
}
shallowReadonly的实现:我们只需要修改 createReactive
函数创建代理对象时,将第二个参数isShallow
设置为true,这样就创建了一个浅只读的代理对象了。
export function shallowReadonly(obj) {
return createReactive(obj, true, true);
}
8.代理数组:
在JS中,数组其实只是一个特殊的对象而已,因此想要更好地实现对数组的代理,就有必要了解数组比起普通对象,特殊在哪里。
- 在前面第二节中,深入讲解了JS中的两种对象:常规对象和异质对象。数组其实就是一个异质对象,主要是因为数组对象的
[[DefineOwnProperty]]
内部方法与常规对象不同,其它内部方法的逻辑都与常规对象相同。 - 所以当我们通过索引读取或设置数组元素的值时,代理对象的 get/set拦截函数也会执行,基于上面的代码我们不需要做额外的工作就能够让数组索引的读取和设置操作是响应式的了。
但是对数组和对普通对象的操作仍然存在不同,下面总结了所有对数组元素或属性的读取操作。
- 通过索引访问数组元素值:arr[0]
- 访问数组的长度:arr.length
- 把数组作为对象,使用for…in循环遍历
- 使用for…of迭代遍历数组
- 数组的原型方法,如:concat/joing/every/some/find等所有不改变原数组的原型方法。
很明显对数组的读取操作要比普通对象丰富多了,再来看看对数组元素或属性的设置操作有哪些:
- 通过索引修改数组元素值:arr[1] = 3
- 修改数组长度:arr.length = 0
- 数组的栈方法:push/pop/shift/unshift
- 修改原数组的原型方法:splice/fill/sort等
不要被上面这些吓到了,代理数组的难度其实没有比代理普通对象的难度大,大部分用来代理常规对象的代码对于数组也是生效的。我们先从数组索引读取说起吧~
8.1数组的索引与length
前面说到,当通过数组的索引访问元素值时,已经能够建立响应联系了,但是通过索引设置数组元素值与设置对象的属性值仍然存在根本不上的不同。具体体现在数组对象部署的内部方法 [[DefineOwnProperty]]
上,当通过索引设置元素值时,触发内部[[Set]]
方法, [[Set]]
其实依赖于[[DefineOwnProperty]]
上,来看看规范:
规范中明确说明,如果设置的索引值大于数组当前的长度,那么就要更新数组的length属性
-
所以通过索引设置元素值时,可能会隐式修改length的属性值。
-
因此触发响应时,也应该触发与
length
属性相关联的副作用函数重新执行。 -
为了实现目标,我们修改set拦截函数,主要修改的是set里面的type属性,代码完整贴出来太长了,我只贴改动的地方
// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性 // Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上 // 如果代理目标是数组,则说明是在添加新的属性,否则是设置已有属性 const type = isArray(target) // 如果代理目标是数组,则检测被设置的索引值是否小于数组长度, // 如果是,则视作 SET操作,否则是 ADD操作 ? Number(key) < target.length ? 'SET' : 'ADD' : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
- 解释:在判断操作类型时,新增了对数组类型的判断。如果代理的目标对象是数组,那么对于操作类型的判断有所区别。即:如果被设置的索引值小于数组长度,视作SET操作,因为它不会改变数组长度;否则视作ADD操作,因为这会隐式地改变数组的length属性值。
接下来就可以在trigger函数中正确地触发与数组对象length属性相关联的副作用函数重新执行了,在trigger加入这段代码:
// 当操作类型为ADD并且目标对是数组,取出并执行那些与length属性相关联的副作用函数 if (type === 'ADD' && isArray(target)) { // 取出与length相关联的副作用函数 const lengthEffects = depsMap.get('length'); // 将这些副作用函数添加到 effectsToRun中,等待执行 lengthEffects && lengthEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) }
其实反过来思考,修改数组的length属性也会隐式影响数组元素。
- 举个例子,如果数组里面有元素,然后我们执行
arr.length =0
,这会造成里面的所有元素都被删除,所以应该触发副作用函数重新执行。 - 再举个例子,如果数组里面有3个元素,然后我们执行
arr.length =5
,这并不影响里面的元素,此时副作用函数不需要重新执行。 - 所以当修改length属性值时,只有那些索引值 >= 新length属性值的元素才需要触发响应。
- 接下来就让我们动手实践吧,修改set拦截函数,再调用trigger触发响应时,把新的属性值传过去。
其实上面的特例五中,我们已经在set里做了修改,但是trigger里还未做修改:
// 为trigger添加第四个参数,新值
export function trigger(target, key, type, newValue) {
// ...省略
// 如果操作目标是数组,且修改了数组的length属性
if (isArray(target) && key === 'length') {
// 对于索引 >= 新的length值的元素
// 需要把所有相关联的副作用函数取出添加到effectsToRun中待执行
depsMap.forEach((effects, key) => {
if (key >= newValue) {
effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
}
})
}
// ...省略
}
如上面代码所示,为trigger函数增加第四个参数,触发响应时的新值,即新的length属性值,它代表新的数组长度,然后进行判断。
8.2遍历数组
前面提到数组对象和常规对象的不同仅体现在 [[DefineOwnProperty]]
上,就是所使用for…in循环遍历数组和遍历常规对象没有差别,因此可以使用 ownKeys
拦截函数进行拦截。
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, ITERATE_KEY);
// 拦截ownKeys操作即可间接拦截 for...in 循环
return Reflect.ownKeys(target);
}
这段代码取自前面为了追踪对普通对象的 for…in操作,通过人为创造 ITERATE_KEY
作为追踪的key,但这是为了代理普通对象考虑的,对一个普通对象来说,只有当添加或删除属性值时才会影响 for…in 循环的结果,所以当添加或删除属性操作发生时,需要取出与 ITERATE_KEY
相关联的副作用函数重新执行。
不过对于数组来说,以下操作会影响 for…in 循环对数组的遍历。
- 添加新元素:
arr[100] = 'bar'
- 修改数组长度:
arr.length = 0
观察上面可以发现,其实本质都是修改了数组的length属性。一旦数组的length属性被修改,for…in循环对数组的遍历结果就会发生改变,所以这种情况下我们应该触发响应。
所以我们可以在 ownkeys
拦截函数内,判断当前操作目标target是否是数组,如果是则使用length作为key去建立响应联系,修改 ownKeys
拦截函数:
ownKeys(target) {
// 将副作用函数与ITERATE_KEY关联
track(target, isArray(target) ? 'length' : ITERATE_KEY);
// 拦截ownKeys操作即可间接拦截 for...in 循环
return Reflect.ownKeys(target);
}
这样无论是为数组添加新元素,还是直接修改length属性,都能正确地触发响应了。
接下来就来看看用 for...of
遍历可迭代对象,这一部分的知识如果不了解或者忘了可以去看看阮一峰的ES6。简单来说可迭代对象就看内部有没有实现 Symbol.iteratoer
属性,像是Array、Map、Set、String、arguments
等数据结构有 Symbol.iteratoer属性
。
数组迭代器的执行流程,在规范里有说。数组迭代器的执行会读取数组的length属性,如果迭代的是数组元素值,还会读取数组的索引。
来模拟一个数组迭代器:
const arr = [1, 2, 3, 4, 5]
arr[Symbol.iterator] = function () {
const target = this;
const len = target.length;
let index = 0;
return {
next() {
return {
value: index < len ? target[index] : undefined,
done: index++ >= len
}
}
}
}
观察发现,其实我们只需要在副作用函数与数组的长度和索引之间建立响应联系,就能够实现响应式的 for…of 迭代了,并且数组的values()方法的返回值其实是数组内建的迭代器,并且我们不需要增加任何代码就能在副作用函数与数组的长度和索引之间建立联系了。
// true
console.log(Array.prototype.values === Array.prototype[Symbol.iterator]);
无论是使用for…of循环,还是调用values等方法,它们都会读取数组的Symbol.iterator
属性。该属性是一个symbol值,为了避免意外的错误,以及性能上的考虑,我们不应该在副作用函数与 Symbol.iterator
这类symbol
值之间建立响应联系,因此需要修改get拦截函数,加多一个判断即可:
get(target, key, receiver) {
if (key === '__isReactive') {
return true;
}
// 非只读的时候才需要建立响应联系
// 并且如果key的类型不是symbol,才进行追踪
if (!isReadonly && typeof key !== 'symbol') {
track(target, key);
}
const res = Reflect.get(target, key, receiver);
// 如果是浅响应,直接返回原始值
if (isShallow) {
return res;
}
// 特例四:深层对象代理 并且判断是否只读(深只读)
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res);
} else {
return res;
}
},
8.3数组的查找方法
通过上一节的介绍,我们意识到数组的方法内部其实都依赖了对象的基本语义。
我们先来看下includes方法的执行流程:
includes方法为了找到给定的值,它内部会访问数组的length属性以及数组的索引,因此当我们修改某个索引指向的元素值后能够触发响应。我们对着一个例子来进行分析:
const obj = {}
const arr = reactive([obj])
arr.includes(arr[0]) // false
-
看上面的执行流程中的第一步,这里的this是谁呢?在上面的例子中,this指向的是代理对象arr。
-
再看10.a步,可以看到
includes
方法会通过索引读取数组元素的值,这里的O是代理对象arr。通过代理对象来访问元素值时,如果值仍然是可被代理的,那么得到的值就是新的代理对象而非原始对象,这个我们在之前的get拦截函数内就证明了这一点:// 特例四:深层对象代理 并且判断是否只读(深只读) if (isObject(res)) { return isReadonly ? readonly(res) : reactive(res); } else { return res; }
知道上面这些后,回过头来看 arr.includes(arr[0])
,arr[0]得到的是一个代理对象,而在 includes
方法内部也会通过arr访问数组元素,从而也得到一个代理对象,但是这两个代理对象是不同的。原因:
export function reactive(obj) {
// 每次调用reactive都会创建新的代理对象
return createReactive(obj);
}
即使参数obj相同,每次调用reactive,也会创建新的代理对象。解决方案如下:
// 定义一个Map实例,存储原始对象到代理对象的映射
const reactiveMap = new Map();
export function reactive(obj) {
// 优先通过原始对象obj 寻找之前创建的代理对象,找到则返回已有的代理对象
const esistionProxy = reactiveMap.get(obj);
if (esistionProxy) return esistionProxy
// 否则,创建新的代理对象
const proxy = createReactive(obj);
// 存储到Map中,避免重复创建
reactiveMap.set(obj, proxy);
return proxy;
}
这样就解决了创建同一个原始对象的多次创建不同代理对象的问题了。
const obj = {}
const arr = reactive([obj])
arr.includes(arr[0]) // true
此时就解决了上面的问题了,但是还有一个问题:
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(obj)) // false
- 我们直接把原始对象作为参数传递给
includes
方法,这是符合直觉的行为,但是返回结果却是false,这是为什么呢? - 因为
includes
内部的 this 指向的是代理对象 arr,并且在获取数组元素值时也是代理对象,所以拿原始值obj去查找肯定找不到,因此返回false。 - 这可以通过重写数组的
includes
方法实现自定义的行为。arr.includes
方法可以理解为读取代理对象arr的includes
属性,这就会触发get拦截函数,在该函数内检查target
是否是数组,如果是数组并且读取键值位于arrayInstrumentations
上,则返回定义在arrayInstrumentations
对象上相应的值。即:当执行 arr.includes时,实际执行的是定义在arrayInstrumentations
上的includes
函数,这样就实现了重写了。 - 相应的
indexOf
和lastIndexOf
也是这种思路。
先在get拦截函数中增加:
// 如果操作的目标对象是数组,并且key存在于 arrayInstrumentations
// 那么返回定义在arrayInstrumentations上的值
if (isArray(target) && arrayInstrumentations.hasOwnProperty.call(key)) {
return Reflect.get(arrayInstrumentations, key, receiver);
}
const arrayInstrumentations = {};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function (...args) {
// this 是代理对象,先在代理对象中查找,将结果存储到 res 中
let res = originMethod.apply(this, args)
if (res === false || res === -1) {
// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中
// 查找,并更新 res 值
res = originMethod.apply(this.raw, args)
}
// 返回最终结果
return res
}
})
8.4隐式修改数组长度的原型方法
这节讲解如何处理那些会隐式修改数组长度的方法,主要指的是数组的栈方法,例如 push/pop/shift/unshift
。还有splice方法也会隐式地修改数组长度,可以查阅规范来证实这一点。
接下来来看push方法的执行流程:
由第2步和第6步可知,当调用数组的push方法向数组中添加元素时,既会读取数组的length属性值,也会设置数组的length属性值。这会导致两个独立的副作用函数相互影响。
// 第一个副作用函数
effect(() => {
arr.push(1)
})
// 第二副作用函数
effect(() => {
arr.push(1)
})
在浏览器上运行这段代码后发现,得到栈溢出的错误。
分析原因:
- 第一个副作用函数执行。在该函数内,调用 arr.push 方法向数组中添加了一个元素。我们知道,调用数组的 push 方法会间接读取数组的 length 属性。所以,当第一个副作用函数执行完毕后,会与 length 属性建立响应联系。
- 接着,第二个副作用函数执行。同样,它也会与 length 属性建立响应联系。但不要忘记,调用 arr.push 方法不仅会间接读取数组的 ength 属性,还会间接设置 length 属性的值。
- 第二个函数内的 arr.push 方法的调用设置了数组的 length 属性值。于是,响应系统尝试把与 length 属性相关联的副作用函数全部取出并执行,其中就包括第一个副作用函数。问题就出在这里,可以发现,第二个副作用函数还未执行完毕,就要再次执行第一个副作用函数了。
- 第一个副作用函数再次执行。同样,这会间接设置数组的 length属性。于是,响应系统又要尝试把所有与 length 属性相关联的副作用函数取出并执行,其中就包含第二个副作用函数。如此循环往复,最终导致调用栈溢出。
问题的根本原因是push方法的调用会间接读取length属性,所以我们"屏蔽"对length属性的读取,避免在它与副作用函数之间建立响应联系,问题就解决了。
重写push方法:
// 一个标记变量,代表是否进行追踪,默认值为true,允许追踪
let shouldTrack = true;
// 重写数组的push方法
['push'].forEach(method => {
// 取得原始push方法
const originMethod = Array.prototype[method];
// 重写
arrayInstrumentations[method] = function (...args) {
// 调用原始方法之前,禁止追踪
shouldTrack = false;
// push 方法的默认行为
let res = originMethod.apply(this, args);
// 在调用原始方法之后,恢复原来行为,即允许追踪
shouldTrack = true;
return res;
}
})
修改track代码:
export function track(target, key) {
if (!activeEffect || !shouldTrack) {
return;
}
}
这样便解决了当使用push方法间接读取length属性值时,length属性与副作用函数之间建立响应联系的问题。pop、shift、unshift和slice等方法的处理如下:
// 一个标记变量,代表是否进行追踪,默认值为true,允许追踪
export let shouldTrack = true
// 重写数组的push方法
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
// 取得原始push方法
const originMethod = Array.prototype[method];
// 重写
arrayInstrumentations[method] = function (...args) {
// 调用原始方法之前,禁止追踪
shouldTrack = false;
// push 方法的默认行为
const res = originMethod.apply(this, args);
// 在调用原始方法之后,恢复原来行为,即允许追踪
shouldTrack = true;
return res;
}
})
9.总结
其实上面还少了 代理Set和Map类型的数据,但是整体思路都差不多。
- 当读取操作发生时,调用 track函数建立响应联系;
- 当设置操作发生时,调用trigger函数触发响应。
想偷个懒:) 就不写出来了,感兴趣的佬们可以自己去看下。
我们深入了解了Proxy和Reflect,对象是啥,JS中的对象又是啥,以及关于Object的代理,该如何合理地触发响应,深/浅 响应 、只读,以及数组是如何进行代理的。后面将介绍原始值的响应式方案。