CSS Houdini:用瀏覽器引擎實現高階CSS效果

2022-07-11 12:02:13

vivo 網際網路前端團隊-Wei Xing

Houdini被稱之為Magic of styling and layout on the web,看起來十分神祕,但實際上,Houdini並非什麼神祕組織或者神奇魔法,它是一系列與CSS引擎相關的瀏覽器API的總稱。

一、Houdini 是什麼

在瞭解之前,先來看一些Houdini能實現的效果吧:

反向的圓角效果(Border-radius):

動態的球形背景(Backgrond):

彩色邊框(Border):

神奇吧,要實現這些效果使用常規的CSS可沒那麼容易,但對CSS Houdini來說,卻很easy,這些效果只是冰山一角,CSS Houdini能做的有更多。(這些案例均來自Google Chrome Labs,更多案例可以通過 Houdini Samples 檢視)。

看完效果,再來說說Houdini到底是什麼。

首先,Houdini 的出現最直接的目的是為了解決瀏覽器對新的CSS特性支援較差以及Cross-Browser的問題。我們知道有很多新的CSS特性雖然很棒,但它們由於不被主流瀏覽器廣泛支援而很少有人去使用。

隨著CSS規範在不斷地更新迭代,越來越多有益的特性被納入進來,但是一個新的CSS特性從被提出到成為一個穩定的CSS特性,需要經過漫長地等待,直到被大部分瀏覽器支援時,才能被開發者廣泛地使用。

而 Houdini 的出現正是洞察和解決了這一痛點,它將一系列CSS引擎API開放出來,讓開發者可以通過JavasScript創造或者擴充套件現有的CSS特性,甚至創造自己的CSS渲染規則,給開發者更高的CSS開發自由度,實現更多複雜的效果。

二、JS Polyfill vs Houdini

有人會問,實際上很多新的CSS特性在被瀏覽器支援之前,也有可替代的JavaScript Polyfill可以使用,為什麼我們仍然需要Houdini呢?這些Polyfill不是同樣可以解決我們的問題嗎?

要回答這個問題也很簡單,JavaScript Polyfill相對於Houdini有三個明顯的缺陷:

1.不一定能實現或實現困難。

CSSOM開放給JavaScript的API很少,這意味著開發者能做的很有限,只能簡單地操縱DOM並對樣式做動態計算和調整,光是去實現一些複雜的CSS新特性的Polyfill就已經很難了,對於更深層次的Layout、Paint、Composite等渲染規則更是無能為力。所以當一個新的CSS特性被推出時,通過JavaScript Polyfill不一定能夠完整地實現它。

2.實現效果差或有使用限制。

JavaScript Polyfill是通過JavaScript來模擬CSS特性的,而不是直接通過CSS引擎進行渲染,通常它們都會有一定的限制和缺陷。例如,大家熟知的css-scroll-snap-polyfill就是針對新的CSS特性Scroll Snap產生的Polyfill,但它在使用時就存在使用限制或者原生CSS表現不一致的問題。

3.效能較差。

JavaScript Polyfill可能造成一定程度的效能損耗。JavaScript Polyfill的執行時機是在DOM和CSSOM都構建完成並且完成渲染後,通常JavaScript Polyfill是通過給DOM元素設定內聯樣式來模擬CSS特性,這會導致頁面的重新渲染或迴流。尤其是當這些Polyfill和捲動事件繫結時,會造成更加明顯的效能損耗。

Houdini的誕生讓CSS新特性不再依賴於瀏覽器,開發者通過直接操作CSS引擎,具有更高的自由度和效能優勢,並且它的瀏覽器支援度在不斷提升,越來越多的API被支援,未來Houdini必然會加速走進web開發者的世界,所以現在對它做一些瞭解也是必要的。

在本文,我們會介紹Houdini的APIs以及它們的使用方法,看看這些API當前的支援情況,並給出一些在生產環境中使用它們的建議。

Houdini的名稱與一位著名美國逃脫魔術師Harry Houdini的名稱一樣,也許正是取逃脫之意,讓CSS新特性逃離瀏覽器的掌控。

三、Houdini APIs

上文提到CSS Houdini提供了很多CSS引擎相關的API,根據Houdini提供的規範說明檔案,API共分為兩種型別:high-level APIs 和 low-level APIs 。

high-level APIs:顧名思義是高層次的API,這些API與瀏覽器的渲染流程相關。

  • Paint API

提供了一組與繪製(Paint)過程相關的API,我們可以通過它自定義的渲染規則,例如調整顏色(color)、邊框(border)、背景(background)、形狀等繪製規則。

  • Animation API

提供了一組與合成(composite)渲染相關的API,我們可以通過它調整繪製層級和自定義動畫。

  • Layout API

提供了一組與佈局(Layout)過程相關的API,我們可以通過它自定義的佈局規則,類似於實現諸如flex、grid等佈局,自定義元素或子元素的對齊(alignment)、位置(position)等佈局規則。

low-level APIs:低層次的API,這些API是high-level APIs的實現基礎。

  • Typed Object Model API
  • CSS Properties & Values API
  • Worklets
  • Font Metrics API
  • CSS Parser API

這些APIs的支援情況在不斷更新中,可以看到當前最新的一次更新時間是在2021年5月份,還是比較活躍的。(注:圖片來源於Is Houdini ready yet? 

對比下圖2018年底的情況,Houdini目前得到了更廣泛的支援,我們也期待圖裡更多綠色的板塊被逐漸點亮。

大家可以存取 Is Houdini ready yet? 看到Houdini的最新支援情況。

下文中,我們會著重介紹Typed Object Model API、CSS Properties & Values API、Worklets和Paint API、Animation API,因為它們目前具有比其他API更好的支援度,且它們的特性已經趨於穩定,在未來不會有很大的變更,大家也能在瞭解它們之後直接將它們使用在專案中。

四、 Typed Object Model API

在Houdini出現以前,我們通過JavaScript操作CSS Style的方式很簡單,先看看一段大家熟悉的程式碼。

// Before Houdini
 
const size = 30
target.style.fontSize = size + 'px' // "20px"

const imgUrl = 'https://www.exampe.com/sample.png'
target.style.background = 'url(' + imgUrl + ')' // "url(https://www.exampe.com/sample.png)"

target.style.cssText = 'font-size:' + size + 'px; background: url('+ imgUrl +')'  
// "font-size:30px; background: url(https://www.exampe.com/sample.png)"

我們可以看到CSS樣式在被存取時被解析為字串返回,設定CSS樣式時也必須以字串的形式傳入。開發者需要手動拼接數值、單位、格式等資訊,這種方式非常原始和落後,很多開發者為了節省效能損耗,會選擇將一長串的CSS Style字串傳入cssText,可讀性很差,而且很容易產生隱蔽的語法錯誤。

Typed Object ModelTypeScript的命名類似,都增加了Type這個字首,如果你使用過TypeScript就會了解到,TypeScript增強了型別檢查,讓程式碼更穩定也更易維護,Typed Object Model也是如此。

相比於上面晦澀的傳統方法,Typed Object Model將CSS屬性值包裝為Typed JavaScript Object,讓每個屬性值都有自己的型別,簡化了CSS屬性的操作,並且帶來了效能上的提升。通過JavaScript物件來描述CSS值比字串具有更好的可讀性和可維護性,通常也更快,因為可以直接操作值,然後廉價地將其轉換回底層值,而無需構建和解析 CSS 字串。

Typed Object Model中CSSStyleValue是所有CSS屬性值的基礎類別,在它之下的子類用於描述各種CSS屬性值,例如:

  • CSSUnitValue
  • CSSImageValue
  • CSSKeywordValue
  • CSSMathValue
  • CSSNumericValue
  • CSSPositionValue
  • CSSTransformValue
  • CSSUnparsedValue
  • 其它

通過它們的命名就可以看出這些不同的子類分別用於表示哪種型別的CSS屬性值,以CSSUnitValue為例,它可以用於表示帶有單位的CSS屬性值,例如font-size、width、height,它的結構很簡單,由value和unit組成。

{
  value: 30,
  unit: "px"
}

可以看到,通過物件來描述CSS屬性值確實比傳統的字串更易讀了。

要存取和操作CSSStyleValue還需要藉助兩個工具,分別是attributeStyleMap和computedStyleMap(),前者用於處理內聯樣式,可以進行讀寫操作,後者用於處理非內聯樣式(stylesheet),只有讀操作。

// 獲取stylesheet樣式
target.computedStyleMap().get("font-size"); // { value: 30, unit: "px"}

// 設定內聯樣式
target.attributeStyleMap.set("font-size", CSS.em(5));

// stylesheet樣式仍然返回20px
target.computedStyleMap().get("font-size"); // { value: 30, unit: "px"}

// 內聯樣式已經被改變
target.attributeStyleMap.get("font-size"); // { value: 5, unit: "em"}

當然attributeStyleMap和computedStyleMap()還有更多可用的方法,例如clear、has、delete、append等,這些方法都為開發者提供了更便捷和清晰的CSS操作方式。

五、CSS Properties & Values API

根據MDN的定義,CSS Properties & Values API也是Houdini開放的一部分API,它的作用是讓開發者顯式地宣告自定義屬性(css custom properties),並且定義這些屬性的型別、預設值、初始值和繼承方法。

--my-color: red;
--my-margin-left: 100px;
--my-box-shadow: 3px 6px rgb(20, 32, 54);

在被宣告之後,這些自定義屬性可以通過var()來參照,例如:

// 在:root下可宣告全域性自定義屬性
:root {
  --my-color: red;
}
 
#container {
  background-color: var(--my-color)
}

瞭解了自定義屬性的基本概念和使用方式後,我們來考慮一個問題,我們能否通過自定義屬性來幫助我們完成一些過渡效果呢?

例如,我們希望為一個div容器設定背景色的transition動畫,我們知道CSS是無法直接對background-color做transition過渡動畫的,那我們考慮將transition設定在我們自定義的屬性--my-color上,通過自定義屬性的漸變來間接完成背景的漸變效果,是否能做到呢?根據剛才的自定義屬性簡介,也許你會嘗試這麼做:

// DOM
<div id="container">container</div>
 
// Style
:root {
  --my-color: red;
}
 
#container {
  transition: --my-color 1s;
  background-color: var(--my-color)
}
 
#container:hover {
  --my-color: blue;
}

這看起來是個符合邏輯的寫法,但實際上由於瀏覽器不知道該如何去解析--my-color這個變數(因為它並沒有明確的型別,只是被當做字串處理),所以也無法對它採用transition的效果,因此我們並不能得到一個漸變的背景色動畫。

但是,通過CSS Properties & Values API提供的CSS.registerProperty()方法就可以做到,就像這樣:

// DOM
<div id="container">container</div>
 
// JavaScript
CSS.registerProperty({
  name: '--my-color',
  syntax: '<color>',
  inherits: false,
  initialValue: '#c0ffee',
});
 
// Style
#container {
  transition: --my-color 1s;
  background-color: var(--my-color)
}
 
#container:hover {
  --my-color: blue;
}

與上面的不同之處在於,CSS.registerProperty()顯式定義了--my-color的型別syntax,這個syntax告訴瀏覽器把--my-color當做color去解析,因此當我們設定transition: --my-color 1s時,瀏覽器由於提前被告知了該屬性的型別和解析方式,因此能夠正確地為其新增過渡效果,得到的效果如下圖所示。

CSS.registerProperty()接受一個引數物件,引數中包含下面幾個選項:

  • name: 變數的名字,不允許重複宣告或者覆蓋相同名稱的變數,否則瀏覽器會給出相應的報錯。
  • syntax: 告訴瀏覽器如何解析這個變數。它的可選項包含了一些預定義的值等。
  • inherits: 告訴瀏覽器這個變數是否繼承它的父元素。
  • initialValue: 設定該變數的初始值,並且將該初始值作為fallback。

在未來,開發者不僅可以在JavaScript中顯式宣告CSS變數,也可以直接在CSS中直接宣告:

@property --my-color{
  syntax: '<color>',
  inherits: false,
  initialValue: '#c0ffee',
}

六、Font Metrics API

目前 Font Metrics API 還處於早期的草案階段,它的規範在未來可能會有較大的變更。在當前的specification檔案中,說明了** Font Metrics API** 將會提供一系列API,允許開發者干預文字的渲染過程,建立文字或者動態修改文字的渲染效果等。期待它能在未來被採納和支援,為開發者提供更多的可能。

七、CSS Parser API

目前** Font Metrics API **也處於早期的草案階段,當前的specification檔案中說明了它將會提供更多CSS解析器相關的API,用於解析任意形式的CSS描述。

八、Worklets

Worklets是輕量級的 Web Workers,它提供了讓開發者接觸底層渲染機制的API,Worklets的工作執行緒獨立於主執行緒之外,適用於做一些高效能的圖形渲染工作。並且它只能被使用在HTTPS協定中(生產環境)或通過localhost來啟用(開發偵錯)。

Worklets不像Web Workers,我們不能將任何計算操作都放在Worklets中執行,Worklets開放了特定的屬性和方法,讓我們能處理圖形渲染相關的操作。我們能使用的Worklet型別暫時有如下幾種:

  • PaintWorklet - Paint API
  • LayoutWorklet - Animation API
  • AnimationWorklet - Layout API
  • AudioWorklet - Audio API(處於草案階段,暫不介紹)

Worklets提供了唯一的方法Worklet.addModule(),這個方法用於向Worklet新增執行模組,具體的使用方法,我們在後續的Paint API、Layout API、Animation API中介紹。

九、Paint API

Paint API允許開發者通過Canvas 2d的方法來繪製元素的背景、邊框、內容等圖形,這在原始的CSS規則中是無法做到的。

Paint API需要結合上述提到的PaintWorklet一起使用,簡單來說就是開發者構建一個PaintWorklet,再將它傳入Paint API就可以繪製相應的Canvas圖形。如果你熟悉Canvas,那Paint API對你來說也不會陌生。

使用Paint API的過程簡述如下:

  1. 使用registerPaint()方法建立一個PaintWorklet。
  2. 將它新增到Worklet模組中,CSS.paintWorklet.addModule()。
  3. 在CSS中通過paint()方法使用它。

其中registerPaint()方法用於建立一個PaintWorklet,在這個方法中開發者可以利用Canvas 2d自定義圖形繪製。

可以通過Google Chrome Labs給出的一個paint API案例checkboardWorklet來直觀看看它的具體使用方法,案例中利用Paint API為textarea繪製彩色的網格背景,它的程式碼組成很簡單:

/* checkboardWorklet.js */
 
class CheckerboardPainter {
  paint(ctx, geom, properties) {
    const colors = ['red', 'green', 'blue'];
    const size = 32;
    for(let y = 0; y < geom.height/size; y++) {
      for(let x = 0; x < geom.width/size; x++) {
        const color = colors[(x + y) % colors.length];
        ctx.beginPath();
        ctx.fillStyle = color;
        ctx.rect(x * size, y * size, size, size);
        ctx.fill();
      }
    }
  }
}
 
// 註冊checkerboard
registerPaint('checkerboard', CheckerboardPainter);
/* index.html */
<script>
    CSS.paintWorklet.addModule('path/to/checkboardWorklet.js')  // 新增checkboardWorklet到paintWorklet
</script>
/* index.html */
<!doctype html>
<textarea></textarea>
<style>
  textarea {
    background-image: paint(checkerboard);  // 使用paint()方法呼叫checkboard繪製背景
  }
</style>

通過上述三個步驟,最終生成的textarea背景效果如圖所示:

感興趣的同學可以存取 houdini-samples檢視更多官方樣例。

十、Animation API

在過去,當我們想要對DOM元素執行動畫時,通常只有兩個選擇:CSS Transitions和CSS Animations。這兩者在使用上雖然簡單,也能滿足大部分的動畫需求,但是它們有兩個共同的缺點

  • 僅僅依賴時間來執行動畫(time-driven):動畫的執行僅和時間有關。
  • 無狀態(stateless):開發者無法干預動畫的執行過程,獲取不到動畫執行的中間狀態。

但是在一些場景下,我們想要開發一個非時間驅動的動畫或者想要控制動畫的執行狀態,就很難做到。比如視差捲動(Parallax Scrolling),它是根據捲動的情況來執行動畫的,並且每個元素根據捲動情況作出不一致的動畫效果,下面是個簡單的視差捲動效果範例,在通常情況下要實現更加複雜的視差捲動效果(例如beckett頁面的效果)是比較困難的。

Animation API卻可以幫助我們輕鬆做到。

在功能方面,它是CSS Transitions和CSS Animations的擴充套件,它允許使用者干預動畫執行的過程,例如結合使用者的scroll、hover、click事件來控制動畫執行,像是為動畫增加了進度條,通過進度條控制動畫程序,從而實現一些更加複雜的動畫場景。

在效能方面,它依賴於AnimationWorklet,執行在單獨的Worklet執行緒,因此具有更高的動畫影格率和流暢度,這在低端機型中尤為明顯(當然,通常低端機型中的瀏覽器核心還不支援該特性,這裡只是說明Animation API對動畫的視覺體驗優化是很友好的)。

Animation API的使用和Paint API一樣,也同樣遵循Worklet的建立和使用流程,分為三個步驟,簡述如下:

  1. 使用registerAnimator()方法建立一個AnimationWorklet。
  2. 將它新增到Worklet模組中,CSS.animationWorklet.addModule()。
  3. 使用new WorkletAnimation(name, KeyframeEffect)建立和執行動畫。

/* myAnimationWorklet.js */
registerAnimator("myAnimationWorklet", class {
  constructor(options) {
    /* 建構函式,動畫範例被建立時呼叫,可用於做一些初始化 */
  }
   
  //
  animate(currentTime, effect) {
    /* 干預動畫的執行 */
  }
});
/* index.html */
await CSS.animationWorklet.addModule("path/to/myAnimationWorklet.js");;
/* index.html */
 
/* 傳入myAnimationWorklet,建立WorkletAnimation */
new WorkletAnimation(
  'myAnimationWorklet', // 動畫名稱
  new KeyframeEffect(   // 動畫timeline(對應於步驟一中animate(currentTime, effect)中的effect引數)
    document.querySelector('#target'), 
    [
      {
        transform: 'translateX(0)'
      },
      {
        transform: 'translateX(200px)'
      }
    ],
    {
      duration: 2000, // 動畫執行時長
      iterations: Number.POSITIVE_INFINITY  // 動畫執行次數
    }
  ),
  document.timeline // 控制動畫執行程序的數值(對應於步驟一中animate(currentTime, effect)中的currentTime引數)
).play();

可以看到步驟一的animate(currentTime, effect)方法有兩個引數,就是它們讓開發者能夠干預動畫執行過程。

  • currentTime:

用於控制動畫執行的數值,對應於步驟3例子中傳入的document.timeline引數,通常根據它的數值來動態修改另一個引數effect,從而影響動畫執行。例如我們可以傳入document.timeline或者傳入element.scrollTop作為這個動態數值,傳入前者表明我們只是想用時間變化來控制動畫的執行,傳入後者表明我們想通過捲動距離來控制動畫執行。

document.timeline是每個頁面被開啟後從0開始遞增的時間數值,可以簡單理解為頁面被開啟的時長,初始時document.timeline === 0,隨著時間不斷遞增。

  • effect:

對應於步驟3中傳入的new KeyframeEffect(),可通過修改它來影響動畫執行。一個很常見的做法是,通過修改effect.localTime控制動畫的執行,effect.localTime的作用相當於控制動畫播放的進度條,修改它的數值就相當於拖動動畫播放的進度。

如果不修改effect.localTime或者設定effect.localTime = currentTime,那麼動畫會隨著document.timeline正常勻速執行,線性動畫。但是如果將effect.localTime設定為某個固定值,例如effect.localTime = 1000ms,那麼動畫將會定格在1000ms時對應的幀,不會繼續執行。

為了更好理解effect.localTime,可以來看看effect.localTime和動畫執行之間的關係,假設我們建立了一個2000ms時長的動畫,並且動畫沒有設定delay時間。

通過上面的描述,大家應該get到如何做一個簡單的捲動驅動(scroll-driven)的動畫了,實際上有個專門用於生成捲動動畫的類:ScrollTimeline,它的用法也很簡單:

/* myWorkletAnimation.js */
 
new WorkletAnimation(
  'myWorkletAnimation',
  new KeyframeEffect(
    document.querySelector('#target'),
    [
      {
        transform: 'translateX(0)'
      },
      {
        transform: 'translateX(500px)'
      }
    ],
    {
      duration: 2000,
      fill: 'both'
    }
  ),
  new ScrollTimeline({
    scrollSource: document.querySelector('.scroll-area'), // 監聽的捲動元素
    orientation: "vertical", // 監聽的捲動方向"horizontal"或"vertical"
    timeRange: 2000 // 根據scroll的高度,傳入0 - timeRage之間的數值,當捲動到頂端時,傳入0,當捲動到底端時,傳入2000
  })
).play();

這樣一來,通過簡單的幾行程式碼,一個簡單的捲動驅動的動畫就做好了,它比任何CSS Animations或CSS Transitions都要順暢。

接下來再看看最後一個同樣有潛力的API:**Layout API **。

十一、Layout API

Layout API允許使用者自定義新的佈局規則,創造類似flex、grid之外的佈局。

但建立一個完備的佈局規則並不簡單,官方的flex、grid佈局是充分考慮了各種邊界情況,才能確保使用時不會出錯。同時Layout API使用起來也比其它API更為複雜,受限於篇幅,本文僅簡單展示相關的API和使用方式,具體細節可參考官方描述。

Layout API和其它兩個API相似,使用步驟同樣分為三個步驟,簡述如下:

  • 通過registerLayout()建立一個LayoutWorklet。
  • 將它新增到Worklet模組中,CSS.layoutWorklet.addModule()。
  • 通過display: layout(exampleLayout)使用它。

Google Chrome Labs案例如下所示,通過Layout API實現了一個瀑布流佈局。

雖然通過Layout API自定義佈局較為困難,但是我們依然可以引入別人的優秀開源Worklet,幫助自己實現複雜的佈局。

十二、新特性檢測

鑑於當前Houdini APIs的瀏覽器支援度仍然不是很完美,在使用這些API時需要先做特性檢測,再考慮使用它們。

/* 特性檢測 */
 
if (CSS.paintWorklet) {
  /* ... */
}
 
if (CSS.animationWorklet) {
  /* ... */
}
 
if (CSS.layoutWorklet) {
  /* ... */
}

想要在chrome中偵錯,可以在位址列輸入chrome://flags/#enable-experimental-web-platform-features,並勾選啟用Experimental Web Platform features。

十三、總結

Houdini APIs讓開發者有辦法接觸到CSS渲染引擎,通過各種API實現更高效能和更復雜的CSS渲染效果。雖然它還沒有完全準備好,很多API甚至還處於草案階段,但它給我們帶來了更多可能性,並且諸如paint API、Typed OM、Properties & Values API這些新特性也都被廣泛支援了,可以直接用於增強我們的頁面效果。未來Houdini APIs一定會慢慢走進開發者的世界,大家可以期待並做好準備迎接它。

參考文獻:

  1. W3C Houdini Specification Drafts
  2. State of Houdini (Chrome Dev Summit 2018)
  3. Houdini’s Animation Worklet - Google Developers
  4. Interactive Introduction to CSS Houdini
  5. CSS Houdini Experiments
  6. Interactive Introduction to CSS Houdini
  7. Houdini Samples by Google Chrome Labs