CSS 的模块化与作用域
本章概要
- 将 CSS 整理成可复用的模块
- 防止选择器优先级不断攀升
- 组织 CSS 代码的通用命名规则
- 用
@scope
更好地控制层叠规则- 使用模式库编写模块文档
在修改现有样式表时,这些样式变更可能会影响到整个网站中任意多个页面及其页面元素。有个老笑话是这么说的:两个 CSS 属性走进了一间酒吧,结果另一间酒吧的高脚凳却摔倒了(two CSS properties walk into a bar; a barstool in a different bar falls over.)。那么问题来了:怎样确保这些样式修改仅对希望变更的位置生效、而不影响到其他无关的元素样式呢?
上一章介绍的 CSS 层叠图层是控制页面样式优先级的第一步;当时我还给出了页面图层的推荐组织方案。其中最大的图层,即模块图层(modules layer),也是大多数页面样式所在的图层。本章将围绕该层展开讨论,并将其组织为易于管理的模块化代码。
9.1 模块的定义
Defining modules
模块化 CSS(Modular CSS) 是指将页面分割成不同的组成部分,并让它们在多种上下文语境中重复使用,但相互之间并不存在依赖关系。其最终目的是,当我们修改其中一部分 CSS 样式时,不会对其他部分产生意料之外的影响。
这个概念类似于宜家厨房(IKEA kitchen)采用的模块化家具设计:顾客可以随意选择风格一致、组合起来毫无违和感的多个独立小件;而不是购置一个大型的橱柜单元。此外还可以将这些小件随意摆放到自己喜欢的位置,从而形成完整的布局;模块化 CSS 也是如此,不必再构建一个大型网页,而是独立构建页面的各个部分,然后按照既定设计将它们组合到一起。
在计算机科学中,编写模块化代码并不是什么新潮的做法,但许多开发人员并没有意识到可以将其应用于 CSS 领域。随着现代网站和 Web 应用的体量日渐庞大且复杂,人们不得不另寻出路来管理日益庞大且繁杂的样式表。模块化 CSS 便是一种行之有效的解决方案。
之前的样式表可以使用选择器在页面上随意修改样式;而模块化的样式则可以让开发人员添加一些限制条件。我们把样式表的每个组成部分称为 模块(module),每个模块独立负责自己的样式,并且不会影响其他模块内的样式。由此在 CSS 中引入了软件封装的原则。
封装的定义
封装(Encapsulation) 是将相关功能和数据组合在一起,构成一个对象。通常用来隐藏结构化对象内部的状态或值,从而无法从外部操作对象内部。
CSS 没有数据和传统函数的概念,但是有选择器及其选中的页面元素;为了达到封装的目的,它们会成为模块的组成部分,并且每个模块都只负责少量的 DOM 元素样式。此外,CSS 全新推出的 @scope
功能也为样式封装和模块化设计的落地提供了极大的便利。
牢固树立封装的思想后,就可以为页面上相互独立的组件定义模块了,如导航菜单、对话框、进度条、缩略图(thumbnail images)等。每个模块都可以通过作用于 DOM 元素的唯一类名来进行标识。同时,每个模块又包含各自的子元素来构成页面上的组件。模块内部还可以嵌套其他模块,最终构成一个完整的页面。
注意
如果用的是
React
、Vue
或SolidJS
这样的JavaScript
框架,那么您就不会对模块这个概念感到陌生,因为其中的JavaScript
也是以类似的方式组织成组件(component)的。在按模块组织 CSS 时,所构建的 CSS 模块很可能与JavaScript
组件一一对应,只是这样的关联并非强制要求。
9.1.1 模块和全局样式
Modules and global styles
在开始编程模块化样式前,需要提前备好一些基础样式(basic styles),包括常用的重置样式(resets)和全局样式(如字体)。鉴于这些样式会影响到页面上的所有内容,在构建任何特定模块之前,都需要提前设置这些样式。
新建一个页面并关联一个样式表,然后将代码清单 9.1 中的基础样式和全局样式添加到 CSS 中。reset
重置图层的样式取自上一章代码清单 8.5。
代码清单 9.1 添加重置样式与全局样式代码
@layer reset, global, modules;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: unset;
}
button,
input,
textarea,
select {
font: inherit;
}
img,
picture,
svg,
canvas {
display: block;
max-width: 100%;
height: auto;
}
}
@layer global {
body {
font-family: Helvetica, Arial, sans-serif;
}
}
根据第 8 章的介绍,我们知道一个完整的网站可能会用到更多的图层;但上述代码已经足以让我们对模块进行更深入的了解。
层叠图层并非编写模块化 CSS 的先决条件(prerequisite)。如果因为浏览器不支持 CSS 图层功能而无法正常使用,您仍然可以遵循这些模块构建原则。
9.1.2 一个简单的模块
A simple module
下面来创建一个用于发送简短通知信息的简单模块。每个模块都需要一个唯一的名称,不妨将该模块命名为 message
(即“消息”模块)。为了吸引用户注意,可以加上一些颜色和边框效果,如图 9.1 所示:
图 9.1 message 消息模块样式示意图
该模块的 HTML 标记是一个带 message
样式类的 div
元素。将其添加到您的页面。
代码清单 9.2 message 消息模块的 HTML 标记
<div class="message">
Save successful
</div>
模块的 CSS 是一个规则集,可通过类名选中该模块。CSS 中设置了内边距、边框、圆角半径以及字体颜色等样式。只要将代码清单 9.3 添加到基础样式后,就可以令这些样式应用到消息模块了。
代码清单 9.3 message 消息模块的样式实现
@layer modules {
.message { /* 通过类名选中 message 消息模块 */
padding: 0.8em 1.2em;
border-radius: 0.2em;
border: 1px solid #265559;
color: #265559;
background-color: #e0f0f2;
}
}
这些属性应该非常熟悉了,至少现在看上去跟您在本书见到的其他 CSS 代码类似,并没有什么特别之处。事实上,我们写过的 CSS 代码有很多是符合模块化 CSS 的原则的,只是之前并没有专门提及罢了。下面来重点分析一下这些 CSS 是如何模块化的。
有一点非常重要:该模块的选择器只包含一个类名。该选择器中并没有其他规则来限制这些样式只对页面某个位置生效。例如使用像 #sidebar .message
这样的选择器,则表示该模块只能在 #sidebar
元素内使用;一旦取消这一限制条件,该模块即可在任何上下文中重复使用。
通过给元素添加类名,就可以把这些样式复用到很多场景,比如根据表单输入提供用户反馈信息、提供醒目的帮助文字,抑或是提醒用户注意某个系统通知等等。通过复用同一个模块,就能在任意位置实现一致的用户界面,不必担心某些地方的蓝绿色有色差,或者某些地方的内边距偏大等问题。
我曾经在没有模块化 CSS 的项目里工作过,其中一个项目出现过好几个类似的按钮:.save-form button
、.login-form button
以及 .toolbar .options button
。同样的代码在样式表中重复出现了多次,尽管并不是完全的复制。这么做旨在获得一致的用户体验,本无可厚非;但随着时间的推移,不同的按钮间还是出现了一些不一样的样式调整,导致有的按钮内边距略有不同,而有的按钮又红得更鲜艳。
这个问题的解决办法,就是把按钮样式重构成一个可复用的模块,不受页面位置的限制。创建 CSS 模块不仅可以精简代码(减少冗余),还可以保证视觉上的一致性。这样处理后看上去也更专业,不会给人仓促堆砌的感觉。用户在潜意识里也会更容易信赖我们的应用程序。
9.1.3 模块的变体
Variations of a module
保持一致性确实不错,但有时候需要特意避免完全一致。方才的消息模块固然好用,但在某些情况下也需要作些调整。比如,如果需要显示一则报错信息,这时就应该使用红色而不是之前的蓝绿色(teal);再比如,您可能需要区分单纯的信息和示意操作成功的通知(如保存成功),这时就可以通过定义 修饰符(modifiers) 来实现。
修饰符可以通过一个以模块名称开头的新类名来定义。例如,消息模块的 error
修饰符可以写作 message-error
。通过包含模块名称,您就明确指定了该样式类属于消息模块。
关于 BEM 类名约定
带双连字符以及双下划线的类名是一种常见的类名约定,它们都属于 BEM 方法论的范畴。BEM 是 Block-Element-Modifier 的首字母缩写。
在 BEM 类名约定中,一个 block 块表示的是模块的主元素(main element),通常为一个具有描述性的、唯一的类名,如
message
。BEM 中的 element 元素则表示模块中的子元素(有时也可以为子孙元素),并通常用双下划线来连接 block 块名称与 element 元素名称,例如用media__image
来引用media
媒体模块中的图片元素。而 BEM 中的 modifier 修饰符则是在创建模块的变体形式时添加到 block 块中的某个类名称,它们通常用 block 块名称后的一组双连字符来加以区分,例如message--error
。值得一提的是,BEM 只是众多类名约定中的一种,但由于其名气最大,本章也采纳了里面的部分做法;其他广受欢迎的类名约定还包括
OOCSS
、SMACSS
、ITCSS
以及SUITCSS
。虽然在具体的约定内容和命名规则上略有不同,但目的都是一样的:以模块化的方式来组织页面样式。
下面为示例模块创建三个修饰符:成功(success
)、警告(warning
)和错误(error
)。将代码清单 9.4 添加到样式表中,通过 BEM 中的双连字符约定来指明该修饰符属于 message
消息模块。
代码清单 9.4 带修饰符类名的消息模块
@layer modules {
.message { /* 基础 message 模块样式 */
padding: 0.8em 1.2em;
border-radius: 0.2em;
border: 1px solid #265559;
color: #265559;
background-color: #e0f0f2;
}
.message--success { /* success 修饰器将 message 模块改为绿色 */
color: #2f5926;
border-color: #2f5926;
background-color: #cfe8c9;
}
.message--warning { /* warning 修饰符将 message 模块改为黄色 */
color: #594826;
border-color: #594826;
background-color: #e8dec9;
}
.message--error { /* error 修饰符将 message 模块改为红色 */
color: #59262f;
border-color: #59262f;
background-color: #e8c9cf;
}
}
AI写代码css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
修饰符的样式不用重新定义整个模块,只需覆盖需要变更的部分即可。在本例中,则表示修改文字、边框及背景的颜色。
如代码清单 9.5 所示,要让修饰符样式生效,只需将主模块类名和对应的修饰符类名同时添加到目标元素上即可。这样既应用了模块的默认样式,又可以根据需求利用修饰符重写个别样式。
代码清单 9.5 使用了错误修饰符 error 的消息模块示例
<div class="message message--error"><!-- 将两个样式类同时放入元素中 -->
Invalid password
</div>
同理,需要展示成功或警告消息时,添加对应的修饰符即可。它们会改变模块的颜色,而其他修饰符还能改变模块的大小甚至布局。
9.1.3.1 按钮模块的变体形式
Button module variants
再来看看另一个模块的变体形式。我们将实现一个按钮模块,其中包含尺寸大小和颜色样式的变体形式(如图 9.2 所示)。这样就能用不同的颜色为按钮添加视觉意义。绿色代表积极的行为,比如保存和提交表单;红色则表示警告,以防用户不小心点到或意外取消按钮。
【图 9.2 使用了不同尺寸和颜色修饰符的按钮效果】
代码清单 9.6 给出了上述按钮的具体样式,其中包括基础按钮模块和四个修饰符样式类:两个尺寸修饰符和两个颜色修饰符。将这些代码添加到样式表中。
代码清单 9.6 按钮模块及其修饰符样式代码
@layer modules {
.button { /* 按钮基础样式 */
padding: 0.5em 0.8em;
border: 1px solid #265559;
border-radius: 0.2em;
background-color: transparent;
font-size: 1rem;
}
.button--success { /* 绿色的 success 颜色变体形式 */
border-color: #cfe8c9;
color: #fff;
background-color: #2f5926;
}
.button--danger { /* 红色的 danger 颜色变体形式 */
border-color: #e8c9c9;
color: #fff;
background-color: #a92323;
}
.button--small { /* 小字号的变体形式 */
font-size: 0.8rem;
}
.button--large { /* 大字号的变体形式 */
font-size: 1.2rem;
}
}
AI写代码css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
字号修饰符旨在设置字体的大小。在第二章中我们用过类似的技巧:通过变更字号来调整元素相对单位 em
的具体取值,进而改变内边距和圆角半径的大小,无需分别重写对应的声明样式。
提示
一个模块中的所有代码务必要集中放到同一个地方,以便让模块一个接一个的组成最终想要的样式表。
有了这些修饰符,写 HTML 的时候就有了多种选择:既可以根据按钮的重要程度来添加修饰符类并控制其大小,也可以搭配不同的颜色来给用户提供相应的语境。
代码清单 9.7 中的 HTML 通过不同的修饰符组合来创建各种按钮效果。将其添加到页面的任意位置,看看效果如何:
代码清单 9.7 使用修饰符创建多种类型的按钮样式
<button class="button button--large">Read more</button><!-- 带大号修饰符的按钮模块 -->
<button class="button button--success">Save</button><!-- 带成功修饰符的按钮模块 -->
<button class="button button--danger button--small">Cancel</button><!-- 带危险和小号修饰符的按钮模块 -->
这里的第一个按钮是大号字体;第二个是表示操作成功的绿色按钮;第三个则带有两个修饰符:一个变更颜色(danger
危险修饰符),另一个变更大小(small
小号修饰符),最终效果如图 9.2 所示。
双连字符的写法看上去稍显多余,但在创建名称很长的模块时,比如 nav-menu
导航菜单或 pull-quote
文章摘要时,该写法的好处就凸显出来了。给这些模块添加修饰符后,类名就可以分别写作 nav-menu--horizontal
、pull-quote--dark
的形式。
双连字符的写法很容易区分哪部分是模块名称,哪部分是修饰符;nav-menu--horizontal
和 nav--menu-horizontal
分别代表了不同的含义。如此一来,即便项目中存在很多名称相仿的模块,也可以很容易进行分辨。
9.1.3.2 不要使用有语境依赖的选择器
Avoiding context-dependent selectors
假设我们正在维护一个网站,里面有浅色调的下拉菜单。现在需要将页面标题的下拉菜单样式反转,使其变为深底白字效果。
如果没有模块化 CSS,这时可能会用类似于 .page-header .dropdown
的选择器,先选中要修改的下拉菜单,再通过重写 dropdown
类的默认样式实现目标;但在模块化 CSS 中,上述选择器的写法是严格禁用的。虽然后代选择器可以满足当下的需求,但后续也很可能带来诸多问题,下面来具体梳理一下这些问题。
首先,必须考虑将这段代码放到哪里:是和页面标题样式放到一起合适呢,还是和下拉菜单放到一起合适?如果设置太多类似的单一用途的样式规则,彼此间毫无关联,到最后样式表肯定会变得杂乱无章;要是后续需要修改样式,您还能快速回忆起它们在哪儿吗?如果换作其他同事来修改,他们能快速定位吗?
其次,这种处理方式提升了选择器优先级。当下次需要修改样式时,则不得不达到或者继续提升该优先级(specificity)。
再者,后续可能需要在其他场景下应用深色的下拉列表,而之前创建的版本需要满足位于页面标题区的大前提。如若侧边栏也需要同样的下拉列表效果,就只能为该规则集添加新的选择器来同时匹配两个业务场景,或者完全复刻一遍样式。
最后,反复采用这样的写法会导致选择器越写越长,最终令 CSS 与特定的 HTML 结构深度绑定。例如,对于选择器 #products-page .sidebar .social-media div::first-child h3
而言,该样式集就与指定页面的指定位置紧密耦合到了一起。
这些问题是开发人员处理 CSS 时屡屡受挫的根源。要使用和维护的样式表越长,情况就会越糟糕。当新样式需要覆盖旧样式时,选择器的优先级也会持续攀升,以致于到最后才猛然发现某个选择器为了选择一个复选框,竟然已经包含了两个 ID 和五个样式类。
在样式表中,元素可能会被各种彼此不相关的选择器选中,以致于很难找出其真正生效的样式。此时理解整个样式表的组织方式变得愈发困难,根本理不清它们是如何将页面渲染成现在这样的。代码搞不明白则意味着 Bug 屡见不鲜,可能很小的样式变更都会波及一大片区域。删除旧代码风险就更高了,因为没人知道这段代码是做什么用的、是否还在生效。样式表越长,类似的问题就愈发严重。模块化 CSS 就是要尝试解决这些问题。
当模块需要不同的外观或者呈现效果时,可以创建一个直接作用于指定元素的修饰符类。例如将选择器 .page-header .dropdown
直接写作 .dropdown--dark
。这样一来,最终效果由且仅由该模块本身决定,其他模块将无法对其进行修改。鉴于深色系下拉菜单效果不与 HTML 中的任何深度嵌套结构绑定,它也就可以在页面任意位置随意设置。
切忌通过基于页面位置的后代选择器来修改模块样式。只要严格遵守这一原则,就可以有效防止样式表变成一堆难以维护的代码。
9.1.4 包含多个元素的模块
Modules with multiple elements
我们已经创建了 message
消息和 button
按钮两个模块,它们既简单又实用,并且都由单个元素组成;但是有很多模块需要多个元素,我们不可能单凭一个元素来实现下拉菜单或者模态框效果。
下面来创建一个更复杂的样式模块。它是一个由前端开发与 CSS 领域专家 妮可·沙利文(Nicole Sullivan) [1] 率先为其命名的媒体模块(media module),如图 9.3 所示:
【图 9.3 由四个元素组成的 media 媒体模块】
这个模块由四个元素组成:一个 div
容器、以及容器内的一张图片、和正文区,最后是正文中的标题。与其他模块一样,我们将给主容器添加 media
样式类作为模块名称。
对于图片和正文,则可以使用样式类 media__image
和 media__body
。它们以模块名开头,并通过双下划线连接模块与子元素名称。这也是 BEM
命名约定的另一种形式。与双连字符表示的修饰符一样,这样的类名可以清楚的告知该元素所扮演的角色及所属的模块。
媒体模块的样式代码如代码清单 9.8 所示。将它们添加到您的本地样式表中:
代码清单 9.8 包含子元素的媒体模块样式代码
@layer modules {
.media { /* 主容器 */
display: flex;
gap: 1.5em;
padding: 1.5rem;
background-color: #eee;
border-radius: 5px;
}
.media__image { /* 图片元素样式 */
align-self: start;
}
.media__body { /* 正文元素样式 */
overflow: auto;
margin-block-start: 0;
}
.media__body > h4 { /* 正文中的标题样式 */
margin-block-start: 0;
color: #333;
}
}
注意,此时无需使用太多后代选择器。虽然图片是媒体模块的一个子元素,也可以写作 .media > .media__image
,但大可不必;因为样式类 media__image
已经包含了模块名称,足以确保该名称是唯一的。
而正文区中的标题直接用到了后代选择器。其实也可以使用 media__title
这样的样式类(或者写作 media__body__title
,以完整表示出其在整个模块层级中的位置),但是大部分时候没必要这么写。本例中的 <h4>
标签已经足够语义化来表明它是媒体模块的标题了。只是这样一来,标题就不能用其他级别的 HTML 标签(如 <h3>
或 <h5>
)了。如果不希望限制得这么死板,可以改为样式类来选中元素。相关 HTML 标记如代码清单 9.9 所示:
代码清单 9.9 媒体模块的 HTML 标记
<div class="media">
<img class="media__image" src="runner.png"
alt="Silhouette of a person running"><!-- 图片子元素 -->
<div class="media__body"><!-- 正文子元素 -->
<h4>Strength</h4><!-- 标题子元素 -->
<p>
Strength training is an important part of
injury prevention. Focus on your core—
especially your abs and glutes.
</p>
</div>
</div>
这是一个多功能模块,可以在各种尺寸的容器内生效,并随着容器宽度的变化进行自适应调整。正文也可以包含多个段落,或者使用不同尺寸大小的图片(可以考虑给图片设置一个 max-width
属性,以避免挤占正文区域)。
9.1.4.1 同时使用变体和子元素
Using variants and subelements together
我们也可以创建模块的变体形式。例如,可以很轻松地将图片从左浮动改为右浮动(如图 9.4 所示)。
图 9.4 把媒体模块的图片改到右侧渲染
变体样式类 media--right
就可以实现上述效果,将该类名添加到媒体模块的 div
主容器上(即 <div class="media media--right">
)即可;然后通过类名选中图片并令其浮动方式为向右浮动。
将该修饰符样式类添加到 HTML 对应元素上后,再根据代码清单 9.10 更新 modules
模块图层中的样式,即可看到最终效果。
代码清单 9.10 为媒体模块定义一个向右浮动的变体形式
@layer modules {
.media--right {
flex-direction: row-reverse;
}
}
上述代码将颠倒各弹性元素的排列顺序,将图片置于容器右侧。
在某些情况下,变体形式的模块样式可能涉及子元素样式的调整,此时可以通过后代选择器(descendant selectors)实现,例如 .media--right .media__image
。
9.1.4.2 避免在模块选择器中使用通用标签名
Avoiding generic tag names in module selectors
我们在媒体模块中使用了选择器 .media__body > h4
来选中标题元素,这样写是说得通的,因为 <h4>
标签本就是用来标识一个次级标题的。对于包含列表项的模块这种写法同样适用,相比为列表中的各列表项分别添加一个 menu__item
的样式类,通过 .menu > li
来进行匹配则要简单很多,尽管该写法尚存争议。
我们应该避免使用过于通用的 HTML 标签来选中目标元素,尤其是 div
元素和 span
元素。类似于 .page-header > span
的选择器太宽泛了。一开始创建模块的时候,可能只是用 span
标签实现了某一效果,但谁能保证后续不会出于其他考虑再加一个 span
呢?到最后才给 span
元素追加类名就比较棘手了,因为需要在 HTML 中找出所有用到过该模块的位置,并全部修改一遍。
P.S.:相信通过本节的学习,您也可以跟我一样,对目前市面上常见的 CSS 框架中的类名的写法有更为清晰的认识,比如 Bootstrap 中的很多前端组件样式,就是用的 BEM 命名约定:定义一个 success 按钮,其样式类就是一个 btn 模块,写作
btn btn-success
;再比如,从阿里图标库定制某些图标效果时也会使用类似的声明方式。将这些具体场景和本篇知识点结合,就能更好地理解 CSS 模块化的用法与注意事项。
9.2 把模块组合成更大的结构
Modules composed into larger structures
每个模块都应该各司其职。消息模块的职责是让消息提示醒目;媒体模块的职责是在一段文本中配置一张图片。我们可以简洁明了地概括出它们的目标:有的是为了版面布局,有的则是出于整体风格的考虑。当模块试图完成的核心任务不止一个时,就应该考虑将其拆分为更小的模块。
为此,本节将实现一个下拉菜单来演示说明(如图 9.5 所示)。该菜单与第六章(6.3.1 小节)里创建的版本类似。
图 9.5 下拉菜单效果图
创建模块之前应该先问问自己:“从更高的层面上看,这个模块的职责是什么?”对于本例,您的回答可能是这样的:“用按钮触发下拉菜单并让菜单项由上至下堆叠排列。”
就这个场景而言,这样的回答还算是个恰如其分的描述。但我有一个经验法则:“如果在描述模块职责时必须使用 和 这个词,那么当下很可能是在描述多项职责。”那它究竟是要触发菜单,还是要堆叠排列菜单项呢?
当我们需要用 和(and) 来描述模块职责时,不妨思考一下是否在描述两种(甚至更多的)职责;当然也有可能不是,毕竟这条经验并非放之四海而皆准的。但如果答案是肯定的,就需要为每个职责分别定义模块。这是模块封装的一个非常重要的原则,也叫做 单一职责原则(Single Responsibility Principle)。要尽可能把多种职责分配到不同的模块中,这样每个模块就可以确保小巧精炼、聚焦功能点并且易于理解。
9.2.1 模块中多个职责的拆分
Dividing multiple responsibilities among modules
本节演示通过两个不同的模块创建下拉菜单的方法。第一个模块可以叫作 下拉(dropdown) 模块,其中包含一个控制容器可见性的按钮元素。换言之,该模块负责显示与隐藏容器。我们也可以描述按钮外观以及指示操作行为的小三角形符号。阐明模块的细节虽然会用到 和(and) 的字眼,但它们都从属于首要职责,因此这么处理是没问题的。
第二个模块叫 菜单(menu) 模块,用于放置链接列表。将菜单模块的一个实例放入下拉模块的容器内,就可以构建出完整的页面了。
将代码清单 9.11 中的样式添加到页面中。这段代码主干是一个下拉模块,其中包含菜单模块。代码中还有一小段 JavaScript
脚本,用于在点击切换按钮时实现菜单的开关功能。
代码清单 9.11 用两个模块构造一个下拉菜单
<div class="dropdown">
<button class="dropdown__toggle">Main Menu</button><!-- 下拉菜单的切换按钮 -->
<div class="dropdown__drawer"><!-- drawer 抽屉子元素作菜单容器 -->
<ul class="menu"><!-- 位于 drawer 元素内部的菜单模块 -->
<li><!--<a href="/">Home</a></li>-->
<li><a href="/coffees">Coffees</a></li>
<li><a href="/brewers">Brewers</a></li>
<li><a href="/specials">Specials</a></li>
<li><a href="/about">About us</a></li>
</ul>
</div>
</div>
<script type="module">
var toggle = document.querySelector('.dropdown__toggle');
toggle.addEventListener('click', function (event) { // 点击切换按钮时切换 is-open 样式类
event.preventDefault();
var dropdown = event.target.parentNode;
dropdown.classList.toggle('is-open');
}
);
</script>
这里使用了双下划线标记来表明 toggle
和 drawer
同为下拉模块的子元素。点击切换按钮将显示或隐藏 drawer
抽屉元素。JavaScript
代码为下拉模块的主元素添加或移除 is-open
样式类,以此来实现下拉效果:按下切换按钮时变为 <div class="dropdown is-open">
;再次点击则恢复为 <div class="dropdown">
。
下拉模块的样式如代码清单 9.12 所示,将其添加到样式表中。这些样式跟第六章中的演示的版本类似,只是为了符合双下划线标记的类名约定更新了相关类名。这样就实现了下拉效果,只是其中的菜单项还没有指定样式。
代码清单 9.12 定义下拉模块样式
@layer modules {
.dropdown {
display: inline-block;
position: relative; /* 为绝对定位的 drawer 抽屉元素建立一个包含块 */
}
.dropdown__toggle {
padding: 0.5em 2em 0.5em 1.5em;
border: 1px solid #ccc;
font-size: 1rem;
background-color: #eee;
}
.dropdown__toggle::after {
content: "";
position: absolute;
right: 1em;
top: 1em;
border: 0.3em solid;
border-color: black transparent transparent; /* 利用边框样式绘制三角形(详见第 6 章) */
}
.dropdown__drawer {
display: none; /* 初始隐藏 drawer 元素,出现 is-open 类时再恢复显示 */
position: absolute;
left: 0;
top: 2.1em;
min-width: 100%;
background-color: #eee;
}
.dropdown.is-open .dropdown__toggle::after {
top: 0.7em;
border-color: transparent transparent black; /* 下拉框打开时翻转三角形 */
}
.dropdown.is-open .dropdown__drawer {
display: block; /* 初始隐藏 drawer 元素,出现 is-open 类时再恢复显示 */
}
}
上述代码中,主元素设置了相对定位,由此为抽屉元素创建了一个包含块(containing block),接着在包含块内设置绝对定位。切换按钮也指定了一些样式,包括 ::after
伪元素中的三角形效果;添加 is-open
样式类后,抽屉元素又重新显示并同时翻转三角形标记。
这段代码共计 35 行,涉及不少内容,但还不至于在使用模块时毫无头绪。当后续需要修改模块时,就会发现模块越小越好,这样就能快速定位到问题所在。
9.2.1.1 CSS 模块中的定位技巧
Positioning in a module
该下拉模块是我们使用定位的首个 CSS 模块,其中创建了模块自己的包含块(即主元素上的 position: relative
声明)。绝对定位的元素(即 drawer
元素与 ::after
伪元素)就是基于同一模块内的位置来定位的。
我们应该尽量让需要定位的元素关联到同一模块内的其他元素,这样当把模块放到另一个设置了定位的容器内时,才不致于弄乱既定样式。
9.2.1.2 关于状态类
State classes
注意,样式类 is-open
在下拉模块中有特定的用途。我们在模块中使用 JavaScript
动态添加或移除这个类。它也是 状态类(state class) 的一个示例,因为它代表着模块在当前状态下的表现。
根据惯例,状态类一般是以 is-
或 has-
开头。这样状态类的目的就会比较明显,它们表示模块当前状态下的一些特征或者即将发生的变化。类似的例子还有 is-expanded
、is-loading
以及 has-error
等。这些状态类的具体表现取决于使用它们的模块。
重点
状态类的代码要和模块的其他代码放在一起。这样,在使用
JavaScript
动态变更模块效果时,就可以使用状态类来触发变更。
预处理器和模块化 CSS
所有的预处理器(如
Sass
或者Less
)都提供了将分散的 CSS 文件合并为一个单独文件的功能。这样就可以用多个文件和多个目录来组织样式,而最终提供给浏览器的只有一个样式文件,从而减少浏览器发起的网络请求数;同时也便于让开发者将代码文件拆分为易于维护的大小。我认为这是 CSS 预处理器具备的最有价值的一个特性。如果您正好在使用某种预处理器,那我强烈建议您将 CSS 里的每个模块都放到各自对应命名的文件里,并按实际需要将这些文件组织进不同的目录中。然后再创建一个主样式表,并引入所有的 CSS 模块。这样一来,想要修改某个模块时就不必到一个冗长的样式表里搜索了。因为很清楚在哪里能找到它。
您可以创建一个只包含
@use
语句的main.scss
主样式文件,如下所示:
@use 'reset';
@use 'global';
@use 'message';
@use 'button';
@use 'media';
@use 'dropdown';
预处理器会从 reset.scss
和 global.scss
中分别引用重置样式和全局样式,并从各自的文件中引入模块样式,然后输出一个包含所有样式的样式表文件。这样每个模块都单独拥有了一个便于维护的文件。
有关预处理器的更多信息,请参阅本书附录B,或者参考预处理器的文档,了解使用其导入指令的更多信息。
9.2.1.3 菜单模块
The menu module
实现了下拉模块,下面来处理菜单模块。我们无需关心下拉菜单的打开和关闭,相关功能已经在下拉模块中实现了。菜单模块只需要实现各菜单项的样式即可。
对应的样式如代码清单 9.13 所示。将其添加到您的样式表中。
代码清单 9.13 菜单模块的样式
@layer modules {
.menu {
margin-block: 0;
padding-inline-start: 0;
list-style-type: none; /* 移除浏览器默认的列表项样式 */
border: 1px solid #999;
}
.menu > li + li {
border-block-start: 1px solid #999; /* 在各列表项链接间添加一条边框 */
}
.menu > li > a { /* 扩大链接的可点击区域 */
display: block;
padding: 0.5em 1.5em;
background-color: #eee;
color: #369;
text-decoration: none;
}
.menu > li > a:hover { /* 给鼠标悬停添加高亮效果 */
background-color: #fff;
}
}
这些样式与第六章中的下拉菜单相同,每个 <li>
都是模块的一个子元素,因此我觉得没必要为每个元素都添加一个双下划线形式的样式类,而是直接使用后代选择器 .menu > li
来实现目标。
菜单模块是完全独立的,并不依赖于下拉模块。这使得代码更加简单,因为大可不必为了理解一个模块而不得不先理解另一个模块,同时也有利于更加灵活地复用模块。
您也可以创建一个风格迥异的菜单(无论是以变体的形式还是完全独立的模块形式)并根据需求在拉下模块内部进行使用;甚至可以把菜单模块用到下拉模块以外的任意位置。我们无法预知页面后续的需求是什么,但有了可复用的样式模块,就可以确保在一定程度上提供前后一致的样式效果。
将代码组织成模块需要一定的实践经验。本例中将下拉模块与菜单模块分开实现也并没有什么不容置疑的理由——这两个模块确实是要协同工作的,并且在实际项目中估计也很少会遇到需要在下拉菜单中支持不同类型菜单的情况。
只是这样处理后,我发现它们在概念上很容易区分开。这样每个模块就变小了,而轻量小巧的模块往往更容易理解,将来也更容易修改。有时,模块也会自然而然地变大;如果拆分起来实在很困难,那就不要拆分,除非遇到特定需求再来考虑。
9.2.2 模块的命名
Naming modules
为模块命名是个很伤脑筋的问题。开发模块的时候我们可以用个临时的名称,但在您认为模块已经开发完毕之前,请务必注意规范命名。这也许算是模块化 CSS 开发中最难的部分了。
回想一下前面章节里介绍的媒体模块,我们曾用它来展示一张跑步者的图片以及跑步的小贴士,如图 9.6 所示。
图 9.6 包含图片和跑步小贴士的 media 媒体模块效果图
假设我们还没有为该模块命名,现在有个页面需要用到它,我们可能会叫它跑步提示模块(running-tip)。这样的命名很贴切,看上去也挺合适;但它也可能用于其他方面。如果使用同样的 UI 元素做别的事情,该如何处理?比如沿用跑步类网站的主题风格,可能需要用到一连串的模块来列出即将举办的赛事信息,这时候“跑步提示”的命名方式就显得格格不入了。
无论使用场景是什么,模块的命名都应该有意义。同时也要避免使用简单描述视觉效果的名称。
将该模块命名为“带图片的灰色盒子”(gray-box-with-image)看似通用,要是今后遇到要改成浅蓝色背景呢?或者需要重新设计网站呢?这样的命名方式显然就不好使了,到头来还得重新命名,然后再到 HTML 里所有用到它的地方进行更新替换。
我们应该换一种思路,思考模块代表的含义究竟是什么。但要真正做到这一点绝非易事。媒体模块的 media 这个命名就很恰当,它表示一种图文混排的版式,并给人以强烈的印象,同时也没有将模块局限于任何特定的场景或视觉效果。
模块要适用于各种不同的场景,因此命名应该简单好记为上。当网站有很多页面的时候,可能会多次用到某个模块。届时您可能会用这样的表述来和团队其他成员进行沟通:“这里就用‘媒体(media)’模块吧”,或者“这些‘板块(tiles)’都挤到一起了”。
至此,我们已经实现了消息模块、媒体模块、下拉模块以及菜单模块。一些比较值得推荐的模块命名包括面板(panel)、警告(alert)、可折叠部分(collapsible-section)、以及表单控件(form-control)等等。如果从一开始就对网站的整体设计有全面把控,无疑将更有利于模块的规范命名。例如,可能有两个 UI 元素都可以叫作 板块(tile),然而它们毫不相关,此时就应该分别给它们更明确的命名(比如分别叫作媒体板块(media-tile)和标题板块(headline-tile))。
有些人强制使用两个单词来命名每个模块,旨在避免模块指代不明;因为您也吃不准什么时候会需要另一个新的 tile 模块。如果现有的 tile 模块的命名较为明确,那么在命名新模块时就相对会容易些,不至于同前一个模块名混淆。
在给模块的变体类命名时,也应该遵循同样的原则。例如,如果已经有按钮模块(button module)了,就不宜使用 button--red
和 button--blue
来命名红色和蓝色的变体子类。网站设计在将来可能会改变,而您并不知道这些按钮的颜色是否会相应变化。推荐使用一些更有意义的名称,例如 button--danger
或 button--success
等。
使用 large
或 small
这样具有相对意味的词语来命名修饰符的做法并不可取,但也是可以接受的。没人说过网站重构时不能修改 button--large
的尺寸,只要它还是比标准按钮大一些就行。但一定要避免使用像 button--20px
这样的过于精确的修饰符类名。
关于 JavaScript 的替代方案 Alternate approaches in JavaScript
在大型团队中书写模块化样式,需要一些苛刻的约束条件来确保每个人遵守相同的类名约定。此外,也需要采取一些措施来防止大家新建的模块名称出现冲突。为了解决这些问题,一些 Web 开发社区开始尝试模块化 CSS 的替代方案。一番探索后,他们转向了
JavaScript
,并最终提出了一种现在被称为 CSS in JS 的解决方案。这种方案不再依赖样式类命名的口头约定,而是使用
JavaScript
来确保创建唯一的类名。为了实现这样的功能,业内已经出现了许多相关的JavaScript
库,其中最受欢迎的有Styled Components
和一个名为CSS Module
(命名易混淆)的库;其中许多工具库都与特定的JavaScript
框架或工具集绑定,例如React
和WebPack
。这种解决方案目前还存在争议,但是相关动态值得关注,尤其是当您在开发单页面应用(即 single page applications,简称 SPA)的时候。它只能在完全由
JavaScript
框架(如React
)渲染的项目里使用。采用这种方案需要做一些权衡,并且会限制使用特定的工具集。CSS in JS 虽然并非完美的解决方案,但在某些应用场景下经过验证确实是可行的。
9.3 CSS 的作用域
CSS scope
CSS 推出了一个对模块化样式大有帮助的全新功能,叫作 作用域(scope)。虽然 BEM 和其他类似方案也提供了以模块化的方式来组织样式代码的相关约定,但它们都需要不同程度的行为规范来加以约束,并且需要对整个开发团队进行相关培训,确保所有人都能正确理解并统一使用这套方法论。
而 CSS 作用域则从另一个角度给出了解决方案。它通过浏览器强制执行并由层叠规则本身对样式加以控制。通过 @scope
规则定义的 CSS 作用域,不仅可以实现让样式仅对页面指定部分生效,同时还针对模块化 CSS 的构建提供了一些新的功能特性。
注意
CSS 作用域是本书介绍的最新 CSS 功能特性,并于 2023 年 10 月首次在
Chrome
浏览器第 118 版中亮相。尽管它已经在 Web 实验性平台中发布有些时日了(详见第 1 章相关介绍),并在不久后现身Safari
的技术预览版(tech preview)中,目前仍不清楚Firefox
浏览器何时跟进该功能,但 CSS 作用域的出现很可能会对后续 CSS 的编写方式产生重大影响,因此,笔者认为有必要将其纳入介绍。
前面已经学过,BEM 和其他类似方案都依赖于精心设计的类名约定,以防止多个模块样式对同一元素生效而相互冲突。CSS 作用域则可以有效控制或避免此类冲突,从而弱化对样式类名称的过分关注。
@scope
规则需要紧跟一个选择器,表示该作用域生效的具体范围,然后用一对大括号将该作用域内的样式包裹起来,如代码清单 9.14 所示。示例选用的是之前介绍过的媒体模块,但已按照 CSS 作用域的语法进行了重写。BEM 风格的类名约定被简化了,在某些情况下甚至可能被完全删除。对照以下样式同步修改本地样式表:
代码清单 9.14 启用作用域后的媒体模块样式代码
@layer modules {
@scope (.media) { /* 该作用域仅限于 .media 元素内部 */
:scope { /* 选中 .media 元素本身 */
display: flex;
gap: 1.5em;
padding: 1.5rem;
background-color: #eee;
border-radius: 5px;
}
:scope.right {
flex-direction: row-reverse;
}
img { /* 选中 .media 内的 img 元素 */
align-self: start;
}
.body { /* 选中 .media 内的 .body 元素 */
overflow: auto;
margin-block-start: 0;
}
h4 { /* 选中 .media 内的 h4 元素 */
margin-block-start: 0;
color: #333;
}
}
}
上述示例代码用到了一个特殊的伪类选择器 :scope
,用于选中当前作用域的根节点元素,即作用域选择器匹配的元素。本例中即为 .media
元素。注意,.media
选择器只出现在定义作用域时;若要对该元素指定样式,需要在该作用域的规则集代码块中使用 :scope
伪类选择器。
由于其他选择器的作用域都在 .media
元素内,因此它们无需再重复声明 .media
选择器;而作用域代码块中的选择器(如 img
和 .body
)也无法选中对应的外围元素。这样就能在页面任意位置复用这些类名和元素标签,并且只对 .media
元素的相关后代元素生效。
更神器的是,@scope
规则中的选择器不会抬升其大括号内的选择器优先级。例如示例中的 img
选择器,优先级看似与 .media img
相仿,但实际优先级仅为 0, 0, 1
(而非 0, 1, 1
);同理,.body
选择器的优先级也仅为 0, 1, 0
。
有了这样的设计,媒体模块中的 HTML 标记就可以简化为更精简的类名。根据以下示例代码更新 HTML:
代码清单 9.15 简化后的媒体模块 HTML 标记
<div class="media">
<img src="runner.png" alt="Silhouette of a person running" /><!-- 无需类名 -->
<div class="body"><!-- 类名由 media__body 简化得来 -->
<h4>Strength</h4>
<p>
Strength training is an important part of
injury prevention. Focus on your core—
especially your abs and glutes.
</p>
</div>
</div>
译注
为方便前后对比,再来看看之前 9.1.4 小节给出的媒体模块原始 HTML 版本(即代码清单 9.9):
<div class="media">
<img class="media__image" src="runner.png"
alt="Silhouette of a person running"><!-- 图片子元素 -->
<div class="media__body"><!-- 正文子元素 -->
<h4>Strength</h4>
<p>
Strength training is an important part of
injury prevention. Focus on your core—
especially your abs and glutes.
</p>
</div>
</div>
查看该示例前,请务必确保 Chrome
浏览器的版本在 118
版及以上(对于版本稍低的 Chrome
浏览器,可以尝试在 chrome://flags/
特性页面启用 Web 实现性平台相关特性(即 Experimental Web Platform features))。
拥有开发者与 CSS 工作组成员双重身份的 Miriam Suzanne 是 CSS 作用域规范的主要作者。她将 CSS 作用域比作某种意义上的“隶属”关系表达(“belonging”)。标准写法下的选择器 .media .body
选中的是恰巧成为 .media
元素后代的所有 .body
元素(译注:这样一来如果有多个 .media
模块,那么每个模块内的 .body
元素都会被选中)。但是人们需要的往往并非媒体模块中的所有 .body
元素,而是仅与媒体模块存在隶属关系的特定 .body
元素。而 CSS 作用域的出现正是为了进一步明确这样的从属关系。
就本例而言,可能还看不出这样的改造相比之前的版本有何高明之处;只有当遇到页面上嵌套了多个作用域时,这样改造的价值才会凸显出来。此时,CSS 作用域的另两个核心概念就变得至关重要了,即 就近原则(proximity) 和 内部作用域边界(inner scoping limit)。接下来分别进行考察。
9.3.1 CSS 作用域的就近原则
Scope proximity
CSS 作用域的 就近原则(Proximity) 是层叠规范中的一项全新指标。当某个作用域嵌入另一个作用域时,二者所包含的选择器很可能选中相同的元素。当出现这种情况时,按照常规的层叠规则,优先级更高的选择器将会胜出;但如果优先级也相同,则需要根据就近原则,令距离样式声明最近的那个作用域(即内部作用域)中的样式最终胜出。
图 9.7 给出了第一章曾介绍过的层叠规则判定流程图。图中着重强调了 CSS 作用域就近原则的判定节点。这里最为重要的一点,是在解决存在冲突的样式声明时,就近原则是先于源码顺序生效的:
图 9.7 作用域就近原则的判定是在选择器优先级判定节点后、源码顺序判定节点前生效的
为了深入理解其工作原理,不妨在页面中添加另一个模块。该模块是一个浅蓝色背景的容器,用于区分页面中不在容器内的其他内容,不妨就叫高亮区块(highlight-block)吧。然后让该区块中任意级别标题(假定为 <h1>
至 <h4>
)的文本颜色为蓝色。最后在媒体模块下方的样式表末尾,添加代码清单 9.16 中的样式:
代码清单 9.16 高亮区块(highlight-block)模块的样式代码
@layer modules {
@scope (.highlight-block) {
:scope {
padding: 1rem 1.5rem;
background-color: #b3cbe6;
}
h1,
h2,
h3,
h4 {
color: #264b73;
}
}
}
这里特地让模块样式简单易懂,关键在于演示它与另一个作用域的模块样式存在相互影响。此时如果将媒体模块放入该高亮模块,则会出现如图 9.8 所示的渲染效果。两个模块都存在标题选择器 <h4>
,它们同时选中了媒体模块中带 “Strength” 字样的标题元素。由于媒体模块是最内层的作用域,因此媒体模块中的样式声明胜出,最终令标题文字呈深灰色而非蓝色(译注:即 #264b73
)。
【图 9.8 作用域存在嵌套时的最终示例样式效果】
在 HTML 中,添加一个带 highlight-block
样式类的 div
元素,并令其包含之前的媒体模块。这样就让高亮区块变为外层作用域,而媒体模块成为了嵌套在高亮模块中的作用域。具体 HTML 标记内容如代码清单 9.17 所示。该代码包含一小段示例文本,旨在阐明 highlight-block
模块原本具备的功能。
代码清单 9.17 包裹媒体模块的高亮区块(highlight block)模块的 HTML 代码
<div class="highlight-block">
<h4>Running tips</h4>
<p>
Here are some tips to stay healthy while training
and improve your running performance.
</p>
<div class="media slot">
<img src="runner.png" alt="Silhouette of a person running" />
<div class="body">
<h4>Strength</h4>
<p>
Strength training is an important part of
injury prevention. Focus on your core—
especially your abs and glutes.
</p>
</div>
</div>
</div>
同时,我还在媒体模块中添加了一个 slot
类,以方便后续演示。
模块化 CSS 的底层逻辑是在 CSS 中打造一个基本单元库,以便根据需求进行任意排列组合,最终构建出整个页面。在构建某个模块(比如媒体模块)时,您可能无从知晓该模块后续会被用于哪些场景下;网站开发人员很可能将其放到各种各样的容器中,届时往往更希望其样式优先于它所属模块的样式来渲染。
而在编写 CSS 样式时,您并不一定能预判出样式的优先渲染顺序是怎样的。而 CSS 作用域的就近原则就是确保目标模块样式不受其所在模块干扰的一大利器。有了 CSS 作用域,仅凭 HTML 的实际结构就能对层叠规则进行干预,而最终效果也往往是符合人们预期的。这一点在嵌套了几十个模块的复杂应用中更是如此。
9.3.2 划定作用域的边界
Scoping limit
虽然 CSS 作用域的就近原则(proximity)有助于确定模块间样式的优先级,但前提是明确知道内部作用域的边界在哪儿。相对于依靠就近原则来解决样式冲突,更好地做法其实是彻底杜绝此类冲突。这可以通过划定 CSS 的 作用域边界(scoping limit) 来实现。
要划定 CSS 的作用域边界,需要在 @scope
规则中使用关键字 to
,并添加第二个选择器,例如 @scope (.highlight-block) to (.slot)
。这里的关键字 to
可以理解为 直到/划定到……为止(until)。这样就能划定一个以 .highlight-block
为上边界、以 .slot
为下边界的作用域有效范围。该写法有时也称为 甜甜圈作用域(donut scoping),因为划定的有效范围在 DOM 结构中呈现出一个空洞,故而得名。
根据代码清单 9.18 中的示例样式更新本地样式表。
代码清单 9.18 划定了 CSS 作用域边界的 @scope 样式示例
@layer modules {
@scope (.highlight-block) to (.slot) {
:scope {
padding: 1rem 1.5rem;
background-color: #b3cbe6;
}
h1,
h2,
h3,
h4 { /* 当前作用域中的这些选择器将不会对 .slot 及其内容生效 */
color: #264b73;
}
}
}
CSS 作用域的就近原则仅在两个作用域间存在样式冲突(即对同一元素的同一样式属性指定了不同样式)时才会触发层叠规则的解析;然而就近原则无法屏蔽外围作用域不存在冲突的其他样式。在前面的示例中(即代码清单 9.16 和 9.17),带 “Strength” 字样的 <h4>
标题根据就近原则,让媒体模块中的样式最终胜出;但是,如果媒体模块没有设置不同的文字颜色,外围作用域指定的蓝色字体还是会生效。
当模块中包含其他模块时,划定作用域边界就显得尤为重要了。这样一来,外围模块无论使用什么样的选择器都不会意外干扰到内部模块。以此前介绍过的下拉模块为例,按照这个思路就可以重构为 @scope (.dropdown) to (.drawer > *)
,使得作用域中的样式只对 <div class="drawer">
元素生效,而它内部的元素则不受丝毫影响。
下拉模块的具体样式如代码清单 9.19 所示。相关 HTML 标记及 JavaScript
脚本的更新就作为练手项目留给各位读者了。同理,也可以对菜单模块做同步修改来启用一个新的作用域。
代码清单 9.19 基于 CSS 作用域重构的下拉模块示例样式代码
@layer modules {
@scope (.dropdown) to (.drawer > *) {
:scope {
display: inline-block;
position: relative;
}
.toggle {
padding: 0.5em 2em 0.5em 1.5em;
border: 1px solid #ccc;
font-size: 1rem;
background-color: #eee;
}
.toggle::after {
content: "";
position: absolute;
right: 1em;
top: 1em;
border: 0.3em solid;
border-color: black transparent transparent;
}
.drawer {
display: none;
position: absolute;
left: 0;
top: 2.1em;
min-width: 100%;
background-color: #eee;
}
:scope.is-open .toggle::after {
top: 0.7em;
border-color: transparent transparent black;
}
:scope.is-open .drawer {
display: block;
}
}
}
上述代码中的选择器还可以进一步优化。切换按钮很可能是该作用域中唯一一个 button
元素,因此还可以直接通过标签名称来选中,无需指定类名。
如果您用惯了 BEM,上述选择器的写法可能会让您不太舒服,毕竟类似 BEM 这样的模块化解决方案需要大量使用类名来避免样式冲突(同时也确保了样式优先级的可预见性);但要是使用得当,借助 CSS 作用域同样可以消除此类隐患。比如可以在几十个模块中随意添加 .title
选择器,然后明确指定各模块样式只对隶属于该模块的标题元素生效。
此外还可以定义一个作用域,使得模块的某些部分在划定边界内,而其余部分则不受边界限制。要实现这样的功能,可以将一个划定了作用域边界的 @scope
规则与另一个不带边界声明的 @scope
规则组合起来,相关示例代码如下:
@scope (.tabs) to (.tabs__pane) {
button {
padding: 0.4rem 1.4rem;
color: #ccc;
background-color: white;
}
}
@scope (.tabs) {
h2 {
font-size: 1rem;
font-weight: bold;
color: #369;
}
}
上述代码中,第一个 CSS 作用域划定了边界,并对 .tabs
选项卡模块下的 button
按钮元素生效;第二个 CSS 作用域则没有边界限制,将对 .tabs
模块下任意深度的所有二级标题 <h2>
生效。灵活运用这两种写法,就能让模块样式有选择性地对指定内容生效。
注意,CSS 的作用域边界并不会阻断样式的继承。当模块存在嵌套关系时,外层模块的字体样式仍然可以继承给内部嵌套模块。
CSS 作用域 vs 影子 DOM
在定义页面组件时,影子 DOM(Shadow DOM)用于隔离组件与页面外部内容。影子 DOM 偶尔也会作为构建样式作用域的一种解决方案,但我认为该方案仅在特定场景下才有意义。影子 DOM 主要还是一个基于
JavaScript
的功能特性(JavaScript-driven feature),因此不在本书的讲述范围之内;之所以将其与 CSS 作用域进行对比,主要是因为使用过影子 DOM 的人在了解 CSS 作用域相关概念时经常会问到这个问题。影子 DOM 中的 Web 页面与其余部分是严格隔离开的。从某种意义上讲,这就好比在页面上开了一个洞,不受层叠规则的制约。影子 DOM 须提供自己的样式;从页面外部无法直接对其指定样式。遇到需要将组件迁移到多个网站使用、并且要在所有网站上保持一致的渲染效果,此时影子 DOM 就派上用场了。例如在博客中嵌入某个社交媒体的插件就是影子 DOM 的一个典型应用。具体细节都是在 HTML 和
JavaScript
中定义的,而样式表则无能为力。另一方面,CSS 作用域将模块整合到层叠规范中,因此更适用于将整个页面视为一体的场景。它既可以让外层样式在某个作用域内生效,同时也可以针对特定区域对于样式的生效与否实现更为精细的把控。本章介绍的模块就是一个很好的例子:它们既可以与页面其他样式一起编写到同一个样式表中,又可以直接依赖某个现成的全局样式或自定义属性。
当所有模块都建立起作用域的概念后,唯一需要注意的命名冲突就是模块根元素上的类名冲突了。这里建议将这些类名作为特例加以区分;其中一种解决方案,是给所有代表模块的类名一个共同的前缀,例如 m-
,从而产生唯一的类名,如 m-media
或 m-dropdown
;此外,也可以通过设置自定义的 attribute 属性值来避免冲突,例如使用 <div data-scope="dropdown">
。对于第二个方案,定义 CSS 作用域时则可以使用 attribute 属性选择器,写作:
@scope ([data-scope="dropdown"]) to ([data-scope])
上述代码划定的内部边界将仅限于带 data-scope
属性(attribute)的内部嵌套元素。若将 <div data-scope="menu">
放入 <div data-scope="dropdown">
中,下拉模块将以菜单模块作为其内部边界(inner bound),模块中的样式将无法对菜单模块内部元素生效,而仅对其他模块生效。要是对所有模块都采取此类做法,那么该模块中的样式声明将只对位于模块作用域内、且不在其任意嵌套模块内的其余内容生效。
由于 CSS 作用域是 CSS 推出的一项全新功能,业界还没有成熟的应用模式进行推广。在此鼓励各位多多尝试,看看哪种方式更适合您。
9.3.3 CSS 中的隐式作用域
Implicit scope
CSS 作用域也可以通过 <style>
标签添加到目标作用域的根节点元素内。通过使用不带任何选择器的 @scope
规则,可以为所在父元素隐式地创建一个作用域。例如以下示例代码,对应的 CSS 作用域为 <div class="wrapper">
:
代码清单 9.20 隐式创建 CSS 作用域示例代码
<div class="wrapper">
<style>
@scope {
.message {
padding: 0.8em 1.2em;
border-radius: 0.2em;
border: 1px solid #265559;
color: #265559;
background-color: #e0f0f2;
}
}
</style>
<div class="message">Save successful</div>
</div>
<p class="message">This is out of scope.</p>
上述写法对于需要嵌入页面的一次性样式很有帮助。其应用场景可以是一篇博文中的一小段演示内容,或者作为 JavaScript
框架的一部分嵌入页面的某段 HTML 标记。
或许这并非 CSS 作用域的常规用法,因为构建模块往往是为了样式代码的复用;每次在给页面添加某个模块时,您肯定也不愿意反复书写同样的 CSS 样式。只是在某些特殊情况下,这样做可能会更方便。
9.3.4 关于 CSS 作用域与层叠图层
Scope and layers
CSS 选择器优先级的判定仍然早于 CSS 作用域,层叠规则这样设计似乎有点奇怪。毕竟,如果想让内部作用域样式覆盖掉外层作用域,为什么非要先判定一下选择器优先级呢?话虽如此,无论是选择器优先级的判定、还是内层作用域边界的设置,亦或是 CSS 层叠图层概念的引入,这些工具和方法都为我们的样式设计提供了前所未有的灵活性。
如果不希望某个模块中的样式干扰到其内部的子模块,可以考虑划定 CSS 作用域的边界;反之,如果外层模块样式需要渗透进内部模块,则可以通过控制两个模块的选择器优先级来决定最终胜出的样式声明。此外,也不必拘泥于将所有样式都放入同一个层叠图层内。
对于本章演示的所有示例,虽然这些带作用域的 CSS 样式都位于同一个层叠图层,但这只是解决问题的实现方案之一。您也可以将 modules
模块图层拆分为多个子图层,例如在 modules.base
图层中定义模块的形状和布局;在优先级更高的 modules.ui
图层则定义模块的颜色与字体。这样优先级较高的图层样式将始终覆盖低优先级图层样式(基于图层出现的顺序判定,详见第八章)。
这无疑又是一项全新的功能,因此业内在这方面还没有形成特定模式。您可以像理解网格布局中的行与列那样,将层叠图层和 CSS 作用域视为两个相互独立的设计维度,并根据网格中的具体位置设置样式。这样在决定样式间彼此的优先级时就有了更多选择。
9.4 CSS 中的模式库
Pattern libraries
开始用模块化的方式编写 CSS 后,Web 页及 Web 应用的构建方式也将随之改变。一开始,这些页面在构建时可能并没有什么特别之处;可一段时间过后,您会发现创建一个新页面所需要的很多模块都已经准备就绪了。比如新建一个媒体对象、或者下拉菜单,亦或是导航菜单,由于之前已经创建过一版了,就积攒了一些现成的样式;最后只需要按照恰当的方式在 HTML 中添加必要的元素标签和样式类即可。
因为模块都是可复用的,所以在编写页面相关板块时就不用再往样式表添加新样式了。与以往先写 HTML 再设计样式的构建模式不同,此时只要利用好这些现成的模块,组装到一起,就可以生成一个新页面。项目进行得越深入,需要的新 CSS 就越少。这时我们需要关注的就不再是新的 CSS 声明,而是样式表里所有可用的模块构成的模块清单了。
将模块清单整合为一组文档,这在大型项目中已经成为通用做法。该文档有时也被称为 模式库(pattern library)。模式库并非网站或应用程序的一部分,而是单独的一组 HTML 页面,用来展示各个 CSS 模块的具体用法,成为您和您的团队后续建站时的必备开发工具。
模式库的具体构建方法很大程度上取决于您熟悉的技术栈,或者项目团队最终确定的技术选型。比如项目是用某个 JavaScript
框架开发的,就可以考虑像 Storybook
(https://storybook.js.org/)这样兼容该框架的构建工具;或者直接手动构建一组 HTML 文档页,并通过索引和具体示例来介绍模块的用法。
9.4.1 采用 CSS 优先的工作流程
Using a CSS-first workflow
在给网站或应用构建新模块时,建议先将其添加到模式库中,然后再应用于页面。这样的开发方案我称之为 CSS 优先(CSS-first)。
与之前先写 HTML 不同,这时得先从 CSS 进行开发。在将样式应用到具体项目前,您可以(并且也应该)按照构建模式库的方式来开发 CSS,具体开发流程如下:
- 开发页面时,最好先有一个草图(sketch)或者原型图(mockup),或者其他可以描述页面效果的大致想法。
- 看看模式库。找找现有模块,如果满足页面需求就直接套用。然后从页面的外围(主页面布局和容器)开始,按自己熟悉的方式逐步深入。如果现有模块足以构建整个页面,就无需添加新的 CSS 了。
- 偶尔也会遇到模式库无法提供的一些功能。这在项目开发早期很常见,后面就会好很多。此时就要根据情况开发一个或多个新模块,或者现有模块的新变体形式;暂停正在开发的页面,在模式库构建出该模块,并为其书写文档,确保其外观与行为模式符合预期。
- 最后回到页面开发,使用刚改好的样式表,并将新模块添加到页面中。
这种开发方式有几个好处。首先,这样写可以为网站提供一致性更好的界面。模式库鼓励开发者复用已有样式,而非重复开发。比如,项目中不应该为 10 个不同的页面分别编写 10 套不同的列表样式,而是更倾向于复用仅有的几种列表类型。CSS 优先的开发方式会强迫开发者停下来思考:是否需要新的样式、现有模块是否可以满足需求。
其次,在按照模式库的方式开始模块时,您可以孤立地看待问题;可以从某个具体的 Web 页面抽离出来,并专注于构建样式模块这样的单一任务上。不同于解决某个页面的某个具体问题,思考新模块的潜在应用场景会相对容易一些,并由此创建出一套通用性更好、可复用性更强的解决方案。
再次,CSS 优先的方式还能让团队中的部分成员专注于 CSS 开发。对 CSS 不太熟悉的开发者可以将部分工作移交给经验更丰富的成员。擅长 CSS 的开发者每完成一个模块,就可以向其他团队成员发送一个链接,指向模式库中的该模块。
最后,CSS 优先的开发方式还可以确保文档是最新的。模式库中的页面是您变更 CSS 样式的测试环境。换句话讲,这些页面会一直呈现出最新的正确行为。修改 CSS 时,使用文档恰好就在一旁的注释块中,这样就可以轻松实现文档的实时更新了。
经常有开发者提问:HTML 究竟该怎样编写才能让样式写起来更轻松。我认为这种提法本身就有问题。相反,我们应该关注的是怎样编写样式,使其可以更好地在任意页面间复用。我们应该先写好 CSS,这样结构良好的 HTML 就是水到渠成的事了。而将 CSS 组织为模块就是一个行之有效的手段。一个 CSS 模块决定了其所需的 HTML 结构,使得该样式能在页面上正常展示。
9.4.2 重构与重大变更
Refactoring and breaking changes
有时也会在修改模块时无需考虑向下兼容的情况。这么做无可厚非,虽然多了一些活,但好歹是可行的。可能您会先完成样式变更,然后遍历整个网站或应用,把每个 HTML 实例都更新一下,让它们符合新的写法。
但我发现最好的做法往往是直接弃用该模块(需要在文档中指明),然后创建一个全新的模块来实现所需的新功能。这样一来,旧模块就可以在原有页面继续生效,而新页面的开发则可以往新模块迁移,因为新模块支持全部功能。
为此,可以使用一个包含三位数字的 语义版本(semver) 来为 CSS 设置版本号。一旦版本号变了,开发者自然就知道模块内容改变了。
语义版本的定义
语义版本(Semver) —— Semantic Versioning 的简写,是一种软件包版本的命名格式。它由三个数字组成,各数字间使用圆点分隔(例如
1.4.2
)。三个数字分别代表主版本号(major version)、次版本号(minor version)和修订版本号(patch version)。了解更多信息,详见语义版本官网 https://semver.org/。
如果只涉及一些小修改,比如 Bug 修复,则增加修订版本号即可(例如,由 1.4.2
改为 1.4.3
);如果添加了新的模块或功能但对现有 HTML 不构成破坏,或者将某个模块标记为已废弃(deprecated)时,则增加次版本号,同时将修订版本号置零(例如,由 1.4.2
改为 1.5.0
);再有,在极少数情况下,我会过一遍样式表并将标记为已废弃的模块全部删掉,这时就该升级主版本号(例如,由 1.4.2
改为 2.0.0
)。而当出现重大设计变更时(例如网站重新设计了),即便现有 API 接口 1 保持不变,也需要升级主版本号。
实际上,版本管理可以采用多种方式,具体取决于使用这些样式的项目实际情况。如果要在 Node.js
模块或者 Ruby Gem
中引入 CSS,那就得用相应的系统构建的版本;如果要在服务器上提供 CSS 静态访问,则可以在 URL 中包含版本号(如 http://example.com/css/1.4.2/styles.css
),并同时支持多个版本。
利用这种方式,就可以按照需求配置项目并使用任意版本的 CSS 了。您可以发布一个包含重大更新的 3.0.0
版本,而 Web 应用则可以继续沿用旧版本,直到开发者彻底更新一遍 HTML 并升级所有已废弃的模块。只要不是刻意升级到新版样式表,改动个别 CSS 并不会破坏现有应用的正常显示。
模式库文档列出了样式表的使用方法,但 HTML 具体启用哪个样式表、采用哪个版本的样式则是开发者说了算。由于 HTML 和 CSS 实现了解耦(decoupled),CSS 可以写好了再应用到 HTML;而在升级新样式表时,HTML 也是完全可控的。这就是 CSS 优先开发的优势所在。
修改模式库时,最好不要一意孤行,先和团队里的其他开发者沟通一下,再决定是否弃用或删除某个模块。必要时可以通过收集他人的反馈来综合判断哪些模块是有用的,哪些已经不再需要了。
通常来讲,CSS 是一门“只增不减”(“additive-only”)的语言。开发者害怕去编辑或删除那些已有的样式,因为他们无法预判这些修改会造成什么样的后果。于是不得不编写新的 CSS 代码,然后添加到样式表底部,或者通过不断提升选择器优先级来覆盖现有样式规则,最终让样式表沦为一堆乱麻而难以维护。
而通过模块化的方式组织 CSS 代码,就可以避免陷入这样尴尬的境地。您会清晰地知道某个模块样式位于何处,每个模块都分工明确,只实现某一个功能。同时,模式库还有助于开发者密切关注样式表开发过程中的一举一动。
9.5 本章小结 Summary
- 模块时一段可复用的小型样式片段,不依赖于页面上下文。模块样式应该集中写到样式表的某个位置,并按照代码封装的相关原则进行组织。模块化 CSS 绝不能进入另一个模块,以期修改其外观。
- 变体类(Variant classes)提供了同一模块样式的不同版本。
- 大型页面构造应该拆分成多个小型模块,并通过组装多个模块来构建出页面。
- 双连字符以及双下划线的命名约定能让模块的结构一目了然。
@scope
规则是 CSS 全新推出的一项功能,可以让一组样式在页面的指定作用域内生效。它是将模块化样式限定在目标模块内的理想工具。通过划定内部作用域边界还可以防止该作用域内的样式声明对边界内部的作用域生效。使用时需要留意该特性最新的浏览器支持情况。- 在大型项目中,可以考虑构建一个模块库来记录和清点项目所使用的模块,并将其作为组装新页面时现有样式模块的参考文档。
- 作者在这里使用了 API 接口的说法,因为他认为模式库的启用在某种意义上相当于维护了一组与 CSS 进行交互的 API 接口。新版中删掉了上一版的相关解释,这里补充说明。
Nicole Sullivan 既是 媒体模块 概念的提出者之一,同时也是 OOCSS(Object-Oriented CSS)和 SMACSS(Scalable and Modular Architecture for CSS)两种 CSS 方法论的重要人物之一,媒体模块这个概念最早是 OOCSS 设计模式的一部分,旨在通过面向对象的方式组织 CSS 代码,强调结构和表现分而治之,以便于样式的复用与后期维护。 ↩︎