【第801期】 构建稳固的、可升缩的CSS框架的八大原则
前言
又是少分享css相关的,上一次分享类似的文章还是一两个月前了。今天的文章来自众成翻译@远杨的翻译分享。
正文从这开始~
这些原则都是我从这些年工作中所含盖的各个大型、复杂的web项目中总结出来的。而这些事情也都是我这些年被多次问到的,所以觉得将其用文档的形式叙述出来会是个不错的想法。
我会尝试着尽量缩短我的解释,以下是关键词:
善用类来定义CSS
将组件的相关代码放在一起
使用一致的类命名空间
让命名空间和文件名间具有映射关系
防止在组件外定义样式
防止泄漏组件内的样式
考虑元素的边界问题
松散的聚合外来样式
简介
如果你从事前端的应用开发,那么你一定会需要设置样式。尽管现在优秀的前端应用花样百出,但CSS仍然是赋予网页上任何东西样式的唯一方法(但是,在某些情况下,原生的应用也可以做到)。也就是说,这篇文章将讲到两大类设置样式的方法,即:
已存在多年的 CSS 预处理器,如:SASS, LESS等)
相对而言,才出现的具有CSS-in-JS 特性的库 (如 free-style, 和 many others)
对于这两种方案的选择完全可以另立文章进行讲解。当然, 它们也都有各自的优缺点。尽管如此,我将集中介绍前一种方法, 所以,如果你选择“追随”后者,那么这篇文章对于你而言会比较乏味。
高层次目标
我们追求的是一个稳固的、可升缩的CSS框架。但是要做到这一点,究竟需要具备些什么特质呢?
面向组件 - 处理UI复杂性的最佳方法就是讲UI分成许多很小的组件。如果你使用了一个“明智的”框架,你就会发现在JS方面也是如此。 比如,React就具有高度组件化的特性。由此,我们希望CSS框架也能如此。
沙盒 - 将组建进行拆分并不一定能能使得其按照我们的期望进行加载,如:一个组件的样式可能会对其他组件的样式产生不可预计的影响。诸如cascade这样的CSS基础特性和具有唯一全局性性质的命名空间的标识符就能造成这种影响。如果你对 Web组件的规范比较熟悉, 你就可以开始考虑利用 Shadow DOM可以隔离样式的优势 来做些什么而不必去关心浏览器的支持程度(或是否有相应的规范)。
易用性 - 我们想要一切“漂亮”的事物,但是又不想做过多工作。也就是说,我们并不想要开发者在运用该框架时具有较差的体验。如果可以,我们想将其做的更好。
容错性 - 与前面的观点有点联系的是,我们希望一切的东西默认都具有本地性,当然,异常除外。而我们工程师都很懒,而通常阻力最小的方案总被证明是正确的解决方案。
一致性原则Concrete rules
1. 善用类来定义CSS
This is just to get the obvious out of the way.
不使用ID (e.g. #header), 因为当你认为这将只是一个事物的实例的时候,你都会被现实-- 在无限的时间尺度上]--很扇一个耳光。这是我们过去发生过的一个案例。当时我们尝试着去剔除一个大型应用中的数据绑定bug,因此我们在同一DOM树中并列使用了两个运用了这个UI库的实例, 而这两个都绑定到了一个名为shared的实例上。 最初,这是为了保证数据模型中的所有修改都能正确的反映到UI库上。然而此时,任意你觉得会是独一无二的组件,如:header bar,都将不复存在。这也是一个发现与独特性假设相关的不易察觉的bug的标准方法。 这个事情的道理在于: 没有相关的解决方法会使得运用ID的方法比运用class的方法好。 所以让我们永远不要使用ID吧。
同样的,你也不能直接指定你的目标元素。 但是如果目标元素在某个组件里,直接指定到是可行。但最终,你需要去清除这些对于组件而言无用的样式 。 而对于我们更高级别的目标来说,这与其是完全相背离的。而设置body上的属性,如:fonts、 line-heights 和colors (又名可继承的属性) 却是一个特例。 但是如果你格外在意组件的独立性的话, 放弃这些是完全可行的(如 使用外在样式中所说的一样).
所以,除了某些特定的场合,你的样式设置最好使用class。
将组件的相关代码放在一起
当在设置一个组建的时候,如果能将其相关部分--js文件、css文件、测试文件等--聚集起来,会对组件的组件化起到很大的作用。
当你在进行编码的时候,只需要打开你的项目编辑器即可。而生成DOM树的js文件和css文件通常具有相关性,我敢打赌你会在编辑过其中一个文件后很快地转向另一个文件的编写。比如:同一个组件中的css和js文件,又或者其相关的测试文件夹中的css和js文件。我们可以将这个看作是UI组建的 局部引用原则 。当然,我也把这个规则运用于维护我源码树中各个独立部分的工作中,如: styles/, tests/, docs/等部分。
使用一致的类名空间
对于类名和其他标识符(如ID、动画名称等)而言,CSS只具有一个单一的、无区分性的命名空间。而对于这一问题的解决方法,人们采用了与过去的PHP时代一样的解决方法,即:使用长而具有结构性的命名方式(BEM 来完成对于命名空间的命名。我们将选用一种命名标准并贯彻执行。
举个例来说, 用 myapp-Header-link 作为一个类名. 而它的每一个部分都有明确的功能:
myapp :用于区分使用在同一DOM树上的不同app
Header :用于区分app中的不同组件
link: 声明了组件空间中的一个局部名称,达到了我们想设置局部样式的目的
但有一个特例,就是Header组建的根元素只需要使用myapp-Header 这个类名进行标识即可。并且对于一个简单的组件而言,这可能就是你所全部需要的命名。
不管是采用了哪一个命名规范,我们都需要坚持使用这一种方法。而这种命名方式中的三个部分除了有其特定的功能,还具有相应的含义。故而在看到一个类名的时候,你会看出它属于哪个部分。也就是说,命名空间可以帮助我们在浏览项目的样式文件时准确归类。
我将这种命名的模式设定为:app-Component-class, 而且这也是我工作中行之有效的方法。当然,你也可以提出你自己的设置模式。
让命名空间和文件名间具有映射关系
这一条规则是上面两条规则(将相关代码放在一起,类的命名空间)的组合,即:所有会影响到同一组件的样式规则应该放在以该组件命名的文件中。
如果你正在使用浏览器观看效果,并且定位到一个表现异常的组件,你可以依次点开这个组件,假设你会看到以下代码:
接着你可以切换到你的编辑器,在敲击了“快速打开文件”的组合键之后,利用你刚获取到的名称--head--进行搜索,然后看到:
而UI组件和其对应的编码文件间所具有的严格映射,对于新进入项目而又不熟悉项目架构的人来说无疑是有用的。
当然,还有一个推论(虽然现在可能不太明显):一个样式文件中应该只含有一个命名空间中的样式。为什么这么说呢? 举个例子吧。现在有一个登陆的表单,它只会用在Header组件中. 而与其相关的js,我们定义在了一个名为Header.js的文件中, 并且不会向其他地方“暴露”该文件。然后,给这个表单赋予了一个类名,即myapp-LoginForm, 并将在Header.js文件和Header.scss使用它。现在,假设有一个新来的人要去修改这个登录表单在布局上的一些小问题 并且他要去判断从哪个元素开始入手。此时,又没有LoginForm.js 文件或者LoginForm.scss文件 。故而,他不得不求助于grep这些工具或者靠猜测去找到相关代码文件。也就是说,如果这个登录表单需要一个独立的命名空间,我们就需要将其放在一个独立的组件中。而这种一致性在工程项目中具有重要的价值,因为它避免了猜测查找.
防止在组件外定义样式
我们已经展示了我们常用的命名规则,现在我们想将这些规则应用到UI组件上。如果所有的组件都只使用了附有独特命名空间前缀的类名, 我们敢说这些样式绝对不会和它人的重合。 从下面会涉及到的注意事项中不难看出这一方法的意义性。但是一遍遍重复的编写这些命名空间确实会让人觉得厌烦。
一个行之有效且非常便捷的方法便是将文件中所涉及到的全部样式放在一个标有前缀名的块中。注意我们是怎么只输入一次app或者组件所需要重复使用的名字的:
虽然上面的例子都是运用在SASS中的,但是喜人的是& 这个符号可以在所有相关的css预编辑器中使用 — (SASS, PostCSS, LESS 和 Stylus)。下面所展示的便是上面的代码在SASS中的编译结果:
所有经常使用的模式在该种定义下都能很好地被编译出来,例如: 为不同状态下的组件设置不同的样式( Modifier in BEM terms):
编译结果:
即使是对于媒体查询的设置也能很方便的进行,只要你使用的预编辑器支持“冒泡”这个功能。(SASS, LESS, PostCSS and Stylus都支持):
编译结果:
上面所提到的方法使得我们不用一遍遍地编写重用的代码就能使用长但却独一无二的类名。而这种便捷性是必须要考虑到的。因为一旦没有了便捷性,我们便会“偷工减料”。
在JS文件里快速命名
虽然这部分是关于样式设置的便捷性的,但是样式并不会独立存在。也就是说,JS文件里同样需要以相同的命名方式快速地完成命名工作。
“无耻”的做个宣传,我已经编写出了一个完全不依赖任何JS库的简单方法来实现这一功能,即: css-ns。当其与框架层一同使用时 ( 如:React),会在一个指定的文件中 强行 加上特定的命名空间,如:
渲染而成的DOM树为:
这很便捷,而且该JS文件可以直接在本地使用。哎,我又再一次离题了。重新回到CSS上吧!
防止泄漏组件内的样式
还记得我说过每个类名前面加上组件的名称空间是一个“非常有效”的沙盒风格吗?还记得我说过这种方法有“注意事项”吗?
看一下下面的样式:
下面是组件的等级分布图:
我们很酷,对吗??只有 Header里的元素有blued样式,这是由我们生成的规则决定的:
但是如果布局变成了下面的结构:
.myapp-Header a 这个选择规则同样适配于LoginForm里的元素, and we've blown our style isolation. 这也说明了一个事实:将所有的样式放在一个命名空间下确实是一个将其与其他组件相区分的有效方法,但是却不能有效地区分其内部元素的样式.
可以用以下两种方法改善这个问题:
决不在样式文件中使用元素名称。如果Header组件里的所有元素都不相同,那么我们也不用去处理这个问题。但是为了更好地建立语义标注,有时会在不同的地方使用多个相同的元素,而且你也不想用多余的类名去区分他们,那么在这种情况下,我们采用第二种方法。
在命名空间中将元素名与选择器“>”配合使用。 应用该种方法后, 我们的样式重写为:
当然,因为此时的选择器为.myapp-Header > a,这会使得“样式隔离”对于组件树中深层次的元素同样奏效。如果这不能让你信服,我们可以看一下下边这个同样凑效的例子:
在文章 多年的可行经验中,我们被告知要避免使用选择器的嵌套(包括>) 。为什么呢?作者陈列出了以下三个原因:
级联风格最终将毁了你一天。选择器嵌套的越多,越可能在无意间与多个样式选择规则相匹配。读到这,你该知道我们已经排除了这种可能性--使用了严格的命名空间前缀且在需要的地方使用了孩子选择器。
太多的特异性将减少可重用性。如:nav p a这样的样式规则即使是在特定的环境下也不能被复用。但是我们也不希望发生这种情况。事实上,当样式规则不能与我们的目标--组件间的相互隔离--相契合时,我们会禁止这种样式的复用性。
过多的特异性会使重构困难。该种情况在现实中有所体现。比如:在.myapp-Header-link a这条规则下,你可以随意在你的HTML组件里移动a元素,且同样的样式仍旧起作用。然而一旦使用的是> nav > p > a这样的规则,你就需要更新选择器以匹配a元素在该HTML组件中的新位置。但考虑到我们想通过小且具有隔离性的组件来组装UI库时,这个理由也就没有了实际意义。当然,如果你不得不在重构项目时考虑到全部的HTML和CSS文件,这将带来可怕后果。但是如果你是将一个个具有样式的小沙盒组合起来使用并且知道沙箱之外的都不需要纳入考量,那么这种类型的变化就不再是一个问题。
这是一个很好的例子来帮助你理解这个规则。于是,你会知道该在什么时候打破这个规则。在我们的架构中,选择器嵌套不只是可行的,有时它是你不得不去做的事情。为它而疯狂吧!
防止将样式注入到组件中
当我们为我们的样式规则设置了“沙盒”之后,这些组件是否都能独立于彼此而独立存在了呢 ?简要介绍一下:
通过在每个类名前加上组件的命名空间防止了样式向 组件外部 泄露的问题:
除此之外,该种方法还防止了项目组件间的影响问题:
通过使用孩子选择器我们还防止了组件内的组件 被影响:
但是,值得注意的是, 外部样式可以“入侵”到我们的组件中来:
比如,我们的组件具有以下的样式规则:
但是我们引入了有“不良行为”的第三方库,它包含了以下的CSS样式:
目前没有简单的方法能规避这样的问题--external abuse, 所以我们通常举手投降。
幸运的是,你通常可以在一定程度上控制你引入的库, 而且可以很快的找到一个表现良好的替代方案。
当然,我是说过没有简单的方法来帮助你规避这个问题,但是并不代表没有。 这些方法里提出了很多折中的解决方案:
直接覆盖: 如果你引入了 CSS reset ,并将其设置到相应的能“胜过”第三方组件设定的规则的选择器上,你就成功了。但是只要你的应用很小(也就是说,一个第三方共享的button组件都能嵌入到你的网页), 这个方法就会失效。所以,这并不是个好方法,列在这儿只是为了方法论介绍的完整性。
all: initial 是一个鲜为人知的CSS属性。它的作用便是用于处理这种问题。除了可以 阻止一个元素继承属性, 还能作为局部样式的重置方法使用, 只要其具有特异性争议 (and as long as you repeat it for each element you want to protect). Its implementation includes some intricacies 并且 并未得到全部浏览器的支持 ,但是最终 all: initial可能成为一个隔离style设置的有效工具。
Shadow DOM在上面已经提到过了。 它可以作为处理该项任务的一个工具,因其能在js和css中对组建的边界进行明确的声明。 尽管 在最近的safari版本中看到了一些希望,但是近年来,对于 Web组件规范的支持并没有多大进展。所以 除非你只考虑在已经支持的浏览器上工作,否则不要依赖于Shadow DOM去解决问题.
最后, 便是...[原文里没有,翻墙没成功,所以我也不知道是什么]。它提供了一种方法来隔离CSS和JS文件的运行时间,但与此同时,也会增加启动成本--来源于延迟和维护成本--来源于内存的保持。然而通常情况下,这种折中是值得的。 对于大多常用网站的嵌入(如:Facebook, Twitter, Disqus等)都是使用 iframes实现的. 然而对于本文想实现的目标--将成千上万的小组件相互分离--而言,这种方法会大幅度降低我们的预期。
不管怎么样,这节的篇幅有点长了,让我们回到我们所列的CSS规则上吧。
考虑组件的边界问题
正如 .myapp-Header > a这条规则一样,当我们想嵌入子元素的时候 ,我们可能需要对子元素应用相应的样式规则 。而不像web组件一样,在处理> a 和 > my-custom-a时没有任何区别。考虑下面这个布局:
我们能马上注意到设置.myapp-Header .myapp-Button 这样一条规则会非常糟糕,所以我们会使用.myapp-Header > .myapp-Button 这条规则。但是什么样的规则才是我们想应用到子元素中的呢? 当想让 LoginForm 元素位于Header元素的右侧时,有人会这样定义:
这个定义没有违反我们制定的任何规则,但是这会使得LoginForm的样式规则不太好被重用。例如:当页面想重复使用LoginForm元素时, 此时重用的的LoginForm就并不具备向右浮动的布局。哎,真糟糕!此时,通常的解决方法就是放宽先前指定的规则,而使得该规则可以应用到该文件所属的命名空间中。也就是说,我们应该如此制定规则:
只要你不随意地设置会影响到子元素盒模型的样式,这个规则还是能很好地起作用的。
我们不允许这样做的原因是在于这样的设置会打破局部修改不应该对全局造成影响的原则。并且使用了上述编码之后,LoginForm.scss 文件就不再是你在需要改变LoginForm组件元素样式时所需要考察的唯一文件了。这时的改变将牵连甚广。那么可不可以的评判标准是什么呢?
为了不依赖于样式的实现细节,我们希望每一个子组件都能有其自己的“界限”。也就是说,对于我们而言,它们是黑盒。相反地,这些子组件之外的便隶属于父组件自身。内、外的区分来自于CSS中的一个最基础的概念: 盒模型.
我的类比很糟糕,但接下来的不是:就像“在一个国家”意味着在其物理边界中一样,我们声明在border外部有一个父元素可以对其内的(直接)子元素的样式造成影响。也就是说,那些会影响定位和尺寸大小的属性,如:position, margin, display, width, float, z-index等在外部(父元素)中声明是被允许的,但是那些会对边界里的内容造成影响的属性,如:border, padding, color, font等的设置就是不被允许的。 是以,按照该推论,下面这样的方式也是不被允许的 :
下面有一些特例:
box-shadow - 作为一个阴影类型,从感观上来说可以作为一个元素的一部分, 也因此其应该包含所有的样式。重申一下,该属性设置的视觉效果能清楚地呈现在边框之外,所以它会进入其父组件的区域内。
color, font 和其他 可被继承的属性 - .myapp-Header > .myapp-LoginForm { color: red } 这样的设置规则会影响到其内子组件的样式。从另一方面来说,它等同于.myapp-Header { color: red; }这样的设置, 而这在我们的规则中是被允许的。
display - 如果子组件使用了 弹性布局,它可能依赖于其根元素上设置了display: flex 的样式。但是,它的父元素可能会使用display: none来隐藏掉这些子元素。
我们需要注意到的是,在这些特例中使用一些级联样式来设置你期望的样式是可行的。是要注意适度使用级联设置即可。 举例来说,让我们来好好分析一下最新的一个例子。在该例子上, 这些判定规则 保证了这些规则如你期望般的工作,即:当元素是可见的时候,.myapp-LoginForm { display: flex } 就是其适用的最具体的一条规则。当其父元素决定隐藏这些元素时,会使用 .myapp-Header-loginBoxHidden > .myapp-LoginBox { display: none }这样的规则。而这样的规则更具体,所以成功隐藏了这些元素。
松散的聚合外来样式
为了避免重复的工作,有时也需要使得不同的元素间共享样式规则。而为了逃避这一工作,你可能会想使用由其他人创建的样式。但无论是哪一种方法,都应该保证不会在代码库中创建不必要且耦合的代码。 作为一个具体的例子,让我们考虑使用Bootstrap--作为一个规避了这些问题的框架--中的一些样式。Bootstrap考虑到了上面所讨论过的问题,也使用了单一的全局命名空间来管理样式,但有以下不好的点:
不管你是否使用,它向外暴露了很多选择器 (对于 3.3.7的版本, 已有2481个) 。 (有趣的旁白:直至IE9,它能处理的选择器也只有4095个。我曾听说有人花了数天进行调试就是想要知道到底发生了什么。)
使用硬编码的类命名方式,如:.btn 和 .table。难以想象的是这些类名被其他开发者或者项目所使用。
尽管如此,我们只能将Bootstrap作为Button 组件的基础使用。
我们要做的并不是像下面这样融合代码:
而是考虑 继承 样式:
这么做的好处在于不会让包括自己在内的所有人产生一个想法,即:你的样式依赖于类名为btn的样式 。Button的样式应该只来源于自身而不会受外部的影响。因此,无论你是使用Bootstrap还是其它框架,亦或者你自己编写代码,都应该遵循一个原则:针对于该类元素的设置不能影响到其它元素。
同样的原则也适用于自己编写的类,此时,你就可以选择使用更合理的类名:
或者 在适当的时候引入所需类 (被大多数预处理器所支持):
最后,所有的CSS预编辑器都支持 mixins 这一概念。
应该注意的是,当使用更友好的框架(如:Bourbon 或者 Foundation)时,他们实际上会做的事只是:定义好了一堆mixin指令供你在需要的地方使用,而不会随意暴露出风格样式。Neat。
结束语
知道这些规则后,才能知道在什么时候可以去打破它
最后,正如前面所提到的一样,当你理解了这些列出的规则之后 ,你就可以判断它对于你来说的价值。例如, 你觉得使用辅助类是有价值的,你就可以这样编码:
这就好比你在测试框架的时候所获得的附加价值值。它让你能更快速地找出可以作为按钮并能被点击的事物。 或者你能做出这样的决断,即:当打破元素间的隔离性所付出的代价很小时可以直接打破,又或者分裂组件的额外的工作是否太多。而我要提醒你的是这是个渐进性的工程且一致性是众多因素中最重要的。只要当你的团队统一风格而且你把事情做好的时候,你才做了正确的事。
最后,可以延伸一篇:
【第762期】CSS最佳实践——成为CSS忍者的十四步(上)
关于本文
译者:@远杨
译文:http://www.zcfy.cc/article/8-simple-rules-for-a-robust-scalable-css-architecture-2082.html