Object代理 本节着手实现响应式数据。前面我们使用get去拦截对属性的读取操作。但在响应系统中,读取是一个很宽泛的概念,有很多操作都涉及到读取这一行为。下面列出了对一个普通 对象的所有可能的读取操作:
访问属性:obj.foo
。
判断对象或原型上是否存在给定的 key:key in obj
。
使用 for...in
循环遍历对象:for (const key in obj) {}
。
对于属性读取(obj.foo),我们知道这可以通过get实现,而对于in和for in应该如何拦截呢?
这是我们在上一节里完成的部分基本代码,在此基础上实现后续功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 const bucket = new WeakMap ();let activeEffect = null ;const effectStack = [];function effect (fn,options = {} ){ const effectFn = ( ) => { cleanup (effectFn) activeEffect = effectFn; effectStack.push (effectFn); const res = fn (); effectStack.pop (); activeEffect = effectStack[effectStack.length - 1 ]; return res; } effectFn.options = options; effectFn.deps = [] if (!options.lazy ){ effectFn () } return effectFn } function track (target,key ){ if (!activeEffect){ return } let depsMap = bucket.get (target) if (!depsMap){ bucket.set (target,(depsMap = new Map ())) } let deps = depsMap.get (key) if (!deps){ depsMap.set (key,(deps = new Set ())) } deps.add (activeEffect) activeEffect.deps .push (deps) } function trigger (target,key ){ const depsMap = bucket.get (target) if (!depsMap){ return } const depsEffects = depsMap.get (key) const effectsToRun = new Set () depsEffects && depsEffects.forEach (effectFn => { if (effectFn !== activeEffect){ effectsToRun.add (effectFn) } }) effectsToRun&&effectsToRun.forEach (fn => { if (fn.options .scheduler ){ fn.options .scheduler (fn) }else { fn () } }); } function cleanup (effectFn ){ for (let i =0 ;i<effectFn.deps .length ;i++){ const deps = effectFn.deps [i] deps.delete (effectFn) } effectFn.deps .length = 0 }
in操作符 使用in操作符检查对象上是否具有给定的key 属于读取操作。
以下是原书中对in操作符拦截的思路:
in 操作符的运算结果是通过调用抽象方法HasProperty
得到的, 而抽象方法HasProperty
的返回值是通过 调用对象的内部方法 [[HasProperty]]
得到的,它对应的拦截函数名叫 has
,因此我们可以通过 has
拦截函数实现对 in
操作符的代理:
1 2 3 4 5 6 7 8 9 10 11 12 const obj = {foo :1 ,bar :2 }const p =new Proxy (obj,{ has (target,key ){ track (target,key) return Reflect .has (target,key) } }) effect (()=> { 'foo' in p })
这样,当我们在副作用函数中通过 in 操作符操作响应式数据时, 就能够建立依赖关系。
for…in循环 以下是原书中for in循环的拦截思路:
可以看到EnumerateObjectProperties
是一个generator 函数,接收一个参数 obj。实际上,obj 就是被 for…in 循环遍历的对象,其关键点在于使用 Reflect.ownKeys(obj)
来获取只属于对象自身拥有的键。
也就是说,我们可以使用 ownKeys
拦截函数来拦截 for...in
里发生的Reflect.ownKeys
操作,即可间接拦截 for...in
循环:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const obj = {foo :1 }const ITERATE_KEY = Symbol ()const p =new Proxy (obj,{ ownKeys (target ){ track (target,ITERATE_KEY ) return Reflect .ownKeys (target) } }) effect (()=> { for (let key in p){ console .log ('key' ,key) } })
ownKeys
用来获取一个对象的所有属于自己的键值,参数只能拿到目标对象target。在调用track时,为了传入for...in
操作对应的key,我们需要自己设置一个key的标识ITERATE_KEY
。在触发响应trigger时,我们同样传入这个标识。
这里有一个问题,当我们设置一个新属性(比如p.bar = 3
)时,会触发 set 拦截函数执行。此时 set 拦截函数接收到和传给trigger 函数的 key 就是字符串 ‘bar’。于此同时,由于添加了新属性,for in的循环执行也会从原来的一次变成两次,这对副作用函数的执行有影响。但根据前文,for…in 循环是在副作用函数与 ITERATE_KEY 之间建立联系,和 ‘bar’ 没有关系,导致我们无法触发ITERATE_KEY的副作用函数。
问题的关键就是触发此副作用函数,所以解决方法也就出来了,在添加属性操作时,我们同样执行ITERATE_KEY的副作用函数就可以了。于是我们修改trigger函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 function trigger (target,key ){ const depsMap = bucket.get (target) if (!depsMap){ return } const depsEffects = depsMap.get (key) const iterateEffects = depsMap.get (ITERATE_KEY ) const effectsToRun = new Set () depsEffects && depsEffects.forEach (effectFn => { if (effectFn !== activeEffect){ effectsToRun.add (effectFn) } }) iterateEffects && iterateEffects.forEach (effectFn => { if (effectFn !== activeEffect){ effectsToRun.add (effectFn) } }) effectsToRun&&effectsToRun.forEach (fn => { if (fn.options .scheduler ){ fn.options .scheduler (fn) }else { fn () } }); }
但这样又有了新问题,我们添加属性 时需要执行ITERATE_KEY相关联的副作用函数,但在修改属性 时,我们是不需要执行其副作用函数的。但由于我们都是通过set拦截的,要想区分这两种,我们需要修改我们的拦截函数:
1 2 3 4 5 6 7 8 9 const p =new Proxy (obj,{ set (target,key,newVal,receiver ){ const type = Object .prototype .hasOwnProperty .call (target,key)?'SET' :'ADD' const res = Reflect .set (target,key,newVal,receiver) trigger (target,key,type) return res } })
我们给trigger传入第三个参数type用于区分操作类型,并且只有当操作类型 type 为 ‘ADD’ 时,才会触发与 ITERATE_KEY 相关联的副作用函数重新执行,避免了不必要的性能损耗:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 function trigger (target,key,type ){ const depsMap = bucket.get (target) if (!depsMap){ return } const depsEffects = depsMap.get (key) const effectsToRun = new Set () depsEffects && depsEffects.forEach (effectFn => { if (effectFn !== activeEffect){ effectsToRun.add (effectFn) } }) if (type === 'ADD' ){ const iterateEffects = depsMap.get (ITERATE_KEY ) iterateEffects && iterateEffects.forEach (effectFn => { if (effectFn !== activeEffect){ effectsToRun.add (effectFn) } }) } effectsToRun&&effectsToRun.forEach (fn => { if (fn.options .scheduler ){ fn.options .scheduler (fn) }else { fn () } }); }
delete操作符 关于对象的代理,还剩下最后一项工作需要做,即删除属性操作的代理:delete p.foo
以下是原书的描述:
delete
操作符的行为依赖 [[Delete]]
内部方法,这个内部方法通过使用deleteProperty
拦截。我们先检查是否存在此属性,再通过Reflect.deleteProperty
进行属性删除,最后调用trigger函数重新执行副作用函数:
1 2 3 4 5 6 7 8 9 10 11 12 const p =new Proxy (obj,{ deleteProperty (target,key ){ const hadKey = Object .prototype .hasOwnProperty .call (target,key) const res = Reflect .deleteProperty (target,key) if (res && hadKey){ trigger (target,key,'DELETE' ) } } })
由于delete删除了对象,减少了for in
的循环次数,我们也需要触发ITERATE_KEY 相关联的副作用函数重新执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 function trigger (target,key,type ){ const depsMap = bucket.get (target) if (!depsMap){ return } const depsEffects = depsMap.get (key) const effectsToRun = new Set () depsEffects && depsEffects.forEach (effectFn => { if (effectFn !== activeEffect){ effectsToRun.add (effectFn) } }) if (type === 'ADD' ||type === 'DELETE' ){ const iterateEffects = depsMap.get (ITERATE_KEY ) iterateEffects && iterateEffects.forEach (effectFn => { if (effectFn !== activeEffect){ effectsToRun.add (effectFn) } }) } effectsToRun&&effectsToRun.forEach (fn => { if (fn.options .scheduler ){ fn.options .scheduler (fn) }else { fn () } }); }
reactive响应触发 我们从规范的角度详细介绍了如何代理对象,在这个过程中,处理了很多边界条件。但想要合理地触发响应,还有许多工作要做。从这里开始我们逐步实现并完善reactive对各种类型的对象的代理。
值无变化取消响应 首先第一个问题,当setter设置的值没有发生变化时, 不应该触发响应。
为了满足需求,我 们需要修改set 拦截函数的代码,在调用 trigger 函数触发响应之 前,需要检查值是否真的发生了变化:
1 2 3 4 5 6 7 8 9 10 11 12 13 const p =new Proxy (obj,{ set (target,key,newVal,receiver ){ const oldVal = target[key] const type = Object .prototype .hasOwnProperty .call (target,key)?'SET' :'ADD' const res = Reflect .set (target,key,newVal,receiver) if (oldVal !== newVal){ trigger (target,key,type) } return res }, )
我们在 set 拦截函数内首先获取旧值 oldVal,接着比较新值与旧值,只有当它们不全等的时候才触发响应。但还有一个问题,对于NaN来说,全等比较的结果是false:
1 2 NaN === NaN NaN !== NaN
因此我们需要对NaN这个特例进行条件判断:
1 2 3 4 5 6 7 8 9 10 11 set (target,key,newVal,receiver ){ const oldVal = target[key] const type = Object .prototype .hasOwnProperty .call (target,key)?'SET' :'ADD' const res = Reflect .set (target,key,newVal,receiver) if (oldVal !== newVal&&(oldVal === oldVal || newVal === newVal)){ trigger (target,key,type) } return res },
这样我们就解决了 NaN 的问题。
从原型上继承属性 接下来我们讨论一种从原型上继承属性的情况,从现在起我们将先前的proxy封装为一个reactive函数,它接收一个对象作为参数,返回为其创建的响应式数据:
1 2 3 4 5 6 function reactive (obj ){ return new Proxy (obj,{ getter... setter... }) }
我们用reactive函数设置一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const proto = {foo :1 }const data = {}const parent = reactive (proto)const child = reactive (data)Object .setPrototypeOf (child,parent)effect (()=> { console .log ('foo' ,child.foo ) }) child.foo = 2 child.foo = 3
我们定义了空对象data 和对象 proto,分别为二者创建了对应的响应式数据 child 和 parent,并且使用 Object.setPrototypeOf
将 parent 设置为 child 的原型。接着访问child.foo
属性,由于child
本身没有设置foo
属性,因此这个foo值是从原型parent上继承来的,控制台输出foo 1
(没有设置原型会输出undefined)。
但当我们赋值child.foo = 2
时,控制台输出了两次,也就是说这里的副作用函数执行了两次,产生了一次不必要的更新。
以下是原书中对原因的分析:
总结一下,当我们添加副作用函数时,console.log('foo',child.foo)
触发了child的getter,但由于child本身没有这个属性,系统就会取它的原型parent并调用原型的getter,副作用函数同时被child和parent收集了。同样的,当我们设置child.foo = 2
时,触发了child的setter,但由于child本身没有这个属性,系统就会取它的原型parent并调用原型的setter,于是就会导致副作用函数被再次执行。
于是解决方法就明确了,只要屏蔽其中一次就可以了。我们选择将parent的副作用函数执行屏蔽,在setter里区分这两次更新。
我们来看两次setter有何不同:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 set (target, key, value, receiver ) { } set (target, key, value, receiver ) { }
在 JavaScript 的 Proxy 机制中,receiver
参数代表 实际触发属性操作的对象 ,即最初发起操作的代理对象。我们可以看到,child和parent的receiver都是 child,而target 则是变化的。对child来说receiver就是target的代理对象,但parent却不是。所以解决办法就有了:判断 receiver 是否是 target 的代理对象,只有当 receiver 是 target 的代理对象时才触发更新。
我们为getter添加一个能力,使代理对象在访问一个特定属性raw
时,可以得到它的原始对象:
1 2 3 4 5 6 7 8 9 10 11 12 get (target,key,receiver ){ if (key === 'raw' ){ return target } track (target,key) return Reflect .get (target,key,receiver) }, child.raw === data parent.raw === proto
这样我们就可以通过访问raw得到其原始对象。于是我们就可以在setter里进行判断了:
1 2 3 4 5 6 7 8 9 10 11 12 set (target,key,newVal,receiver ){ const oldVal = target[key] const type = Object .prototype .hasOwnProperty .call (target,key)?'SET' :'ADD' const res = Reflect .set (target,key,newVal,receiver) if (receiver.raw === target){ if (oldVal !== newVal&&(oldVal === oldVal || newVal === newVal)){ trigger (target,key,type) } } return res },
这样我们就成功屏蔽掉原型引起的更新了,从而避免不必要的更新操作。
浅响应与深相应 目前我们实现的reactive是浅响应的,简单来说,我们的响应式对象的属性如果包含了另一个对象,修改此对象并不能触发响应:
1 2 3 4 5 6 7 const obj = reactive ({ foo : { bar : 1 } })effect (()=> { console .log ('foo' ,obj.foo ) }) obj.foo .bar = 4
我们来看一下此时的reactive实现:
1 2 3 4 5 6 7 8 get (target,key,receiver ){ if (key === 'raw' ){ return target } track (target,key) return Reflect .get (target,key,receiver) },
当我们读取obj.foo.bar
时,首先要通过getter访问obj.foo
,但这里通过Reflect.get
得到的结果是一个普通对象{bar:1}
,并不是响应式对象。因此要实现对象属性的响应,我们需要对Reflect.get
的返回值进行一次封装:
1 2 3 4 5 6 7 8 9 10 11 12 13 get (target,key,receiver ){ if (key === 'raw' ){ return target } track (target,key) const res = Reflect .get (target,key,receiver) if (typeof res === 'object' && res !== null ){ return reactive (res) } return res },
这样我们访问对象属性时,通过递归调用reactive将其包装成响应式数据,就能触发副作用函数重新执行了。reactive成功实现了深响应。
整理以上代码,使用createReactive
函数替代reactive
的代理内容,并使用shallowReactive
浅响应(只响应对象的第一层属性),与原本的reactive
深响应区分开:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 function createReactive (obj,isShallow = false ){return new Proxy (obj,{ get (target,key,receiver ){ if (key === 'raw' ){ return target } track (target,key) const res = Reflect .get (target,key,receiver) if (isShallow){ return res } if (typeof res === 'object' && res !== null ){ return reactive (res) } return res }, ..., ... }) } function reactive (obj ){ return createReactive (obj) } function shallowReactive (obj ){ return createReactive (obj,true ) }
只读与浅只读 我们希望一些数据是只读的。只读本质上也是对数据对象的代理,同样可以使用上文的createReactive
函数实现。我们添加第三个参数isReadonly
,并修改代理内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 function createReactive (obj,isShallow = false ,isReadonly = false ){return new Proxy (obj,{ set (target,key,newVal,receiver ){ if (isReadonly){ console .warn (`属性${key} 是只读的,不能被修改` ) return true } const oldVal = target[key] const type = Object .prototype .hasOwnProperty .call (target,key)?'SET' :'ADD' const res = Reflect .set (target,key,newVal,receiver) if (receiver.raw === target){ if (oldVal !== newVal&&(oldVal === oldVal || newVal === newVal)){ trigger (target,key,type) } } return res }, deleteProperty (target,key ){ if (isReadonly){ console .warn (`属性${key} 是只读的,不能被删除` ) return true } const hadKey = Object .prototype .hasOwnProperty .call (target,key) const res = Reflect .deleteProperty (target,key) if (res && hadKey){ trigger (target,key,'DELETE' ) } } } get (target,key,receiver ){ if (key === 'raw' ){ return target } if (!isReadonly){ track (target,key) } const res = Reflect .get (target,key,receiver) if (isShallow){ return res } if (typeof res === 'object' && res !== null ){ return reactive (res) } return res }, ), } function readonly (obj ){ return createReactive (obj,false ,true ) }
如上代码所示,我们使用createReactive
创建代理对象时,通过第三个参数isReadonly
指定是否创建一个只读的代理对象,并修改set
和 deleteProperty
拦截函数,一旦数据是只读的,则会自动拦截修改操作,提示这是非法操作。由于数据是只读的无法修改,因此不会触发set的trigger函数,也就不需要事先搜集副作用函数依赖了。我们在get
拦截函数里进行判断,只读时取消track函数的触发。
基于以上操作,我们就可以实现readonly
函数,通过调用createReactive
实现只读。
和先前的浅响应与深相应一样,只读也有类似的问题。以上的readonly
只实现了浅只读。我们需要对get拦截进行类似的数据封装:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 get (target,key,receiver ){ if (key === 'raw' ){ return target } if (!isReadonly){ track (target,key) } const res = Reflect .get (target,key,receiver) if (isShallow){ return res } if (typeof res === 'object' && res !== null ){ return isReadonly ? readonly (res) : reactive (res) } return res }, function readonly (obj ){ return createReactive (obj,false ,true ) } function shallowReadonly (obj ){ return createReactive (obj,true ,true ) }
异质对象的代理 在 JavaScript 中有两种对象:常规对象和异质对象。这两类对象内部犯法的逻辑大体是相同的,但后者会有一些特殊方法与处理。为了更好地实现reactive的对象代理,我们需要针对这些和常规对象有差异的对象进行进一步的处理。
原书针对数组和集合的代理展开了长篇幅的深入介绍,由于篇幅实在太长,暂时就跳过了,有空再单独更新重点部分。
代理数组 数组对象除了 [[DefineOwnProperty]]
这个内部方法之 外,其他内部方法的逻辑都与常规对象相同。因此,当实现对数组的代理时,用于代理普通对象的大部分代码可以继续使用:
1 2 3 4 5 6 7 const arr = reactive ([1 ,2 ,3 ])effect (()=> { console .log ('数组' ,arr[0 ]) }) arr[0 ] = 2
当我们通过索引读取或 设置数组元素的值时,代理对象的 get/set 拦截函数也会执行,因此 我们不需要做任何额外的工作,就能够让数组索引的读取和设置操作是响应式的了。
但对数组的操作与对普通对象的操作仍然存在不同,下面总结了所有对数组元素或属性的“读取”操作。
通过索引访问数组元素值:arr[0]。
访问数组的长度:arr.length。
把数组作为对象,使用 for…in 循环遍历。
使用 for…of 迭代遍历数组。
数组的原型方法,如 concat/join/every/some/find/findIndex/includes 等,以及其他所有不改变原数组的原型方法。
可以看到,对数组的读取操作要比普通对象丰富得多。我们再来看看对数组元素或属性的设置操作有哪些。
通过索引修改数组元素值:arr[1] = 3。
修改数组长度:arr.length = 0。
数组的栈方法:push/pop/shift/unshift。
修改原数组的原型方法:splice/fill/sort 等。
除了通过数组索引修改数组元素值这种基本操作之外,数组本身 还有很多会修改原数组的原型方法。有些方法的操作语义是“读取”,而有些方法的操作语义是“设置”。 因此,当这些操作发生时,也应该正确地建立响应联系或触发响应。接下来,我们针对这些数组的操作挨个说起。
数组索引index与length
遍历数组
数组的查找
数组的长度修改
代理集合 Set & Map 本节介绍集合类型数据的响应式方案。集合类型包括 Map/Set 以及 WeakMap/WeakSet。使用 Proxy 代理集合类型的 数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在 很大的不同。下面总结了 Set 和 Map 这两个数据类型的原型属性和方法。
Set 类型的原型属性和方法如下 :
size:返回集合中元素的数量。
add(value):向集合中添加给定的值。
clear():清空集合。
delete(value):从集合中删除给定的值。
has(value):判断集合中是否存在给定的值。
keys():返回一个迭代器对象。可用于 for…of 循环,迭代 器对象产生的值为集合中的元素值。
values():对于 Set 集合类型来说,keys() 与 values() 等 价。
entries():返回一个迭代器对象。迭代过程中为集合中的每一 个元素产生一个数组值 [value, value]。
forEach(callback[, thisArg]):forEach 函数会遍历集 合中的所有元素,并对每一个元素调用 callback 函数。 forEach 函数接收可选的第二个参数 thisArg,用于指定 callback 函数执行时的 this 值。
Map 类型的原型属性和方法如下 :
size:返回 Map 数据中的键值对数量。
clear():清空 Map。
delete(key):删除指定 key 的键值对。
has(key):判断 Map 中是否存在指定 key 的键值对。
get(key):读取指定 key 对应的值。
set(key, value):为 Map 设置新的键值对。
keys():返回一个迭代器对象。迭代过程中会产生键值对的 key 值。
values():返回一个迭代器对象。迭代过程中会产生键值对的 value 值。
entries():返回一个迭代器对象。迭代过程中会产生由 [key, value] 组成的数组值。
forEach(callback[, thisArg]):forEach 函数会遍历 Map 数据的所有键值对,并对每一个键值对调用 callback 函 数。forEach 函数接收可选的第二个参数 thisArg,用于指定 callback 函数执行时的 this 值。
可以发现Map和Set还是挺类似的,区别主要在于添加和读取元素的方法:Set 类型使用 add(value) 方添加元素,而Map 类型使用 set(key, value) 方法设置键值对,并且Map类型可以使用 get(key) 方法读取相应的值。
如何代理 Set 和 Map
建立响应联系
避免污染原始数据
处理 forEach
迭代器方法
values 与 keys 方法