「故に遠くに遠くに耽美に無様に芽生えたら。」——Kanaria
上回書說道,我們在/src/runtime-dom路徑下創建了patchProp.ts檔案,並在同層modules路徑下創建了attr.ts、class.ts、event.ts、style.ts四個檔案。今天要從這四個檔案開始,完成給dom元素添上屬性的功能。
不過在此之前,眼尖的小夥伴可能會發現,昨天的進度中在patchProp.ts暴露的方法patchProp,其接收參數包含了prevValue及nextValue。這是因為其實所謂的首次渲染,其實也是一種比較,只是一般的比較是從舊數據對應的dom元素變成新數據對應的dom元素,而首次渲染是從null變成新數據所對應的dom元素。
如果覺得我描述得抽象,我們可以看一個例子:
<template>
<div>{{ msg }}</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const msg = ref<string>('nmsl')
setTimeout(()=>{
msg.value = 'wcnm'
}, 3000)
</script>
這是一段很簡單的例子,先在畫面上顯示你媽s……我是說檸檬森林,並在3秒後改成我操n……晚餐檸檬。從檸檬森林變成晚餐檸檬,就是div裡面的文字從檸檬森林改成晚餐檸檬,這應該很直觀,沒甚麼爭議。不過在最一開始script執行完,要去渲染template時,其實也是將不存在的標籤變成了裝著檸檬森林的div。
所以其實渲染dom元素的這些方法,可以全部復用比對數據變更驅動視圖時,比對數據並重新渲染dom元素的方法。
接下來我們給dom元素增/減屬性時,也會使用prevValue和nextValue為參數命名,請記得渲染dom元素即是從空值變成有值。
比對屬性及class是最為簡單的,因為js已為我們提供了便利的api,attr.ts僅需以下代碼:
export const patchAttr = (el: HTMLElement, key: string, nextValue: string) => {
if (nextValue) el.setAttribute(key, nextValue)
else el.removeAttribute(key)
}
而class.ts僅需以下代碼:
export const patchClass = (el: HTMLElement, nextValue: string) => {
if (nextValue == null) el.removeAttribute('class')
else el.className = nextValue
}
一模一樣的步驟,完全相同的邏輯。就是判斷有沒有接收到新的屬性,有的話設置上去,收到null的話把它移除。
然後我們再回到/src/runtime-dom/patchProp.ts把這兩個方法的實參補上:
if (key === 'class') patchClass(el, nextValue);
else patchAttr(el, key, nextValue);
就完成了。
style的部分相對複雜一點點,需要以下代碼:
type Style = null | Record<string, string>;
export const patchStyle = (el: HTMLElement, prevValue: Style, nextValue: Style) => {
for (const key in nextValue) {
// 這邊請容許我給key類型斷言成any,真源碼甚至直接給它放著紅線報錯……
el.style[<any>key] = nextValue[key];
}
if (prevValue) {
for (const key in prevValue) {
if (nextValue == null || nextValue[key] == null) el.style[<any>key] = null;
}
}
}
prevValue和nextValue會是這種格式:
{
color: 'pink',
padding: '5px'
}
所以我們先遍歷新style的每一個key,既然是新style的key,自然將這個el的對應樣式設置成新的值。
for (const key in nextValue) {
el.style[<any>key] = nextValue[key];
}
但這樣一來並不會清掉舊的樣式。例如如果我們數據發生變化,使某個節點的樣式從{ color: 'pink' }變成了null,要是我們只遍歷newValue的每個屬性去改變style,舊的color: 'pink'是不會被清掉的。因此我們遍歷完newValue後,勢必還得再遍歷prevValue,將舊值有但新值沒的style清除。
for (const key in prevValue) {
if (nextValue == null || nextValue[key] == null) el.style[<any>key] = null;
}
最後一樣要在patchProp.ts補上實參:
else if (key === 'style') patchStyle(el, prevValue, nextValue);
事件的綁定是今天的進度中最困難的部分,但其實和過去我們學習過的依賴收集,以及之後要學的diff算法、ast抽象語法樹相比也是小菜一疊。
首先事件的綁定要使用addEventListener,而清除綁定需要使用removeEventListener,而removeEventListener需要接收事件名及要清除的方法的地址
,因此我們需要記錄每個節點的每一種事件所對應的方法的地址。
因此我們暫且先在節點對象上新增一個_vei屬性,這個屬性是一個代表映射表的物件,將每個事件名和對應的方法的地址做映射關係。
export const patchEvent = (el: any, vEvent: string, nextValue: Function | string) => {
if (typeof nextValue === 'string') nextValue = eval(nextValue);
const invokers = el._vei || (el._vei = {});
const event = vEvent.slice(1);
const existingInvoker = invokers[event];
if (nextValue && existingInvoker) {
el.removeEventListener(event, existingInvoker);
el.addEventListener(event, invokers[event] = nextValue);
} else if (nextValue) {
el.addEventListener(event, invokers[event] = nextValue);
} else if (existingInvoker) {
el.removeEventListener(event, existingInvoker);
invokers[event] = undefined;
}
};
這是一個相對直觀但有待優化的範例,我們先逐行解讀。
if (typeof nextValue === 'string') nextValue = eval(nextValue);
eval能將字串轉換成代碼,例如eval(() => { console.log('test'); })就會回傳() => { console.log('test'); }。
const event = vEvent.slice(1);
我們接收到的vEvent會是@click或@change或@keydown……等等。之後要拿來綁定eventListener的事件名會需要把前面的@拿掉。
const existingInvoker = invokers[event];
此時這個節點會分成三種情況:
我們需要將舊事件清除綁定,並重新監聽新的事件:
if (nextValue && existingInvoker) {
el.removeEventListener(event, existingInvoker);
el.addEventListener(event, invokers[event] = nextValue);
}
invokers所指向的el._vei也需要更新,讓invokers[event] = nextValue建立新的映射關係。
這種情況只需要綁定新事件,並在el._vei所指向的invokers中記錄這個事件即可。
else if (nextValue) {
el.addEventListener(event, invokers[event] = nextValue);
}
這種情況我們要將舊的事件清掉,而el._vei所指向的invokers也必須清除這個事件的映射關係。
else if (existingInvoker) {
el.removeEventListener(event, existingInvoker);
invokers[event] = undefined;
}
如此一來我們便可根據這三種不同的情況,去給節點新增/刪除監聽的事件,並且隨時記錄綁定事件的方法的地址。
然而,這樣的寫法是有優化空間的。
綁定事件與解除事件的綁定也是消耗效能的,我們可以試著在控制台用console.time()跟console.timeEnd()計算給一個dom元素綁定1億次事件監聽器所需的時間,當然這同樣也與設備性能相關,但根據我的測試,它的效能損失完全不亞於其他dom元素的操作,我們必須盡量避免增加或移除事件監聽器,才能達到效能的最優化。
而在上述「已有舊事件,也有新事件」的情境,其實我們大可不必先移除舊的事件監聽器再綁定新的事件監聽器,我們完全可以復用同一個地址的方法,只是改變這個方法的行為。因此我們在event.ts中再宣告一個方法:
interface Invoker extends EventListener {
value?: Function;
}
function createInvoker(callback: Function) {
const invoker: Invoker = (e: Event) => invoker.value?.(e);
invoker.value = callback;
return invoker;
}
這個createInvoker方法會返回一個Invoker函數,而Invoker函數是包含value屬性的函數,我們可以透過value屬性緩存任何事件綁定的方法,當Invoker函數被調用時,就再去調用這個value屬性緩存的方法,如此一來便能實現地址固定,但行為可變的函數。
我們再以這個Invoker函數為基礎,去改造一下patchEvent:
export const patchEvent = (el: any, vEvent: string, nextValue: Function | string) => {
if (typeof nextValue === 'string') nextValue = eval(nextValue);
const invokers = el._vei || (el._vei = {});
const event = vEvent.slice(1);
const existingInvoker = invokers[event];
if (nextValue && existingInvoker) {
existingInvoker.value = nextValue;
} else if (nextValue) {
el.addEventListener(event, invokers[event] = createInvoker(<Function>nextValue));
} else if (existingInvoker) {
el.removeEventListener(event, existingInvoker);
existingInvoker.value = undefined;
}
};
當綁定的事件改變時,僅需改變Invoker函數的value屬性,當Invoker函數被調用時,會再去調用value屬性所指向的新方法,如此一來便能在不改變地址的前提下改變事件所對應的行為,也就不需要重新addEventListener了。
最後我們再去patchProp.ts寫上patchEvent的實參,就大功告成啦~
else if (/^\@/.test(key)) patchEvent(el, key, nextValue);
githubmain分支commit「[Day 12] runtime-dom——封裝操作dom元素的方法 - 2」