前端新人,昨天下午看到一篇不错的文章,就翻译了一下,文中“我”都是指的原作者。
原项目地址:8 simple rules for a robust, scalable CSS architecture
这些是我身为专业前端工程师多年来在大型的复杂项目中总结出的一些关于管理 CSS 的经验。因为被别人问过太多遍,所以我觉得将其整理成文档是个好主意。
我已经尽量保持行文简练,但下面还是给出一个太长不看版:
如果你在编写前端应用,总免不了跟样式打交道。尽管现在前端的技术百花齐放、日新月异, CSS 仍然是 WEB 端编写样式的唯一选择。目前主要有两种解决方案:
这两种方法各有利弊,关于选择问题可以单独写一篇文章了。本文将更加专注于第一种方法,如果你偏好于后者,那么这篇文章可能就没那么有意思了。
那么标题说的健壮的可扩展的 CSS 架构具体指的是什么呢?
这是最显而易见的一个论点。
不要使用 ID 选择器(例如 #header
),因为无论何时,你觉得某个东西只会有一个实例,随着时间的流逝,你都会被证明是错误的。
比如有一次,我们想要找出一个当时正在做的大型应用中所有数据绑定错误。于是我们启动了两套 UI 实例,将其并排放在 DOM 中,两者都绑定到同一个共享的数据模型。这是为了确保数据模型中的所有改动都可以正确的映射到两套 UI 中。在这种情况下,所有之前假定唯一的组件(例如标题栏等)都不成立了。另外,这也是检测其他唯一性假定所导致的奇怪 BUG 的好方法。有点跑题,但这个故事寓意在于,任何可以使用 ID 选择器的地方都不如使用类选择器好,那干脆就不要用它了。
另外,你也不应该直接使用标签选择器(例如 p
),通常来说在组件中使用是允许的(后面会有提及),但不要单独使用,因为你总会在某个不需要这些样式的组件中取消它们。另外这也不符合我们刚才提到的高层次目标:面向组件、规避级联、默认局部化。如果需要在 body
上设置字体、行高、颜色(即继承属性)等,也不违背此规则。但如果你想严格遵循组件隔离,放弃这些也是完全可行的(具体见第 8 条)。
所以除了少数的例外情况,应该总是使用类选择器。
当你编写某个组件的时,如果所有相关的东西( Javascript 、样式、测试、文档等)都放在一起会非常有帮助:
ui/
├── layout/
| ├── Header.js // 组件代码
| ├── Header.scss // 组件样式
| ├── Header.spec.js // 组件相关的单元测试
| └── Header.fixtures.json // 单元测试可能会用到的模拟数据
├── utils/
| ├── Button.md // 组件的使用文档
| ├── Button.js // ...等等
| └── Button.scss
当你写代码的时候,打开项目浏览工具,所有跟这个组件相关的东西都尽在指尖。样式和产生 DOM 的 Javascript 代码生来就紧密相关,很大程度上你写一会 CSS 就会去编写相应的 Javascript ,反之亦然。对于组件和对应的测试文件也是同理。你可以把这个当作是 UI 组件的局部性原理。我曾经也小心翼翼的将我的代码分放到 styles/
、tests/
、docs/
等目录下,直到后来我才意识到,这样做的原因只是因为习惯而已,并没任何实质性的好处。
对于类以及其他的标记符(如 ID 、动画名称等), CSS 只有一个全局的、扁平的命名空间。与老版本的 PHP 类似,社区普遍使用冗长的、结构化的名字来模拟命名空间(BEM 就是其中之一)。我们也需要选择一种命名空间约定,并且遵守它。
举个例子,比如说我们使用 myapp-Header-link
作为类名,三个部分都有具体的职责:
myapp
确保将我们的应用和同 DOM 下的其他应用隔离Header
将当前组件和同应用下的其他组件隔离link
作为组件的命名空间下的一个局部名字为组件提供样式作为特殊情况,Header
组件的根元素可以简单的命名为 myapp-Header
类。对于一个简单的组件来说,可能只需要一个根元素就够了。
不管选择什么样的命名空间约定,我们都需要保证始终如一。上面提到的三个部分不止有各自的职责,也有具体的意义。只需要看一眼这个类的名字,就可以知道它属于什么地方。命名空间也是我们组织项目样式的方式。
这篇文章中将会使用 app-Component-class
的命名方式,我个人觉得这种方式很实用,当然你也可以选择一种更加适合自己的。
这条规则只是前面两条(合并组件代码和类命名空间)的逻辑结合:所有跟组件相关的样式代码都应该放到以该组件命名的文件中,没有例外。
如果你在浏览器中发现了某个组件没有正常工作,就可以右键审查元素,你可能会看到如下代码:
<div class="myapp-Header">...</div>
看到组件的名字,切换到代码编辑器,按下“快速打开文件”的快捷键,输入“ head ”,就可以看到:
如果你刚刚加入某个团队,不太熟悉代码的架构,这种严格的对应方式会变得更加有用:你不需要特别清楚整个项目的核心就可以快速完成工作。
这样做有一个自然的,但也许不是显而易见的推论:一个独立的样式文件应该只包含一个单独的组件。为什么?假设我们有一个登录表单组件,这个组件只会在 Header
组件中用到。在 Javascript 中,它被作为 Header.js
的一个辅助组件,没有被导出到其他地方。我们可能会给这个组件起名为 myapp-LoginForm
,并把它的相关代码塞到 Header.js
和 Header.scss
中。
然后假设团队中来了个新人,负责修复登录表单中一个小布局问题。他检查 DOM 元素找到了问题所在。但文件目录中并没有 LoginForm.js
或 LoginForm.scss
存在,所以他就不得不使用 grep
等搜索工具或靠猜测来找到相关的文件。也就是说,如果登录表单是一个单独的组件,就把它放到不同的文件中,这在非小型的项目中是非常重要的。
既然已经确定了命名空间的规范,我们就可以利用它们将 UI 组件进行分离了。如果可以保证每个组件只使用自己的唯一前缀的类名,就可以保证组件内的样式不会泄漏给其他的组件。这种做法非常有效(后面会有注意事项),但不可避免的需要不断地输入命名空间,难免心累。
一个健壮的,同时非常简单的方法是把整个样式文件放到一个前缀块中。下面的代码仅需要写一次组件的名字:
.myapp-Header {
background: black;
color: white;
&-link {
color: blue;
}
&-signup {
border: 1px solid gray;
}
}
上面的例子是使用 SASS 编写的,但比较棒的是 &
符号对于几乎所有的 CSS 预处理器(SASS、PostCSS、LESS 以及 Stylus)都适用。为了说明论点,上面的 SASS 编译后的代码如下:
.myapp-Header {
background: black;
color: white;
}
.myapp-Header-link {
color: blue;
}
.myapp-Header-signup {
border: 1px solid gray;
}
这个也适用于大部分的模式,例如对于不同的组件状态使用不同的样式:
.myapp-Header {
&-signup {
display: block;
}
&-isScrolledDown &-signup {
display: none;
}
}
编译后:
.myapp-Header-signup {
display: block;
}
.myapp-Header-isScrolledDown .myapp-Header-signup {
display: none;
}
即使是媒体查询也非常方便,只要你使用的预处理器支持冒泡即可( SASS 、 LESS 、 PostCSS 以及 Stylus 都支持)
.myapp-Header {
&-signup {
display: block;
@media (max-width: 500px) {
display: none;
}
}
}
编译后:
.myapp-Header-signup {
display: block;
}
@media (max-width: 500px) {
.myapp-Header-signup {
display: none;
}
}
上面的模式可以让我们非常方便地使用略显冗长的唯一类名,而不用一遍一遍地反复输入。这种便利性是非常必要的,因为如果写起来很麻烦,程序猿就会偷工减料。
这篇文章说的是样式上的约定,但样式也不是孤岛般的存在,在 JS 端我们也需要处理相同的命名空间化的类名,所以便利性也非常重要。
羞耻的插个广告,我为此编写了一个非常简单的、零依赖的 JS 库 css-ns
,当和框架相结合(比如 React)时,它允许你在文件中指定一个特定的命名空间:
// Create a namespace-bound local copy of React:
var { React } = require('./config/css-ns')('Header');
// Create some elements:
<div className="signup">
<div className="intro">...</div>
<div className="link">...</div>
</div>
编译到 DOM 中的结果:
<div class="myapp-Header-signup">
<div class="myapp-Header-intro">...</div>
<div class="myapp-Header-link">...</div>
</div>
这是非常方便的,上面的代码也将 JS 代码默认局部化了。
好吧我又跑题了,回到 CSS 上。
还记得我之前说在每个类名前面加上组件的命名空间是一个非常有效的沙盒化做法吗?还记得我说会有注意事项吗?
考虑下面的样式:
.myapp-Header {
a {
color: blue;
}
}
在下面的组件继承中:
+-------------------------+
| Header |
| |
| [home] [blog] [kittens] | <-- 这些都是 <a> 元素
+-------------------------+
没什么问题,对吗?只有在 Header
内部的 <a>
是蓝色的,因为我们生成的规则是:
.myapp-Header a { color: blue; }
但假设布局之后变成了这样:
+-----------------------------------------+
| Header +-----------+ |
| | LoginForm | |
| | | |
| [home] [blog] [kittens] | [info] | | <-- 这些都是 <a> 元素
| +-----------+ |
+-----------------------------------------+
选择器 .myapp-Header a
同时匹配了 LoginForm
内部的 <a>
元素,我们所谓的样式隔离的也就灰飞烟灭了。事实证明,把所有的样式放在一个命名空间块中可以有效隔离组件间的样式污染,但对于组件内部却是无能为力的。
这个问题有两种解决方案:
Header
中的每个 <a>
都用 <a class="myapp-Header-link">
替代,我们就永远不会有这个问题。但有时你使用了完美的语义化标签,<article>
、<aside>
、<th>
等,并且运用非常到位,你可能就不希望再画蛇添足地为其添加额外的类,对于这种情况:>
。针对后一种方法,我们可以把代码改成:
.myapp-Header {
> a {
color: blue;
}
}
这可以保证隔离可以在指定深度的组件树中工作,因为生成的选择器变成了 .myapp-Header > a
。
这可能听起来有争议,那我举个更极端的例子来证明我的观点:
.myapp-Header {
> nav > p > a {
color: blue;
}
}
经过多年的可靠建议,我们都对选择器嵌套唯恐避之不及,包括直接后代选择器 >
,但为什么呢?主要原因有下面三个:
nav p a
除了在当前环境中,其他地方很难用上。但这篇文章就是为了避免这个问题,我们使用组件隔离了彼此,所以这个问题自然不存在。.myapp-Header-link a
,那你可以在组件内把 <a>
放到任何地方,<a>
的样式总是可以匹配;但如果是 > nav > p > a
,你就需要更新选择器以重新匹配 <a>
的新位置。但因为我们把 UI 组织成小的、互相隔离的组件,这个也不是什么问题。的确,如果在重构时,你需要考虑整个项目 HTML 和 CSS 代码,那确实很可怕。但如果只是针对一个小沙盒内的几十行代码,并且明确的知道无需考虑沙盒外部的东西,那也就构不成啥问题了。到现在为止,我们是否达成了完美的沙盒目标,每个组件和页面的其他东西完全隔离呢?下面来快速回顾一下:
我们通过对每个组件内的类添加命名空间前缀阻止了样式外泄:
+-------+
| |
| -----X--->
| |
+-------+
这也就意味了我们阻止了组件之间的样式泄漏
+-------+ +-------+
| | | |
| ------X------> |
| | | |
+-------+ +-------+
我们还通过限制子选择器阻止了组件的样式内漏:
+---------------------+
| +-------+ |
| | | |
| ----X------> | |
| | | |
| +-------+ |
+---------------------+
但最重要的一点,外部的样式仍然可以渗入到我们的组件中:
+-------+
| |
----------> |
| |
+-------+
举例来说,比如我们的组件样式如下:
.myapp-Header {
> a {
color: blue;
}
}
但我们引入了一个不符合约定的第三方库,定义了如下 CSS :
a {
font-family: "Comic Sans";
}
但不幸的是,并没有什么简单的办法保护你的组件不受外部侵害,对于这种情况,也许我们只能放弃挣扎。
但幸运的是,你一般都可以选择自己的依赖库,所以只需要找一个更好的替代品即可。
同时,我只是说没有简单的方法防止这种情况,这并不代表没有方法。事实上,有非常多的方法来解决这个问题,只是都要付出一些额外的代价:
all: initial
是个不为人知的新 CSS 属性,它就是为这个问题而诞生的。 它可以 阻断属性的继承,也可以用来做局部重置。它的实现有些复杂,并且目前还没有被完全支持,但最终 all: initial
会成为一个样式隔离的有用工具。<iframe>
可供选择。它提供了 Web 运行时中最强的隔离性( CSS 和 JS ),但也拖慢了启动速度,提高了维护成本。但有些时候这点成本是值得的,其实很多著名的网页嵌入( Facebook 、 Twitter 、 Disqus 等)都是用 iframe 实现的。但对于我们而言,如果使用 iframe 隔离成千上万的小组件,这可能会让性能跌到谷底。总之,这个题外话有点跑远了,回到主题 CSS 规则中来:
正如之前说的 .myapp-Header > a
,当我们进行组件嵌套的时候,我们可能需要对子组件应用一些样式,考虑下面的布局:
+---------------------------------+
| Header +------------+ |
| | LoginForm | |
| | | |
| | +--------+ | |
| +--------+ | | Button | | |
| | Button | | +--------+ | |
| +--------+ +------------+ |
+---------------------------------+
显而易见的,使用 .myapp-Header .my-Button
并不是个好主意,而是需要使用.myapp-Header > .myapp-Button
。但什么样式可以写在此处,而不是在子组件内部指定呢?
注意到 LoginForm
是浮在 Header
的右侧的。凭直觉,此处样式可能是这样的:
.myapp-LoginForm {
float: right;
}
我们没有违反之前的任何规则,但我们已经让 LoginForm
非常难以重用了:如果后续的主页也想使用这个登录框,但不想浮在右侧,那就束手无策了。
一个比较务实的解决方案是释放上面的约束,将其转移到具体使用它的组件中。具体来说,我们需要这样做:
.myapp-Header {
> .myapp-LoginForm {
float: right;
}
}
这样做没有任何问题,只要我们不乱来:
// 反例; 不要这样做
.myapp-Header {
> .myapp-LoginForm {
color: blue;
padding: 20px;
}
}
我们肯定不希望这样做,因为这打破了本地修改不间接影响全局安全保证。上面的代码中,LoginForm.scss
不再是修改组件样式时唯一需要检查的地方,会使我们又回到混乱的原点。所以怎么界定哪些属性在外部修改是 OK 的,哪些是禁止的呢?
我们想要把沙盒保持在每个组件的内部,并且外部不需要知道组件的实现细节。它对我们来说是个黑盒子。子组件和父组件之间的界限来源于 CSS 中的某个基础概念:盒模型
我的类比比较糟糕,但大致如此:就像是在某个国家的意思是身处这个国家的边界内部,我们也规定父组件只能修改子组件(直接后代)边界外部的属性。外部的意思是跟位置以及尺寸相关的属性(例如:position
、margin
、display
、 width
、float
、z-index
等),修改它们是允许的,但 border
内部的属性(包括 border
自身、padding
、color
、font
等)是不允许修改的。
所以,下面的代码显示是不合法的:
// 反例; 不要这样做
.myapp-Header {
> .myapp-LoginForm {
> a { // relying on implementation details of LoginForm ;__;
color: blue;
}
}
}
还有一些有趣的/无聊的边界情况,例如:
box-shadow
- 阴影是组件样式的重要组成部分,所以组件内部应该有这个属性。但明显这个属性是在 border
之外的,所以它也属于父组件的管辖范围。color
、font
和其他的[继承属性](]( https://developer.mozilla.org/en-US/docs/Web/CSS/inheritance)) - .myapp-Header > .myapp-LoginForm { color: red }
浸入了子组件的内部,但某种程度上来说,上面的代码功能上可以等同于 .myapp-Header { color: red; }
,而后者没有违反任何规则。display
- 如果子组件使用了 Flexbox 布局,它可能要求在根节点设置 display: flex
。但父节点可能使用 display:none
来对其进行隐藏。重要的是这些边界情况并不是危险区,只是把一点 CSS 级联重新引进你的样式中而已。相对于其他的坏味道,享受一点限制性的级联是没问题的。比如,仔细看看最后一个例子,优先级规则完美的解决了这个问题:当组件可见时,.myapp-LoginForm { display: flex }
是优先级最高的规则,所以这条规则生效。当父组件决定隐藏它时,.myapp-Header-loginBoxHidden > .myapp-LoginBox { display: none }
是优先级最高的规则,所以该规则生效,没毛病。
为了避免重复工作,组件之间可能需要一个共享的样式;为了不从头造轮子,你可能也会选择第三方的样式库。这两种情况都应该避免给现有代码增添不必要的耦合。
举个实际例子,假设我们需要使用 Bootstrap 的某些样式。因为其所有样式共享同一个全局的命名空间非常容易导致冲突, Bootstrap 作为一个非常蛋疼的样式库:
.btn
和 .table
等。无法想象这种名字不会碰巧被其他的开发者或项目使用。不管怎样,假设我们就想使用 Bootstrap 作为我们的 Button
组件的基础,不要再 HTML
中编写:
<button class="myapp-Button btn">
而是考虑在你的样式中 extend 这个类:
<button class="myapp-Button">
.myapp-Button {
@extend .btn; // from Bootstrap
}
这样做的好处是不在 HTML 组件中暴露给任何人荒谬的 btn
类。Button
可以把这个作为实现细节隐藏起来,外界不需要知道。这样的话,无论你是否用 Bootstrap 或者其他的库(或者全部由自己写),内部的改变外界都无法感知(除了界面上 Button
的外观变化)。
这种做法同样适用于你自己写的助手类,而且你还可以选择一个更合理的名字:
.myapp-Button {
@extend .myapp-utils-button; // 在项目的其他地方定义
}
甚至完全不导出多余的类(大多数的预处理器都支持):
.myapp-Button {
@extend %myapp-utils-button; // 在项目的其他地方定义
}
最后,所有的 CSS 预处理器都支持 mixins,这是个非常强大的工具:
.myapp-Button {
@include myapp-generateCoolButton($padding: 15px, $withExplosions: true);
}
另外需要提及的一点是,对于更加开明的框架(例如 Bourbon、Foundation),它们都是这样做的:声明一堆的 mixins 供你根据需要选用,而不是产生一堆的样式污染命名空间。
Neat.
Know the rules, so you know when to break them
打破成规
最后,正如之前提到了,当你理解了你所拟定的规则(或者是采用了网络上的规则),你也可以允许一些例外情况,如果这样做更有意义的话。比如如果你觉得直接使用助手类有一些额外的价值的话,那就可以这样做:
<button class="myapp-Button myapp-utils-button">
这个额外的价值可能是你的自动测试框架可以更加智能地知道哪个元素是按钮,是否可以点击等。
或者你可能觉得当改变非常小时,可以破坏组件之间的独立性,因为保持组件独立需要做大量的工作。我可能要提醒你这么做不太好,统一性非常重要。但只要你的团队没意见,你也完成了工作,那就没有任何问题。
如果喜欢这篇文章,你可以向别人推荐它。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.