用CSS Houdini畫一片星空

人人網FED發表於2018-04-22

要問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})`; ctx.fillRect(x, y, size, size); } }複製程式碼

效果如下:

為什麼它要用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 px
let 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給的那幾種佈局。

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

人人網招聘高階前端


相關文章