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>,因為它在另一棵樹裡。
attachShadow 的 mode 有兩種: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。選擇器在邊界就止步了。
但「完全隔離」有兩個刻意保留的例外,理解它們才能做主題化:
- 可繼承屬性會穿透。
color、font-family這類會繼承的屬性,仍會從宿主往內繼承。需要完全乾淨時,可在:host上all: 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 會依序跑一堆過濾器(wpautop、wptexturize、KSES、實體編碼),加上頁面快取與頁面建構器(Elementor),每一層都可能默默改寫你的 inline 內容。我們依序踩到的坑:
- 中文變亂碼。 檔案沒有 charset 提示,瀏覽器猜錯編碼。解法:把整份內容變成純 ASCII(非 ASCII 全部
\uXXXX轉義),執行時再由 JS 還原。 Uncaught SyntaxError: Invalid or unexpected token(Firefox 是'#' not followed by identifier)。 這是最惡毒的一個:the_content會把 JS 裡的&&編碼成&,那個#就是錯誤來源。腳本一開頭就掛,customElements.define根本沒跑,畫面全白。解法:讓整份檔案 0 個&——把每個&&改寫成等義的三元式a ? b : false,把‹這類實體改成字元。- 整段憑空消失。 帳號沒有
unfiltered_html權限時,KSES 會在存檔時把<script>與未知標籤整個刪掉。解法:恢復權限,或改用 WPCode 之類的程式碼片段外掛輸出(繞過 KSES)。 - 顯示的是舊內容。 頁面快取把你改版前的 HTML 快取住了。用 REST API 抓
content.rendered一比就知道——資料庫是新的、實頁是舊的,purge 快取即可。 - Elementor 頁面整個無視你的區塊。 Elementor 版面不會輸出 Gutenberg 區塊內容;要改用 Elementor 的 HTML widget。
每一關我們都是在瀏覽器 console 實測抓根因(比對 REST 與實頁、把 rendered 內容丟進 srcdoc 加上 error catcher、數 && 對 & 的比例),而不是猜。
到這裡有個關鍵體悟:這整套知識『難、可重複、有明確的成功判準』正是不該只寫在某個人腦袋或某則筆記裡的東西。它該被「固化」。
什麼是 Agent Skill,以及我們怎麼把它固化
Agent Skill 是什麼
Agent Skill 是一個可攜的「程序知識(procedural knowledge)」套件,是給模型與機器共讀的行為契約。 它不是模型微調、不是單一 tool、也不是塞滿單一檔案的長 prompt。它回答的是:「遇到這類任務時,該用什麼步驟、什麼工具、什麼驗證、什麼安全邊界去完成。」
它採 agentskills.io 的開放標準,核心設計是三層漸進揭露(progressive disclosure),讓模型只在需要時載入最小必要資訊:
- 第一層 metadata(約 100 tokens):只有 frontmatter 的
name與description,session 啟動時就載入,讓 agent 判斷「這個任務要不要用這支 skill」。 - 第二層 body(建議 < 500 行):
SKILL.md正文,語意觸發後才載入,用「輸入 → 工具 → 輸出」的結構寫主流程。 - 第三層 resources:
references/(長文件)、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 讓解法到處都用得上。