現代 CSS 高階技巧,像 Canvas 一樣自由繪圖構建樣式!

chokcoco發表於2022-12-12

在上一篇文章中 -- 現代 CSS 之高階圖片漸隱消失術,我們藉助了 CSS @Property 及 CSS Mask 屬性,成功的實現了這樣一種圖片漸變消失的效果:

CodePen Demo -- 基於 @property 和 mask 的文字漸隱消失術

但是,這個效果的缺陷也非常明顯,雖然藉助了 SCSS 簡化了非常多的程式碼,但是,如果我們檢視編譯後的 CSS 檔案,會發現,在利用 SCSS 只有 80 的程式碼的情況下,編譯後的 CSS 檔案行數高達 2400+ 行,實在是太誇張了。

究其原因在於,我們利用原生的 CSS 去控制 400 個小塊的過渡動畫,控制了 400 個 CSS 變數!程式碼量因而變得如此之大。

CSS Houdini 之 CSS Paint API

那麼,如何有效的降低程式碼量呢?

又或者說,在今天,是否 CSS 還存在著更進一步的功能,能夠實現更為強大的效果?

沒錯,是可以的,這也就引出了今天的主角,CSS Houdini 之 CSS Paint API

首先,什麼是 CSS Houdini?

Houdini 是一組底層 API,它們公開了 CSS 引擎的各個部分,從而使開發人員能夠透過加入瀏覽器渲染引擎的樣式和佈局過程來擴充套件 CSS。Houdini 是一組 API,它們使開發人員可以直接訪問 CSS 物件模型 (CSSOM),使開發人員可以編寫瀏覽器可以解析為 CSS 的程式碼,從而建立新的 CSS 功能,而無需等待它們在瀏覽器中本地實現。

而 CSS Paint API 則是 W3C 規範中之一,目前的版本是 CSS Painting API Level 1。它也被稱為 CSS Custom Paint 或者 Houdini's Paint Worklet。

簡單來說人話,CSS Paint API 的優缺點都很明顯。

CSS Paint API 的優點

  1. 實現更為強大的 CSS 功能,甚至是很多 CSS 原本不支援的功能
  2. 將這些自定義的功能,很好的封裝起來,當初一個屬性快速複用

當然,優點看著很美好,缺點也很明顯,CSS Paint API 的缺點

  1. 需要寫一定量的 JavaScript 程式碼,有一定的上手成本
  2. 現階段相容的問題

小試牛刀 registerPaint

CSS Houdini 的特性就是 Worklet (en-US)。在它的幫助下,你可以透過引入一行 JavaScript 程式碼來引入配置化的元件,從而建立模組式的 CSS。不依賴任何前置處理器、後置處理器或者 JavaScript 框架。

廢話不多說,我們直接來看一個最簡單的例子。

<div style="--color: red"></div>
<div style="--color: blue"></div>
<div style="--color: yellow"></div>

<script>
if (CSS.paintWorklet) {              
    CSS.paintWorklet.addModule('/CSSHoudini.js');
}
</script>
div {
    margin: auto;
    width: 100px;
    height: 100px;
    background: paint(drawBg);
}
// 這個檔案的名字為 CSSHoudini.js
// 對應上面 HTML 程式碼中的 CSS.paintWorklet.addModule('/CSSHoudini.js')
registerPaint('drawBg', class {
  
   static get inputProperties() {return ['--color']}
   
   paint(ctx, size, properties) {
       const c = properties.get('--color');
      
       ctx.fillStyle = c;
       ctx.fillRect(0, 0, size.width, size.height);
   }
});

先看看最終的結果:

看似有點點複雜,其實非常好理解。仔細看我們的 CSS 程式碼,在 background 賦值的過程中,沒有直接寫具體顏色,而是藉助了一個自定義了 CSS Houdini 函式,實現了一個名為 drawBg 的方法。從而實現的給 Div 上色。

registerPaint 是以 worker 的形式工作,具體有幾個步驟:

  1. 建立一個 CSSHoudini.js,比如我們想用 CSS Painting API,先在這個 JS 檔案中註冊這個模組 registerPaint('drawBg', class),這個 class 是一個類,下面會具體講到
  2. 我們需要在 HTML 中引入 CSS.paintWorklet.addModule('CSSHoudini.js'),當然 CSSHoudini.js 只是一個名字,沒有特定的要求,叫什麼都可以,
  3. 這樣,我們就成功註冊了一個名為 drawBg 的自定義 Houdini 方法,現在,可以用它來擴充套件 CSS 的功能
  4. 在 CSS 中使用,就像程式碼中示意的那樣 background: paint(drawBg)
  5. 接下來,就是具體的 registerPaint 實現的 drawBg 的內部的程式碼

上面的步驟搞明白後,核心的邏輯,都在我們自定義的 drawBg 這個方法後面定義的 class 裡面。CSS Painting API 非常類似於 Canvas,這裡面的核心邏輯就是:

  1. 可以透過 static get inputProperties() {} 拿到各種從 CSS 傳遞進來的 CSS 變數
  2. 透過一套類似 Canvas 的 API 完成整個圖片的繪製工作

而我們上面 DEMO 做的事情也是如此,獲取到 CSS 傳遞進來的 CSS 變數的值。然後,透過 ctx.fillStylectx.fillRect 完成整個背景色的繪製。

使用 registerPaint 實現自定義背景圖案

OK,瞭解了上面最簡單的 DEMO 之後,接下來我們嘗試稍微進階一點點。利用 registerPaint 實現一個 circleBgSet 的自定義 CSS 方法,實現類似於這樣一個背景圖案:

CodePen Demo -- CSS Hudini Example - Background Circle

首先,我們還是要在 HTML 中,利用 CSS.paintWorklet.addModule('') 註冊引入我們的 JavaScript 檔案。

<div style=""></div>

<script>
if (CSS.paintWorklet) {              
     CSS.paintWorklet.addModule('/CSSHoudini.js'');
}
</script>

其次,在 CSS 中,我們只需要在呼叫 background 屬性的時候,傳入我們即將要實現的方法:

div {
    width: 100vw;
    height: 1000vh;
    background: paint(circleBgSet);
    --gap: 3;
    --color: #f1521f;
    --size: 64;
}

可以看到,核心在於 background: paint(circleBgSet),我們將繪製背景的工作,交給接下來我們要實現的 circleBgSet 方法。同時,我們定義了 3 個 CSS 變數,它們的作用分別是:

  1. --gap:表示圓點背景的間隙
  2. -color:表示圓點的顏色
  3. --size:表示圓點的最大尺寸

好了,接下來,只需要在 JavaScript 檔案中,利用 CSS Painting API 實現 circleBgSet 方法即可。

來看看完整的 JavaScript 程式碼:

// 這個檔案的名字為 CSSHoudini.js
registerPaint(
    "circleBgSet",
    class {
        static get inputProperties() {
            return [
                "--gap", 
                "--color",
                "--size"
            ];
        }

        paint(ctx, size, properties) {
            const gap = properties.get("--gap");
            const color = properties.get("--color");
            const eachSize = properties.get("--size");
            const halfSize = eachSize / 2;
            
            const n = size.width / eachSize;
            const m = size.height / eachSize;
            
            ctx.fillStyle = color;
           
            for (var i = 0; i < n + 1; i++) {
                for (var j = 0; j < m + 1; j++) {
                    
                    let x = i * eachSize + ( j % 2 === 0 ? halfSize : 0);
                    let y = j * eachSize / gap;
                    let radius = i * 0.85;
                    
                    ctx.beginPath();
                    ctx.arc(x, y, radius, 0, 2 * Math.PI);
                    ctx.fill();
                }
            }
        }
    }
);

程式碼其實也不多,並且核心的程式碼非常好理解。這裡,我們再簡單的解釋下:

  1. static get inputProperties() {},我們在 CSS 程式碼中定義了一些 CSS 變數,而需要取到這些變數的話,需要利用到這個方法。它使我們能夠訪問所有 CSS 自定義屬性和它們設定的值。
  2. paint(ctx, size, properties) {} 核心繪畫的方法,其中 ctx 類似於 Canvas 2D 畫布的 ctx 上下文物件,size 表示 PaintSize 物件,可以拿到對於元素的高寬值,而 properties 則是表示 StylePropertyMapReadOnly 物件,可以拿到 CSS 變數相關的資訊

  1. 最終,仔細看看我們的 paint() 方法,核心做的就是拿到 CSS 變數後,基於雙重迴圈,把我們要的圖案繪製在畫布上。這裡核心就是呼叫了下述 4 個方法,對 Canvas 瞭解的同學不難發現,這裡的 API 和 Canvas 是一模一樣的。

    • ctx.fillStyle = color
    • ctx.beginPath()
    • ctx.arc(x, y, radius, 0, 2 * Math.PI)
    • ctx.fill()

這裡,其實 CSS Houdini 的畫布 API 是 Canvas API 的是一樣的,具體存在這樣一些對映,我們在官方規範 CSS Painting API Level 1 - The 2D rendering context 可以查到:

還記得我們上面傳入了 3 個 CSS 變數嗎?這裡我們只需要簡單改變上面的 3 個 變數,就可以得到不一樣的圖形。讓我們試一試:

div {
    width: 100vw;
    height: 1000vh;
    background: paint(circleBgSet);
    // --gap: 3;
    // --color: #f1521f;
    // --size: 64;
    --gap: 6;
    --color: #ffcc00;
    --size: 75;
}

又或者:

div {
    width: 100vw;
    height: 1000vh;
    background: paint(circleBgSet);
    // --gap: 3;
    // --color: #f1521f;
    // --size: 64;
    --gap: 4;
    --color: #0bff00;
    --size: 50;
}

利用 registerPaint 實現自定義 mask

有了上面的鋪墊,下面我們開始實現我們今天的主題,利用 registerPaint 自定義方法還原實現這個效果,簡化 CSS 程式碼量:

自定義的 paint 方法,不但可以用於 background,你想得到的地方,其實都可以。

能力越大,責任越大!在 Houdini 的幫助下你能夠在 CSS 中實現你自己的佈局、柵格、或者區域特性,但是這麼做並不是最佳實踐。CSS 工作組已經做了許多努力來確保 CSS 中的每一項特性都能正常執行,覆蓋各種邊界情況,同時考慮到了安全、隱私,以及可用性方面的表現。如果你要深入使用 Houdini,確保你也把以上這些事項考慮在內!並且先從小處開始,再把你的自定義 Houdini 推向一個富有雄心的專案。

因此,這裡,我們利用 CSS Houdini 的 registerPaint 實現自定義的 mask 屬性繪製。

首先,還是一樣,HTML 中需要引入一下定義了 registerPaint 方法的 JavaScript 檔案:

<div></div>

<script>
if (CSS.paintWorklet) {              
    CSS.paintWorklet.addModule('/CSSHoudini.js');
}
</script>

首先,我們會實現一張簡單的圖片:


div {
    width: 300px;
    height: 300px;
    background: url(https://tvax4.sinaimg.cn/large/6f8a2832gy1g8npte0txnj21jk13a4qr.jpg);
}

效果如下:

當然,我們的目標是利用 registerPaint 實現自定義 mask,那麼需要新增一些 CSS 程式碼:


div {
    width: 300px;
    height: 300px;
    background: url(https://tvax4.sinaimg.cn/large/6f8a2832gy1g8npte0txnj21jk13a4qr.jpg);
    mask: paint(maskSet);
    --size-m: 10;
    --size-n: 10;
}

這裡,我們 mask: paint(maskSet) 表示使用我們自定義的 maskSet 方法,並且,我們引入了兩個 CSS 自定義變數 --size-m--size-n,表示我們即將要用 mask 屬性分隔圖片的行列數。

接下來,就是具體實現新的自定義 mask 方法。當然,這裡我們只是重新實現一個 mask,而 mask 屬性本身的特性,透明的地方背後的內容將會透明這個特性是不會改變的。

JavaScript 程式碼:

// 這個檔案的名字為 CSSHoudini.js
registerPaint(
    "maskSet",
    class {
        static get inputProperties() {
            return ["--size-n", "--size-m"];
        }

        paint(ctx, size, properties) {
            const n = properties.get("--size-n");
            const m = properties.get("--size-m");
            const width = size.width / n;
            const height = size.height / m;

            for (var i = 0; i < n; i++) {
                for (var j = 0; j < m; j++) {
                    ctx.fillStyle = "rgba(0,0,0," + Math.random() + ")";
                    ctx.fillRect(i * width, j * height, width, height);
                }
            }
        }
    }
);

這一段程式碼非常好理解,我們做的事情就是拿到兩個 CSS 自定義變數 --size-n--size-m 後,透過一個雙迴圈,依次繪製正方形填滿整個 DIV 區域,每個小正方形的顏色為帶隨機透明度的黑色。

記住,mask 的核心在於,透過顏色的透明度來隱藏一個元素的部分或者全部可見區域。因此,整個圖片將變成這樣:

當然,我們這個自定義 mask 方法也是可以用於 background 的,如果我們把這個方法作用於 backgorund,你會更好理解一點。

div {
    width: 300px;
    height: 300px;
    background: paint(maskSet);
    // mask: paint(maskSet);
    --size-m: 10;
    --size-n: 10;
}

實際的圖片效果是這樣:

好,迴歸正題,我們繼續。我們最終的效果還是要動畫效果,Hover 的時候讓圖片方塊化消失,肯定還是要和 CSS @property 自定義變數發生關聯的,我們簡單改造下程式碼,加入一個 CSS @property 自定義變數。

@property --transition-time {
  syntax: '<number>';
  inherits: false;
  initial-value: 1;
}

div {
    width: 300px;
    height: 300px;
    background: url(https://tvax4.sinaimg.cn/large/6f8a2832gy1g8npte0txnj21jk13a4qr.jpg);
    mask: paint(maskSet);
    --size-m: 10;
    --size-n: 10;
    --transition-time: 1;
    transition: --transition-time 1s linear;
}

div:hover {
  --transition-time: 0;
}

這裡,我們引入了 --transition-time 這個變數。接下來,讓他在 maskSet 函式中,發揮作用:

registerPaint(
    "maskSet",
    class {
        static get inputProperties() {
            return ["--size-n", "--size-m", "--transition-time"];
        }

        paint(ctx, size, properties) {
            const n = properties.get("--size-n");
            const m = properties.get("--size-m");
            const t = properties.get("--transition-time");
            const width = size.width / n;
            const height = size.height / m;

            for (var i = 0; i < n; i++) {
                for (var j = 0; j < m; j++) {
                    ctx.fillStyle = "rgba(0,0,0," + (t * (Math.random() + 1)) + ")";
                    ctx.fillRect(i * width, j * height, width, height);
                }
            }
        }
    }
);

這裡,與上面唯一的變化在於這一行程式碼:ctx.fillStyle = "rgba(0,0,0," + (t * (Math.random() + 1)) + ")"

對於每一個小格子的 mask,我們讓他的顏色值的透明度設定為 (t * (Math.random() + 1))

  1. 其中 t 就是 --transition-time 這個變數,記住,在 hover 的過程中,它的值會逐漸從 1 衰減至 0
  2. (Math.random() + 1) 表示先生成一個 0 ~ 1 的隨機數,再讓這個隨機數加 1,加 1 的目的是讓整個值必然大於 1,處於 1 ~ 2 的範圍
  3. 由於一開始 --transition-time 的值一開始是 1,所以乘以 (Math.random() + 1) 的值也必然大於 1,而最終在過渡過程中 --transition-time 會逐漸變為 0, 整個表示式的值也最終會歸於 0
  4. 由於上述 (3)的值控制的是每一個 mask 小格子的透明度,也就是說每個格子的透明度都會從一個介於 1 ~ 2 的值逐漸變成 0,藉助這個過程,我們完成了整個漸隱的動畫

看看最終的效果:

bg.gif

CodePen Demo -- CSS Hudini Example

是的,細心的同學肯定會發現,文章一開頭給的 DEMO 是切分了 400 份 mask 的,而我們上面實現的效果,只用了 100 個 mask。

這個非常好解決,我們不是傳入了 --size-n--size-m 兩個變數麼?只需要修改這兩個值,就可以實現任意格子的 Hover 漸隱效果啦。還是上面的程式碼,簡單修改 CSS 變數的值:

div:nth-child(1) {
    --size-m: 4;
    --size-n: 4; 
}
div:nth-child(2) {
    --size-m: 6;
    --size-n: 6; 
}
div:nth-child(3) {
    --size-m: 10;
    --size-n: 10; 
}
div:nth-child(4) {
    --size-m: 15;
    --size-n: 15; 
}

結果如下:

CodePen Demo -- CSS Hudini Example

到這裡,還有一個小問題,可以看到,在消失的過程中,整個效果非常的閃爍!每個格子其實閃爍了很多次。

這是由於在過渡的過程中,ctx.fillStyle = "rgba(0,0,0," + (t * (Math.random() + 1)) + ")" 內的 Math.random() 每一幀都會重新被呼叫並且生成全新的隨機值,因此整個動畫過程一直在瘋狂閃爍。

如何解決這個問題?在這篇文章中,我找到了一種利用偽隨機,生成穩定隨機函式的方法:Exploring the CSS Paint API: Image Fragmentation Effect

啥意思呢?就是我們希望每次生成的隨機數都是都是一致的。其 JavaScript 程式碼如下:

const mask = 0xffffffff;
const seed = 30; /* update this to change the generated sequence */
let m_w  = (123456789 + seed) & mask;
let m_z  = (987654321 - seed) & mask;

let random =  function() {
  m_z = (36969 * (m_z & 65535) + (m_z >>> 16)) & mask;
  m_w = (18000 * (m_w & 65535) + (m_w >>> 16)) & mask;
  var result = ((m_z << 16) + (m_w & 65535)) >>> 0;
  result /= 4294967296;
  return result;
}

我們利用上述實現的隨機函式 random() 替換掉我們程式碼原本的 Math.random(),並且,mask 小格子的 ctx.fillStyle 函式,也稍加變化,避免每一個 mask 矩形小格子的漸隱淡出效果同時發生。

修改後的完整 JavaScript 程式碼如下:

registerPaint(
    "maskSet",
    class {
        static get inputProperties() {
            return ["--size-n", "--size-m", "--transition-time"];
        }

        paint(ctx, size, properties) {
            const n = properties.get("--size-n");
            const m = properties.get("--size-m");
            const t = properties.get("--transition-time");
            const width = size.width / n;
            const height = size.height / m;
            const l = 10;

            const mask = 0xffffffff;
            const seed = 100; /* update this to change the generated sequence */
            let m_w = (123456789 + seed) & mask;
            let m_z = (987654321 - seed) & mask;

            let random = function () {
                m_z = (36969 * (m_z & 65535) + (m_z >>> 16)) & mask;
                m_w = (18000 * (m_w & 65535) + (m_w >>> 16)) & mask;
                var result = ((m_z << 16) + (m_w & 65535)) >>> 0;
                result /= 4294967296;
                return result;
            };

            for (var i = 0; i < n; i++) {
                for (var j = 0; j < m; j++) {
                    ctx.fillStyle = 'rgba(0,0,0,'+((random()*(l-1) + 1) - (1-t)*l)+')';
                    ctx.fillRect(i * width, j * height, width, height);
                }
            }
        }
    }
);

還是上述的 DEMO,讓我們再來看看效果,分別設定了不同數量的 mask 漸隱消失:

CodePen Demo -- CSS Hudini Example & Custom Random

Wow!修正過後的效果不再閃爍,並且消失動畫也並非同時進行。在 Exploring the CSS Paint API: Image Fragmentation Effect 這篇文章中,還介紹了一些其他利用 registerPaint 實現的有趣的 mask 漸隱效果,感興趣可以深入再看看。

這樣,我們就將原本 2400 行的 CSS 程式碼,透過 CSS Painting API 的 registerPaint,壓縮到了 50 行以內的 JavaScript 程式碼。

當然,CSS Houdini 的本事遠不止於此,本文一直在圍繞 background 描繪相關的內容進行闡述(mask 的語法也是背景 background 的一種)。在後續的文章我將繼續介紹在其他屬性上的應用。

相容性如何?

那麼,CSS Painting API 的相容性到底如何呢?

CanIUse - registerPaint 資料如下(截止至 2022-11-23):

Chrome 和 Edge 基於 Chromium 核心的瀏覽器很早就已經支援,而主流瀏覽器中,Firefox 和 Safari 目前還不支援。

CSS Houdini 雖然強大,目前看來要想大規模上生產環境,仍需一段時間的等待。讓我們給時間一點時間!

最後

好了,本文到此結束,希望本文對你有所幫助 :)

如果還有什麼疑問或者建議,可以多多交流,原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

相關文章