你有沒有遇過這種狀況:想用 CSS 改某個瀏覽器內建元件的樣式,例如 <input type="date"> 裡面的月曆圖示,結果不管怎麼選都選不到?
或者你曾經打開瀏覽器的開發者工具,看到某個元素旁邊多了一個標示 #shadow-root 的區塊,裡面藏著一堆平常看不到的 HTML?
這些現象背後的原因,都跟同一個東西有關:Shadow DOM。
這篇文章會從最根本的問題——「為什麼需要 Shadow DOM」開始講起,一路講到它實際上怎麼運作。
先搞懂問題:為什麼一般的 DOM 不夠用?
在講 Shadow DOM 之前,我們先看一個很常見的痛點。
CSS 的污染問題
假設你寫了一個 .card 樣式:
.card {
padding: 16px;
border-radius: 8px;
}如果你把這段 CSS 引入專案,理論上它會套用到「所有」帶有 card class 的元素上。
這在小專案裡沒問題,但當專案變大,或是你把某個元件包成套件給別人用,問題就來了:只要用的人也剛好用了 card 這個名字,樣式就會互相污染。
JavaScript 的污染問題
JavaScript 也有類似的狀況。
你在元件內部用 document.querySelector 找元素,理論上可以找到頁面上「任何」符合條件的節點,而不只是你自己元件內的節點。
來看一個具體的例子。假設你寫了一個「刪除按鈕」元件,內部邏輯是這樣:
function setupDeleteButton() {
const button = document.querySelector('.delete-btn');
button.addEventListener('click', () => {
console.log('刪除這張卡片');
});
}
setupDeleteButton();這段程式碼看起來沒問題,寫的人心裡想的是「幫我的元件裡那顆刪除按鈕加上點擊事件」。
但 document.querySelector('.delete-btn') 實際上是對「整個頁面」搜尋,不是只搜尋你的元件內部。
如果頁面上剛好有另一個地方也用了 .delete-btn 這個名字,例如某個列表項目自己的刪除按鈕:
有問題的寫法
<div class="my-component">
<button class="delete-btn">刪除卡片</button>
</div>
<ul>
<li>項目一 <button class="delete-btn">刪除項目</button></li>
</ul>// querySelector 只抓到第一個符合的元素
// 如果 DOM 順序不同,綁到的可能是別人的按鈕,不是你元件裡那顆
const button = document.querySelector('.delete-btn');querySelector 只會回傳「第一個」符合條件的節點,它不知道你的本意是「我元件裡的那顆」,只看 class 名稱對不對。
如果別的地方的 .delete-btn 剛好排在 DOM 更前面,你的元件抓到的就是別人的按鈕,事件綁錯對象,行為完全不是你預期的。
改善後的寫法
function setupDeleteButton(componentRoot) {
// 只在自己元件的範圍內找,不去動整個頁面
const button = componentRoot.querySelector('.delete-btn');
button.addEventListener('click', () => {
console.log('刪除這張卡片');
});
}
const myComponent = document.querySelector('.my-component');
setupDeleteButton(myComponent);這裡把查詢範圍限制在 componentRoot(也就是你元件自己的根節點)底下,querySelector 只會在這個範圍內搜尋,不會誤觸外面同名的元素。
但這個寫法只是「約定俗成」的解法,靠的是寫程式的人自己記得要傳入正確的範圍。
如果團隊裡有人忘記傳、或是不小心又寫了一次 document.querySelector,同樣的撞名問題還是會發生。
因為 JavaScript 本身沒有語言層級的機制,強制你的程式碼只能看到自己元件內部的節點。
小結:問題出在「共用同一個命名空間」
換句話說,一般的 DOM 是「全域」的:一份 CSS、一棵 DOM 樹,大家共用同一個命名空間。
這對於寫獨立、可重複使用的元件來說,是個麻煩——你沒辦法保證你的樣式或程式碼不會被外部影響,也沒辦法保證你不會不小心影響到外部。
Shadow DOM 就是為了解決這個「封裝」問題而生的。
Shadow DOM 要解決的問題
前面提到的問題,說到底就是一句話:CSS 跟 DOM 查詢預設都是全域的。
你的元件沒辦法把自己的樣式跟結構「關起門來」。
不管是 .card 這種 class 名稱,還是 document.querySelector('.delete-btn') 這種查詢,都可能被外部影響,或是不小心影響到外部。
聽起來解法很簡單:把元件的內部整個包起來,跟外部徹底切開就好了。
只是這樣一來,元件會變成一個誰都用不了的黑盒子,失去元件原本該有的用途。
真正該解決的是:元件要能自己決定哪些東西留在內部、哪些東西願意露出去給外部使用。
而不是像現在這樣,內部的每個細節都無差別暴露在同一個全域空間裡,任誰都能選到,也可能被誰不小心蓋掉。
Shadow DOM 要達成的效果就是這個:讓一個元件可以把自己的內部結構「關起來」,外部的 CSS 選擇器、querySelector 都找不到被關起來的內容,元件內部的樣式也不會外洩出去影響別人。
同時,元件仍然留下正式的管道,讓外部可以照元件的規則來互動。
換句話說,前面那個 .card 撞名、.delete-btn 選錯按鈕的問題,只要把內容放進 Shadow DOM 裡面,就不會再發生——因為外部程式碼根本看不到、選不到裡面的東西。
Shadow DOM 實際上留了兩個正式管道給外部使用:一個是 mode,決定要不要留一個正式窗口給外部;另一個是 slot,讓外部可以把內容放進元件內部指定的位置。
這兩個管道實際上怎麼運作,後面章節會再詳細說明,這裡只需要先知道:關起來不代表沒有對外接口,元件還是可以自己決定要開放哪些管道。
Shadow DOM 是什麼:給元素一顆「隱藏的子樹」
那這個「關起來」的效果實際上是怎麼做到的?
Shadow DOM 的定義很直白:它讓你可以在一個 DOM 元素底下,附加一棵獨立的、外部看不到也選不到的 DOM 子樹。
這裡有三個名詞需要先對應起來:
- Shadow host(宿主):掛上 Shadow DOM 的那個外層元素
- Shadow root(影子根):這棵獨立子樹的根節點
- Shadow tree(影子樹):掛在 shadow root 底下的所有內容
白話講,你可以把一個元素想成有兩層:外面看到的「殼」(host),跟藏在殼裡面的「內容」(shadow tree)。
外部的 CSS 選擇器、querySelector,預設都穿不進這層殼,就像這個元素把自己的內部結構包起來,只露出一個接口給外面用。
來看最基本的用法:
// 建立一個普通的 div 當作 host
const host = document.createElement('div');
document.body.appendChild(host);
// 在這個 div 上掛一個 shadow root
const shadowRoot = host.attachShadow({ mode: 'open' });
// 把內容放進 shadow root,而不是放進 host 本身
shadowRoot.innerHTML = `
<p>這段文字活在 Shadow DOM 裡面</p>
`;attachShadow 是關鍵動作,它回傳一個 shadow root 物件。
從這一刻起,host 這個元素就多了一棵外部一般選不到的子樹。
如果你在外層執行 document.querySelector('p'),是找不到這個 <p> 的,因為它被封裝在 shadow tree 裡面。
樣式封裝:CSS 為什麼進不去、也出不來
前面看到的是 querySelector 這種 DOM 查詢被封裝擋住的狀況。
那最開始提到的 .card 撞名這個 CSS 污染問題呢?Shadow DOM 又是怎麼解決的?
關鍵在於:寫在 shadow tree 裡面的 <style>,只對 shadow tree 內部生效;外部頁面的 CSS,預設也不會滲透進 shadow tree。
const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
p {
color: red;
}
</style>
<p>我在 shadow tree 裡面,是紅色的</p>
`;
// 外層頁面同時存在另一個 p
const outsideP = document.createElement('p');
outsideP.textContent = '我在外面,不受影響';
document.body.appendChild(outsideP);這段程式碼裡,shadowRoot 裡的 <style> 只會把 shadow tree 裡的 <p> 變成紅色,完全不會影響到外層那個 outsideP。
反過來說,如果外層頁面的 CSS 也寫了 p { color: blue; },它一樣不會影響到 shadow tree 裡面的 <p>。
這就是為什麼 Shadow DOM 常被拿來解決「元件樣式互相污染」的問題:每個元件把自己的樣式鎖在自己的 shadow tree 裡,不用再靠取名慣例(例如 BEM)去避免撞名。
open 跟 closed:封裝的程度可以調整
Shadow DOM 的封裝不是「全有全無」的,attachShadow 的參數裡有個 mode,決定了封裝的嚴格程度。
open 模式:留一個正式窗口給外部
mode: 'open' 的意思是:一般的 DOM 查詢方法還是找不到 shadow tree 裡面的內容,但瀏覽器另外留了一個正式窗口,讓外部可以主動拿到 shadow root 的參照。
這個窗口就是 host.shadowRoot 這個屬性。
只要 attachShadow 用的是 open 模式,host 這個元素上就會多出 shadowRoot 這個屬性,指向剛剛建立的那個 shadow root,外部可以透過它進一步操作裡面的內容。
來看一個對照,同一個 host,用兩種方式去拿內部的 <p>:
const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `<p>藏在裡面的文字</p>`;// 第一種方式:從外層直接查詢整個頁面
document.querySelector('p'); // null,找不到,因為被封裝了
// 第二種方式:先拿到 shadowRoot 的參照,再從裡面查詢
host.shadowRoot.querySelector('p'); // <p>藏在裡面的文字</p>,拿得到document.querySelector('p') 找不到,因為它查詢的是「一般的 DOM 世界」,而 <p> 被封裝在 shadow tree 裡面,不屬於這個世界。
但 host.shadowRoot 不是一般的查詢,它是 host 這個元素自己身上的屬性,直接指向內部那個 shadow root。
拿到 host.shadowRoot 之後,就等於拿到了進入 shadow tree 的入口,接下來要對裡面的元素做查詢、操作,都可以正常進行——差別只在於,你必須先經過 host.shadowRoot 這一步,不能像操作一般 DOM 那樣直接從最外層一次查到底。
console.log(host.shadowRoot); // 可以拿到,因為是 open 模式closed 模式:更徹底的封裝
mode: 'closed' 則更徹底:host.shadowRoot 會回傳 null,外部完全沒有官方途徑拿到這棵子樹的參照。
這裡有一個容易搞混的細節:attachShadow({ mode: 'closed' }) 這行程式碼本身,還是會正常回傳 shadow root 物件。
打個比方:attachShadow 就像幫你開一個置物櫃,並且當場把鑰匙直接交到你手上。
你手上有這把鑰匙,當然可以正常打開櫃子,操作裡面的東西。
但 closed 模式不會再多留一把鑰匙掛在櫃子外面給別人拿。
所以除了你自己手上這把鑰匙之外,其他程式碼想透過 host.shadowRoot 去問櫃子要鑰匙,只會得到 null。
換句話說,變成 null 的是 host.shadowRoot 這個對外公開的屬性,不是 shadow root 本身消失了。
來看一個對照:
// ------- 元件內部的程式碼 ----const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'closed' });
shadowRoot.innerHTML = `<p>內部內容</p>`;
// 這裡的 shadowRoot 是剛剛建立時拿到的變數,操作起來完全正常
shadowRoot.querySelector('p'); // 拿得到// ------- 另一段外部程式碼,手上沒有 shadowRoot 這個變數 ----// 只能透過 host.shadowRoot 這個公開屬性去問
console.log(host.shadowRoot); // null,拿不到兩段程式碼裡的 host 是同一個元素,差別在於:第一段程式碼手上直接握著 attachShadow 回傳的 shadowRoot 變數。
第二段程式碼沒有這個變數,只能透過 host.shadowRoot 這個公開屬性去問。
而這個屬性在 closed 模式下永遠是 null。
所以 open 跟 closed 的差別,其實是「除了建立者以外的其他程式碼,能不能透過 host.shadowRoot 這個公開屬性拿到參照」,而不是「shadow root 到底存不存在」。
建立元件的那段程式碼,不管選哪個模式,永遠都能透過自己手上的變數正常操作內部的內容。
常見誤解:以為 closed 是安全機制
有問題的認知:以為 closed 是安全機制
const shadowRoot = host.attachShadow({ mode: 'closed' });
shadowRoot.innerHTML = `<input type="password" value="secret">`;closed 只是讓「一般的 DOM API」拿不到參照,並不是真正的資安邊界。
瀏覽器的開發者工具依然可以直接看到 shadow tree 的內容,想隱藏敏感資料不能靠這個機制。
正確的理解:closed 是封裝慣例,不是安全機制
// 大多數情況用 open 就夠了,方便除錯跟第三方整合
const shadowRoot = host.attachShadow({ mode: 'open' });怎麼選:預設用 open 就好
mode 的選擇比較像是在表達「我希不希望外部程式碼把我的內部結構當成公開介面來操作」,而不是在防止資料外洩。
絕大多數的元件函式庫(包含瀏覽器內建元件)都選擇用 open。
slot:讓外部內容「投影」進 shadow tree
Shadow DOM 把內部包起來之後,馬上會遇到一個新問題:如果元件完全封閉,使用這個元件的人要怎麼放入自己的內容?
比如一個 <custom-card> 元件,使用者總會想放自己的文字進去吧。
這時候要用的機制叫 slot。
定義:slot 是 shadow tree 裡預留的一個「插槽」,host 元素底下(light DOM,也就是一般外部 DOM)的子節點,會被投影顯示到這個插槽的位置。
const host = document.createElement('div');
host.innerHTML = `<p>這是使用者放進來的內容</p>`; // 這是 light DOM
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
.wrapper { border: 1px solid gray; padding: 8px; }
</style>
<div class="wrapper">
<slot></slot>
</div>
`;執行結果是:host 底下原本那個 <p>這是使用者放進來的內容</p>,會被顯示在 <slot> 標記的位置,套上 .wrapper 的框線跟間距。
拆解一下這中間發生的事:host.innerHTML 那一行,把 <p> 塞進 host 底下,這是一般的 light DOM 操作,跟 Shadow DOM 完全沒關係。
接著 attachShadow 掛上 shadow root,shadowRoot.innerHTML 裡面寫了一個 .wrapper 跟一個空的 <slot></slot>——注意這個 <slot> 裡面本身沒有放任何內容。
瀏覽器渲染畫面的時候,看到 shadow tree 裡有一個沒有指定 name 的 <slot>,就會去找 host 底下「沒有指定要放進哪個具名插槽」的子節點,把它們顯示在這個 <slot> 的位置——這裡符合條件的就是那個 <p>。
但這個「顯示在哪個位置」只是畫面上呈現的結果,不是 DOM 樹結構真的變動了。
<p> 這個節點本身,自始至終都還是 host 的子節點,屬於 light DOM,從來沒有被搬進 shadow tree 裡面過。
slot 也可以用 name 屬性做多個插槽,分配不同內容放到不同位置:
host.innerHTML = `
<span slot="title">標題內容</span>
<span slot="body">內文內容</span>
`;
shadowRoot.innerHTML = `
<div class="header"><slot name="title"></slot></div>
<div class="content"><slot name="body"></slot></div>
`;帶 slot="title" 的節點,會對應投影到 <slot name="title"> 的位置,slot="body" 同理。
這讓元件作者可以設計好內部版面,同時保留彈性讓使用者塞進自己的內容。
實務應用:跟 Custom Elements 搭配使用
Shadow DOM 很少單獨出現,它通常是 Web Components 這套技術裡的一環,搭配 Custom Elements(自訂標籤)一起用,才會發揮完整效果。
Custom Elements 是瀏覽器提供的另一個機制,讓你可以自己定義一個新的 HTML 標籤,例如 <user-card>,並且規定這個標籤出現在頁面上的時候該做什麼事。
定義一個 Custom Element,基本上要做兩件事:寫一個繼承自 HTMLElement 的 class,再用 customElements.define 把這個 class 註冊成一個標籤名稱。
以下面這段程式碼為例:
class UserCard extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
.card { border: 1px solid #ddd; padding: 12px; border-radius: 6px; }
</style>
<div class="card">
<slot name="name"></slot>
</div>
`;
}
}
customElements.define('user-card', UserCard);你可以把這三行想成是在教瀏覽器認識一個新元件,分三個步驟:
第一步,class UserCard extends HTMLElement:先寫一份「設計圖」,規定這個新元件長什麼樣子、有什麼行為,而且這個設計圖是照著瀏覽器原生元素的規格寫的,所以裡面才能正常呼叫 this.attachShadow。
第二步,connectedCallback:在設計圖裡留一個欄位,寫著「這個元件真正被放到頁面上的那一刻,要做什麼事」。你不用自己想辦法偵測「元件什麼時候出現在畫面上」,時機到了,瀏覽器會自動幫你執行這段程式碼。
第三步,customElements.define('user-card', UserCard):把這份設計圖正式交給瀏覽器,並且告訴它「以後只要看到 <user-card> 這個標籤,就照這份設計圖來處理」。
<user-card>
<span slot="name">Alice</span>
</user-card>這段 HTML 其實跟前面 slot 章節裡 host.innerHTML = '<span slot="title">...</span>' 是同一件事,只是寫法不同。
前面是用 JavaScript 把內容塞進 host 底下,這裡則是直接把 <user-card> 當成一個普通標籤寫在 HTML 裡。
<span slot="name">Alice</span> 就是它的 light DOM 子節點。
瀏覽器看到頁面上有 <user-card> 這個標籤,就會照 UserCard 這個 class 的規則去處理它。
而 connectedCallback 裡建立的 <slot name="name">,就會把這裡的 Alice 投影顯示到卡片裡指定的位置。
這樣一來,<user-card> 就變成一個可以到處重複使用的元件:它的樣式不會外洩,也不會被外部樣式干擾,而使用者只需要透過 slot 傳入內容,不需要知道內部結構長怎樣。
什麼時候該考慮用它?
如果你在做的是「共用元件庫」,或是需要嵌入到別人網站、不能控制對方 CSS 環境的元件(例如客服外掛、廣告元件),Shadow DOM 提供的樣式隔離會很有價值。
但如果只是團隊內部專案,用 CSS Modules、BEM 命名規則,或框架本身的 scoped style(例如 Vue 的 <style scoped>)通常就夠了,不一定需要動用 Shadow DOM 這麼底層的機制。
重點整理
- Shadow DOM 讓一個元素(host)底下可以掛一棵外部一般選不到的獨立子樹(shadow tree)
attachShadow({ mode })是建立 shadow root 的方法,mode只有open和closed兩種closed只是讓host.shadowRoot拿不到參照,不是資安機制,DevTools 依然看得到內容- shadow tree 裡的 CSS 只在內部生效,外部 CSS 也進不去,這解決了元件樣式互相污染的問題
slot讓外部(light DOM)的內容可以投影顯示到 shadow tree 預留的位置,原始節點本身不會被搬動slot="name"可以搭配<slot name="name">做多插槽分配- Shadow DOM 通常跟 Custom Elements 搭配,組成完整的 Web Components
- 團隊內部專案通常靠命名規則或框架的 scoped style 就夠了,真正需要跨環境隔離時才需要 Shadow DOM