反向操作,我讓 vue/reactivity 支援非 Proxy 環境

李十三發表於2021-12-14

背景

我們都知道 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的硬傷,我們只能通過新增額外的方法來支援

所以我們新增了 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 語法靈活性過高也無法支撐

相關文章