Skip to content

CSS Houdini Paint API 探索

CSS Houdini 是 W3C 提出的一组底层 CSS API,它让开发者可以直接接入 CSS 渲染引擎的各个阶段。其中 Paint API 是目前浏览器支持度最好的一个,它允许我们用 JavaScript 绘制自定义的 CSS 图案,替代传统的背景图方案。

什么是 CSS Paint API

传统方式下,如果我们想实现一个复杂的背景效果(比如渐变网格、波浪线、动态斑点),要么用 CSS 渐变拼凑,要么用图片,要么用 SVG。CSS Paint API 提供了第四种方案:用 JavaScript 编写绘制逻辑,然后像 CSS 函数一样直接调用。

css
/* 传统方式 */
.box {
  background: url('dots.png') repeat;
}

/* Paint API 方式 */
.box {
  background: paint(dots);
}

核心概念包括:

  • Worklet:运行在独立线程中的轻量级 JS 模块
  • registerPaint():注册一个绘制处理器
  • paint():实际的绘制回调函数

浏览器支持与检测

截至目前,Chrome 65+ 已经完整支持 Paint API,Edge 基于 Chromium 也支持,Firefox 和 Safari 暂未默认开启。

js
// 检测浏览器支持
if ('paintWorklet' in CSS) {
  console.log('CSS Paint API 可用');
} else {
  console.log('需要 polyfill 或降级方案');
}

可以使用 css-paint-polyfill 做兼容:

html
<script src="https://unpkg.com/css-paint-polyfill"></script>

注册第一个 Paint Worklet

创建一个独立的 JS 文件作为 Worklet,在其中使用 registerPaint 注册绘制器。Worklet 中可以使用的绘图 API 是 Canvas 2D API 的一个子集:

js
// worklets/dots.js
class DotsPainter {
  // 声明此绘制器依赖的 CSS 属性
  static get inputProperties() {
    return [
      '--dot-color',
      '--dot-size',
      '--dot-spacing'
    ];
  }

  // 绘制回调
  paint(ctx, size, properties) {
    const dotColor = properties.get('--dot-color').toString().trim() || '#3498db';
    const dotSize = parseFloat(properties.get('--dot-size').toString()) || 4;
    const spacing = parseFloat(properties.get('--dot-spacing').toString()) || 20;

    ctx.fillStyle = dotColor;

    for (let x = 0; x < size.width; x += spacing) {
      for (let y = 0; y < size.height; y += spacing) {
        ctx.beginPath();
        ctx.arc(x + spacing / 2, y + spacing / 2, dotSize, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }
}

registerPaint('dots', DotsPainter);

在页面中加载 Worklet:

js
// 主线程中注册
if ('paintWorklet' in CSS) {
  CSS.paintWorklet.addModule('/worklets/dots.js');
}

然后就可以在 CSS 中使用了:

css
.dot-box {
  --dot-color: #e74c3c;
  --dot-size: 3;
  --dot-spacing: 24;
  background: paint(dots);
  width: 400px;
  height: 300px;
}

使用 inputProperties 响应 CSS 变量

Paint API 最强大的地方在于它可以读取 CSS 自定义属性(CSS Variables),这意味着绘制行为可以完全由 CSS 控制:

js
class GradientWavePainter {
  static get inputProperties() {
    return [
      '--wave-color',
      '--wave-amplitude',
      '--wave-frequency',
      '--wave-offset'
    ];
  }

  paint(ctx, size, properties) {
    const color = properties.get('--wave-color').toString().trim() || '#667eea';
    const amplitude = parseFloat(properties.get('--wave-amplitude').toString()) || 30;
    const frequency = parseFloat(properties.get('--wave-frequency').toString()) || 0.02;
    const offset = parseFloat(properties.get('--wave-offset').toString()) || 0;

    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.moveTo(0, size.height);

    for (let x = 0; x <= size.width; x++) {
      const y = size.height / 2 + Math.sin((x * frequency) + offset) * amplitude;
      ctx.lineTo(x, y);
    }

    ctx.lineTo(size.width, size.height);
    ctx.closePath();
    ctx.fill();
  }
}

registerPaint('wave', GradientWavePainter);

CSS 部分:

css
.wave-section {
  --wave-color: rgba(102, 126, 234, 0.5);
  --wave-amplitude: 40;
  --wave-frequency: 0.015;
  --wave-offset: 0;
  background: paint(wave);
}

实战:自定义边框绘制器

一个常见的需求是自定义边框样式,比如锯齿边框:

js
class ZigzagPainter {
  static get inputProperties() {
    return [
      '--zigzag-color',
      '--zigzag-size'
    ];
  }

  paint(ctx, size, properties) {
    const color = properties.get('--zigzag-color').toString().trim() || '#333';
    const zigSize = parseFloat(properties.get('--zigzag-size').toString()) || 10;

    ctx.fillStyle = color;

    // 顶部锯齿
    for (let x = 0; x < size.width; x += zigSize * 2) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x + zigSize, zigSize);
      ctx.lineTo(x + zigSize * 2, 0);
      ctx.fill();
    }

    // 底部锯齿
    for (let x = 0; x < size.width; x += zigSize * 2) {
      ctx.beginPath();
      ctx.moveTo(x, size.height);
      ctx.lineTo(x + zigSize, size.height - zigSize);
      ctx.lineTo(x + zigSize * 2, size.height);
      ctx.fill();
    }
  }
}

registerPaint('zigzag', ZigzagPainter);

实现动画效果

Worklet 运行在独立线程,不能直接访问 DOM 或使用 requestAnimationFrame。动画需要在主线程中通过修改 CSS 变量来触发重绘:

js
// 主线程代码
function animateWave() {
  const el = document.querySelector('.wave-section');
  let offset = 0;

  function frame() {
    offset += 0.05;
    el.style.setProperty('--wave-offset', offset);
    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

animateWave();

实战:Skeleton 加载占位图

js
class SkeletonPainter {
  static get inputProperties() {
    return [
      '--skeleton-base-color',
      '--skeleton-shine-color',
      '--skeleton-progress'
    ];
  }

  paint(ctx, size, properties) {
    const baseColor = properties.get('--skeleton-base-color').toString().trim() || '#e0e0e0';
    const shineColor = properties.get('--skeleton-shine-color').toString().trim() || '#f5f5f5';
    const progress = parseFloat(properties.get('--skeleton-progress').toString()) || 0;

    // 底色
    ctx.fillStyle = baseColor;
    ctx.fillRect(0, 0, size.width, size.height);

    // 闪光扫过效果
    const shineX = (size.width + 200) * progress - 100;
    const gradient = ctx.createLinearGradient(shineX, 0, shineX + 200, 0);
    gradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
    gradient.addColorStop(0.5, shineColor);
    gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');

    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, size.width, size.height);
  }
}

registerPaint('skeleton', SkeletonPainter);

配合主线程动画:

js
CSS.paintWorklet.addModule('/worklets/skeleton.js');

function startSkeletonAnimation() {
  const el = document.querySelector('.skeleton-box');
  let progress = 0;

  function frame() {
    progress = (progress + 0.005) % 1;
    el.style.setProperty('--skeleton-progress', progress);
    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

与 Canvas 的对比

特性Paint APICanvas
运行线程Worklet 线程主线程
与 CSS 集成天然集成,可直接用作 background需要手动设置
响应式自动随元素尺寸变化需要手动监听 resize
DOM 访问不可访问可访问
事件处理不支持支持

小结

  • CSS Paint API 是 Houdini 规范中最成熟的模块,Chrome 已完整支持
  • 通过 registerPaint() 注册绘制器,用 Canvas 2D 子集 API 绘制
  • inputProperties 可以读取 CSS 自定义属性,实现声明式控制
  • Worklet 运行在独立线程,不会阻塞主线程
  • 适合用于背景图案、边框装饰、占位图等纯视觉效果
  • 动画需要在主线程修改 CSS 变量来触发重绘
  • 可以通过 css-paint-polyfill 做降级兼容

MIT Licensed