聊聊 React 中的 css-in-js

5,126 阅读5分钟

一、传统class的痛点

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

为什么前端开发者们更青睐于这些css-in-js的方案呢?我觉得关键原因有以下几点: 1、css在设计之初对“组件化”的考虑是不完全的,css直接作用于全局,无法直接作用于某个组件内部的样式。 2、在我们的前端组件中有很多“组件样式随着数据变化”的场景,但传统css应对这种场景很无力。 3、虽然我们可以通过一些规范来规避问题,但是真正用起来太繁琐了,也不利于跨团队的写作。

比如一个遵循BEM规范的表单组件要写成这个样子(例子来源于getbem.com):

<style>
.form { }
.form--theme-xmas { }
.form--simple { }
.form__input { }
.form__submit { }
.form__submit--disabled { }
</style>

<form class="form form--theme-xmas form--simple">
  <input class="form__input" type="text" />
  <input class="form__submit form__submit--disabled" type="submit" />
</form>

实在是太繁琐了!如果这是一段业务代码(注意,是业务代码),那团队中的其他人去读这段代码的时候内心一定是比较崩溃的。

当然,如果是维护基础组件的话,遵守BEM规范还是非常重要的。

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

2-1、有规范约束的className

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

// style.css
.form {
  background-color: withe;
}

.form__input {
  color: black;
}
 

import './stype.css'
const App = props => {
  return (
  	<form className="form">
  	  <input class="form__input" type="text" />
  	</form>
  )
}

这种方式比较适合基础组件库的开发,主要原因是: 1、使用class开发的组件库,业务方可以很方便地由组件样式的覆盖。 2、基础组件库一般由专门的团队开发,命名规范能统一。 3、使用最基础的class,能有效降低组件库的大小。

2-2、inline styling

const App = props => {
  return (
  	<div style={{color: "red"}}>123</div>
  )
}

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

2-3、css-loader(CSS Module)

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

// webpack config
module.exports = {
  module: {
    loaders: [
      { 
      	test: /\.css$/, 
      	loader: 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]' 
      },
    ]
  },
  ...
 }
// App.css

.app {
    background-color: red;
}

.form-item{
  color: red;
}
import styles from './App.css';

const App = props => {
  return (
  	<div className={style.app}>123</div>
  	<div className={style['form-item']}>456</div>
  )
}

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

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

2-4、css-in-js

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

下面以emotion为例,介绍一下css-in-js的方案:

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

const color = 'white'

// 下面这种写法是带标签的模板字符串
// 该表达式通常是一个函数,它会在模板字符串处理后被调用,在输出最终结果前
// 我们可以通过该函数来对模板字符串进行操作处理
// 详细链接 —— https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
const App = props => {
  return (
	<div
	  className={css`
	    padding: 32px;
	    background-color: hotpink;
	    font-size: 24px;
	    border-radius: 4px;
	  `}
	>
	  This is test.
	</div>
  )
}

在开发业务代码的时候,由于维护人员较多且不固定,且代码规模会逐渐增大,不能保证 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

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

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

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

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

四、emotion实现原理

4-1、emotion效果

首先让我们来看一下emotion做了什么:

这是一个使用了emotion的React组件

import React from 'react';
import { css } from 'emotion'

const color = 'white'

function App() {
  return (
    <div className={css`
      padding: 32px;
      background-color: hotpink;
      font-size: 24px;
      border-radius: 4px;
      &:hover {
        color: ${color};
      }
    `}>
      This is emotion test
    </div>
  );
}

export default App;

这是渲染出的html:

<html lang="en">
  <head>
    <title>React App</title>
    <style data-emotion="css">
      .css-czz5zq {
        padding: 32px;
        background-color: hotpink;
        font-size: 24px;
        border-radius: 4px;
      }
    </style>
    <style data-emotion="css">
      .css-czz5zq:hover {
        color: white;
      }
    </style>
  </head>
  <body>
    <div id="root">
      <div class="css-czz5zq">This is React.js test</div>
    </div>
  </body>
</html>

我们可以看到emotion实际上是做了以下三个事情: 1、将样式写入模板字符串,并将其作为参数传入css方法。 2、根据模板字符串生成class名,并填入组件的class="xxxx"中。 3、将生成的class名以及class内容放到<script>标签中,然后放到html文件的head中。

4-2、emotion初始化

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

import createEmotion from 'create-emotion'

export const {
  injectGlobal,
  keyframes,
  css,
  cache,
  //...
} = createEmotion()
let createEmotion = (options: *): Emotion => {
  // 生成emotion cache
  let cache = createCache(options)

  // 用于普通css
  let css = (...args) => {
    let serialized = serializeStyles(args, cache.registered, undefined)
    insertStyles(cache, serialized, false)
    return `${cache.key}-${serialized.name}`
  }

  // 用于css animation
  let keyframes = (...args) => {
    let serialized = serializeStyles(args, cache.registered)
    let animation = `animation-${serialized.name}`
    insertWithoutScoping(cache, {
      name: serialized.name,
      styles: `@keyframes ${animation}{${serialized.styles}}`
    })

    return animation
  }
  
  // 注册全局变量
  let injectGlobal = (...args) => {
    let serialized = serializeStyles(args, cache.registered)
    insertWithoutScoping(cache, serialized)
  }

  return {
    css,
    injectGlobal,
    keyframes,
    cache,
    //...
  }
}

4-3、emotion cache

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

let createCache = (options?: Options): EmotionCache => {
  if (options === undefined) options = {}
  let key = options.key || 'css'
  let stylisOptions

  if (options.prefix !== undefined) {
    stylisOptions = {
      prefix: options.prefix
    }
  }

  let stylis = new Stylis(stylisOptions)

  let inserted = {}
  let container: HTMLElement
  if (isBrowser) {
    container = options.container || document.head
  }

  let insert: (
    selector: string,
    serialized: SerializedStyles,
    sheet: StyleSheet,
    shouldCache: boolean
  ) => string | void

  if (isBrowser) {
    stylis.use(options.stylisPlugins)(ruleSheet)

    insert = (
      selector: string,
      serialized: SerializedStyles,
      sheet: StyleSheet,
      shouldCache: boolean
    ): void => {
      let name = serialized.name
      Sheet.current = sheet
      stylis(selector, serialized.styles)  // 该方法会在对应的selector中添加对应的styles
      if (shouldCache) {
        cache.inserted[name] = true
      }
    }
  }

  const cache: EmotionCache = {
    key,
    sheet: new StyleSheet({
      key,
      container,
      nonce: options.nonce,
      speedy: options.speedy
    }),
    nonce: options.nonce,
    inserted,
    registered: {},
    insert
  }
  return cache
}

4-4、emotion css方法

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

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

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

export const serializeStyles = function(
  args: Array<Interpolation>,
  registered: RegisteredCache | void,
  mergedProps: void | Object
): SerializedStyles {
  // 如果只传入一个参数,那么直接返回
  if (
    args.length === 1 &&
    typeof args[0] === 'object' &&
    args[0] !== null &&
    args[0].styles !== undefined
  ) {
    return args[0]
  }
  
  // 如果传入多个参数,那么就需要merge这些样式
  let stringMode = true
  let styles = ''

  let strings = args[0]
  if (strings == null || strings.raw === undefined) {
    stringMode = false
    styles += handleInterpolation(mergedProps, registered, strings, false)
  } else {
    styles += strings[0]
  }
  // we start at 1 since we've already handled the first arg
  for (let i = 1; i < args.length; i++) {
    styles += handleInterpolation(
      mergedProps,
      registered,
      args[i],
      styles.charCodeAt(styles.length - 1) === 46
    )
    if (stringMode) {
      styles += strings[i]
    }
  }

  // using a global regex with .exec is stateful so lastIndex has to be reset each time
  labelPattern.lastIndex = 0
  let identifierName = ''

  let match
  while ((match = labelPattern.exec(styles)) !== null) {
    identifierName +=
      '-' +
      match[1]
  }

  let name = hashString(styles) + identifierName

  return {
    name,
    styles
  }
}

// 生成对应的样式
function handleInterpolation(
  mergedProps: void | Object,
  registered: RegisteredCache | void,
  interpolation: Interpolation,
  couldBeSelectorInterpolation: boolean
): string | number {
  if (interpolation == null) {
    return ''
  }
  if (interpolation.__emotion_styles !== undefined) {
    return interpolation
  }

  switch (typeof interpolation) {
    case 'boolean': {
      return ''
    }
    case 'object': {
      if (interpolation.anim === 1) {
        cursor = {
          name: interpolation.name,
          styles: interpolation.styles,
          next: cursor
        }

        return interpolation.name
      }
      if (interpolation.styles !== undefined) {
        let next = interpolation.next
        if (next !== undefined) {
          // not the most efficient thing ever but this is a pretty rare case
          // and there will be very few iterations of this generally
          while (next !== undefined) {
            cursor = {
              name: next.name,
              styles: next.styles,
              next: cursor
            }
            next = next.next
          }
        }
        let styles = `${interpolation.styles};`
        if (
          interpolation.map !== undefined
        ) {
          styles += interpolation.map
        }

        return styles
      }

      return createStringFromObject(mergedProps, registered, interpolation)
    }
    case 'function': {
      if (mergedProps !== undefined) {
        let previousCursor = cursor
        let result = interpolation(mergedProps)
        cursor = previousCursor

        return handleInterpolation(
          mergedProps,
          registered,
          result,
          couldBeSelectorInterpolation
        )
      }
      break
    }
    case 'string':
      break
  }

  // finalize string values (regular strings and functions interpolated into css calls)
  if (registered == null) {
    return interpolation
  }
  const cached = registered[interpolation]
  return cached !== undefined && !couldBeSelectorInterpolation
    ? cached
    : interpolation
}

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

export const insertStyles = (
  cache: EmotionCache,
  serialized: SerializedStyles,
  isStringTag: boolean
) => {
  let className = `${cache.key}-${serialized.name}`
  if (cache.inserted[serialized.name] === undefined) {
    let current = serialized
    do {
      let maybeStyles = cache.insert(
        `.${className}`,
        current,
        cache.sheet,
        true
      )
      current = current.next
    } while (current !== undefined)
  }
}

五、一些总结

总体来说,如果是进行基础组件的开发,那么使用“有规范约束”的原生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最香(滑稽)

<style scoped>
.example {
  color: red;
}
</style>

<template>
  <div class="example">hi</div>
</template>