假設你正在維護一個文件網站。首頁的文章卡片有細邊框、圓角與留白;旁邊的版本提示,也有幾乎一樣的外框。你把那幾行 CSS 複製過去,改了邊框顏色,兩個區塊便各自成立。

幾週後,設計調整了整站的容器間距。卡片改好了,提示區塊卻漏了一處。這時很容易想:如果當初抽成 mixin,就不會忘記了。

但也可能出現另一種情況:提示區塊需要更緊湊,卡片卻應該保留舒適的閱讀空間。若兩者已經綁在同一份抽象裡,一次修改反而會波及不該改的地方。

重複的程式碼提供了一條線索,接下來仍要判斷:這些樣式是否代表同一個規則,未來是否應該一起改變? 原生 CSS @mixin 值得關注,正是因為它讓我們有機會替一組樣式規則命名,並在使用的地方傳入差異。

先找出需要一起變動的規則

沿用這個假設中的文件網站,先把需求說得具體一點:文章卡片與版本提示都需要一層安靜的外框,圓角一致;邊框與標題使用相同的強調色,提示可以有比較緊湊的間距。

兩種容器承載的內容不同,卻共享一部分視覺規則。問題不只是如何消除那幾行重複,而是哪些決定應該有共同來源,哪些應該留在各自的元件裡。

例如,圓角代表整站的外觀約定,可以集中管理;內距則可能與內容密度有關。至於卡片要不要顯示摘要、提示是否需要操作連結,都是各自的責任,沒有必要為了共用外框一起收進來。

這個區分會決定抽象的大小。如果連「共用的是什麼」都還說不清楚,先保留幾行重複通常比較容易調整。等使用情境出現,才有依據把真正穩定的部分收在一起。

自訂屬性與共用 class,已經能走多遠?

先不用 mixin。以下 HTML 是後續範例共同的結構:

<article class="site-card">
  <h2 class="site-panel-title">安裝指南</h2>
  <p>從專案的第一個頁面開始。</p>
</article>
 
<aside class="site-notice">
  <h2 class="site-panel-title">版本提醒</h2>
  <p>升級前請先閱讀變更紀錄。</p>
</aside>

只靠選擇器分組與自訂屬性,就能把外框集中起來。這段使用的是既有 CSS:

:root {
  --panel-radius: 0.5rem;
}
 
.site-card,
.site-notice {
  border: 1px solid var(--panel-accent, #777777);
  border-radius: var(--panel-radius);
  padding: var(--panel-space, 1.5rem);
}
 
.site-card > .site-panel-title,
.site-notice > .site-panel-title {
  margin-block: 0 0.75rem;
  color: var(--panel-accent, #777777);
}
 
.site-notice {
  --panel-accent: #45687d;
  --panel-space: 1rem;
}

這裡有兩種重用:分組的選擇器讓不同元素共用宣告,自訂屬性則提供可替換的值。var() 的工作是在屬性值裡帶入變數;它本身不會把一整組 border、padding 與巢狀規則插進另一條規則。自訂屬性規格定義了這個替換機制。

如果能調整 HTML,也可以把共同規則改放在 .site-panel,分別使用 class="site-panel site-card" 與 class="site-panel site-notice"。共用 class 表達外框,另一個 class 表達元件自己的角色;自訂屬性繼續控制顏色與內距。

對這個只有兩種容器的小網站,我會先選這種做法。宣告集中、差異看得見,也沒有額外的呼叫關係需要追查。日後增加第三種容器,如果它確實共享這層外框,加入同一個 class 就很清楚。

當共用規則要在多個元件樣式裡組合、各處需要明確傳入不同設定,或不方便替不同來源的 HTML 都加上共同 class 時,mixin 才提供另一種組織方式。它讓套用的位置留在 CSS 中,透過名稱表達元件選用了哪一組規則。

想重用的內容可以先考慮需要維護的關係
顏色、間距、圓角等值自訂屬性變數的來源、繼承與覆寫
一組完全相同的宣告共用 class 或選擇器分組哪些元素應該共同接受修改
帶參數、可在不同規則中組合的樣式區塊原生 mixin 草案呼叫位置、參數與區塊的責任

三者可以搭配使用。mixin 裡仍然可以引用設計 token,共用 class 也可以是 mixin 的套用位置。選擇的重點是讓變更的責任更清楚。

用 @mixin 定義規則,在 @apply 的位置套用

把外框改寫成 mixin,最小形式如下。這段與後續的 mixin 範例都屬於草案語法:

/* 草案語法:定義與套用 */
@mixin --panel-frame() {
  border: 1px solid #777777;
  border-radius: 0.5rem;
  padding: 1.5rem;
}
 
.site-card {
  @apply --panel-frame();
}
 
.site-notice {
  @apply --panel-frame();
}

@mixin 定義可重用的區塊,名稱以 -- 開頭;@apply 則指定套用的位置。依草案的定義與套用規則,mixin 定義不能放在一般樣式規則內,@apply 則用在樣式規則或其中的巢狀群組規則內。上例因此將定義放在頂層,把呼叫放進各自的選擇器。

可以先把它理解成:在呼叫位置引入那組宣告與規則。不過,這只是幫助閱讀的模型;涉及參數、變數與作用範圍時,不能把它當成任意文字的複製貼上。

沒有參數時,目前草案允許省略括號,例如 @apply --panel-frame;。本文保留括號,方便辨識呼叫,也讓下一步增加參數時保持一致。這不是把 class 名稱傳給 @apply;這裡引用的是定義好的 mixin 名稱。

接著,把強調色與內距列為可調整的部分。下例是取代上一版定義的完整 CSS,繼續搭配前面的 HTML:

/* 草案語法:參數、預設值與巢狀規則 */
@mixin --panel-frame(
  --accent <color>: #777777,
  --space <length>: 1.5rem
) {
  border: 1px solid var(--accent);
  border-radius: 0.5rem;
  padding: var(--space);
 
  & > .site-panel-title {
    margin-block: 0 0.75rem;
    color: var(--accent);
  }
}
 
.site-card {
  @apply --panel-frame();
}
 
.site-notice {
  @apply --panel-frame(#45687d, 1rem);
}

--accent 接受顏色,--space 接受長度;冒號後面是省略引數時使用的預設值。卡片採用預設設定,提示則傳入藍灰色與較小的內距。型別與預設值的寫法沿用草案的參數語法,例如 1.5rem 符合 <length>,百分比則不屬於這個型別。

這兩個參數刻意保持少量,因為它們對應前面已經確認的差異。圓角仍是共用規則,標題仍跟隨外框的強調色;讀呼叫位置,就能看出這個元件選了哪種變化。

參數雖然透過 var() 讀取,卻不等於在元素上新增一般、公開的自訂屬性。依目前的參數模型,同名參數會遮蔽外部值;它們的私有作用範圍也限制了哪些元素能取得值。上例只處理容器及其子元素;如果改成選取兄弟元素,就不能直接假設參數仍可用。

巢狀的 & > .site-panel-title 讓「外框與標題共用強調色」成為同一份規則。這也建立了一項結構約定:標題必須是容器的直接子元素。若某些元件沒有這個結構,就應該拆開標題與外框,而不是持續擴充選擇器來包容所有情況。

用 @contents 共用條件,把內容留給呼叫者

現在加入另一項需求:畫面變窄時,卡片與提示都要調整,但調整的內容不一樣。卡片需要更緊密的排列,提示則要縮小內距、加強側邊框。

如果這是整個頁面共同採用的窄版條件,可以把條件命名,透過 @contents 留出放入樣式的位置。以下草案範例接在上一節的 CSS 後面:

/* 草案語法:共用條件,各自提供內容 */
@mixin --compact-layout() {
  @media (width <= 40rem) {
    @contents;
  }
}
 
.site-card {
  @apply --compact-layout() {
    display: grid;
    gap: 0.75rem;
    padding: 1rem;
  }
}
 
.site-notice {
  @apply --compact-layout() {
    padding: 0.75rem;
    border-inline-start-width: 3px;
  }
}

@apply 後面的區塊是呼叫者交進來的內容,@contents 是放入該內容的位置。草案的 @contents 定義允許 mixin 接受這種區塊,因此共用的是「何時進入窄版」,而各自如何改變,仍留在卡片與提示的樣式裡。

40rem 是這個假設案例選定的版面條件,不是通用的裝置分類。若卡片與提示應該依不同容器的可用空間調整,硬共用同一個 viewport 門檻就會失去意義。此時應重新檢查條件,而不是因為已經有 mixin 就繼續使用。

@contents 也可以提供預設區塊:只有呼叫者完全沒傳內容時才使用;傳入空區塊則表示有傳內容,只是內容為空。本文的 --compact-layout() 沒有預設內容,所以只呼叫它、卻不提供區塊,不會替元件增加任何宣告。

對目前這兩個元件,直接寫一個 @media 並在裡面列出兩條規則,依然很容易理解。條件 mixin 的價值,要在多處確實共享同一個版面判斷、又希望樣式留在各自元件附近時才會出現。多一個名稱,應該換來更清楚的意圖。

抽象的成本,會出現在修改的那一天

假設接下來又增加促銷卡片、錯誤提示與側欄摘要。你可能想替 --panel-frame() 加上邊框粗細、背景、圖示位置、標題大小,再補幾個開關控制特殊情況。

參數越來越多時,值得重新檢查的是它們的關係。如果多數呼叫都要覆寫一半設定,或只有某個元件使用一組特殊參數,原本的「共用外框」可能已經變成數個需求的集合。此時,把規則拆小,甚至讓某些元件保留自己的宣告,都可能更好維護。

我會用三個問題檢查這個界線:

  • 修改會一起發生嗎? 圓角調整應該影響所有容器,還是只影響某一種內容?
  • 呼叫位置能說明意圖嗎? 顏色與內距容易理解,一長串位置引數則需要反覆查定義。
  • 例外放在哪裡最清楚? 只屬於提示區塊的側邊框,可以留在提示的規則中,不必升格為全站共用的選項。

抽成 mixin 後,維護者至少需要連起呼叫位置、定義與傳入的值。若 mixin 裡又呼叫其他 mixin,追查路徑還會變長。集中修改帶來的便利,必須和這個閱讀成本一起考慮。

混入的宣告也仍然需要與其他樣式共同運作;套用了 mixin,不代表原本的 cascade 問題就消失。例如在這個簡單案例中,可以把元件自己的圓角宣告放在 @apply 後面,表達局部差異;若另一個來源又設定同一屬性,仍要查看來源、layer、選擇器權重與順序。抽象不應讓實際生效的值更難解釋。

更重要的是,class 與 mixin 都會建立共同修改的關係。兩處今天長得相似,並不足以證明它們永遠應該同步。先替規則說出一個合理的名字,再選擇實作工具,往往比先追求最少行數可靠。

現在值得學的是哪一部分?

如果你熟悉 Sass,這個概念不陌生。不過,Sass 的 mixin使用 @include 套用、以 $ 命名參數,由 Sass 處理後輸出 CSS;本文的原生提案使用 @apply、-- 名稱與 CSS 的值系統。理解用途上的相似,不能直接推導出語法、作用範圍或遷移方式相同。

同一份 CSS 草案中的 @function則處理值:例如依輸入計算一個長度,供 padding 等屬性使用。@mixin 處理的是樣式區塊,可以包含多項宣告與巢狀規則。要重用的東西停在哪一層,會影響應該選擇哪一種能力。

草案本身也仍在整理。本文依據第 5.1 至 5.4 節的定義撰寫範例;第 5.5 節仍有待配合新私有屬性模型更新的註記,並保留舊 @result 寫法。因此,這篇文章沒有把內部替換步驟當成已定案的行為,也沒有把語法解說當作相容性保證。

回到一開始的文件網站,我仍會先採用共用 class 或選擇器分組,讓自訂屬性承擔明確的值差異。等規則真的需要跨元件組合、需要一個清楚的參數介面,再評估 mixin 的表達是否值得。

原生 @mixin 提案提供了一個值得理解的方向:CSS 的重用可以從單一值,延伸到有名字、可調整的一組規則。要讓這個能力帶來好處,我們仍得先回答那個最初的問題:下一次修改時,究竟有哪些樣式應該一起改變?