天天看點

用CSS Houdini畫一片星空

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

CSS Houdini可以做些什麼?

谷歌開發者文檔

列了幾個demo,我們先來看一下這幾個demo:

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

demo
用CSS Houdini畫一片星空
使用以下CSS代碼:

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

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

用CSS Houdini畫一片星空

使用以下CSS:

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

(3)點選圓圈擴散動畫(

用CSS Houdini畫一片星空

這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); } }
           

效果如下:

用CSS Houdini畫一片星空

為什麼它要用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);
    }
}
           

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

用CSS Houdini畫一片星空

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的時候也會随機切換,如下圖所示:

用CSS Houdini畫一片星空

這樣就造成了有兩個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;
           

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

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

用CSS Houdini畫一片星空

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

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%左右,這個應該還是可以接受的。

效果如下圖所示:

用CSS Houdini畫一片星空

如果用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給的那幾種布局。

原文釋出時間為:2018年04月22日

原文作者:人人網FED

本文來源:

https://juejin.im/post/5adc091b51882567105f5586 掘金 https://juejin.im/entry/5b3a29f95188256228041f46

如需轉載請聯系原作者

繼續閱讀