作者 | jkonieczny
譯者 | 核子可樂
策劃 | Tina
Vue 的 reactivity 響應式機制确實不錯,隻是有個“小”缺點:它會搞亂引用。本來一切看起來好好的,連 TypeScript 都說沒問題,但突然就崩了。
我這裡聊的可不是帶有強制輸入的嵌套引用,那明顯更複雜、更混亂。隻有對一切了然于胸的大師才能解決這類問題,是以本文暫且不表。
哪怕在日常使用當中,如果大家不了解其工作原理,reactivity 也可能引發各種令人抓狂的問題。
一個簡單數組
讓我們看看以下代碼:
let notifications = [] as Notification[];
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
notifications.push(notification);
function removeNotification() {
notifications = notifications
.filter((inList) => inList != notification);
}
if (autoclose > 0) {
setTimeout(removeNotification, autoclose);
}
return removeNotification;
}
都挺好的,對吧?如果 autoclose 不為零,它就會自動從清單中删除通知。我們也可以調用傳回的函數來手動将其關閉。代碼又清晰又漂亮,哪怕調用兩次,removeNotification 也能正常起效,僅僅删除掉跟我們推送到數組中的元素完全相同的内容。
好的,但它不符合響應式标準。現在看以下代碼:
const notifications = ref<Notification[]>([]);
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
notifications.value.push(notification);
function removeNotification() {
notifications.value = notifications.value
.filter((inList) => inList != notification);
}
if (autoclose > 0) {
setTimeout(removeNotification, autoclose);
}
return removeNotification;
}
這完全就是一回事,是以應該也能正常運作吧?我們是想讓數組疊代各條目,并過濾掉與我們所添加條目相同的條目。但情況并非如此。理由也不複雜:我們以參數形式收到的 notification 對象很可能是個普通的 JS 對象,而在數組中該條目是個 Proxy。
那該如何處理?
使用 Vue 的 API
如果我們出于某種原因而不想修改對象,則可以使用 toRaw 擷取數組中的實際條目,調整之後該函數應該如下所示:
function removeNotification() {
notifications.value = notifications.value
.filter(i => toRaw(i) != notification);
}
簡而言之,函數 toRaw 會傳回 Proxy 下的實際執行個體,這樣我們就可以直接對執行個體進行比較了。到這裡,問題應該消失了吧?
不好意思,問題可能仍然存在,後面大家就知道為什麼了。
直接使用 ID/Symbol
最簡單也最直覺的解決方案,就是在 notification 中添加一個 ID 或者 UUID。我們當然不想在每次代碼調用通知時都生成一個 ID,比如 showNotification({ title: “Done!”, type: “success” }),是以這裡做如下調整:
type StoredNotification = Notification & {
__uuid: string;
};
const notifications = ref<StoredNotification[]>([]);
function showNotification(notification: Notification) {
const { autoclose = 5000 } = notification;
const stored = {
...notification,
__uuid: uuidv4(),
}
notifications.value.push(stored);
function removeNotification() {
notifications.value = notifications.value
.filter((inList) => inList.__uuid != stored.__uuid);
}
// ...
}
由于 JS 運作時環境是單線程的,我們不會将其發送到任何其他地方,是以這裡隻需要建立一個計數器并生成 ID,具體參考以下代碼:
let _notificationId = 1;
function getNextNotificationId() {
const id = _notificationId++;
return `n-${id++}`;
}
// ...
const stored = {
...notification,
__uuid: getNextNotificationId(),
}
實際上,隻要這裡的 _uuid 不會被發送到其他地方,而且調用次數不超過 2⁵³次,那上述代碼就沒什麼問題。如果非要改進,也可以加上帶有遞增值的日期時間戳。
如果擔心 2⁵³這個最大安全整數值還不夠用,可以采取以下方法:
function getNextNotificationId() {
const id = _notificationId++;
if (_notificationId > 1000000) _notificationId = 1;
return `n-${new Date().getTime()}-${id++}`;
}
到這裡問題就解決了,但本文的重點不在于此。
使用“淺”響應
既然沒有必要,為什麼要使用“深”響應?說真的,我知道這很簡單、性能也不錯,但是……為什麼要在非必要時使用“深”響應?
無需更改給定對象中的任何内容。我們可能需要顯示通知的定義、一些相關标簽,也許還涉及某些操作(函數),但這些都不會對内部造成任何影響。隻需将 ref 直接替換成 shallowRef,就這麼簡單!
const notifications = shallowRef<Notification[]>([]);
現在 notifications.value 将傳回源數組。但容易被大家忽略的是,如此一來該數組本身不再具有響應性,我們也無法調用.push,因為它不會觸發任何效果。是以說如果我們用 shallowRef 直接替換 ref,結果就是條目隻有在被移除出數組時才會更新,因為這時我們才會用新執行個體重新配置設定數組。我們需要把:
notifications.value.push(stored);
替換成:
notifications.value = [...notifications.value, stored];
這樣,notifications.value 将傳回一個包含普通對象的普通數組,保證我們可以用 == 安全進行比較。
下面我們總結一下前面這些内容,并稍做解釋:
- 普通 JS 對象——就是一個簡單的原始 JS 對象,沒有任何打包器,console.log 将隻輸出{title: ‘foo’},僅此而已。
- ref 與 shallowRef 執行個體會直接輸出名為 RefImpl 的類的對象,其中包含一個字段(或者說 getter).value 和一些其他我們無需處理的私有字段。
- ref 的.value 所傳回的,就是會傳回 reactive 的相同内容,即用于模仿給定值的 Proxy,是以它将輸出 Proxy(Object){title: ‘foo’}。每個非原始嵌套字段也都是一個 Proxy。
- shallowRef 的.value 傳回該普通 JS 對象。同樣的,這裡隻有.value 是響應式的(後文将具體解釋),而且不涉及嵌套字段。
我們可以總結如下:
plain: {title: 'foo'}
deep: RefImpl {__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: Proxy(Object)}
deepValue: Proxy(Object) {title: 'foo'}
shallow: RefImpl {__v_isShallow: true, dep: undefined, __v_isRef: true, _rawValue: {…}, _value: {…}}
shallowValue: {title: 'foo'}
現在來看以下代碼:
const raw = { label: "foo" };
const deep = ref(raw);
const shallow = shallowRef(raw);
const wrappedShallow = shallowRef(deep);
const list = ref([deep.value]);
const res = {
compareRawToOriginal: toRaw(list.value[0]) == raw,
compareToRef: list.value[0] == deep.value,
compareRawToRef: toRaw(list.value[0]) == deep.value,
compareToShallow: toRaw(list.value[0]) == shallow.value,
compareToRawedRef: toRaw(list.value[0]) == toRaw(deep.value),
compareToShallowRef: list.value[0] == shallow,
compareToWrappedShallow: deep == wrappedShallow,
}
運作結果為:
{
"compareRawToOriginal": true,
"compareToRef": true,
"compareRawToRef": false,
"compareToShallow": true,
"compareToRawedRef": true,
"compareToShallowRef": false,
"compareToWrappedShallowRef": true
}
解釋:
- compareOriginal (toRaw(list.value[0]) == raw): toRaw(l.value[0]) 将傳回與 raw 相同的内容:一個普通 JS 對象執行個體。這也證明了我們之前的假設。
- compareToRef (list.value[0] == deep.value): deep.value 是一個 Proxy,與該數組要使用的 proxy 相同,這裡無需建立額外的打包器。此外,這裡還存在另一種機制。
- compareRawToRef (toRaw(list.value[0]) == deep.value): 我們是在将“rawed”原始對象與 Proxy 進行比較。之前我們已經證明了 toRaw(l.value[0]) 與 raw 相同,是以它肯定不是 Proxy。
- compareToShallow (toRaw(list.value[0]) == shallow.value): 然而,這裡我們将 raw(通過 toRaw 傳回)與 shallowRef 存儲的值進行比較,而後者并非響應式,是以 Vue 在這裡不會傳回任何 Proxy,而僅傳回該普通對象,也就是 raw。跟預期一樣,這裡沒有問題。
- compareToRawedRef (toRaw(list.value[0]) == toRaw(deep.value)): 但如果我們将 toRaw(l.value[0]) 與 toRaw(deep.value) 進行比較,就會發現二者擁有相同的原始對象。總之,我們之前已經證明 l.value[0] 與 deep.value 是相同的。可 TypeScript 會将此标記為錯誤。
- compareToShallowRef (list.value[0] == shallow): 明顯為 false,因為 shallowRef 的 Proxy 不可能與 ref 的 Proxy 相同。
- compareToWrappedShallowRef (deep == wrappedShallow): 這是……什麼玩意?出于某種原因,如果向 shallowRef 給定一個 ref,它隻會傳回該 ref。而如果源 ref 與預期 ref 均屬于同一類型(淺或深),那就完全沒問題。但這裡……可就奇了怪了。
總結:
- deep.value == list[0].value (一個内部 reactive)
- shallow.value == raw (普通對象,沒什麼特别)
- toRef(deep.value) == toRef(list[0].value) == raw == shallow.value (擷取普通對象)
- wrappedShallow == deep , 是以 wrappedShallow.value == deep.value (重用為該目标建立的 reactive )
現在來看第二個條目 ,根據 shallowRef 的值或者直接根據 raw 值進行建立:
const list = ref([shallow.value]);
複制代碼
{
"compareRawToOriginal": true,
"compareToRef": true,
"compareRawToRef": false,
"compareToShallow": true,
"compareToRawedRef": true,
"compareToShallowRef": false
}
複制代碼
看起來平平無奇,是以這裡我們隻聊最重要的部分:
- compareToRef (list.value[0] == deep.value): 我們将清單傳回的 Proxy 與根據同一來源建立的 ref 的.value 進行比較。結果……為 true?這怎麼可能?Vue 在内部使用 WeakMap 來存儲對所有 reactive 的引用,是以當建立一個 reactive 時,它會檢查之前是否已經重複建立并進行重用。正因為如此,從同一來源建立的兩個單獨 ref 才會彼此産生影響。這些 ref 都将擁有相同的.value。
- compareRawToRef (toRaw(list.value[0]) == deep.value): 我們再交将普通對象與 RefImpl 進行比較。
- compareToShallowRef (list.value[0] == shallow): 即使條目是根據 shallowRef 的值建立而成,清單也仍為“深”響應式,且會傳回深響應式 RefImpl——其中所有字段均為響應式。是以比較式左側包含 Proxy,而右側是一個執行個體。
那又會怎樣?
即使我們将清單的 ref 替換為 shallowRef,那麼哪怕清單本身并非深響應式,隻要以參數形式給定的值為響應式,則該清單也将包含響應式元素。
const notification = ref({ title: "foo" });
showNotification(notification.value);
複制代碼
被添加進數組中的值将是 Proxy,而非{title: ‘foo’}。好消息是 == 仍然能夠正确完成比較,因為.value 傳回的對象也會随之改變。但如果我們隻在一側執行 toRaw,則 == 将無法正确比較兩個對象。
總結
VUe 中的深響應式機制确實很棒,但也帶來了不少值得我們小心警惕的陷阱。請大家再次牢記,在使用深響應式對象時,我們實際上一直在處理 Proxy、而非實際 JS 對象。
請盡量避免用 == 對響應式對象執行個體進行比較,如果确定必須這樣做,也請保證操作正确——比如兩側都需要使用 toRaw。而更好的辦法,應該是嘗試添加唯一辨別符、ID、UUID,或者使用可以安全比較的現有條目唯一原始值。如果對象是資料庫中的條目,則很可能擁有唯一的 ID 或者 UUID(如果足夠重要,可能還包含修改日期)。
千萬不要直接使用 Ref 作為其他 Ref 的初始值。務必使用它的.value,或者通過 ToValue 或 ToRaw 擷取正确的值,具體取決于大家對代碼可調試性的需求。
友善的話盡量使用淺響應式,或者更确切地說:隻在必要時使用深響應式。在大多數情況下,其實我們根本不需要深響應式。當然,通過編寫 v-model=”form.name”來避免重寫整個對象肯定是好事,但請想好有沒有必要在一個隻從後端接收資料的隻讀清單上使用響應式?
對于體量龐大的數組,我在實驗渲染時成功實作了性能倍增。雖然 2 毫秒和 4 毫秒之間的差異可有可無,但 200 毫秒和 400 毫秒間的差異卻相當明顯。而且資料結構越是複雜(涉及大量嵌套對象和數組),這種性能差異就越大。
Vue 的響應式類型可謂亂七八糟,我們完全沒必要非去避簡就繁。而且隻要一旦開始使用奇奇怪怪的機制,就需要更多奇奇怪怪的操作來善後。千萬别在這條彎路上走得太遠,及時回頭方為正道。這裡我就不讨論把 Ref 存儲在其他 Ref 中的情況了,那容易讓人腦袋爆炸。
原文連結:Vue 的響應式機制就是個“坑”_架構/架構_InfoQ精選文章