学习了简单的响应式系统的设计,可以简单的拦截set操作和get操作,但是Vue.js中的响应式系统要远不止如此,比如支持for in,其他的数据结构,Map Set等支持,仍然需要深入学习。
1、理解Proxy和Reflect
什么是Proxy?
Vue.js中的响应式是靠Proxy实现,使用Proxy可以创建一个代理对象,它能够实现对其他对象的代理。Proxy只能代理对象值,无法代理非对象值。
什么是代理?
Vue.js中的代理指的是对一个对象基本语义的代理。它允许拦截并重新定义一个对象的基本操作。
什么是基本语义?
简单来说就是基本操作,读取、设置、函数调用(js里面万物皆对象,函数也是对象,函数的调用也是对象的基本操作)
obj.foo++ //读取和设置
obj.foo //读取
fn() //函数调用
//Proxy代理拦截
const p = new Proxy(obj, {
get() { return obj.foo },
set(target, key, value) {
obj[key] = value
}
})
// 函数调用的拦截
const fn = (name) => {
console.log('我是:', name)
}
const p2 = new Proxy(fn, {
apply(target, thisArg, argArray) {
target.call(thisArg, ...argArray)
}
})
复合操作
复合操作的的典型就是调用对象下面的方法,比如obj.fn()由两个基本语义组成,先get获取到obj.fn属性,再调用方法,也就是之前提到的apply。
Reflect
Reflect是一个全局对象,有许多方法,如
Reflect.get()
Reflect.set()
Reflect.apply()
//...
Reflect的get方法中有receiver,代表谁在读取属性
2、JS对象和Proxy的工作原理
ECMAScript规范中[[xxx]]表示内部方法,js对象有很多必须的内部方法
还有两个需要重点注意的两个必要的内部方法
如果一个对象被作为函数调用则内部必须有[[Call]]方法。创建对象需要有[[Construct]]
异质对象和常规对象
满足以下三点要求的对象就是常规对象:
1、对于表 1 列出的内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义实现
2、对于内部方法[[Call]],必须使用 ECMA 规范 10.2.1 节给出的定义实现
3、对于内部方法[[Construct]],必须使用 ECMA 规范 10.2.2 节给出的定义实现。
不符合上面三点的任意一点就是异质对象,而响应式系统最主要的Proxy对象的内部方法[[Get]]没有使用 ECMA 规范的 10.1.8 节给出的定义实现,所以 Proxy 是一个异质对象。
3、如何代理Object对象
in操作符的拦截
响应式系统需要考虑的细节是很多的,其中读取是个很宽泛的概念,in操作符本质上也是一种读取操作。
想要拦截in操作符,就需要了解in操作符运行时的逻辑。
在 ECMA-262 规范的 13.10.1 节中,明确定义了 in 操作符的运行时逻辑,翻译过来如下:
1、让 lref 的值为 RelationalExpression 的执行结果。
2、让 lval 的值为 ? GetValue(lref)。
3、让 rref 的值为 ShiftExpression 的执行结果。
4、让 rval 的值为 ? GetValue(rref)。
5、如果 Type(rval) 不是对象,则抛出 TypeError 异常。
6、返回 ? HasProperty(rval, ? ToPropertyKey(lval))。
最重要的是第6步,in操作符通过HasProperty抽象方法返回结果。
关于 HasProperty 抽象方法,可以在 ECMA-262 规范的 7.3.11 节中找到,主要内容为
断言:Type(O) 是 Object。
断言:IsPropertyKey(P) 是 true。
返回 ? O.[HasProperty]
可以发现,最后调用的是[[HasProperty]]这个内部方法,对应的拦截函数名叫has,通过has
函数进行拦截
//has
const p = new Proxy(obj, {
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
})
拦截for in 操作符
for in 操作符最主要的逻辑部分如下:
如果iteration是枚举,则
1.如果exprValue是undefined或null,那么返回Completion { [[Type]]: break, [[Value]]: empty, [[Target]]: empty }。
2.让obj的值为!toObject(exprValue)。
3.iterator值为?EnumerateObjectProperties(obj)
4.让 nextMethod 的值为 ! GetV(iterator, "next")
5.返回 Record{ [[Iterator]]: iterator, [[NextMethod]]: nextMethod, [[Done]]: false }
其中关键点是EnumerateObjectProperties抽象方法,返回一个迭代器对象,规范的 14.7.5.9 节给出了满足该抽象方法的示例实现,代码如下:
function* EnumerateObjectProperties(obj) {
const visited = new Set();
for (const key of Reflect.ownKeys(obj)) {
if (typeof key === "symbol") continue;
const desc = Reflect.getOwnPropertyDescriptor(obj, key);
if (desc) {
visited.add(key);
if (desc.enumerable) yield key;
}
}
const proto = Reflect.getPrototypeOf(obj);
if (proto === null) return;
for (const protoKey of EnumerateObjectProperties(proto)) {
if (!visited.has(protoKey)) yield protoKey;
}
}
该方法是一个 generator 函数,接收一个参数 obj,实际上被obj被for in遍历,其中关键点是
Reflect.ownkeys(obj)方法,可以使用ownkeys拦截函数进行拦截
const ITERATE_KEY = Symbol()
//原始数据
const obj = { foo: 1 }
//对原始数据的代理
const p = new Proxy(obj, {
ownKeys(target) {
//将副作用函数与ITERATE_KEY进行关联
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
}
ownkeys拦截函数与get、set拦截函数不同,ownkeys只能拿到target,无法拿到key,只能构造ITERATE_KEY用来追踪。
新增属性与修改属性
p对象目前只有一个属性,for in执行一次,在添加新属性后,for in需要执行两次,当前的响应式系统并不能做到ITERATE_KEY并没有与新增属性产生联系,需要进行优化。
解决方案:
当添加属性时,将与ITERATE_KEY相关的副作用函数也同时取出来,代码如下:
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
// 取得与 key 相关联的副作用函数
const effects = depsMap.get(key)
// 取得与 ITERATE_KEY 相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
const effectsToRun = new Set()
// 将与 key 相关联的副作用函数添加到 effectsToRun
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
// 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
// effects && effects.forEach(effectFn => effectFn())
}
上面的代码只能解决新增属性的问题,不能解决修改值的问题,需要在set拦截方法里面进行区分。Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上,如果存在,则说明当前操作类型为 SET 否则为 ADD新增,具体代码如下:
// 拦截设置操作
set(target, key, newVal, receiver) {
// 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
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
}
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const iterateEffects = depsMap.get(ITERATE_KEY)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
console.log(type, key)
if (type === 'ADD') {
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
在 trigger 函数内就可以通过类型 type 来区分当前的操作类型,并且只有当操作类型 type 为 'ADD' 时,才会触发与 ITERATE_KEY 相关联的副作用函数重新执行,免了不必要的性能损耗。
4、合理的触发响应
响应式系统应该合理的触发响应,即在值产生变化的时候才进行响应,当前的响应式系统在修改值和以前一样时仍然会触发响应式,需要优化当前的响应式系统。
解决方案:在set拦截函数中添加newValue与oldValue进行全等比较,封装reactive函数判断 receiver 是否是 target 的代理对象,只有当 receiver 是 target 的代理对象时才触发更新,避免继承导致的触发多次的响应式。
修改后的set和封装的reactive代码如下:
// 拦截设置操作
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
},
function reactive(obj) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
//raw访问对象原始值
if (key === 'raw') {
return target
}
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return Reflect.get(target, key, receiver)
},
// 拦截设置操作
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)
console.log(target === receiver.raw)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
},
has(target, key) {
track(target, key)
return Reflect.has(target, key)
},
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
},
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const res = Reflect.deleteProperty(target, key)
if (res && hadKey) {
trigger(target, key, 'DELETE')
}
return res
}
})
}
5、浅响应与深响应
当前响应式系统的reactive的是浅响应,无法响应对象嵌套对象的问题,通过Reflect获取到的对象中的对象是一个普通对象,不能触发副作用函数,需要改为深响应解决嵌套对象不能触发副作用函数的问题。
解决方案:当读取属性值时,首先检测该值是否是对象,如果是对象,则递归地调用 reactive 函数将其包装成响应式数据并返回。代码如下:
const res = Reflect.get(target, key, receiver)
if (typeof res === 'object' && res !== null) {
return reactive(res)
}
浅响应
并不是所有时候都需要深响应,有时候也需要只响应一层的shallowReactive
实现方法:添加isShallow标记,在get拦截函数时同时isShallow判断是否是浅响应,如果是则直接返回原始值,代码如下:
function createReactive(obj, isShallow = false) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if (key === 'raw') {
return target
}
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
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
},
})
}
6、代理数组
数组在JS中也是对象,代理数组,需要明白数组这个对象的特点,数组对象还是个异质对象,[[DefineOwnProperty]],内部方法与常规对象不同。
数组索引与长度
[[DefineOwnProperty]]在规范10.2.4节中是关键部分是这样定义的:
如果index >= oldLen, 那么
将oldLenDesc.[[Value]]设置index + 1。
让succeed的值为OrdinaryDefineOwnProperty(A, ''length'', oldLenDesc)
断言:succeed是true
规范中可以看到更新数组length属性,所以当通过索引设置元素值时,可能会隐式的修改length属性,所以在触发length属性关联的副作用函数重新执行,需要修改set拦截函数。
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
// 拦截设置操作
set(target, key, newVal, receiver) {
console.log('set: ', key )
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}
const oldVal = target[key]
// 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
const type = Array.isArray(target)
? Number(key) < target.length ? 'SET' : 'ADD'
: Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
}
})}
在修改索引如果小于长度则视为set,否则视为add,通过这些信息重新修改trigger
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.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)
}
})
}
if (type === 'ADD' && Array.isArray(target)) {
const lengthEffects = depsMap.get('length')
lengthEffects && lengthEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
数组长度修改,并不一定影响数组元素的值,修改length并不会影响index为0的元素值,在这种情况下也不需要触发响应式,需要修改set拦截函数,在调用 trigger 函数触发响应时,应该把新的属性值传递过去,代码如下
// 拦截设置操作
set(target, key, newVal, receiver) {
console.log('set: ', key )
if (isReadonly) {
console.warn(`属性 ${key} 是只读的`)
return true
}
const oldVal = target[key]
// 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
const type = Array.isArray(target)
? Number(key) < target.length ? 'SET' : 'ADD'
: Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type, newVal)
}
}
return res
},
修改trigger,增加第四个参数newValue,newValue是新的length,判断操作目标是否是数组,如果是,则需要找到所有索引值大于或等于新的 length 值的元素,然后把与它们相关联的副作用函数取出并执行
function trigger(target, key, type, newVal) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.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)
}
})
}
if (type === 'ADD' && Array.isArray(target)) {
const lengthEffects = depsMap.get('length')
lengthEffects && lengthEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
if (Array.isArray(target) && key === 'length') {
depsMap.forEach((effects, key) => {
if (key >= newVal) {
effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
})
}
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
遍历数组
数组也是对象,同样可以for in遍历,那么也需要ownKey进行拦截。
在Proxy加入ownKeys
ownKeys(target) {
console.log('ownkeys: ')
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
},
隐式修改数组长度的原型方法
数组的栈方法也会也会修改数组的长度,如push、pop、shift、unshift除此之外还有splice,
以push为例,规范的 23.1.3.20 节定义了 push 方法,大致如下:
1.让O的值为?ToObject(this value)
2.让len的值为?LengthOfArrayLike(O)
3.让argCount的值为items的元素数量
4.如果len + argCount > 2的53次方-1,则抛出TypeError异常
5.对于items中的每一个元素E:
a.执行 ? Set(O, !ToString(!(len)), E, true)
b.将len设置为len + 1
6.执行 ? Set(O, ''length'', (len), true)
7.返回(len)
通过第2、6步可知,调用push方法既读取又设置length,最终会导致栈的溢出。
解决方案:重写push方法,屏蔽对length属性的读取,避免它与副作用函数建立联系,代码如下:
// 标记,是否允许追踪,默认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需要补充追踪的判断
function track(target, key) {
//当禁止追踪时,直接返回
if(!activeEffect || !shouldTrack) return
//其他部分代码省略
}
7、代理Set和Map
Set和Map有很多相似的操作,如delete()、clear()、has(),最大的不同是,添加Set是add,而Map是set(),此外Map还可以通过get(key)获取对应的value。二者非常相似,可以用相同的方式代理Set和Map。
如何代理Set和Map
Set的size是访问器属性,通过规范24.2.3.9,可以得知大致逻辑如下:
1.让S的值为this
2.执行?RequireInternalSlot(S, [[SetData]])
3.让entries的值为List,即S.[[SetData]]
4.让count的值为0
5.对于entries中的每个元素e执行:
a.如果e不是空的,则将count设置为 count + 1
6.返回(count)
其中this指向的是代理对象,而不是当前对象,调用抽象方法 RequireInternalSlot(S, [[SetData]])来检查是否存在内部槽[[SetData]],代理对象不存在[[SetData]]]内部槽,当前的响应式系统会在访问size时抛出错误。
解决方案:调整访问器属性的getter函数执行时this的指向,修改代码如下:
get(target, key, receiver) {
if (key === 'size') {
return Reflect.get(target, key, target)
}
return Reflect.get(target, key, receiver)
}
建立响应联系
了解了Set和Map类型数据创建代理时的注意事项之后,接下来可以建立响应系统,
在访问size属性时调用track函数进行依赖追踪,然后在add方法执行时调用trigger函数触发响应。
注意:响应联系需要建立在ITERATE_KEY与副作用函数之间,这是因为任何新增、删除操作都会影响size属性。
需要重新自定义add方法,代码如下
const mutableInstrumentations = {
add(key) {
const target = this.raw
const hadKey = target.has(key)
const res = target.add(key)
if (!hadKey) {
trigger(target, key, 'ADD')
}
return res
},
delete(key) {
const target = this.raw
const res = target.delete(key)
trigger(target, key, 'DELETE')
return res
}
}
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, receiver) {
if (key === 'raw') return target
if (key === 'size') {
track(target, ITERATE_KEY)
return Reflect.get(target, key, target)
}
return mutableInstrumentations[key]
}
})
}
避免数据污染
什么是数据污染:响应式数据设置到原始数据上的的行为称为数据污染。会在不需要触发响应系统时提前触发了响应系统。
解决方案:在target.set函数设置值之前,对值进行检查,发现数据将要设置的值是响应式数据,那么就通过raw属性获取原始数据,再把原始数据设置到target上,代码如下:
set(key, value) {
const target = this.raw
const had = target.has(key)
const oldValue = target.get(key)
// 获取原始数据,由于value本身可能已经是原始数据,若value.raw不存在则直接使用value
const rawValue = value.raw || value
target.set(key, rawValue)
if (!had) {
trigger(target, key, 'ADD')
} else if (oldValue !== value || (oldValue === oldValue && value === value)) {
trigger(target, key, 'SET')
}
}
总结
本章学习了:
1、Proxy与Reflect,Vue.js3的响应式基于Proxy实现的,Proxy可以为其他对象创建一个代理对象。代理:对一个对象基本语义的代理。允许拦截并重新定义对一个对象的基本操作。
2、学习了JS中的对象概念,Proxy的工作原理,常规对象和异质对象。
3、对象Object的代理,复合操作,添加、修改属性对for in的影响。
4、深响应与浅响应,深浅指的是对象的层级,浅响应只需要响应一层对象
5、数组的代理,数组是异质对象,关于数组length的响应,隐式或显式的修改数组的length,影响数组中已有的元素
6、隐式修改数组长度的原型方法,即push、pop、shift、unshift和splice
7、集合类型的响应式方案,size属性是一个访问器属性,本身没有内部槽,需要调整this的指向,在get函数内通过.bind函数为这些方法绑定正确的this值。避免数据污染,数据污染指的是,不小心把响应式数据添加到了原始数据中,通过响应式数据对象的 raw 属性来访问对应的原始数据对象,避免数据污染。