我們知道,瀏覽器在渲染頁面時,首先會解析頁面的 HTML 和 CSS,生成渲染樹(rendering tree),再經(jīng)由布局(layout)和繪制(painting),呈現(xiàn)出整個頁面內(nèi)容。在 Houdini 出現(xiàn)之前,這個流程上我們能操作的空間少之甚少,尤其是 layout 和 painting 環(huán)節(jié),可以說是完全封閉,使得我們很難通過 polyfill 等類似的手段為欠支持的 CSS 特性提供兼容。而另一方面,語法特性的缺失也極大地限制了 CSS 的編程靈活性,社區(qū)中 sass、less、stylus 等 CSS 預(yù)處理技術(shù)的出現(xiàn)大多都源于這個原因,它們都希望通過預(yù)編譯,突破 CSS 的局限性,讓 CSS 擁有更強大的組織和編寫能力。所以慢慢地,我們都不再手寫 CSS,更方便、更靈活的 CSS 擴展語言成了 web 開發(fā)的主角??吹竭@樣的情況,CSS Houdini 終于坐不住了。
什么是 CSS Houdini?
CSS Houdini 對外開放了瀏覽器解析流程的一系列 API,這些 API 允許開發(fā)者介入瀏覽器的 CSS engine 運作,帶來了更多的 CSS 解決方案。

CSS Houdini 目前主要提供了以下幾個 API:
CSS Properties and Values API

允許在 CSS 中定義變量和使用變量,是目前支持程度最高的一個 API。CSS 變量以 -- 開頭,通過 var()調(diào)用:
div {
--font-color: #9e4a9b;
color: var(--font-color);
}
web前端開發(fā)學(xué)習(xí)Q-q-u-n: 731771211,分享學(xué)習(xí)的方法和需要注意的小細(xì)節(jié),不停更新最新的教程和學(xué)習(xí)方法(詳細(xì)的前端項目實戰(zhàn)教學(xué)視頻,PDF)
此外,CSS 變量也可以在其他節(jié)點中使用,只不過是有作用域限制的,也就是說自身定義的 CSS 變量只能被自身或自身的子節(jié)點使用:
.container {
--font-color: #9e4a9b;
}
.container .text {
color: var(--font-color);
}
定義和使用 CSS 變量可以讓我們的 CSS 代碼變得更加簡潔明了,比如我們可以單純通過改變變量來改變 box-shadow 的顏色:
.text {
--box-shadow-color: #3a4ba2;
box-shadow: 0 0 30px var(--box-shadow-color);
}
.text:hover {
--box-shadow-color: #7f2c2b;
}
Painting API

允許開發(fā)者編寫自己的 Paint Module,自定義諸如 background-image 這類的繪制屬性。自定義的重點在于,"怎么畫" 的邏輯需要我們來描述,因此我們利用 registerPaint 來描述我們的繪制邏輯:
registerPaint('rect', class {
paint(ctx, size, properties, args) {
// @TODO
}
});
registerPaint 方法注冊了一個 Paint 類 rect 以供調(diào)用,這個類的核心在于它的 paint 方法。paint 方法用于描述自定義的繪制邏輯,它接收四個參數(shù):
-
ctx:一個 Canvas 的 Context 對象,因此 paint 中的繪制方式跟 canvas 繪制是一樣的。 -
size:包含節(jié)點的尺寸信息,同時也是 canvas 可繪制范圍(畫板)的尺寸信息。 -
properties:包含節(jié)點的 CSS 屬性,需要調(diào)用靜態(tài)方法inputProperties聲明注入。 -
args: CSS 中調(diào)用 Paint 類時傳入的參數(shù),需要調(diào)用靜態(tài)方法inputArguments聲明注入。
編寫完 Paint 類之后,我們在 CSS 中只需要這樣調(diào)用,就能應(yīng)用到我們自定義的繪制邏輯:
.wrapper {
background-image: paint(rect);
}
Painting API 目前在高版本 Chrome、Opera 瀏覽器已有支持,且實現(xiàn)起來比較簡單,后邊我們還將通過 demo 進一步演示。
Layout API
允許開發(fā)者編寫自己的 Layout Module,自定義諸如 display 這類的布局屬性。同樣的,"如何布局" 的邏輯需要我們自己編寫:
web前端開發(fā)學(xué)習(xí)Q-q-u-n: 731771211,分享學(xué)習(xí)的方法和需要注意的小細(xì)節(jié),不停更新最新的教程和學(xué)習(xí)方法(詳細(xì)的前端項目實戰(zhàn)教學(xué)視頻,PDF)
registerLayout('block-like', class {
layout(children, edges, constraints, properties, breakToken) {
// @TODO
return {
// inlineSize: number,
// blockSize: number,
// autoBlockSize: number,
// childFragments: sequence<LayoutFragment>
}
}
})
registerLayout 方法用于注冊一個 Layout 類以供調(diào)用,它的 layout 方法用于描述自定義的布局邏輯,最終返回一個包含布局后的位置尺寸信息和子節(jié)點序列信息的對象,引擎將根據(jù)這個對象進行布局渲染。
同樣的,調(diào)用時只需:
.wrapper {
display: layout('block-like');
}
因此利用 Layout API,你完全可以實現(xiàn)對 flex 布局的手工兼容。相比 Painting,Layout 的編寫顯得更加復(fù)雜,涉及到盒模型的深入概念,且支持度不高,這里就不細(xì)講了。
Worklets
registerPaint、registerLayout 這些 API 在全局上并不存在,為什么可以直接調(diào)用呢?這是因為上述的 JS 代碼并不是直接執(zhí)行的,而是通過 Worklets 載入執(zhí)行的。Worklets 類似于 Web Worker,是一個運行于主代碼之外的獨立工作進程,但比 Worker 更為輕量,負(fù)責(zé) CSS 渲染任務(wù)是最合適的了。和 Web Worker 一樣,Worklets 擁有一個隔離于主進程的全局空間,在這個空間里,沒有 window 對象,卻有 registerPaint、registerLayout 這些全局 API。因此,我們需要這樣引入自定義 JS 代碼:
if ("paintWorklet" in CSS) {
CSS.paintWorklet.addModule("paintworklet.js");
}
if ("layoutWorklet" in CSS) {
CSS.layoutWorklet.addModule("layoutworklet.js");
}
基礎(chǔ):三步用上 Painting API
我們來自定義 background-image 屬性,它將用于給作用節(jié)點繪制一個矩形背景,背景色值由該節(jié)點上的一個 CSS 變量 --rect-color 指定。
1、編寫一個 Paint 類:
新建一個 paintworklet.js,利用 registerPaint 方法注冊一個 Paint 類 rect,定義屬性的繪制邏輯:
registerPaint("rect", class {
static get inputProperties() {
return ["--rect-color"];
}
paint(ctx, geom, properties) {
const color = properties.get("--rect-color")[0];
ctx.fillStyle = color;
ctx.fillRect(0, 0, geom.width, geom.height);
}
});
上邊定義了一個名為 rect 的 Paint 類,當(dāng) rect 被使用時,會實例化 rect 并自動觸發(fā) paint 方法執(zhí)行渲染。paint 方法中,我們獲取節(jié)點 CSS 定義的 --rect-color 變量,并將元素的背景填充為指定顏色。由于需要使用屬性 --rect-color,我們需要在靜態(tài)方法 inputProperties 中聲明。
2、Worklets 加載 Paint 類:
HTML 中通過 Worklets 載入上一步驟實現(xiàn)的 paintworklet.js 并注冊 Paint 類:
<div class="rect"></div>
<script>
if ("paintWorklet" in CSS) {
CSS.paintWorklet.addModule("paintworklet.js");
}
</script>
3、使用 Paint 類:
CSS 中使用的時候,只需要調(diào)用 paint 方法:
.rect {
width: 100vw;
height: 100vh;
background-image: paint(rect);
--rect-color: rgb(255, 64, 129);
}
可以看得出利用 CSS Houdini,我們可以像操作 canvas 一樣靈活自如地實現(xiàn)我們想要的樣式功能。
進階:實現(xiàn)動態(tài)波紋
根據(jù)上述步驟,我們演示一下如何用 CSS Painting API 實現(xiàn)一個動態(tài)波浪的效果:
web前端開發(fā)學(xué)習(xí)Q-q-u-n: 731771211,分享學(xué)習(xí)的方法和需要注意的小細(xì)節(jié),不停更新最新的教程和學(xué)習(xí)方法(詳細(xì)的前端項目實戰(zhàn)教學(xué)視頻,PDF)
<!-- index.html -->
<div id="wave"></div>
<style>
#wave {
width: 20%;
height: 70vh;
margin: 10vh auto;
background-color: #ff3e81;
background-image: paint(wave);
}
</style>
<script>
if ("paintWorklet" in CSS) {
CSS.paintWorklet.addModule("paintworklet.js");
const wave = document.querySelector("#wave");
let tick = 0;
requestAnimationFrame(function raf(now) {
tick += 1;
wave.style.cssText = `--animation-tick: ${tick};`;
requestAnimationFrame(raf);
});
}
</script>
// paintworklet.js
registerPaint('wave', class {
static get inputProperties() {
return ['--animation-tick'];
}
paint(ctx, geom, properties) {
let tick = Number(properties.get('--animation-tick'));
const {
width,
height
} = geom;
const initY = height * 0.4;
tick = tick * 2;
ctx.beginPath();
ctx.moveTo(0, initY + Math.sin(tick / 20) * 10);
for (let i = 1; i <= width; i++) {
ctx.lineTo(i, initY + Math.sin((i + tick) / 20) * 10);
}
ctx.lineTo(width, height);
ctx.lineTo(0, height);
ctx.lineTo(0, initY + Math.sin(tick / 20) * 10);
ctx.closePath();
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fill();
}
})
paintworklet 中,利用 sin 函數(shù)繪制波浪線,由于 AnimationWorklets 尚處于實驗階段,開放較少,這里我們在 worklet 外部用 requestAnimationFrame API 來做動畫驅(qū)動,讓波浪紋動起來。完成后能看到下邊這樣的效果。

然而事實上這個效果略顯僵硬,sin 函數(shù)太過于規(guī)則了,現(xiàn)實中的波浪應(yīng)該是不規(guī)則波動的,這種不規(guī)則主要體現(xiàn)在兩個方面:
1)波紋高度(Y)隨位置(X)變化而不規(guī)則變化

把圖按照 x-y 正交分解之后,我們希望的不規(guī)則,可以認(rèn)為是固定某一時刻,隨著 x 軸變化,波紋高度 y 呈現(xiàn)不規(guī)則變化;
2)固定某點(X 固定),波紋高度(Y)隨時間推進而不規(guī)則變化
動態(tài)過程需要考慮時間維度,我們希望的不規(guī)則,還需要體現(xiàn)在時間的影響中,比如風(fēng)吹過的前一秒和后一秒,同一個位置的波浪高度肯定是不規(guī)則變化的。
提到不規(guī)則,有朋友可能想到了用 Math.random 方法,然而這里的不規(guī)則并不適合用隨機數(shù)來實現(xiàn),因為前后兩次取的隨機數(shù)是不連續(xù)的,而前后兩個點的波浪是連續(xù)的。這個不難理解,你見過長成鋸齒狀的波浪嗎?又或者你見過上一刻 10 米高、下一刻就掉到 2 米的波浪嗎?
為了實現(xiàn)這種連續(xù)不規(guī)則的特征,我們棄用 sin 函數(shù),引入了一個包 simplex-noise。由于影響波高的有兩個維度,位置 X 和時間 T,這里需要用到 noise2D 方法,它提前在一個三維的空間中,構(gòu)建了一個連續(xù)的不規(guī)則曲面:
// paintworklet.js
import SimplexNoise from 'simplex-noise';
const sim = new SimplexNoise(() => 1);
registerPaint('wave', class {
static get inputProperties() {
return ['--animation-tick'];
}
paint(ctx, geom, properties) {
const tick = Number(properties.get('--animation-tick'));
this.drawWave(ctx, geom, 'rgba(255, 255, 255, 0.4)', 0.004, tick, 15, 0.4);
this.drawWave(ctx, geom, 'rgba(255, 255, 255, 0.5)', 0.006, tick, 12, 0.4);
}
/**
* 繪制波紋
*/
drawWave(ctx, geom, fillColor, ratio, tick, amp, ih) {
const {
width,
height
} = geom;
const initY = height * ih;
const speedT = tick * ratio;
ctx.beginPath();
for (let x = 0, speedX = 0; x <= width; x++) {
speedX += ratio * 1;
var y = initY + sim.noise2D(speedX, speedT) * amp;
ctx[x === 0 ? 'moveTo' : 'lineTo'](x, y);
}
ctx.lineTo(width, height);
ctx.lineTo(0, height);
ctx.lineTo(0, initY + sim.noise2D(0, speedT) * amp);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
}
})
web前端開發(fā)學(xué)習(xí)Q-q-u-n: 731771211,分享學(xué)習(xí)的方法和需要注意的小細(xì)節(jié),不停更新最新的教程和學(xué)習(xí)方法(詳細(xì)的前端項目實戰(zhàn)教學(xué)視頻,PDF)
修改峰值和偏置項等參數(shù),可以再畫多一個不一樣的波浪紋,效果如下,完工!
