现代 CSS 高阶技巧,像 Canvas 一样自由绘图构建样式!
阅读原文时间:2023年07月11日阅读:3

在上一篇文章中 -- 现代 CSS 之高阶图片渐隐消失术,我们借助了 CSS @Property 及 CSS Mask 属性,成功的实现了这样一种图片渐变消失的效果:

CodePen Demo -- 基于 @property 和 mask 的文本渐隐消失术

但是,这个效果的缺陷也非常明显,虽然借助了 SCSS 简化了非常多的代码,但是,如果我们查看编译后的 CSS 文件,会发现,在利用 SCSS 只有 80 的代码的情况下,编译后的 CSS 文件行数高达 2400+ 行,实在是太夸张了。

究其原因在于,我们利用原生的 CSS 去控制 400 个小块的过渡动画,控制了 400 个 CSS 变量!代码量因而变得如此之大。

那么,如何有效的降低代码量呢?

又或者说,在今天,是否 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. 现阶段兼容的问题

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 完成整个背景色的绘制。

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 自定义方法还原实现这个效果,简化 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

是的,细心的同学肯定会发现,文章一开头给的 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 虽然强大,目前看来要想大规模上生产环境,仍需一段时间的等待。让我们给时间一点时间!

好了,本文到此结束,希望本文对你有所帮助

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。