vuejs设计与实现-非原始值的响应式方案
理解 Proxy 和 Relfect
Proxy: 对一个对象基本语义的代理, 允许拦截并重新定义一个对象的基本操作.
obj.foo++ // 对象的读取设置操作
const fn = (a) => console.log(a)
// 函数的调用操作
const fnP = new Proxy(fn, {
apply(target, thisArg, argArray) {
console.log(target, thisArg, argArray)
target.call(thisArg, 'hhhhh')
}
})
// 非基本操作(复合操作) 1. get操作得到 obj.fn 2. (obj.fn)()函数调用
obj.fn()
Relfect: 全局对象, 提供了访问一个对象属性的默认行为. 第三个参数可以指定接收者receiver
, 可以理解为this
.
// bar 是一个访问器属性,返回了 this.foo 属性的值
const obj = {
foo: 1,
get bar(){
return this.foo
}
}
Relfect.get(obj, 'foo', { foo: 2 })
const p = new Proxy(obj, {
get(target, key, receiver){
track(target, key)
// return target[key]
return Relfect.get(target, key, receiver)
}
})
effect(() => {
// 此时访问器属性bar中的this指向代理对象p
// target[key] 此种情况执向原对象 obj.foo 无法建立联系
console.log(p.bar)
})
javascript对象及Proxy的工作原理
js中对象分为常规对象和异质对象. 对象的实际语义是由对象的内部方法(对一个对象操作时在引擎内部调用的方法)指定的. 函数对象会部署内部方法[[call]]
或者[[construct]]
(构造函数通过new关键字调用).
proxy是一个异质对象, 其内部的[[get]]
方法不同于普通方法的实现. 如果创建代理对象时没有指定对应的拦截函数, 代理对象内部的[[get]]
会调用原始对象的内部方法[[get]]
获取属性值. 拦截函数用于自定义代理对象的内部方法和行为, 而不是被代理对象.
如何代理Object
响应系统需要拦截所有读取操作, 普通对象所有可能的读取操作:
const obj = { foo: 1 }
// 访问属性: obj.foo
const p = new Proxy(obj, {
get(target, key, receiver){
track(target, key)
return Reflect.get(target, key, receiver)
}
})
effect(() => {
p.foo
})
// in操作符 key in obj
const p = new Proxy(obj, {
has(target, key){
track(target, key)
return Reflect.has(target, key)
}
})
effect(() => {
'foo' in p
})
// for...in循环
// 不与具体的key绑定, 所以构造一个唯一的key
const ITERATE_KEY = Symbol()
const p = new Proxy(obj, {
ownKeys(target){
track(target, ITERATE_KEY)
// 返回keys数组
return Reflect.ownKeys(target)
}
})
effect(() => {
for(const key in p){ ... }
})
// 修改trigger方法, 增加对 for...in 的处理
fcuntion 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)
}
})
// 只有新增或删除操作, 才触发与ITERATE_KEY相关联的副作用函数
// 其中proxy对象set操作的拦截方法中需要对操作类型作判断 add|edit
if(type === 'add' || type === 'delete'){
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach(fn => {
// 如果 scheduler 存在就调用, 并将副作用函数作为参数传递
if(fn.options.scheduler){
fn.options.scheduler(fn)
}else {
fn()
}
})
}
合理地触发响应
我们只需要在值发生变化时触发响应, 且NaN === NaN
为false同样不需要触发响应.
function reactive(obj){
return new Proxy(obj, {
get(target, key, receiver){
// 代理对象可以通过raw属性访问原始数据
if(key === 'raw') return target
track(target, key)
return Relfect.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)
// 只有当receiver是target的代理对象时才触发更新, 屏蔽由原型引起的更新
if(target === receiver.raw){
if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
trigger(target, key, type)
}
}
}
// ...其他拦截方法
})
}
只读和浅只读
代理数组
数组是一个特殊的对象(也是异质对象), 除了内部方法[[defieOwnProperty]]
外, 其他内部方法的逻辑与常规对象相同, 但数组的操作与普通对象有些不同. 以下是所有对数组元素或属性的读取操作:
- 通过索引访问元素: arr[0]
- 访问数组长度: arr.length
- 把数组作为对象, 使用for...in遍历
- 使用for...of遍历数组
- 数组的原型方法: concat/join/some/every/find(Index)/includes 以及其他不改变原数组的方法.
及设置操作:
- 通过索引修改元素: arr[0] = 1
- 修改数组长度: arr.length = 0
- 数组的栈方法: push/pop/shift/unshift
- 修改原数组的方法: splice/fill/sort 等
当这些操作发生时, 应该正确地建立响应联系或触发响应.
数组的索引与length
一般来说, 通过索引访问元素与对象是类似的. 但是如果设置的索引值大于当前长度, 此次操作也会更新length
属性; 如果设置length
属性的新值小于原来的值, 则会删除多余的元素. 此类操作都应该触发响应.
// 1. 判断当前的操作类型, 当前索引是否大于数组长度
// 2. lenght属性赋值, newV 是新的长度
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]
// 判断代理对象是否为数组
// 数组: 判断设置的索引值是否小于数组长度
// 对象: 判断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)){
// 修改length值时, newVal就是新的长度
trigger(target, key, type, newVal)
}
}
return res
}
})
}
fcuntion trigger(target, key, type, newVal){
const depsMap = bucket.get(target)
if(!depsMap) return
// ...省略其他代码
// 如果是数组且为add操作, 数组的length属性也应变化触发响应
if(Array.isArray(target) && type === 'add') {
const lengthEffects = depsMap.get('length')
lengthEffects && lengthEffects.forEach(effectFn => {
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
})
}
// 如果是数组, 且修改了length属性, newVal便是新的length
if(Array.isArray(target) && key === 'length') {
// 索引大于或等于新的length值的元素, 将其所有相关联的副作用函数取出执行
depsMap.forEach((effects, key) => {
if(key >= newVal){
effects.forEach(effectFn => {
if(effectFn !== activeEffect){
effectsToRun.add(effectFn)
}
}
})
}
effectsToRun.forEach(fn => {
// 如果 scheduler 存在就调用, 并将副作用函数作为参数传递
if(fn.options.scheduler){
fn.options.scheduler(fn)
}else {
fn()
}
})
}
遍历数组
对于普通对象, 添加或删除属性时才会影响for...in
循环的结果. 对于数组, 只要是length发生变化, 都应该触发响应.
const p = new Proxy(obj, {
// ...,
ownKeys(target){
// 如果是数组, 则使用length属性代替ITERATE_KEY
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
与for...in
遍历不同, for...of
是用来遍历可迭代对象的.
数组的查找方法
数组的方法内部其实都依赖了对象的基本语义. 大多数情况下不需要特殊处理即可让这些方法按预期工作.
const arr = reactive([1, 2])
effect(() => {
console.log(arr.includes(1)) // 初始打印true
})
arr[0] = 3 // 重新执行打印false
- 通过代理对象访问元素值, 如果值仍然是可以被代理的, 那么得到的值就是新的代理对象而非原始对象. 通过
arr[0]
和includes
都是得到代理对象, 因为每次调用reactive
函数都会创建新的代理对象. 因此需要一个存储原始对象到代理对象的映射, 避免多次创建代理对象. - 通过
includes
判断原始对象是否存在时, 直觉上应该为真. 但实际上由于访问的是代理对象, 会返回false
. 因此需要重写数组方法...
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(arr[0])) // false
console.log(arr.includes(obj)) // false
// 1: 使用map实例存储映射关系 解决第一种情况
const reactiveMap = new Map()
function reactive(obj){
let proxy = reactiveMap.get(obj)
if(proxy) return proxy
proxy = createReactive(obj)
reactiveMap.set(obj, proxy)
return proxy
}
// 2.重写includes等方法, 分别对原始对象和代理对象尝试执行此方法
const arrayInstrumentations = {};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function(...args){
let res = originMethod.apply(this, args)
// 没找到, 则在原始数组中再查一次
if(res === false || res === -1){
res = originMethod.apply(this.raw, args)
}
return res
}
})
function createReactive(obj){
return new Proxy(obj, {
get(target, key, receiver){
// ...
// 如果是数组, 且是对应的操作则返回重写后的值
if(Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
// ...
}
})
}
隐式修改数组长度的原型方法
push/pop/shift/unshift
等方法会修改原数组, 改变数组长度. 比如push
操作, 需要在末位插入元素, 既会读取又会设置lenght
.
// 是否允许追踪
let shouldTrack = true;
function track(){
// ...
if(!shouldTrack || !activeEffect) return
// ...
}
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function(...args){
shouldTrack = false
let res = originMethod.apply(this, args)
// 调用方法之后才允许追踪, push读取length但此时阻止了追踪
shouldTrack = true
return res
}
})
代理Set和Map
集合类型对象与普通对象有很大不同, 有自己独特的属性及方法.
如何代理Set和Map
所以进行代理时需要做特殊的处理.
const reactiveMap = new Map()
fucntion reactive(obj){
const existionProxy = reactiveMap.get(obj)
if(existionProxy) return existionProxy
const proxy = createReactive(obj)
reactiveMap.set(obj, proxy)
return proxy
}
fucntion createReactive(obj, isShallow, isReadonly = false){
return new Proxy(obj, {
get(target, key, receiver) {
// map.size 集合类对象特殊处理, 访问size属性时this指向原始对象
if(key === 'size') {
return Reflect.get(target, key, target)
}
// map.delete(...) 方法特殊处理, 将方法与原始对象绑定后返回
return target[key].bind(target)
}
})
}
建立响应联系
了解代理时的关键, 就可以实现响应式方案了.
- size属性: 读取size属性时, 调用track函数建立响应联系. 由于新增和删除操作都会改变size属性, 所以需要在ITERATE_KEY与副作用之间建立联系.
- 同时需要实现自定义新增与删除方法, 将返回值
target[key].bind(target)
改为mutableInstrumentations[key]
, 在自定义方法中执行trigger
. - 另, 集合类对象在新增(如果已存在)或删除(如果不存在)时, 不需要触发响应!
避免污染原始数据
响应式数据设置到原始数据的行为就是数据污染, 比如Map类型的set
、Set类型的add
、普通对象的写值操作、数组添加元素等, 都需要避免污染.
避免污染的方法, 在进行上述操作时, 如果要写入的值为响应式数据, 则通过raw
(源码中用Symbol类型)属性获取原始数据, 再把原始数据写入target
.
处理forEach
与数组方法的forEach
方法不同, 其回调函数有三个参数(value, key, map)
, Map对象既关心key
也关心value
. 除了与ITERATE_KEY建立联系, 也要对set
操作进行处理.
总之, 不同对象的forEach
方法有其各自的特点, 均需要根据实际语义对其做处理.
迭代器方法
总结
- vue3响应式数据基于
Proxy
代理实现, 其中Reflect.*
解决了this
指向的问题. - Obejct对象的代理
- 深响应与浅响应以及深只读与浅只读, 深响应(只读)返回值需要做一层包装.
...未完成
转载自:https://juejin.cn/post/7208540587077763109