和 Houdini, CSS Paint API 打个招呼吧

2 个月前

原文链接:SAY HELLO TO HOUDINI AND THE CSS PAINT API
作者:Will Boyd
相关阅读:Houdini:CSS 领域最令人振奋的革新

浏览器发展至今,已经很久没有感受过这种期待了。

Hodini 的出现将赋予开发者前所未有的控制页面视觉表现的能力。这个项目的第一步是实现 CSS Paint API。本篇将解释为什么 Houdini 的到来让人如此兴奋,以及向读者展示如何开始使用 Paint API。

老生常谈的问题

相信每次要使用 CSS 新特性时,你都会看到下面这句话:

Wooo,这个效果太酷了!我想等到(大概两年后吧)大部分浏览器都支持的时候就用上。

但我们并不想等那么久,那干脆用 CSS polyfills 好了。但在一些边界情况下 polyfills 也无能为力。更何况它还可能带来性能问题。在大部分情况下原生浏览器的实现都优于 polyfills。

如果对此你还有疑问,可以看看这篇说的 CSS polyfill 的坏处

新的希望

看到这里,是不是有些失望了?别灰心,很快你不用等浏览器厂商,可以直接自己实现一个新忒性。这就是 Houdini 要做的事,它来自可拓展的 Web Manifesto,允许开发者直接操作浏览器的 CSS 引擎,开发者拥有极大的权限,甚至能干预浏览器原生的渲染流程。

这些自定义的 CSS 属性可以在 worklet 中定义,worklet 也用 JavaScript 编写,只是浏览器执行它们的方式和我们认知里不同,稍后会详聊这部分。成功使用之后, worklet 将在访问者的浏览器内植入了新特性,用户就能看到新特性下的视觉效果了。

这就表示,开发者不用再等待浏览器厂商了,只要支持了 Houdini 就能用上新特性。甚至是浏览器压根不打算实现的,开发者也能自力更生传达完美的效果给用户。

浏览器支持

好消息是 Apple、Google、微软、Mozilla、Opera 都是 Houdini 项目的推动者。不过到目前为止只有 Google Chrome 落地实施了这个计划。撰写本文时,各个浏览器厂商的实现程度:

01.005eb0aacbaf.png

这个表格信息量有些大,容我细细解释。

Houdini 就好比是一张拼图,它是一系列 API 的统称。开发者可以通过 Layout API 控制元素的布局;通过 Parser API 控制 CSS 表达式处理参数的逻辑…不过看得出来,Houdini 项目之路漫漫。

好消息是,其中一个 API 已经可以用起来了:Paint API。通过 Paint API 开发者可以画出图像,然后把这些图像运用到合适的 CSS 属性上,比如 bakcground-imagelist-style-image

暂时你还只能在 Chrome 上做试验。Chrome 65+ 已默认开启该接口,65 以下的 Chrome 需要通过访问 chrome://flags 开启 Experimental Web Platform features

可以通过以下任意一种方式确认 Chrome 是否支持该 API:

if ('paintWorklet' in CSS) {
    // 逻辑写这里
}
@supports (background: paint(id)) {
    /* 样式在此 */
}

也可以通过这个 Codepen demo 确认,如果访问链接看到的是两个绿色打钩,就说明浏览器已经准备好了!

技术性提示

Paint API 必须要在支持 https 服务器上或者本地 localhost 上才能使用。所以如果你是在本地开发,可以用 http-server 在本地快速搭建一个服务器。

要记得禁用浏览器缓存,让最新的 worklets 立马生效。

目前暂时无法在 worklets 中打断点或者插入 debugger ,不过 console.log() 还是可以用的。

简单的 Paint Worklet

让我们用 Paint API 搞点事情!先来个小前菜:在一个元素上画一个叉。这个效果的实际应用就是占位符,常见于一些模型设计/线框图中,表示该占位需要放一张图片。·

效果如下,代码在此

02.633f9c6d1bb4.jpg

绘制代码会被写入 paint worklet 中,它的作用域和功能都有限。Paint Worklet 无法操作 DOM 和全局方法(比如 setInterval)。这样的特性保证了 worklet 的高效和可多线程化(目前还不支持,但这点是众望所归)。

class PlaceholderBoxPainter {
    paint(ctx, size) {
        ctx.lineWidth = 2;
        ctx.strokeStyle = '#666';

        // 从左上角到右下角的一条线
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(size.width, size.height);
        ctx.stroke();

        // 从右上角到左下角的一条线
        ctx.beginPath();
        ctx.moveTo(size.width, 0);
        ctx.lineTo(0, size.height);
        ctx.stroke();
    }
}

registerPaint('placeholder-box', PlaceholderBoxPainter);

当重绘元素被触发时,paint() 方法就会被调用。它接收两个传入参数,第一个是将被绘制的 ctx 对象,和 CanvasRenderingContext2D 对象差不多,不过多了些限制(比如无法绘制文字)。size 决定了绘制元素的宽和高。

接下来,浏览器页面将接收这个 paint worklet,给页面加一个 <div class="placeholder"> 标签。

<script>
    CSS.paintWorklet.addModule('worklet.js');
</script>

<div class="placeholder"></div>

最后,将 worklet 和 <div> 通过 css 关联起来:

.placeholder {
    background-image: paint(placeholder-box);

    /* 其他样式... */
}

嗯,就是这样。

恭喜!看来你已经知道怎么用 Paint API 了!

Input Property 的使用

现在我们写的叉中,线的粗细程度和颜色都是硬编码的,如果想要改成对齐容器边框的粗细和颜色要怎么写呢?

我们可以通过 input property(输入属性)实现,这一特性由 Typed Object Model (也可以称之为 Typed OM)提供。Typed OM 同属于 Houdini,但和 Paint API 不同的是,需要手动开启 chrome://flags 中的 Experimental Web Platform features

可以通过下面的代码确认是否成功启用该特性:

if ('CSSUnitValue' in window) {
    // 样式在此
}

启用之后,就可以修改原来的 paint worklet 让它可以接收 input property 了:

class PlaceholderBoxPropsPainter {
    static get inputProperties() {
        return ['border-top-width', 'border-top-color'];
    }

    paint(ctx, size, props) {
        // 默认值
        ctx.lineWidth = 2;
        ctx.strokeStyle = '#666';

        // 设置线的宽度为(如果存在的)顶边宽度
        let borderTopWidthProp = props.get('border-top-width');
        if (borderTopWidthProp) {
            ctx.lineWidth = borderTopWidthProp.value;
        }

        // 设置线的样式为(如果存在的)定边样式
        let borderTopColorProp = props.get('border-top-color');
        if (borderTopColorProp) {
            ctx.strokeStyle = borderTopColorProp.toString();
        }

        // 上面 demo 中的代码从这里开始...
    }
}

registerPaint('placeholder-box-props', PlaceholderBoxPropsPainter);

通过添加 inputProperties,paint worklet 就知道要去哪里找 CSS 属性。paint() 函数也能够接收第三个传入参数 props,通过它获取到 CSS 属性值。现在,我们的占位符看着自然多了(codepen 链接):

03.713b193468b4.png

border 也可以,不过要记得这个属性其实是简写,背后其实有12个属性:

.shorthand {
    border: 1px solid blue;
}

.expanded {
    border-top-width: 1px;
    border-right-width: 1px;
    border-bottom-width: 1px;
    border-left-width: 1px;
    border-top-style: solid;
    border-right-style: solid;
    border-bottom-style: solid;
    border-left-style: solid;
    border-top-color: blue;
    border-right-color: blue;
    border-bottom-color: blue;
    border-left-color: blue;
}

paint worklet 需要指明具体属性,到目前为止的例子里,我们用到的属性是 border-top-widthborder-top-color

值得注意的是,paint worklet 在处理 border-top-width 时会转化为以像素为单位的数值。这个处理方式堪称完美,正是 ctx.lineWidth 所希望的处理方式。什么?怎么知道会转成像素的?看看 demo 中的第三个占位符,它的 border-top-width1rem,但 paint worklet 接收以后就变成了 16px

带锯齿的边界

让我们把目光投向新的舞台 — 用 paint worklet 画一个带锯齿的边界,代码在此

04.fc7958e7c9d4.png

接下来,让我们详细看看具体实现:

class JaggedEdgePainter {
    static get inputProperties() {
        return ['--tooth-width', '--tooth-height'];
    }

    paint(ctx, size, props) {
        let toothWidth = props.get('--tooth-width').value;
        let toothHeight = props.get('--tooth-height').value;

        // 为确保「牙齿」排列集中,需要进行一系列计算
        let spaceBeforeCenterTooth = (size.width - toothWidth) / 2;
        let teethBeforeCenterTooth = Math.ceil(spaceBeforeCenterTooth / toothWidth);
        let totalTeeth = teethBeforeCenterTooth * 2 + 1;
        let startX = spaceBeforeCenterTooth - teethBeforeCenterTooth * toothWidth;

        // 从左开始画
        ctx.beginPath();
        ctx.moveTo(startX, toothHeight);

        // 给所有「牙齿」画上锯齿
        for (let i = 0; i < totalTeeth; i++) {
            let x = startX + toothWidth * i;
            ctx.lineTo(x + toothWidth / 2, 0);
            ctx.lineTo(x + toothWidth, toothHeight);
        }

        // 闭合「牙齿」的曲线,并填色
        ctx.lineTo(size.width, size.height);
        ctx.lineTo(0, size.height);
        ctx.closePath();
        ctx.fill();
    }
}

registerPaint('jagged-edge', JaggedEdgePainter);

这里我们又用上了 inputProperties,需要控制每个「牙齿」的宽度和高度。还用到了自定义属性(也被称为CSS 变量--tooth-width--tooth-height。这确实比占用现有的 CSS 属性要好,但想在 paint worklet 中使用自定义属性还要多走一步。

你看,浏览器能够识别它已知的 CSS 属性值和对应的变量值,知道某一个属性需要「长度」作为它的属性值(比如上面的 border-top-width)。但自定义属性是开发者控制的,会有各种各样的属性值,浏览器不知道哪个属性该对应什么样的值才合法。所以要用自定义属性就多了一步,需要告知浏览器识别属性值。

Properties and Values API 做的就是这件事情。这个 API 也是 Houdini 的一部分,同样需要手动开启(译者:方法同上,不再赘述)。

可以通过 JS 确认是否成功开启:

if ('registerProperty' in CSS) {
    // 这里写代码
}

确认开启后,在 paint worklet 外面加上下面这一段:

CSS.registerProperty({
    name: '--tooth-width',
    syntax: '<length>',
    initialValue: '40px'
});
CSS.registerProperty({
    name: '--tooth-height',
    syntax: '<length>',
    initialValue: '20px'
});

--tooth-width--tooth-height 上填长度相关的值后,浏览器就知道在 paint worklet 中使用这两个属性时,需要把对应值转成像素。甚至可以用 calc() !如果不小心写成非长度值,则会传入 initialValue 不至于报错。

.jagged {
    background: paint(jagged-edge);
    /* 其他样式... */
}

.slot:nth-child(1) .jagged {
    --tooth-width: 50px;
    --tooth-height: 25px;
}

.slot:nth-child(2) .jagged {
    --tooth-width: 2rem;
    --tooth-height: 3rem;
}

.slot:nth-child(3) .jagged {
    --tooth-width: calc(33vw - 31px);
    --tooth-height: 2em;
}

并不是只允许使用 <length> 类型,更多可选类型请参考这里

比如我们也能定义 --tooth-color 自定义属性,并规定属性值是 <color>。不过在实现锯齿边距上,我还有个更好的方案:在 paint worklet 中用 -webkit-mask-image 。这个方案不用修改锯齿背景色就能实现各种各样背景的锯齿了:

.jagged {
    --tooth-width: 80px;
    --tooth-height: 30px;
    -webkit-mask-image: paint(jagged-edge);

    /* 其他样式... */
}

.slot:nth-child(1) .jagged {
    background-image: linear-gradient(to right, #22c1c3, #fdbb2d);
}

.slot:nth-child(2) .jagged {
    /* 图源来自游戏 Iconoclasts http://www.playiconoclasts.com/ */
    background-image: url('iconoclasts.png');
    background-size: cover;
    background-position: 50% 0;
}

paint worklet 代码修改不大,具体效果如下:

05.9ba309ff7814.png

输入参数

可以通过输入参数 (input arguments) 向 paint worklet 中传参,从 CSS 中传入参数:

.solid {
    background-image: paint(solid-color, #c0eb75);

    /* 其他的样式... */
}

paint worklet 中定义了 inputArguments 需要传入什么样的参数。paint() 函数可以通过第四个传入参数获取到所有 inputArguments,第四个参数是名为 args 的数组:

class SolidColorPainter {
    static get inputArguments() {
        return ['<color>'];
    }

    paint(ctx, size, props, args) {
        ctx.fillStyle = args[0].toString();
        ctx.fillRect(0, 0, size.width, size.height);
    }
}

registerPaint('solid-color', SolidColorPainter);

说实话,我并非这种写法的拥趸。而且我认为相比之下,自定义属性更灵活,还可以通过变量名得到自文档化的 CSS。

动画革命

最后一个 demo 了。通过以上所学知识,我们能做出下面这漂亮的褪色圆点图案

06.e607e085b15d.png

为了控制这些渐变点,第一步就是先注册几个自定义属性:

CSS.registerProperty({
    name: '--dot-spacing',
    syntax: '<length>',
    initialValue: '20px'
});
CSS.registerProperty({
    name: '--dot-fade-offset',
    syntax: '<percentage>',
    initialValue: '0%'
});
CSS.registerProperty({
    name: '--dot-color',
    syntax: '<color>',
    initialValue: '#fff'
});

注册之后 paint worklet 就能使用这些变量啦,接下来就是进行一系列计算,画出想要的褪色效果:

class PolkaDotFadePainter {
    static get inputProperties() {
        return ['--dot-spacing', '--dot-fade-offset', '--dot-color'];
    }

    paint(ctx, size, props) {
        let spacing = props.get('--dot-spacing').value;
        let fadeOffset = props.get('--dot-fade-offset').value;
        let color = props.get('--dot-color').toString();

        ctx.fillStyle = color;
        for (let y = 0; y < size.height + spacing; y += spacing) {
            for (let x = 0; x < size.width + spacing; x += spacing * 2) {
                // 通过变换 x 在每一行中创建交错的点
                let staggerX = x + ((y / spacing) % 2 === 1 ? spacing : 0);

                // 通过 fade offset和每个点的横坐标,计算出该点的半径
                let fadeRelativeX = staggerX - size.width * fadeOffset / 100;
                let radius = spacing * Math.max(Math.min(1 - fadeRelativeX / size.width, 1), 0);

                // 画出目标点
                ctx.beginPath();
                ctx.arc(staggerX, y, radius, 0, 2 * Math.PI);
                ctx.fill();
            }
        }
    }
}

registerPaint('polka-dot-fade', PolkaDotFadePainter);

最后,还要在 CSS 中用上这个 paint worklet 才能看到效果:

.polka-dot {
    --dot-spacing: 20px;
    --dot-fade-offset: 0%;
    --dot-color: #40e0d0;
    background: paint(polka-dot-fade);

    /* 其他样式... */
}

现在,故事的转折点来了!动画效果可以通过改变自定义属性的方式实现。当属性值发生变化时,paint worklet 会被调用,然后浏览器重绘元素,最终实现动画效果。

那么来试试通过 CSS 动画中的 keyframestransition 也可以)改变 --dot-fade-offset--dot-color

.polka-dot {
    --dot-spacing: 20px;
    --dot-fade-offset: 0%;
    --dot-color: #fc466b;
    background: paint(polka-dot-fade);

    /* 其他样式... */
}

.polka-dot:hover, .polka-dot:focus {
    animation: pulse 2s ease-out 6 alternate;

    /* 其他样式... */
}

@keyframes pulse {
    from {
        --dot-fade-offset: 0%;
        --dot-color: #fc466b;
    }
    to {
        --dot-fade-offset: 100%;
        --dot-color: #3f5efb;
    }
}

最终效果如下,完整代码在此

banner.54e9e80d1008.gif

看到 houdini 的潜力了吧!是不是酷毙了,paint worlets + 自定义属性的组合将会给动画带来革命!

优点和缺点

让我们再回顾一下 Houdini 的优点(着重回顾本篇大量用到的 CSS Paint API):

  • 不受限制,开发者能创造各种各样的视觉效果。
  • 不需要新增 DOM 节点。
  • 在浏览器渲染管道中执行,效率高。
  • 比起 polyfill,更加性能友好,也更健壮。
  • 这是浏览器原生支持的接口,开发者能有不用 hack 的选择了。
  • 用于实现视觉效果的 CSS 常常被诟病不像一门编程语言,几乎无法表达完整的逻辑。那现在可以用 paint worklet 编写视觉效果上的逻辑了。
  • 动画革命。
  • 快浏览器厂商一步实现特性,而且这些特性能实实在在地展现在用户的设备上。
  • 五大浏览器厂商都表示支持 Houdini。

当然了,缺点也不能避而不谈:

  • Houdini 的实现之路漫漫。
  • 虽然它可以缓解兼容问题,但首先,浏览器们得先兼容 Houdini…
  • 浏览器加载 paint worklet 并执行它需要时间,这是异步的,可能导致样式上的闪动。
  • 开发者工具尚不支持 paint worklet 的断点调试(也不支持 debugger),不过 console.log() 还能用。

结论

Houdini 将会改变我们现在编写 CSS 的方式。虽然可能它将历时不短,但从目前可用的部分(比如,Paint API)来看,潜力惊人。所以,请继续关注 Houdini 啊~

本文中用到的 demo 都在 Github 上了。更多效果请移步 @iamvdo作品

1
推荐阅读