1
头图

背景

我们都知道 vue3 重写了响应式代码,使用 Proxy 来劫持数据操作,分离出来了单独的库@vue/reactivity,不限于vue 在任何 js 代码都可以使用

但是正因为使用了ProxyProxy还无法用polyfill来兼容,就导致了不支持Proxy的环境下无法使用,这也是 vue3 不支持 ie11 的一部分原因

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

通过本文可以一些内容:

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

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

响应式

在开始之前我们先对 @vue/reactivity 的响应式有个简单的了解

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

get 获取数据的时候去收集依赖,记录自己是在哪个方法里调用的,假设是被方法 effect1 调用

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

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

区别

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

Proxy可以比Object.defineProperty有更全面的代理拦截:

Proxy 虽然带来了更加全面的功能,但是也带来了性能,Proxy 实际上比 Object.defineProperty 慢得多)

关于ES6 Proxy性能的思考

  • 未知属性的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的硬伤,我们只能通过新增额外的方法来支持

所以我们新增了 setgethasdelownKeys 方法

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

使用
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 语法灵活性过高也无法支撑


李十三
13.6k 声望17.8k 粉丝