乐趣区

关于vue3:反向操作我让-vuereactivity-支持非-Proxy-环境

背景

咱们都晓得 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 语法灵活性过高也无奈撑持

退出移动版