变换

本章概要

本章将学习 transform 属性,它可以用来改变页面元素的形状和位置,其中包括二维或三维视角下的旋转、缩放与倾斜。变换通常与过渡或动画结合起来使用,这也是为什么我把本章内容放在这两个主题之间。本书最后两章会演示大量的过渡、变换与动画等页面特效来创建页面。

首先,我会带您了解 CSS 变换在静态元素上的设置方法,这样就可以理解这些变换行为是如何独立发挥作用的,以便后续把变换融入过渡特效中。接着我们会实现一个复杂的小菜单,涉及多种变换设置与过渡效果。最后再来看看 3 D 变换与透视图的用法。这部分内容会一直延续到下一章,届时我们将结合动画特效来考察 3 D 变换的实际应用。

16.1 旋转、平移、缩放与倾斜

Rotate, translate, scale, and skew

以下代码定义了一个基本的 CSS 变换规则:

transform: rotate(90deg);

这条样式规则在元素上生效后,会使该元素向右(顺时针)旋转 90 度。变换函数 rotate() 用于指定元素具体的变换方式。此外还有另外几种变换函数,它们通常被分为以下四类(如图 16.1 所示):

图 16.1 四种基本变换类型(虚线代表元素的初始位置与大小)
图 16.1 四种基本变换类型(虚线代表元素的初始位置与大小)

每种变换都会使用对应的函数来作为 transform 属性的值。下面创建一个简单的示例页,并在浏览器中小试牛刀。如图 16.2 所示,示例页为一张图文卡片。接下来就给该元素添加变换效果。

图 16.2 应用了旋转变换的简单卡片效果图
图 16.2 应用了旋转变换的简单卡片效果图

新建一个示例页面并关联一个新的样式表,然后将代码清单 16.1 所示的 HTML 标记添加到页面中。

代码清单 16.1 创建简单卡片示例页的 HTML 标记

<div class="card">
  <img src="images/chicken1.jpg" alt="a chicken">
  <h4>Mrs. Featherstone</h4>
  <p>
    She may be a bit frumpy, but Mrs. Featherstone gets the job done. She
    lays her largish cream-colored eggs on a daily basis. She is gregarious
    to a fault.
  </p>
  <p>This Austra White is our most prolific producer.</p>
</div>

接下来在样式表中添加代码清单 16.2 中的 CSS,其中包含了一些基础样式、颜色设置以及卡片元素的旋转变换设置。

代码清单 16.2 带旋转变换效果的示例卡片样式代码

body {
  background-color: hsl(210, 80%, 20%);
  font-family: Helvetica, Arial, sans-serif;
}

img {
  max-inline-size: 100%;
}

.card {
  max-inline-size: 300px;
  padding: 0.5em;
  margin-inline: auto; /* 居中卡片 */
  background-color: white;
  transform: rotate(15deg); /* 令卡片向右旋转 15 度 */
}

在浏览器中加载页面,会看到旋转后的卡片。不妨多试试,以建立对 rotate() 函数的初步印象。若角度为复制,卡片将向左旋转(比如改为 rotate(-30deg))。

接下来可以尝试使用其他函数修改变换类型。分别改为以下属性值,并观察它们的行为:

设置 CSS 变换时需要注意一点:虽然元素可能会移动到页面上的新位置,但它不会脱离(shift)文档流。您可以在屏幕范围内任意平移某个元素,其初始位置不会被其他元素占用。此外,当旋转某个元素时,它的某个角可能会移出屏幕边缘,同样也可能会遮住另一个元素的某些内容(如图 16.3 所示)。

图 16.3 变换元素不会令其他元素移动,因此可以发生重叠现象
图 16.3 变换元素不会令其他元素移动,因此可以发生重叠现象

某些情况下,为这样的变换元素(或者连同受牵连的元素)预留出足够的外边距,可以有效避免不必要的重叠。

警告

CSS 变换无法对 <span> 或者 <a> 这样的行内元素生效。若确实要对此类元素设置变换效果,要么将该元素的 display 属性手动改为除 inline 之外的其他属性值(例如 inline-block);要么将元素改为弹性子元素或者网格元素项(grid item,即对父元素声明 display: flex 或者 display: grid)。

16.1.1 变换原点的更改

Changing the transform origin

变换是围绕某个原点发生的。它是旋转的轴心,也是缩放或者倾斜开始的位置。换言之,元素的原点是固定不动的,而元素的其余部分则围绕该原点进行变换(但 translate() 是个例外,因为平移过程中整个元素都会移动)。

默认情况下,原点就是元素的中心点,但也可以通过 transform-origin 属性进行修改。图 16.4 展示了几个围绕不同原点位置实现的元素变换效果。

图 16.4 以不同的边角位置为原点实现的元素旋转、缩放与倾斜变换效果示意图

【图 16.4 以不同的边角位置为原点实现的元素旋转、缩放与倾斜变换效果示意图】

上图中左侧的元素,其围绕旋转的原点位置,由 transform-origin: right bottom 来定义;中间的元素也向着原点位置(即 right top)缩放;而右侧的元素的倾斜方式为:原点(即 left top)保持不动,元素其余部分向外拉伸(stretches away)。

原点位置也可以用百分比设定,从元素的左上角开始测量。一下两句声明是等效的:

transform-origin: right center;
transform-origin: 100% 50%;

此外,也可以使用 pxem 或者其他单位的长度值来设置原点。不过根据我的经验,使用 toprightbottomleftcenter 这些关键字,在大部分项目中已经够用了。

16.1.2 多重变换的设置

Applying multiple transforms

transform 属性也可以设置多个值,用空格分隔即可。变换的每个值将按照 从右向左 的顺序依次生效。例如设置 transform: translate(50px, 0) rotate(15deg),元素会先顺时针旋转 15 度角,然后再向右平移 50px。请根据代码清单 16.3 同步更新本地样式表。

代码清单 16.3 设置多重变换的示例样式代码

.card {
  max-inline-size: 300px;
  padding: 0.5em;
  margin-inline: auto;
  background-color: white;
  transform: translate(50px, 0) rotate(15deg); /* 先顺时针旋转 15 度,然后再向右平移 50px */
}

最简单的查看这种效果的方法就是打开浏览器的开发者工具,实时修改属性值,看看它们是如何影响元素行为的。

弄错变换的执行顺序可能会让情况变得非常棘手。通常情况下,将 translate() 操作放在时间上最靠后的位置往往更简单(这样 transform 对应的源码顺序就要放到开头的位置),如本例所示。这样就可以使用正常的上/下、左/右坐标系来设置旋转后的元素效果了。

为了更好的理解这一点,不妨再将变换设置反转为 transform: rotate(15deg) translate(50px, 0)。注意,在开发者工具 DevTools 中修改 translate() 的参数值貌似会沿着某个倾斜的坐标轴进行平移,而非正常期望的方向;这是因为先执行的是平移变换、后执行的旋转变换。同理,人们通常也习惯先执行 scale() 变换、再执行 translate() 变换。

16.1.3 单个变换属性的设置

Individual transform properties

CSS 最常见的三个独立的变换属性分别为 translaterotatescale。对于简单的变换效果,使用它们往往更加简便;而对于倾斜变换、以及后续将会介绍的一些更高级的 3 D 变换效果,CSS 则没有提供单独的样式属性。

根据代码清单 16.4 同步更新本地样式表。该代码启用了单独的 translate 属性和 rotate 属性来实现上个示例所演示的变换效果。

代码清单 16.4 单个变换属性的用法示例

.card {
  max-inline-size: 300px;
  padding: 0.5em;
  margin-inline: auto;
  background-color: white;
  translate: 50px 0;
  rotate: 15deg;
}

当存在多个单独的变换属性声明时,首先生效的应该是 scale 倾斜变换,其次为 rotate 旋转变换、最后生效的应该是 translate 平移变换;这往往是最简单的工作顺序。这三个变换属性会在其他利用 transform 属性声明的样式生效后再生效。

通常,无论是在设计简单变换效果的时候,还是希望单独对其中的某个变换添加过渡或动画特效的时候,我都更倾向于启用这三个独立属性。在后续构建的示例页中我还会给出几个这样的示例。另一方面,当我想修改变换效果的顺序、或者希望对它们同时使用过渡/动画特效时,则会考虑用 transform 属性来实现。

16.2 变换在动效中的应用

Transforms in motion

变换本身并不是特别实用。虽然添加了 skew() 变换的内容框看上去可能很有趣,但并不适合文字阅读。但是,当与动效结合起来使用的时候,变换就有用多了。

本节将创建一个新页面来实践这种用法。最终的页面效果如图 16.5 所示。我们会给页面添加很多动态效果。

图 16.5 左侧的导航菜单图标将包含几个变换和过渡效果

【图 16.5 左侧的导航菜单图标将包含几个变换和过渡效果】

本节我们将实现左侧的导航菜单部分。初始状态下,菜单只有四个纵向排列的图标;但只要鼠标悬停上去,菜单文字就会出现。这个示例包含了几个过渡与变换效果。我们先来实现页面,然后再进一步研究导航菜单部分(下一章我们会实现中间主区域的卡片部分,并为其添加更多的变换与动画特效)。

新建一个页面并关联一个新的样式表 style.css,然后添加代码清单 16.5 中的 HTML 标记。该代码包含了一个谷歌字体的 API 链接,引用了两款 Web 字体(即 Alfa Slab OneRaleway)。此外还包含页头和导航菜单部分的 HTML 标记。

代码清单 16.5 带动态变换效果的示例页 HTML 标记

<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>The Yolk Factory</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Raleway&display=fallback"
      rel="stylesheet"><!-- 为页面添加  Alfa Slab One 与 Raleway 两款字体 -->
    <link rel="stylesheet" href="style.css">
  </head>

  <body>
    <header>
      <h1 class="page-header">The Yolk Factory</h1>
    </header>
    <nav class="main-nav">
      <ul class="nav-links">
        <li>
          <!--<a href="/"> 每个导航链接分别包含一张图片和一段文本标签 -->
            <img src="images/home.svg" class="nav-links__icon"/>
            <span class="nav-links__label">Home</span>
          </a>
        </li>
        <li>
          <a href="/events"><!-- 每个导航链接分别包含一张图片和一段文本标签 -->
            <img src="images/calendar.svg" class="nav-links__icon"/>
            <span class="nav-links__label">Events</span>
          </a>
        </li>
        <li>
          <a href="/members"><!-- 每个导航链接分别包含一张图片和一段文本标签 -->
            <img src="images/members.svg" class="nav-links__icon"/>
            <span class="nav-links__label">Members</span>
          </a>
        </li>
        <li>
          <a href="/about"><!-- 每个导航链接分别包含一张图片和一段文本标签 -->
            <img src="images/star.svg" class="nav-links__icon"/>
            <span class="nav-links__label">About</span>
          </a> 
        </li> 
      </ul> 
    </nav>
  </body>
</html>

上述代码中,nav 元素占据了绝大部分篇幅,其中有个带链接的无序列表(<ul>)。每个链接都由一个图标图片和一个文本标签构成。注意,这里的图标图片是 SVG 格式的。后面您就会知道这一点很重要,届时我们将在第 17 章为页面添加更多内容。

关于 SVG 的定义

SVG —— 全称为 Scalable Vector Graphics,即可缩放矢量图形。这是一种基于 XML 的图片格式,利用矢量(vectors)来定义图片。由于图片是使用数学计算得到的,因此可以无损地缩放到任意尺寸。

接下来我们添加一些基础样式,包括背景渐变标题区域的内边距,同时在为页面引入 Web 字体。请根据代码清单 16.6 同步更新本地样式表。这些都是页面的全局样式以及页头的一些模块层样式。菜单部分的布局设置后面再说。

代码清单 16.6 页面全局样式及页头模块的示例样式代码

@layer reset, theme, global, modules;

@layer reset {
  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }

  body {
    margin: unset;
  }

  img {
    max-inline-size: 100%;
  }

  @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.01ms !important;
      scroll-behavior: auto !important;
    }
  }
}

@layer theme {
  :root {
    --bg-color-1: oklch(47% 0.1 238deg);
    --bg-color-2: oklch(32% 0.08 238deg);
    --font-color: white;
    --accent-yellow: oklch(87% 0.13 83deg);
  }
}

@layer global {
  body {
    min-block-size: 100dvh; /* 确保 body 元素填满视口,实现渐变特效的全覆盖 */
    background-color: var(--bg-color-1);
    background-image: radial-gradient( /* 深蓝色背景渐变效果 */
      var(--bg-color-1),
      var(--bg-color-2)
    );
    color: var(--font-color);
    font-family: Raleway, Helvetica, Arial, sans-serif;
    line-height: 1.4;
  }

  h1,
  h2,
  h3 {
    font-family: "Alfa Slab One", serif;
    font-weight: 400;
  }
}

@layer modules {
  .page-header {
    margin-block: unset;
    padding: 1rem; /* 让页头模块在移动端视口下减少内边距 */
  }

  @media (min-width: 480px) {
    .page-header {
      padding: 2rem 2rem 3rem; /* 让页头模块在较大的视口中相应调大内边距 */
    }
  }
}

本例应用了前面章节的很多知识点。body 元素的背景样式采用了径向渐变,可以给页面带来些许立体感。标题文字采用的是 Web 字体 Alfa Slab One,而正文部分则使用 Raleway 字体。此外还利用媒体查询实现了页头部分的响应式设计,当屏幕尺寸允许的情况下适度增大了内边距。

而菜单部分的样式设计则需要分为以下几个步骤。首先完成菜单布局,然后提供一些响应式行为。我们将采用移动端优先(详见第 7 章)的实现方案,因此最好从小尺寸视口开始设计。最终标题和菜单项要实现的页面效果,如图 16.6 所示。

图 16.6 导航菜单的移动端设计效果图

【图 16.6 导航菜单的移动端设计效果图】

鉴于小尺寸屏上的导航链接是水平排列的,使用 Flexbox 布局比较合适。只要在弹性容器上声明 align-content: space-between,导航菜单项就可以在整个页面宽度上均匀分布。然后再设置字体颜色并对齐图标。请根据代码清单 16.7 同步更新本地样式表。

代码清单 16.7 移动端导航菜单的示例样式代码

@layer modules {
 .nav-links {
    display: flex; /* 使用 Flexbox 布局 */
    justify-content: space-between; /* 让导航菜单项水平均匀展布 */
    gap: 0.8em;
    margin-block: 0 1rem;
    padding-inline: 1rem;
    list-style: none;
  }
  .nav-links > li > a {
    display: block;
    padding-block: 0.8em;
    color: white;
    font-size: 0.8rem;
    /* 为链接文字设计样式: */
    text-decoration: none;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }
  .nav-links__icon {
    height: 1.5em;
    width: 1.5em;
    vertical-align: -0.2em; /* 图标略微下移,与文本标签对齐 */
  }
  .nav-links > li > a:hover {
    color: var(--accent-yellow);
  }
}

这样就实现了小尺寸屏上的导航菜单效果;而屏幕越大,可以添加的特效相应就更多。对于桌面端布局,可以用固定定位让导航菜单停靠在屏幕左侧,效果看上去如图 16.7 所示。

图 16.7 停靠在大尺寸屏左侧的导航菜单效果图
图 16.7 停靠在大尺寸屏左侧的导航菜单效果图

此时的菜单由两个模块组成,即已经命名的外层元素 main-nav,以及名为 nav-links 的内部结构。其中 main-nav 为容器元素,需要固定在左侧,并提供了深色背景。下面就来实现一版。

请根据代码清单 16.8 同步更新本地样式表,注意第二个媒体查询及其内容需要放到 nav-links 样式的后面,这样才能在查询条件生效时有效覆盖对应的移动端样式。

代码清单 16.8 大尺寸屏下定位导航菜单的示例样式代码

@layer modules {
  @media (min-width: 480px) { /* 该样式规则仅对大中型屏幕生效 */
    .main-nav {
      position: fixed;
      top: 8.25rem;
      left: 0;
      z-index: 10; /* 确保导航菜单渲染到后续新增内容的上一层 */
      background-color: transparent; /* 令初始背景色为透明 */
      transition: background-color 0.5s linear; /* 为背景色设置过渡特效 */
      border-top-right-radius: 0.5em;
      border-bottom-right-radius: 0.5em;
    }
    .main-nav:hover,
    .main-nav:focus-within {
      background-color: rgb(0 0 0 / 0.6); /* 鼠标悬停时,背景色变为深色半透明效果 */
    }
  }

  .nav-links {
    display: flex;
    justify-content: space-between;
    gap: 0.8em;
    margin-block: 0 1rem;
    padding-inline: 1rem;
    list-style: none;
  }
  .nav-links > li > a {
    display: block;
    padding-block: 0.8em;
    color: white;
    font-size: 0.8rem;
    text-decoration: none;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }
  .nav-links__icon {
    height: 1.5em;
    width: 1.5em;
    vertical-align: -0.2em;
  }
  .nav-links > li > a:hover,
  .nav-links > li > a:focus {
    color: var(--accent-yellow);
  }

  @media (min-width: 480px) { /* 覆盖移动端的 Flexbox 布局,让各链接纵向排列 */
    .nav-links {
      display: block;
      padding: 1em;
      margin-block-end: 0;
    }
    .nav-links__label {
      margin-left: 1em;
    }
  }
}

导航菜单通过声明 position: fixed 进行固定定位,即便页面滚动其位置也不受影响;而 display: block 规则覆盖了移动端下的 display:flex,从而令菜单项垂直叠放在一起。

Now you can start layering in some transition and transform effects. For that, you’ll do three things:
现在你可以开始添加一些过渡和变换效果。为此,你需要做三件事:

一切就绪后,就可以添加过渡和变换效果了。为此,需要实现以下三个功能:

  1. 鼠标划过链接时,图标元素需要放大;
  2. 先隐藏链接标签,直到用户鼠标悬停到菜单上时,才将其全部展示出来,并设置淡入(fade-in)的过渡特效。
  3. 利用平移变换给链接标签添加 “飞入”(“fly in”)效果,并与淡入特效相结合。

下面来逐一实现这些功能点。

16.2.1 放大图标

Scaling up the icon

先来看看导航链接的 HTML 结构。每个列表项都包含一个链接元素(<a>),链接里面又包含了一个图标和一个标签:

<li>
  <!--<a href="/">-->
    <img src="images/home.svg" class="nav-links__icon"/>
    <span class="nav-links__label">Home</span>
  </a>
</li>

注意

由于列表项要与父元素 <ul> 组合在一起使用,构建出的模块就比我预想中的体积更大,嵌套层级也更深。我也考虑过将其拆分为较小的模块,但眼下还是有必要将它们放在一起,这样方便对其整体设置特效。

我们先来实现鼠标悬停时的图标放大效果。这里要用到缩放变换,然后设置过渡特效,实现变换过程的平稳流畅(seamlessly)。如图 16.8 所示,当鼠标悬停在 Events 菜单项上时,该图标会稍微放大一些。

图 16.8 鼠标悬停到某链接后,其对应图标略微放大

【图 16.8 鼠标悬停到某链接后,其对应图标略微放大】

Events 的图标具有固定的宽高,因此可以调大这些属性值来尝试放大图标。但这样会导致文档流重新计算,使得周围一些元素跟着移动。

如果改用变换(transform),周围的元素则不受任何影响,Events 的文本标签部分也不会向右移动。请根据代码清单 16.9 同步更新样式表,让下列样式在元素悬停(通过鼠标操作)或聚焦(通过键盘操作)时都能生效。

代码清单 16.9 图标链接在悬停或聚焦时放大的示例样式代码

@media (min-width: 30em) {
  .nav-links {
    display: block;
    padding: 1em;
    margin-block-end: 0;
  }
  .nav-links__label {
    margin-left: 1em;
  }

  .nav-links__icon {
    transition: scale 0.2s ease-out; /* 为 transform 属性设置过渡特效 */
  }

  .nav-links a:hover > .nav-links__icon,
  .nav-links a:focus > .nav-links__icon {
    scale: 1.3; /* 增大图标尺寸 */
  }
}

这样,当鼠标划过菜单项,您将看到对应的图标会变大一些,帮助用户确认正在悬停的菜单项。这里特意使用了 SVG 图片资源,这样在图片尺寸变化时就不会出现像素颗粒或者其他奇怪的失真感。缩放变换效果是实现该功能点的绝佳方案。

SVG:一种更好的图标解决方案(SVG: A better approach to icons)

图标在某些设计的重要组成部分。图标的使用技巧也一直在进化。很长一段时间里,使用图标的最佳实践,是把所有图标放入单个图片文件,并称之为 精灵图(sprite sheet。然后利用 CSS 背景图片,小心翼翼地调整尺寸大小和背景位置,在元素中渲染出精灵图上的某个图标。

后来,图标字体(icon font) 开始流行起来。这种解决方案不再把图标嵌入精灵图,而是将每个图标作为字符嵌入一个自定义的字体文件中。通过使用 Web 字体,单个字符将被渲染成图标。像 Font Awesome 这样的一些线上服务还提供了几百个通用图标来简化这一过程,但使用这种方法也存在一些无障碍浏览方面的问题(accessibility concerns)。

尽管这些技术仍然有效,但我还是建议您使用 SVG 图标。SVG 功能更强大,性能也更好。SVG 既可以像本章演示的那样作为 <img> 的源(source)来使用,同时也提供了其他用法。我们可以创建一个 SVG 精灵图,或者利用 SVG 本就是基于 XML 的文件格式这一本质特征,直接将其嵌入 HTML 页面中,例如:

<li>
  <!--<a href="/">-->
    <svg class="nav-links__icon" width="20" height="20" viewBox="0 0 20 20">
      <path fill="#ffffff" d="M19.871 12.165l-8.829-9.758c-0.274-0.303-0.644-0.47-1.042-0.47-0 0 0 0 0 0-0.397 0-0.767 0.167-1.042 0.47l-8.829 9.758c-0.185 0.205-0.169 0.521 0.035 0.706 0.096 0.087 0.216 0.129 0.335 0.129 0.136 0 0.272-0.055 0.371-0.165l2.129-2.353v8.018c0 0.827 0.673 1.5 1.5 1.5h11c0.827 0 1.5-0.673 1.5-1.5v-8.018l2.129 2.353c0.185 0.205 0.501 0.221 0.706 0.035s0.221-0.501 0.035-0.706zM12 19h-4v-4.5c0-0.276 0.224-0.5 0.5-0.5h3c0.276 0 0.5 0.224 0.5 0.5v4.5zM16 18.5c0 0.276-0.224 0.5-0.5 0.5h-2.5v-4.5c0-0.827-0.673-1.5-1.5-1.5h-3c-0.827 0-1.5 0.673-1.5 1.5v4.5h-2.5c-0.276 0-0.5-0.224-0.5-0.5v-9.123l5.7-6.3c0.082-0.091 0.189-0.141 0.3-0.141s0.218 0.050 0.3 0.141l5.7 6.3v9.123z"></path>
    </svg>
    <span class="nav-links__label">Home</span>
  </a>
</li>

如有必要,也可以直接选中 SVG 中的某个部分设置 CSS 样式。使用常规的 CSS 就可以动态调整 SVG 不同部分的颜色,甚至改变其大小和位置。同时 SVG 的文件体积更小,因为它不同于 GIFPNG 这些像素化的图片或者其他基于光栅的(raster-based)图片格式。

如果您不太熟悉 SVG,可以查看 CSS-TRICKS 网站的这篇《Using SVG》,其中介绍了在 Web 页面使用 SVG 的多种方式,是个不错的入门资料。

现在图标看上去可以了,接下来让我们把注意力转向它旁边的文本标签。

译注:ChatGPT 对 Web 字体无障碍浏览问题的解读

问:Web 字体据说存在无障碍浏览方面的问题?

答: 是的,Web 字体在无障碍浏览方面确实可能存在一些问题。以下是主要的关注点及其解决方案:

关注点 问题描述 解决方案
加载延迟 Web 字体可能需要时间加载,这会导致文本内容在初次渲染时显示为后备字体,影响用户体验。 使用 font-display: swap; 属性,以便在 Web 字体加载时先显示后备字体,减少内容不可见的时间。
可读性 一些 Web 字体的设计可能降低可读性,尤其对有视觉障碍的用户来说,复杂的字形可能难以识别。 选择简单、清晰的字体,确保足够的对比度,并考虑使用无衬线字体,通常更易读。
兼容性问题 某些屏幕阅读器可能无法正确识别或朗读使用 Web 字体显示的文本。 使用广泛支持的字体,确保页面在不同设备和浏览器中表现一致。
字体特性 一些装饰性字体可能包含复杂的字形或不常见的字符,这可能对有阅读障碍的用户造成困扰。 避免使用过于复杂或艺术化的字体,选择设计简洁的字体。
后备字体 如果 Web 字体无法加载,文本可能会显示为后备字体,影响视觉一致性。 为每种字体定义后备字体,确保文本在任何情况下都能正常显示。

结论
Web 字体可以增强网站的视觉吸引力,但在实现无障碍浏览时,需要谨慎选择和使用。通过优化字体加载、选择易读的字体和确保兼容性,可以提升所有用户的体验,尤其是有特殊需求的用户。

16.2.2 带“飞入”特效的文本标签的创建

Creating “fly in” labels

菜单标签不必一直保持可见状态。默认情况下可以将其隐藏,只在相应位置保留图标告诉用户菜单的位置即可。当用户移动鼠标到菜单或者导航元素上时,再把标签以淡入的方式展示出来。这样,只要鼠标一靠近图标,整个导航菜单就都渲染出来了,其间会用到多个特效:背景和文本标签都使用了淡入特效,且标签是从最终位置略偏左一点开始过渡的(如图 16.9 所示)。

图 16.9 鼠标悬停时,菜单淡入,文本标签则从左侧滑动淡入

【图 16.9 鼠标悬停时,菜单淡入,文本标签则从左侧滑动淡入】

整个特效中,文本标签同时用到了两个不同的过渡特效:一个是针对透明度,另一个则针对平移变换。试根据代码清单 16.10 同步更新本地样式表。

代码清单 16.10 导航菜单项的过渡特效示例样式代码

@media (min-width: 480px) {
  .nav-links {
    display: block;
    padding: 1em;
    margin-block-end: 0;
  }
  .nav-links__label {
    display: inline-block; /* 令标签为行内块级元素,以便对其设置变换 */
    margin-left: 1em;
    padding-right: 1em;
    opacity: 0; /* 标签初始隐藏 */
    translate: -1em; /* 令标签左移 1em */
    /* 为有变更的属性值设置过渡特效: */
    transition: transform 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.3), 
                opacity 0.4s linear;
  }
  .nav-links:hover .nav-links__label,
  .nav-links:focus-within .nav-links__label {
    opacity: 1; /* 鼠标悬停或聚焦状态下,令标签可见 */
    translate: 0; /* 鼠标悬停或聚焦状态下,令标签同时平移回原位 */
  }
  .nav-links__icon {
    transition: transform 0.2s ease-out;
  }
  .nav-links a:hover > .nav-links__icon,
  .nav-links a:focus > .nav-links__icon {
    transform: scale(1.3);
  }
}

该导航菜单尽管只占了屏幕的一小部分,但实际上包含了很多样式;其中某些菜单选择器还写得又长又复杂。

留意上述代码在顶层的 nav-links 元素上添加的 :hover:focus-within 这两个伪类样式。这样一来,无论是用户鼠标划过菜单,还是通过键盘上 Tab 键让菜单项获得焦点,所有标签都会立刻渲染出来。

在隐藏状态下,文本标签利用 translate() 函数向左平移了 1em;而在淡入过程中,又通过过渡特效回到了原来的位置。这里省略了 translate() 函数的第二个参数,只设置了 x 的值,这样就只产生水平位移。因为元素无需上下移动,所以这样写也没问题。

而自定义的 cubic-bezier() 三次贝塞尔函数值得重点关注。它产生了一个弹跳特效:标签向右移动时超出了最终停止的位置,然后再回到最终位置停下来。其运动曲线效果如图 16.10 所示。

图 16.10 终点处带弹跳特效的贝塞尔曲线示意图

【图 16.10 终点处带弹跳特效的贝塞尔曲线示意图】

我们发现贝塞尔曲线超出了上边框,这就意味着数值超过了过渡特效的最终值。在从 translate: -1em 过渡到 translate: 0 期间,文本标签的变换会短暂到达超出最终数值约 0.15em 的位置,然后再缓缓回到最终数值。同理,我们也可以在定时函数的初始阶段添加弹跳效果,即把第一个控制柄移至矩形框底边以外。但过渡曲线是不可能超出左右两侧边缘的,这不合逻辑。

在浏览器中加载页面,查看最终的过渡效果。弹跳过程一闪而过(subtle),估计得调慢过渡时间才能更好地进行观察调试。这样看上去像是给文本标签引入了重量和动能,使得运动过程更加自然。

16.2.3 过渡特效的交错渲染

Staggering the transitions

至此,导航菜单看上去已经非常不错了,我们最后再做一次优化,让它更加精致。我们会用到 transition-delay 属性,为每个菜单项设置不同的延迟时间。这样就可以让每段飞入动画交错渲染,而不是一次性全部渲染出来,犹如翻滚的 “波浪”(如图 16.11 所示)。

图 16.11 各导航菜单项将从上到下依次飞入渲染

【图 16.11 各导航菜单项将从上到下依次飞入渲染】

要实现这样的页面效果,需要用到 :nth-child() 伪类选择器,根据每个菜单项在列表中的位置进行选中,然后分别给每个菜单项设置连续变长的过渡延迟。请将下列代码清单 16.11 给出的示例样式代码更新到本地样式表,并放在 nav-llinks 样式规则的后面。

代码清单 16.11 为菜单项添加带交错渲染效果的过渡延迟示例代码

.nav-links:hover .nav-links__label,
.nav-links:focus-within .nav-links__label {
  opacity: 1;
  translate: 0;
}
.nav-links > li:nth-child(2) .nav-links__label { /* 选中第二个菜单项的标签 */
  transition-delay: 0.1s; /* 为过渡设置 0.1s 的延迟 */
}
.nav-links > li:nth-child(3) .nav-links__label { /* 选中第三个菜单项的标签 */
  transition-delay: 0.2s; /* 为过渡设置 0.2s 的延迟 */
}
/* 根据实际需求重复上述操作 */
.nav-links > li:nth-child(4) .nav-links__label {
  transition-delay: 0.3s;
}
.nav-links > li:nth-child(5) .nav-links__label {
  transition-delay: 0.4s;
}

选择器 :nth-child(2) 选中的是列表项中的第二项,我们给它加上一些延迟。第三项(即 :nth-child(3))上的延迟时间稍长一点,第四、五项的又分别再长一点。我们不需要为第一个元素项操心,因为我们希望首个元素的过渡特效立即生效,无需延迟。

在浏览器中重新加载页面,并用鼠标划过导航菜单来查看效果。整个菜单显得流畅生动。鼠标移开时,所有元素又以同样的交错顺序淡出屏幕。

我们发现这种实现方式存在一个缺点,即菜单项必须和所写的选择器数量一样多。示例代码针对第五个元素也添加了延迟规则,尽管当前的导航菜单只有四个元素。这是一种预防措施(safeguard),以防将来再增加一个菜单项。虽然安全起见我们甚至可以为第六个元素添加规则,但需要意识到菜单数量总是有可能在某个时间点超出已有样式规则的,届时一定要记得在 CSS 中同步添加更多的规则。

提示

类似的重复代码块可以使用预处理器来进行简化。相关示例参见 附录 B

这样导航菜单就大功告成了,接下来可以给页面添加更多内容。我们将在下一章继续实现,因此请保留示例页面以便随时添加新内容。在此之前,还有几件和变换效果有关的事项需要了解。

16.3 动画的性能

Animation performance

有些变换看上去好像没有存在的必要。例如平移变换的效果,通常也可以使用相对定位来实现;对图片或 SVG 进行缩放变换,其实也可以通过设置宽高完成。

其实,变换在浏览器中的性能要好很多。如果我们对元素位置设置动画效果(例如对属性 left 添加过渡特效),就能明显感受到性能上的不足。特别是在给大型复杂元素设置动画、或者是在页面上一次性给大量元素添加动画时,问题尤为突出。这种性能问题在 CSS 过渡(详见第 15 章)和动画(下一章会讨论)效果上都有体现。

如果我们要实现过渡或动画,无论什么类型,包括元素定位或大小设置,都应该尽可能地考虑用变换实现。想要彻底搞懂原因,需要进一步深入考察页面在浏览器中的渲染方式。

16.3.1 渲染流程

Looking at the rendering pipeline

当浏览器计算好页面上哪些样式会在哪些元素上生效之后,需要把这些样式转换为屏幕上的像素,这个过程就叫作 渲染(rendering。渲染可分为三个阶段:布局(layout)阶段、绘制(paint)阶段与合成(composite)阶段(如图 16.2 所示)。

图 16.12 渲染流程中的三个阶段

【图 16.12 渲染流程中的三个阶段】

16.3.1.1 布局

Layout

在第一个阶段 布局(layout 中,浏览器需要计算每个元素将在屏幕上占据多大空间。由于文档流的工作方式,一个元素的大小和位置可以影响页面上无数其他元素的大小和位置。这个阶段会将所有这些问题整理清楚。

任何时候改变一个元素的宽高,或者调整其位置属性(如 topleft)时,都必须重新计算该元素的布局。如果使用 JavaScript 在 DOM 中插入或移除某个元素,也会导致布局的重新计算。一旦布局发生改变,浏览器就必须 重排(reflow(译注:即重新排版)页面,重新计算因布局变更而被移动、或被调整大小的所有其他元素的布局。

16.3.1.2 绘制

Paint

布局之后便是 绘制(painting。这个过程就是填充像素:例如绘制文本、给图片、边框、阴影上色等。这一阶段并不会真正显示到屏幕上,而是在内存中绘制。页面各部分内容就这样被绘制到不同的 图层(layers 中。

举个例子,假如需要改变某个元素的背景颜色,就必须重新绘制该元素。但由于更改背景色不会影响页面上任何元素的位置与大小,这样的变更也无需重新计算布局。因此,改变背景颜色的计算开销要低于改变元素尺寸的开销。

在适当的条件下,页面元素会被提取到自己的图层。此时,该元素会从页面的其他图层中独立出来单独绘制。浏览器把这个图层发送到计算机的图形处理器(graphics processing unit,即 GPU)进行绘制,而不是像主图层那样使用主 CPU 绘制。这样安排是有好处的,因为 GPU 经过了充分的优化,比较适合做这类计算。

这就是我们经常提到的 硬件加速(hardware acceleration,因为需要依赖于计算机上的某些硬件来提高渲染速度。多个图层就意味着需要消耗更多的内存,但好处则是可以加快渲染的处理时间。

16.3.1.3 合成

Composite

合成(composite 阶段,浏览器收集所有绘制完成的图层,并把它们整合为最终呈现给用户浏览的图像。合成过程需要按照特定顺序进行,以确保图层在出现重叠时,让正确的图层出现在其他图层的前面。

某些属性在属性值发生变更时,需要的渲染时间会更短,尤其是 opacitytransform 这两个属性。在我们修改元素的这两个属性之一时,浏览器就会把元素提升到它们各自的绘制图层并使用 GPU 加速。因为元素位于自己的图层中,所以主图层在整个图像变化过程中不会发生变化,也无须反复重绘(repeated repainting)。

如果只是对页面做一次性修改,这样的优化往往不会带来多么显著的差异;但要是修改的是动画的一部分时,屏幕需要在一秒内发生多达几十次的更新,这种情况下渲染速度的提升就很关键了。大部分的屏幕每秒钟会刷新 60 次。理想情况下,动画中的每一次变化需要的重新计算也至少要达到这个速度,才能在屏幕上生成最流畅的运动轨迹。浏览器在每次重新计算时需要完成的工作越多,达到这个速度也就相应越困难。

使用 will-change 控制绘制图层(Controlling paint layers with will-change)

浏览器在优化渲染流程方面已经取得了长足的进步,会尽可能将一些元素划归到不同的图层。如果对某个元素的 opacity 或者 transform 属性设置动画,现代浏览器为了使动画过程更加流畅,通常会基于包括系统资源在内的一系列因素来作出最佳处理;但有时也可能会遭遇卡顿(choppy)或者动画闪烁(flickering animations)。

如果遇到这种情况,可以使用一个叫做 will-change 的属性来对渲染图层实施控制。该属性可以提前告知浏览器,元素的特定属性将发生改变。这通常意味着元素将被提升到自己的绘制图层中。例如,设置了 will-change: transform 就表示我们期望改变元素的 transform 属性。

除非遇到性能问题,否则不要在页面上盲目添加该属性,因为这样会占用很多系统资源。最好应用前后都测试一下,在性能表现良好时再将 will-change 留在样式表中。想要更加深入地了解该属性的用法,以及是否应该启用该属性,可以查看 Sara Soueidan 的这篇优质文章《Everything You Need To Know About The CSS will-change Property》。

我发现这篇文章发表后,有件事发生了变化:文章表示只有 3 D 变换会将元素提升到自己的图层,现在已经不是这样了。最新的浏览器也支持对 2 D 变换使用 GPU 加速。

在处理过渡或动画(下一章会重点介绍)特效的时候,尽量只改变 transformopacity 属性。如有必要,可以修改那些只会导致重绘(paint)而不会重新布局(re-layout)的属性。只有在没有其他替代方案的情况下,再去修改那些影响布局的属性,并且密切关注动画中是否存在性能问题。如果想要查看哪些属性会导致布局、绘制和/或合成,也可以访问这个网站:CSS Triggers

译注

实测发现,介绍 will-change 属性时提到的那篇文章链接已经失效了,要查看原文可以访问这个链接:https://richstyle.org/?documentation/css-will-change-property-en

16.4 三维变换

3D transforms

到目前为止我们使用的变换都是二维的。这些变换是最容易处理的(也最常见),因为页面本身就是二维的。但我们不应该止步于此,旋转和平移都可以在三个维度上实现:x 轴、y 轴和 z 轴。

我们可以像之前那样使用 translate() 函数,在水平和垂直方向上平移(即 xy 维度);也可以通过 translateX()translateY() 函数来实现同样的效果。下面三个声明会产生同样的效果:

transform: translate(15px, 50px);
transform: translateX(15px) translateY(50px);
translate: 15px 50px;

我们还可以使用 translateZ() 函数、或者给 translate 属性提供第三个参数值来实现 z 轴上的平移变换。从概念上看,这个 z 值相当于通过平移的方式让该元素更靠近或远离用户。

同理,也可以让元素绕着三个不同的坐标轴进行旋转。但与平移不同的是,我们已经非常熟悉 rotateZ() 函数了,因为 rotateZ() 也是 rotate() 的别名,它就是绕着 z 轴旋转的。函数 rotateX()rotateY() 分别围绕水平方向上的 x 轴(使元素向前或向后倾斜(pitching))和垂直方向上的 y 轴(使元素向左或者向右转动或 偏移(yawing)旋转。有关这些函数的具体示例,如图 16.13 所示。

图 16.13 透视距离为 300px 下的三个坐标轴上的旋转效果图(虚线表示元素的初始位置)

【图 16.13 透视距离为 300 px 下的三个坐标轴上的旋转效果图(虚线表示元素的初始位置)】

在使用 rotate 属性进行变换时,还可以指定坐标轴的名称 xy 或者 z,并带上角度值,例如 rotate: z 20deg 或者 rotate: x 30deg

16.4.1 控制透视距离

Controlling perspective

在给页面添加三维变换特效前,我们需要先明确一个概念,即 透视距离(perspective。变换后的元素共同构成了一个 3 D 场景。随后浏览器会计算该 3 D 场景的 2 D 图像并渲染到屏幕上。我们可以把透视距离想象成 “摄像机” 与场景之间的距离,前后移动镜头就可以改变整个场景在最终图像中的展示方式。

如果镜头离得较近(即透视距离小),那么 3 D 效果会比较强;如果镜头离得较远(即透视距离大),则 3 D 效果就比较弱。图 16.14 列出了几种不同的透视距离效果。

图 16.14 同一旋转变换在不同透视距离下的页面效果
图 16.14 同一旋转变换在不同透视距离下的页面效果

左侧这个旋转后的元素没有设置透视距离,看起来不太像是 3 D 的。它只是在水平方向上做了些压缩,并没有什么立体感。不设置透视距离的三维变换看上去像是平的(flat);元素中 “更远” 的那部分并没有渲染得小一些。另一方面,中间那个盒子设置了 400px 的透视距离,其右边缘(即距离观察者较远的那一边),看起来小了些;而离观察者较近的这一侧看上去则变大了些。右边的盒子透视距离更短,只有 100px。这样就大大增强了 3 D 效果,使得元素边缘离得越远,图像缩小得也就越明显。

我们可以通过两种方式来指定透视距离:使用 perspective() 变换,或者使用 perspective 属性(property)。两种方式略有不同。下面通过一个简单的示例来说明。这是个简化版示例,仅用于演示透视距离的页面效果。

首先,我们为四个元素添加旋转特效,使用 rotateX() 将它们向后倾斜(如图 16.5 所示)。因为每个元素旋转同样的角度,并且设置了相同的 perspective() 变换,所以它们看上去是一样的。

图 16.15 四个元素都围绕 x 轴旋转、并且均设置了 perspective(200px) 后的效果图
图 16.15 四个元素都围绕 x 轴旋转、并且均设置了 perspective (200 px) 后的效果图

为这个演示案例新建一个页面,并复制以下代码清单 16.12 中的 HTML。

代码清单 16.12 用于演示三维变换及透视效果的四个盒子 HTML 标记

<div class="row">
  <div class="box">One</div>
  <div class="box">Two</div>
  <div class="box">Three</div>
  <div class="box">Four</div>
</div>

接着,我们给每个盒子添加 3 D 变换和透视距离。同时也可以来点颜色和内边距样式,略微扩充些尺寸大小,让最终效果更加明显。请根据代码清单 16.13 同步更新本地样式表。

代码清单 16.13 给盒子添加三维变换特效的示例样式代码

.row {
  display: flex;
  gap: 4em;
  justify-content: center;
}

.box {
  box-sizing: border-box;
  width: 150px;
  padding: 60px 0;
  text-align: center;
  background-color: oklch(60% 0.12 158deg);
  transform: perspective(200px) rotateX(30deg); /* 令盒子向后旋转 30 度角,并指定透视距离 */
}

在本例中,每个盒子看上去都相同。它们都有自己的透视距离,并利用 perspective() 函数进行定义。这个方法可以为单个元素设置透视距离,示例中我们直接为所有盒子做了相同的设置,这样就像给每个元素分别单独拍照,但是拍摄位置是相同的。

警告
perspective() 变换必须在期望其影响的其他变换 之后 生效。也就是说,perspective() 在与单独的变换属性(如 rotate)结合使用时效果并不理想,因为这些属性是在 transform 属性之后生效的。当使用 perspective() 变换时,应该将它列为第一项,然后在同一个 transform 声明中列出其他变换函数。

有时候我们希望多个元素能共享同一套透视距离,仿佛它们都处于相同的三维空间中。图 16.16 展示了这种情况的一个效果图。图中有四个相同的元素,并且它们都向着远方的一个相同的交汇点延伸,就仿佛把四个元素放一起然后拍了一张整体的照片。要实现这种效果,需要为它们的父元素设置 perspective 属性。

图 16.16 为公共祖先元素设置 perspective 属性可以让多个元素共享相同的透视距离

【图 16.16 为公共祖先元素设置 perspective 属性可以让多个元素共享相同的透视距离】

要查看这样的效果,可以移除各元素盒上的 perspective() 函数,并在其父容器上设置 perspective 属性。具体样式如代码清单 16.14 所示。

代码清单 16.14 建立统一透视距离的示例样式代码

.row {
  display: flex;
  gap: 4em;
  justify-content: center;
  perspective: 200px; /* 在容器上添加透视距离 */
}
.box {
  box-sizing: border-box;
  width: 150px;
  padding: 60px 0;
  text-align: center;
  background-color: oklch(60% 0.12 158deg);
  transform: rotateX(30deg); /* 不在盒子本身设置透视距离 */
}

通过为父容器(或其他祖先元素)设置统一的透视距离,父容器包含的所有应用了三维变换的子元素,都将共享这一相同的透视距离。

添加透视距离是三维变换中非常重要的知识点。如果不设置透视距离,既不能让离得较远的元素显小,也不会让离得较近的元素渲染得更大。上面的例子都比较简单,下一章我们还会把这些技术应用到一个实际案例中,实现元素从远处 “飞入” 的效果。

16.4.2 高级三维变换效果的实现

Implementing advanced 3 D transforms

在对元素进行三维变换时,还有其他一些很有用的属性(properties)。我不打算花太多精力讲解这些属性,因为在实际开发中应用场景寥寥无几(few and far between)。但了解它们的存在也是有必要的,万一今后用得上也不错。想要更深入地研究这些属性,稍后我也会提供几个线上的示例。

16.4.2.1 perspective-origin 属性

Perspective-origin property

默认情况下,透视距离的渲染是假设观察者(或相机镜头)位于元素中心的正前方。perspective-origin 属性可以上下、左右移动镜头位置。图 16.17 展示了刚才的示例,只是镜头移到了左下角位置。

图 16.17 移动透视原点后,元素向远端延伸的形变增加了

【图 16.17 移动透视原点后,元素向远端延伸的形变增加了】

请根据代码清单 16.15 同步更新本地样式表,查看上述演示效果。

代码清单 16.15 利用 perspective-origin 移动镜头位置的示例样式代码

.row {
  display: flex;
  justify-content: center;
  perspective: 200px;
  perspective-origin: left bottom; /* 将镜头位置调至元素左下角 */
}

还是跟之前一样的透视距离,但是视角改变了,所有的盒子都移到了镜头的右侧。我们既可以使用关键字 topleftbottomrightcenter 来设置方位,也可以使用百分比或长度值来精确控制,并从元素的左上角开始计算偏移量(例如 perspective-origin: 25% 25%)。

本例演示的效果较为夸张,原因在于透视距离相对较近,仅有 200px。您也可以将该距离调大些,让页面的立体感没那么强烈。

16.4.2.2 backface-visibility 属性

backface-visibility property

如果使用 rotateX() 或者 rotateY() 旋转元素超过 90 度,就会发现一些有趣的现象:元素的 “正面” 不再直接朝向您;相反,它是背对您的。您会看到元素的背面。如图 16.18 所示,图中元素设置了 rotateY(180deg) 的变换特效,看上去就像是之前元素的镜像图片。

图 16.18 通过元素旋转来查看其背面效果

【图 16.18 通过元素旋转来查看其背面效果】

这就是元素背面的效果图。默认情况下背面是可见的,但我们可以为该元素设置 backface-visibility: hidden 来进行修改。添加这句声明后,元素只有在正面朝向观察者的时候才是可见的,而朝向反面时将被隐藏起来。

这项技术的一个可能的应用场景,是把两个元素背靠背放在一起,就像卡片的正反两面。卡片的正面展示出来,背面则隐藏。然后我们可以旋转它们的容器元素,让这两个元素同时翻转过来,使得正面隐藏、背面则会显现。卡片翻转特效的演示案例,可以参考这篇文章:《Intro to CSS 3D Transforms: Card Flip》。

16.4.2.3 transform-style (preserve-3 D) 属性

transform-style (preserve-3 D) property

如果要使用嵌套元素来构建复杂的 3 D 场景,transform-style 属性就变得非常重要。假设我们已经对容器设置了透视距离,接下来对容器内的元素进行三维变换。容器元素在渲染时,实际上会被绘制成二维场景,如同三维对象的一张照片。这看起来没什么问题,因为元素终将渲染到二维屏幕上。

但如果这时对容器元素进行三维旋转,就会出问题:这样做没能对整个场景进行旋转,只是旋转了三维场景下的二维图像。透视距离全都错了,场景中的立体感也被破坏了。相关的页面效果如图 16.19 所示。

图 16.19 如果对 3D 变换元素的父元素再进行 3D 变换,可能需要启用 preserve-3d 属性(右侧)

【图 16.19 如果对 3 D 变换元素的父元素再进行 3 D 变换,可能需要启用 preserve-3 d 属性(右侧)】

左图展示了通过把六个面变换到相应位置创建的一个 3 D 立方体效果;中间的图片展示的是对整个立方体(即它们的父元素)使用变换后的效果。为了修正这个问题,应该在父元素上声明 transform-style: preserve-3d

想要了解更多讲解内容和实用案例,可以查看 Ana Tudor 的线上教程,详见:https://davidwalsh.name/3d-transforms。虽然这些示例很有意思,但我从未在实际项目中用过 preserve-3d。如果您决定上手试试三维变换,想看看可以实现哪些效果,这份教程会很有帮助。

16.5 本章小结 Summary