內容來源於“Let’s learn how modern JavaScript frameworks work by building one”,我在本文中不會逐字翻譯,只會將關鍵部分列出。
React 是一個很棒的框架,但本文要實現的“現代 JavaScript 框架”是指“後 React 時代的框架”——即 Lit、Solid、Svelte、Vue 等,並且只討論客戶端渲染。
後 React 框架都集中在相同的基本思想上:
- 響應式更新 DOM。
- 使用克隆模板進行 DOM 渲染。
- 使用現代 Web API,例如 <template> 和 Proxy。
一、克隆 DOM 樹
長期以來,JavaScript 框架渲染 DOM 的最快方法是單獨創建和掛載每個 DOM 節點。
換句話說,可以使用 createElement()、setAttribute() 和 textContent 等 API 逐個構建 DOM:
const div = document.createElement('div') div.setAttribute('class', 'blue') div.textContent = 'Blue!'
一種替代方法是將一個大的 HTML 字符串塞入 innerHTML,然後讓瀏覽器爲您解析它:
const container = document.createElement('div') container.innerHTML = ` <div class="blue">Blue!</div> `
這種簡單的方法有一個很大的缺點:如果 HTML 中有任何動態內容,那麼將需要一遍又一遍地解析 HTML 字符串。
另外,每次更新都會破壞 DOM,這會重置狀態,例如文本框內的值。
注意,使用 innerHTML 也有安全隱患。但出於本文的目的,我們假設 HTML 內容是可信的。
不過,人們發現解析一次 HTML,然後在整個過程中調用 cloneNode(true) 會非常快:
const template = document.createElement('template') template.innerHTML = ` <div class="blue">Blue!</div> ` template.content.cloneNode(true) // this is fast!
<template> 標籤的優點是創建“惰性”DOM。換句話說,像 <img> 或 <video autoplay> 不會自動開始下載任何內容。
根據 Tachometer 的報告,與手動 DOM API 相比,克隆技術在 Chrome 中的運行速度大約快 50%,在 Firefox 中快 15%,在 Safari 中快 10%。
<template> 是一種新的瀏覽器 API,在 IE11 中不可用,最初是爲 Web 組件設計的,現在被用於各種 JavaScript 框架,無論它們是否使用 Web 組件。
這項技術有一個重大挑戰,那就是如何在不破壞 DOM 狀態的情況下高效更新動態內容,我們將在稍後討論這個問題。
二、現代 JavaScript API
我們已經遇到了一個非常有用的新 API,那就是 <template>。另一個正在穩步流行的 API 是 Proxy,它可以讓響應式系統的構建變得更加簡單。
當我們構建玩具示例時,我們也將使用標記模板字面量來創建這樣的 API:
const dom = html`
<div>Hello ${ name }!</div>
`
標記模板字面量可以使構建符合人體工程學的 HTML 模板 API 變得更加簡單,而無需編譯器。
1)創建響應式
響應式是我們構建框架其餘部分的基礎。響應式將定義如何管理狀態,以及狀態更改時 DOM 如何更新。
const state = {} state.a = 1 state.b = 2 createEffect(() => { state.sum = state.a + state.b })
我們想要一個名爲 state 的“神奇對象”,它有兩個 props:a 和 b。每當這些 props 發生變化時,我們都希望將 sum 設置爲兩者的總和。
假設我們事先不知道 props,一個普通的對象是不夠的。因此,讓我們使用 Proxy,它可以在設置新值時做出響應:
const state = new Proxy({}, { get(obj, prop) { onGet(prop) return obj[prop] }, set(obj, prop, value) { obj[prop] = value onSet(prop, value) return true } })
現在,Proxy 沒有做任何有趣的事情,除了給我們一些 onGet() 和 onSet() 鉤子。因此,我們讓它在微任務後刷新更新:
let queued = false function onSet(prop, value) { if (!queued) { queued = true queueMicrotask(() => { queued = false flush() }) } }
注意,queueMicrotask 是一個較新的 DOM API,與 Promise.resolve().then(...) 基本相同,但輸入量較少。
爲什麼要在 flush() 更新?主要是因爲不想運行太多計算。如果在 a 和 b 發生變化時進行更新,那麼將無用地計算兩次總和。
通過將刷新合併爲單個微任務,我們可以提高效率。
function flush() { state.sum = state.a + state.b }
這套代碼很棒,但我們還需要實現 createEffect(),以便僅當 a 和 b 更改時才計算總和(而不是當其他內容更改時)。
爲此,讓我們使用一個對象來跟蹤哪些 props 需要得到哪些副作用(effect),副作用會直接或間接影響其他函數的執行。
const propsToEffects = {}
接下來就是關鍵部分了!需要確保副作用可以訂閱正確的 props。爲此,需要記下它進行的任何 get 調用,並在 prop 和副作用之間創建映射。
createEffect(() => { state.sum = state.a + state.b })
當該函數運行時,它會調用兩個 getter:state.a 和 state.b。這些 getter 應該觸發響應系統以注意到該函數依賴於這兩個 props。
爲了實現這一點,我們將從一個簡單的全局變量開始來跟蹤當前副作用是什麼:
let currentEffect
然後,createEffect() 函數將在調用該回調之前設置此全局值:
function createEffect(effect) { currentEffect = effect effect() currentEffect = undefined }
現在,我們可以在代理中實現 onGet,它將設置全局 currentEffect 和屬性之間的映射:
function onGet(prop) { const effects = propsToEffects[prop] ?? (propsToEffects[prop] = []) effects.push(currentEffect) }
運行一次後,propsToEffects 應該如下所示:
{ "a": [theEffect], "b": [theEffect] }
其中 theEffect 是我們要運行的“sum”函數。
接下來,我們的 onSet 應該將需要運行的任何副作用添加到 dirtyEffects 數組中:
const dirtyEffects = [] function onSet(prop, value) { if (propsToEffects[prop]) { dirtyEffects.push(...propsToEffects[prop]) // ... } }
至此,我們已經準備好用於flush 調用所有dirtyEffects 的所有部分:
function flush() { while (dirtyEffects.length) { dirtyEffects.shift()() } }
將所有這些放在一起,現在擁有一個功能齊全的響應式系統。
查看在線示例,可以自己嘗試一下,只要 state.a 和 state.b 其中之一發生更改,state.sum 就會更新。
2)DOM 渲染
上述響應式系統可以跟蹤變化並計算副作用,但僅此而已,JavaScript 框架還需要將一些 DOM 渲染到屏幕上。
在本節中,讓我們暫時忘記響應式,假設我們只是嘗試構建一個函數,該函數可以構建 DOM 樹和有效地更新它。
再次,讓我們從一段夢想代碼開始:
function render(state) { return html` <div class="${state.color}">${state.text}</div> ` }
重新使用之前的狀態對象,這次帶有顏色和文本屬性。
state.color = 'blue'
state.text = 'Blue!'
當我們將此狀態對象傳遞給渲染函數時,它應該返回應用了狀態的 DOM 樹:
<div class="blue">Blue!</div>
不過,在繼續之前,需要快速瞭解標記模板字面量。
html 標籤只是一個接收兩個參數的函數:tokens(靜態 HTML 字符串數組)和 expressions(計算的動態表達式)。
function html(tokens, ...expressions) { }
在這種情況下,tokens 是(刪除空格):
[ "<div class=\"", "\">", "</div>" ]
expressions 是:
[ "blue", "Blue!" ]
tokens 數組總是比 expressions 數組長 1,因此我們可以輕鬆地將它們壓縮在一起:
const allTokens = tokens.map((token, i) => (expressions[i - 1] ?? '') + token)
這將爲我們提供一個字符串數組:
const htmlString = allTokens.join('')
然後我們可以使用 innerHTML 將其解析爲 <template>:
function parseTemplate(htmlString) { const template = document.createElement('template') template.innerHTML = htmlString return template }
該模板包含我們的惰性 DOM(技術上是 DocumentFragment),我們可以隨意克隆它:
const cloned = template.content.cloneNode(true)
當然,每當調用 html 函數時就解析完整的 HTML 對於性能來說並不是很好。幸運的是,標記模板字面量有一個內置功能,可以在這裏提供很大幫助。
對於標記模板字面量的每一個獨特用法,每當調用函數時,tokens 數組總是相同的 - 事實上,它是完全相同的對象!
例如,考慮這種情況:
function sayHello(name) { return html`<div>Hello ${name}</div>` }
每當調用 sayHello() 時,tokens 數組將始終相同:
[ "<div>Hello ", "</div>" ]
只有當標記模板字面量的位置完全不同時,tokens 纔會不同:
html`<div></div>` html`<span></span>` // Different from above
我們可以通過使用 WeakMap 來保留 tokens 數組到結果模板的映射來利用這一點:
const tokensToTemplate = new WeakMap() function html(tokens, ...expressions) { let template = tokensToTemplate.get(tokens) if (!template) { // ... template = parseTemplate(htmlString) tokensToTemplate.set(tokens, template) } return template }
這是一個令人興奮的概念,tokens 數組的唯一性本質上意味着我們可以確保每次調用 html`...` 只解析 HTML 一次。
接下來,我們只需要一種方法來使用 expressions 數組更新克隆的 DOM 節點(與 tokens 不同,其每次都可能不同)。
爲了簡單起見,我們只需將 expressions 數組替換爲每個索引的佔位符:
const stubs = expressions.map((_, i) => `__stub-${i}__`)
如果我們像以前一樣將其壓縮,它將創建以下 HTML:
<div class="__stub-0__"> __stub-1__ </div>
我們可以編寫一個簡單的字符串替換函數來替換 stubs:
function replaceStubs (string) { return string.replaceAll(/__stub-(\d+)__/g, (_, i) => ( expressions[i] )) }
現在,每當調用 html 函數時,我們都可以克隆模板並更新佔位符:
const element = cloned.firstElementChild for (const { name, value } of element.attributes) { element.setAttribute(name, replaceStubs(value)) } element.textContent = replaceStubs(element.textContent)
注意,我們使用 firstElementChild 來獲取模板中的第一個頂級元素。對於我們的玩具框架,我們假設只有一個。
我們可以通過不同狀態的渲染來測試它,查看在線示例。
document.body.appendChild(render({ color: 'blue', text: 'Blue!' }))
document.body.appendChild(render({ color: 'red', text: 'Red!' }))
3)結合響應式和 DOM 渲染
由於我們已經從上面的渲染系統中獲得了 createEffect(),因此我們現在可以將兩者結合起來根據狀態更新 DOM:
const container = document.getElementById('container') createEffect(() => { const dom = render(state) if (container.firstElementChild) { container.firstElementChild.replaceWith(dom) } else { container.appendChild(dom) } })
可以將其與響應式部分中的“sum”示例結合起來,只需創建另一個副作用來設置文本:
createEffect(() => { state.text = `Sum is: ${state.sum}` })
渲染結果是“Sum is 3”,查看在線示例,若設置 state.a = 5,則文本將自動更新爲“Sum is 7”。
三、下一步
我們可以對該系統進行很多改進,尤其是 DOM 渲染位。
最值得注意的是,我們缺少一種更新深層 DOM 樹內元素內容的方法,例如:
<div class="${color}"> <span>${text}</span> </div>
爲此,我們需要一種方法來唯一標識模板內的每個元素。有很多方法可以做到這一點:
- Lit 在解析 HTML 時,使用正則表達式和字符匹配系統來確定佔位符是否在屬性或文本內容內,以及目標元素的索引(按深度優先的 TreeWalker 順序)。
- 像 Svelte 和 Solid 這樣的框架可以在編譯期間解析整個 HTML 模板,從而提供相同的信息。 它們還生成調用 firstChild 和 nextSibling 的代碼來遍歷 DOM 以查找要更新的元素。
注意,使用 firstChild 和 nextSibling 進行遍歷與 TreeWalker 方法類似,但比 element.children 更高效。 這是因爲瀏覽器在底層使用鏈表來表示 DOM。
無論我們決定進行 Lit 風格的客戶端解析還是 Svelte/Solid 風格的編譯時解析,我們想要的是這樣的某種映射:
[ { elementIndex: 0, // <div> above attributeName: 'class', stubIndex: 0 // index in expressions array }, { elementIndex: 1 // <span> above textContent: true, stubIndex: 1 // index in expressions array } ]
這些綁定將準確地告訴我們哪些元素需要更新,哪些屬性(或 textContent)需要設置,以及在哪裏可以找到替換 stub 的 expression。
下一步是避免每次都克隆模板,而是直接根據 expressions 更新 DOM。 換句話說,我們只想解析一次,即只想克隆和設置綁定一次。
這會將後續更新減少到最少的 setAttribute() 和 textContent 調用。
另一個有趣的實現模式是迭代(或重複器),它有自己的一系列挑戰,例如協調更新之間的列表和處理“鍵”以實現高效替換。