阅读 593

CSS Modules 特性与项目实践

CSS Modules 本身比较简单,还要写它的原因是我发现网上很多关于 CSS Modules 的教程或实践文章都已经过时,会存在某些使用方式现今已不可行的情况。可前往 css-modules-demo 查看本文中所有示例完整代码。

特性

CSS Modules 比较简单,先介绍一下 CSS Modules 常用特性。 在此之前先做一个约定,以 .module.css 结尾的 css 文件表示 CSS Modules,其他 .css 文件仅视为普通的 css 模块,这也是很多项目中的约定。

局部/全局作用域

由于 CSS 的规则都是全局生效的,所以如果希望一个类名(.class)需要实现“局部作用域”的效果就必须保证类名是唯一的。

  • 使用类选择器语法(.className)或 :local 伪类将类声明为局部作用域类,类名将被编译成一个唯一的哈希类名(哈希字符串,如_1wtd1y1DR22bj8P0JYV7nH)。值得注意的是,同一个局部类名被使用多次,编译后的类名都是相同的。
  • 使用 :global 伪类将声明为一个全局类名,编译后类名不会改变。
/* 局部作用域 */
.title {
  color: red;
}
/* 等效于:
:local(.title) {
  color: red;
}
 */

/* 全局作用域 */
:global(.title) {
  color: green;
}
复制代码

使用:

import React from 'react';
import styles from "./scope.module.css";

const Demo = () => {
  return (
    <div>
      <h3 className={styles.title}>局部作用域</h3>
      <h3 className="title">全局作用域</h3>
    </div>
  )
};
export default Demo;
复制代码

自定义哈希类名

默认算法生成的哈希类名虽然唯一但没有可读性(如 title 编译为 _3zyde4l1yATCOkgn-DBWEL),所以我们往往希望自定义哈希类名,有两种方式:

  1. 设置 css-loaderoptions.modules.localIdentName 选项,值接受一个字符串模板。比如:
module.exports = {
  module: {
    rules: [
      {
        test: /\.module\.css$/,
        use: [
          require.resolve('style-loader'),
          {
            loader: require.resolve('css-loader'),
            options: {
              // 开启 CSS Modules
              modules: {
                compileType: 'module',
                localIdentName: "[path][name]__[local]--[hash:base64:5]",
              },
            }
          }
        ]
      },
    ],
  },
};
复制代码
  1. 通过设置 css-loaderoptions.modules.getLocalIdent 选项,值传递一个自定义函数。

这里使用 create-react-app 中的设置作为示例👇,getCSSModuleLocalIdent 函数是为了创建格式为 [filename]_[classname]__[hash] 唯一类名。比如 .content.module.css 中的 title 类名将编译为 content_title__2eyf6

const loaderUtils = require('loader-utils');
const path = require('path');

function getCSSModuleLocalIdent (context, localIdentName, localName, options) {
  // 使用文件名或文件夹名
  const fileNameOrFolder = context.resourcePath.match(/index\.module\.(css|scss|sass)$/) ? '[folder]' : '[name]';
  // 根据文件位置和类名创建哈希
  const hash = loaderUtils.getHashDigest(
    path.posix.relative(context.rootContext, context.resourcePath) + localName,
    'md5',
    'base64',
    5
  );
  // 使用 loaderUtils 查找文件或文件夹名称
  const className = loaderUtils.interpolateName(
    context,
    fileNameOrFolder + '_' + localName + '__' + hash,
    options
  );
  // 删除类名中的 `.module`,并替换所有 "." with "_"。
  return className.replace('.module_', '_').replace(/\./g, '_');
};

module.exports = {
  module: {
    rules: [
      {
        test: /\.module\.css$/,
        use: [
          require.resolve('style-loader'),
          {
            loader: require.resolve('css-loader'),
            options: {
              modules: {
                compileType: 'module',
                getLocalIdent: getCSSModuleLocalIdent
              },
            }
          }
        ]
      },
    ],
  },
};
复制代码

组合

CSS Modules 中使用 composes 实现样式复用。 composes 不仅可组合本模块类名,还可以组合其他 CSS Module 中导入的类名,均仅限在局部样式(:local)中使用:

/* btn.module.css */
.btn {
  border: 1px solid #ccc;
}

/* 组合本模块中的类名👇 */
.btnPrimary {
  composes: btn;
  background: #1890ff;
}

/* 组合其他 CSS Module 中导入的类名👇 */
.btnCircle {
  font-size: 14px;
  composes: circle from './shape.module.css';
  /* 要从多个模块导入,则使用多个 composes */
  composes: bgColor color from "./color.module.css";
  composes: btn;
}
复制代码
// Composes.js
import React from 'react';
import styles from "./btn.module.css";

const Demo = () => {
  return (
    <div>
      <button className={styles.btn}>Button</button>
      <button className={styles.btnPrimary}>Primary Button</button>
      <button className={styles.btnCircle}><Icon type="search"/></button>
    </div>
  );
}
export default Demo;
复制代码

className={styles.btnPrimary} 编译后的类名为 class="btn_btnPrimary__1RVFt btn_btn__1qiyw"

:global 中使用 composes 将报错:

/* Error: composition is only allowed when selector is single :local class name not in ".button", ".button" is weird */
:global(.button) {
  composes: btn;
  color: red;
}
复制代码

变量

variables.module.css

@value primary: #BF4040;
@value secondary: #1F4F7F;
复制代码

text.module.css

@value fontSize: 16px;

/* 从其他模块文件中导入👇 */
@value primary as color-primary, secondary from "./variables.module.css";

/* 等效于👇 */
@value variables: "./variables.module.css";
@value primary as color-primary, secondary from variables;

/* 值作为选择器名称 */
@value selectorValue: secondary-color;
.selectorValue {
  color: secondary;
}

.textPrimary {
  font-size: fontSize;
  color: color-primary;
}

.textSecondary {
  font-size: fontSize;
  color: secondary;
}
复制代码
import React from 'react';
import styles from "./text.module.css";

const Demo = () => {
  return (
    <div>
      变量:<br/>
      <p className={styles.textPrimary}>这是一段话...</p>
      <p className={styles.textSecondary}>这是一段话...</p>
      <p className={styles['secondary-color']}>这是一段话...</p>
    </div>
  )
}

export default Demo;
复制代码

导入、导出变量

此特性是为了将变量从 CSS 传递给 JS。CSS Module 通过 Interoperable CSS (ICSS) 实现此特性,ICSS 作为 CSS Modules 的低级文件格式规范,只是在标准 CSS 中额外增加了两个的伪选择器 :import:export。也因此 ICSS 下不能使用上面👆介绍的 CSS Modules 特性。

实际项目中,通常约定扩展名为 .module.css 为的文件需解析为 CSS Modules,而常规的 .css 文件解析为 ICSS。css-loader 中通过 options.modules.compileType 选项设置 CSS Modules 解析程度。

{
  test: /\.css$/, // 匹配css 模块
  exclude: /\.module\.css$/, // 排除 `.module.css` 扩展文件
  use: [
    require.resolve('style-loader'),
    {
      loader: require.resolve('css-loader'),
      options: {
        modules: {
          // compileType:控制编译程度
          compileType: 'icss', // 仅开启 :import 和 :export
        },
      }
    },
  ]
},
{
  test: /\.module\.css$/, // 匹配 CSS Modules
  use: [
    require.resolve('style-loader'),
    {
      loader: require.resolve('css-loader'),
      options: {
        modules: {
          compileType: 'module', // CSS Modules 所有特性
        },
      }
    },
  ]
},
复制代码

如果设置为 false 值会提升性能,因为避免了 CSS Modules 特性的解析。

:export:import

/* 导入变量 */
:import("path/to/dep.css") {
  localAlias: keyFromDep;
  /* ... */
}
/* 导出变量 */
:export {
  exportedKey: exportedValue;
	/* ... */
}
复制代码

:export 相当于 cjs 中的 module.exports

module.exports = {
	exportedKey: exportedValue;
}
复制代码

推荐约定,但不强制:

  • :export:只有一个 :export块,位于文件的顶部,但在任何 :import 块之后。
  • :import:每个依赖项应该有一个导入;所有导入都应位于文件顶部;本地别名应以双下划线(__)为前缀。

具体示例:

./dep.css

:export {
  theme-color: #1890ff;
  header-height: 60px;
  header-name: abc-header;
  color-secondary: #666;
  screen-min: 768px;
  screen-max: 1200px;
}
复制代码

icss.css

:import("./dep.css") {
  __themeColor: theme-color;
  __headerHeight: header-height;
  __headerName: header-name;
  __secondary: color-secondary;
  __screenMin: screen-min;
  __screenMax: screen-max;
}

/* 导入的变量可用于任何选择器、任何值和媒体查询参数中 */
/* 任何选择器 */
.__headerName .logo {
  color: red;
  line-height: __headerHeight;
}

/* 任何值 */
.border-theme {
  border: 1px solid __themeColor;
}

/* 媒体查询参数 */
@media (min-width: __screenMin) and (max-width: __screenMax) {
  .__headerName {
    box-shadow: 0 4px 4px __secondary;
  }
}

/* 导出供 js 模块使用 */
:export {
  headerHeight: __headerHeight;
  headerName: __headerName;
}
复制代码

👆 因为上面使用了导出的变量(__headerName__headerHeight),所以 :export 块需放在样式规则下方,否则会导致样式规则失效。

使用:

import React from 'react';
import styles from "./icss.css";
const { headerHeight, headerName } = styles;

const Demo = () => {
  return (
    <div className={`${headerName} border-theme`} style={{ height: headerHeight }}>
      <span className="logo">logo</span>
    </div>
  )
}

export default Demo;
复制代码

项目实践

实际项目开发中都使用 webpack,使用 css-loader 解析使用 css-loader 解析 CSS Modules。本文中使用 webpack5 做示例演示,去这里可查看完整代码,package.jsonwebpack.config.js 配置如下👇,使用 npm run dev 命令启动本地服务。

webpack 配置

package.json

{
  "name": "css-modules-demo",
  "scripts": {
    "dev": "webpack serve --hot",
    "build": "webpack"
  },
  "devDependencies": {
    "@babel/core": "^7.14.6",
    "@babel/preset-react": "^7.14.5",
    "babel-loader": "^8.2.2",
    "css-loader": "^5.2.6",
    "html-webpack-plugin": "^5.3.2",
    "loader-utils": "^2.0.0",
    "postcss-loader": "^6.1.1",
    "style-loader": "^3.0.0",
    "webpack": "^5.44.0",
    "webpack-cli": "^4.7.2",
    "webpack-dev-server": "^3.11.2"
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  // ...
}
复制代码

项目中通常做如下约定:

  • .module.css 结尾的扩展文件表示 CSS Modules(解析所有 CSS Modules 特性)。
  • 其他 .css 文件仅视为常规的 css 模块(仅解析 ICSS 特性, 相比标准 CSS 规范仅额外支持 :import:export)。
import React from 'react';
import styles from './Button.module.css'; // 导入 CSS Modules 样式文件
import './another-stylesheet.css'; // 导入常规样式

const Button = () => {
  // 作为 js 对象引用
  return <button className={styles.error}>Error Button</button>;
}
复制代码

这样约定的目的一方面是为了规范代码书写,另一方面避免所有样式文件 webpack 都要做 CSS Modules 解析,造成性能浪费。

通过为所有未匹配到 *.module.scss 命名约定文件设置 compileType 选项,只允许使用可交互的 CSS 特性(即 ICSS 特性, 如 :import:export),而不使用其他的 CSS Module 特性。此处仅供参考,因为在 v4 之前,css-loader 默认将 ICSS 特性应用于所有文件。

因此做如下 webpack 配置,其中设置 css-loadermodules 选项已启用 CSS Modules,modules.compileType 控制编译程度,有 moduleicss 可选。 css-loader 更多配置详解,请查看这里

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');

const cssModuleRegex = /\.module\.css$/;

module.exports = {
  mode: 'development',
  devServer: {
    contentBase: './dist',
    hot: true,
  },
  module: {
    rules: [
      {
        oneOf: [
          {
            test: /\.(js|mjs|jsx|ts|tsx)$/,
            loader: require.resolve('babel-loader'),
            options: {
              presets: ['@babel/preset-react']
            }
          },
          // 匹配普通 css 模块
          {
            test: /\.css$/,
            exclude: /\.module\.css$/, // 排除以 `.module.css` 扩展名的文件
            use: [
              require.resolve('style-loader'),
              {
                loader: require.resolve('css-loader'),
                options: {
                  modules: {
                    // 控制编译程度
                    compileType: 'icss', // 仅开启 :import 和 :export
                  },
                }
              },
            ]
          },
          // 匹配 CSS Modules
          {
            test: /\.module\.css$/,
            use: [
              require.resolve('style-loader'),
              {
                loader: require.resolve('css-loader'),
                options: {
                  modules: {
                    compileType: 'module', // CSS Modules 所有特性
                    getLocalIdent: getCSSModuleLocalIdent, // 自定义哈希类名,上面👆介绍过
                  },
                }
              },
            ]
          },
        ]
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      inject: true,
      template: 'index.html',
    })
  ]
};
复制代码

sass/scss

项目中通常都会使用像 less、sass 这些预处理器,这里就以 sass 为例。 同样约定仅扩展名为 .module.scss.module.sass 解析 CSS Modules 特性,其他 .scss.sass 扩展文件视为普通的 sass 模块,只做支持 ICSS 特性(:import:export)的解析。

修改 webpack.config.js,增加两个匹配组,配置上主要区别在于 modules.compileType 选项:

// 匹配常规的 sass 模块,仅支持 ICSS
{
  test: /\.(scss|sass)$/,
  exclude: /\.module\.(scss|sass)$/, // 排除 .module.scss 或 .module.sass 扩展文件
  use: [
    require.resolve('style-loader'),
    {
      loader: require.resolve('css-loader'),
      options: {
        importLoaders: 3, // 3 => postcss-loader, resolve-url-loader, sass-loader
        modules: {
          compileType: 'icss',
        },
      }
    },
    {
      // 帮助 sass-loader 找到对应的 url 资源
      loader: require.resolve('resolve-url-loader'),
      options: {
        root: resolveApp('src'),
      },
    },
    {
      loader: require.resolve('sass-loader'),
      options: {
        sourceMap: true, // 这里不可少
      },
    },
  ],
},
// 支持 CSS Modules, 仅匹配 .module.scss 或 .module.sass 扩展文件
{
  test: /\.module\.(scss|sass)$/,
  use: [
    require.resolve('style-loader'),
    {
      loader: require.resolve('css-loader'),
      options: {
        importLoaders: 3,
        modules: {
          compileType: 'module',
          getLocalIdent: getCSSModuleLocalIdent,
        },
      }
    },
    {
      loader: require.resolve('resolve-url-loader'),
      options: {
        root: resolveApp('src'),
      },
    },
    {
      loader: require.resolve('sass-loader'),
      options: {
        sourceMap: true,
      },
    },
  ],
},
// 下面示例中将使用 file-loader 处理图片
{
  loader: require.resolve('file-loader'),
  exclude: [/\.(js|jsx)$/, /\.html$/, /\.json$/],
  options: {
    name: 'static/media/[name].[hash:8].[ext]',
  },
},
复制代码

说明:

  • importLoaders:表示配置在 css-loader 之前的 loader,有几个可以去处理 @import 资源(如 @import 'a.css';)。上面的配置中 3 表示 @import 进来的资源可以经过 postcss-loaderresolve-url-loadersass-loader 处理。
  • resolve-url-loader:帮助 sass-loader 找到对应的 url 资源。Saas 没有提供 url 重写的功能,此 loader 设置于 loader 链中的 sass-loader 之前就可以重写 url。

示例:

分别创建了一个 header.module.sassheader.sass 文件,内容均如下:

$shadow-color: #ccc
$header-height: 60px
$theme: #1890ff

.className
  border: 1px solid $theme
  padding: 20px
  box-shadow: 0 4px 4px $shadow-color
  a
    background-color: #fff
    display: inline-block

// 导出变量
:export
  height: $header-height
复制代码
import React from 'react';
import styles1 from "./header.module.sass";
import styles2 from "./header.sass"; // ICSS
import logo from './logo.png';

console.log('styles1', styles1); // {height: "60px", className: "header_className__rUUph"}
console.log('styles2', styles2); // {height: "60px"}

const Logo = ({ height }) => (
  <img style={{ height, width: height }} src={logo} />
);

const Demo = () => {
  return (
    <div className={styles1.className}>
      <Logo height={styles1.height} />
    </div>
  );
};
export default Demo;
复制代码

classnames

匹配 classnames 使用更加方便:

import classNames from 'classnames';
import styles from './dialog.module.css';

const Dialog = ({ disabled }) => {
  const cx = classNames({
    [styles.confirm]: !disabled,
    [styles.disabledConfirm]: disabled
  });
  return (
    <div className={styles.root}>
      <a className={cx}>Confirm</a>
      ...
    </div>
  );
};
复制代码

参考

文章分类
前端
文章标签