用CSS Houdini畫一片星空

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

CSS Houdini能夠作些什麼?谷歌開發者文檔列了幾個demo,咱們先來看一下這幾個demo:css

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

使用如下CSS代碼:前端

textarea {
    background-image: paint(checkerboard);
}複製代碼

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

使用如下CSS:node

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

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

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

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

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

而第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給的那幾種佈局。

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

人人網招聘高級前端

相關文章
相關標籤/搜索