在这一篇中,我们将继续探索,尝试使用 CSS Painting API,去实现过往 CSS 中非常难以实现的一个点,那就是如何绘制不规则图形的边框。

CSS Painting API

再简单快速的过一下,什么是 CSS Painting API。

CSS Painting API 是 CSS Houdini 的一部分。而 Houdini 是一组底层 API,它们公开了 CSS 引擎的各个部分,从而使开发人员能够通过加入浏览器渲染引擎的样式和布局过程来扩展 CSS。Houdini 是一组 API,它们使开发人员可以直接访问 [CSS 对象模型][] (CSSOM),使开发人员可以编写浏览器可以解析为 CSS 的代码,从而创建新的 CSS 功能,而无需等待它们在浏览器中本地实现。

CSS Paint API 目前的版本是 [CSS Painting API Level 1][]。它也被称为 CSS Custom Paint 或者 Houdini’s Paint Worklet。

我们可以把它理解为 JS In CSS,利用 JavaScript Canvas 画布的强大能力,实现过往 CSS 无法实现的功能。

过往 CSS 实现不规则图形的边框方式

CSS 实现不规则图形的边框,一直是 CSS 的一个难点之一。在过往,虽然我们有很多方式利用 Hack 出不规则图形的边框,我在之前的多篇文章中有反复提及过:

  • [有意思!不规则边框的生成方案][]
  • [CSS 奇技淫巧 | 巧妙实现文字二次加粗再加边框][]

我们来看看这样一个图形:

利用 CSS 实现这样一个图形是相对简单的,可以利用 mask 或者 background 中的渐变实现,像是这样:

<div class="arrow-button"></div>
.arrow-button { position: relative; width: 180px; height: 64px; background: #f49714; &::after { content: ""; position: absolute; width: 32px; height: 64px; top: 0; right: -32px; background: linear-gradient(-45deg, transparent 0, transparent 22px, #f49714 22px, #f49714 100%), linear-gradient(-135deg, transparent 0, transparent 22px, #f49714 22px, #f49714 100%); background-size: 32px 32px; background-repeat: no-repeat; background-position: 0 bottom, 0 top; } }

但是,如果,要实现这个图形,但是只有一层边框,利用 CSS 就不那么好实现了,像是这样:

image

在过往,有两种相对还不错的方式,去实现这样一个不规则图形的边框:

  1. 借助 filter,利用多重 drop-shadow()
  2. 借助 SVG 滤镜实现

我们快速回顾一下这两个方法。

借助 filter,利用多重 drop-shadow() 实现不规则边框

还是上面的图形,我们利用多重 drop-shadow(),可以大致的得到它的边框效果。代码如下:

div { position: relative; width: 180px; height: 64px; background: #fff; &::after { content: ""; position: absolute; width: 32px; height: 64px; top: 0; right: -32px; background: linear-gradient(-45deg, transparent 0, transparent 22px, #fff 22px, #fff 100%), linear-gradient(-135deg, transparent 0, transparent 22px, #fff 22px, #fff 100%); background-size: 32px 32px; background-repeat: no-repeat; background-position: 0 bottom, 0 top; } } div { filter: drop-shadow(0px 0px .5px #000) drop-shadow(0px 0px .5px #000) drop-shadow(0px 0px .5px #000); }

可以看到,这里我们通过叠加 3 层 drop-shadow(),来实现不规则图形的边框,虽然 drop-shadow() 是用于生成阴影的,但是多层值很小的阴影叠加下,竟然有了类似于边框的效果:

image

借助 SVG 滤镜实现实现不规则边框

另外一种方式,需要掌握比较深的 SVG 滤镜知识。通过实现一种特殊的 SVG 滤镜,再通过 CSS 的 filter 引入,实现不规则边框。

看看代码:

<div></div> <svg width="0" height="0"> <filter id="outline"> <feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="1"></feMorphology> <feMerge> <feMergeNode in="DILATED" /> <feMergeNode in="SourceGraphic" /> </feMerge> </filter> </svg>
div { position: relative; width: 180px; height: 64px; background: #fff; &::after { content: ""; position: absolute; width: 32px; height: 64px; top: 0; right: -32px; background: linear-gradient(-45deg, transparent 0, transparent 22px, #fff 22px, #fff 100%), linear-gradient(-135deg, transparent 0, transparent 22px, #fff 22px, #fff 100%); background-size: 32px 32px; background-repeat: no-repeat; background-position: 0 bottom, 0 top; } } div { filter: url(#outline); }

简单浅析一下这段 SVG 滤镜代码:

  1. <feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="1"></feMorphology> 将原图的不透明部分作为输入,采用了 dilate 扩张模式且程度为 radius=“1”,生成了一个比原图大 1px 的黑色图块
  2. 使用 feMerge 将黑色图块和原图叠加在一起
  3. 可以通过控制滤镜中的 radius=“1” 来控制边框的大小

这样,也可以实现不规则图形的边框效果:

image

[CodePen Demo – 3 ways to achieve unregular border][]

利用 CSS Painting API 实现不规则边框

那么,到了今天,利用 CSS Painting API ,我们有了一种更为直接的方式,更好的解决这个问题。

还是上面的图形,我们利用 clip-path 来实现一下。

<div></div>
div { position: relative; width: 200px; height: 64px; background: #f49714; clip-path: polygon(85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%;); }

我们可以得到这样一个图形:

image

当然,本文的主角是 CSS Painting API,既然我们有 clip-path 的参数,其实完全也可以利用 CSS Painting API 的 borderDraw 来绘制这个图形。

我们尝试一下,改造我们的代码:

<div></div> <script> if (CSS.paintWorklet) { CSS.paintWorklet.addModule('/CSSHoudini.js'); } </script>
div { position: relative; width: 200px; height: 64px; background: paint(borderDraw); --clipPath: 85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%;); }

这里,我们将原本的 clip-path 的具体路径参数,定义为了一个 CSS 变量 --clipPath,传入我们要实现的 borderDraw 方法中。整个图形效果,就是要利用 background: paint(borderDraw) 绘制出来。

接下来,看看,我们需要实现 borderDraw。核心的点在于,我们通过拿到 --clipPath 参数,解析它,然后通过循环函数利用画布把这个图形绘制出来。

// CSSHoudini.js 文件 registerPaint( "borderDraw", class { static get inputProperties() { return ["--clipPath"]; } paint(ctx, size, properties) { const { width, height } = size; const clipPath = properties.get("--clipPath"); const paths = clipPath.toString().split(","); const parseClipPath = function (obj) { const x = obj[0]; const y = obj[1]; let fx = 0, fy = 0; if (x.indexOf("%") > -1) { fx = (parseFloat(x) / 100) * width; } else if (x.indexOf("px") > -1) { fx = parseFloat(x); } if (y.indexOf("%") > -1) { fy = (parseFloat(y) / 100) * height; } else if (y.indexOf("px") > -1) { fy = parseFloat(y); } return [fx, fy]; }; var p = parseClipPath(paths[0].trim().split(" ")); ctx.beginPath(); ctx.moveTo(p[0], p[1]); for (var i = 1; i < paths.length; i++) { p = parseClipPath(paths[i].trim().split(" ")); ctx.lineTo(p[0], p[1]); } ctx.closePath(); ctx.fill(); } } );

简单解释一下上述的代码,注意其中最难理解的 parseClipPath() 方法的解释。

  1. 首先我们,通过 properties.get("--clipPath"),我们能够拿到传入的 --clipPath 参数
  2. 通过 spilt() 方法,将 --clipPath 分成一段段,也就是我们的图形实际的绘制步骤
  3. 这里有一点非常重要,也就是 parseClipPath() 方法,由于我们的 -clipPath 的每一段可能是 100% 50% 这样的构造,但是实际在绘图的过程中,我们需要的实际坐标的绝对值,譬如在一个 100 x 100 的画布上,我们需要将 50% 50% 的百分比坐标,转化为实际的 50 50 这样的绝对值
  4. 在理解了 parseClipPath() 后,剩下的就都非常好理解了,我们通过 ctx.beginPath()ctx.movectx.lineTo 以及 ctx.closePath() 将整个 --clipPath 的图形绘制出来
  5. 最后,利用 ctx.fill() 给图形上色

这样,我们就得到了这样一个图形:

image

都拿到了完整的图形了,那么我们只给这个图形绘制边框,不上色,不就得到了它的边框效果了吗?

简单改造一些 JavaScript 代码的最后部分:

// CSSHoudini.js 文件 registerPaint( "borderDraw", class { static get inputProperties() { return ["--clipPath"]; } paint(ctx, size, properties) { // ... ctx.closePath(); // ctx.fill(); ctx.lineWidth = 1; ctx.strokeStyle = "#000"; ctx.stroke(); } } );

这样,我们就得到了图形的边框效果:

image

仅仅利用 background 绘制的缺陷

但是,仅仅利用 [bacg](background: paint(borderDraw)) 来绘制边框效果,会有一些问题。

上述的图形,我们仅仅赋予了 1px 的边框,如果我们把边框改成 5px 呢?看看会发生什么?

// CSSHoudini.js 文件 registerPaint( "borderDraw", class { static get inputProperties() { return ["--clipPath"]; } paint(ctx, size, properties) { // ... ctx.lineWidth = 5; ctx.strokeStyle = "#000"; ctx.stroke(); } } );

此时,整个图形会变成:

image

可以看到,没有展示完整的 5px 的边框,这是由于整个画布只有元素的高宽大小,而上述的代码中,元素的边框有一部分绘制到了画布之外,因此,整个图形并非我们期待的效果。

因此,我们需要换一种思路解决这个问题,继续改造一下我们的代码,仅仅需要改造 CSS 代码即可:

div { position: relative; width: 200px; height: 64px; margin: auto; clip-path: polygon(var(--clipPath)); --clipPath: 85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%; &::before { content:""; position:absolute; inset: 0; mask: paint(borderDraw); background: #000; } }

这里,我们的元素本身,还是利用了 clip-path: polygon(var(--clipPath)) 剪切了自身,同时,我们借助了一个伪元素,利用这个伪元素去实现具体的边框效果。

这里其实用了一种内外切割的思想,去实现的边框效果:

  1. 利用父元素的 clip-path: polygon(var(--clipPath)) 剪切掉外围的图形
  2. 利用给伪元素的 mask 作用实际的 paint(borderDraw) 方法,把图形的内部镂空,只保留边框部分

还是设置 ctx.lineWidth = 5,再看看效果:

image

看上去不错,但是实际上,虽然设置了 5px 的边框宽度,但是实际上,上图的边框宽度只有 2.5px 的,这是由于另外一点一半边框实际上被切割掉了。

因此,我们如果需要实现 5px 的效果,实际上需要 ctx.lineWidth =10

当然,我们可以通过一个 CSS 变量来控制边框的大小:

div { position: relative; width: 200px; height: 64px; margin: auto; clip-path: polygon(var(--clipPath)); --clipPath: 85% 0%, 100% 50%, 85% 100%, 0% 100%, 0% 0%; --borderWidth: 5; &::before { content:""; position:absolute; inset: 0; mask: paint(borderDraw); background: #000; } }

在实际的 borderDraw 函数中,我们将传入的 --borderWidth 参数,乘以 2 使用就好:

registerPaint( "borderDraw", class { static get inputProperties() { return ["--clipPath", "--borderWidth"]; } paint(ctx, size, properties) { const borderWidth = properties.get("--borderWidth"); // ... ctx.lineWidth = borderWidth * 2; ctx.strokeStyle = "#000"; ctx.stroke(); } } );

这样,我们每次都能得到我们想要的边框长度:

image

[CodePen Demo – CSS Hudini & Unregular Custom Border][]

到这里,整个实现就完成了,整个过程其实有多处非常关键的点,会有一点点难以理解,具体可能需要自己实际调试一遍找到实现的原理。

具体应用

在掌握了上述的方法后,我们就可以利用这个方式,实现各类不规则图形的边框效果,我们只需要传入对于的 clip-path 参数以及我们想要的边框长度即可。

好,这样,我们就能实现各类不同的不规则图形的边框效果了。

像是这样:

div { position: relative; width: 200px; height: 200px; clip-path: polygon(var(--clipPath)); --clipPath: 0% 15%, 15% 15%, 15% 0%, 85% 0%, 85% 15%, 100% 15%, 100% 85%, 85% 85%, 85% 100%, 15% 100%, 15% 85%, 0% 85%; --borderWidrh: 1; --color: #000; &::before { content:""; position:absolute; inset: 0; mask: paint(borderDraw); background: var(--color); } } div:nth-child(2) { --clipPath: 50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%; --borderWidrh: 2; --color: #ffcc00; } div:nth-child(3) { --clipPath: 90% 58%90% 58%, 69% 51%, 69% 51%, 50% 21%, 50% 21%, 39% 39%, 39% 39%, 15% 26%, 15% 26%, 15% 55%, 15% 55%, 31% 87%, 31% 87%, 14% 84%, 14% 84%, 44% 96%, 44% 96%, 59% 96%, 59% 96%, 75% 90%, 75% 90%, 71% 83%, 71% 83%, 69% 73%, 69% 73%, 88% 73%, 88% 73%, 89% 87%, 89% 87%, 94% 73%, 94% 73%; --borderWidrh: 1; --color: deeppink; } div:nth-child(4) { --clipPath: 0% 0%, 100% 0%, 100% 75%, 75% 75%, 75% 100%, 50% 75%, 0% 75%; --borderWidrh: 1; --color: yellowgreen; } div:nth-child(5) { --clipPath: 20% 0%, 0% 20%, 30% 50%, 0% 80%, 20% 100%, 50% 70%, 80% 100%, 100% 80%, 70% 50%, 100% 20%, 80% 0%, 50% 30%; --borderWidrh: 3; --color: #c7b311; }

得到不同图形的边框效果:

image

[CodePen Demo – CSS Hudini & Unregular Custom Border][]

又或者是基于它们,去实现各类按钮效果,这种效果在以往使用 CSS 是非常非常难实现的:

image

它们的核心原理都是一样的,甚至加上 Hover 效果,也是非常的轻松:

完整的代码,你可以戳这里:[CodePen Demo – https://codepen.io/Chokcoco/pen/ExRLqdO][]

至此,我们再一次利用 CSS Painting API 实现了我们过往 CSS 完全无法实现的效果。这个也就是 CSS Houdini 的魅力,是 JS In CSS 的魅力。

兼容性?

好吧,其实上一篇文章也谈到了兼容问题,因为可能有很多看到本篇文章并没有去翻看前两篇文章的同学。那么,CSS Painting API 的兼容性到底如何呢?

[CanIUse - registerPaint][] 数据如下(截止至 2022-11-23):

image

Chrome 和 Edge 基于 [Chromium][] 内核的浏览器很早就已经支持,而主流浏览器中,Firefox 和 Safari 目前还不支持。

CSS Houdini 虽然强大,目前看来要想大规模上生产环境,仍需一段时间的等待。让我们给时间一点时间!