iT邦幫忙

2023 iThome 鐵人賽

DAY 12
0
Vue.js

淺談vue3源碼,很淺的那種系列 第 12

[Day 12] runtime-dom——封裝操作dom元素的方法 - 2

  • 分享至 

  • xImage
  •  

「故に遠くに遠くに耽美に無様に芽生えたら。」——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元素即是從空值變成有值。

attr.ts及class.ts

比對屬性及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.ts

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);

event.ts

事件的綁定是今天的進度中最困難的部分,但其實和過去我們學習過的依賴收集,以及之後要學的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];

此時這個節點會分成三種情況:

  1. 已有舊事件,也有新事件
  2. 沒有舊事件,只有新事件
  3. 只有舊事件,沒有新事件

1. 已有舊事件,也有新事件

我們需要將舊事件清除綁定,並重新監聽新的事件:

if (nextValue && existingInvoker) {
  el.removeEventListener(event, existingInvoker);
  el.addEventListener(event, invokers[event] = nextValue);
}

invokers所指向的el._vei也需要更新,讓invokers[event] = nextValue建立新的映射關係。

2. 沒有舊事件,只有新事件

這種情況只需要綁定新事件,並在el._vei所指向的invokers中記錄這個事件即可。

else if (nextValue) {
  el.addEventListener(event, invokers[event] = nextValue);
}

3. 只有舊事件,沒有新事件

這種情況我們要將舊的事件清掉,而el._vei所指向的invokers也必須清除這個事件的映射關係。

else if (existingInvoker) {
  el.removeEventListener(event, existingInvoker);
  invokers[event] = undefined;
}

如此一來我們便可根據這三種不同的情況,去給節點新增/刪除監聽的事件,並且隨時記錄綁定事件的方法的地址。
然而,這樣的寫法是有優化空間的。

優化event.ts

綁定事件與解除事件的綁定也是消耗效能的,我們可以試著在控制台用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」


上一篇
[Day 11] runtime-dom——封裝操作dom元素的方法 - 1
下一篇
[Day 13] runtime-core——render方法
系列文
淺談vue3源碼,很淺的那種31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言