03-非原始值的响应式方案

102 阅读13分钟

学习了简单的响应式系统的设计,可以简单的拦截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 属性来访问对应的原始数据对象,避免数据污染。