Background

随着前端项目日益复杂,如何构建可维护、可复用、可配置的CSS代码,成了每个前端工程师都需要思考的问题。问题的本质:CSS最初是为了描述网页样式而被提出的,并不具备编程语言的特性,于是在前端走向工程化的道路上,CSS暴露出一些问题拖了后腿:

  • 全局作用域,没有模块的概念,在复杂的系统及多人协作时容易产生样式冲突,难以维护;
  • 缺乏变量、函数等编程语言的特性,不利于常用属性、样式的抽象及复用;
  • 各浏览器及其不同版本对CSS语法支持程度,支持方式不一致,具体表现在是否支持某些功能,同一属性在不同浏览器中属性名不同;
  • 在根据不同的状态渲染样式时(这里称之为State Styling)需要定义多个class,可读性差;

.....

针对这些问题,爱折腾的前端程序员们探索出了各种技术及解决方案。本文简单介绍常用的CSS技术,然后分享两种常见的CSS工程化解决方案,希望可以帮助那些和我一样对这些概念比较模糊的同学对此有个系统的认知。

BEM

BEM(Block__Element--Modifier),是一种CSS命名规范,看个例子:

<body class="scenery">
  <section class="scenery__sky scenery__sky--dusk"></section>
  <section class="scenery__ground"></section>
  <section class="scenery__people"></section>
</body>

scenery对应Block,sky、ground、people对应Element,dusk对应modifier。不难看出BEM的本质其实是把HTML元素的层级关系及元素本身的状态组合起来,形成元素独有的className,旨在解决CSS全局作用域引发的样式冲突的问题。

但BEM毕竟是一种规范,不是框架。Block,Element,Modifier的命名都需要开发者思考,引用某位大牛说过的话:“命名和缓存失效是计算机领域最难的两件事情”,可见BEM会增加开发者的工作量。另外,在HTML结构复杂时,BEM形式的className会很长,可读性很差,且增加了代码文件的体积。

CSS Preprocessor

CSS Preprocessor(CSS预处理器)是一类旨在增强CSS语言功能,从而帮助开发者写出可复用,可维护的样式代码的CSS框架。主流的CSS Preprocessor有:Sass,Less,Stylus,都是以DSL(Sass: .scss/.sass, Less: .less, Stylus: .styl)的形式为开发者提供更强大的语言特性,再编译为浏览器能看懂的.css文件。

Sass

Sass(Syntactically Awesome Style sheets)号称世界上最成熟,功能最强大的CSS Preprocessor。无可厚非,Sass有着庞大的用户群体,活跃的社区和详细的文档是它的优势之一。最初基于Ruby,后来衍生了libSass,DartSass,使得Sass编译速度更快。Sass功能强大,为CSS扩充了变量,Mixin,继承,数学运算等编程语言功能,优化了CSS本身的语法,比如适当地使用嵌套可以使样式结构更清晰;提供不额外产生http请求的import,还提供了一系列功能强大的内置函数。

Less

Less(Leaner Style Sheets)基于JS,它的设计理念是尽可能类似CSS的语法以及函数式编程。Less甚至是向后兼容CSS的,这意味着在迁移老项目到Less时可以直接把CSS代码复制到.less文件中,当然还是要利用Less提供的功能做出改动,但这无疑减少了工作量。所以Less上手快,但相对地Less的功能较弱,比如不提供类似Sass中的@function功能,Mixin在需要返回值的情景下并不适用;又比如Less的extend功能实际上是把被Extend对象的样式复制到目标对象中,而不是像Sass那样为多个class定义同一个样式,导致产生冗余代码。如:

/* Less Code */
.header {
  padding: 2px;
  font-weight: bold;
}

h1 {
  .header; /* Extends .header styles */
  font-size: 42px;
}
h2 {
  .header; /* Extends .header styles */
  font-size: 36px;
}

编译结果:

.header{
  padding: 2px;
  font-weight: bold;
}
h1 {
  padding: 2px;
  font-weight: bold;
  font-size: 42px;
}
h2 {
  padding: 2px;
  font-weight: bold;
  font-size: 36px;
}

Stylus

Stylus基于NodeJS, 在适当贴近CSS语法的同时提供更加强大的功能,看上去像是Sass和Less的结合体。Stylus的语法是python风格,提倡简洁,所以推荐不写大括号,当然,这是可选的。看一段Stylus的代码:

border-radius()
  -webkit-border-radius: arguments
  -moz-border-radius: arguments
  border-radius: arguments

body
  font: 12px Helvetica, Arial, sans-serif

a.button {
  border-radius: 5px
}

可以看到Stylus在定义函数,变量或者mixin的时候甚至不需要像sass那样加上$,@等符号,语法十分简洁。

总得来说,Sass有详细的文档,成熟的社区以及相对强大的功能和编译速度;Less向后兼容CSS,学习曲线平缓,旧项目迁移难度低,但是功能没有Sass和Stylus强大;Stylus功能最强大,语法最简洁,但文档可读性较差。

PostCSS

另外再说一下PostCSS,PostCSS本质上是一个平台,平台本身并没有对CSS做任何增强,只是将CSS解析成AST提供给插件,所有需要的功能都可以通过插件灵活地订制(babel也是这种思想),比如Autoprefixer,类似于babel-preset-env的PostCSS Preset Env,CSS Modules,stylelint等等,甚至可以自己写插件。

所以用PostCSS替代以上三者也是可以的,即需要哪些语法功能就去找到对应的PostCSS插件,如:

  • postcss-partial-import
  • postcss-advanced-variables
  • postcss-nested

...

CSS Modules

CSS Modules是一种CSS模块化规范:通过为CSS Rule生成独一无二的class name,使得每一个CSS Module下的CSS Rule默认都是locally,当然也可以声明global的rule。CSS Module export出local class name与global class name的map:

/* style.css */
.className {
  color: green;
}
import styles from "./style.css";
// import { className } from "./style.css";

element.innerHTML = '<div class="' + styles.className + '">';

另外还支持同一module或不同module中的CSS Rule之间的composite,提升了样式可复用性。

常用的实现有webpack的css-loader,以及针对React优化的HOC版本react-css-modules。

// css-loader
{
  test: /\.css$/,
  loader: 'style!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]' 
}
// react-css-modules

import React from 'react';
import CSSModules from 'react-css-modules';
import styles from './table.css';

class Table extends React.Component {
    render () {
        return <div styleName='table'>
            <div styleName='row'>
                <div styleName='cell'>A0</div>
                <div styleName='cell'>B0</div>
            </div>
        </div>;
    }
}

export default CSSModules(Table, styles);

CSS-IN-JS

CSS-IN-JS也是一种CSS工程化解决方案,核心思想在于完全由JS托管CSS,借助JS的模块,变量,函数等概念来提升CSS代码的可维护性,可复用性。常用的实现有:styled-components,glamorou,emotion等。

// styled-components

const Container = styled.div`
  text-align: center;
  color: ${props => props.color};
  
`
render(
  <Container>
    Test Container
  </Container>
);
// emotion

import { css, jsx } from '@emotion/core'

const color = 'white'

render(
  <div
    css={css`
      padding: 32px;
      background-color: hotpink;
      font-size: 24px;
      border-radius: 4px;
      &:hover {
        color: ${color};
      }
    `}
  >
    Hover to change color.
  </div>
)

可以看到styled-components和emotion都使用了ES6的Tagged Templates语法分别调用styled、css函数,拿styled函数举例,上述代码会被编译成类似下面的代码:

const Container = styled(
  'div',
  ['css-Container-duiy4a'], // generated class names
  [props => props.color], // dynamic values
  function createStyledRules (x0) {
    return [`.css-Container-duiy4a { text-align: center; color:${x0} }`]
  }
)

render时,styled将执行dynamic values中的函数,赋予其最新的props。然后调用createStyledRules并传入dynamic values的结果,最后把createStyledRules生成的样式插入stylesheet中,再将generated class names赋给div的className属性。

CSS-IN-JS库的trade-off在于runtime性能,因为可能要在runtime做解析模板字符串,根据props动态生成样式,调用hash算法生成独特的css classname等操作。不同的库性能差异就体现在对这些操作的优化措施,以及尽可能地把这些操作提前到build time做。

Solutions

实际项目中的CSS解决方案是“因地制宜”的,因为怎么处理CSS,是由实际需求和项目中其他技术决定的。比如React项目会用到react-css-modules;结合React HOC的形式使代码更简洁;Vue项目会用到vue-loader、vue-style-loader;选用不同的CSS-IN-JS库,如styled-components,emotion等。

尽管存在差异,但CSS解决方案大致可以分为两种:传统的CSS,CSS-IN-JS。

Traditional

考虑到可维护性和可复用性,我们需要引入一种CSS Preprocessor,具体的选择可以参考上文对Sass、Less、Stylus的概述,这里以Sass为例。然后利用sass的partial功能合理地组织样式代码目录结构,比如:

sass/ 
| 
|– base/ 
|   |– _reset.scss       # Reset/normalize 
|   |– _typography.scss  # Typography rules 
|   ...                  # Etc… 
| 
|– components/ 
|   |– _buttons.scss     # Buttons 
|   |– _carousel.scss    # Carousel 
|   |– _cover.scss       # Cover 
|   |– _dropdown.scss    # Dropdown 
|   |– _navigation.scss  # Navigation 
|   ...                  # Etc… 
| 
|– helpers/ 
|   |– _variables.scss   # Sass Variables 
|   |– _functions.scss   # Sass Functions 
|   |– _mixins.scss      # Sass Mixins 
|   |– _helpers.scss     # Class & placeholders helpers 
|   ...                  # Etc… 
| 
|– layout/ 
|   |– _grid.scss        # Grid system 
|   |– _header.scss      # Header 
|   |– _footer.scss      # Footer 
|   |– _sidebar.scss     # Sidebar 
|   |– _forms.scss       # Forms 
|   ...                  # Etc… 
| 
|– pages/ 
|   |– _home.scss        # Home specific styles 
|   |– _contact.scss     # Contact specific styles 
|   ...                  # Etc… 
| 
|– themes/ 
|   |– _theme.scss       # Default theme 
|   |– _admin.scss       # Admin theme 
|   ...                  # Etc… 
| 
|– vendors/ 
|   |– _bootstrap.scss   # Bootstrap 
|   |– _jquery-ui.scss   # jQuery UI 
|   ...                  # Etc… 
| 
| 
`– main.scss             # primary Sass file

并在main.scss中import这些partial。

此外再考虑样式代码的build过程,结合webpack使用的话需要使用sass-loader,css-loader,style-loader等,以下配置仅供参考:

  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        include: path.resolve(__dirname, 'src'),
        use: [
          'style-loader',
          'css-loader',
          'sass-loader'
        ]
      }
      // ...other rules
    ]
  }

考虑到样式代码的code split及缓存策略,在生产模式下一般会把style-loader替换成MiniCssExtractPlugin,这样可以将css代码单独build成文件,而不是在runtime时以<style></style>的形式insert到document中去。

  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        include: path.resolve(__dirname, 'src'),
        use: [
          process.env.NODE_ENV !== 'production'
            ? 'style-loader'
            : MiniCssExtractPlugin.loader,
          'css-loader',
          'sass-loader'
        ]
      }
      // ...other rules
    ]
  }

最后,可能你的项目需要一些额外的功能,比如使用了一些浏览器兼容程度较差的CSS语法需要转译成兼容的语法,这种情况下你还需要引入postcss及相关插件,如:

  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        include: path.resolve(__dirname, 'src'),
        use: [
          process.env.NODE_ENV !== 'production'
            ? 'style-loader'
            : MiniCssExtractPlugin.loader,
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              ident: 'postcss',
              sourceMap: true,
              plugins: [
                postcssPresetEnv({
                  browsers: BROWSERSLIST,
                }),
              ],
            }
          },
          'sass-loader'
        ]
      }
      // ...other rules
    ]
  }

CSS-IN-JS

css-in-js方案最重要的莫过于选择一个合适的库。styled-components、emotion、glamorou、JSS......,根据自身项目的业务场景,选用的MVVM框架种类(React or Vue or Angular),开发团队水平等因素选择最适合团队的css-in-js库,既能提升开发效率又能减小迁移风险。

Package As Object As Tagged Templates SSR RN Support Agnostic Dynamic Babel plugins Bindings
emotion react-emotion, preact-emotion
fela react-fela native-fela preact-fela inferno-fela
jss react-jss styled-jss
rockey rockey-react
styled-components
aphrodite
csx
glam
glamor
glamorous
styletron styletron-react
aesthetic
j2c

目前css-in-js还是有一定局限的:对于React应用较为友好,虽然很多库有Agnostic的版本,另外还有针对vue的styled-components-vue,emotion-vue等,但在功能和写法上都不如结合React使用。

另外,组织好目录结构、抽象可复用代码对CSS-IN-JS同样适用,可参考上文Traditional方案中的目录结构和粒度。CSS-IN-JS在这方面可以做得更好,因为复用的粒度可以上升到组件级别。

无论对传统方案还是CSS-IN-JS方案,都可以通过服务端渲染提取critical css以提升首屏渲染速度。大致思路是根据用户访问的路由加载对应的页面,通过React的context api获取页面对应的样式并以style标签的形式插入到html文档的head中去,系统内跳转时交给client端控制,具体可以参考isomorphic-style-loader的实现。

一些感想

CSS Solutions是会随着各种新技术的出现而不断变化的,很多技术往往都是源自于某位开发者的灵光一现在社区提出了某个思想,一些赞同的人可能就会尝试给出具体的实现。所以当我们哪天灵光一现时,千万不要就只是想想,勇敢地去与他人分享或者尝试去实现,即使失败了也能学到很多东西。

扩展阅读


simon_z
254 声望12 粉丝