比使用 Canvas 绘制背景图更好的方法 -- Paint Worklet

434 阅读9分钟

Paint Worklet 是一种现代Web技术,它允许开发者通过JavaScript直接在浏览器中自定义CSS属性的绘制逻辑。这意味着开发者可以创建复杂的图形效果,而无需依赖外部图形库或预处理器。Paint Worklet 作为CSS的一个扩展,提供了一种新的编程范式,使得Web页面的视觉效果更加丰富和动态。

Paint Worklet 的基本使用步骤

  1. 创建 Paint Worklet 文件:编写一个包含自定义绘制逻辑的JavaScript文件,例如 index.js
  2. 注册 Paint Worklet:在HTML文档的 <script> 标签中,使用 CSS.paintWorklet.addModule("index.js"); 来注册你的 Paint Worklet 文件。
  3. 定义 CSS 类:在CSS中定义使用 Paint Worklet 的类,并通过 background-image: paint(your-paint-worklet-name); 来应用这些自定义绘制效果。
  4. 使用自定义属性:通过CSS变量(如 --variable-name),你可以动态地调整绘制效果,使得效果更加灵活。

1. 首先创建一个 index.js 文件,然后在其中写入下面的内容:

class MyGradient {
    paint(ctx, geom) {
        // 创建一个线性渐变
        const gradient = ctx.createLinearGradient(0, 0, geom.width, geom.height);
        gradient.addColorStop(0, 'red');
        gradient.addColorStop(1, 'blue');

        // 使用渐变填充整个区域
        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, geom.width, geom.height);
    }
}
// 注册自定义绘制器
registerPaint('my-gradient', MyGradient);

这个类定义了一个名为 MyGradient 的 Paint Worklet,它使用 paint 方法来绘制一个从红色到蓝色的线性渐变。ctx 是一个 CanvasRenderingContext2D 对象,它提供了绘制渐变所需的方法。geom 是一个包含绘制区域尺寸的对象。createLinearGradient 方法创建了一个渐变对象,通过 addColorStop 方法添加颜色停止点,最后使用 fillRect 方法用渐变填充整个区域。

2. 然后创建一个 index.html 文件,其内容如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="./index.js"></script>
    <script>
      CSS.paintWorklet.addModule("index.js");
    </script>
    <style>
      body {
        background-color: #ccc;
      }
      .my-element {
        width: 100px;
        height: 100px;
        background-image: paint(my-gradient);
      }
    </style>
  </head>
  <body>
    <div class="my-element"></div>
  </body>
</html>

最后使用 live server 等工具查看效果

image.png

image.png

Paint Worklet 的作用

Paint Worklet 允许开发者创建自定义的图形和动画效果,这些效果可以响应页面的尺寸变化,并且可以与CSS的其他属性和媒体查询相结合,以实现更加丰富和交互式的用户界面。

效果案例

1. 动态波浪效果

image.png

class WaveEffect {
    static get inputProperties() {
        return ['--wave-color'];
    }
    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;
        const amplitude = 25;
        const frequency = 0.3;
        const color = styleMap.get('--wave-color').toString();

        ctx.strokeStyle = color;
        ctx.strokeWidth = 1;

        ctx.beginPath();
        for (let x = 0; x < width; x += 4) {
            const y = height * Math.sin(x * frequency) * amplitude;
            ctx.lineTo(x, y);
        }
        ctx.lineTo(width, height);
        ctx.closePath();
        ctx.fill();
    }
}

WaveEffect 类定义了一个波浪效果的 Paint Worklet。它首先通过 get inputProperties 静态方法指定了它依赖的 CSS 自定义属性 --wave-color。在 paint 方法中,它使用正弦函数 Math.sin 来计算波浪的 Y 坐标,然后通过 lineTo 方法绘制波浪线。波浪的颜色由 --wave-color 属性决定。

在 CSS 中使用波浪效果:

  .my-element2 {
    width: 100px;
    height: 100px;
    --wave-color: red;
    background-image: paint(wave-effect);
  }

2. 星系螺旋臂

image.png

class SpiralGalaxy {
    static get inputProperties() {
        return ['--spiral-inner-color', '--spiral-outter-color', '--spiral-count'];
    }
    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;
        const armLength = Math.min(width, height) * 0.45;

        ctx.fillStyle = styleMap.get('--spiral-inner-color');
        ctx.strokeStyle = styleMap.get('--spiral-outter-color');
        ctx.strokewidth = 2;

        for (let i = 0; i < styleMap.get('--spiral-count'); i++) {
            const angle = (i / 4) * Math.PI;
            const radius = armLength * (i + 1) / styleMap.get('--spiral-count');
            ctx.beginPath();
            ctx.arc(width / 2, height / 2, radius, angle, angle + Math.PI / 4, false);
            ctx.stroke();
            ctx.beginPath();
            ctx.arc(width / 2, height / 2, Math.max(radius - 0, 0), angle + Math.PI / 4, angle + Math.PI / 2, false);
            ctx.fill();
        }
    }
}

SpiralGalaxy 类绘制了一个螺旋星系效果。它通过 get inputProperties 方法指定了三个依赖的 CSS 自定义属性:--spiral-inner-color--spiral-outter-color--spiral-count。在 paint 方法中,它使用 arc 方法来绘制螺旋的弧线,并通过循环来创建多个螺旋臂。螺旋的内部使用 fillStyle 填充颜色,而外部使用 strokeStyle 绘制轮廓。

在 CSS 中使用星系螺旋臂效果:

  .my-element3 {
    width: 100px;
    height: 100px;
    --spiral-inner-color: rgba(30, 30, 200, 0.8);
    --spiral-outter-color: white;
    --spiral-count: 15;
    background-image: paint(spiral-galaxy);
  }

3. 动态颜色过渡

image.png

class GradientTransition {
    static get inputProperties() {
        return ['--colors', '--stops'];
    }

    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;

        const colors = styleMap.get('--colors').toString().split(' ').map(color => color.trim());
        const stops = styleMap.get('--stops').toString().split(' ').map(stop => parseFloat(stop));

        const gradient = ctx.createLinearGradient(0, 0, width, height);

        const numStops = Math.min(colors.length, stops.length);

        for (let i = 0; i < numStops; i++) {
            gradient.addColorStop(stops[i] / 100, colors[i]);
        }

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

GradientTransition 类允许用户通过 CSS 自定义属性定义渐变的颜色和停止点。get inputProperties 方法指定了两个依赖的属性:--colors--stops。在 paint 方法中,它首先解析这些属性的值,然后创建一个线性渐变对象,并通过循环添加颜色停止点。最后,使用 fillRect 方法用渐变填充整个区域。

在 CSS 中使用动态颜色过渡效果:

  .my-element4 {
    width: 100px;
    height: 100px;
    --colors: red pink yellow blue; /* 颜色列表,空格分隔 */
    --stops: 0% 33% 67% 100%; /* 颜色停止点,空格分隔 */
    background-image: paint(gradient-transition);
  }

4. 动态阴影效果

gif20240430050432.gif

class DynamicShadow {
    static get inputProperties() {
        return [
            '--blur-radius',
            '--x-offset',
            '--y-offset',
            '--shadow-bg-color',
            '--shadow-color'
        ];
    }

    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;

        const blurRadius = styleMap.get('--blur-radius')[0];
        const x = styleMap.get('--x-offset')[0];
        const y = styleMap.get('--y-offset')[0];
        const shadowColor = styleMap.get('--shadow-color')[0];

        ctx.fillStyle = styleMap.get('--shadow-bg-color')[0];
        ctx.shadowBlur = blurRadius;
        ctx.shadowColor = shadowColor;
        ctx.shadowOffsetX = x;
        ctx.shadowOffsetY = y;

        ctx.beginPath();
        ctx.arc(width / 2, height / 2, Math.min(width, height) / 2 - Math.max(Math.abs(x), Math.abs(y)) - blurRadius, 0, 2 * Math.PI);
        ctx.closePath();
        ctx.fill();
    }
}

DynamicShadow 类创建了一个动态阴影效果。它通过 get inputProperties 方法指定了五个依赖的 CSS 自定义属性,用于控制阴影的大小、位置和颜色。在 paint 方法中,它首先获取这些属性的值,然后设置阴影的样式,包括模糊半径、颜色、偏移量和背景颜色。最后,使用 arc 方法和 fill 方法来绘制带有阴影的圆形。

在 CSS 中使用动态阴影效果:

  .my-element5 {
    width: 100px;
    height: 100px;
    --blur-radius: 5; /* 模糊半径 */
    --x-offset: 2; /* X 轴偏移 */
    --y-offset: 2; /* Y 轴偏移 */
    --shadow-bg-color: white; /* 阴影颜色 */
    --shadow-color: rgba(0, 0, 0, 0.5); /* 阴影颜色 */
    animation: sun linear infinite;
    animation-duration: 3000ms;
    background-image: paint(dynamic-shadow);
  }

  @keyframes sun {
    0%,
    100% {
      --x-offset: -5; /* X 轴偏移 */
      --y-offset: 2; /* Y 轴偏移 */
    }

    25% {
      --x-offset: -1; /* X 轴偏移 */
      --y-offset: 2; /* Y 轴偏移 */
    }

    50% {
      --x-offset: 2; /* X 轴偏移 */
      --y-offset: 2; /* Y 轴偏移 */
    }
    75% {
      --x-offset: 5; /* X 轴偏移 */
      --y-offset: 2; /* Y 轴偏移 */
    }
  }

5. 自定义标题

image.png

registerPaint(
    "headerHighlight",
    class {
        static get inputProperties() {
            return ["--highColor"];
        }
        static get contextOptions() {
            return { alpha: true };
        }

        paint(ctx, size, props) {
            const x = 0;
            const y = size.height * 0.3;
            const blockWidth = size.width * 0.33;
            const highlightHeight = size.height * 0.85;
            const color = props.get("--highColor");

            ctx.fillStyle = color;

            ctx.beginPath();
            ctx.moveTo(x, y);
            ctx.lineTo(blockWidth, y);
            ctx.lineTo(blockWidth + highlightHeight, highlightHeight);
            ctx.lineTo(x, highlightHeight);
            ctx.lineTo(x, y);
            ctx.closePath();
            ctx.fill();

            for (let start = 0; start < 8; start += 2) {
                ctx.beginPath();
                ctx.moveTo(blockWidth + start * 10 + 10, y);
                ctx.lineTo(blockWidth + start * 10 + 20, y);
                ctx.lineTo(
                    blockWidth + start * 10 + 20 + highlightHeight,
                    highlightHeight,
                );
                ctx.lineTo(
                    blockWidth + start * 10 + 10 + highlightHeight,
                    highlightHeight,
                );
                ctx.lineTo(blockWidth + start * 10 + 10, y);
                ctx.closePath();
                ctx.fill();
            }
        } // paint
    },
);

headerHighlight 类为 h1 元素创建了一个高亮效果。它通过 get inputProperties 方法指定了一个依赖的 CSS 自定义属性 --highColor,用于定义高亮颜色。get contextOptions 方法设置了 alpha 通道,允许使用透明度。在 paint 方法中,它首先定义了高亮区域的起始点、宽度和高度,然后使用 fill 方法填充高亮颜色。接着,它通过循环创建了一系列的虚线效果,增加了视觉效果的复杂性。 通过这些自定义绘制器,开发者可以创建出独特的视觉效果,增强网页的吸引力和用户体验。

在 CSS 中使用动态阴影效果:

  .fancy {
    background-image: paint(headerHighlight);
  }
  h1 {
    --highColor: hsl(155 90% 60% / 70%);
  }

总之,Paint Worklet 为Web开发者提供了一个强大的工具,使得他们能够以一种声明式和响应式的方式,将创意转化为网页上的动态图形。

最终效果

image.png

如果你想要亲自实现上面的效果,请按照下面的步骤:

  1. 创建项目结构
mkdir test-worklet && cd test-worklet
touch index.html index.js
  1. 将 index.js 的内容复制到空文件中
// 在 paint-worklet.js 文件中
class MyGradient {
    paint(ctx, geom) {
        // 创建一个线性渐变
        const gradient = ctx.createLinearGradient(0, 0, geom.width, geom.height);
        gradient.addColorStop(0, 'red');
        gradient.addColorStop(1, 'blue');

        // 使用渐变填充整个区域
        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, geom.width, geom.height);
    }
}

// 注册自定义绘制器
registerPaint('my-gradient', MyGradient);

// wave-worklet.js
class WaveEffect {
    static get inputProperties() {
        return ['--wave-color'];
    }
    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;
        const amplitude = 25;
        const frequency = 0.3;
        const color = styleMap.get('--wave-color').toString();

        ctx.strokeStyle = color;
        ctx.strokeWidth = 1;

        ctx.beginPath();
        for (let x = 0; x < width; x += 4) {
            const y = height * Math.sin(x * frequency) * amplitude;
            ctx.lineTo(x, y);
        }
        ctx.lineTo(width, height);
        ctx.closePath();
        ctx.fill();
    }
}

registerPaint('wave-effect', WaveEffect);

// spiral-worklet.js
class SpiralGalaxy {
    static get inputProperties() {
        return ['--spiral-inner-color', '--spiral-outter-color', '--spiral-count'];
    }
    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;
        const armLength = Math.min(width, height) * 0.45;

        ctx.fillStyle = styleMap.get('--spiral-inner-color');
        ctx.strokeStyle = styleMap.get('--spiral-outter-color');
        ctx.strokewidth = 2;

        for (let i = 0; i < styleMap.get('--spiral-count'); i++) {
            const angle = (i / 4) * Math.PI;
            const radius = armLength * (i + 1) / styleMap.get('--spiral-count');
            ctx.beginPath();
            ctx.arc(width / 2, height / 2, radius, angle, angle + Math.PI / 4, false);
            ctx.stroke();
            ctx.beginPath();
            ctx.arc(width / 2, height / 2, Math.max(radius - 0, 0), angle + Math.PI / 4, angle + Math.PI / 2, false);
            ctx.fill();
        }
    }
}

registerPaint('spiral-galaxy', SpiralGalaxy);

// gradient-transition-worklet.js
class GradientTransition {
    static get inputProperties() {
        // 指定 Paint Worklet 所依赖的 CSS 自定义属性
        return ['--colors', '--stops'];
    }

    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;

        // 获取 CSS 自定义属性 'colors' 和 'stops'
        const colors = styleMap.get('--colors').toString().split(' ').map(color => color.trim());
        const stops = styleMap.get('--stops').toString().split(' ').map(stop => parseFloat(stop));

        // 创建线性渐变
        const gradient = ctx.createLinearGradient(0, 0, width, height);

        // 确保 colors 和 stops 数组长度相同
        const numStops = Math.min(colors.length, stops.length);

        // 添加颜色停止点到渐变
        for (let i = 0; i < numStops; i++) {
            gradient.addColorStop(stops[i] / 100, colors[i]);
        }

        // 使用渐变填充整个区域
        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, width, height);
    }
}

// 注册自定义绘制器
registerPaint('gradient-transition', GradientTransition);

class DynamicShadow {
    static get inputProperties() {
        // 指定依赖的 CSS 自定义属性
        return [
            '--blur-radius',
            '--x-offset',
            '--y-offset',
            '--shadow-bg-color',
            '--shadow-color'
        ];
    }

    paint(ctx, geom, styleMap) {
        const width = geom.width;
        const height = geom.height;

        // 使用 styleMap.get() 获取 CSS 自定义属性的值
        const blurRadius = styleMap.get('--blur-radius')[0];
        const x = styleMap.get('--x-offset')[0];
        const y = styleMap.get('--y-offset')[0];
        const shadowColor = styleMap.get('--shadow-color')[0]; // 颜色值是字符串

        ctx.fillStyle = styleMap.get('--shadow-bg-color')[0];
        ctx.shadowBlur = blurRadius;
        ctx.shadowColor = shadowColor; // 直接使用颜色字符串
        ctx.shadowOffsetX = x;
        ctx.shadowOffsetY = y;

        ctx.beginPath();
        ctx.arc(width / 2, height / 2, Math.min(width, height) / 2 - Math.max(Math.abs(x), Math.abs(y)) - blurRadius, 0, 2 * Math.PI);
        ctx.closePath(); // 闭合路径
        ctx.fill();
    }
}

// 注册自定义绘制器
registerPaint('dynamic-shadow', DynamicShadow);

registerPaint(
    "headerHighlight",
    class {
        static get inputProperties() {
            return ["--highColor"];
        }
        static get contextOptions() {
            return { alpha: true };
        }

        paint(ctx, size, props) {
            /* set where to start the highlight & dimensions */
            const x = 0;
            const y = size.height * 0.3;
            const blockWidth = size.width * 0.33;
            const highlightHeight = size.height * 0.85;
            const color = props.get("--highColor");

            ctx.fillStyle = color;

            ctx.beginPath();
            ctx.moveTo(x, y);
            ctx.lineTo(blockWidth, y);
            ctx.lineTo(blockWidth + highlightHeight, highlightHeight);
            ctx.lineTo(x, highlightHeight);
            ctx.lineTo(x, y);
            ctx.closePath();
            ctx.fill();

            /* create the dashes */
            for (let start = 0; start < 8; start += 2) {
                ctx.beginPath();
                ctx.moveTo(blockWidth + start * 10 + 10, y);
                ctx.lineTo(blockWidth + start * 10 + 20, y);
                ctx.lineTo(
                    blockWidth + start * 10 + 20 + highlightHeight,
                    highlightHeight,
                );
                ctx.lineTo(
                    blockWidth + start * 10 + 10 + highlightHeight,
                    highlightHeight,
                );
                ctx.lineTo(blockWidth + start * 10 + 10, y);
                ctx.closePath();
                ctx.fill();
            }
        } // paint
    },
);

  1. 将 index.html 的内容复制到空文件中
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="./index.js"></script>
    <script>
      CSS.paintWorklet.addModule("index.js");
    </script>
    <style>
      body {
        background-color: #ccc;
      }
      div {
        margin-bottom: 10px;
      }
      .my-element {
        width: 100px;
        height: 100px;
        background-image: paint(my-gradient);
      }
      .my-element2 {
        width: 100px;
        height: 100px;
        --wave-color: red;
        background-image: paint(wave-effect);
      }

      .my-element3 {
        width: 100px;
        height: 100px;
        --spiral-inner-color: rgba(30, 30, 200, 0.8);
        --spiral-outter-color: white;
        --spiral-count: 15;
        background-image: paint(spiral-galaxy);
      }

      .my-element4 {
        width: 100px;
        height: 100px;
        --colors: red pink yellow blue; /* 颜色列表,空格分隔 */
        --stops: 0% 33% 67% 100%; /* 颜色停止点,空格分隔 */
        background-image: paint(gradient-transition);
      }

      .my-element5 {
        width: 100px;
        height: 100px;
        --blur-radius: 5; /* 模糊半径 */
        --x-offset: 2; /* X 轴偏移 */
        --y-offset: 2; /* Y 轴偏移 */
        --shadow-bg-color: white; /* 阴影颜色 */
        --shadow-color: rgba(0, 0, 0, 0.5); /* 阴影颜色 */
        animation: sun linear infinite;
        animation-duration: 3000ms;
        background-image: paint(dynamic-shadow);
      }

      @keyframes sun {
        0%,
        100% {
          --x-offset: -5; /* X 轴偏移 */
          --y-offset: 2; /* Y 轴偏移 */
        }

        25% {
          --x-offset: -1; /* X 轴偏移 */
          --y-offset: 2; /* Y 轴偏移 */
        }

        50% {
          --x-offset: 2; /* X 轴偏移 */
          --y-offset: 2; /* Y 轴偏移 */
        }
        75% {
          --x-offset: 5; /* X 轴偏移 */
          --y-offset: 2; /* Y 轴偏移 */
        }
      }
      .fancy {
        background-image: paint(headerHighlight);
      }
      h1 {
        --highColor: hsl(155 90% 60% / 70%);
      }
    </style>
  </head>
  <body>
    <div class="my-element"></div>
    <div class="my-element2"></div>
    <div class="my-element3"></div>
    <div class="my-element4"></div>
    <div class="my-element5"></div>
    <h1 class="fancy">Largest Header</h1>
  </body>
</html>

  1. 浏览 index.html