Skip to content

2.2 CSS:修饰页面的布局和样式

与 HTML 一样,CSS 也不是一种编程语言。CSS 的中文全称是“层叠样式表”,顾名思义是专门用来设置样式的。CSS 的核心价值就是描述 HTML 元素应该如何被浏览器渲染。

到目前为止,CSS 一共经历了 3 次大版本的迭代。CSS1 已经被废弃;CSS2 是 W3C 制定的规范,当前仍然是标准;CSS3 能力强大也最好用,官方标准仍然在制定中。

现代工程化的前端对新特性几乎做到了全面支持,所以可以不用考虑兼容性问题,可以大胆地在项目中使用。

关于经典的 CSS2 和未来的 CSS3,下面从两个方面来介绍,分别是“布局”和“样式”。

2.2.1 三种页面布局方案

早期的前端布局就是 table 布局,主要用做表格的思路来描绘一个前端页面结构。表格布局不需要特别定义样式,基本上 table,tr,td 就能满足一个简单布局的需要。对于稍微复杂些的页面,table 还支持跨行,跨列,合并单元格,所以在没有 CSS 的情况下,table 也扛了很久。

CSS 出现以后,不使用 table 也可以做布局。一个简单的 div 标签,先按照页面需要的任意结构互相嵌套,再在 CSS 中自定义布局规则,这种方式比使用 table 更灵活。

CSS 中的布局方式也在不断进化,先后出现了 3 种。

浮动布局

在 CSS 早期,最经典的方案叫作 “浮动布局”。如果想实现左侧是菜单而右侧是内容的布局,那么基本代码如下:

html
<div id="app">
  <div class="menu">我是菜单</div>
  <div class="content">我是内容</div>
</div>

<style>
  #app .menu {
    width: 200px;
    height: 400px;
    float: left;
    background: red;
  }
  #app .content {
    height: 400px;
    background: blue;
  }
</style>

这里通过 float:left 属性将当前元素设置为左浮动,结果如图 2-9:

图3-1

浮动布局出现并流行了很久,但是这种方式并不很好用。比如元素浮动以后会脱离正常的文档流,导致父元素无法被撑开,高度变成了 0,而浮动的元素又与其他元素混在一起,看起来非常奇怪和难以理解。

而如果要处理这些奇怪问题,还要在 CSS 中通过 “clear:both” 清除浮动。从布局角度来说,这样并不优雅。

inline-block 布局

比浮动布局稍好的一个,是 inline-block 布局。因为元素设置 display: inline-block 之后,它本身就会自动横向排列,同时还可以设置宽高,内外边距等,实现起来更直观。

下面采用 inline-block 布局实现上面的左右布局,代码如下:

html
<style>
  #app {
    display: block;
  }
  #app .menu {
    width: 200px;
    height: 400px;
    display: inline-block;
    background: red;
  }
  #app .content {
    display: inline-block;
    width: 800px;
    height: 400px;
    background: blue;
  }
</style>

最终的显示效果如图 2-10:

图2-10

由图 2-10 可以发现一个关键问题,如果采用 inline-block 布局,那么元素之间默认有留白,导致元素不能紧挨着,可以使用 letter-spacing 属性来处理:

html
<style>
  #app {
    /* 负值可以尽可能的大 */
    letter-spacing: -100px;
  }
  #app .menu {
    letter-spacing: 0;
  }
  #app .content {
    letter-spacing: 0;
  }
</style>

这也是一个比较经典的面试题,大家可以自己实现一下效果。

Flex 布局

CSS3 带来了布局的终极方案 —— Flex 布局。因为要考虑兼容性,所以 Flex 布局早期主要用在移动端,后来随着工程化工具的支持,PC 端也开始普及 Flex 布局。

Flex 布局使用起来非常顺手。例如,之前要实现一个简单的居中布局,还要考虑子元素是块级元素还是行内元素。采用 Flex 布局只需要设置父元素即可,可以无视子元素类型:

css
#app {
  display: flex;
  justify-content: center;
  align-items: center;
}

Flex 布局有三个重要的概念:“容器”,“主轴”,“交叉轴”。

容器很简单,只要将任意元素设置为 display: flex 之后它就是一个使用 Flex 布局的容器。在这个容器之下,子元素会按照主轴的方向顺序排列。主轴的默认方向是横向,也就是元素从左到右排列;交叉轴的方向与主轴正好相差 90 度,主轴是从左到右,那么交叉轴就是从上到下。

主轴方向是可以设置的,设置方式也很简单:

css
#app {
  display: flex;
  flex-directioncolumn;
}

这里用 flex-direction 来设置主轴方向,可选值有 4 个:

  • row:横向从左到右(默认)。
  • row-reverse:横向从右到左。
  • column:纵向从上到下。
  • column-reverse:纵向从下到上。

使用这 4 个属性值,不光能设置方向,还可以设置相同方向的排列方式,是从前到后,还是从后到前。光这一个属性就能解决我们大部分的布局问题,不得不赞叹一声好强大。

当主轴方向改变,交叉轴也随之改变。主轴方向变成纵向时,交叉轴方向就成了横向。

主轴和交叉轴的方向我们确定了之后,接下来就可以考虑两个轴上的元素如何对齐了。主轴通过 justify-content属性来设置元素的对齐方式,可选值如下:

  • flex-start:从左到右。
  • flex-end:从右到左。
  • center:居中对齐。
  • space-between:两端对齐。
  • space-around:两端对齐。

space-between 和 space-around 都表示两端对齐,二者有什么区别呢?

其实二者的区别就体现在元素的间距上。前者元素本身没有间距,所以会贴着两边对齐;后者元素之间的间距要相等,相当于各自有一个相等的 margin,所以不会贴着两边对齐。

除了设置主轴方向的元素对齐,还可以用 align-items 属性设置交叉轴方向的元素对齐。align-items 属性的可选值如下:

  • flex-start:从上到下。
  • flex-end:从下到上。
  • center:居中对齐。
  • baseline:基线对齐。
  • stretch:填满整个高度(默认)。

前 3 个属性值不再展开介绍,和主轴的含义相同。baseline 是指按照文字的基线对齐。因为一个容器内不同文字的大小可能不同,高度也就会不同,采用基线对齐就可以按照文字的最低处对齐,这样有利于文字排版。

stretch 表示填满整个父元素的高度,如上面提到的左右布局,如果希望任意一列的高度改变时,另一列能以最高的高度展示,永远填满父元素,那么此时使用 stretch 就可以。

使用上面介绍的主轴和交叉轴的方向、排列方式、对齐方式完成布局基本上已经够用。然而,当元素在一个方向放不下时,应该如何展示?是否需要换行?

容器元素是否换行,可以通过 flex-wrap 属性设置。flex-wrap 属性的可选值如下:

  • nowrap:不换行(默认)。
  • wrap:换行,第一行在上。
  • wrap-reverse:换行,第一行在下。

当一个轴的元素放不下时,默认是不换行的,Flex 容器会将元素的宽度等比例压缩,使其排列到一行。在一般情况下,如果需要换行,将 flex-wrap 属性设置为 wrap 即可,超出元素会自动换到下一行,如图 2-12 所示。

后面的实战部分主要使用 Flex 布局,所以读者务必要学会如何使用这种布局方式。

2.2.2 样式与动画解析

CSS 诞生之初主要是为网页内容添加样式,如最基本的宽度、高度、边距、颜色和字体等。但是随着前端不断地追求用户体验,这些基本样式已经不能满足需求,于是 CSS3 带来了功能更加强大的样式与动画系统。

本节着重介绍新添加的、很酷且非常实用的 CSS3 样式与动画。

渐变

可以将 CSS3 渐变(Gradients)看作一个颜色组,用来在两个或多个指定的颜色之间平稳过渡。设置渐变后,就可以将它视作一种自定义的颜色来使用。 CSS3 定义了如下两种类型的渐变。

  • 线性渐变(Linear Gradients):上下/左右/对角方向改变颜色。
  • 径向渐变(Radial Gradients):由中心点向外扩散改变颜色。

线性渐变通过 linear-gradient() 函数来实现。linear-gradient() 函数的第一个参数表示渐变方向,通过一个角度来控制。示例如下。

  • 0deg:0°,表示从下到上渐变。
  • 90deg:90°,表示从左到右渐变。
  • 180deg:180°,表示从上到下渐变。
  • -90deg:-90°,表示从右到左渐变。

如果要实现一个 120° 的渐变背景色,那么代码如下:

html
<div class="box"></div>
<style>
  .box {
    width: 200px;
    height: 100px;
    background-color: red; /* 浏览器不支持的时候显示 */
    background-image: linear-gradient(120deg, red, yellow, blue);
  }
</style>

背景可以直接设置渐变色,那有没有办法设置渐变色的文字呢?当然有,不过不支持将渐变色直接赋值给 color,我们可以用一种变通的方法,实现文字渐变。代码如下:

html
<h1>前端真好玩</h1>
<style>
  h1 {
    background: linear-gradient(120deg, red, yellow, blue);
    -webkit-background-clip: text;
    color: transparent;
  }
</style>

这里主要用 -webkit-background-clip 这个属性,将背景色的应用区域只限制在文字上,相当于在文字后面藏了这个背景色。然后在将文字颜色设置为透明,这样有着文字轮廓的背景色就显示出来了。

最终在浏览器中显示的效果如图 2-13 所示:

线性渐变和径向渐变大同小异。径向渐变通过 radial-gradient() 函数来实现。径向渐变默认展示一个椭圆形状,中心点在正中央。radial-gradient() 函数的第一个参数 shape 表示形状,支持圆(circle)和椭圆(ellipse)两种。

基于上面的代码实现一个圆形的径向渐变:

html
<div class="box"></div>
<style>
  .box {
    width: 200px;
    height: 200px;
    background-color: red; /* 浏览器不支持的时候显示 */
    background-image: radial-gradient(circle, red, yellow, blue);
  }
</style>

显示的效果如图 2-14 所示。

转换

CSS3 转换(Transform)可以对元素本身进行改变,包括移动、缩放、转动或拉伸。

这个特性非常适合做鼠标指针移入动画,如常见的某个按钮,鼠标指针移入时变大并出现阴影,移出后元素恢复原状,用转换实现非常轻松。转换分为 2D 转换和 3D 转换,常用的是 2D 转换。

2D 转换的分类及其对应的实现函数如下。

  • 位移:translate(x,y)。
  • 旋转:rotate(0deg)。
  • 缩放:scale(x,y)。
  • 倾斜:skew(x,y)。

这些都是经常使用的函数。位移会移动元素本身的位置;旋转会指定一个角度;缩放则以 1 为基准,设置放大或缩小的比例。除了 rotate(),其他函数都可以指定两个参数,分别表示在 X 轴和 Y 轴上如何转换。

html
<div class="box"></div>
<style>
  .box {
    width: 100px;
    height: 100px;
    transform: translate(20px, 30px);
    /* transform: rotate(60deg); 旋转60度  */
    /* transform: scale(1.2); 放大1.2倍  */
    /* transform: skew(10deg,20deg); X倾斜10deg,Y倾斜20deg  */
  }
</style>

用两个参数表示 X 轴和 Y 轴如何转换的方法,也可以拆分成两个单独的方法分别设置 X 轴和 Y 轴上的变化。例如,可以将位移函数 translate(20px,30px)拆分为如下形式。

  • translateX(20px):X 轴位移 20 像素。
  • translateY(30px):Y 轴位移 30 像素。

transform 属性还支持同时定义多个函数。例如,设置一个元素,鼠标指针移入时放大并旋转,代码如下:

css
.box:hover {
  transform: scale(1.2) rotate(30deg);
}

过渡

CSS3 中的过渡(Transition)是指元素在发生变化时,可以指定一个时间让元素慢慢改变,而不是瞬间改变,瞬间改变给用户的反应太生硬,加一些过渡效果会有更好的用户体验。

实现过渡也很简单,需要指定两方面内容:一是需要过渡的 CSS 属性,二是效果持续的时间。

例如,对于一个元素,在鼠标指针移入时高度升高 20 像素,移出时恢复原状,动画持续时间是 1 秒,代码如下:

html
<div class="box"></div>
<style>
  .box {
    width: 100px;
    height: 100px;
    background: red;
    transition: height 1s;
  }
  .box:hover {
    height: 120px;
  }
<style>

在浏览器中运行这段代码就能看到鼠标指针移入和移出时高度在缓慢改变。

过渡还支持多个属性同时改变,如果想要将上面的动画改为“鼠标指针移入时高度增加 20 像素,向右移动 10 像素,同时放大 1.1 倍”,那么 CSS 部分修改为如下形式:

css
.box {
  transition: height 1s, transform 1s;
}
.box:hover {
  height: 120px;
  transform: translate(10px) scale(1.1);
}

翻看 API 文档发现,transition 其实是一个简写属性,它是由 4 个属性组成的:

  • transition-property:指定过渡的 CSS 属性名。
  • transition-duration:指定过渡时间,默认 0。
  • transition-timing-function:过渡的时间变化速度,默认 "ease"。
  • transition-delay:过渡何时开始,默认是 0。

我们前面只用到了前两个属性,后两个属性其实更强大,利用它们能做出不少效果。

比如 transition-timing-function 属性指定时间变化速度,可设置匀速(linear),先快后慢(ease-out),先慢后快(ease-in),慢快慢(ease)等不同的速度,如果你要更精准的控制不同时间的变化速度,可以直接写贝塞尔曲线:

css
.box {
  transition: transform 1s cubic-bezier(0.2, 0.1, 0.2, 1);
}

贝塞尔曲线通过 cubic-bezier(x1,y1,x2,y2) 方法实现。该方法共有 4 个参数,分别表示两个控制速度变化的点的坐标,也就是图 2-15 中 P1 和 P2 两个点的坐标。

这两个点的坐标的改变会引起曲线的改变,同时速度会随着曲线的坡度改变而改变。有兴趣的话可以打开下面网址在线尝试。

贝塞尔曲线可视化生成:https://cubic-bezier.com/

transition-delay 属性用于指定动画延迟触发,这样可以在上一个动画完成后,再触发下一个,从而实现简单的连续动画。

动画

在 CSS 中,利用过渡可以很轻松地实现常用的动画效果,但是过渡是一种线性行为,只能指定从 A 到 B 的直线变化。假如需要一个连续动画,让某个元素永不停止地旋转,此时使用过渡就无法达到目的。

制作连续动画需要使用 CSS 中的 animation 属性来实现。动画通过@keyframes 来定义,下面列举一个简单的例子:

css
/* 定义动画 */
@keyframes myAnim {
  from {
    transform: red;
  }
  to {
    background: blue;
  }
}
/* 使用动画 */
.box {
  animation: myAnim 5s;
}

代码中定义了一个动画命名为 myAnim,其中 from 和 to 分别代表开始和结束的变化,然后在使用的时候赋值给 animation 属性,并指定动画时间。

animation 也是一个简写属性,它包含的动画属性有:

  • animation-name:动画名称。
  • animation-duration:动画时长。
  • animation-timing-function:速度变化,贝塞尔曲线。
  • animation-delay:延迟时间。
  • animation-iteration-count:动画播放次数,infinite 代表无限次。

了解了这些,再看开始我们说到如何实现一个元素永不停止的旋转,这就简单了:

css
/* 定义动画 */
@keyframes myAnim {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
/* 使用动画 */
.box {
  animation: myAnim 1s linear 0s infinite;
}

定义动画,除了使用 from 和 to 分别表示开始和结束的变化,还可以用百分比以更细粒度控制动画在不同时间点分别做什么。from 和 to 对应的百分比分别是 0%和 100%。

下面用动画来实现跑马灯效果,代码如下:

css
@keyframes runhorse {
  0% {
    left: 0px;
    top: 0px;
  }
  25% {
    left: 200px;
    top: 0px;
  }
  50% {
    left: 200px;
    top: 200px;
  }
  75% {
    left: 0px;
    top: 200px;
  }
  100% {
    left: 0px;
    top: 0px;
  }
}

/* 应用到小光点 */
.point {
  animation: runhorse 2s linear 0s infinite;
}

上述代码指定了不同时间点(百分比)的变化位置,并应用到元素上循环播放。

2.2.3 CSS 工程化

提到前端工程化,基本上是指 JavaScript 的工程化体系。很多前端程序员可能没有意识到,CSS 也是有工程化的,如 Sass 和 Less 就是 CSS 工程化的一种。 由于 CSS 不支持嵌套用法,因此当 HTML 结构比较复杂时,CSS 代码就会存在明显的类名重复,可读性差,以及难以维护等问题。示例如下:

css
.main {
  font-size: 18px;
}
.main .box {
  margin: 10px;
}
.main .box h2 {
  font-size: 20px;
}
.main .box h2 span {
  color: red;
}

除此之外,CSS 还不支持模块化。JavaScript 在 ES6 中支持通过 import/export 来导入/导出模块,使代码能够更好地隔离而互不影响,但是 CSS 显然还做不到这一点。

但是这些问题在前端工程化的演进中已用工程化的方式解决了,这些解决方案中最主要的角色就是预处理器。

预处理器:Less/Sass

对于 CSS 来说,预处理器就相当于 React 和 Vue.js 对 JavaScript 的意义。预处理器提供了更简单、更高级的方式来实现功能,开发者不用裸写 CSS 代码,它会处理好一切,如图 2-16 所示。

预处理器的代表就是 Less 和 Sass,他们普遍具有这样的特性:

  • 嵌套代码的能力
  • 支持模块化的引用
  • 支持定义 CSS 变量
  • 允许代码混入
  • 提供计算函数

嵌套代码就是上面展示的有层级的代码。Less 和 Sass 都实现了模块化引用,用关键字 @import 来表示导入,导出不需要显示指定。示例如下:

less
// a.less
.box {
  background: red;
}

// b.less
@import "./a.less";
.box2 {
  background: blue;
}

这里的模块化引用仅仅是将两个文件拼接到一起,并没有做到真正的模块化。例如,在 JavaScript 中导入另一个模块,两个模块的代码不会属性冲突。当使用 Sass/Less 导入模块时,如果两个模块有相同的类名,就会覆盖其中一个类名。

代码复用:变量和混入

在 JS 中复用一段代码和容易,但是 CSS 就比较困难。复用代码可以分两个层次:复用一个属性(变量)和复用几个属性(代码片段)。比如说在我们项目中指定了一个主颜色,这个主颜色在所有页面的 CSS 中几乎都要用到,那可不可以设置成一个变量呢?

预处理器同样支持了定义变量,不过 less 和 sass 的定义标识不同。前者使用 @ 符号,后者使用 $ 符号。如下:

less
// a.less
@main-color: red;
.a {
  color: @main-color;
}

// b.sass
$main-color: red;
.a {
  color: $main-color;
}

编译之后的结果相同:

css
.a {
  color: red;
}

不过在 Chrome 49 之后支持自定义变量,但定义方式又与预处理器有所不同。先通过根伪类 :root 来表示变量可以全局使用,再使用前缀 “--” 来表示变量名,最后使用 var() 函数引用变量:

css
:root {
  --main-color: red;
}
.a {
  color: var(--main-color);
}

复用单个属性的需求实现了,如何复用一个代码片段(一组属性)呢?

预处理器对于代码片段的复用被称为混入(Mixins)。使用 Less 实现混入的方式如下:

less
// 阴影代码片段
.custom-shadow {
  box-shadow: 2px 0px 2px 1px #f3f3f3;
  &:hover {
    box-shadow: 2px 2px 10px 2px #ddd;
  }
}

// 使用代码块
.box1 {
  background: red;
  .custom-shadow();
}
.box2 {
  background: blue;
  .custom-shadow();
}

可见,Less 先将代码片段定义为一个类名,再在需要使用这个代码片段的地方将类名当作函数使用。不过这种方式会使代码片段和普通样式难以区分。下面通过示例来介绍 Sass 是如何实现的:

scss
// 阴影代码片段
@mixin custom-shadow {
  box-shadow: 2px 0px 2px 1px #f3f3f3;
  &:hover {
    box-shadow: 2px 2px 10px 2px #ddd;
  }
}

// 使用代码块
.box1 {
  background: red;
  @include custom-shadow;
}
.box2 {
  background: blue;
  @include custom-shadow;
}

Sass 的实现方式比较优雅,且一目了然。

预处理器还内置了许多函数,在定义一些复杂值时非常方便。例如,下面的代码中的 hsl()函数通过色相(hue)、饱和度(saturation)和亮度(lightness)来创建一种颜色。

less
.box {
  background: hsl(90, 100%, 50%);
}

后处理器:PostCSS

前面说的预处理器,提供了一些列高级功能,最终将代码转换成了 CSS 代码。但是转换成 CSS 之后,并不是万事大吉了,比如一些新属性需要作浏览器兼容,就需要加一堆前缀:

css
.box {
  transition: all 4s ease;
  -webkit-transition: all 4s ease;
  -moz-transition: all 4s ease;
  -ms-transition: all 4s ease;
  -o-transition: all 4s ease;
}

显然,这样的用法是非常烦琐的,但是有了 PostCSS 就可以完全忽略浏览器指定的前缀。在预处理器将代码转换成 CSS 代码后,PostCSS 会监测到一些需要兼容的属性,并且自动在属性前加前缀,这是通过 autoprefixer 实现的。

除了自动添加前缀,PostCSS 还支持直接使用未来的 CSS 语法,并且可以自动处理 polyfills。当然,要实现这两项功能还需要构建工具(如 Webpack、Vite)进行配合。

2.2.4 动态值与响应式

响应式布局是为了在不同屏幕尺寸的设备上打开网页时,可以动态显示适合当前设备的样式,从而解决 PC 端网页用手机打开样式“乱跑”的问题。

响应式的一个关键就是“动态”。例如,一个元素的字号在计算机上是 20 像素,在平板电脑上会变成 18 像素,在手机上则变成 16 像素。不同的屏幕要展示合适的尺寸,可以使用 CSS 的媒体查询来实现。示例如下:

css
body {
  font-size: 20px;
}
@media screen and (max-width: 850px) {
  body {
    font-size: 18px;
  }
}
@media screen and (max-width: 400px) {
  body {
    font-size: 16px;
  }
}

媒体查询是响应式的一种方案,CSS3 提供了更多的方案可供选择:

  • 使用 rem
  • 使用 vwvh
  • 计算动态尺寸。

rem 是一个新的 CSS 单位,它的值永远指向 HTML 根元素的 font-size。比如我设置了如下样式:

css
html {
  font-size: 10px;
}
body h2 {
  font-size: 2rem;
  /* 编译后的结果是 font-size: 20px; */
}

如果动态更改 HTML 根元素的 font-size 属性,那么使用 rem 的样式都会自动改变。

媒体查询只能用来设置一个宽度的范围,相当于设置一个边界值,但是 rem 可以用来设置“连续变化”的效果。

下面演示如何通过监听浏览器窗口的变化来实时改变 rem 的值:

html
<h2>前端真好玩</h2>
<style>
  html {
    font-size: 10px;
  }
  body h2 {
    font-size: 2rem;
  }
</style>
<script>
  // 判断窗口变化的事件
  var resizeEvent =
    "orientationchange" in window ? "orientationchange" : "resize";
  // 监听文档初始化事件
  document.addEventListener("DOMContentLoaded", recalc);
  // 监听窗口变化事件
  window.addEventListener(resizeEvent, recalc);
  // 函数,动态修改跟元素的 font-size
  function recalc() {
    let width = document.body.clientWidth;
    document.getElementsByTagName("html")[0].style.fontSize =
      14 * (width / 750) + "px";
  }
</script>

在浏览器中运行代码,并且不断改变窗口的宽度,这时可以发现字号随着鼠标指针的拖动而不断改变。

上述是兼容性比较好的实现方案,如果充分发挥 CSS 的能力,还可以更简单。vw 和 vh 分别代表浏览器窗口的宽度和高度。示例如下。

css
.box {
  /* 1vw = 浏览器宽度的 1% */
  width: 20vw;
  /* 1vh = 浏览器高度的 1% */
  height: 20vh;
}

当为一个属性附值为 vw/vh 时,这个属性就变成了一个动态值。比如简单的左右布局,设置左边元素宽 20vw,右边元素宽 80vw,这就实现了响应式。

还有一个很强大的计算函数 calc(),借助这个函数可以轻松实现宽度计算。比如一个三栏布局,左右两栏固定,中间填充剩余空间。不借助 flex 布局,我们可以这样写:

less
.box {
  .left {
    width: 100px;
  }
  .left {
    width: calc(100vw - 100px - 80px);
  }
  .right {
    width: 80px;
  }
}

看到了吧,calc() 可以混合计算 vw,vh,px 等不同单位,相当于可以在 vw/vh 动态值的基础上再加一层动态计算。这就非常灵活了,给了我们充分发挥计算的能力。

最后大家可以思考一下,上面我们监听浏览器窗口变化,动态改变 rem 的值,还有没有更好的方式?纯 CSS 能否实现?

当然可以的,而且非常简单,就一行代码:

css
html {
  font-size: 0.3vw;
}

因为 vw 是动态值,所以根元素的 font-size 属性也变成动态值,进而 rem 也变成动态值。此时如果再改变浏览器窗口的宽度,文字大小就会随之改变!