紀錄工作經驗、相關知識,解決技術相關問題。

HTML, Javascript, 新手 Jamp Holes, 網站優化, 網站相關

HTML 模組化載入機制演進:Import Attributes 語法更新與動態載入實作

HTML 模組化載入機制演進:Import Attributes 語法更新與動態載入實作 特色圖片

本文將詳細解析 HTML 模組化 的核心規範、底層運作機制與實務配置要點,協助開發者在專案中快速排查常見問題並落實標準寫法。

在先前的系列文章中,我們已經探討過瀏覽器資源排程與首屏效能的基礎機制:包含在 Core Web Vitals LCP 最大內容繪製優化實作:Sub-parts 四階段拆解、fetchpriority 與首屏載入調校 中剖析的 首屏渲染阻塞延遲(Element Render Delay),以及在 HTML Resource Hints 資源預載入:preload、preconnect、prefetch 與 dns-prefetch 排程機制 中介紹過的 ES Module Map 與 rel="modulepreload" 預編譯管線。

本篇將聚焦於原生網頁元件載入機制的演進:從早期被廢棄的 HTML Imports(<link rel="import">)到現代 HTML Modules 提案與現行標準替代方案——解析為什麼舊版 HTML Imports 會阻塞主執行緒並與 ES Modules 產生依賴衝突,同時提供 WHATWG 宣告式 Shadow DOM(<template shadowrootmode="open">)、TC39 ES2025 Import Attributes(with { type }),以及結合 fetch 原生子資源完整性(SRI integrity)與 DOMParser 快取池的完整解決思路。


常見問題(Q&A)與程式碼範本快速跳轉

如果目前正在將老舊專案中的 <link rel="import"> 遷移至現代標準、排查動態載入 HTML 片段造成的樣式閃爍(FOUC)與 SEO 檢索缺失,或想確認 import ... assert 為何在最新瀏覽器報錯,可以直接點擊下方連結跳轉至對應解答與程式碼範本:

如果不趕時間,建議順著下方章節往下讀,接下來會逐一講解相關底層運作原理與實作細節。


一、 為什麼早期 HTML Imports 被瀏覽器廢棄?剖析三大架構瓶頸

1. 早期 link rel="import" 的運作方式與三大致命瓶頸

在 2013 年 Web Components v0 草案時期,Google 曾提出 HTML Imports(<link rel="import" href="card.html">),企圖讓開發者像引入外部樣式表一樣,在 <head> 中直接引入包含 <template>、<style> 與 <script> 的獨立 HTML 子文件:

<!-- 早期已廢棄的 Web Components v0 寫法(Chrome 80 已徹底移除支援) -->
<head>
  <link rel="import" href="/components/user-card.html">
</head>
<body>
  <script>
    // 陷阱:依賴全域 link.import 屬性存取子文件 DOM,且阻塞後方腳本執行
    const importDoc = document.querySelector('link[rel="import"]').import;
  </script>
</body>

這項看似直覺的設計,在實際大規模應用與標準化審查中暴露出三個嚴重的架構缺陷,Mozilla 明確表態不實作、Safari 也從未支援,最終於 Chrome 80 正式從 Blink 引擎移除,W3C 也已將 HTML Imports 草案列為 Discontinued Draft:

1. 獨立子文件解析與內部同步腳本阻塞主文件渲染:當主頁面解析器遇到 <link rel="import"> 時,瀏覽器必須為每個被引入的 .html 建立獨立的 Document 實例,並依序下載、解析且同步執行子文件內部的 <script>,同時還會阻塞主文件中後續的腳本與樣式表評估。若元件之間存在多層巢狀引入,就會形成嚴重的網路瀑布流(Network Waterfall),大幅拖慢首屏 LCP 最大內容繪製時間。

HTML Imports 建立獨立子文件並阻塞主文件渲染流程圖

圖:舊版 link rel="import" 會建立獨立的子 Document 並執行內部同步腳本,導致主文件的渲染管線被迫排隊等待

2. 脫離 ES Modules 模組依賴圖(Module Graph),引發重複載入與全域汙染:在 ES6(ES2015)正式確立 import / export 作為 JavaScript 唯一官方模組標準後,HTML Imports 卻維持著自己獨立的「HTML 網址去重池」,無法與瀏覽器的 ES Module Map 互通。這導致在 HTML Import 內部載入的腳本預設運行於全域作用域(Global Scope),不僅容易汙染 window 命名空間,當同一個工具函式同時被 HTML Import 與 <script type="module"> 引用時,也無法透過單一模組圖進行依賴去重與打包樹搖(Tree-shaking)。

HTML Imports 與 ES Modules 雙軌模組系統衝突示意圖

圖:HTML Imports 與 ES Modules 各自維護互不相通的載入管線,造成全域命名空間汙染與模組重複下載

3. 搜尋引擎爬蟲不檢索且純客戶端替換易引發 FOUC 與 CLS:Googlebot 以常青版 Chromium 算繪頁面,而 Chromium 自 Chrome 80 起已不認得 <link rel="import">,透過它引入的外部文件內容自然不會出現在算繪結果中;而當開發者退而求其次,改在頁面載入後以未經封裝的 fetch() 將 HTML 與 <style> 塞入首屏空白容器時,不僅關鍵文案無法在第一時間被索引,外部樣式晚於結構抵達更會造成明顯的無樣式內容閃爍(FOUC)與版面位移(CLS)。

廢棄 rel=import 與純客戶端延遲載入引發 SEO 缺失與 FOUC 閃爍流程圖

圖:將首屏核心內容放在廢棄的 rel="import" 或未預先渲染的客戶端請求中,會導致爬蟲第一波檢索落空並引發畫面閃爍


2. 從 HTML Imports 到 HTML Modules 與現行標準方案對照表

為了徹底解決上述三大瓶頸,W3C 與 WHATWG 將「原生 HTML 模組化」拆解並整合進現有的標準體系中。下表整理了四種技術方案的標準狀態與適用場景:

技術方案現行標準狀態模組依賴與執行作用域實務適用場景
1. HTML Imports (rel="import")已廢除(Chrome 80+ 移除)獨立子文件、腳本汙染全域僅存於舊版 Polymer 1/2 遺留系統,應全面遷移
2. 宣告式 Shadow DOM (shadowrootmode)WHATWG 正式標準(Baseline 2024:Chrome 111、Safari 16.4、Firefox 123 起)隨主文件 HTML 串流解析,零 JS 阻塞首屏核心元件與 SSR 架構(兼顧 SEO 檢索與零 FOUC)
3. fetch() + DOMParser + <template>現行原生標準 API 組合運行於標準 ES Module 作用域內非首屏按需動態載入元件(如彈跳視窗、次要面板)
4. Import Attributes (with { type }) 與 HTML Modulesjson 已納入 ES2025;css 由 WHATWG HTML 規範定義(Safari 尚未支援);type: 'html' 仍是 WICG 提案整合進單一 ES Module Graph原生匯入 JSON 與 CSSStyleSheet;HTML 可搭配 Vite ?raw 預編譯

下圖展示了現代前端架構如何依「首屏 SSR 宣告式渲染」、「非首屏動態 Fetch 快取」與「ES2025 Import Attributes 模組整合」三條路徑取代舊有的 HTML Imports:

現代 HTML 模組化三條標準實作路徑架構對照圖

圖:現代網頁依場景分流——首屏採用宣告式 Shadow DOM 保障 SEO 與零閃爍,非首屏採用 Fetch + DOMParser 快取,樣式與靜態模板則納入 ES Import Attributes


二、 HTML Modules 提案進展與現代三大替代技術解析

1. TC39 Import Attributes(with { type })與 WICG HTML Modules 語法校準

1. TC39 Import Attributes(with { type })與 WICG HTML Modules 語法校準

在閱讀網路上關於 WICG HTML Modules 提案 的早期文章時,經常會看到 import doc from './card.html' assert { type: 'html' } 的寫法。這裡有一個極為重要的標準變更必須注意:

TC39 委員會已將早期的 Import Assertions(assert 關鍵字)正式廢棄,並於 ES2025 標準中全面改為 Import Attributes(with 關鍵字)(Chrome 123+、Safari 17.2+、Firefox 138+ 與 Node.js 20.10+ 皆已實作 with;舊的 assert 只在 Chrome 91~125 與 Node.js 16.14~21.x 出現過,已分別於 Chrome 126 與 Node.js 22 移除,Firefox 與 Safari 則從未支援 assert)。

在當前的標準進度中:

  1. JSON Modules 與 CSS Module Scripts 已可直接使用 with 語法:你可以透過 import sheet from './card.css' with { type: 'css' } 將外部 CSS 直接匯入為 CSSStyleSheet 物件,並透過 shadowRoot.adoptedStyleSheets = [sheet] 在多個元件實例間共用同一份記憶體樣式表。type: 'json' 是 ES2025 定義的標準型別;type: 'css'(CSS module scripts)則由 WHATWG HTML 規範定義,目前 Chrome/Edge 123+ 與 Firefox 147+ 支援,Safari 與 Node.js 尚未支援。
  2. HTML Modules(with { type: 'html' })仍停留在 WICG 提案:尚未納入 WHATWG HTML 標準,也沒有瀏覽器正式出貨(Chrome Platform Status 標示為「In development」)。其設計目標是讓 ES Modules 能直接匯入解析好的 Document 或 HTMLTemplateElement;在瀏覽器全面開放原生 type: 'html' 之前,實務上可透過打包工具(如 Vite 的 import html from './card.html?raw')或標準的 fetch + DOMParser 管線達成相同效果。

2. 結合原生 SRI(RequestInit.integrity)、Promise 快取池與 DOMParser 的動態載入架構

當我們需要在瀏覽器端非同步載入遠端 HTML 片段時,為避免重複發起網路請求或遭遇跨站內容竄改,建議採用以下四階段安全管線(註:關於 Shadow DOM 內部的樣式隔離細節與 Custom Elements 四大生命週期 Hook,我們將在後續的專文中分別深入探討,本節專注於模組載入器本身):

  1. 以 Map<string, Promise<HTMLTemplateElement>> 快取進行中的請求:當頁面上同時有 10 個元件實例請求同一個 /templates/card.html 時,直接快取 Promise 本身(而非等回應完成後才快取結果),確保瀏覽器只會發起唯一一次 HTTP 請求。
  2. 直接使用 fetch(url, { integrity }) 原生子資源完整性驗證:根據 WHATWG Fetch 規範,fetch() 的第二個參數 RequestInit 原生內建 integrity 欄位(例如 sha384-...)。當遠端 CDN 回傳的 HTML 位元組雜湊值不符時,瀏覽器會在底層直接拒絕該回應並拋出 TypeError,完全不需要自己手寫 crypto.subtle.digest 計算雜湊。
  3. 透過 DOMParser.parseFromString(html, 'text/html') 離線解析並存入 <template>:使用 DOMParser 在離線文件(Inert Document)中完成語法樹建構,不會執行內部的 <script>,也不會觸發主頁面的排版重算;接著將節點移入離線的 <template> 元素,後續每個元件只需呼叫 .content.cloneNode(true) 即可高速複製出獨立的 DOM 子樹。

fetch 結合原生 SRI integrity、Promise 快取池與 DOMParser 安全載入流程圖

圖:透過 Promise 快取池合併並發請求,並結合 fetch 原生 integrity 驗證與 DOMParser 離線解析,打造高效能的 HTML 模板載入管線


三、 可直接套用的現代 HTML 模組化與宣告式載入程式碼範本

範本一:具備 Promise 去重快取池、原生 SRI integrity 驗證與 DOMParser 的 HTML 模板載入器 {#example-secure-html-loader}

以下範本提供一套可直接用於生產環境的原生 ES Module 模板載入器(htmlModuleLoader.js),完整解決「多實例並發重複請求」、「CDN 子資源完整性(SRI)驗證」與「離線 <template> 高速複製」三大需求:

// htmlModuleLoader.js
/**
 * 儲存 URL 對應之 Promise<HTMLTemplateElement>,防止多組件並發時重複發起網路請求
 */
const templatePromiseCache = new Map();

/**
 * 安全載入並解析遠端 HTML 模組為可重複複製的 HTMLTemplateElement
 * @param {string} url 遠端 HTML 模板路徑
 * @param {Object} [options]
 * @param {string} [options.integrity] 選填之原生 SRI 雜湊值 (例如 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC')
 * @returns {Promise<HTMLTemplateElement>}
 */
export function importHtmlTemplate(url, { integrity } = {}) {
  if (templatePromiseCache.has(url)) {
    return templatePromiseCache.get(url);
  }

  const loadPromise = (async () => {
    const fetchOptions = {
      method: 'GET',
      mode: 'cors',
      credentials: 'same-origin',
    };

    // 直接使用 WHATWG Fetch 原生支援的 integrity 屬性交由瀏覽器底層驗證 SRI
    if (integrity) {
      fetchOptions.integrity = integrity;
    }

    const response = await fetch(url, fetchOptions);
    if (!response.ok) {
      throw new Error(`HTML 模組載入失敗 (${response.status}): ${url}`);
    }

    const rawHtml = await response.text();

    // 使用 DOMParser 於離線文件中安全解析 HTML(解析期間不執行內部腳本、不觸發主頁面 Reflow)
    const parser = new DOMParser();
    const doc = parser.parseFromString(rawHtml, 'text/html');

    // 若遠端檔案只有一個頂層 <template>,直接取出;否則包裝為新的 HTMLTemplateElement
    // 注意:檔案開頭的 <template> 會被 HTML Parser 放進 head 而非 body,因此 head 與 body 要一起判斷
    const topLevelElements = [...doc.head.children, ...doc.body.children];
    if (topLevelElements.length === 1 && topLevelElements[0].localName === 'template') {
      return topLevelElements[0];
    }

    const template = document.createElement('template');
    // 將 head 內的 <style> 與 body 內的結構一併移入離線 template.content
    template.content.append(
      ...doc.head.querySelectorAll('style, link[rel="stylesheet"]'),
      ...doc.body.childNodes
    );
    return template;
  })().catch((err) => {
    // 若載入失敗則自快取移除,允許後續重試
    templatePromiseCache.delete(url);
    throw err;
  });

  templatePromiseCache.set(url, loadPromise);
  return loadPromise;
}

/**
 * 將遠端 HTML 模組實例化並掛載至目標容器(支援 Shadow DOM 隔離)
 */
export async function mountHtmlModule(hostElement, url, { integrity, useShadow = true } = {}) {
  const template = await importHtmlTemplate(url, { integrity });
  const clonedFragment = template.content.cloneNode(true);

  const targetRoot = useShadow
    ? hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
    : hostElement;

  targetRoot.replaceChildren(clonedFragment);
  return targetRoot;
}

範本二:支援 SSR 首屏零閃爍的宣告式 Shadow DOM(shadowrootmode="open")與 CSS Module Scripts 整合範例 {#example-declarative-shadow-dom-css-module}

針對需要被搜尋引擎完整索引、且絕不能出現首屏樣式閃爍(FOUC)的核心區塊,建議直接採用 WHATWG 宣告式 Shadow DOM(Declarative Shadow DOM) 搭配 Import Attributes with { type: 'css' } 樣式表模組(CSS module scripts,Safari 尚未支援,需相容 Safari 時可改用 <link rel="stylesheet"> 或內嵌 <style>):

<!-- 1. 伺服器端或靜態 HTML 直接輸出 shadowrootmode="open"(自訂標籤依 WHATWG 規範採用全小寫 kebab-case) -->
<!-- 瀏覽器 HTML Parser 解析至此處時,不需任何 JS 即可立即建立隔離的 Shadow Root 並完成首屏繪製 -->
<product-banner-card>
  <template shadowrootmode="open">
    <style>
      :host {
        display: block;
        padding: 1.25rem;
        border-radius: 0.75rem;
        background: #f8fafc;
        border: 1px solid #cbd5e1;
      }
      .banner-title {
        margin: 0 0 0.5rem;
        font-size: 1.25rem;
        color: #0f172a;
      }
    </style>
    <article>
      <h2 class="banner-title">
        <slot name="title">原生宣告式組件標題</slot>
      </h2>
      <p><slot name="summary">此區塊於初始 HTML 解析階段即完成渲染,爬蟲可直接檢索且零 FOUC 閃爍。</slot></p>
    </article>
  </template>

  <!-- 外部投射之語意化內容(直接存在於主文件 Light DOM,爬蟲不需執行 JS 就能讀到) -->
  <span slot="title">次世代雲端儲存方案</span>
  <span slot="summary">支援跨區即時同步與硬體級加密保護。</span>
</product-banner-card>
// 2. 客戶端進階整合:使用 Import Attributes (with { type: 'css' }) 匯入 CSS module script,共用構造式樣式表
import sharedCardSheet from './shared-card.css' with { type: 'css' };

export function enhanceCardWithSharedStyles(hostElement) {
  const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' });
  // 將匯入的 CSSStyleSheet 直接附加至 adoptedStyleSheets,多個組件共用同一份記憶體樣式
  shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, sharedCardSheet];
}

四、 開發者常見問題(Q&A)與排查注意事項

Q1:為什麼舊教材寫的 import ... assert { type: 'json' } 在新版 Chrome 與 Node.js 會直接拋出 SyntaxError? {#qa-1}

原因說明:assert 是 TC39 早期在 Stage 3 階段實驗性推出的關鍵字(Import Assertions)。後來標準委員會為了允許屬性影響模組載入行為(而不僅僅是被動斷言),決定將關鍵字正式更名為 with(Import Attributes) 並納入 ES2025 標準。自 Chrome 126 與 Node.js 22 起,V8 引擎已正式移除對 assert 關鍵字的支援;Firefox 與 Safari 則從未支援 assert。以 Node.js v22 執行舊寫法會得到:

SyntaxError: Unexpected identifier 'assert'

解法:請將專案中所有的 assert { type: 'json' } 或 assert { type: 'css' } 全面替換為標準語法 with { type: 'json' } 與 with { type: 'css' }。


Q2:為什麼在客戶端用 fetch() 非同步載入首屏 HTML 片段會傷害 SEO 並引發無樣式內容閃爍(FOUC)?如何透過宣告式 Shadow DOM 解決? {#qa-2}

原因說明:當首屏的主視覺或文章導覽列依賴客戶端 JavaScript 執行 fetch('/components/hero.html') 後才注入 DOM 時,使用者會先看到空白畫面或版面劇烈跳動(CLS);同時若局部 HTML 內含外部 <link rel="stylesheet">,在樣式表下載完成前會先露出未排版的原始文字(FOUC)。

解法:

  1. 首屏關鍵區塊採用宣告式 Shadow DOM(<template shadowrootmode="open">):直接在伺服器端輸出的初始 HTML 中內嵌 <template shadowrootmode="open">,瀏覽器 HTML 解析器在讀取串流時就會原生建立 Shadow Root 並套用內部 <style>,不需等待 JS 執行、避免 FOUC,內容也直接存在於初始 HTML 中供爬蟲讀取。
  2. 非首屏動態載入元件:在替換內容前,先等待內部樣式資源就緒,或搭配 adoptedStyleSheets 同步掛載已預先匯入的 CSSStyleSheet。

Q3:使用 fetch() 載入跨網域或 CDN 上的 HTML 模板時,真的需要自己手寫 crypto.subtle.digest 才能驗證 SRI 雜湊值嗎? {#qa-3}

原因說明:不需要!許多開發者誤以為只有 <script integrity="..."> 與 <link integrity="..."> 標籤才支援子資源完整性(Subresource Integrity, SRI),因而自己用 TextEncoder 與 crypto.subtle.digest('SHA-256', ...) 手動算雜湊。事實上根據 WHATWG Fetch 規範與 MDN RequestInit 文件,原生 fetch() 早就內建了 integrity 參數支援。

解法:在呼叫 fetch(url, { integrity: 'sha384-...' }) 時直接傳入標準 SRI 雜湊字串即可。瀏覽器會在取得回應內容後自動比對雜湊,一旦不符便以網路錯誤處理,fetch() 的 Promise 會以 TypeError 拒絕,同時請確保跨網域 CDN 已正確回應 Access-Control-Allow-Origin CORS 標頭。


Q4:為什麼取得遠端 HTML 字串後,建議使用 DOMParser.parseFromString() 搭配 template 複製,而非直接對頁面上已掛載的容器執行 innerHTML = html? {#qa-4}

原因說明:如果頁面上有 20 個卡片元件,每次都對已掛載在畫面上的 DOM 容器直接執行 container.innerHTML = htmlString,瀏覽器就必須把同一段 HTML 字串重複進行 20 次詞法分析(Tokenization)與語法樹建構(關於 HTML Parser 建構 DOM 樹的成本,請參閱 DOM 樹狀結構與渲染引擎揭秘)。

解法:如本文範本一所示,首次取得字串時使用 new DOMParser().parseFromString(rawHtml, 'text/html') 解析一次並轉存至離線的 HTMLTemplateElement 快取中;後續第 2~20 個元件只需呼叫 template.content.cloneNode(true) 直接複製已解析好的節點,不必再重新對 HTML 字串做詞法分析。


Q5:在 WICG HTML Modules 正式成為全瀏覽器原生標準前,Vite 與 Webpack 專案如何優雅地將 .html 與 .css 當作 ES 模組直接匯入? {#qa-5}

原因說明:在現代前端工程化環境中,我們通常希望在建置階段(Build Time)就把小型 HTML 模板打包進模組圖中,省去執行期的額外 HTTP 往返。

解法:

  1. 在 Vite 專案中:依據 Vite 官方靜態資源規範,只要在匯入路徑後方加上 ?raw 後綴(例如 import cardHtml from './card.html?raw'),Vite 就會在編譯階段將 HTML 檔案內嵌為字串模組;樣式則可使用 ?inline 取得處理後的 CSS 字串(不會自動注入頁面),再以 new CSSStyleSheet() 搭配 replaceSync() 轉成可放進 adoptedStyleSheets 的樣式表。
  2. 在 Webpack 專案中:可依 Webpack Asset Modules 文件,在 module.rules 中將 .html 配置為 Webpack 5 內建的 type: 'asset/source'(無需額外安裝舊版 raw-loader),即可直接透過 import cardHtml from './card.html' 取得模板字串。

五、 總結

回顧從 <link rel="import"> 到 HTML Modules 與現代標準替代方案的演進,前端架構已全面收斂至 ES Modules 單一模組圖與宣告式 HTML 標準:

  1. 徹底淘汰 <link rel="import"> 與過時的 assert 語法:改用 ES2025 標準的 with { type: 'json' | 'css' }(Import Attributes) 與標準 ES Module 載入管線。
  2. 依首屏與非首屏區分載入策略:首屏核心與 SEO 敏感元件優先採用 宣告式 Shadow DOM(<template shadowrootmode="open">);非首屏互動元件則採用 fetch(url, { integrity }) + Promise 快取池 + DOMParser 離線 <template> 複製。
  3. 搭配 <link rel="modulepreload"> 消除模組瀑布流:對於關鍵的 ES 模組與元件載入器,可結合我們在 HTML Resource Hints 資源預載入要點 中介紹的 modulepreload 提前預載入模組圖。

參考資料與延伸閱讀

發佈留言