CSS Modules VS. styled-components,哪个才是解决 CSS 不足之处的更好方案?

6,907 阅读19分钟

CSS 是一门标记语言,用于元素布局及样式定义。它存在很多问题,例如书写效率和维护性低;缺乏模块机制、变量、函数等概念;容易出现全局样式污染和样式冲突等。目前前端社区存在很多解决上述问题的方案,主要包括 CSS Module以及 styled-components💅(CSS-in-JS 的代表)。

styled-components 在我的日常开发中用得很多,并且用得非常顺手。它的 CSS-in-JS 思想以及通过 props 来动态更改样式跟 React 的开发理念一脉相承,并且还基于 React Context API 还提供了自己的的主题切换能力。

但是,这周跟我同事讨论了一下 styled-components。我同事是 styled-components 的反对者,认为用 CSS Modules 就已经很足够了,因为CSS Modules提供了局部作用域和模块功能,配合 Sass / Less 使用完全能达到跟 styled-components 同样的效果。使用 styled-components 虽然 可能 能提升了开发体验,但是它运行时(runtime)的机制却对性能有损耗,可能对用户体验造成负面影响(这里用可能是因为我并没有量化地去比较,感觉这也很难量化)。我自己也并没有很认真地去比较过两者的原理和异同,因此很好奇:这么开发者都在用 styled-components,难道它真的是提升开发体验而降低用户体验的东西吗?

因此,这篇文章就对两者进行了较为详细的介绍和比较(也是为了是通过写文章,来加深自己对两者的理解)。其实比较完之后,发现两者在各自的领域都都已经是很成熟的方案了,因此用哪个方案,就是看开发者偏好以及团队选择了。

先从 CSS 说起

CSS 是一个用于布局和定义样式的语言:它非常容易理解和上手,但要精通它却很难。CSS 的属性互不正交,大量的依赖与耦合难以记忆,规则非常庞杂。如果只使用原生的 CSS 语言去写样式的话,主要可能会遇到以下两个问题:

  • CSS 缺乏没有变量、函数这些概念,也没有模块机制,导致书写效率以及代码的维护性都不高。注意,CSS 的 @import 机制并不算真正的模块机制,因为 @import 是在一个 CSS 文件里面引入另一个 CSS 文件,并且只有执行到 @import 语句的时候才会触发浏览器下载被引入的 CSS 文件。这会对网页加载速度产生不利影响。

  • 复用性低:CSS 缺少抽象的机制,选择器很容易出现重复,不利于维护和复用。

  • 全局污染:CSS 选择器的作用域是全局的,如果两个选择器名称相同,后定义的选择器会覆盖前定义的选择器。此外,不同种类的选择器,例如ID 选择器、类选择器、元素选择器等的权重也不一样,这很容易引起样式相互覆盖或冲突。虽然可以通过差异化类命名的方式来避免全局冲突,但这又会导致类命名的复杂度上升。

为了解决 CSS 存在的这些不足之处,前端社区出现了很多种解决方案。例如,Saas 或者 LessSassLess 都属于 CSS 预处理,即在 CSS 的基础上进行了扩展,增加了一些编程的特性,并且将 CSS 作为目标生成文件。具体来说,Sass / Less 增加了规则、变量、混入、选择器、继承等特性,还引入了模块系统。因此,相比与 CSS,Sass / Less 更像是一门编程语言,可以提升写 CSS 的效率,代码更易于组织和维护。Sass / Less 文件最终都会被编译为 CSS 文件,这样才能被浏览器正常识别。然而,Sass / Less 更多地解决的是上述不足的第一点以及第二点,即通过引入编程语言特性和模块机制提升编写效率,提高可维护性。

CSS Module方案以及 styled-components 方案是社区中比较著名的解决方案,可以较好地解决 CSS 的上述问题。这两者解决问题采用的是两种不同的思路:CSS Module 是通过工程化的方法,加入了局部作用域和模块机制来解决命名冲突的问题。CSS Module 通常会配合 Sass 或者 Less 一起使用。styld-components 是一种 CSS-in-JS 的优秀实践,通过 JS 来声明、抽象样式来提高组件的可维护性,在组件加载时动态地加载样式,并且动态地生成类名避免命名冲突和全局污染。

CSS Modules

CSS 用于描述网页样式,一个典型的网页包含许多元素或组件,例如菜单、按钮、输入框等,这些元素或组件的样式是由单个或多个 CSS 规则决定的,这些规则被包含在一个 CSS 文件当中,并且可供包含该文件的整个网页访问。也就是说。所有 CSS 样式都是全局的,任何一个组件的样式规则,都对整个页面有效。如果希望某些样式仅对页面的某个组件可见,应该怎么办呢?

基本用法

CSS Modules 就是为了解决这种场景而生的,它加入了局部作用域和模块依赖,可以保证某个组件的样式不会影响到其他组件。具体而言,CSS Modules 通过工程化的方法,可以将类名编译为哈希字符串,从而使得每个类名都是独一无二的,不会与其他的选择器重名,由此可以产生局部作用域。CSS Modules 提供各种插件,支持不同的构建工具,包括 Webpack, Browserify, NodeJS 等。其中,WebpackCSS Loader 插件提供了对 CSS Modules 的支持,可以很方便地打开 CSS Modules 功能。以如下 Demo 为例:

module.exports = {
  entry: './src/index.js',

  output: {
    filename: 'index.js',
    path: path.resolve('./dist'),
    libraryTarget: 'umd'
  },

  module: {
    loaders: [
      { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ },
      { test: /\.css$/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader') },
      { test: /\.svg$/, loader: "url-loader?limit=10000&mimetype=image/svg+xml" }
    ]
  },

  postcss: [
    require('autoprefixer-core'),
    require('postcss-color-rebeccapurple')
  ],

  resolve: {
    modulesDirectories: ['node_modules', 'components']
  },

  plugins: [
    new ExtractTextPlugin('style.css', { allChunks: true }),
    new ReactToHtmlPlugin('index.html', 'index.js', {
      static: true,
      template: ejs.compile(fs.readFileSync(__dirname + '/src/template.ejs', 'utf-8'))
    })
  ]
};

其中,跟 CSS Modules 相关的关键代码是

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

其中,开启 CSS Modules 的关键就在于 modules: true,即给所有文件都开启 CSS Modules。modules还可能有以下的值,具体可以参照 css-loader 的 modules 配置项

  • undefined(默认值):所有符合正则表达式 /.module.\w+$/i.test(filename)或者 /.icss.\w+$/i.test(filename)的文件都会开启 CSS Modules;

  • true:所有文件都开启 CSS Modules;

  • false:所有文件都关闭 CSS Modules;

  • string(local, global, pure 或者 icss): 所有文件都关闭 CSS Modules 并且设置 mode 属性的值。

    • local(默认值): CSS Modules 会默认开启局部作用域,所有全局变量都要加上 :global前缀;
    • global: CSS Modules 会默认开启全局作用域,所有局部变量都要加上 :local前缀;
    • pure: 选择器必需包含至少一个局部 class 或者 id;
    • icssicss 只会编译低级别的Interoperable CSS 格式,用于声明 CSS 和其他语言之间的 :import :export依赖项。
  • object: 一个配置对象,默认所有文件都开启 CSS Modules,具体情况根据 modules.auto的值而定。

开启 CSS Modules 之后,所有的类名都会被编译成一个哈希字符串,以下面组件 App.jsx及其样式文件 App.css为例:

// App.css
.appTitle {
    color: red;
}
// APP.jsx
import React from 'react';
import styles from './App.css';

export default () => {
  return (
    <h1 className={styles['appTitle']}>
      Hello World
    </h1>
  );
};

注意,根据 CSS Modules 的官方规范,更推荐以驼峰式的命名方式定义类名,而非 kebab-casing。以上述例子为例,我们把 h1 的类名命名为 appTitle 而非 app-title,这是因为app-title这种命名方式不能用 . 访问法,即:

// 驼峰式
<h1 className={styles['appTitle']}> // 🉑️
<h1 className={styles.appTitle}> // 同样🉑️

// kebab-casing
<h1 className={styles['app-title']}> // 🉑️
<h1 className={styles.app-title}> // 不🉑️,会导致错误

css-loader 默认的哈希算法是[hash:base64],但是可以通过设置 localIdentName的属性来更改哈希算法的规则。上面例子的 webpack 配置设置了localIdentName 的属性是[name]__[local]___[hash:base64:5],那么 h1的类名会被编译为:

<h1 class="App__appTitle__GyYTO">
  Hello World
</h1>

并且 App.css也会被编译为

.App__appTitle__GyYTO {
  color: red;
}

这样一来,类名app-title就被编译为了独一无二App__app-title__GyYTO,并且只对 App组件有效。

全局变量

CSS Modules 允许使用 :global(.className)的语法,声明一个全局规则。凡是这样声明的 class,都不会被编译成哈希字符串。例如,我们在 App.css中加入全局类名 globalTitle 。注意,CSS Modules 还提供一种显式的局部作用域语法 :local(.className),这在 css Loader 设置 modules = local 时等同于 .className


// App.css
.appTitle {
	color: red;
}

:local(.appTitle1) {
  color: yellow;
}


:global(.globalTitle){
	color: green;
}

App.jsx中,就可以以普通 CSS 的写法去引用全局 class 了。此时,渲染的 Hello World 是红色,而 Hello World Again 是绿色,因为它用的是全局变量。

import React from 'react';
import styles from './App.css';

export default () => {
  return (
    <h1 className={styles['appTitle']}>
      Hello World
    </h1>
    <h1 className = "globalTitle">
       Hello World Again
    </h1>
  );
};

此时 App.CSS 被编译为

.App__appTitle__GyYTO {
  color: red;
}

.App__appTitle1__NHgyT {
  color: yellow;
}

// 全局类名不会被编译为哈希类名
.globalTitle {
    color: green;
}

组合 (Composition)

除了局部作用域,CSS Modules 的另一个很重要的特性是组合(Composition),一个选择器可以继承另一个选择器的规则。组合可以发生在同一个 CSS 文件的不同类之间,也可以发生在不同 CSS 文件的不同类之间。后者可以理解为在 CSS 中加入了模块机制

同 CSS 文件中 class 的组合

App.css中建立一个用于定义背景颜色的 class appBackground,并且新建一个继承 appBackground 以及 appTitle 的 class appStyle

// App.css
.appTitle {
    color: red;
}

.appBackground {
    color: blue;
}

.appStyle {
    composes: appTitle appBackground; // 不同类之间用空格隔开
    padding: 8px;
}

注意,composes 仅对局部(local-scoped)且只包含单独的类名的选择器有效,并且可以一个选择器里面可以存在多条 composes 规则,但所有的 composes 规则都必须定义在其他规则的前面。此时,App.css 会被编译为:

// 编译后的 App.css
.App__appTitle__GyYTO {
  color: red;
}

.App__appBackground__NhvyT {
  color: yellow;
}

.App__appStyle__dahOP {
  border: 1px solid black;
}

App.jsx 如下:

import React from 'react';
import styles from './App.css';

export default () => {
  return (
    <h1 className={styles['appStyle']}>
      Hello World
    </h1>
  );
};

那么 h1 的 class 会被编译为

<h1 className="App__appTitle__GyYTO App__appBackground__NhvyT App__appStyle__dahOP">

不同 CSS 文件间 class 的组合

假设除了 App.css 之外,还有一个 another.css,并且App.css继承 another.css其中的规则:

/* another.css */
.ohterBackground {
  background-color: blue;
}

/* App.css */
.appStyle {
  composes: ohterBackground from './another.css';
  color: red;
}

这样,渲染出来的 h1 会有蓝色的背景颜色以及红色的字体颜色。注意,当一个类从不同文件中组合多个类时,被组合类的规则的应用顺序是不可预测的。因此,应该要避免来自不同文件的多个类名中为同一属性定义不同的值。 例如 App.css继承 another.cssanother1.css的两条规则:

/* another.css */
.ohterBackground {
  background-color: blue;
}

/* another1.css */
.ohter1Background {
  background-color: grey;
}

/* App.css */
.appStyle {
  composes: ohterBackground from './another.css';
  composes: ohter1Background from './another1.css'; // 不要这样做,会导致最终的 background-color 不可预测
  color: red;
}

由于 ohterBackground 以及 ohter1Background background-color定义了不同的值。由于 appStyle同时组合了 ohterBackground 以及 ohterBackground1,由于后定义的属性值会覆盖前面定义的同属性的值,这会使得应用了 appStyleh1标签实际的背景颜色会变得不可预测,可能是 blue (如果是 ohterBackground 后应用)或者 red (如果是 ohterBackground1 后应用)。此外,还注意组合不应该形成循环依赖,这会使得 Css Modules 抛错。

// 循环依赖会导致错误,不要这样做!!
/* another.css */
.ohterBackground {
  composes: appStyle from './App.css';
  background-color: blue;
}

/* App.css */
.appStyle {
  composes: ohterBackground from './another.css'; // 形成了循环依赖
  color: red;
}

此外,局部 class 中还可以组合全局 class,例如:

/* global.css */
:global(.globalBackground){
    color: green;
}

/* App.css */
.appStyle {
  composes: globalBackground from './global.css';
  color: red;
}

在 CSS Modules 里面使用变量

在安装 PostCSS 以及 postcss-modules-values 之后,并且把 postcss-loader 加入 webpack 配置之后,在 CSS Modules 使用变量了。例如:

/* colors.css */
@value primary: #BF4040;
@value secondary: #1F4F7F;

.text-primary {
  color: primary;
}

.text-secondary {
  color: secondary;
}

/* breakpoints.css */
@value small: (max-width: 599px);
@value medium: (min-width: 600px) and (max-width: 959px);
@value large: (min-width: 960px);


/* App.css */
/* alias paths for other values or composition */
@value colors: "./colors.css";
/* import multiple from a single file */
@value primary, secondary from colors;
/* make local aliases to imported values */
@value small as bp-small, large as bp-large from "./breakpoints.css";
/* value as selector name */
@value selectorValue: secondary-color;

.selectorValue {
  color: secondary;
}

.header {
  composes: text-primary from colors; // colors 是 "./colors.css" 的别名
  box-shadow: 0 0 10px secondary;
}

@media bp-small {
  .header {
    box-shadow: 0 0 4px secondary;
  }
}
@media bp-large {
  .header {
    box-shadow: 0 0 20px secondary;
  }
}

除了这种方式之外,可以将 CSS ModulesSass / Less 进行组合使用,从而既能拥有 Sass / Less 的 CSS 预处理器的能力(规则、变量、混入、选择器、继承等),又可以拥有 CSS Modules 提供的局部作用域的能力,避免全局污染。

styled-componnets 💅(这个 logo 有点魔性)

介绍完了 CSS Modules,终于轮到 styled-components 💅 了。styled-components 在我的日常开发中用的很多,并且个人感觉的确非常好用,这种 CSS-in-JS 的写法能让组件的样式定义变得很明了且带有语义特性(但也可能会让组件的 tsx 文件变得很长)。styled-components 的基本思想是通过删除样式和组件之间的映射来强制执行最佳实践,同时还拆分了容器组件和展示组件,确保开发人员只能构建小型且集中的组件。

但是,我有同事是 styled-components 的反对者,因为它在运行时引入了 PostCSS,应用启动时编译样式。应充分利用编译期能力,把 CSS 在编译期确定下来,这样才能享受浏览器内核自己的优化。后来我自己查阅相关文章才发现,前端社区早就有很多关于是否应该使用styled-components 的讨论。首先让我们了解什么是 styled-components:

styled-components 以组件的形式来声明样式,让样式也成为组件

Styled Components 的官方网站将其优点归结为:

  • Automatic critical CSSstyled-components 持续跟踪页面上渲染的组件,并自动注入样式。结合使用代码拆分, 可以实现仅加载所需的最少代码。

  • 解决了 class name 冲突styled-components 为样式生成唯一的 class name,开发者不必再担心 class name 重复、覆盖以及拼写的问题。(CSS Modules 通过哈希编码局部类名实现这一点)

  • CSS 更容易移除:使用 styled-components 可以很轻松地知道代码中某个 class 在哪儿用到,因为每个样式都有其关联的组件。如果检测到某个组件未使用并且被删除,则其所有的样式也都被删除。

  • 简单的动态样式:可以很简单直观的实现根据组件的 props 或者全局主题适配样式,无需手动管理多个 classes。(这一点很赞)

  • 无痛维护:无需搜索不同的文件来查找影响组件的样式,无论代码多庞大,维护起来都是小菜一碟。

  • 自动提供前缀:按照当前标准写 CSS,其余的交给 styled-components 处理。

因为 styled-components 做的只是在 runtime 把 CSS 附加到对应的 HTML 元素或者组件上,它完美地支持所有 CSS。 媒体查询、伪选择器,甚至嵌套都可以工作。但是要注意,styled-componentsReact 下的 CSS-in-JS 的实践,因此下面的所有例子的技术栈都是 React

安装

# install with npm
npm install --save styled-components

# install with yarn
yarn add styled-components

基本用法

下面是一个简单的 styled-components 例子。styled.h1 调用后会返回一个 React 组件。 styled-components 会自动生成一个附加到这个 React 组件的名称哈希化后的 class(默认以 sc- 开头),并且把定义的样式与这个 class 相关联。

image.png


import React from 'react';
import styled from 'styled-components';

const ScH1 = styled.h1`
    color: red;
    background-color: blue;
    text-align: center;
    padding: 10px;
`;

export default () => {
  return (
    <ScH1>
        Hello World
    </ScH1>
  );
};

Styled-Components 使用了标记模板文字(tagged template literals)来为组件添加样式。当你定义你的样式时,实际上是在创建一个普通的 React 组件,该组件附加了你的样式。Styled-Components 使用了 stylis 自动为 Css 规则自动加上前缀。

注意,Styled-Components 定义的组件一定要放在组件函数定义之外(对于 Class 类型的组件,不要放在 render 方法内 )。因为在 react 组件的 render 方法中声明样式化的组件,会导致每次渲染都会创建一个新组建。 这意味着 React 将不得不在每个后续渲染中丢弃并重新计算 DOM 子树的那部分,而不是仅仅计算它们之间变化的差异,从而导致性能瓶颈和不可预测的行为。

// ❌ 绝对不要这样写
const Header = () => {
  const Title = styled.h1`
    font-size: 10px;
  `

  return (
    <div>
      <Title />
    </div>
  )
}

// ✅应该要这样写
const Title = styled.h1`
  font-size: 10px;
`

const Header = () => {
  return (
    <div>
      <Title />
    </div>
  )
}

此外,如果 styled-components 的目标是一个简单的 HTML 元素(例如 styled.div),那么 styled-components 将传递所有原生的 HTML AttributesDOM。如果是自定义 React 组件(例如 styled(MyComponent)),则 styled-components 会传递所有的 props

styled-componnets 的动态样式

styled-components 支持通过 props 实现动态样式,并且可以与 TypeScript 配合使用。并且 VsCode 还有一款插件 vscode-styled-components 能识别 styled-components ,并能自动进行 CSS 高亮、补全、纠正等。

image.png

# 与 TypeScript 配合使用
$ npm install @types/styled-components -D

下面例子展示了一个样式化的 Button 接收 primary 属性,并根据该属性调整背景颜色 background 以及 color

import React, {
  ButtonHTMLAttributes
} from 'react';
import styled from 'styled-components';

interface IScButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
    primary?: boolean;
}

const ScWrapper = styled.div`
    margin-top: 12px;
`;

const ScButton = styled.button<IScButtonProps> `
    background: ${props => props.primary ? "blue" : "white"};
    color: ${props => props.primary ? "white" : "blue"};
    border: 2px solid palevioletred;
    border-radius: 3px;
    padding: 0.25em 1em;
`;

export default () => {
  return (
   <ScWrapper>
       <ScButton>Normal</ScButton>
       <ScButton primary>Primary</ScButton>
  </ScWrapper>
  );
};

扩展组件样式

styled-components 不仅能对原生的 element 进行样式定义,也能对组件的样式进行扩展。可以通过 styled()创建一个继承另一个组件样式的新组件。例如,我们想要创建一个继承了上述 ScButton 的新组件 ScExtendedButton

const ScButton = styled.button`
    color: white;
    background-color: blue;
    border: 2px solid palevioletred;
    border-radius: 3px;
    padding: 0.25em 1em;
`;

// 创建一个继承 ScButton 的新组件 ScExtendedButton
const ScExtendedButton = styled(ScButton)`
    color: blue;
    background-color: white;
    margin-top: 1em;
`;

通过这种方式,ScExtendedButton 拥有跟 ScButton相同的 border, border-radius, padding属性,但是多了 margin-top属性,并且覆盖了 ScButton 中的 color, background-color属性。此外,如果我们想要创建一个继承 ScButton的所有样式的 a元素,可以使用 as属性来制定最终渲染的内容(可以是原生的元素或者是自定义组件),例如:

// 创建一个继承 ScButton 的新组件 ScExtendedButton,最终会渲染为 a 元素
const ScExtendedButton = styled(ScButton)`
    color: blue;
    background-color: white;
`;

const ReversedTextButton = (children, ...props) => <Button {...{
    children: children.spilit('').reverse().join(''),
    ...props
  }} />

export default () => {
    return (
        <ScExtendedButton as="a" href="#">
            Extends Link with Button styles
        </ScExtendedButton>
        {/* as 属性可以是自定义组件 */}
        <ScExtendedButton as="ReversedTextButton">
            Extends Component with Button styles
        </ScExtendedButton>
     )
}

扩展第三方组件

只要将传递的 className 属性附加到 DOM 元素,styled-components 就可以在自己创建的或是第三方组件中运行。

// 在自己创建的组件上运行
const Link = ({ className, children }) => (
  // className 属性附加到 DOM 元素
  <a className={className}>
    {children}
  </a>
);

const StyledLink = styled(Link)`
  color: red;
  font-weight: bold;
`;

render(
  <div>
    <Link>Unstyled Link</Link>
    <br />
    <StyledLink>Styled Link</StyledLink>
  </div>
);

我们同样可以扩展第三方组件,例如阿里的企业级中后台组件库 fusion 的 Button 组件,由于它同样把 className 属性附加到渲染的 Dom 元素,因此可以利用 styled()扩展

image.png

import {
    Button
} from '@alifd/next';

const ScButton = styled(Button)`
    margin-top: 12px;
    color: green;
`;

render(
    <div>
        <ScButton>Styled Fusion Button</ScButton>
    </div>
);

对伪元素、伪选择器以及嵌套的支持

由于 styled-components 采用 stylis 作为预处理器,因此提供了对伪元素、伪选择器以及嵌套写法的支持(跟 Les 很类似)。其中,& 指向组件本身:

const ScDiv = styled.div`
   color: blue;

  &:hover {
    color: red; // 被 hover 时的样式
  }

  & ~ & {
    background: tomato; // ScDiv 作为 ScDiv 的 sibling
  }

  & + & {
    background: lime; // 与 ScDiv 相邻的 ScDiv
  }

  &.something {
    background: orange; // 带有 class .something 的 ScDiv
  }

  .something-child & {
    border: 1px solid; // 不带有 & 时指向子元素,因此这里表示在带有 class .something-child 之内的 ScDiv
`;

render(
  <React.Fragment>
    <ScDiv>Hello world!</ScDiv>
    <ScDiv>How ya doing?</ScDiv>
    <ScDiv className="something">The sun is shining...</ScDiv>
    <ScDiv>Pretty nice day today.</ScDiv>
    <ScDiv>Don't you think?</ScDiv>
    <div className="something-else">
      <ScDiv>Splendid.</ScDiv>
    </div>
  </React.Fragment>
)

渲染的结果如图所示:

image.png

通过 .attrs 传递 props 或 attributes

.attrs允许传递静态或动态的 props,或者第三方的 props 给组件。attrs 一般接收函数作为参数,并且该函数的参数是组件接收到的 props,函数的返回值将会与 propsmerge,由此可以得到组件最终的 props。例如:

const ScInput = styled.input.attrs(props => ({
  // 定义静态的 prop
  type: "text",

 // 定义动态的 prop
  size: props.size || "1em"
}))`
  color: palevioletred;
  font-size: 1em;
  border: 2px solid palevioletred;
  border-radius: 3px;

  /* 注意,最终组件的 props 是合并 attrs 返回值的 props 的结果 */
  margin: ${props => props.size};
  padding: ${props => props.size};
`;

render(
  <div>
    <ScInput placeholder="A small text input" />
    <br />
    <ScInput placeholder="A bigger text input" size="2em" />
  </div>
);

注意,在对 styled-componnets 进行包装时,.attrs 的应用顺序是从最里面的样式化的组件到最外面的样式化的组件。因此外层的包装可以对内层的 .attrs 做覆盖。例如:


const ScInputInner = styled.input.attrs(props => ({
  type: "text",
  size: props.size || "1em"
}))`
  border: 2px solid palevioletred;
  margin: ${props => props.size};
  padding: ${props => props.size};
`;

// ScInputInner 的 attrs 将被先应用,然后是这个 ScInputOutter 的 attrs 被应用
const PasswordInput = styled(Input).attrs({
  // 这会覆盖 ScInputInner 的 type: text
  type: "password" 
})`
  /* 同样,这会覆盖 ScInputInner 的 border*/
  border: 2px solid aqua;
`;

主题切换

styled-components 通过导出 <ThemeProvider> 组件从而能支持主题切换。 <ThemeProvider>是基于 React 的 Context API 实现的,可以为其下面的所有 React 组件提供一个主题。在渲染树中,任何层次的所有样式组件都可以访问提供的主题。例如:

// 通过使用 props.theme 可以访问到 ThemeProvider 传递下来的对象
const Button = styled.button`
  font-size: 1em;
  margin: 1em;
  padding: 0.25em 1em;
  border-radius: 3px;
  color: ${props => props.theme.main};
  border: 2px solid ${props => props.theme.main};
`;

// 为 Button 指定默认的主题
Button.defaultProps = {
  theme: {
    main: "palevioletred"
  }
}

const theme = {
  main: "mediumseagreen"
};

render(
  <div>
    <Button>Normal</Button>
    // 采用了 ThemeProvider 提供的主题的 Button
    <ThemeProvider theme={theme}>
      <Button>Themed</Button>
    </ThemeProvider>
  </div>
);

ThemeProvidertheme除了可以接受对象之外,还可以接受函数。函数的参数是父级的 theme对象。此外,还可以通过使用 theme prop 来处理 ThemeProvider 未定义的情况(这跟上面的 defaultProps是一样的效果),或覆盖 ThemeProvider的 theme。例如:

const ScButton = styled.button`
  color: ${props => props.theme.fg};
  border: 2px solid ${props => props.theme.fg};
  background: ${props => props.theme.bg};
`;

const theme = {
  fg: "palevioletred",
  bg: "white"
};

const invertTheme = ({ fg, bg }) => ({
  fg: bg,
  bg: fg
});

render(
  // ThemeProvider 未定义的情况
  <ScButton theme={{
    	fg: 'red',
      bg: 'white'
    }}>Default Theme</ScButton>
  <ThemeProvider theme={theme}>
    <div>
      <ScButton>Default Theme</ScButton>
    	// theme 接收的是一个函数,函数的参数是父级的 theme
      <ThemeProvider theme={invertTheme}>
        <ScButton>Inverted Theme</ScButton>
      </ThemeProvider>
      // 覆盖 ThemeProvider的 theme
      <ScButton theme={{
        fg: 'red',
      	bg: 'white'
        }}>Override Theme</ScButton>
    </div>
  </ThemeProvider>
);

CSS Prop

当不想创建额外的组件,而是只为了应用一些样式时,CSS Prop 可以实现这一点。它适用于普通的 HTML 标签和组件,并支持任何 styled-components 支持的特性,包括基于 props、主题和自定义组件的调整。注意,为了使 CSS Prop生效,需要用到 styled-components 提供的 babel-plugin

<div
  css={`
    background: papayawhip;
    color: ${props => props.theme.colors.text};
  `}
/>


<MyComponent
  css="padding: 0.5em 1em;"
/>

除了上述用法之外,还有一种用法是提取多个 styled-components 组件会用到的共同样式,这样可以减少冗余代码

import styled, {
     css
} from 'styled-components';
import {
    Button as FusionButton
} from '@alifd/next';


const mixinCommonCSS = css`
    margin-top: 12px;
    border: 1px solid grey;
    borde-radius: 4px;
`;

const ScButton = styled.button`
    ${mixinCommonCSS}
    color: yellow;
`;

const ScFusionButton = styled(FusionButton)`
    ${mixinCommonCSS}
    color: blue;
`;

从编译方法来看 styled-components

image.png

只创建样式化组件,但不实例化组件,不会产生额外开销

当在应用中第一次 import styled-components 时,它会创建一个内部计数器变量 counter来计算通过工厂函数(styled())创建的所有组件。当 styled-components 创建一个新组件时,它也会创建内部标识符 componentId。 以下是标识符的计算方式:

// 计算标识符
counter++;
const componentId = 'sc-' + hash('sc' + counter); // 这就是一开提到的附加到组件上的类名 sc- 的计算方式,因为使用了 hash 算法,因此可以确保唯一

创建标识符后,styled-components将新的 HTML <style> 元素插入页面的 <head>(如果它是第一个组件并且该元素尚未插入),并添加带有 componentId 的特殊注释标记到 稍后将使用的元素。 假设生成的 componentIdsc-bdhhai

// 注意这个 data-styled-components
<style data-styled-components>
  /* sc-component-id: sc-bdhhai */
</style>

实例化组件时,传递给 styled() 函数目标的目标组件的 componentId 保存在静态字段中:

StyledComponent.componentId = componentId;
StyledComponent.target = TargetComponent;

因此,当只创建样式化的组件 styled-components时,没有任何性能开销,因为只有在创建组件的时候componentId才会保留在内存中。即使你定义了数百个样式化组件,但是并不使用它们,你得到的只是一个或多个带有几百条注释的 <style> 元素

继承自 BaseStyledComponent类的工厂函数 styled

假设我们用 styled-componnets 创建了一个样式化的 Button组件 ScButton,并实例化了组件

const ScButton = styled.button`
  font-size: ${({ sizeValue }) => sizeValue + 'px'};
  color: coral; 
  padding: 0.25rem 1rem; 
  border: solid 2px coral; 
  border-radius: 3px;
  margin: 0.5rem;
  &:hover {
    background-color: bisque;
  }
`;

ReactDOM.render(
  <ScButton sizeValue={24}>I'm a button</ScButton>,
  document.getElementById('root')
);

BaseStyledComponent有自己的生命周期函数 componentWillMount, 。下面简单介绍下这几个生命周期函数的作用:

ComponnetWillMount

  • 解析 tagged templatestring 类型的 evaluatedStyles
font-size: 24px; // sizeValue 用 props 传递的 24 
color: coral; 
padding: 0.25rem 1rem; 
border: solid 2px coral; 
border-radius: 3px;
margin: 0.5rem;
&:hover {
  background-color: bisque;
}
  • 生成 CSS 对应的类名:类名基于 componentIdevaluatedStyles ,通过 MurmurHash 算法生成,并以 generatedClassNam存在组件的 state中。
const className = hash(componentId + evaluatedStyles);
  • CSS 预处理:styled-components利用了 stylis CSS 预处理器,从而得到有效的 CSS 样式字符串

image.png

const selector = '.' + className;
const cssStr = stylis(selector, evaluatedStyles);

ScButton最终的 CSS 样式字符串如下所示:

.jsZVzX {
  font-size: 24px;
  color: coral;
  padding: 0.25rem 1rem;
  border: solid 2px coral;
  border-radius: 3px;
  margin: 0.5rem;
}

.jsZVzX:hover{
  background-color: bisque;
}

注意,styled-componentsv1(现在已经是 v5) 中使用了 PostCSS,一种非常流行的 CSS-in-JS 和 CSS 工具和构建管道工具,用于转换 CSS。具体来说,使用了 postcss-safe-parser, postcss-nested, inline-style-prefixer,分别用于解析 CSS、取消嵌套(Unnesting)以及自动加上前缀。 PostCSS 也与 AST 一起工作,这意味着我们有一个抽象的 CSS 语法树结构,我们可以随意更改。 就像 Babel 改变 JavaScript 所做的那样。

由于 styled-components可以立即安装和使用,因此所有的 CSS 流程(Pipeline)都是在 runtime 包中。由于 PostCSS的体积过于大,导致 styled-components的 bundle 体积有 21kb(after minimized and gzipd), 并且还带有 AST。

因此从 v2开始,styled-components 的开发团队就用高度专业化,体积小,速度极快的 stylis 替换了 PostCSS,成功把包体积降到了 9kB,并在一次传递中转换 CSS。

  • 将 CSS 样式字符串注入页面: 将CSS 注入上面提到的页面 head<style> 元素,紧跟在组件的注释标记之后:
<style data-styled-components> 
/* sc-component-id: sc-bdhhai */ 
.sc-bdVaJa {} 
.jsZVzX{font-size:24px;color:coral; ... } 
.jsZVzX:hover{background-color:bisque;} 
</style>
  • 渲染:CSS 注入到页面之后,styled-components要做的就是创建一个带有对应 className: sc-bdhhai 的元素了
const TargetComponent = this.constructor.target; // In our case just 'button' string.
const componentId = this.constructor.componentId;
const generatedClassName = this.state.generatedClassName;

return ( 
  <TargetComponent 
    {...this.props} 
    className={this.props.className + ' ' + componentId + ' ' + generatedClassName}
  />
);

componentWillReceiveProps

如果在 button完成 mounted之后更改其 props,如下所示。每次单击按钮时,都会使用递增的 sizeValue 属性调用 componentWillReceiveProps(),并执行与 componentWillMount() 相同的操作:

let sizeValue = 24;

const updateButton = () => {
  ReactDOM.render(
    <ScButton sizeValue={sizeValue} onClick={updateButton}>
      Font size is {sizeValue}px
    </ScButton>,
    document.getElementById('root')
  );
  sizeValue++;
}

updateButton();

点击几次之后,查看生成的 styles,大概是这个样子。可以看到,每个 CSS 类的唯一区别 font-size属性,并且不会删除未使用的 CSS 类。这是由于删除它们会增加性能开销,而保持它们不会。此外,在样式字符串中没有插值的组件被标记为 isStatic并且在 componentWillReceiveProps() 中检查这个标志以跳过相同样式的不必要计算。

<style data-styled-components>
  /* sc-component-id: sc-bdVaJa */
  .sc-bdhhai {} 
  .jsZVzX{font-size:24px;color:coral; ... } .jsZVzX:hover{background-color:bisque;}
  .kkRXUB{font-size:25px;color:coral; ... } .kkRXUB:hover{background-color:bisque;}
  .jvOYbh{font-size:26px;color:coral; ... } .jvOYbh:hover{background-color:bisque;}
  .ljDvEV{font-size:27px;color:coral; ... } .ljDvEV:hover{background-color:bisque;}
</style>

采用 .attrs优化 styled-components

从上面可以看到,采用 font-size: ${({ sizeValue }) => sizeValue + 'px'};这样的插值方法,每次组件重新渲染都会产生新的类。可以将其替换为 attrs属性来提升性能。但是,styled-components的作者也不建议把这种方法用于所有的动态样式,而是所有结果数量减少的动态样式使用 .attrs属性。例如,如果有一个具有可自定义字体大小的组件,或从服务器加载的具有不同颜色的标签列表,则最好使用样式属性 attrs。但是,如果您在一个组件中有多种按钮,例如 default、primary、warn 等,则可以在样式字符串中使用带条件的插值。

const Button = styled.button.attrs({
  style: ({ sizeValue }) => ({ fontSize: sizeValue + 'px' })
})`
  color: coral;
  padding: 0.25rem 1rem; 
  border: solid 2px coral; 
  border-radius: 3px;
  margin: 0.5rem;
  &:hover {
    background-color: bisque;
  }
`;

CSS Modules Vs. styled-components

为了解决 CSS 本身的不足之处,CSS Modules 是编译时的原生 CSS 解决方案,而 styled-components 是基于 CSS-in-JS 理念,运行时的解决方案。前端社区也一直都存在要不要用 CSS-in-JS(典型代表 styled-components)的讨论。styled-components 要解决的很多问题,CSS Modules 也可以解决,并且时机是在编译时而非运行时。反观 styled-components,它的执行时机是在运行时,虽然它的开发团队采取了很多优化措施,但运行时的开销导致的影响是不可避免的。 styled-components 的反对派们的主要观点包括

  • 虽然 styled-components 解决了全局命名空间和样式冲突, 但是 CSS Modules、Shadow DOM 和命名约定很久以前在社区中就解决了这个问题。这不是一个开始使用 styled-components 的充分理由;

  • styled-components 可以让使用样式组件使代码更简洁是一个误区。例如:

// styled-componnets
<TicketName></TicketName>

// CSS Modules
<div className={styles.ticketName}></div>

虽然 TicketName看起来更语义化了,但是这个命名完全取决于写代码的人,如果起了不表意的样式化组件名,反而有副作用。并且这带来的收益很小

  • 虽然 styled-components 提供了扩展样式的能力,但通过 CSS Modules 的组合 (Composition)能力,或者 SASS 继承 mixin @extend都可以做到。这也不是一个开始使用 styled-components 的充分理由;
  • 虽然 styled-components 可以利用 props 对组件进行有条件的样式设置,这很符合 React 体系,并且利用了 JavaScript 的强大功能,然而,这也意味着风格更难解释,并且 CSS 同样也可以做到:
// styled-components
const ScButton = styled.button`
  background: ${props => props.primary ? '#f00' : props.secondary ? '#0f0' : '#00f'};
  color: ${props => props.primary ? '#fff' : props.secondary ? '#fff' : '#000'};
  opacity: ${props => props.active ? 1 : 0};
`;

<ScButton primary />
<ScButton secondary />
<ScButton primary active={true} />
// & 基于 CSS 预处理器的能力
button {
  background: #00f;
  opacity: 0;
  color: #000;
  
  &.primary,
  &.seconary {
    color: #fff;
  }
  &.primary {
    background: #f00;
  }
  &.secondary {
    background: #0f0;
  }
  &.active {
    opacity: 1;
  }
}
  • styled-components 允许在同一个文件中包含样式和 JavaScript。但是将样式和标记塞入一个文件中是一个可怕的解决方案,它不仅使版本控制难以跟踪,而且还很容易写出非常长的 JSX 代码。此外,如果必须在同一个文件中包含 CSS 和 JavaScript,请考虑使用 css-literal-loader,它在构建时使用 extract-text-webpack-plugin 提取 CSS,并使用标准 css loader 配置来处理 CSS。

  • styled-components 能提升开发体验也是一个误区:当样式出现问题时,整个应用程序将因长堆栈跟踪错误而崩溃。而使用 CSS 时,“样式错误”只会错误地呈现元素。此外,无效的样式会被简单地忽略,这可能导致比较难以调试的问题。

  • styled-components 是运行时的方案,这会对前端性能产生不利影响,包括

    • styled-components 无法提取到静态 CSS 文件中(例如使用 extract-text-webpack-plugin),这意味着在 styled-components 解析样式并将它们添加到 DOM 之后,浏览器才能开始解释样式。
    • 缺少单独的文件意味着您无法单独缓存 CSS 和 JavaScript。
    • 所有样式化的组件都被包装在一个额外的 HoC 中,会产生不必要的性能损失。注意,react-css-modules 也有这样的问题,请使用 babel-plugin-react-css-modules

上面这些观点主要想提醒开发者不要盲目去使用 styled-componentsstyled-components 本身是个很优秀的 CSS-in-JS 解决方案,并且有更好的跨平台支持能力。

References:

Less Sass

css-modules

CSS Modules github

styled-components 官方网站

# Styled Components: Enforcing Best Practices In Component-Based Systems

why-i-don-t-like-to-use-styled-components

css-evolution-from-css-sass-bem-css-modules-to-styled-components

styled-components-to-use-or-not-to-use

getting-the-most-out-of-styled-components-7-must-know-features

how-styled-components-works

with-styled-components-into-the-future

stop-using-css-in-javascript-for-web-development