背景
我們都知道 vue3
重寫了響應式程式碼,使用 Proxy
來劫持資料操作,分離出來了單獨的庫@vue/reactivity
,不限於vue
在任何 js 程式碼都可以使用
但是正因為使用了Proxy
,Proxy
還無法用polyfill
來相容,就導致了不支援Proxy
的環境下無法使用,這也是 vue3
不支援 ie11
的一部分原因
本文內容:重寫了 @vue/reactivity
的劫持部分,來相容不支援 Proxy
的環境
通過本文可以一些內容:
- 響應式原理
@vue/reactivity
和vue2
響應式的區別- 在改寫中遇到的問題及解決方案
- 程式碼實現
- 應用場景及限制
原始碼地址: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;
- 對
Map
、Set
、WeakMap
、WeakSet
的支援
這些是Proxy
帶來的功能,還有一些新的概念或使用方式上的變化
- 獨立的分包,不止可以在
vue
裡使用 - 函式式的方法
reactive
/effect
/computed
等方法,更加靈活 - 原始資料與響應資料隔離,也可以通過
toRaw
來獲取原始資料,在vue2
中是直接在原始資料中進行劫持操作 - 功能更加全面
reactive
/readonly
/shallowReactive
/shallowReadonly
/ref
/effectScope
,只讀、淺層、基礎型別的劫持、作用域
那麼如果我們要使用Object.defineProperty
,能完成上面的功能嗎?會遇到哪些問題?
問題及解決
我們先忽略Proxy
和Object.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
去收集依賴
在 set
或 push
等操作的時候去 觸發 trigger
用過 vue2
的都應該知道defineProperty
的缺陷,無法監聽屬性刪除和未知屬性的設定,所以有一個已有屬性和未知屬性的區別
其實上面的示例稍微完善一下就可以了,就已經支援了已有屬性的劫持
const obj = reactive({
name: 1,
});
effect(() => {
console.log(obj.name);
});
obj.name = 2;
接下來在實現上我們要修復 defineProperty
和 Proxy
的差異
下面幾點差異:
- 陣列下標變動
- 未知元素的劫持
- 元素的
hash
操作 - 元素的
delete
操作 - 元素的
ownKeys
操作
陣列的下標變化
陣列有點特殊就是當我們呼叫 unshift
在陣列最開始插入元素的時候,要 trigger
去通知陣列每一項變化了,這個在Proxy
中完全支援不需要寫多餘程式碼,但是使用defineProperty
就需要我們去相容去計算哪些下標變動
在splice
、shift
、pop
、push
等操作的時候也同樣需要去計算出變動了哪些下標然後去通知
另外有個缺點:陣列改變 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
本來是一個空物件,並不知道未來會新增什麼屬性
像 set
和 del
都是 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 語法靈活性過高也無法支撐