乐趣区

CSS-Solutions

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

扩展阅读

  • CSS Evolution: From CSS, SASS, BEM, CSS Modules to Styled Components
  • The tradeoffs of CSS-in-JS
  • emotion The Next Generation of CSS-in-JS
退出移动版