假設你正在維護一個文件網站。文章卡片需要寬鬆的內距,版本提示要緊湊一點,但兩者都應遵守同一套間距單位。你寫下兩次 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.5rem24px
--space(3, var(--space-unit)),卡片單位設為 0.25rem呼叫處明確傳入12px
--local-space(3)函式讀取卡片上的 token12px

明確傳參比較容易追查與測試;讀取外部 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參數、使用位置、瀏覽器支援
建置時的資料處理與產生 CSSSass function編譯工具與輸入資料
一組相同樣式宣告共用 class 或選擇器分組HTML 與樣式的組織
帶參數的樣式區塊原生 @mixin 草案草案成熟度及區塊責任

Sass function由編譯器執行;原生 CSS 函式參與瀏覽器的樣式求值。Sass 仍可輸出包含 var() 或 calc() 的 CSS,讓瀏覽器繼續處理,因此不能簡化成「Sass 都是固定值、原生才會動」。

@function 產出的是值,不能用來插入一整組 border、padding 與巢狀選擇器。需要理解區塊層級的重用,可以接著閱讀本站的原生 CSS @mixin 導讀。兩者雖在同一份規格裡,實作成熟度不能混為一談。

目前採用,先保留可用的基準樣式

截至查證日,相容性原始資料記錄如下。這是功能支援狀態,不是使用率,也不是本站訪客分布。

瀏覽器@function 支援狀態
Chrome/Chrome Android139 起
Edge139 起
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(),可能仍是更好的選擇。