anjia / blog

博客,积累与沉淀

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

CSS Painting API

anjia opened this issue · comments

今天和大家分享一个非常酷炫的 API CSS Painting API。它是 CSS Houdini #23 的一部分。

简介

它能做什么呢?简单点说,它可以让网页开发人员干预浏览器的绘制(Paint)环节。

为什么要干预绘制环节呢?干预绘制,意味着开发人员可以自行决定页面要绘制成的样子,而不一定非要等到浏览器支持才行。

举个例子,CSS3 的新属性conic-gradient圆锥形渐变:

<style>
    div {
        display: inline-block;
        width: 150px; height: 150px; margin: 10px; 
        border-radius: 50%;
    }
    .color-palette {
        background: conic-gradient(red, yellow, lime, aqua, blue, magenta, red);
    }
    .color-rgb {
        border: 1px solid #999;
        background: conic-gradient(red 0, red 16%,white 16%, white 32%,green 32%, green 48%,white 48%, white 64%,blue 64%, blue 80%,white 80%, white);
    }
</style>

<div class="color-palette"></div>
<div class="color-rgb"></div>

以上代码的运行效果如下,也可在线预览(Chrome 69+):

根据 Can I use,目前仅 Chrome 支持conic-gradient。但是,有了CSS Painting API,我们就可以自己画出类似效果,然后在项目中使用了,而不用等到所有的浏览器都支持conic-gradient

当然,除了充当 CSS3 新特性的 polyfill 之外,我们还可以用它画任意形状。比如钻石状的 Div

比如,符合 Google Material Design波纹效果

浏览器支持情况

截止目前,CSS Painting API 的浏览器支持情况如下:

  • Chrome 65+ 和 Opera 52+ 已经支持
  • Firefox 有实现的意愿
  • Safari 还在考虑中
  • Edge 暂无反馈

也有相应的 CSS Paint Polyfill 供我们选择。

W3C 标准层面

CSS Painting API Level 1 已于今年8月9日成为候选推荐标准,这意味着该模块的所有已知 Issues 均已被解决,并且已经开始向浏览器厂商征集实现。

接下来,我们通过一个实例来理解下 Paint API。

使用方法

使用挺简单的,就这三步:

  1. 在 CSS 中使用指定的 Paint Worklet,用paint()
  2. 加载定义了 Paint Worklet 的脚本文件,用CSS.paintWorklet.addModule()
  3. 定义 Paint Worklet,用registerPaint()

核心代码

在 index.html 里

<style>
  .css-paint {
    /* 1. 通过 paint() 调用指定的 Paint Worklet 'checkerboard'*/
    background-image: paint(checkerboard); 
  }
</style>

<div class="css-paint"></div>

<script>
  // 2. 加载 Paint Worklet
  CSS.paintWorklet.addModule('my_paint_worklet.js');
</script>

在 my_paint_worklet.js 里

class CheckerboardPainter {
  paint(ctx, geom) {
    const size = 30;
    const spacing = 10;
    const colors = ['red', 'green', 'blue'];
    for(let y = 0; y < geom.height/size; y++) {
      for(let x = 0; x < geom.width/size; x++) {
        ctx.fillStyle = colors[(x + y) % colors.length];
        ctx.beginPath();
        ctx.rect(x*(size + spacing), y*(size + spacing), size, size);
        ctx.fill();
      }
    }
  }
}
// 3. 定义 Paint Worklet 'checkerboard'
registerPaint('checkerboard', CheckerboardPainter);

运行后的效果如下。我们可以看到,绘制的背景是响应式的。

自定义参数

Paint Worklet 也支持自定义参数,我们通过自定义属性来实现。

改动三处即可:

  1. 在 CSS 里增加自定义属性
  2. 在定义 Paint Worklet 时
    • 指定绘制时可以访问的属性列表
    • 绘制时,获取属性的值

具体代码如下:

在 index.html 里,自定义 CSS 属性

<style>
  .css-paint {
    --checkerboard-size: 32;   /* 1. 自定义2个参数 */
    --checkerboard-spacing: 10;  
    background-image: paint(checkerboard);
  }
</style>

在 my_paint_worklet.js 里,接收参数

class CheckerboardPainter {
    // 2.1 静态方法,返回 paint() 可以访问的 CSS 属性列表
    static get inputProperties() {
      return ['--checkerboard-spacing', '--checkerboard-size'];
    }

    // 注意:新增了第三个参数 properties
    paint(ctx, geom, properties) {
      // 2.2 获取输入参数的值
      const size = parseInt(properties.get('--checkerboard-size').toString());
      const spacing = parseInt(properties.get('--checkerboard-spacing').toString());
      // ... 同上
    }
}
registerPaint('checkerboard', CheckerboardPainter);

这样,当修改自定义属性时,绘制的图像也会相应变化。效果如下:

优雅降级

当浏览器不支持 Paint API 时,我们需要向前兼容。修改以下两处:

  1. 在写 CSS 时,给属性写个备用值
  2. 在 JS 里加载 Paint Worklet 之前,先做个判断

具体代码如下:

在 index.html 里,修改两处

<style>
  .css-paint {
    background-image: linear-gradient(0, red, blue);  /* 1. 备用值 */
    background-image: paint(checkerboard);
  }
</style>

<script>
  // 2. 判断是否支持
  if ('paintWorklet' in CSS) {
    CSS.paintWorklet.addModule('my_paint_worklet.js');
  }
</script>

完整代码见 https://github.com/anjia/blog/tree/master/src/css-paint-api
注意,代码需要运行在 localhost 或 HTTPS 下

在上面的实例中,我们用到了三个函数:

  1. paint():在 CSS 中使用指定的 Paint Worklet
  2. CSS.paintWorklet.addModule():加载定义了 Paint Worklet 的脚本文件
  3. registerPaint():定义 Paint Worklet

下面,我们将对它们进行进一步介绍。

关键代码解析

CSS.paintWorklet.addModule()

CSS 的 paintWorklet 属性提供了与绘制相关的 Worklet,它的全局执行上下文是 PaintWorkletGlobalScope。PaintWorkletGlobalScope 里存了 devicePixelRatio 属性,它和 Window.devicePixelRatio 一样。

CSS.paintWorklet.addModule('filename.js')负责加载定义了 Paint Worklet 的脚本文件。

registerPaint()

下面是实例代码中文件 my_paint_worklet.js 里的内容。

class CheckerboardPainter {
  static get inputProperties() {
    return ['--checkerboard-spacing', '--checkerboard-size'];
  }

  paint(ctx, geom, properties) {
    const size = parseInt(properties.get('--checkerboard-size').toString());
    const spacing = parseInt(properties.get('--checkerboard-spacing').toString());
    const colors = ['red', 'green', 'blue'];
    for(let y = 0; y < geom.height/size; y++) {
      for(let x = 0; x < geom.width/size; x++) {
        ctx.fillStyle = colors[(x + y) % colors.length];
        ctx.beginPath();
        ctx.rect(x*(size + spacing), y*(size + spacing), size, size);
        ctx.fill();
      }
    }
  }
}
registerPaint('checkerboard', CheckerboardPainter);

Paint Worklet 的全局脚本上下文只给开发人员暴露了一个方法registerPaint(),用来注册。

registerPaint(name, paintCtor) 有两个参数:

  • name 是 Paint Worklet 的名字。必填,且全局唯一
  • paintCtor 是一个类。之所以用类,是考虑到:
    • 类之间可以相互组合,比如继承
    • 类可以执行一些预初始化的工作,比如只执行一次的 CPU 密集型工作

e.g. 继承

class RectPainter {
  static get inputProperties() {
    return ['--rect-color'];
  }
  paint(ctx, size, style) {
    //...
  }
}

class BorderRectPainter extends RectPainter {
  static get inputProperties() {
    return ['--border-color', ...super.inputProperties];
  }
  paint(ctx, size, style) {
    super.paint(ctx, size, style);
    //...
  }
}

registerPaint('border-rect', BorderRectPainter);

e.g. 预初始化工作

registerPaint('lots-of-paths', class {

  constructor() {
    this.paths = performPathPreInit();
  }
  
  performPathPreInit() {
    // Lots of work here to produce list of Path2D object to be reused.
  }
  
  paint(ctx, size, style) {
    ctx.stroke(this.paths[i]); 
  }
});

paint 函数

paintCtor的类里有个函数paint(),它是渲染引擎在浏览器绘制阶段的回调。

回调会传3个参数 paint(ctx, geom, properties)

  • ctx 绘制的渲染上下文 PaintRenderingContext2D
  • geom 绘制的图像大小 pageSize,它有两个只读属性 width 和 height
  • properties 当前绘制元素的计算样式,它只包含inputProperties里列出的属性

以下三种情况,都会触发回调的调用:

  • 视口要显示绘制的元素了。i.e. 初始创建 Paint 类的实例对象时
  • 绘制区域的大小变了。i.e. 网页响应式
  • inputProperties 列出的属性值变了。i.e. 图像可根据参数的改变而改变

绘制的渲染上下文

PaintRenderingContext2DCanvasRenderingContext2D 的子集。所以,会用 Canvas 的小伙伴也就会在 Paint Worklet 里绘制图像了。

它目前不支持 CanvasImageData, CanvasUserInterface, CanvasText, CanvasTextDrawingStylesAPI
详见 PaintRenderingContext2D

PaintRenderingContext2D 对象有一个输出位图,当类的实例被创建时,它就被初始化。输出位图的大小,不一定等于实际渲染的位图大小,因为实际输出的图像会根据设备像素比的不同而不同。浏览器会记住paint()里绘制的操作序列,以便动态适应不同的设备像素比,这样就保证了图像在高清屏下的显示质量。

未来的规范会提供不同类型的渲染上下文,比如 WebGL 的渲染上下文,这样就能绘制 3D 效果了

inputProperties

在输入属性列表inputProperties里列出的属性,意味着:

  • 可以在paint()回调里访问它们,这些属性会通过第三个参数properties传过去
  • Paint Worklet 会订阅这些属性,以便在它们的值发生改变时触发paint()回调,以实时重新绘制图像

paint() 函数

最后,就是在 CSS 中使用写好的 Paint Worklet 了。写法如下:

.css-paint {
  background-image: paint(checkerboard);
}

参数 checkerboard 就是 Paint Worklet 的名字,即在registerPaint(name, paintCtor)里提供的 name。

paint()是 CSS 的 <image> 类型支持的一种写法。我们平时用url()加载图片或者用渐变函数linear-gradient()的地方都可以使用paint()。它可以用在 background-image、border-image、list-style-image 和 cursor 等属性上。

并不是在 CSS 里每调用一次piant()就执行一次 Paint Worklet 类的 paint 方法,而是当元素的大小改变时,或者 inputProperties 里声明的属性值改变时才会触发。

最后

CSS Painting API 给网页开发人员提供了通过 JS 绘制<image>的能力,具体图像长什么样子以及页面如何交互,我们可以充分发挥自己的想象力。可以留意日常工作和业务里的点滴,也可以去 CSS Houdini 寻找灵感。

主要参考

Google Developers.CSS Paint API
CSS Paint API Explained
CSS Painting API Level 1

更多阅读

CSS Houdini 的九大内容:CSS Painting API 只是其中之一
谈谈 Animation Worklet