1362 字
7 分鐘

CSS :has() 該取代哪些 JavaScript?我會用與不會用的界線

驗證於

本文目錄

監聽 radio 的 change 事件,再替外層卡片加上 is-selected,這種程式碼在 :has() 普及後幾乎可以全部刪掉。問題是刪到哪裡該停。

我的界線是:只有外觀跟著 DOM 狀態變的,交給 :has();狀態一旦會影響行為、輔助技術讀到的內容,或表單送出的資料,就留在 JavaScript。 下面用實際例子說明這條線怎麼畫。

:has() 從 2023 年 12 月起在各主要瀏覽器都能用,MDN 標為 Baseline 廣泛可用(MDN:())。一般專案現在可以把它當成普通選擇器。

直接改用 () 的三種情況#

外層依內容切換版型#

卡片有圖片時排成兩欄,沒有圖片時單欄。以前多半在模板裡判斷有沒有圖,再輸出不同 class;改成 CSS 後,模板就不用和樣式同步維護同一個條件。

.card {
display: block;
}
.card:has(> img) {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
}

> img 只看直接子元素。卡片內文如果夾了圖片,不會被誤判成「有封面」。

選項勾選後,外層卡片跟著變#

<form>
<label class="plan"><input type="radio" name="plan" value="basic"> 基本方案</label>
<label class="plan"><input type="radio" name="plan" value="pro"> 進階方案</label>
</form>
.plan {
border: 1px solid #c8c8c8;
}
.plan:has(input:checked) {
border-color: #0064ff;
}

這是我最推薦的用法。勾選狀態本來就由瀏覽器管理,CSS 只是讀它。用 JavaScript 加 class 等於多養一份要同步的狀態,而且容易漏掉某些路徑:實測呼叫 form.reset() 後,radio 回到未勾選,卻沒有觸發 change 事件。靠 change 維護的 class 會停在舊狀態,:has() 的框線則會跟著恢復。

依後方元素調整間距#

h2 {
margin-bottom: 20px;
}
h2:has(+ .subtitle) {
margin-bottom: 4px;
}

標題後面緊接副標時縮小間距。Markdown 或 CMS 產出的內容特別適合這樣寫,因為通常改不到 HTML 結構,只能從選擇器下手。

看似能做,我仍交給 JavaScript 的情況#

用樣式假裝停用的按鈕#

「沒勾同意條款,送出按鈕就變淡」很容易寫成這樣:

.form:has(#agree:not(:checked)) .submit {
opacity: 0.5;
pointer-events: none;
}

畫面看起來停用了,按鈕本身並沒有。實測時這顆按鈕的 disabled 仍是 false,程式呼叫 click() 或 form.requestSubmit() 都會照樣送出表單。pointer-events 只管滑鼠、觸控這類指標操作,鍵盤使用者 Tab 到按鈕一樣按得下去,螢幕報讀軟體也會把它當成可用的按鈕。

真的要擋,就讓勾選框加上 required 交給表單驗證,或用 JavaScript 切換 disabled。:has() 可以留著做額外的視覺提示,但它擋不住送出。

需要回報展開狀態的元件#

用隱藏 checkbox 搭配 :has(:checked) 做下拉選單,滑鼠點起來沒問題,輔助技術讀到的卻是一個勾選框,而不是一顆會展開選單的按鈕。這類元件我會用 <details>/<summary>、Popover API,或在 JavaScript 裡維護 aria-expanded。

錨點範圍太大的條件#

開啟對話框時鎖住頁面捲動,常見寫法是 body:has(dialog[open])。MDN 的效能說明特別提到,錨點選 body、:root 或 * 時,整個子樹任何 DOM 變動都可能讓瀏覽器重新檢查條件,建議改用範圍小的容器,並在 :has() 裡用 >、+ 限縮查找範圍。

我沒有量測過這個成本在一般頁面有多大。但對話框的開關本來就有 JavaScript 處理,在同一段程式裡切換一個 class 幾乎不增加維護負擔,所以聊天室、無限捲動這類 DOM 變動頻繁的頁面,我會選擇那條路。

撰寫時會撞到的限制#

:has() 不能放進另一個 :has(),裡面也不能放 ::before 這類偽元素。比較容易踩到的是錯誤的影響範圍:選擇器清單只要有一項無效,整條規則都會被丟掉。

/* .list-a 也會一起失效,因為整條規則被瀏覽器忽略 */
.list-a,
.list-b:has(:has(p)) {
color: green;
}

實測時 .list-a 拿不到這個顏色。新寫的 :has() 規則沒有生效時,先把它拆成獨立規則確認是不是語法問題。

在 JavaScript 裡也能用同樣的選擇器,例如 document.querySelectorAll('.card:has(> img)')。巢狀 :has() 或偽元素丟進 querySelector() 會直接拋出 SyntaxError,比 CSS 默默忽略容易察覺。

如果專案仍要支援 2023 年底以前的瀏覽器,可以用 @supports selector(:has(*)) 包住新寫法,讓舊瀏覽器沿用原本的 class,細節可參考 @supports 的條件寫法。我會先看流量分析裡的瀏覽器版本,再決定要不要多寫這一層。

我的判斷順序#

遇到「依子元素改樣式」的需求,我會依序問三個問題:

  1. 這個狀態是不是瀏覽器本來就知道的,例如 :checked、:focus-within、元素存不存在?是的話,用 :has() 讀它,比自己同步一份 class 可靠。
  2. 拿掉這條 CSS 之後,功能還正確嗎?如果答案是否定的,例如按鈕會變成可以送出,那它就是行為,應該由 HTML 屬性或 JavaScript 負責。
  3. 錨點是不是頁面上很大的容器?是的話,先縮小範圍,縮不了就改回 class。

本文範例在 2026-11-14 以 macOS 上的 Chrome 157(headless)執行,透過 getComputedStyle() 確認版型切換、勾選框線、form.reset() 後的恢復、間距、巢狀與無效清單的行為,也確認淡化按鈕仍會送出表單。其他瀏覽器沒有逐一測試。

參考資料:

MDN:()

MDN:pointer-events

Selectors Level 4:Relational pseudo-class

CSS :has() 該取代哪些 JavaScript?我會用與不會用的界線
https://laplusda.com/posts/css-has-selector-replace-javascript/
作者
Zero
發佈於
2026-11-14
許可協議
CC BY-NC-SA 4.0