博客 / 詳情

返回

現代 JavaScript 框架工作原理你瞭解多少?

讓我們通過構建一個現代 JavaScript 框架來了解其工作原理。

我的日常工作是開發 JavaScript 框架 (LWC)。雖然我已經在這個框架上工作了近三年,但我仍然覺得自己是個門外漢。當我閲讀大型框架領域的最新動態時,我常常會被自己不知道的事情壓得喘不過氣來。

不過,瞭解某些東西如何工作的最好方法之一就是自己動手創建。另外,我們還得讓那些 “days since last JavaScript framework” 的話題繼續下去。所以,讓我們編寫自己的現代 JavaSctipt 框架吧!

什麼是“現代 JavaScript 框架”?

React 是一個很棒的框架,我不是來這裏討論它的。但就本文而言,“現代 JavaScript 框架”指的是“後 React 時代的框架”,即 Lit、Solid、 Svelte、Vue 等。

React 長期以來一直主導着前端領域,以至於每個新框架都在它的影響下成長。這些框架都深受 React 的啓發,但它們以驚人相似的方式從 React 演變而來。儘管 React 本身在不斷創新,但我發現後 React 框架彼此之間的相似度比現在的 React 更相似。

簡單起見,我不打算談論 Astro、Marko 和 Qwik 等服務器優先的框架。這些框架各有千秋,但與客户端框架相比,它們的思想傳統略有不同。因此,在這篇文章中,我們只討論客户端渲染。

是什麼讓現代框架與眾不同?

在我看來,“後 React 框架”都趨向於相同的基本理念:

  1. 使用響應式(如 signals)進行 DOM 更新。
  2. 使用克隆模版進行 DOM 渲染。
  3. 使用現代 Web API(如 <template>Proxy),使上述所有操作變得更容易。

需要明確的是,這些框架在微觀層面以及它們如何處理 Web 組件、編譯和麪向用户的 API 等方面存在很大差異。甚至並非所有框架都使用 Proxy。但從廣義上講,大多數框架的作者似乎都同意上述觀點,或者他們在朝着這個方向努力。

因此,對於我們自己的框架來説,讓我們從響應式入手,盡力實現這些思想理念。

響應式(Reactivity)

人們常説 “React 不是響應式的”。這句話的意思是,React 採用的是 pull-based 的模式,而不是 push-based 的模式。簡單地説就是 React 假定您的整個虛擬 DOM 樹都需要從頭開始重建,而防止這些更新的唯一方法就是實現 useMemo(或者以前的 shouldComponentUpdate)。

使用虛擬 DOM 可以減輕“一切從零開始”策略的一些成本,但並不能完全解決問題。要求開發人員編寫正確的備忘錄代碼是一場失敗的戰鬥(有關解決此問題的嘗試,請參閲 React Forget)。

相反,現代框架使用的是 push-based 的響應模型。在這種模型中,組件樹的各個部分都會訂閲狀態更新,只有在相關狀態發生變化時才會更新 DOM。這優先考慮了“默認情況下的高性能”設計,以換取一些前期記錄成本(尤其是在內存方面)來跟蹤哪些狀態與 UI 的部分相關聯。

請注意,這種技術並不一定與虛擬 DOM 方法不兼容:Preact Signals 和 Million 等工具都表明,您可以使用混合系統。如果您的目標是保留現有的虛擬 DOM 框架(如 React),但在對性能更為敏感的場景中選擇性地應用基於 push-based 的模型,那麼這種方法就非常有用。

在這篇文章中,我不會重述 signals 本身的細節,也不會討論細粒度響應式等更微妙的話題,但我會假設我們將使用響應式系統。

注意:在談論什麼是“響應式”時,有很多細微差別。我的目標是將 React 與後 React 框架進行對比,特別是 Solid、“runes” 模式下的 Svelte v5 和 Vue Vapor。

克隆 DOM 樹

長期以來,JavaScript 框架的集體智慧都認為,渲染 DOM 的最快方法是單獨創建和加載每個 DOM 節點。換句話説,您可以使用 createElementsetAttributetextContent 等 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,這會重置狀態,例如 <input> 的值等。

注意:使用 innerHTML 也會涉及安全問題,但在本文中,我們假設 HTML 內容是可信的。

不過,在某些時候,人們發現解析一次 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> 這樣的東西不會自動開始下載任何東西。

與手動 DOM API 相比,速度有多快?下面是一個小型基準測試。根據 Tachometer 的報告,克隆技術在 Chrome 瀏覽器中的運行速度大約快 50%,在 Firefox 瀏覽器中快 15%,在 Safari 瀏覽器中快 10%(這將根據 DOM 大小和迭代次數的不同而有所變化,但你可以大致有個瞭解)。

有趣的是,<template> 是一種新的瀏覽器 API,在 IE11 中不可用,最初是為 Web 組件設計的。有點諷刺的是,這種技術現在被用於各種 JavaScript 框架,無論它們是否使用 Web 組件。

注意:以下是 Solid、Vue Vapor 和 Svelte v5 中的 <template> 上使用 cloneNode 的用法,以供參考。

這種技術有一個主要的挑戰,那就是如何在不破壞 DOM 狀態的情況下高效更新動態內容。我們稍後將在構建玩具框架時介紹這一點。

現代 JavaScript API

我們已經遇到了一個能提供很大幫助的新 API,那就是 <template>。另一個正在穩步流行的 API 是 Proxy,它可以讓響應式系統的構建變得更加簡單。

當我們構建玩具示例時,我們也將使用標記模版字面量(Tagged Template Literals),簡單來説它可以讓我們用另一種方式進行函數調用,來創建這樣的 API:

const dom = html`<div>Hello ${name}!</div>`

並非所有框架都使用這一方式,但值得關注的包括 Lit、HyperHTML 和 ArrowJS。標記模版字面量可以使構建符合人體工程學的 HTML 模板 API 變得更加簡單,而無需編譯器。

步驟一:構建響應式

響應式是我們構建框架其餘部分的基礎。響應式將定義如何管理狀態,以及狀態發生變化時 DOM 如何更新。

讓我們從一些"夢想代碼"開始,來説明我們想要什麼:

const state = {}

state.a = 1
state.b = 2
createEffect(() => {
  state.sum = state.a + state.b
})

基本上,我們需要一個名為 state 的 "神奇對象",它有兩個道具(props):ab

假設我們事先不知道 props(或編譯器無法確定 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
  }
})

現在,除了給我們提供一些 onGetonSet 鈎子外,我們的 Proxy 並沒有做任何有趣的事情。因此,我們要讓它在微任務之後刷新更新:

let queued = false
function onSet(prop, value) {
  if(!queued) {
    queued = true
    queueMicrotask(() => {
      queued = false
      flush()
    })
  }
}
注意:如果您不熟悉 queueMicrotask,它是一種較新的 DOM API,與 Promise.resolve().then(…) 基本相同,但輸入量較少。

為什麼要刷新更新?主要是因為我們不想進行過多的計算。如果每當 ab 都發生變化時就更新,那麼我們就會無用地計算兩次總和。通過將刷新合併到一個微任務中,我們可以提高效率。

接下來,讓 flush 更新總和:

function flush() {
  state.sum = state.a + state.b
}

這很好,但還不是我們的 "夢想代碼"。我們需要實現 createEffect,以便僅當 ab 發生變化時(而不是當別的東西發生變化時!)才會計算總和。

為此,我們需要一個對象來跟蹤哪些 props 需要運行哪些效果(effects):

const propsToEffects = {}

接下來就是關鍵的部分了!我們需要確保我們的 effects 可以訂閲正確的 props。為此,我們將運行 effect,記錄下它進行的任何 get 調用,並在 prop 和 effect 之間創建映射。

請記住,我們的 "夢想代碼 "是:

createEffect(() => {
  state.sum = state.a + state.b
})

當該函數運行時,它會調用兩個 getter :state.astate.b。這些 getter 會觸發響應式系統,使其注意到該函數依賴於兩個 props。

為了實現這一點,我們將從一個簡單的全局開始,以跟蹤 "當前" effect 是什麼:

let currentEffect

然後,createEffect 會在調用該函數前設置這個全局變量:

function createEffect(effect) {
  currentEffect = effect
  effect()
    currentEffect = undefined
}

這裏最重要的一點是,effect 會被立即調用,並提前設置全局 currentEffect 。這樣我們就可以跟蹤 effect 可能調用的任何 getter。

現在,我們可以在代理 Proxy 中實現 onGet,它將設置全局 currentEffect 和屬性之間的映射:

function onGet(prop) {
  const effects = propsToEffects[prop] ?? (propsToEffects[prop] = [])
    effects.push(currentEffect)
}

運行一次後,propsToEffects 應該是這樣的:

{
  "a": [theEffect],
  "b": [theEffect] 
}

其中,theffect 是我們要運行的 "求和 "函數。

接下來,我們的 onSet 將把需要運行的 effects 添加到 dirtyEffects 數組中:

const dirtyEffects = []
function onSet(prop, value) {
  if(propsToEffects[prop]) {
    dirtyEffects.push(...propsToEffects[prop])
    // ...
  }
}

至此,我們已經準備就緒,以便 flush 可以調用所有 dirtyEffects

function flush() {
  while(dirtyEffects.length) {
    dirtyEffects.shift()()
  }
}

將這一切整合在一起,我們現在就有了一個功能齊全的響應系統!你可以嘗試在 DevTools 控制枱中設置 state.astate.b,只要其中一個發生變化,state.sum 就會更新。

const propsToEffcts = {}
const dirtyEffects = []
let queued = false

const state = new Proxy({}, {
  get(obj, prop) {
    onGet(prop)
    return obj[prop]
  },
  set(obj, prop, value) {
    obj[prop] = value
    onSet(prop, value)
    return true
  }
})

function onGet(prop) {
  if(currentEffect) {
    const effects = propsToEffects[prop] ?? (propsToEffects[prop] = [])
    effects.push(currentEffect)
  }
}

function flush() {
  while(dirtyEffects.length) {
    dirtyEffects.shift()()
  }
}

function onSet(prop, value) {
  if(propsToEffects[prop]) {
    dirtyEffects.push(...propsToEffects[prop])
    if(!queued) {
      queued = true
      queueMicrotask(() => {
        queued = false
        flush()
      })
    }
  }
}

function createEffect(effect) {
  currentEffect = effect
  effect()
  currentEffect = undefined
}

// Initial state
state.a = 1
state.b = 2
createEffect(() => {
  state.sum = state.a + state.b
})

console.log({...state})
console.log('Setting a to', 5)
state.a = 5

Promise.resolve().then(() => {
  console.log({...state})
})

現在,還有很多高級案例,我們在這裏就不一一介紹了:

  1. 在 effect 出錯時使用try/catch
  2. 避免重複運行同一 effect
  3. 防止無限循環
  4. 在後續運行中向新 props 訂閲 efftcts(例如,如果某些 getter 只在 if 代碼塊中調用)

不過,這些對於我們的玩具示例來説已經足夠了,讓我們繼續進行 DOM 渲染。

步驟二:DOM 渲染

我們現在有了一個功能性響應系統,但它本質上是 "無頭 "的。它可以跟蹤變化並計算 effects,但僅此而已。

不過,在某些時候,我們的 JavaScript 框架需要將一些 DOM 實際呈現到屏幕上。(這也是關鍵所在)。

在本節中,讓我們暫時忘掉響應性,想象一下我們只是在嘗試構建一個函數,它可以:1)構建 DOM 樹;2)高效地更新 DOM 樹。

再次,讓我們從一些“夢想代碼”開始:

function render(state) {
  return html`<div class="${state.color}">${state.text}</div>`
}

正如我所提到的,我使用標記模版字面量(ala Lit),因為我發現這是一種無需編譯器就能編寫 HTML 模板的好方法。(稍後我們將看到為什麼我們需要編譯器)。

我們將重複使用之前的 state 對象,這次將使用 colortext 屬性。狀態可能如下:

state.color = 'blue'
state.text = 'Blue!'

當我們將該 state 傳遞給 render 時,它應該返回應用了該狀態的 DOM 樹:

<div class="blue">Blue!</div>

不過,在進一步瞭解之前,我們需要對標記模版字面量進行一個簡單的入門。我們的 html 標籤只是一個函數,它接收兩個參數:tokens(靜態 HTML 字符串數組)和 expressions(計算的動態表達式):

function html(tokens, ...expressions) {
  console.log(tokens)
  console.log(expressions)
}

在這種情況下,tokens 為(去掉空格):

[
  "<div class=\"",
  "\">",
  "</div>"
]

expressions 是:

[
  "blue",
  "Blue!"
]

這個 tokens 數組總是比 expressions 數組長 1,因此我們可以將它們壓縮在一起:

const allTokens = tokens.map((token, i) => (expressions[i - 1] ?? '') + token)

我們將得到一個字符串數組:

[
  "<div class=\"",
  "blue",
  "\">",
  "Blue!",
  "</div>"
]

我們可以將這些字符串連接起來,組成我們的 HTML:

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 數組與生成 template 的映射來充分利用這一點:

const tokensToTemplate = new WeakMap()

function html(tokens, ...expressions) {
  let template = tokensToTemplate.get(tokens)
  if(!template) {
    // ...
    template = parseTemplate(htmlString)
    tokensToTemplate.set(tokens, tempalte)
    return template
  }
}

這個概念有點令人費解,但 tokens 數組的唯一性本質上意味着我們可以確保每次調用 html 函數時,只解析一次 HTML。

接下來,我們只需要一種方法,用 expressions 數組更新克隆的 DOM 節點( 每次都可能不同,這與 tokens 不一樣)。

為了簡單起見,我們只需將 expressions 數組替換為每個索引的佔位符即可:

const stubs = expressions.map((_, i) => `__stub-${i}__`)

如果我們像之前一樣把這個壓縮起來,就會生成這樣的 HTML:

<div class="__stub-0__">__stub-1__</div>

我們可以編寫一個簡單的字符串替換函數來替換存根:

function replaceStubs(string) {
  return string.replaceAll(/__stub-(\d+)__/g, (_, i) => {
    expressions[i]
  })
}

現在,只要調用 html 函數,我們就可以克隆模板並更新佔位符:

const element = cloned.firstElementChild
for(const { name, value } of element.attributes) {
  elements.setAttribute(name, replaceStubs(value))
}
element.textContent = replaceStubs(element.textContent)
注意:我們使用 firstElementChild 來抓取模板中的第一個頂級元素。對於我們的玩具框架,我們假設只有一個。

現在看來,這樣做的效率仍然不高——尤其是,我們正在更新不一定需要更新的 textContent 和屬性。但對於我們的玩具框架來説,這已經足夠好了。

我們可以通過不同 state 的渲染來測試它:

document.body.appendChild(render({ color: 'blue', text: 'Blue!' }))
document.body.appendChild(render({ color: 'red', text: 'Red!' }))

這招管用!

const tokensToTemplate = new WeakMap()

function parseTemplate(htmlString) {
  const template = document.createElement('template')
  template.innerHTML = htmlString
  return template
}

function html(tokens, ...expressions) {
  const replaceStubs = (string) => (
    string.replaceAll(/__stub-(\d+)__/g, (_, i) => (
      expressions[i]
    ))
  )
  // get or create the template
  let template = tokensToTemplate.get(tokens)
  if (!template) {
    const stubs = expressions.map((_, i) => `__stub-${i}__`)
    const allTokens = tokens.map((token, i) => (stubs[i - 1] ?? '') + token)
    const htmlString = allTokens.join('')
    template = parseTemplate(htmlString)
    tokensToTemplate.set(tokens, template)
  }
  // clone and update bindings
  const cloned = template.content.cloneNode(true)
  const element = cloned.firstElementChild
  for (const { name, value } of element.attributes) {
    element.setAttribute(name, replaceStubs(value))
  }
  element.textContent = replaceStubs(element.textContent)
  return element
}

function render(state) {
  return html`
    <div class="${state.color}">${state.text}</div>
  `
}

// Let's test it out!
document.body.appendChild(render({ color: 'blue', text: 'Blue!' }))

// And again!
document.body.appendChild(render({ color: 'red', text: 'Red!' }))

image-20231204154727467.png

步驟三:將響應式與 DOM 渲染相結合

由於我們已經有了上面渲染系統中的 createEffect,現在可以將兩者結合起來,根據狀態更新 DOM:

const container = document.getElementById('container')

createEffect(() => {
  const dom = render(state)
  if(container.firstElementChild) {
    container.firstElementChild.replaceWith(dom)
  } else {
    container.appendChild(dom)
  }
})

這實際上是可行的!我們可以將其與響應式部分的"求和"示例結合起來,只需創建另一個 effect 來設置 text 即可:

createEffect(() => {
  state.text = `Sum is: ${state.sum}`
})

這將渲染 "Sum is 3":

image-20231204155344921.png

你可以試試這個玩具示例,如果設置 state.a = 5,文本就會自動更新為 "Sum is 7"。

下一步工作

我們可以對這個系統進行很多改進,尤其是 DOM 渲染部分。

最值得注意的是,我們缺少一種方法來更新深層 DOM 樹中元素的內容,例如:

<div class="${color}">
    <span>${text}</span>
</div>

為此,我們需要一種方法來唯一標識模板內的每個元素。有很多方法可以做到這一點:

  1. Lit 在解析 HTML 時,會使用正則表達式和字符匹配系統來確定佔位符是否位於屬性或文本內容中,以及目標元素的索引(按 TreeWalker 深度優先順序)。
  2. Svelte 和 Solid 等框架可以在編譯過程中解析整個 HTML 模板,從而提供相同的信息。它們還會生成調用 firstChildnextSibling 的代碼,以遍歷 DOM 找到要更新的元素。
注意:使用 firstChildnextSibling 遍歷與 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)需要設置,以及在哪裏找到 expressions 來替換存根。

下一步是避免每次都克隆模板,而是直接根據 expressions 更新 DOM。

換句話説,我們不僅希望解析一次,還希望只克隆和設置綁定一次。這將把每次後續更新減少到最少的 setAttributetextContent 調用。

注意:您可能會問,如果我們最終還是需要調用 setAttributetextContent,那麼克隆模板有什麼意義呢?答案是,大多數 HTML 模板基本上都是靜態內容,只有少數幾個動態 "漏洞"。通過使用模板克隆,我們可以克隆 DOM 的絕大部分內容,同時只為 "漏洞"做額外的工作。這就是該系統運行良好的關鍵所在。

另一種有趣的實現模式是迭代(或中繼器),它也有自己的挑戰,比如在更新之間協調列表,以及處理 "鍵"以實現高效替換。

不過我累了,這篇博文也寫得夠長了。所以我把剩下的內容留給讀者練習!

結論

就是這樣。在一篇(冗長的)博文中,我們實現了自己的 JavaScript 框架。您可以將此作為您全新 JavaScript 框架的基礎,向全世界發佈,讓 Hacker News 的讀者們大開眼界。

我個人覺得這個項目很有教育意義,這也是我最初做這個項目的部分原因。我還想用一個更小、更定製化的解決方案來替換 my emoji picker component 的現有框架。在這個過程中,我成功地編寫了一個很小的框架,它通過了所有現有的測試,而且比當前的實現小 6kB,我為此感到非常自豪。

未來,我認為如果瀏覽器 API 的功能足夠齊全,那麼構建自定義框架就會變得更加容易。例如,DOM Part API 提案將省去我們上面構建的 DOM 解析和替換系統的大量繁瑣工作,同時也為潛在的瀏覽器性能優化打開了大門。我還可以想象(胡亂比劃一番),Proxy 的擴展可以讓我們更輕鬆地構建一個完整的響應式系統,而無需擔心刷新、批處理或週期檢測等細節問題。

如果所有這些都到位了,那麼你就可以想象自己實際上擁有了一個 "瀏覽器中的 Lit",或者至少是一種快速構建自己的 "瀏覽器中的 Lit "的方法。同時,我希望這個小練習有助於説明框架作者所考慮的一些事情,以及你最喜歡的 JavaScript 框架背後的一些機制。

感謝 Pierre-Marie Dartus 對本文草稿的反饋意見。

原文參考:https://nolanlawson.com/2023/12/02/lets-learn-how-modern-java...

user avatar guizimo 頭像 jidongdehai_co4lxh 頭像 coderleo 頭像 susouth 頭像 columsys 頭像 weirdo_5f6c401c6cc86 頭像 ailim 頭像 pugongyingxiangyanghua 頭像 light_5cfbb652e97ce 頭像 pangsir8983 頭像 dashnowords 頭像 lidalei 頭像
40 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.