背景

咱们都晓得 vue3 重写了响应式代码,应用 Proxy 来劫持数据操作,分离出来了独自的库@vue/reactivity,不限于vue 在任何 js 代码都能够应用

然而正因为应用了ProxyProxy还无奈用polyfill来兼容,就导致了不反对Proxy的环境下无奈应用,这也是 vue3 不反对 ie11 的一部分起因

本文内容:重写了 @vue/reactivity 的劫持局部,来兼容不反对 Proxy 的环境

通过本文能够一些内容:

  • 响应式原理
  • @vue/reactivityvue2 响应式的区别
  • 在改写中遇到的问题及解决方案
  • 代码实现
  • 利用场景及限度

源码地址:reactivity 次要为 defObserver.ts 文件

响应式

在开始之前咱们先对 @vue/reactivity 的响应式有个简略的理解

首先是对一个数据进行了劫持

get 获取数据的时候去收集依赖,记录本人是在哪个办法里调用的,假如是被办法 effect1 调用

set 设置数据的时候就拿到 get 时候记录的办法,去触发 effect1 函数,达到监听的目标

effect 是一个包装办法,在调用前后将执行栈设置为本人,来收集函数执行期间的依赖

区别

vue3 相比 vue2 的最大区别就是应用了 Proxy

Proxy能够比Object.defineProperty有更全面的代理拦挡:

  • 未知属性的get/set劫持

    const obj = reactive({});effect(() => {  console.log(obj.name);});obj.name = 111;

    这一点在Vue2中就必须应用set办法来赋值

  • 数组元素下标的变动,能够间接应用下标来操作数组,间接批改数组length

    const arr = reactive([]);effect(() => {  console.log(arr[0]);});arr[0] = 111;
  • delete obj[key] 属性删除的反对

    const obj = reactive({  name: 111,});effect(() => {  console.log(obj.name);});delete obj.name;
  • key in obj 属性是否存在 has 的反对

    const obj = reactive({});effect(() => {  console.log("name" in obj);});obj.name = 111;
  • for(let key in obj){} 属性被遍历 ownKeys 的反对

    const obj = reactive({});effect(() => {  for (const key in obj) {    console.log(key);  }});obj.name = 111;
  • MapSetWeakMapWeakSet 的反对

这些是Proxy带来的性能,还有一些新的概念或应用形式上的变动

  • 独立的分包,不止能够在 vue 里应用
  • 函数式的办法 reactive/effect/computed 等办法,更加灵便
  • 原始数据与响应数据隔离,也能够通过toRaw来获取原始数据,在 vue2 中是间接在原始数据中进行劫持操作
  • 性能更加全面 reactive/readonly/shallowReactive/shallowReadonly/ref/effectScope,只读、浅层、根底类型的劫持、作用域

那么如果咱们要应用Object.defineProperty,能实现下面的性能吗?会遇到哪些问题?

问题及解决

咱们先疏忽ProxyObject.defineProperty性能上的差别

因为咱们要写的是@vue/reactivity而不是基于vue2,所以要先解决一些新概念差别的问题,如原始数据和响应数据隔离

@vue/reactivity 的做法,原始数据和响应数据之间有一个弱类型的援用WeakMap),在 get 一个object类型数据的时候拿的还是原始数据,只是判断一下如果存在对应的响应数据就去取,不存在就生成一个对应的响应式数据保留并获取

这样在 get 层面管制,通过响应式数据拿到的永远是响应式,通过原始对象拿到的永远是原始数据(除非间接将一个响应式间接赋值给一个原始对象里属性)

那么 vue2 的源码就不能间接拿来用了

依照下面所说的逻辑,写一个最小实现的代码来验证逻辑:

const proxyMap = new WeakMap();function reactive(target) {  // 如果以后原始对象曾经存在对应响应对象,则返回缓存  const existingProxy = proxyMap.get(target);  if (existingProxy) {    return existingProxy;  }  const proxy = {};  for (const key in target) {    proxyKey(proxy, target, key);  }  proxyMap.set(target, proxy);  return proxy;}function proxyKey(proxy, target, key) {  Object.defineProperty(proxy, key, {    enumerable: true,    configurable: true,    get: function () {      console.log("get", key);      const res = target[key];      if (typeof res === "object") {        return reactive(res);      }      return res;    },    set: function (value) {      console.log("set", key, value);      target[key] = value;    },  });}

<!-- 此示例在 codepen 中尝试 -->

在线上示例中尝试

这样咱们做到了,原始数据和响应数据隔离,并且不论数据层级有多深都能够

当初咱们还面临一个问题,数组怎么办?

数组通过下标来获取,跟对象的属性还不太一样,这要怎么来做隔离

那就是跟对象一样的形式来劫持数组下标

const target = [{ deep: { name: 1 } }];const proxy = [];for (let key in target) {  proxyKey(proxy, target, key);}

在线上示例中尝试

就是在下面的代码里加个isArray的判断

而这样也决定了咱们前面要始终保护这个数组映射,其实也简略,在数组push/unshift/pop/shift/splice等长度变动的时候给新增或删除的下标从新建设映射

const instrumentations = {}; // 寄存重写的办法["push", "pop", "shift", "unshift", "splice"].forEach((key) => {  instrumentations[key] = function (...args) {    const oldLen = target.length;    const res = target[key](...args);    const newLen = target.length;    // 新增/删除了元素    if (oldLen !== newLen) {      if (oldLen < newLen) {        for (let i = oldLen; i < newLen; i++) {          proxyKey(this, target, i);        }      } else if (oldLen > newLen) {        for (let i = newLen; i < oldLen; i++) {          delete this[i];        }      }      this.length = newLen;    }    return res;  };});

老的映射无需扭转,只用映射新的下标和删除已被删除的下标

这样做的毛病就是,如果你重写了数组的办法,并在外面设置了一些属性并不能成为响应式

例如:

class SubArray extends Array {  lastPushed: undefined;  push(item: T) {    this.lastPushed = item;    return super.push(item);  }}const subArray = new SubArray(4, 5, 6);const observed = reactive(subArray);observed.push(7);

这里的 lastPushed 无奈被监听,因为 this 是原始对象
有个解决方案就是在 push 之前将响应数据记录,在 set 批改元数据的时候判断并触发,还在思考是否这样应用

// 在劫持push办法的时候enableTriggering()const res = target[key](...args);resetTriggering()// 申明的时候{  push(item: T) {    set(this, 'lastPushed', item)    return super.push(item);  }}

实现

get 劫持里调用 track 去收集依赖

setpush 等操作的时候去 触发 trigger

用过 vue2 的都应该晓得defineProperty的缺点,无奈监听属性删除和未知属性的设置,所以有一个已有属性未知属性的区别

其实下面的示例略微欠缺一下就能够了,就曾经反对了已有属性的劫持

const obj = reactive({  name: 1,});effect(() => {  console.log(obj.name);});obj.name = 2;

接下来在实现上咱们要修复 definePropertyProxy 的差别

上面几点差别:

  • 数组下标变动
  • 未知元素的劫持
  • 元素的 hash 操作
  • 元素的 delete 操作
  • 元素的 ownKeys 操作

数组的下标变动

数组有点非凡就是当咱们调用 unshift 在数组最开始插入元素的时候,要 trigger 去告诉数组每一项变动了,这个在Proxy中齐全反对不须要写多余代码,然而应用defineProperty就须要咱们去兼容去计算哪些下标变动

spliceshiftpoppush等操作的时候也同样须要去计算出变动了哪些下标而后去告诉

另外有个毛病:数组扭转 length 也不会被监听,因为无奈从新length属性

将来可能思考换成对象来代替数组,不过这样就不能用Array.isArray来判断了:

const target = [1, 2];const proxy = Object.create(target);for (const k in target) {  proxyKey(proxy, target, k);}proxyKey(proxy, target, "length");

其余操作

剩下的这些属于defineProperty的硬伤,咱们只能通过新增额定的办法来反对

所以咱们新增了 set、get、has、del、ownKeys 办法

(可点击办法查看源码实现)

应用
const obj = reactive({});effect(() => {  console.log(has(obj, "name")); // 判断未知属性});effect(() => {  console.log(get(obj, "name")); // 获取未知属性});effect(() => {  for (const k in ownKeys(obj)) {    // 遍历未知属性    console.log("key:", k);  }});set(obj, "name", 11111); // 设置未知属性del(obj, "name"); // 删除属性

obj 原本是一个空对象,并不知道将来会增加什么属性

setdel 都是 vue2 中存在的,用来兼容defineProperty的缺点

set 代替了未知属性的设置
get 代替了未知属性的获取
del 代替了delete obj.name 删除语法
has 代替了 'name' in obj 判断是否存在
ownKeys 代替了 for(const k in obj) {}等遍历操作,在将要遍历对象/数组的时候要用ownKeys包裹

利用场景及限度

目前来说此性能次要定位为:vue环境并且不反对 Proxy

其余的语法应用 polyfill 兼容

因为老版的 vue2 语法也不必改,如果要在 vue2 应用新语法也能够应用 composition-api 来兼容

为什么要做这个事件,起因还是咱们的利用(小程序)其实还是有一部分用户的环境是不反对 Proxy ,但还想用 @vue/reactivity 这种语法

至于通过下面应用的例子咱们应该也晓得了,限度是挺大的,灵活性的代价也很高

如果想要灵便一点必须应用办法包装一下,如果不灵便的话,用法就跟 vue2 差不太多,所有的属性先初始化的时候定义一下

const data = reactive({  list: [],  form: {    title: "",  },});

这种办法带来了一种心智上的损耗,在应用和设置的时候都要思考这个属性是否是未知的属性,是否要应用办法来包装

粗犷点的给所有设置都用办法包裹,这样的代码也好看不到哪里去

而且依据木桶效应,一旦应用了包装办法,那么在高版本的时候主动切换到Proxy劫持如同也就没有必要了

另一种计划是在编译时解决,给所有获取的时候套上 get 办法,给所有的设置语法套上 set 办法,但这种带来的老本无疑是十分大的,并且一些 js 语法灵活性过高也无奈撑持