從 Shadow DOM 到 Agent Skill:把一份 HTML 打包成到處都貼得上的模組

TL;DR:先看成果


上面這份精美(應該是吧!?)的 HTML Slide是一個完整自我封裝的 Shadow DOM 元件,再複雜的 HTML、JS跟 CSS都完全的跟主頁隔離開來。

做了一份漂亮的互動式簡報(純 HTML + CSS + JS),想直接貼進客戶的 WordPress 文章裡。結果樣式互相打架、腳本被默默吃掉、中文變亂碼,還跳出一個莫名其妙的 '#' not followed by identifier。這篇文章把我們一路踩到底的經驗整理成三件事:Shadow DOM 是什麼為什麼它能把 HTML + CSS + JS 模組化並完全隔離,以及我們如何把整套方法固化成一支 Agent Skill

什麼是 Shadow DOM

現代瀏覽器原生支援的 Web Components,由三個技術組成:Custom Elements(自訂標籤)、HTML Templates(樣板),以及本文主角 Shadow DOM

Shadow DOM 的核心概念只有一個:你可以在某個元素底下,掛一棵「與主文件分離的 DOM 子樹」。這個元素叫 shadow host(宿主),掛上去的子樹入口叫 shadow root,整棵子樹叫 shadow tree。它會正常被瀏覽器渲染出來,但在樣式與 DOM 查詢上,它跟外面的世界是隔開的。

最簡單的範例:

const host = document.querySelector('#widget');
const root = host.attachShadow({ mode: 'open' });   // 掛一棵 shadow tree
root.innerHTML = `
  <style> p { color: crimson; } </style>   <!-- 這條 CSS 只作用於這棵 shadow tree -->
  <p>我住在 shadow tree 裡</p>
`;

頁面上其他的 <p> 完全不會變紅色,document.querySelector('p') 也找不到裡面那個 <p>,因為它在另一棵樹裡。

attachShadowmode 有兩種:open 允許外部用 host.shadowRoot 拿到 shadow root;closed 則回傳 null,把入口藏起來。實務上除非有強隔離需求,一般用 open

為什麼 Shadow DOM 能模組化 HTML + CSS + JS 且完全隔離

「模組化」的真正門檻不是把程式寫在一起,而是它貼到任何頁面都不會互相干擾。Shadow DOM 之所以能做到,是因為它同時處理了三個維度的隔離,但每個維度的機制不同,講清楚才不會誤用。

CSS:雙向隔離,但留了一道刻意的接縫

這是 Shadow DOM 最強的部分:

  • 裡面的樣式不外洩。 shadow tree 裡的 <style> 只作用於這棵樹。你甚至可以放 * { margin:0 } 這種全域重置,也只會打到 shadow 內部,不會把整個網站的 margin 清掉。
  • 外面的樣式(大多)不入侵。 母頁的 .card { padding: 40px } 不會套進 shadow tree 裡同名的 .card。選擇器在邊界就止步了。

但「完全隔離」有兩個刻意保留的例外,理解它們才能做主題化:

  • 可繼承屬性會穿透。 colorfont-family 這類會繼承的屬性,仍會從宿主往內繼承。需要完全乾淨時,可在 :hostall: initial 阻斷。
  • CSS 自訂變數(custom properties)會穿透。 這是特色不是 bug——它讓外部可以「注入主題」而不會破壞封裝。

所以正確的做法是:把設計 token 定義在 :host(代表宿主元素本身),內部樣式一律引用變數:

:host { display: block; --accent: #cc9e6c; }   /* token 放這裡,會往內繼承 */
.dot  { background: var(--accent); }

Shadow DOM 給你的是「預設隔離、按需開孔」,而不是一堵完全不透光的牆。

DOM:作用域化的查詢與 ID

shadow tree 是獨立的一棵樹,所以:

  • document.getElementById('btn') 找不到 shadow 內的 #btn。你必須用 root.getElementById('btn')
  • ID 是作用域化的——不同 shadow tree 裡可以有同名 ID 而不衝突。
  • 從 shadow 內冒泡到外面的事件會被重定向(event retargeting):外面接到的 event.target 會變成宿主元素,而不是內部真正被點的節點。封裝因此對外一致。

JS:Shadow DOM 不管,要靠你自己收好

這是最多人誤解的地方:Shadow DOM 不會隔離 JavaScript 的執行環境。 全域 window 是共用的。如果你在 shadow 元件的腳本裡不小心宣告了一個全域變數,它照樣污染母頁。

真正的 JS 隔離要靠三件事一起做到:

  • IIFE + 'use strict' 把所有變數包在閉包裡(strict mode 會讓任何漏網的全域直接報錯,測試時就抓得到)。
  • 所有 DOM 查詢對著 shadow root,不對 document
  • 事件監聽綁在 shadow 元件本身,不要掛 window / document;要監聽尺寸變化就用 ResizeObserver,不要用全域的 resize

把這三件事,加上一個 Custom Element,包在一起,才是一個真正自足的模組。完整骨架長這樣:

<my-widget-3f9a1c2e></my-widget-3f9a1c2e>   <!-- 標籤名內嵌 UUID,全域唯一、不會撞名 -->
<script>
(function () {
  'use strict';
  var TAG  = 'my-widget-3f9a1c2e';
  var CSS  = ':host{display:block;--accent:#cc9e6c} .dot{background:var(--accent)}';
  var HTML = '<div class="dot"></div>';

  if (!customElements.get(TAG)) {
    customElements.define(TAG, class extends HTMLElement {
      connectedCallback() {
        var root = this.attachShadow({ mode: 'open' });
        root.innerHTML = HTML;                       // 只放標記
        var st = document.createElement('style');    // 樣式用 createElement 掛,別用字串
        st.textContent = CSS;
        root.insertBefore(st, root.firstChild);
        // ... 所有初始化都對 root 操作 ...
      }
    });
  }
})();
</script>

到這裡,你有了一個 CSS 不外洩、DOM 查得住、JS 不污染母頁、標籤全域唯一的模組。理論上,貼到哪都該正常運作。

理論很美,現實很多雷:真實世界的 CMS

我們把上面這種元件貼進 WordPress 的「自訂 HTML」區塊,結果一路踩雷。原因是:CMS 不會原封不動地輸出你的內容。 WordPress 的 the_content 會依序跑一堆過濾器(wpautopwptexturize、KSES、實體編碼),加上頁面快取與頁面建構器(Elementor),每一層都可能默默改寫你的 inline 內容。我們依序踩到的坑:

  • 中文變亂碼。 檔案沒有 charset 提示,瀏覽器猜錯編碼。解法:把整份內容變成純 ASCII(非 ASCII 全部 \uXXXX 轉義),執行時再由 JS 還原。
  • Uncaught SyntaxError: Invalid or unexpected token(Firefox 是 '#' not followed by identifier)。 這是最惡毒的一個:the_content 會把 JS 裡的 && 編碼成 &#038;,那個 # 就是錯誤來源。腳本一開頭就掛,customElements.define 根本沒跑,畫面全白。解法:讓整份檔案 0 個 &——把每個 && 改寫成等義的三元式 a ? b : false,把 &#8249; 這類實體改成字元。
  • 整段憑空消失。 帳號沒有 unfiltered_html 權限時,KSES 會在存檔時把 <script> 與未知標籤整個刪掉。解法:恢復權限,或改用 WPCode 之類的程式碼片段外掛輸出(繞過 KSES)。
  • 顯示的是舊內容。 頁面快取把你改版前的 HTML 快取住了。用 REST API 抓 content.rendered 一比就知道——資料庫是新的、實頁是舊的,purge 快取即可。
  • Elementor 頁面整個無視你的區塊。 Elementor 版面不會輸出 Gutenberg 區塊內容;要改用 Elementor 的 HTML widget。

每一關我們都是在瀏覽器 console 實測抓根因(比對 REST 與實頁、把 rendered 內容丟進 srcdoc 加上 error catcher、數 &&&#038; 的比例),而不是猜。

到這裡有個關鍵體悟:這整套知識『難、可重複、有明確的成功判準』正是不該只寫在某個人腦袋或某則筆記裡的東西。它該被「固化」。

什麼是 Agent Skill,以及我們怎麼把它固化

Agent Skill 是什麼

Agent Skill 是一個可攜的「程序知識(procedural knowledge)」套件,是給模型與機器共讀的行為契約。 它不是模型微調、不是單一 tool、也不是塞滿單一檔案的長 prompt。它回答的是:「遇到這類任務時,該用什麼步驟、什麼工具、什麼驗證、什麼安全邊界去完成。」

它採 agentskills.io 的開放標準,核心設計是三層漸進揭露(progressive disclosure),讓模型只在需要時載入最小必要資訊:

  • 第一層 metadata(約 100 tokens):只有 frontmatter 的 namedescription,session 啟動時就載入,讓 agent 判斷「這個任務要不要用這支 skill」。
  • 第二層 body(建議 < 500 行)SKILL.md 正文,語意觸發後才載入,用「輸入 → 工具 → 輸出」的結構寫主流程。
  • 第三層 resourcesreferences/(長文件)、scripts/(可執行的驗證)、assets/(樣板)——真正需要時才讀。

其中 description 是最關鍵的觸發槓桿:要用第三人稱、同時寫「做什麼」與「何時用」、放進觸發關鍵字與負面邊界,而且要描述觸發條件而不是流程(否則模型會照 description 直接動手、跳過 body)。

我們的 skill:deus-shadow-dom-embed

我們把上面第二、三節的所有血淚,做成了一支 skill。它的 description 涵蓋了「把 HTML 打包成能貼進 WordPress/CMS 的 Shadow DOM 元件」「貼上後被吃掉或跳 # SyntaxError」「文字變亂碼」「樣式外洩」等觸發情境。檔案結構:

deus-shadow-dom-embed/
├── SKILL.md                        # 主流程 + 症狀對照表
├── references/
│   ├── cms-corruption-traps.md     # KSES / the_content / 快取 / CSP / Elementor 全目錄
│   ├── packaging-recipe.md         # extract → escape → 組裝 的完整配方
│   └── diagnostics.md              # REST vs 實頁、srcdoc+error catcher 等實測探針
├── scripts/
│   └── check_component.mjs         # 交付前的自動驗證器
├── assets/
│   └── web-component-template.html # 一份可直接通過驗證的最小骨架
└── evals/                          # trigger / golden / safety 測試

主流程五步,就是本文的濃縮:封裝進 Shadow DOM → 不污染母頁 → 防 CMS 過濾 → 驗證 → 交付。其中「防 CMS 過濾」是靈魂:純 ASCII、0 個 &、不留 (c)/(r)/(tm) 這類 texturizer 代碼、payload 裡不放 <style> 字面量(改用 createElement 掛)。

為什麼要做成 skill,而不是一段 README

因為 skill 把「可觸發、可驗證、可版控、可攜、可跨專案重用」一次滿足。最有價值的是那支驗證器——它把交付前檢查自動化了:

node scripts/check_component.mjs deck.html --expect-selector ".slide" --expect-count 14
# [PASS] zero ampersands
# [PASS] pure ASCII (no non-ASCII bytes)
# [PASS] no <style> literal in payload
# [PASS] component builds a shadow root  ->  ok
# [PASS] shadow content: .slide  ->  14 (expected 14)
# ALL CHECKS PASSED

它會掃 &(c)/(r)/(tm)、非 ASCII、<style>/<template> 字面量,再用 jsdom 實際把元件建構一次、確認 shadow root 與元素數都對。下次任何人(或任何 agent)要做同類的嵌入,不必再重踩一次我們踩過的雷。

結語

這趟旅程從一個很小的問題出發,「怎麼把一份 HTML 乾淨地貼進別人的頁面」最後串起了三層東西:Shadow DOM 的隔離模型、把 HTML/CSS/JS 收成模組的工程手法,以及把這套來之不易的程序知識固化成 Agent Skill 的方法論。

模組化的真正意義,從來不只是把程式碼包在一起。是把知識也模組化:讓它可被觸發、可被驗證、可被重用。Shadow DOM 讓元件到處都貼得上;Agent Skill 讓解法到處都用得上。