使用 CSS Paint API 模拟投影

Avatar of Steve Fulghum
Steve Fulghum

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费信用额度!

询问一百名前端开发人员,他们中的大多数,如果不是全部,都会在职业生涯中使用过 box-shadow 属性。阴影经久不衰,如果使用得当,可以增添优雅、微妙的效果。但阴影在 CSS 盒模型中占据着奇怪的位置。它们对元素的宽度和高度没有影响,如果父元素(或祖父母元素)的溢出隐藏,它们很容易被裁剪。

我们可以用几种不同的方法来解决标准 CSS 中的这个问题。但是,现在一些 **CSS Houdini** 规范正在浏览器中实现,出现了一些令人兴奋的新选项。例如,**CSS Paint API** 允许开发人员在运行时以编程方式生成图像。让我们看看如何利用它来 **在边框图像内绘制一个复杂的阴影**。

Houdini 简介

您可能听说过一些新的 CSS 技术正在平台上发布,其名称很吸引人,叫做 Houdini。Houdini 承诺提供更多访问浏览器如何绘制页面的权限。正如 MDN 所述,它是一组“低级 API,它们公开 CSS 引擎的某些部分,使开发人员能够通过挂钩到浏览器的渲染引擎的样式和布局过程来扩展 CSS。”

CSS Paint API

CSS Paint API 是这些 API 中第一个进入浏览器的 API。它是一个 W3C 候选推荐。这是规范开始实施的阶段。目前,它在 Chrome 和 Edge 中可供普遍使用,而 Safari 在一个标志后面实现了它,而 Firefox 将它列为 “值得原型设计”。还有一个 可用于不支持的浏览器的 polyfill,但它不能在 IE11 中运行。

虽然 CSS Paint API 在 Chromium 中已启用,但将参数传递给 paint() 函数仍然需要一个标志。您需要 启用实验性 Web 平台功能 暂时。不幸的是,这些示例可能无法在您选择的浏览器中运行。将它们视为未来事物的一个例子,目前尚未准备好投入生产。

方法

我们将生成一个带有阴影的图像,然后将其用作 border-image … 嗯?好吧,让我们仔细看看。

如上所述,阴影不会为元素添加任何宽度或高度,而是从其边界框中散开。在大多数情况下,这不是问题,但这些阴影容易被裁剪。常见的解决方法是 使用填充或边距创建某种偏移量

我们将要做的是将阴影 **直接构建到元素中**,通过将其绘制到 border-image 区域。这有几个关键优势

  1. border-width 会增加元素的整体宽度
  2. 内容不会溢出到边框区域并与阴影重叠
  3. 填充不需要额外的宽度来容纳阴影 *和* 内容
  4. 元素周围的边距不会干扰该元素的同级元素

对于上面提到的那一百名使用过 box-shadow 的开发人员,很可能只有他们中的一小部分使用过 border-image。这是一个奇怪的属性。它本质上是接受一个图像并将其切成九块,然后将它们放置在四个角、边和(可选)中心。您可以阅读更多关于这一切是如何工作的,在 Nora Brown 的文章 中。

CSS Paint API 将负责生成图像的繁重工作。我们将为它创建一个模块,告诉它如何在彼此之上分层一系列阴影。然后,该图像将被 border-image 使用。

我们将采取以下步骤

  1. 为我们要绘制的元素设置 HTML 和 CSS
  2. 创建一个 **模块** 来绘制图像
  3. 将模块加载到一个 **paint worklet** 中
  4. 使用新的 paint() 函数在 CSS 中调用 worklet

设置画布

您会在这里以及其他 CSS Paint API 资源中多次听到 **画布** 这个词。如果这个词听起来很熟悉,那是对的。API 的工作方式类似于 HTML <canvas> 元素。

首先,我们必须设置 API 将在其上进行绘制的画布。该区域将与调用 paint 函数的元素具有相同的尺寸。让我们创建一个 300×300 的 div。

<section>
  <div class="foo"></div>
</section>

以及样式

.foo {
  border: 15px solid #efefef;
  box-sizing: border-box;
  height: 300px;
  width: 300px;
}

创建 paint 类

HTTPS 是必需的,对于 *任何* JavaScript worklet,包括 paint worklet。如果您通过 HTTP 提供内容,则根本无法使用它。

第二步是创建加载到 worklet 中的 **模块** — 一个包含 registerPaint() 函数的简单文件。此函数接受两个参数:worklet 的名称和一个具有绘画逻辑的类。为了保持整洁,我们将使用一个匿名类。

registerPaint(
  "shadow",
  class {}
);

在 *我们的* 情况下,该类需要两个属性,inputPropertiesinputArguments,以及一个方法,paint()

registerPaint(
  "shadow",
  class {
    static get inputProperties() {
      return [];
    }
    static get inputArguments() {
      return [];
    }
    paint(context, size, props, args) {}
  }
);

inputPropertiesinputArguments 是可选的,但对于将数据传递到类中是必需的。

添加输入属性

我们需要告诉 worklet 使用 inputProperties 从目标元素中提取哪些 CSS 属性。它是一个返回字符串数组的 getter。

在此数组中,我们列出了类需要的自定义 *和* 标准属性:--shadow-colorsbackground-colorborder-top-width。请特别注意我们如何使用非简写属性。

static get inputProperties() {
  return ["--shadow-colors", "background-color", "border-top-width"];
}

为了简单起见,我们在这里假设边框在所有边上都是均匀的。

添加参数

目前,inputArguments 仍然需要一个标志,因此需要启用实验性功能。如果没有它们,请改用 inputProperties 和自定义属性。

我们还使用 inputArguments 将参数传递给 paint 模块。乍一看,它们似乎是 inputProperties 的多余部分,但它们在使用方式上存在细微差异。

当在样式表中调用 paint 函数时,inputArguments 会在 paint() 调用中显式传递。这使它们比 inputProperties 更占优势,inputProperties 可能正在监听可能被其他脚本或样式修改的属性。例如,如果您使用在 :root 上设置的自定义属性,该属性会发生改变,它可能会向下筛选并影响输出。

inputArguments 的第二个重要区别,这一点并不直观,就是它们 *没有* 命名。相反,它们在 paint 方法中作为数组中的项目引用。当我们告诉 inputArguments 它正在接收什么时,我们实际上是在向它提供参数的 *类型*。

shadow 类需要三个参数:一个用于 X 位置,一个用于 Y 位置,一个用于模糊度。我们将将其设置为三个用空格分隔的整数列表。

任何注册过自定义属性的人可能会认出这种语法。在我们的例子中,<integer> 关键字表示任何 *整数*,而 + 表示用空格分隔的列表。

static get inputArguments() {
  return ["<integer>+", "<integer>+", "<integer>+"];
}

为了使用inputProperties来代替inputArguments,你可以直接在元素上设置自定义属性并监听它们。命名空间至关重要,以确保从其他地方继承的自定义属性不会泄露。

添加 paint 方法

现在我们有了输入,是时候设置 paint 方法了。

paint() 的一个关键概念是context 对象。它类似于 HTML 的<canvas> 元素上下文,并且工作方式与之非常相似,尽管有一些细微的差异。目前,你不能从画布中读取像素(出于安全原因),也不能渲染文本(这个 GitHub 线程中简要解释了原因)。

paint() 方法有四个隐式参数

  1. 上下文对象
  2. 几何图形(一个具有宽度和高度的对象)
  3. 属性(来自inputProperties 的映射)
  4. 参数(从样式表传递的参数)
paint(ctx, geom, props, args) {}

获取尺寸

geometry 对象知道元素的大小,但我们需要针对 X 和 Y 轴上总共 30 像素的边框进行调整。

const width = (geom.width - borderWidth * 2);
const height = (geom.height - borderWidth * 2);

使用属性和参数

属性参数保存了从inputPropertiesinputArguments 解析的数据。属性以类似于映射的对象形式传入,我们可以使用get()getAll() 获取值。

const borderWidth = props.get("border-top-width").value;
const shadowColors = props.getAll("--shadow-colors");

get() 返回单个值,而getAll() 返回数组。

--shadow-colors 将是一个以空格分隔的颜色列表,可以将其拉入数组。稍后我们将将其注册到浏览器,以便它知道应该预期什么。

我们还需要指定填充矩形的颜色。它将使用与元素相同的背景颜色。

ctx.fillStyle = props.get("background-color").toString();

如前所述,参数以数组的形式进入模块,我们通过索引引用它们。目前它们是CSSStyleValue 类型——让我们更容易地遍历它们。

  1. 使用toString() 方法将CSSStyleValue 转换为字符串。
  2. 使用正则表达式将结果按空格拆分。
const blurArray = args[2].toString().split(/\s+/);
const xArray = args[0].toString().split(/\s+/);
const yArray = args[1].toString().split(/\s+/);
// e.g. ‘1 2 3’ -> [‘1’, ‘2’, ‘3’]

绘制阴影

现在我们有了尺寸和属性,是时候绘制一些东西了!由于我们需要为shadowColors 中的每个项目创建一个阴影,我们将遍历它们。从forEach() 循环开始。

shadowColors.forEach((shadowColor, index) => { 
});

使用数组的索引,我们将从 X、Y 和模糊参数中获取匹配的值。

shadowColors.forEach((shadowColor, index) => {
  ctx.shadowOffsetX = xArray[index];
  ctx.shadowOffsetY = yArray[index];
  ctx.shadowBlur = blurArray[index];
  ctx.shadowColor = shadowColor.toString();
});

最后,我们将使用fillRect() 方法在画布上绘制。它需要四个参数:X 位置、Y 位置、宽度和高度。对于位置值,我们将使用来自inputPropertiesborder-width;这样,border-image 将被裁剪以仅包含矩形周围的阴影。

shadowColors.forEach((shadowColor, index) => {
  ctx.shadowOffsetX = xArray[index];
  ctx.shadowOffsetY = yArray[index];
  ctx.shadowBlur = blurArray[index];
  ctx.shadowColor = shadowColor.toString();

  ctx.fillRect(borderWidth, borderWidth, width, height);
});

此技术也可以使用画布的阴影过滤器和单个矩形来实现。它在 Chrome、Edge 和 Firefox 中得到支持,但在 Safari 中不支持。在 CodePen 上查看完成的示例。

快到了!只需几个步骤即可将所有内容连接起来。

注册 paint 模块

我们首先需要将我们的模块注册为浏览器中的paint worklet。这在我们的主 JavaScript 文件中完成。

CSS.paintWorklet.addModule("https://codepen.io/steve_fulghum/pen/bGevbzm.js");
https://codepen.io/steve_fulghum/pen/BazexJX

注册自定义属性

我们应该做的另一件事,但不是绝对必要,是通过注册它们来告诉浏览器更多关于我们自定义属性的信息。

注册属性会赋予它们类型。我们希望浏览器知道--shadow-colors 是一个实际颜色的列表,而不仅仅是一个字符串。

如果你需要针对不支持属性和值 API 的浏览器,不要绝望!自定义属性仍然可以被 paint 模块读取,即使没有注册。但是,它们将被视为未解析的值,实际上是字符串。你需要添加自己的解析逻辑。

addModule() 一样,这也被添加到主 JavaScript 文件中。

CSS.registerProperty({
  name: "--shadow-colors",
  syntax: "<color>+",
  initialValue: "black",
  inherits: false
});

你也可以在你的样式表中使用@property 来注册属性。你可以在MDN 上阅读简要说明

将此应用于边框图像

我们的 worklet 现在已注册到浏览器,我们可以在主 CSS 文件中调用 paint 方法以代替图像 URL。

border-image-source: paint(shadow, 0 0 0, 8 2 1, 8 5 3) 15;
border-image-slice: 15;

这些是无单位的值。由于我们正在绘制 1:1 的图像,因此它们等效于像素。

适应显示比率

我们快完成了,但还有一个问题需要解决。

对于你们中的一些人来说,事情可能看起来不太像预期的那样。我敢打赌你购买了高端的、高 DPI 的显示器,对吧?我们遇到了设备像素比率的问题。传递给 paint worklet 的尺寸还没有按比例缩放以匹配。

与其手动缩放每个值,一个简单的解决方案是将border-image-slice 值乘以比例。以下是如何在不同环境中正确显示的步骤。

首先,让我们为 CSS 注册一个新的自定义属性来公开window.devicePixelRatio

CSS.registerProperty({
  name: "--device-pixel-ratio",
  syntax: "<number>",
  initialValue: window.devicePixelRatio,
  inherits: true
});

由于我们正在注册属性,并为它提供了初始值,因此我们不需要:root 上设置它,因为inherit: true 会将其传递给所有元素。

最后,我们将使用calc()border-image-slice 的值乘以比例。

.foo {
  border-image-slice: calc(15 * var(--device-pixel-ratio));
}

重要的是要注意,paint worklet 默认可以访问devicePixelRatio 值。你可以在类中直接引用它,例如console.log(devicePixelRatio)

完成

哇!现在我们应该在边框区域内绘制了一个正确缩放的图像!

Live demo (best viewed in Chrome and Edge)
现场演示 (最佳 在 Chrome 和 Edge 中查看)

额外内容:将此应用于背景图像

如果我不演示使用background-image 而不是border-image 的解决方案,我就会失职。只需对我们刚刚编写的模块进行一些修改,就可以轻松实现。

由于没有border-width 值可用,我们将将其设为自定义属性。

CSS.registerProperty({
  name: "--shadow-area-width",
  syntax: "<integer>",
  initialValue: "0",
  inherits: false
});

我们还需要使用自定义属性来控制背景颜色。由于我们在内容框内部绘制,设置实际的background-color 仍然会在背景图像后面显示。

CSS.registerProperty({
  name: "--shadow-rectangle-fill",
  syntax: "<color>",
  initialValue: "#fff",
  inherits: false
});

然后在.foo 上设置它们。

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
}

这一次,paint()background-image 上设置,使用与border-image 相同的参数。

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
  background-image: paint(shadow, 0 0 0, 8 2 1, 8 5 3);
}

正如预期的那样,这将在背景中绘制阴影。但是,由于背景图像会延伸到内边距框,我们需要调整padding 以便文本不会重叠。

.foo {
  --shadow-area-width: 15;
  --shadow-rectangle-fill: #efefef;
  background-image: paint(shadow, 0 0 0, 8 2 1, 8 5 3);
  padding: 15px;
}

回退

众所周知,我们生活在一个并非每个人使用相同浏览器或能够访问最新技术的世界上。为了确保他们没有收到损坏的布局,让我们考虑一些回退方案。

填充修复

父元素的填充将压缩内容框以适应其子元素延伸出的阴影。

section.parent {
  padding: 6px; /* size of shadow on child */
}

边距修复

子元素的边距可用于间距,以保持阴影远离它们的裁剪父元素

div.child {
  margin: 6px; /* size of shadow on self */
}

结合边框图像和径向渐变

这比填充或边距更偏离常规,但它在浏览器中得到了很好的支持。CSS 允许使用渐变代替图像,因此我们可以在 border-image 中使用一个,就像我们在 paint() 中使用的一样。只要设计不需要完全相同的阴影,这可能是一个非常好的替代 Paint API 解决方案的方案。

渐变可能很挑剔,而且很难设置正确,但 Geoff Graham 有一篇关于使用渐变的文章

div {
  border: 6px solid;
  border-image: radial-gradient(
    white,
    #aaa 0%,
    #fff 80%,
    transparent 100%
  )
  25%;
}

一个偏移伪元素

如果你不介意一些额外的标记和 CSS 定位,并且需要一个精确的阴影,你也可以使用一个内嵌伪元素。注意 z-index!根据上下文,可能需要调整它。

.foo {
  box-sizing: border-box;
  position: relative;
  width: 300px;
  height: 300px;
  padding: 15px;
}

.foo::before {
  background: #fff;
  bottom: 15px;
  box-shadow: 0px 2px 8px 2px #333;
  content: "";
  display: block;
  left: 15px;
  position: absolute;
  right: 15px;
  top: 15px;
  z-index: -1;
}

最终想法

就是这样,朋友们,这就是如何使用 CSS Paint API 来绘制你需要的图像。这是你下一个项目要尝试的第一件事吗?好吧,由你决定。浏览器支持仍在不断发展,但正在不断推进。

公平地说,它可能会增加比简单问题所需的复杂性更多。但是,如果你遇到需要将像素放在你想要的位置的情况,CSS Paint API 是一款强大的工具。

不过,最令人兴奋的是它为设计师和开发者提供的机会。绘制阴影只是 API 可以做到的一个小小例子。只要有点想象力和创造力,就可以实现各种新的设计和交互。

进一步阅读