CSS和佈局動畫

使用 CSS Houdini:創建普通 CSS 無法實現的自定義效果

使用 CSS Houdini:創建普通 CSS 無法實現的自定義效果

試吓做一件睇落好簡單嘅事:喺 hover 時流暢咁動畫化 conic-gradient() 入面嘅角度。你定義一個 custom property --kat: 0deg,加上 transition: --kat 0.4s,滑鼠一移上去——結果乜嘢都冇發生。漸變會跳躍式咁變化,好似 transition 根本唔存在咁。呢個唔係你程式碼嘅bug。對瀏覽器嚟講,--kat 只係一個普通、不透明嘅字串——佢唔知道呢個係一個角度,所以無從喺 0deg180deg 之間做插值運算。瀏覽器可以對數字、顏色、長度做插值——因為佢識得呢啲值嘅類型。Custom properties 同內建嘅 CSS 屬性唔同,本身完全冇類型可言。CSS Houdini 正正就係填補呢個缺口。

Houdini 究竟係咩

呢個係一個常見誤解:Houdini 唔係單一技術,甚至唔係單一規範。佢係 W3C 入面一系列獨立提案嘅統稱,呢啲提案由一個共同理念貫穿——將渲染引擎入面(值嘅解析、layout、繪圖、動畫)以往完全對開發者隱藏嘅部分暴露出嚟。你唔使等 CSS 出現一個度身訂造、剛好做到你想要嘅效果嘅屬性,而係可以喺流程入面某個具體階段拎到一個低層次嘅掛鈎(hook)。

呢啲掛鈎透過 worklet 嚟實現——一種細細嘅 JavaScript 模組,概念上接近 Web Worker,但係專門為渲染管線(rendering pipeline)而設計。Worklet 喺主執行緒之外運行,冇 windowdocument 或者 DOM 嘅存取權限,只可以透過一套嚴格定義嘅合約(例如 paint(ctx, size, properties))嚟溝通。呢個唔係規範制定者偷懶所致嘅限制——而係一個刻意嘅架構決定。正正因為有咗呢種隔離,引擎先可以喺獨立執行緒上並行運行worklet,並且可以快取結果,唔使擔心你嘅程式碼會觸碰到唔應該觸碰嘅嘢。同一個隔離機制亦解釋咗點解喺 worklet 入面搵唔到 Dateperformance.now() 或者 fetch——限制對精確時間同網絡嘅存取,係刻意用嚟防範喺渲染頁面嘅程式碼層面發起 timing/fingerprinting 之類嘅攻擊。

呢把傘下面嘅各個部分,今日處於完全唔同嘅階段:有一個好耐之前已經納入 Baseline,另一個就只有 Chrome 先支援,仲有一個經過十幾年都仍然係實驗性質。呢個區分至關重要——而大部分入門文章講 Houdini 嗰陣都會忽略咗呢一點,將成個 Houdini 當成單一、隨時可用嘅技術嚟講。

今日真正值得用嘅 Houdini:@property

CSS Properties and Values API 正正解決咗本文開頭嗰個問題。你將 custom property 註冊一個具體嘅類型(syntax)、初始值,以及係咪需要繼承——由呢一刻開始,瀏覽器就會將佢當成一個完整、可以做動畫嘅值,而唔係一個字串。

html

<div class="ring"></div>

css

@property --kat {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.ring {
width: 200px;
height: 200px;
border-radius: 50%;
background: conic-gradient(from var(--kat), #ff4d4d, #4d79ff, #4dff88, #ff4d4d);
transition: --kat 0.6s ease;
}
.ring:hover {
--kat: 180deg;
}

呢個例子真真正正做到一件普通 CSS 做唔到嘅事——冇咗 @property,上面嘅 transition 根本唔會生效,同文章開頭嗰個情況一模一樣。syntax: '<angle>' 話瞭俾瀏覽器聽,--kat 係一個角度,所以佢可以做流暢嘅插值運算;inherits: false 就防止子元素意外繼承呢個值——喺 custom properties 入面,呢種情況經常係好難追查嘅bug嘅源頭。

同一個效果都可以喺 JavaScript 層面做到,當你需要動態註冊屬性(例如根據資料生成),而唔係喺樣式表入面寫死嘅時候,呢個做法就特別有意義:

javascript

if ('registerProperty' in CSS) {
CSS.registerProperty({
name: '--kat',
syntax: '<angle>',
inherits: false,
initialValue: '0deg',
});
}

呢個係今日整個 Houdini 入面唯一一個達到 Baseline 狀態嘅部分——喺 Chrome、Firefox(128 版起)同 Safari(16.4 起)都原生支援,唔使 polyfill,都唔使 @supports。如果成個 Houdini 入面你只可以記住同實際採用一件事,就係呢一個。

CSS Paint API:真正負責繪圖嘅 worklet

Paint API 容許你用 paint() 函式取代 background,直接喺元素上繪圖,用嘅係一個精簡版、類似 Canvas 2D 嘅介面(PaintRenderingContext2D)——冇 fillTextdrawImage,都唔可以讀取像素。呢個做法同直接喺 <canvas> 上繪圖最大唔同之處,在於 inputProperties:worklet 會宣告佢想觀察邊啲 custom properties,並且只有當其中一個發生變化時先會自動重新繪製——完全唔使一行 JavaScript 去負責監聽事件。

下面係一個警示用嘅斜紋圖案,佢嘅角度由同一個之前已經註冊嘅 --kat 屬性(準確嚟講係佢本地嘅變體 --stripe-angle)控制——呢個例子展示咗 @property 同 Paint API 係點自然咁互相配合。

html

<div class="hazard"></div>

css

@property --stripe-angle {
syntax: '<angle>';
inherits: false;
initial-value: 45deg;
}
.hazard {
width: 100%;
height: 120px;
--stripe-angle: 45deg;
background: paint(hazardStripes);
transition: --stripe-angle 0.4s ease;
}
.hazard:hover {
--stripe-angle: 135deg;
}

javascript

// main.js
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('hazard-stripes.js');
}

javascript

// hazard-stripes.js
class HazardStripesPainter {
static get inputProperties() {
return ['--stripe-angle'];
}
paint(ctx, size, properties) {
const angle = properties.get('--stripe-angle').to('deg').value;
const stripeWidth = 24;
const { width, height } = size;
const diagonal = Math.sqrt(width ** 2 + height ** 2) * 2;
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((angle * Math.PI) / 180);
ctx.translate(-diagonal, -diagonal);
let stripeIndex = 0;
for (let x = 0; x < diagonal * 2; x += stripeWidth) {
ctx.fillStyle = stripeIndex % 2 === 0 ? '#111111' : '#f5c400';
ctx.fillRect(x, 0, stripeWidth, diagonal * 2);
stripeIndex++;
}
ctx.restore();
}
}
registerPaint('hazardStripes', HazardStripesPainter);

值得留意嘅係 properties.get('--stripe-angle').to('deg').value 呢一句——呢個就係 CSS Typed OM,屬於同一個家族嘅另一個 API。你唔使自己手動解析字串(parseFloat、剪走 "deg"),而係會攞到一個有類型嘅值 CSSUnitValue,可以明確咁轉換去你需要嘅單位。因為畫出嚟嗰啲斜紋嘅角度完全取決於 inputProperties 入面宣告嗰個 custom property,所以引擎清楚知道幾時先需要重新繪製元素——頁面其他地方一個冇關聯嘅 CSS 屬性發生變化,唔會觸發 paint() 重新執行。

Layout API 同 Animation Worklet:2026 年嘅現實

呢度就進入咗大部分 Houdini 入門文章都閉口不談嘅部分。CSS Layout API——同一份規範入面嘅第三條支柱,容許你定義自己一套 layout 演算法(相當於自訂一個 display: grid)——呢個提案由 2015 年起就已經存在,經過十幾年至今依然係實驗性質。冇任何一款正式版瀏覽器將佢原生實現;只有喺 Chrome 嘅實驗旗標(flag)或者 origin trial 入面先用得到。如果你見到有篇文章將 display: layout(名稱) 講成一個隨時可用嘅方案,記住check吓佢嘅發佈日期——嗰好可能係幾年前某個 origin trial 嘅示範,唔係你今日可以喺用戶嘅瀏覽器度跑得到嘅嘢。

Animation Worklet 原本目標係要俾開發者對由scroll驅動、喺主執行緒之外運行嘅動畫有完全嘅控制權。實際上就只有 Chromium 支援。更重要嘅係——佢最主要嘅使用場景已經唔再需要靠 Houdini 嚟解決,因為 CSS 本身已經有咗原生方案應付同一個問題:animation-timeline: scroll() 今日已經可以令動畫同scroll綁定,唔使一行 JavaScript,都唔使worklet,而且瀏覽器支援度不斷提升。呢個好能夠說明一個更廣泛嘅模式:Houdini 當初誕生嘅部分原因,隨住時間過去,已經直接被納入咗 CSS 規範本身,而唔係停留喺一個俾開發者用嘅低層次 API。

幾時喺實際場景先用得着

  • @property 可以放心大膽用,唔使加任何保護措施——佢已經係 Baseline 狀態,唔需要 @supports,都唔需要 polyfill。
  • Paint API 應該當成漸進式增強(progressive enhancement)嚟睇——用 @supports(background: paint(x)) 包住佢,並且為 Firefox 準備一個合理嘅 fallback(例如一個普通嘅 gradient);如果個效果一定要喺所有地方都行得通,可以考慮用 css-paint-polyfill 呢個 polyfill。
  • 唔好將 Layout API 排入production功能嘅計劃——佢依然係一個冇實際瀏覽器支援嘅實驗,適合玩吓,唔適合寫入 roadmap。
  • 記住 paint() 唔係「免費」嘅 CSS——佢係你嘅 JavaScript 程式碼,每次已宣告嘅 inputProperties 發生變化就會重新運行一次。成本較高嘅運算(生成噪點、複雜嘅程序化圖案)都要好似優化 <canvas> 上嘅渲染循環咁樣去優化——好容易一唔小心就整咗一個喺 layout 主執行緒上逐格重新繪製成個元素嘅動畫出嚟。

總結

Houdini 唔係一個「用定唔用」咁單一嘅決定——而係三、四個風險程度各異嘅獨立決定。首先,@property 解決咗一個實際、具體嘅問題(幫 custom properties 做動畫),而且今日用起上嚟已經同任何其他 CSS 屬性一樣安全——呢個係成個生態入面唯一一個值得毫不猶豫咁採用嘅部分。第二,Paint API 帶嚟真正嘅威力(worklet 只會喺已宣告嘅依賴發生變化時先重新繪製,唔使手動監聽事件),但需要你有清晰嘅計劃去應付冇支援嘅瀏覽器——呢個係一件用嚟做漸進式增強嘅工具,唔係用嚟做關鍵功能。第三,Layout API 同 Animation Worklet 好能夠說明一件事:規範本身並唔保證會被採用——過咗咁多年,一個仍然喺度等緊被實現,另一個就已經部分俾更簡單、原生嘅 CSS 取代咗。喺你決定用 Houdini 之前,先check吓你需要嘅正正係邊一個部分,屬於呢三個籃入面邊一個。