假設你正在維護一個文件網站。文章卡片需要寬鬆的內距,版本提示要緊湊一點,但兩者都應遵守同一套間距單位。你寫下兩次 calc(),分別乘上三與二,需求就完成了。
後來,側欄也加入卡片,而且側欄採用較小的間距單位。計算仍然簡單,真正容易散掉的是約定:哪些地方使用同一套倍率?單位從哪裡來?修改一個元件時,是否會連帶改變其他元件?
CSS @function 提供了替這段計算命名的方法。當不同元件共享計算規則,輸入卻各自不同,把規則與輸入分開,就能讓修改的責任更清楚。 但如果只是替一次乘法多取一個名字,也可能讓讀者多跳一層才看懂樣式。
先看一般 CSS 已經能做什麼
沿用這個假設中的文件網站,兩個元件的 HTML 如下。後續範例使用同一組 class:
<article class="site-card">
<h2>安裝指南</h2>
<p>從專案的第一個頁面開始。</p>
</article>
<aside class="site-notice">
<h2>版本提醒</h2>
<p>升級前請先閱讀變更紀錄。</p>
</aside>先用自訂屬性提供共用單位,直接在各元件計算:
:root {
--space-unit: 0.5rem;
}
.site-card {
padding: calc(var(--space-unit) * 3);
}
.site-notice {
padding: calc(var(--space-unit) * 2);
}根字級為 16px 時,單位是 8px,卡片內距是 24px,提示內距是 16px。單位改變,兩者一起更新;倍率仍留在各自的規則裡。
這份 CSS 已經足夠清楚。自訂屬性負責提供值,calc() 負責運算,並沒有因為出現兩次乘法就需要重構。開始考慮函式的理由,應該是這套計算已經形成需要共同維護的規則,而不只是程式碼看起來相似。
用 @function 替計算命名
以下是可以取代上一段的完整 CSS。先以明確的引數傳入倍率與單位,讓呼叫位置看得出依賴:
:root {
--space-unit: 0.5rem;
}
@function --space(--steps <number>: 1, --unit <length>: 0.5rem)
returns <length> {
result: calc(var(--steps) * var(--unit));
}
.site-card {
padding: --space(3, var(--space-unit));
}
.site-notice {
padding: --space(2, var(--space-unit));
}函式名稱與參數都以 -- 開頭。定義寫在頂層,使用時在屬性值裡直接寫 --space(...);不是 var(--space(...)),也不需要 @apply。var(--steps) 與 var(--unit) 讀取函式參數,result 描述產出的值。草案的函式定義列出了這些組成。
這個介面接受數字倍率與長度單位,並要求結果為長度。--space() 使用兩個預設值,得到 0.5rem;--space(3) 得到 1.5rem;--space(3, 6px) 得到 18px。<length> 不包含百分比,型別選擇應反映函式真正承諾的能力。
型別與預設值都可以省略,但這裡保留它們,是為了讓使用者知道傳什麼。預設單位是函式自己的 0.5rem;若希望它跟隨 --space-unit,就像卡片範例一樣明確傳入。日後把根元素的 token 改成 0.75rem,也不會神奇地改寫函式宣告中的預設值。
自訂屬性在哪裡求值,會影響結果
容易誤會的地方是:把計算放進根元素的自訂屬性,不等於保存了一個會在每個子元素重新讀取 token 的函式。
將下面這段 CSS 獨立搭配前面的 HTML,先觀察卡片:
:root {
--space-unit: 0.5rem;
--card-space: calc(var(--space-unit) * 3);
}
.site-card {
--space-unit: 0.25rem;
padding: var(--card-space);
}根字級為 16px 時,卡片內距仍是 24px。--card-space 在根元素解析其中的 var(--space-unit),再被子元素繼承;卡片上的 0.25rem 不會倒回去重新解析根元素的這段變數引用。這是自訂屬性的求值與繼承規則,不是變數失去反應能力。
不使用函式也能修正:把 calc(var(--space-unit) * 3) 放在卡片本身即可。函式的價值,是當很多地方都需要這套規則時,仍能集中定義。
下面示範另一種介面:倍率是明確輸入,單位則從呼叫的元素讀取。這個函式與前面的 --space() 刻意使用不同名稱,避免把兩種依賴方式混在一起。
:root {
--space-unit: 0.5rem;
}
@function --local-space(--steps <number>: 1) returns <length> {
result: calc(var(--space-unit, 0.5rem) * var(--steps));
}
.site-card {
--space-unit: 0.25rem;
padding: --local-space(3);
}
.site-notice {
padding: --local-space(2);
}現在卡片內距是 12px,提示仍是 16px。函式內沒有名為 --space-unit 的參數或區域變數,因此會讀取使用元素上的自訂屬性;參數、區域變數與外部屬性的關係見草案的作用域規則。
| 寫法 | 卡片的單位取自哪裡 | 本例卡片內距 |
|---|---|---|
繼承根元素的 --card-space | 根元素解析變數時的 0.5rem | 24px |
--space(3, var(--space-unit)),卡片單位設為 0.25rem | 呼叫處明確傳入 | 12px |
--local-space(3) | 函式讀取卡片上的 token | 12px |
明確傳參比較容易追查與測試;讀取外部 token 則讓呼叫更短,但增加了隱含依賴。若 --local-space() 是設計系統的一部分,就需要把它會讀取 --space-unit 寫進使用約定。同名參數或區域變數也會遮蔽外部值,命名時應避免讓它們意外撞名。
result 是宣告,條件不會提前返回
現在增加一項假設需求:寬度不超過 40rem 時,兩個元件的間距都縮為原本的四分之三。這是文件網站的設計選擇,不是通用的手機分類。
以下完整 CSS 用區域變數保存倍率,條件成立時覆寫它,再由同一份公式產出值:
:root {
--space-unit: 0.5rem;
}
@function --responsive-space(--steps <number>: 1) returns <length> {
--density: 1;
@media (width <= 40rem) {
--density: 0.75;
}
result: calc(var(--space-unit, 0.5rem) * var(--steps) * var(--density));
}
.site-card {
padding: --responsive-space(3);
}
.site-notice {
padding: --responsive-space(2);
}根字級與預設字級皆為 16px、viewport 為 800px 時,結果是 24px 與 16px;viewport 改成 600px,結果是 18px 與 12px。--density 留在函式內,不會因此成為元素上可供其他 CSS 讀取的 token。
這裡仍然遵循宣告式模型。result 並不是 JavaScript 的 return:以下函式即使符合窄版條件,結果也一直是 1rem。
@function --always-one() returns <length> {
@media (width <= 40rem) {
result: 0.75rem;
}
result: 1rem;
}符合條件的宣告參與結果選擇,後面那個 result 仍然覆蓋前面的值,不會在第一個結果處停止執行。條件與執行模型解釋了這項差異。把基準值放前面、條件覆寫放後面,或者像上一例集中產出結果,都比較容易閱讀。
不過,若卡片與提示不該共用同一個窄版條件,就應將各自的媒體查詢留在元件規則裡。替條件取共同名稱,也是在建立未來一起修改的約定。
型別檢查不代表會退回上一行
先用沒有預設倍率的函式觀察失效:
@function --strict-space(--steps <number>) returns <length> {
result: calc(var(--steps) * 0.5rem);
}
.site-card {
padding: 24px;
padding: --strict-space(red);
}在支援自訂函式的瀏覽器中,red 不符合 <number>,這個呼叫會在計算值階段失效。此時 padding 得到初始值 0,不會回頭選取上面的 24px。省略必要引數而呼叫 --strict-space() 也沒有可用倍率。一般屬性的失效結果要依其繼承行為判斷,不能把本例的 0 套到所有屬性。計算值階段失效說明了為什麼 cascade 不會重新執行。
還要區分「沒有預設值」與「有預設值」。前面的 --space() 為倍率提供了 1,因此依引數解析規則,傳入不符合型別的倍率會嘗試使用預設值:--space(red) 的結果是 0.5rem,而不是整段必然失效。這也表示預設值可能掩蓋呼叫錯誤,開發時仍應檢查結果。
型別只約束值的種類,不等於完整的設計限制。負數也屬於 <number>;函式算出的負長度可以用在 margin,卻不適合 padding。介面的有效範圍與最後使用的屬性,都要一起考慮。
選擇重用方式,要看共同維護的是什麼
這幾種能力並不互斥。對本例而言,我會先保留 token 與直接計算;當間距規則需要在更多元件中共同調整,才抽出函式。
| 想共同維護的內容 | 適合先考慮 | 需要注意 |
|---|---|---|
| 間距、顏色等可覆寫的值 | 自訂屬性 | 宣告位置、繼承與依賴 |
| 一次運算或限制範圍 | calc()、min()、max()、clamp() | 直接寫通常最容易讀 |
| 接受不同輸入的共用計算 | 原生 @function | 參數、使用位置、瀏覽器支援 |
| 建置時的資料處理與產生 CSS | Sass function | 編譯工具與輸入資料 |
| 一組相同樣式宣告 | 共用 class 或選擇器分組 | HTML 與樣式的組織 |
| 帶參數的樣式區塊 | 原生 @mixin 草案 | 草案成熟度及區塊責任 |
Sass function由編譯器執行;原生 CSS 函式參與瀏覽器的樣式求值。Sass 仍可輸出包含 var() 或 calc() 的 CSS,讓瀏覽器繼續處理,因此不能簡化成「Sass 都是固定值、原生才會動」。
@function 產出的是值,不能用來插入一整組 border、padding 與巢狀選擇器。需要理解區塊層級的重用,可以接著閱讀本站的原生 CSS @mixin 導讀。兩者雖在同一份規格裡,實作成熟度不能混為一談。
目前採用,先保留可用的基準樣式
截至查證日,相容性原始資料記錄如下。這是功能支援狀態,不是使用率,也不是本站訪客分布。
| 瀏覽器 | @function 支援狀態 |
|---|---|
| Chrome/Chrome Android | 139 起 |
| Edge | 139 起 |
| Firefox | 尚未支援 |
| Safari/iOS Safari | 資料標示 preview,不能視為穩定版已普及 |
對需要跨瀏覽器閱讀的網站,可以保留原本能運作的 calc(),用 @supports at-rule(@function)隔開增強版本。下面是可獨立搭配開場 HTML 的完整 CSS:
:root {
--space-unit: 0.5rem;
}
.site-card {
padding: calc(var(--space-unit) * 3);
}
.site-notice {
padding: calc(var(--space-unit) * 2);
}
@supports at-rule(@function) {
@function --space(--steps <number>: 1, --unit <length>: 0.5rem)
returns <length> {
result: calc(var(--steps) * var(--unit));
}
.site-card {
padding: --space(3, var(--space-unit));
}
.site-notice {
padding: --space(2, var(--space-unit));
}
}這是保守的增強方式。at-rule() 查詢本身也需要支援;即使瀏覽器能執行函式,若不認得這個查詢,仍會留在基準版本。這裡兩條路得到相同的外觀,代價是暫時維護兩份計算,因此值得先評估導入收益。
也不要用 @supports (padding: --space(3)) 判斷函式是否存在且能正確執行。宣告能被解析,不代表某個呼叫一定會成功;本次實測中,即使換成未定義的函式名稱,支援自訂函式的引擎仍回報 true。at-rule() 也只檢查規則能力,不替你檢查每個引數。
本文範例已以本機 Chromium 153.0.8010.12 與 Playwright WebKit 26.6 驗證計算結果,並在 Chromium 關閉 CSSFunctions 後確認基準樣式。這不是 Safari 穩定版或 Firefox 的實機驗證;Firefox 測試程式在本機啟動失敗,支援表依前述來源列示。
回到卡片與提示區塊,值得集中的是已經形成共識的計算規則。函式名稱應能讓維護者看懂這份共識,參數則讓差異留在使用的位置。如果抽完之後必須追查更多隱藏的 token,才能知道一個 padding 為何如此,繼續直接寫那行 calc(),可能仍是更好的選擇。
