React css-in-js

avatar
前端 @北京字节跳动科技有限公司

一、传统class的痛点

随着React、Vue等支持组件化的MVVM前端框架越来越流行,在js中直接编写css的技术方案也越来越被大家所接受。

为什么前端开发者们更青睐于这些css-in-js的方案呢?我觉得关键原因有以下几点:

  1. css在设计之初对“组件化”的考虑是不完全的,css直接作用于全局,无法直接作用于某个组件内部的样式。
  2. 在我们的前端组件中有很多“组件样式随着数据变化”的场景,但传统css应对这种场景很无力。
  3. 虽然我们可以通过一些规范来规避问题,但是真正用起来太繁琐了,也不利于跨团队的写作。

比如一个遵循BEM规范的表单组件要写成这个样子:

1  <style>
2      .form { }
3      .form--theme-xmas { }
4      .form--simple { }
5      .form__input { }
6      .form__submit { }
7      .form__submit--disabled { }
8  </style>
9  <form class="form form--theme-xmas form--simple">
10   <input class="form__input" type="text" />
11   <input class="form__submit form__submit--disabled" type="submit" />
12 </form>

实在是太繁琐了!如果这是一段业务代码(注意,是业务代码),那团队中的其他人去读这段代码的时候内心一定是比较崩溃的。当然,如果是维护基础组件的话,遵守BEM规范「块(block)、元素(element)、修饰符(modifier)」还是非常重要的。

二、React中编写css的几种方式

2-1、有规范约束的className

使用一些命名规范(比如BEM规范)来约束className,比如下面这种:

1  // style.css
2  .form {
3    background-color: white;
4  }
5  .form__input {
6    color: black;
7  }
8 
9  import './stype.css'
10 const App = props => {
11   return (
12     <form className="form">
13       <input class="form__input" type="text" />
14     </form>
15   )
16 }

这种方式比较适合基础组件库的开发,主要原因是:

  1. 使用class开发的组件库,业务方可以很方便地由组件样式的覆盖。
  2. 基础组件库一般由专门的团队开发,命名规范能统一。
  3. 使用最基础的class,能有效降低组件库的大小。

2-2、inline styling

1  const App = props => {
2    return (
3      <div style={{color: "red"}}>123</div>
4    )
5  }

这种方式是JSX语法自带的设置style的方法,会渲染出来内联样式,它有一个好处是可以在style中使用一些全局变量(但实际上,less等css预处理语言也是支持的)。另外,如果你只是要调一下组件的margin,这种写法也是代码量最小的写法。

2-3、css-loader(CSS Module)

使用webpack的css-loader可以在打包项目的时候指定该样式的scope,比如我们可以这样打包:

1  // webpack config
2  module.exports = {
3    module: {
4      loaders: [
5        { 
6          test: /\.css$/, 
7          loader: 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]' 
8        },
9      ]
10   },
11   ...
12  }
1  // App.css
2  .app {
3      background-color: red;
4  }
5  .form-item{
6    color: red;
7  }
1  import styles from './App.css';
2  const App = props => {
3    return (
4      <div className={style.app}>123</div>
5      <div className={style['form-6  item']}>456</div>
6    )
7  }

这样.app就会被编译为.App__app___hash这样的格式了。这种方式是借助webpack实现将组件内的css只作用于组件内样式,相比于直接写inline styling也是一个不错的解决方案。

但使用style['form-item']这种形式去className的值(并且我们单独编写css文件时一般也都会使用“-”这个符号),我觉得不少开发者会觉得很尴尬……

另外虽然webpack支持“-”和驼峰互相转换,但是在实际开发中,如果面对一个样式比较多的组件,在css文件中使用“-”然后在js组件中使用驼峰也是有一定的理解成本的。

2-4、css-in-js

顾名思义,css-in-js是在js中直接编写css的技术,也是react官方推荐的编写css的方案,在 github.com/MicheleBert… 这个代码仓库中我们可以看到css-in-js相关的package已经有60多个了。
下面以emotion为例,介绍一下css-in-js的方案:

1  import { css, jsx } from '@emotion/core'
2  const color = 'white'
3  // 下面这种写法是带标签的模板字符串
4  // 该表达式通常是一个函数,它会在模板字符串处理后被调用,在输出最终结果前
5  // 我们可以通过该函数来对模板字符串进行操作处理
6  // 详细链接 —— https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
7  const App = props => {
8    return (
9    <div
10     className={css`
11       padding: 32px;
12       background-color: hotpink;
13       font-size: 24px;
14       border-radius: 4px;
15     `}
16   >
17     This is test.
18   </div>
19   )
20 }

在开发业务代码的时候,由于维护人员较多且不固定,且代码规模会逐渐增大,不能保证 css 不会交叉影响,所以我们不能只通过规范来约束,而是要通过 css-in-js 这样的方案来解决 css 交叉影响问题。

三、css-in-js方案比较

我们选取了 github.com/MicheleBert… 仓库中支持功能全面且月下载量较多的几个css-in-js方案进行一下比较(其实它们在使用的时候都差距不大,主要是实现原理以及支持的特性有一些不太一样)

package star gzip size feature
styled-components 17306 12.5kB Automatic Vendor Prefixing、Pseudo Classes、Media Queries
emotion 4101 5.92kB (core) Automatic Vendor Prefixing、Pseudo Classes、Media Queries、Styles As Object Literals、Extract CSS File
radium 6372 23.3kB Automatic Vendor Prefixing、Pseudo Classes、Media Queries、Styles As Object Literals
aphrodite 4175 7.23kB Automatic Vendor Prefixing、Pseudo Classes、Media Queries、Styles As Object Literals、Extract CSS File
jss 5900 6.73kB Automatic Vendor Prefixing、Pseudo Classes、Media Queries、Styles As Object Literals、Extract CSS File

从体积来看:emotion的体积是最小的。

从技术生态环境(以及流行程度):styled-components的star最多,文档相对来讲也是最完善的。

从支持的特性来看:emotion、aphrodite、jss支持的特性是最多的。

所以新人可以尝试接触styled-components,综合来看emotion是一个相当不错的选择。

我们团队其实很早就开始使用React + emotion进行前端开发了。当时选择emotion主要的考虑就是它拥有最全面的功能,以及在当时的css-in-js方案中相对最小的体积

而且emotion是为数不多的支持source-map的css-in-js框架之一。

四、emotion实现原理简介

4-1、emotion效果

首先让我们来看一下emotion做了什么,这是一个使用了emotion的React组件:

1  import React from 'react';
2  import { css } from 'emotion'
3  const color = 'white'
4  function App() {
5    return (
6      <div className={css`
7        padding: 32px;
8        background-color: hotpink;
9        font-size: 24px;
10       border-radius: 4px;
11       &:hover {
12         color: ${color};
13       }
14     `}>
15       This is emotion test
16     </div>
17   );
18 }
19 export default App;

这是渲染出的html:

1  <html lang="en">
2    <head>
3      <title>React App</title>
4      <style data-emotion="css">
5        .css-czz5zq {
6          padding: 32px;
7          background-color: hotpink;
8          font-size: 24px;
9          border-radius: 4px;
10      }
11    </style>
12    <style data-emotion="css">
13      .css-czz5zq:hover {
14        color: white;
15      }
16    </style>
17  </head>
18  <body>
19    <div id="root">
20      <div class="css-czz5zq">This is React.js test</div>
21    </div>
22   </body>
23 </html>

我们可以看到emotion实际上是做了以下三个事情:

  1. 将样式写入模板字符串,并将其作为参数传入css方法。
  2. 根据模板字符串生成class名,并填入组件的class="xxxx"中。
  3. 将生成的class名以及class内容放到<style>标签中,然后放到html文件的head中。

4-2、emotion初始化

首先我们可以看到,在emotion实例化的时候(也就是我们在组件中import { css } from 'emotion'的时候),首先调用了create-emotion包中的createEmotion方法,这个方法的主要作用是初始化emotion的cache(用于生成样式并将生成的样式放入<head>中,后面会有详细介绍),以及初始化一些常用的方法,其中就有我们最常使用的css方法。

1  import createEmotion from 'create-emotion'
2  export const {
3    injectGlobal,
4    keyframes,
5    css,
6    cache,
7    //...
8  } = createEmotion()
9  ```
10 ```ts
11 let createEmotion = (options: *): Emotion => {
12   // 生成emotion cache
13   let cache = createCache(options)
14   // 用于普通css
15   let css = (...args) => {
16     let serialized = serializeStyles(args, cache.registered, undefined)
17     insertStyles(cache, serialized, false)
18     return `${cache.key}-${serialized.name}`
19   }
20   // 用于css animation
21   let keyframes = (...args) => {
22     let serialized = serializeStyles(args, cache.registered)
23     let animation = `animation-${serialized.name}`
24     insertWithoutScoping(cache, {
25       name: serialized.name,
26       styles: `@keyframes ${animation}{${serialized.styles}}`
27     })
28     return animation
29   }
30   
31   // 注册全局变量
32   let injectGlobal = (...args) => {
33     let serialized = serializeStyles(args, cache.registered)
34     insertWithoutScoping(cache, serialized)
35   }
36   return {
37     css,
38     injectGlobal,
39     keyframes,
40     cache,
41     //...
42   }
43 }

4-3、emotion cache

emotion的cache用于缓存已经注册的样式,也就是已经放入head中的样式。在生成cache的时候,使用一款名为Stylis的CSS预编译器对我们传入的序列化的样式进行编译,同时它还生成了插入样式方法(insert)。

1  let createCache = (options?: Options): EmotionCache => {
2    if (options === undefined) options = {}
3    let key = options.key || 'css'
4    let stylisOptions
5    if (options.prefix !== undefined) {
6      stylisOptions = {
7        prefix: options.prefix
8      }
9    }
10   let stylis = new Stylis(stylisOptions)
11   let inserted = {}
12   let container: HTMLElement
13   if (isBrowser) {
14     container = options.container || document.head
15   }
16   let insert: (
17     selector: string,
18     serialized: SerializedStyles,
19     sheet: StyleSheet,
20     shouldCache: boolean
21   ) => string | void
22   if (isBrowser) {
23     stylis.use(options.stylisPlugins)(ruleSheet)
24     insert = (
25       selector: string,
26       serialized: SerializedStyles,
27       sheet: StyleSheet,
28       shouldCache: boolean
29     ): void => {
30       let name = serialized.name
31       Sheet.current = sheet
32       stylis(selector, serialized.styles)  // 该方法会在对应的selector中添加对应的styles
33       if (shouldCache) {
34         cache.inserted[name] = true
35       }
36     }
37   }
38   const cache: EmotionCache = {
39     key,
40     sheet: new StyleSheet({
41       key,
42       container,
43       nonce: options.nonce,
44       speedy: options.speedy
45     }),
46     nonce: options.nonce,
47     inserted,
48     registered: {},
49     insert
50   }
51   return cache
52 }

4-4、emotion css方法

这是emotion中比较重要的方法,它其实是调用了serializeStyles方法来处理css方法中的参数,然后使用insertStyles方法将其插入html文件中,最后返回class名,然后我们在组件中使用<div className={css('xxxxx')}></div>的时候就能正确指向对应的样式了。

1  let css = (...args) => {
2      let serialized = serializeStyles(args, cache.registered, undefined)
3      insertStyles(cache, serialized, false)
4      return `${cache.key}-${serialized.name}`
5  }

serializeStyles方法是一个比较复杂的方法,它的主要作用是处理css方法中传入的参数,生成序列化的class。

1  export const serializeStyles = function(
2    args: Array<Interpolation>,
3    registered: RegisteredCache | void,
4    mergedProps: void | Object
5  ): SerializedStyles {
6    // 如果只传入一个参数,那么直接返回
7    if (
8      args.length === 1 &&
9      typeof args[0] === 'object' &&
10     args[0] !== null &&
11     args[0].styles !== undefined
12   ) {
13     return args[0]
14   }
15  
16   // 如果传入多个参数,那么就需要merge这些样式
17   let stringMode = true
18   let styles = ''
19   let strings = args[0]
20   if (strings == null || strings.raw === undefined) {
21     stringMode = false
22     styles += handleInterpolation(mergedProps, registered, strings, false)
23   } else {
24     styles += strings[0]
25   }
26   // we start at 1 since we've already handled the first arg
27   for (let i = 1; i < args.length; i++) {
28     styles += handleInterpolation(
29       mergedProps,
30       registered,
31       args[i],
32       styles.charCodeAt(styles.length - 1) === 46
33     )
34     if (stringMode) {
35       styles += strings[i]
36     }
37   }
38  // using a global regex with .exec is stateful so lastIndex has to be reset each time
39   labelPattern.lastIndex = 0
40   let identifierName = ''
41   let match
42   while ((match = labelPattern.exec(styles)) !== null) {
43     identifierName +=
44       '-' +
45       match[1]
46   }
47   let name = hashString(styles) + identifierName
48   return {
49     name,
50     styles
51   }
52 }
53 // 生成对应的样式
54 function handleInterpolation(
55   mergedProps: void | Object,
56   registered: RegisteredCache | void,
57   interpolation: Interpolation,
58   couldBeSelectorInterpolation: boolean
59 ): string | number {
60  // ...
61 }
62

insertStyles方法其实比较简单,首先读取cache中是否insert了这个style,如果没有,则调用cache中的insert方法,将样式插入到head中。

1  export const insertStyles = (
2    cache: EmotionCache,
3    serialized: SerializedStyles,
4    isStringTag: boolean
5  ) => {
6    let className = `${cache.key}-${serialized.name}`
7    if (cache.inserted[serialized.name] === undefined) {
8      let current = serialized
9      do {
10       let maybeStyles = cache.insert(
11         `.${className}`,
12         current,
13         cache.sheet,
14         true
15       )
16       current = current.next
17     } while (current !== undefined)
18   }
19 }

五、一些总结

总体来说,如果是进行基础组件的开发,那么使用“有规范约束”的原生css(比如遵守BEM规范的css),或者less之类的预处理语言会比较合适,这能最大幅度地减小组件库的体积,也能为业务方提供样式覆盖的能力。

如果是进行业务开发,个人比较推荐css-in-js的方案,因为它不仅能够做到在组件中直接编写css,同时也能够直接使用组件中的js变量,能有效解决“组件样式随着数据变化”的问题。另外,在业务开发中,由于迭代速度快,开发人员流动性相对大一些,我们直接使用规范对css进行约束会有一定的风险,当项目规模逐渐变大后代码的可读性会很差,也会出现css互相影响的情况。

另外使用CSS Module在业务开发中也是一种不错的方案,但一般大部分前端开发者会使用“-”来为样式命名,但放到组件中就只能使用style['form-item']这样的方式去引用了,我个人是不太喜欢这种风格的写法的。

不过没有一项技术是能解决全部问题的,针对不同的场景选择最合适的技术才是最优解。如果团队中还未使用这些技术,并且在开发组件的样式时遇到了文中所述的“传统css在组件开发中的痛点”,那么我建议去尝试一下css-in-js或者css module,具体选择何种方案就看团队成员更能接受哪种写法了。如果已经使用了css-in-js或者css module,那么继续使用即可,这些都是能够cover现有组件开发的场景的。

六、写在最后

目前主流的前端框架,像vue和angular都针对css的作用域进行了额外的处理,比如vue的scoped,而react这里则是将css作用域处理完全交给了社区,也就出现了各种各样的css-in-js框架,虽说这两种做法其实没什么高下之分,但就个人观感来看,用来用去还是觉得vue sfc中的scoped最香(滑稽)

1  <style scoped>
2  .example {
3    color: red;
4  }
5  </style>
6  <template>
7    <div class="example">hi</div>
8  </template>

作者shadowings-zy