Web Components 是為了方便 Web 應用中的視圖隔離和簡化內容分發流程而提出的一系列技術,目前包括:

  • Custom Elements API:用於定義瀏覽器可識別的自定義元素,擴展 HTML 行為;
  • Shadow DOM:用於隔離局部樣式,節點訪問以及事件傳播,構造獨立管理的視圖內容;
  • HTML Template:用於定義視圖模版,自身不進行渲染或產生副作用;

本文將基於現有的提案出發,簡單總結 Web Components 在可預測的未來中可能出現的形態,部分內容會涉及到 Web Components 之外的技術。

萬物即模塊

Web Components 的一個重要目的就是簡化內容分發的成本,早期的 Web Components 概念中曾經包含 HTML Imports 提案,用於建立 HTML 文件間的依賴關係。不過該提案已從 Web Components 概念中移除,目前 Safari 和 Edge 都並未實現過該特性,即便已經提供了實現的 Chrome 也在 73 版本中停止了對這一特性的支持。

雖然 HTML Imports 被認為是一個糟糕的方案,但是直接分發 HTML 內容仍然是 Web Components 實踐中的切實需求。

新的 HTML Modules 提案有效解決了這一問題,能夠直接將 HTML 文件*作為 ECMAScript Module 引入:

import { libDom, libHelper } from ./my-lib.html

* 決定是否為 HTML 文件的是 MIME type,並非文件後綴。

而在 HTML Module 文件類似於一個普通的局部 HTML:

<div id="blogPost">
<p> Some Amazing Content </p>
</div>
<script>
let blogPost = import.meta.document.querySelector("#blogPost")
export { blogPost }
</script>

當然也有一些細節需要注意:

  • HTML 自身被解析為 DocumentFragment,並作為 default export(可覆蓋);
  • 文件中所有的 Inline Script 都是 Module*(不論是否指定 type),而非 Script;
  • Inline Script 可通過 import.meta.document 訪問當前的 DocumentFragment;

* Module 和 Script 是 ES 中的稱呼方式,而在 HTML 中分別叫做 Module Script 和 Classic Script。

通過這些約束,可以很方便的定義一個 Web Component,例如:

<template id="myCustomElementTemplate">
<div class="myDiv">
<div class="devText">Here is some amazing text</div>
</div>
</template>

<script type="module">
let importDoc = import.meta.document

class myCustomElement extends HTMLElement {
constructor() {
super()
let shadowRoot = this.attachShadow({ mode: open })
let template = importDoc.getElementById(myCustomElementTemplate)
shadowRoot.appendChild(template.content.clone(true))
}
}

customElements.define(myCustomElement, myCustomElement)
</script>

由於直接作為 HTML 文件使用,因此不需要再通過字元串字面量的方式來內嵌模版內容,或者通過複雜的預處理步驟進行嵌入。

HTML Modules 提案極大地降低了對構建工具的需求,開發人員可以能夠便捷高效地發布源代碼,提升開發體驗。

與之類似的提案還包括 CSS Modules 以及 JSON Modules,相比於 HTML Modules 而言,兩者的處理內容要簡單得多,並且相應的語義早已在構建工具中廣泛使用。

除此之外*,另一個將能夠被用作 ES Module 使用的內容是 Web Assembly,相應的 PR 在:

  • Layering: Enable cyclic dependencies with non-STMR module types by linclark · Pull Request #1311 · tc39/ecma262?

    github.com
    圖標
    github.com/tc39/ecma262
  • Layering: Rename Module.Instantiate to Module.Link by linclark · Pull Request #1312 · tc39/ecma262

* 這部分內容與 Web Components 沒有直接關聯。

之後,我們將能夠直接在 JavaScript 中引入 Web Assembly 文件:

import { superFastAlgorithm } from ./lib.wasm

export function bridge(input) {
return superFastAlgorithm(input)
}

可以遇見,之後會有越來越多的內容能夠被作為 ES Module 使用,Web App 對構建工具的硬性需求也會逐漸減少。

Web 基礎庫

由於 Web 應用的需求逐漸複雜,一般的項目中很難做到全部功能都自行實現,往往需求引入第三方類庫來簡化開發過程。

從另一個方面來考慮,如果一些高級功能在大量應用中都會用到,那麼我們是否能將其作為 Web 規範,直接通過瀏覽器引入呢?或者說,定義 Web 中的 JavaScript 標準庫。

ES Module 為此提供了極大便利,由於 ES 中 Module Resolution 是未定義行為,在 Web App 中如何獲取模塊內容是由 HTML 規範定義的行為,因此提供內置模塊並不會破壞 JavaScript 的語義。

為了解決這一問題,Layered APIs 提案應用而生,為 Web App 提供了高層次的類庫模塊功能。其 API 基於 ES Module,使用方式類似於*:

import { storage } from @std/async-local-storage

* Async Local Storage 是基於 Layered APIs 之上的獨立提案,並不是 Layered APIs 本身的內容。

不過在 Web 中提供內置功能會遇到的一個常見問題是,由於 Web 規範會不斷演進,因此特定瀏覽器內可能不具備當前規範的全部功能,為此需要支持 Polyfill 的方案。

於是這裡涉及到了另一個 Import Maps 提案,允許對 import 的標識符進行路徑映射,提供若干個候選項,從而在內置模塊不可用時自動切換到 Polyfill 的實現:

<script type="importmap">
{
"imports": {
"@std/async-local-storage": [
"@std/async-local-storage",
"/node_modules/als-polyfill/index.mjs"
]
}
}
</script>

由於應用的範圍並不僅限於內置模塊,從而也可以用於第三方庫的映射*:

<script type="importmap">
{
"imports": {
"lodash": "/lodash.mjs",
"moment": "/moment.mjs"
}
}
</script>

* 需要注意現階段大部分類庫都並未提供 Web 兼容的 ES Module 發布版本。

而後就能直接在代碼中通過標識符的方式引入第三方依賴:

import moment from moment

console.log(moment())

該提案的本質是引入了新的 URL Scheme "import",自動應用於全體通過 ES Module Import 以及 ES Dynamic Import 導入的內容,因此上面的代碼產生的模塊 URL 為:

import:moment

所有該 Scheme 的 URL 均受到 Import Map 的控制,依次嘗試可能的路徑。

因為是基於 URL Scheme 的處理,所以也同樣能夠應用於資源文件:

<img src="import:my-avatar">

不過用於資源文件的意義並不是太大,資源文件大多都是本地內容,且很少需要重複引入,因此在不依賴構建工具的情況下依靠相對 URL 即可滿足大部分場景。

原生模版引擎

Template Tag 雖然設定為模版,但僅僅提供了 Parser 級別的特殊語義(其內容不作為 innerHTML 渲染到頁面),並未提供使用上的便利性。因此為了使用其內容,需要首先獲取其 DocumentFragment 內容,然後通過 document.importNode() 克隆節點(其它克隆方案也可行),最後通過 DOM API 手動替換節點中的已有內容,類似於:

// <template id="hello">
// Hello, <span class="name"></span>!
// </template>

const template = document.querySelector(#hello)
const instance = document.importNode(template.content, true)
const name = instance.querySelector(.name)
name.textContent = World

$element.appendChild(instance)

繁瑣的步驟極大地限制了 Template Tag 的使用,大部分情況下我們期望模版引擎能夠自動分析佔位符,並根據提供的 ViewModel 對象自動替換佔位符的內容,例如藉助於 Polymer 模版語法*,可以簡化為:

// <template>
// Hello, {{name}}!
// </template>

const item = { name: World }

* Polymer 中自身存在模版機制,普通的內容插值並不需要用到 <template>,通常為結合 dom-if、dom-repeat 等 Control Flow 元素使用。

為了真正發揮 Template Tag 的價值,需要能夠直接實例化模版並更新其內容,因此 Template Installation 提案被提出。該提案為 HTMLTemplateElement 增加了 createInstance() 方法,能夠自動複製節點並替換佔位符:

// <template id="hello">
// Hello, {{name}}!
// </template>

const template = document.querySelector(#hello)
const instance = template.createInstance({ name: World })
$element.appendChild(instance)

此外,也可能對 ViewModel 進行後續修改:

instance.update({ name: Web })

至此,我們以及具備了原生的數據綁定能力。甚至更進一步,還能夠對模版實例化的過程進行攔截。假設我們有以下模版:

<template>
Hello, {{uppercase(name)}}!
</template>

這裡我們執行了一個函數,並且將返回值綁定到內容中,至少我們看起來是這樣。但是,結果真的如此么?和一般模版引擎不同的是,Template Tag 並未自行定義一套模版語法(JavaScript 的子集 + 擴展),也不會將該內容作為全局 JavaScript 執行(失去了模版的意義),因此只會得到 vm[uppercase(name)]。大部分情況下這並非我們需要的結果。

為了能夠支持這類用例,可以通過指定 type 屬性(Attribute)為該模版定義攔截過程:

<template type="allow-func">
Hello, {{uppercase(name)}}!
</template>

並實現對應的 callback:

document.defineTemplateType(allow-func, {
processCallback: (instance, parts, state) => {
for (const part of parts) {
if (/* matches function call */) {
const [func, ...args] = extractFuncText(part.expression)
part.value = state[func](...args.map(arg => state[arg]))
}
}
}
})

由於是自定義的攔截過程,因此也可以選擇其它形式的語法,例如 Pipe:

<template type="allow-pipe">
Hello, {{name|uppercase}}!
</template>

基於這個攔截過程,還能夠進一步實現更強大的功能,例如循環內容。現在考慮以下模版:

<template type="with-for-each">
<ul>
{{foreach items}}
<li>{{label}}</li>
{{/foreach}}
</ul>
</template>

如果有後端模版經驗,我們可能會認為 foreach 是一個模版語法,用於定義循環內容。不過這裡並不是這樣,{{foreach items}}{{/foreach}} 都是普通的插值,其表達式內容分別為 "foreach items" "/foreach"。同樣的,得到 vm[foreach items]vm[/foreach] 並非我們期望的結果,不過通過對模版實例化過程的攔截,我們確確實實能夠模擬出模版語法的效果*。

* 實踐上來說,使用類 Polymer 的方案將 Control Flow 的內容作為子模版往往更易實現。

雖然這裡的預處理過程非常強大,實際應用中往往並不希望出現差異化的處理方式,因此可以將所有需要用到的 DSL 集中到同一個 TemplateTypeInit,或者提取為專門的類庫:

<template type="awesome-template">
<ul>
{{foreach items}}
<li>{{label}}</li>
{{/foreach}}
</ul>
<p>Hello, {{name|uppercase}}!</p>
</template>

這樣,就能僅通過 DOM API 以極小的成本達到模版引擎級別的能力。

寫在最後

當然,並不是所有願景都一定能夠在可預見的未來內實現,即便能夠實現,我們仍然需要優先處理當下的問題。

雖然 Web Components 現階段還不具備實用性,但是作為 Web 的原生特性,真正*具備著達到 0KB Runtime 的能力。隨著個別老舊瀏覽器的淘汰,並且現代瀏覽器具備快速更新的能力,未來的 Web App 必將更加偏向於 Evergreen Browser,從而充分發揮 Web 功能。

* 某自稱 0KB Runtime 的框架僅僅是把成本分擔到每個組件中,應用達到一定規模後的總體成本比 Shared Runtime 反而更高。


推薦閱讀:
相关文章