用CSS Houdini畫一片星空

要問2018最讓人興奮的CSS技術是什麼,CSS Houdini當之無愧,甚至可以去掉2018這個限定。其實這個技術在2016年就出來了,但是在今年3月發布的Chrome 65才正式支持。

CSS Houdini可以做些什麼?谷歌開發者文檔列了幾個demo,我們先來看一下這幾個demo:

(1)給textarea加一個方格背景(demo)

使用以下CSS代碼:

textarea { background-image: paint(checkerboard);}

(2)給div添加一個鑽石形狀背景(demo)

使用以下CSS:

div { --top-width: 80; --top-height: 20; -webkit-mask-image: paint(demo);}

(3)點擊圓圈擴散動畫(demo)

這3個例子都是用了Houdini裡面的CSS Paint API。

第1個例子如果使用傳統的CSS屬性,我們最多可能就是使用漸變來做顏色的變化,但是做不到這種一個格子一個格子的顏色變化的,而第2個例子也是沒有辦法直接用CSS畫個鑽石的形狀。這個時候你可能會想到會SVG/Canvas的方法,SVG和Canvas的特色是矢量路徑,可以畫出各種各樣的矢量圖形,而Canvas還能控制任意像素點,所以用這兩種方式也是可以畫出來的。

但是Canvas和html相結合的時候就會顯得有點笨拙,就像第2個例子畫一個鑽石的形狀,用Canvas你需要利用類似於BFC定位的方式,把Cavans調到合適的定位,還要注意z-index的覆蓋關係,而使用SVG可能會更簡單一點,可以設置background-image為一張鑽石的svg圖片,但是無法像Canavas一樣很方便地做一些變數控制,例如隨時改一下鑽石邊框的顏色粗細等。

而第1個例子給textarea加格子背景,只能使用background-image + svg的方式,但是你不知道這個textarea有多大,svg的格子需要準備多少個呢?當然你可能會說誰會給textarea加一個這樣的背景呢。但這只是一個示例,其它的場景可能也會遇到類似的問題。

第3個例子點擊圓圈擴散動畫,這個也可以在div裡面absolute定位一個canvas元素,但是我們又遇到另外一個問題:無法很方便復用,假設這種圈圈擴散效果在其它地方也要用到,那就得在每個地方都寫一個canvas元素並初始化。

所以傳統的方式存在以下問題:

(1)需要調好和其它html元素的定位和z-index關係等

(2)編輯框等不能方便地改背景,不能方便地做變數控制

(3)不能方便地進行復用

其實還有另外一個更重要的問題就是性能問題,用Cavans畫這種效果時需要自己控制好幀率,一不小心電腦CPU風扇可能就要呼嘯起來,特別是不能把握重繪的時機,如果元素大小沒有變化是不需要重繪,如果元素被拉大了,那麼需要進行重繪,或者當滑鼠hover的時候做動畫才需要重繪。

CSS Houdini在解決這種自定義圖形圖像繪製的問題提供了很好的解決方案,可以用Canvas畫一個你想要的圖形,然後註冊到CSS系統裡面,就能在CSS屬性裡面使用這個圖形了。以畫一個星空為例,一步步說明這個過程。

1. 畫一個黑夜的夜空

CSS Houdini只能工作在localhost域名或者是https的環境,否則的話相關API是不可見(undefined)的。如果沒有https環境的話,可以裝一個http-server的npm包,然後在本地啟動,訪問localhost:8080就可以了,新建一個index.html,寫入:

<!DOCType><html><head> <meta charset="utf-8"><style>body { background-image: paint(starry-sky);}</style> </head><body><script> CSS.paintWorklet.addModule(starry-sky.js);</script></body></html>

通過在JS調用CSS.paintWorklet.addModule註冊一個CSS圖形starry-sky,然後在CSS裡面就可以使用這個圖形,寫在background-image、border-image或者mask-image等屬性裡面。如上面代碼的:

body { background-image: paint(starry-sky);}

註冊paint worket的時候需要給它一個獨立的js,作為這個worklet的工作環境,這個環境裡面是沒有window/document等對象的和web worker一樣。如果你不想寫管理太多js文件,可以藉助blob,blob是可以存放任何類型的數據的,包括JS文件。

Worklet需要的starry-sky.js的代碼如下所示:

class StarrySky { paint (ctx, paintSize, properties) { // 使用Canvas的API進行繪製 ctx.fillRect(0, 0, paintSize.width, paintSize.height); }}// 註冊這個屬性registerPaint(starry-sky, StarrySky);

寫一個類,實現paint介面,這個介面會傳一個canvas的context變數、當前畫布的大小即當前dom元素的大小,以及當前dom元素的css屬性properties.

在paint函數裡面調用canvas的繪製函數fillRect進行填充,默認填充色為黑色。訪問index.html,就會看到整個頁面變成黑色了。我們的Hello World的CSS Houdini Painter就跑起來了,沒錯,就是這麼簡單。

但是有一點需要強調的是,瀏覽器實現並不是給那個dom元素添加一個Canvas然後隱藏起來,這個Paint Worket實際上是直接影響了當前dom元素重繪過程,相當於我們給它添加了一個重繪的步驟,下文會繼續提及。

如果不想獨立寫一個js,用blob可以這樣:

let blobURL = URL.createObjectURL( new Blob([ (, function(){ class StarrySky { paint (ctx, paintSize, properties) { ctx.fillRect(0, 0, paintSize.width, paintSize.height); } } registerPaint(starry-sky, StarrySky); }.toString(), )() ], { type: application/javascript } ) );CSS.paintWorklet.addModule(blobURL);

2. 畫星星

Cavans星星效果網上找一個就好了,例如這個Codepen,代碼如下:

paint (ctx, paintSize, poperties) { let xMax= paintSize.width; let yMax = paintSize.height; // 黑色夜空 ctx.fillRect(0, 0, xMax, yMax); // 星星的數量 let hmTimes = xMax + yMax; for (let i = 0; i <= hmTimes; i++) { // 星星的xy坐標,隨機 let x = Math.floor((Math.random() * xMax) + 1); let y = Math.floor((Math.random() * yMax) + 1); // 星星的大小 let size = Math.floor((Math.random() * 2) + 1); // 星星的亮暗 let opacityOne = Math.floor((Math.random() * 9) + 1); let opacityTwo = Math.floor((Math.random() * 9) + 1); let hue = Math.floor((Math.random() * 360) + 1); ctx.fillStyle = `hsla(${hue}, 30%, 80%, .${opacityOne + opacityTwo})`

效果如下:

為什麼它要用fillRect來畫星星呢,星星不應該是圓的么?因為如果用arc的話性能會明顯降低。由於星星比較小,所以使用了這種方式,當然改成arc也是可以的,因為我們只是畫一次就好了。

3. 控制星星的密度

現在要做一個可配參數控制星星的密度,就好像border-radius可以控制一樣。藉助CSS變數,給body添加一個自定義屬性--star-density:

body { --star-density: 0.8; background-image: paint(starry-sky); }

規定密度係數從0到1變化,通過paint函數的propertis參數獲取到屬性。但是我們發現body/html的自定義屬性無法獲取,可以繼承給body的子元素,但無法在body上獲取,所以改成畫在body:before上面:

body:before { content: ""; position: absolute; left: 0; top: 0; width: 100%; height: 100%; --star-density: 0.5; background-image: paint(starry-sky); }

然後給class StarrySky添加一個靜態方法:

class StarrySky { static get inputProperties() { return [--star-density]; }}

告知我們需要獲取哪些CSS屬性,可以是自定義的,也可以是常規的CSS屬性。然後在paint方法的properties裡面就可以拿到屬性值:

class StarrySky { paint (ctx, paintSize, properties) { // 獲取自定義屬性值 let starDensity = +properties.get(--star-density).toString() || 1; // 最大只能為1 starDensity > 1 && (starDensity = 1); // 星星的數量剩以這個係數 let hmTimes = Math.round((xMax + yMax) * starDensity); }}

讓星星的數量剩以傳進來的係數進而達控制密度的目的。上面設置星星的數量為最大值的一半,效果如下:

3. 重繪

當拉頁面的時候會發現所有星星的位置都發生了變化,這是因為觸發了重繪。

在paint函數裡面添加一個console.log,拉動頁面的時候就可以觀察到瀏覽器在不斷地執行paint函數。因為這個CSS屬性是寫在body:befoer上面的,佔滿了body,body大小改變就會觸發重繪。而如果寫在一個寬度固定的div裡面,拉動頁面不會觸發重繪,觀察到paint函數沒有執行。如果改了div或者body的任何一個CSS屬性也會觸發重繪。所以這個很方便,不需要我們自己去監聽resize之類的DOM變化。

頁面拉大時,右邊新拉出來的空間星星沒有畫大,所以本身需要重繪。而重繪給我們造成的問題是星星的位置發生變化,正常情況下應該是頁面拉大拉小,星星的位置應該是要不變的。所以需要記錄一下星星的一些相關信息。

4. 記錄星星的數據

可以在SkyStarry這個類裡面添加一個成員變數stars,保存所有star的信息,包括位置和透明度等,在paint的時候判斷一下stars的長度,如果為0則進行初始化,否則使用直接上一次初始化過的星星,這樣就能保證每次重繪都是用的同樣的星星了。但是在實際的操作過程中,發現一個問題,它會初始化兩次starry-sky.js,在paint的時候也會隨機切換,如下圖所示:

這樣就造成了有兩個stars的數據,在重繪過程中來回切換。原因可能是因為CSS Houdini的本意並不想讓你保存實例數據,但是既然它設計成一個類,使用類的實例數據應該也是合情合理的。這個問題我想到的一個解決方法是把random函數變成可控的,只要隨機化種子一樣,那麼生成的random系列就是一樣的,而這個隨機化種子由CSS變數傳進來。所以就不能用Math.random了,自己實現一個random,如下代碼所示:

random () { let x = Math.sin(this.seed++) * 10000; return x - Math.floor(x); }

只要初始化seed一樣,那麼就會生成一樣的random系列。seed和星星密度類似,由CSS變數控制:

body:before { --starry-sky-seed: 1; --star-density: 0.5; background-image: paint(starry-sky);}

然後在paint函數裡面通過properties拿到seed:

paint (ctx, paintSize, properties) { if (!this.stars) { let starOpacity = +properties.get(--star-opacity).toString(); // 得到隨機化種子,可以不傳,默認為0 this.seed = +(properties.get(--starry-sky-seed).toString() || 0); this.addStars(paintSize.width, paintSize.height, starDensity); }}

通過addStars函數添加星星,這個函數調用上面自定義的random函數:

random () { let x = Math.sin(this.seed++) * 10000; return x - Math.floor(x);}addStars (xMax, yMax, starDensity = 1) { starDensity > 1 && (starDensity = 1); // 星星的數量 let hmTimes = Math.round((xMax + yMax) * starDensity); this.stars = new Array(hmTimes); for (let i = 0; i < hmTimes; i++) { this.stars[i] = { x: Math.floor((this.random() * xMax) + 1), y: Math.floor((this.random() * yMax) + 1), size: Math.floor((this.random() * 2) + 1), // 星星的亮暗 opacityOne: Math.floor((this.random() * 9) + 1), opacityTwo: Math.floor((this.random() * 9) + 1), hue: Math.floor((this.random() * 360) + 1) }; }}

這段代碼由Math.random改成this.random保證只要隨機化種子一樣,生成的所有數據也都是一樣的。這樣就能解決上面提到的初始化兩次數據的問題,因為種子是一樣的,所以兩次的數據也是一樣的。

但是這樣有點單調,每次刷新頁面星星都是固定的,少了點靈氣。可以給這個隨機化種子做下優化,例如實現單個小時內是一樣的,過了一個小時後刷新頁面就會變。通過以下代碼可以實現:

const ONE_HOUR = 36000 * 1000;this.seed = +(properties.get(--starry-sky-seed).toString() || 0) + Date.now() / ONE_HOUR >> 0;

這樣拉動頁面的時候星星就不會變了。

但是在從小拉大的時候,右邊會沒有星星:

因為第一次的畫布沒那麼大,以後又沒有更新星星的數據,所以右邊就空了。

5. 增量更新星星數據

不能全部更新星星的數據,不然第4步就白做了。只能把右邊沒有的給它補上。所以需要記錄一下兩次畫布的大小,如果第二次的畫布大了,則增加星星,否則刪掉邊界外的星星。

所以需要有一個變數記錄上一次畫布的大小:

class StarrySky { constructor () { // 初始化 this.lastPaintSize = this.paintSize = { width: 0, height: 0 }; this.stars = []; }}

把相關的操作抽成一個函數,包括從CSS變數獲取設置,增量更新星星等,這樣可以讓主邏輯變得清晰一點:

paint (ctx, paintSize, properties) { // 更新當前paintSize this.paintSize = paintSize; // 獲取CSS變數設置,把密度、seed等存放到類的實例數據 this.updateControl(properties); // 增量更新星星 this.updateStars(); // 黑色夜空 for (let star of this.stars) { // 畫星星,略 } }

增量更新星星需要做兩個判斷,一個為是否需要刪除掉一些星星,另一個為是否需要添加,根據畫布的變化:

updateStars () { // 如果當前的畫布比上一次的要小,則刪掉一些星星 if (this.lastPaintSize.width > this.paintSize.width || this.lastPaintSize.height > this.paintSize.height) { this.removeStars(); } // 如果當前畫布變大了,則增加一些星星 if (this.lastPaintSize.width < this.paintSize.width || this.lastPaintSize.height < this.paintSize.height) { this.addStars(); } this.lastPaintSize = this.paintSize;}

刪除星星removeStar的實現很簡單,只要判斷x, y坐標是否在當前畫布內,如果是的話則保留:

removeStars () { let stars = [] for (let star of stars) { if (star.x <= this.paintSize.width && star.y <= this.paintSize.height) { stars.push(star); } } this.stars = stars;}

添加星星的實現也是類似的道理,判斷x, y坐標是否在上一次的畫布內,如果是的話則不添加:

addStars () { let xMax = this.paintSize.width, yMax = this.paintSize.height; // 星星的數量 let hmTimes = Math.round((xMax + yMax) * this.starDensity); for (let i = 0; i < hmTimes; i++) { let x = Math.floor((this.random() * xMax) + 1), y = Math.floor((this.random() * yMax) + 1); // 如果星星落在上一次的畫布內,則跳過 if (x < this.lastPaintSize.width && y < this.lastPaintSize.height) { continue; } this.stars.push({ x: x, y: y, size: Math.floor((this.random() * 2) + 1), // 星星的亮暗 }); } }

這樣當拖動頁面的時候就會觸發重繪,重繪的時候就會調paint更新星星。

6. 讓星星閃起來

通過做星星透明度的動畫,可以讓星星閃起來。如果用Cavans標籤,可以藉助window.requestAnimationFrame註冊一個函數,然後用當前時間減掉開始的時間模以一個值就得到當前的透明度係數。使用Houdini也可以使用這種方式,區別是我們可以把動態變化透明度係數當作當前元素的CSS變數或者叫自定義屬性,然後用JS動態改變這個自定義屬性,就能夠觸發重繪,這個已在第3點重繪部分提到。

給元素添加一個--star-opacity的屬性:

body:before { --star-opacity: 1; --star-density: 0.5; --starry-sky-seed: 1; background-image: paint(starry-sky);}

在星星的時候,每個星星的透明度再乘以這個係數:

// 獲取透明度係數this.starOpacity = +properties.get(--star-opacity).toString();for (let star of this.stars) { // 每個星星的透明度都乘以這個係數 let opacity = +(. + (star.opacityOne + star.opacityTwo)) * this.starOpacity; ctx.fillStyle = `hsla(${star.hue}, 30%, 80%, ${opacity})`; ctx.fillRect(star.x, star.y, star.size, star.size);}

然後在requestAnimationFrame動態改變這個CSS屬性:

let start = Date.now();// before無法獲取,所以需要改成正常元素let node = document.querySelector(.starry-sky);window.requestAnimationFrame(function changeOpacity () { let now = Date.now(); // 每隔一1s,透明度從0.5變到1 node.style.setProperty(--star-opacity, (now - start) % 1000 / 2 + 0.5); window.requestAnimationFrame(changeOpacity);});

這樣就能重新觸發paint函數重新渲染了,但是這個效果其實是有問題的,因為得有一個alternate輪流交替的效果,即0.5變到1,再從1變到0.5,而不是每次都是0.5到1. 模擬CSS animation的alternate這個也好解決,可以規定奇數秒就是變大,而偶數秒就是變小,這個好實現,略。

但實際上可以不用這麼麻煩,因為改變CSS屬性直接用animation就可以了,如下代碼所示:

body:before { --star-opacity: 1; --star-density: 0.5; --starry-sky-seed: 1; background-image: paint(starry-sky); animation: shine 1s linear alternate infinite;}@keyframes shine { from { --star-opacity: 1; } to { --star-opacity: 0.6; }}

這樣也能觸發重繪,但是我們發現它只有在from和to這兩個點觸發了重繪,沒有中間過渡的過程。可以推測因為它認為--star-opacity的屬性值不是一個數字,而是一個字元串,所以這兩關鍵幀就沒有中間的過渡效果了。因此我們得告訴它這是一個整型,不是一個字元串。類型化CSS對象模型(Typed CSSOM)提供了這個API。

類型化CSS對象模型一個很大的作用就是把所有的CSS單位都用一個相應的對象來表示,提供加減乘除等運算,如:

// 10 pxlet length = CSS.px(10);// 在循環裡面改length的值,不用自己去拼字元串div.attributeStyleMap.set(width, length.add(CSS.px(1)))

這樣的好處是不用自己去拼字元串,另外還提供了轉換,如transform的值轉成matrix,度數轉成rad的形式等等。

它還提供了註冊自定義類型屬性的能力,使用以下API:

CSS.registerProperty({ name: --star-opacity, // 指明它是一個數字類型 syntax: <number>, inherits: false, initialValue: 1});

這樣註冊之後,CSS系統就知道--star-opacity是一個number類型,在關鍵幀動畫裡面就會有一個漸變的過渡效果。

類型CSS對象模型在Chrome 66已經正式支持,但是registerProperty API仍然沒有開放,需要打開chrome://flags,搜索web platform,從disabled改成enabled就可以使用。

這個給我們提供了做動畫新思路,CSS animation + Canvas的模式,CSS animation負責改變屬性數據並觸發重繪,而Canvas去獲取動態變化的數據更新視圖。所以它是一個數據驅動的動畫模式,這也是當前做動畫的一個流行方式。

在我們這個例子裡面,由於星星數太多,1s有60幀,每幀都要計算和繪製1000個星星,CPU使用率達到90%多,所以這個性能有問題,如果用Cavans標籤可以使用雙緩衝技術,CSS Houdini好像沒有這個東西。但是可以換一個思路,改成做整體的透明度動畫,不用每個星星都算一下。

如下代碼所示:

body { background-color: #000; }body:before { background-image: paint(starry-sky); animation: shine 1s linear alternate infinite;}@keyframes shine { from { opacity: 1; } to { opacity: 0.6; }}

這個的效果和每個星星都單獨算是一樣的,CPU消耗12%左右,這個應該還是可以接受的。

效果如下圖所示:

如果用Canvas標籤,可以設置globalAlpha全局透明度屬性,而使用CSS Houdini我們直接使用opacity就行了。

一個完整的Demo:CSS Houdini Starry Sky,需要使用Chrome,因為目前只有Chrome支持。

總的來說,CSS Houdini的Paint Worket提供了CSS和Canvas的粘合,讓我們可以用Canvas畫出想要的CSS效果,並藉助CSS自定義屬性進行控制,通過使用JS或者CSS的animation/transition改變自定義屬性的值觸發重繪,從而產生動畫效果,這也是數據驅動的開發思想。並討論了在畫這個星空的過程中遇到的一些問題,以及相關的解決方案。

本文只是介紹了CSS Houdini裡面的Paint Worket和Typed CSSOM,它還有另外一個Layout Worklet,利用它可以自行實現一個flex布局或者其它自定義布局,這樣的好處是:一方面當有新的布局出現的時候可以藉助這個API進行polyfill就不用擔心沒有實現的瀏覽器不兼容,另一方面可以發揮想像力實現自己想要的布局,這樣在布局上可能會百花齊放了,而不僅僅使用W3C給的那幾種布局。

【再一次強推書】高效前端已上市,京東、亞馬遜、淘寶等均有售

推薦閱讀:

網頁中實現正六邊形的N種姿勢
canvas基礎繪製之時鐘
SharedArrayBuffer and Atomics - Web 的多線程並發編程
vue-schart : vue.js 的圖表組件
【canvas】一個少女心滿滿的例子帶你入門 canvas

TAG:前端開發 | Canvas | HTMLCSS |