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

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

CodePen Demo -- 基於 @property 和 mask 的文本漸隱消失術 [2]

但是,這個效果的缺陷也非常明顯,雖然藉助了 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 對象模型 [3] (CSSOM),使開發人員可以編寫瀏覽器可以解析爲 CSS 的代碼,從而創建新的 CSS 功能,而無需等待它們在瀏覽器中本地實現。

而 CSS Paint API 則是 W3C 規範中之一,目前的版本是 CSS Painting API Level 1[4]。它也被稱爲 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[5] 的特性就是 Worklet (en-US)[6]。在它的幫助下,你可以通過引入一行 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.fillStyle 和 ctx.fillRect 完成整個背景色的繪製。

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

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

CodePen Demo -- CSS Hudini Example - Background Circle[7]

首先,我們還是要在 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 是一模一樣的。

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

還記得我們上面傳入了 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,藉助這個過程,我們完成了整個漸隱的動畫

看看最終的效果:

CodePen Demo -- CSS Hudini Example[9]

是的,細心的同學肯定會發現,文章一開頭給的 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[10]

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

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

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

啥意思呢?就是我們希望每次生成的隨機數都是都是一致的。其 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[12]

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

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

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

兼容性如何?

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

CanIUse - registerPaint[14] 數據如下(截止至 2022-11-23):

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

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

最後

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

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

參考資料

[1]

現代 CSS 之高階圖片漸隱消失術: https://github.com/chokcoco/cococss/issues/23

[2]

CodePen Demo -- 基於 @property 和 mask 的文本漸隱消失術: https://codepen.io/Chokcoco/pen/qBKPgZY

[3]

CSS 對象模型: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Object_Model

[4]

CSS Painting API Level 1: https://drafts.css-houdini.org/css-paint-api/#paintworkletglobalscope

[5]

CSS Houdini: https://developer.mozilla.org/zh-CN/docs/Web/Guide/Houdini

[6]

Worklet (en-US): https://developer.mozilla.org/en-US/docs/Web/API/Worklet

[7]

CodePen Demo -- CSS Hudini Example - Background Circle: https://codepen.io/Chokcoco/pen/abKExxN

[8]

CSS Painting API Level 1 - The 2D rendering context: https://drafts.css-houdini.org/css-paint-api/#paintworkletglobalscope

[9]

CodePen Demo -- CSS Hudini Example: https://codepen.io/Chokcoco/pen/KKeQWJb

[10]

CodePen Demo -- CSS Hudini Example: https://codepen.io/Chokcoco/pen/oNyEpLN

[11]

Exploring the CSS Paint API: Image Fragmentation Effect: https://dev.to/this-is-learning/exploring-the-css-paint-api-image-fragmentation-effect-3ekl

[12]

CodePen Demo -- CSS Hudini Example & Custom Random: https://codepen.io/Chokcoco/pen/eYKVQGG

[13]

Exploring the CSS Paint API: Image Fragmentation Effect: https://dev.to/this-is-learning/exploring-the-css-paint-api-image-fragmentation-effect-3ekl

[14]

CanIUse - registerPaint: https://caniuse.com/?search=registerPaint

[15]

Chromium: https://www.google.com.hk/search?newwindow=1&rlz=1C5GCEM_enCN988CN988&q=Chromium&spell=1&sa=X&ved=2ahUKEwi3he2ensL7AhVaSmwGHdnzBxgQkeECKAB6BAgoEAE

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/W4-lMQhHb_wuaV3ItUT8Gw