3813 字
19 分鐘
Shadow DOM 完全指南:DOM 隔離、CSS 封裝與實戰解法

前言#

最近我需要將一個功能模組以完全不同的架構和樣式插入到別的專案中。這個模組需要:

  • ✅ 完全隔離外部樣式,避免被專案的全域 CSS 干擾
  • ✅ 內部樣式不會洩漏,影響專案原有的 UI
  • ✅ 使用不同的前端框架或原生 JavaScript 實作
  • ✅ 能在任何專案中「即插即用」,不需擔心樣式衝突

這正是 Shadow DOM 的設計初衷——提供一個完全隔離的環境,讓元件可以在任何上下文中穩定運行。


什麼是 Shadow DOM?#

Shadow DOM 是 Web Components 標準的核心技術之一,它允許開發者將一個隱藏的、獨立的 DOM 樹附加到元素上。這個 DOM 樹與主文檔的 DOM 完全隔離,擁有自己的作用域。

基本概念#

// 建立一個 Shadow DOM
class MyComponent extends HTMLElement {
constructor() {
super();
// 附加 Shadow Root
const shadowRoot = this.attachShadow({mode: 'open'});
// 在 Shadow DOM 內渲染內容
shadowRoot.innerHTML = `
<style>
p { color: blue; }
</style>
<p>這是 Shadow DOM 內的內容</p>
`;
}
}
customElements.define('my-component', MyComponent);

使用時:

<my-component></my-component>

瀏覽器實際渲染結果:

▼ <my-component>
▼ #shadow-root (open)
▼ <style>...</style>
▼ <p>這是 Shadow DOM 內的內容</p>

核心特性#

  1. DOM 封裝:Shadow DOM 內的元素不會被外部的 JavaScript 查詢到
  2. 樣式隔離:內部樣式不會影響外部,外部樣式也不會滲透進來
  3. 組合性:可以透過 <slot> 元素接收外部內容

Shadow DOM vs iframe:該如何選擇?#

當需要隔離內容時,許多人會想到使用 <iframe>,但 Shadow DOM 和 iframe 有本質上的差異:

對比表格#

特性Shadow DOMiframe
隔離層級同一文檔,輕量級隔離完全獨立的瀏覽上下文
效能✅ 輕量,渲染快速❌ 重量級,獨立渲染引擎
樣式隔離✅ 雙向隔離✅ 完全隔離
DOM 存取可透過 shadowRoot 存取需透過 contentWindow
事件冒泡✅ 可穿透邊界 (composed: true)❌ 無法冒泡到外部
SEO✅ 內容可被索引⚠️ 內容較難被索引
載入時間✅ 即時❌ 需要額外載入時間
適用場景可重用元件、設計系統第三方內容、沙箱環境

使用場景建議#

使用 Shadow DOM 的情況:

  • ✅ 開發可重用的 UI 元件
  • ✅ 建構設計系統 (Design System)
  • ✅ 需要樣式隔離但保持互動性
  • ✅ 微前端架構中的元件封裝

使用 iframe 的情況:

  • ✅ 嵌入完全不可信的第三方內容
  • ✅ 需要完全獨立的 JavaScript 執行環境
  • ✅ 嵌入跨域內容 (如廣告、外部網頁)
  • ✅ 需要額外的安全沙箱保護

實際案例#

// ❌ 不好的做法:用 iframe 做元件隔離
<iframe srcdoc="<button>點擊我</button>"></iframe>
// ✅ 更好的做法:用 Shadow DOM
class IsolatedButton extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<style>button { /* 隔離的樣式 */ }</style>
<button><slot>點擊我</slot></button>
`;
}
}

實際開發:當 querySelector 突然失效#

了解了 Shadow DOM 的基本概念後,讓我們看看實際開發中會遇到的第一個問題。

問題情境#

在開發過程中,我遇到了一個令人困惑的問題:明明 DOM 結構中存在 #my-element 元素,但 document.querySelector() 卻總是回傳 null。開發者工具也顯示元素確實存在,這到底是怎麼回事?

// ❌ 這段程式碼無法選取到 Shadow DOM 內的元素
const element = document.querySelector('#my-element');
console.log(element); // null

這個問題的根源就是 Shadow DOM 的 DOM 隔離機制


一、Shadow DOM 的 DOM 隔離機制#

隔離機制原理#

Shadow DOM 的封裝特性包含三個層面:

  1. DOM 樹隔離:Shadow DOM 內的節點不會出現在父文檔的 DOM 樹中
  2. 選擇器邊界:外部的 document.querySelector() 無法穿透 Shadow 邊界
  3. 事件重定向:事件冒泡時會被重新定向到 host 元素

這種設計確保了元件的內部實作細節不會洩漏到外部,也不會被外部程式碼意外修改。

正確的存取方式#

要存取 Shadow DOM 內的元素,必須先取得宿主元素 (host element),再透過 .shadowRoot 進入影子樹:

// ✅ 正確做法:透過 shadowRoot 存取
const host = document.querySelector('my-custom-element'); // 取得宿主元素
const shadowRoot = host.shadowRoot; // 取得 shadow root
const element = shadowRoot.querySelector('#my-element'); // 在影子樹內查詢
console.log(element); // 成功取得元素

Open vs Closed 模式#

建立 Shadow DOM 時可以選擇兩種模式:

// Open 模式:允許外部程式碼存取 shadowRoot
const shadowRoot = element.attachShadow({mode: 'open'});
// Closed 模式:完全封閉,element.shadowRoot 會回傳 null
const shadowRoot = element.attachShadow({mode: 'closed'});

選擇建議

  • Open 模式:適合需要提供外部存取介面的元件 (如第三方套件、UI 函式庫)
  • Closed 模式:適合內部專用元件,強制使用公開的屬性和方法互動

常見錯誤與除錯技巧#

錯誤 1:直接使用 document.querySelector#

// ❌ 錯誤:無法穿透 Shadow DOM
document.querySelector('#my-button'); // null

錯誤 2:忘記檢查 shadowRoot 是否存在#

// ❌ 錯誤:可能因為 closed 模式或元素沒有 Shadow DOM 而報錯
const element = host.shadowRoot.querySelector('#target');
// ✅ 正確:加上防禦性檢查
const shadowRoot = host.shadowRoot;
if (shadowRoot) {
const element = shadowRoot.querySelector('#target');
}

除錯技巧:使用開發者工具#

現代瀏覽器的開發者工具都支援檢視 Shadow DOM:

  1. Chrome DevTools:Shadow DOM 會以 #shadow-root 節點顯示
  2. 可以直接在 Elements 面板中展開查看內部結構
  3. 使用 $0.shadowRoot 在 Console 中存取選中元素的 Shadow Root

二、Shadow DOM 的 CSS 隔離原理#

為什麼能隔離樣式?#

Shadow DOM 不只隔離 DOM 結構,也建立了 樣式作用域邊界 (Style Scoping Boundary)。這個邊界有兩個關鍵特性:

  1. 外部樣式無法穿透:主文檔的 CSS 選擇器無法選取到 Shadow DOM 內的元素
  2. 內部樣式不會外洩:Shadow DOM 內定義的樣式不會影響外部的 Light DOM

實際範例#

<!-- 主文檔的樣式 -->
<style>
.button {
background: red;
font-size: 16px;
}
</style>
<!-- 使用 Web Component -->
<my-component>
#shadow-root
<style>
.button {
background: blue;
padding: 10px;
}
</style>
<button class="button">Shadow 按鈕</button>
</my-component>
<button class="button">普通按鈕</button>

結果

  • Shadow 按鈕:藍色背景、10px padding (僅受 Shadow DOM 內的樣式影響)
  • 普通按鈕:紅色背景、16px 字體 (僅受主文檔樣式影響)

隔離機制的實作細節#

瀏覽器在渲染時會為 Shadow DOM 建立獨立的 CSSOM (CSS Object Model):

Document CSSOM
├─ Global Styles
└─ Shadow Root CSSOM (隔離)
└─ Shadow Styles

CSS 選擇器在匹配時,會被限制在各自的 DOM 子樹內,無法跨越 Shadow 邊界進行匹配。

可控的樣式穿透點#

雖然有隔離,但 Shadow DOM 提供了三種「開孔」機制讓外部可以影響內部樣式:

1. CSS 自訂屬性 (CSS Variables)#

CSS 變數會依繼承規則由外往內傳遞:

<style>
my-component {
--theme-color: #3b82f6;
--spacing: 16px;
}
</style>
<my-component>
#shadow-root
<style>
.content {
color: var(--theme-color);
padding: var(--spacing);
}
</style>
<div class="content">可主題化的內容</div>
</my-component>

優點:保持封裝性的同時允許外部主題化

2. ::part() 偽元素#

元件作者可以用 part 屬性標記允許外部設計樣式的「零件」:

<!-- Shadow DOM 內部 -->
<style>
.tab {
padding: 10px;
}
</style>
<div part="tab" class="tab">標籤</div>
<!-- 外部樣式 -->
<style>
my-tabs::part(tab) {
background: #f3f4f6;
border-radius: 4px;
}
</style>

3. ::slotted() 偽元素#

控制透過 <slot> 投影進來的內容:

<!-- Shadow DOM 內部 -->
<style>
::slotted(h2) {
color: #1f2937;
margin-top: 0;
}
</style>
<slot></slot>
<!-- 使用元件 -->
<my-card>
<h2>這是標題</h2>
<p>內容文字</p>
</my-card>

CSS 隔離的實務價值#

  1. 避免樣式衝突:多個團隊開發的元件可以共存,不需擔心 class 名稱衝突
  2. 可預測性:元件在不同頁面中的外觀一致,不會被全域樣式意外覆蓋
  3. 減少 CSS 權重戰爭:不需要用 !important 或提高選擇器權重來確保樣式生效
  4. 微前端友善:多個獨立應用整合時,樣式不會互相干擾

三、瀏覽器支援與相容性#

主流瀏覽器支援狀況 (2025 年)#

Shadow DOM v1 規範已被主流現代瀏覽器廣泛支援:

瀏覽器最低支援版本備註
Chrome53+ (2016)完整支援,包含宣告式 Shadow DOM (v90+)
Edge79+ (Chromium 版)與 Chrome 同步支援
Safari10.1+ (2017)支援 Shadow DOM v1,iOS Safari 同步
Firefox63+ (2018)支援 Shadow DOM v1,宣告式 Shadow DOM 尚未支援
IE❌ 不支援無任何 Shadow DOM 支援

宣告式 Shadow DOM (Declarative Shadow DOM)#

Chrome 90 與 Edge 91 引入了宣告式 Shadow DOM,允許純 HTML 定義:

<my-component>
<template shadowrootmode="open">
<style>
p { color: blue; }
</style>
<p>Shadow DOM 內容</p>
</template>
</my-component>

支援狀況

  • ✅ Chrome 90+、Edge 91+
  • ❌ Safari、Firefox 尚未支援

Polyfill 方案#

對於需要支援舊瀏覽器的專案,可以使用 Polyfill:

Terminal window
npm install @webcomponents/webcomponentsjs
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>

注意事項

  • Polyfill 無法完全模擬原生 Shadow DOM 的所有特性
  • 僅提供基礎的封裝功能,效能不如原生實作
  • 建議僅用於必須支援舊瀏覽器的場景

漸進增強策略#

class MyComponent extends HTMLElement {
constructor() {
super();
// 功能檢測
if (this.attachShadow) {
// 使用 Shadow DOM
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>p { color: blue; }</style>
<p>Shadow DOM 內容</p>
`;
} else {
// 降級方案:直接渲染到 Light DOM
this.innerHTML = `
<p class="my-component-text">內容</p>
`;
}
}
}

四、實戰應用:設計一個封裝良好的 Web Component#

設計原則#

  1. 最小暴露原則:只暴露必要的 API (屬性、方法、事件)
  2. 樣式可定制:透過 CSS Variables 和 ::part 提供主題化能力
  3. 語義化插槽:為不同內容區域提供具名插槽
  4. 錯誤處理:優雅處理 Shadow DOM 不支援的情況

完整範例:可定制的卡片元件#

class CustomCard extends HTMLElement {
constructor() {
super();
// 建立 open 模式的 Shadow DOM
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
}
.card {
background: var(--card-bg, white);
border-radius: var(--card-radius, 8px);
padding: var(--card-padding, 16px);
box-shadow: var(--card-shadow, 0 2px 8px rgba(0,0,0,0.1));
}
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.title {
font-size: var(--title-size, 18px);
font-weight: 600;
color: var(--title-color, #1f2937);
margin: 0;
}
.badge {
background: var(--badge-bg, #3b82f6);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
}
.content {
color: var(--content-color, #6b7280);
line-height: 1.6;
}
.actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
</style>
<div class="card" part="card">
<div class="header" part="header">
<h3 class="title" part="title">
<slot name="title">預設標題</slot>
</h3>
<span class="badge" part="badge">
<slot name="badge"></slot>
</span>
</div>
<div class="content" part="content">
<slot></slot>
</div>
<div class="actions" part="actions">
<slot name="actions"></slot>
</div>
</div>
`;
}
// 監聽屬性變更
static get observedAttributes() {
return ['title'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'title') {
const titleSlot = this.shadowRoot.querySelector('.title slot');
if (titleSlot && !titleSlot.assignedNodes().length) {
titleSlot.textContent = newValue;
}
}
}
}
// 註冊元件
customElements.define('custom-card', CustomCard);

使用範例#

<!-- 基本使用 -->
<custom-card title="通知">
<span slot="badge">新</span>
您有一則新訊息
<button slot="actions">查看</button>
</custom-card>
<!-- 主題化 -->
<style>
.dark-card {
--card-bg: #1f2937;
--title-color: #f9fafb;
--content-color: #d1d5db;
--badge-bg: #10b981;
}
/* 精準控制特定零件 */
.dark-card::part(card) {
border: 1px solid #374151;
}
</style>
<custom-card class="dark-card" title="深色主題卡片">
這是深色主題的內容
</custom-card>

關鍵設計細節#

  1. 選擇器:設定宿主元素的預設樣式
  2. CSS Variables:提供 14 個可定制的樣式變數,涵蓋顏色、尺寸、陰影等
  3. part 屬性:標記 5 個可外部設計的零件 (card, header, title, badge, content, actions)
  4. 具名插槽:為標題、徽章、操作按鈕提供獨立插槽
  5. 預設內容:在插槽未提供內容時顯示預設文字

五、除錯與效能最佳化#

Chrome DevTools 除錯技巧#

1. 檢視 Shadow DOM 結構#

在 Elements 面板中,Shadow DOM 會以 #shadow-root 顯示:

▼ <custom-card>
▼ #shadow-root (open)
▼ <style>...</style>
▼ <div class="card">

2. 在 Console 中存取 Shadow DOM#

// 選中元素後
$0.shadowRoot.querySelector('.card')
// 或直接查詢
document.querySelector('custom-card').shadowRoot

3. 樣式除錯#

在 Styles 面板中可以看到:

  • Inherited from host:從宿主元素繼承的樣式
  • :元件內部的 規則
  • CSS Variables:顯示所有可用的自訂屬性及其值

效能考量#

1. 避免過度使用 Shadow DOM#

// ❌ 不好:為每個小元素都建立 Shadow DOM
class TinyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<button>Click</button>`;
}
}
// ✅ 較好:僅為需要封裝的複雜元件使用
class ComplexWidget extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
// 包含多層結構、樣式、邏輯的複雜內容
}
}

2. 使用 <template> 元素提升效能#

const template = document.createElement('template');
template.innerHTML = `
<style>...</style>
<div>...</div>
`;
class MyComponent extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'open'});
// 複製模板,比重複解析 HTML 字串快
shadowRoot.appendChild(template.content.cloneNode(true));
}
}

3. 延遲初始化#

class LazyComponent extends HTMLElement {
connectedCallback() {
// 僅在元素實際插入 DOM 時才初始化
if (!this.shadowRoot) {
this.attachShadow({mode: 'open'});
this.render();
}
}
render() {
this.shadowRoot.innerHTML = `...`;
}
}

六、常見問題與解決方案#

Q1: 如何在 Shadow DOM 內使用全域樣式?#

問題:想在 Shadow DOM 內使用 Tailwind CSS 或 Bootstrap 等全域框架。

解決方案 1:手動引入樣式表

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<link rel="stylesheet" href="/path/to/global.css">
<div class="tw-class">內容</div>
`;

解決方案 2:使用 Constructable Stylesheets (推薦)

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
@import url('/path/to/tailwind.css');
/* 或直接寫樣式 */
.custom { color: red; }
`);
shadowRoot.adoptedStyleSheets = [sheet];

Q2: Shadow DOM 內的表單元素無法參與外部表單提交?#

問題:Shadow DOM 內的 <input> 不會被包含在外部 <form> 的提交中。

解決方案:使用 Form-Associated Custom Elements (Chrome 77+)

class CustomInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this._internals = this.attachInternals();
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<input type="text" id="inner">
`;
const input = shadowRoot.querySelector('#inner');
input.addEventListener('input', (e) => {
// 同步值到表單
this._internals.setFormValue(e.target.value);
});
}
}

Q3: 如何處理事件冒泡?#

問題:Shadow DOM 內的事件會被重定向,外部難以判斷真實來源。

解決方案:使用 event.composedPath()

// Shadow DOM 內觸發事件
const button = shadowRoot.querySelector('button');
button.addEventListener('click', (e) => {
// 讓事件穿透 Shadow 邊界
this.dispatchEvent(new CustomEvent('action', {
bubbles: true,
composed: true, // 關鍵:允許穿越 Shadow 邊界
detail: { originalTarget: e.target }
}));
});
// 外部監聽
document.addEventListener('action', (e) => {
console.log(e.composedPath()); // 完整的事件路徑
console.log(e.detail.originalTarget); // 實際觸發源
});

Q4: 第三方套件無法正常運作?#

問題:某些依賴全域 DOM 查詢的套件 (如 jQuery、某些圖表庫) 無法選取到 Shadow DOM 內的元素。

解決方案

  1. 傳遞 shadowRoot 作為根元素
// 不要用全域 document
// const chart = new Chart(document.querySelector('#canvas'));
// 改用 shadowRoot
const canvas = this.shadowRoot.querySelector('#canvas');
const chart = new Chart(canvas);
  1. 避免在 Shadow DOM 內使用不相容的套件

如果套件高度依賴全域 DOM,考慮將其放在 Light DOM 中:

class MyComponent extends HTMLElement {
connectedCallback() {
// Shadow DOM 用於樣式隔離
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<style>...</style>`;
// 將套件渲染目標放在 Light DOM
this.innerHTML = `<div id="chart-container"></div>`;
// 套件可以正常查詢
new Chart(this.querySelector('#chart-container'));
}
}

總結#

Shadow DOM 是 Web Components 規範的核心技術,提供了強大的 DOM 和 CSS 隔離能力。理解其運作原理對於開發可重用、可維護的元件至關重要。

關鍵要點回顧#

  1. DOM 隔離

    • document.querySelector() 無法穿透 Shadow 邊界
    • 必須透過 element.shadowRoot.querySelector() 存取
    • Open 模式允許外部存取,Closed 模式完全封閉
  2. CSS 隔離

    • Shadow DOM 建立獨立的樣式作用域
    • 外部樣式不會影響內部,內部樣式不會外洩
    • 透過 CSS Variables、::part()、::slotted() 提供可控的樣式介面
  3. 瀏覽器支援

    • 主流現代瀏覽器 (Chrome 53+、Edge 79+、Safari 10.1+、Firefox 63+) 皆支援
    • IE 完全不支援,需要 Polyfill
    • 宣告式 Shadow DOM 僅 Chrome/Edge 支援
  4. 實務建議

    • 僅為需要封裝的複雜元件使用 Shadow DOM
    • 設計清晰的 API (屬性、事件、CSS Variables)
    • 提供足夠的樣式定制點 (::part、CSS Variables)
    • 使用功能檢測進行漸進增強

下一步#


參考資源

MDN Web Components 文檔

Shadow DOM v1: Self-Contained Web Components (web.dev)

宣告式 Shadow DOM (web.dev)

Can I use: Shadow DOM

Shadow DOM 完全指南:DOM 隔離、CSS 封裝與實戰解法
https://laplusda.com/posts/shadow-dom-complete-guide/
作者
Zero
發佈於
2025-10-20
許可協議
CC BY-NC-SA 4.0
這篇文章有幫助嗎?

回報錯字、失效連結,或告訴我你想看的延伸主題。