一、传统class的痛点
随着React、Vue等支持组件化的MVVM前端框架越来越流行,在js中直接编写css的技术方案也越来越被大家所接受。
为什么前端开发者们更青睐于这些css-in-js的方案呢?我觉得关键原因有以下几点:
- css在设计之初对“组件化”的考虑是不完全的,css直接作用于全局,无法直接作用于某个组件内部的样式。
- 在我们的前端组件中有很多“组件样式随着数据变化”的场景,但传统css应对这种场景很无力。
- 虽然我们可以通过一些规范来规避问题,但是真正用起来太繁琐了,也不利于跨团队的写作。
比如一个遵循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 }
这种方式比较适合基础组件库的开发,主要原因是:
- 使用class开发的组件库,业务方可以很方便地由组件样式的覆盖。
- 基础组件库一般由专门的团队开发,命名规范能统一。
- 使用最基础的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实际上是做了以下三个事情:
- 将样式写入模板字符串,并将其作为参数传入
css
方法。 - 根据模板字符串生成class名,并填入组件的
class="xxxx"
中。 - 将生成的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