CSS Paint API是Houdini开放给开发者的底层接口之一,它让JavaScript能够参与CSS属性的图像生成过程。过去我们想要在元素的背景上画点阵、波浪或者随机纹理,要么切图,要么用复杂的CSS渐变堆叠,维护成本很高。Paint API的思路是直接提供一个paint worklet,你在里面用Canvas2D的绘制指令生成图像,然后CSS就能像使用url()图片一样使用它。

一、CSS Paint API的基础概念
CSS Paint API的核心是两个端:一端是JavaScript里的worklet文件,用来定义绘制逻辑;另一端是CSS属性,通过paint()函数消费这个逻辑。worklet和普通的JS脚本不同,它运行在渲染线程的孤立环境中,不能直接操作DOM,也不能用window上的很多API,但可以用传入的CanvasRenderingContext2D来画图。
这种架构带来的好处是绘制过程和主线程解耦。比如列表里有上百个项都用同一个动态噪声背景,主线程的滚动和交互不会被绘图卡住。同时因为绘制参数可以从CSS自定义属性传进去,所以同一套worklet能根据--color、--size等变量画出不同效果,比写死图片灵活得多。
1.1 registerPaint的结构
在worklet脚本里,我们通过registerPaint注册一个绘制类。这个类必须实现paint方法,接收ctx、size和props三个参数。ctx就是画布上下文,size给出当前绘制区域的宽高,props是CSS自定义属性的集合。下面是一个最简单的例子,画一个实心方块背景:
// paint-solid.js
registerPaint('solid-box', class {
static get inputProperties() {
return ['--box-color'];
}
paint(ctx, size, props) {
const color = props.get('--box-color') || 'red';
ctx.fillStyle = color;
ctx.fillRect(0, 0, size.width, size.height);
}
});
上面代码里inputProperties声明了依赖的CSS变量,只有这些变量变化时才重新绘制。如果不声明,则绘制一次后缓存。这一点在写复杂动画背景时要特别注意,声明少了不会更新,声明多了又可能频繁重绘。
1.2 在页面中加载worklet
worklet文件不能通过普通script标签引入,必须用CSS.paintWorklet.addModule异步加载。由于网络或浏览器不支持可能失败,需要包裹在特性判断里。下面是在主页面注册并使用的典型写法:
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('paint-solid.js').catch(function(e) {
console.log('加载worklet失败', e);
});
}
加载成功后,CSS里就可以用paint(solid-box)作为图像值。如果浏览器不支持Paint API,这行声明会被忽略,此时需要提前写好降级背景,比如纯色或渐变,避免元素透明。
二、在CSS中调用绘制结果
调用方式非常直观,把paint()放在background-image或border-image等接受图像的属性里。配合自定义属性,还能在运行时改变图像。以下样式让元素背景使用上面注册的solid-box,并通过变量控制颜色:
.my-element {
--box-color: #3366ff;
background-image: paint(solid-box);
/* 不支持时的降级 */
background-color: #3366ff;
}
这里background-color作为降级,当paint()无效时用户依然能看到蓝色块。如果去掉降级,旧版Safari上元素背景就会消失。实际项目中建议所有paint背景都带一层传统背景兜底。
2.1 传递动态参数
除了颜色,也可以传尺寸、间距等数值。worklet里通过props.get拿到的是CSSStyleValue,通常要用toString或parseFloat处理。比如画棋盘格,可以传--grid-size控制格子宽高:
registerPaint('checker', class {
static get inputProperties() {
return ['--grid-size', '--grid-color'];
}
paint(ctx, size, props) {
const grid = parseFloat(props.get('--grid-size')) || 20;
const color = props.get('--grid-color').toString() || '#000';
ctx.fillStyle = color;
for (let y = 0; y < size.height; y += grid * 2) {
for (let x = 0; x < size.width; x += grid * 2) {
ctx.fillRect(x, y, grid, grid);
ctx.fillRect(x + grid, y + grid, grid, grid);
}
}
}
});
上面代码用两层循环交错填充,形成国际象棋盘效果。注意循环里比较都用size.width而不是写死,这样元素尺寸变化时会自动铺满。如果在CSS里改--grid-size,浏览器会触发重新绘制,不需要手动调JS。
2.2 高清屏适配
默认情况下worklet的size是以CSS像素给出的,但在devicePixelRatio大于1的屏幕上直接按这个尺寸画会发虚。Paint API没有提供自动缩放,需要自己在paint里用ctx.scale或者按ratio乘宽高。简单做法是读取window.devicePixelRatio,不过worklet里不能直接访问window,可以通过CSS变量把ratio传进去:
:root {
--dpr: 1;
}
@media (min-resolution: 2dppx) {
:root { --dpr: 2; }
}
然后在worklet里取出--dpr并放大绘制坐标。虽然多一步,但能保证视网膜屏下边框和纹理清晰。这也是很多教程容易漏掉的点,导致开发者在手机上看到模糊背景。
三、适用场景与限制
CSS Paint API适合做那些用纯CSS表达太啰嗦、用图片又不灵活的场景。例如 loading占位骨架屏的脉冲波纹、随内容长度变化的虚线边框、以及根据主题变量生成的噪点遮罩。它把绘制逻辑收敛到一个文件,比内联SVG好维护。
但必须清醒看到兼容性现实:Chrome、Edge等Chromium系支持良好,Firefox需要手动开启实验特性,Safari长期未实装。所以如果面向公开Web,只能把它当作渐进增强。内部后台系统或Electron客户端则可以放心主打。
3.1 与SVG和Canvas的区别
有人会问,既然都能画图,为什么不用SVG或离屏Canvas。区别在于SVG是声明式且DOM化,不适合按CSS变量实时重绘大量实例;离屏Canvas要自己写JS把图贴到每个元素,绕开了CSS层叠。Paint API恰好填补中间空白:用JS画,但由CSS引擎调度和缓存。
| 方案 | 是否随CSS变量更新 | 是否主线程绘图 | 兼容性 |
|---|---|---|---|
| CSS Paint API | 是 | 否(worklet线程) | Chromium为主 |
| 内联SVG | 部分 | 否 | 广泛 |
| JS离屏Canvas | 手动 | 是 | 广泛 |
从表里能看出,Paint API在自动响应样式变化和线程隔离上有独特优势,但代价是兼容面窄。选型时如果目标用户清一色Chrome,那它几乎是最优解。
3.2 调试技巧
调试worklet不能像普通JS那样随便console,因为运行环境隔离。建议在paint里把关键参数通过props回写到另一个自定义属性,或者在支持的工作里用console.log,Chromium会在控制台单独显示worklet上下文日志。另外可以给元素加outline观察绘制区域是否和size吻合,避免图像被拉伸。
当绘制没出现时,先确认addModule路径正确且返回resolved,再检查CSS里paint名称是否和registerPaint字符串一致。大小写不符是常见低级错误。确认无误后,用DevTools的Rendering面板勾选Paint flashing,能看到背景重绘时机是否符合预期。
四、一个完整可运行示例
下面把前面知识点拼成一个小例子:页面加载worklet,画随变量变色的圆点阵列,并带降级背景。你可以把这当作模板改。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<style>
.dots {
--dot-color: #ff6600;
--dot-gap: 24;
width: 300px;
height: 200px;
background-color: #eee;
background-image: paint(dot-grid);
}
</style>
</head>
<body>
<div class="dots"></div>
<script>
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('dot-grid.js');
}
</script>
</body>
</html>
对应的dot-grid.js内容如下,它读取间隙和颜色画一排排圆点:
registerPaint('dot-grid', class {
static get inputProperties() {
return ['--dot-color', '--dot-gap'];
}
paint(ctx, size, props) {
const color = props.get('--dot-color').toString() || '#000';
const gap = parseFloat(props.get('--dot-gap')) || 20;
ctx.fillStyle = color;
for (let y = gap / 2; y < size.height; y += gap) {
for (let x = gap / 2; x < size.width; x += gap) {
ctx.beginPath();
ctx.arc(x, y, gap / 4, 0, Math.PI * 2);
ctx.fill();
}
}
}
});
把两个文件放同目录用本地服务器打开,就能看到橙色圆点背景。改CSS里的--dot-gap,圆点疏密会立刻变化。若用不支持的浏览器打开,元素是浅灰底,也不至于空白。
总体来看,CSS Paint API把绘图权交回开发者手里,又保持和CSS变量体系的亲缘关系。理解worklet隔离性、inputProperties声明和降级策略,就能在合适项目中用它替代笨重的图片资源,让界面更轻和可定制。
CSS_Paint_APIworkletHoudini修改时间:2026-08-08 09:27:44