CSS Modules 是什么?

官方文档的介绍如下:

所有的类名和动画名称默认都有各自的作用域的 CSS 文件。CSS Modules 并不是 CSS 官方的标准,也不是浏览器的特性,而是使用一些构建工具,比如 webpack,对 CSS 类名和选择器限定作用域的一种方式(类似命名空间)

本文来介绍一下 CSS Modules 的简单使用,以及 CSS Modules 的实现原理(CSS-loader 中的实现)

CSS Modules 的简单使用

项目搭建以及配置

新建一个项目,本文的 Demo

npx create-react-app learn-css-modules-react
cd learn-css-modules-react
# 显示 webpack 的配置
yarn eject

看到 config/webpack.config.js,默认情况下,React 脚手架搭建出来的项目,只有 .module.css 支持模块化,如果是自己搭建的话,可以支持 .css 文件的后缀等

// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
  test: cssModuleRegex,
  use: getStyleLoaders({
    importLoaders: 1,
    sourceMap: isEnvProduction
      ? shouldUseSourceMap
      : isEnvDevelopment,
    modules: {
      getLocalIdent: getCSSModuleLocalIdent,
    },
  }),
}

其中 getStyleLoaders 函数,可以看到 css-loader 的配置

const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [
    // ...
    {
      loader: require.resolve('css-loader'),
      options: cssOptions,
    },
    // ...
  ];
  // ...
  return loaders;
};

我们就基于这个环境当做示例进行演示

局部作用域

之前的样式

首先,我们将 App.css 修改成 App.module.css,然后导入 css,并设置(这里有个小知识点,实际上 CSS Modules 推荐的命名是驼峰式,主要是这样的话,使用对象 style.className 就可以访问。如果是如下,就需要 styles['***-***'])

import styles from './App.module.css';

// ...
<header className={styles['App-header']}></header>

就会根据特定的规则生成相应的类名

这个命名规则是可以通过 CSS-loader 进行配置,类似如下的配置:

module: {
  loaders: [
    // ...
    {
      test: /\.css$/,
      loader: "style-loader!css-loader?modules&localIdentName=[path][name]---[local]---[hash:base64:5]"
    },
  ]
}

全局作用域

默认情况下,我们发现,在 css modules 中定义的类名必须得通过类似设置变量的方式给 HTML 设置(如上示例所示)

那么我能像其他的 CSS 文件一样直接使用类名(也就是普通的设置方法),而不是编译后的哈希字符串么?

使用 :global 的语法,就可以声明一个全局的规则

:global(.App-link) {
  color: #61dafb;
}

这样就可以在 HTML 中直接跟使用普通的 CSS 一样了

但这里感觉就是 CSS Modules 给开发者留的一个后门,我们这样的 CSS,还是直接放在普通 .css 文件中比较好,我理解这就是 React 为什么对待 .css 和 .module.css 不同后缀进行不同的处理的原因

Class 的组合

在 CSS Modules 中,一个选择器可以继承另一个选择器的规则,这称为 "组合"("composition"

比如,我们定义一个 font-red,然后在 .App-header 中使用 composes: font-red; 继承

.font-red {
  color: red;
}

.App-header {
  composes: font-red;
  /* ... */
}

输入其他的模块

不仅仅可以同一个文件中的,还可以继承其他文件中的 CSS 规则

定义一个 another.module.css

.font-blue {
  color: blue;
}

在 App.module.css 中

.App-header {
  /* ... */
  composes: font-blue from './another.module.css';
  /* ... */
}

使用变量

我们还可以使用变量,定义一个 colors.module.css

@value blue: #0c77f8;

在 App.module.css 中

@value colors: "./colors.module.css";
@value blue from colors;

.App-header {
  /* ... */
  color: blue;
}

使用小结

总体而言,CSS Modules 的使用偏简单,上手非常的快,接下来我们看看 Webpack 中 CSS-loader 是怎么实现 CSS Modules

CSS Modules 的实现原理

从 CSS Loader 开始讲起

lib/processCss.js

var pipeline = postcss([
    ...
    modulesValues,
    modulesScope({
    // 根据规则生成特定的名字
        generateScopedName: function(exportName) {
            return getLocalIdent(options.loaderContext, localIdentName, exportName, {
                regExp: localIdentRegExp,
                hashPrefix: query.hashPrefix || "",
                context: context
            });
        }
    }),
    parserPlugin(parserOptions)
]);

主要看 modulesValuesmodulesScope 方法,实际上这两个方法又是来自其他两个包

var modulesScope = require("postcss-modules-scope");
var modulesValues = require("postcss-modules-values");

postcss-modules-scope

这个包主要是实现了 CSS Modules 的样式隔离(Scope Local)以及继承(Extend)

它的代码比较简单,基本一个文件完成,源码可以看这里,这里会用到 postcss 处理 AST 相关,我们大致了解它的思想即可

默认的命名规则

实际上,假如你没有设置任何的规则时候会根据如下进行命名

// 生成 Scoped name 的方法(没有传入的时候的默认规则)
processor.generateScopedName = function (exportedName, path) {
  var sanitisedPath = path.replace(/\.[^\.\/\\]+$/, '').replace(/[\W_]+/g, '_').replace(/^_|_$/g, '');
  return '_' + sanitisedPath + '__' + exportedName;
};

这种写法在很多的源码中我们都可以看到,以后写代码的时候也可以采用

var processor = _postcss2['default'].plugin('postcss-modules-scope', function (options) {
  // ...
  return function (css) {
    // 如果有传入,则采用传入的命名规则
       // 否则,采用默认定义的 processor.generateScopedName
    var generateScopedName = options && options.generateScopedName || processor.generateScopedName;
  }
  // ...
})

前置知识—— postcss 遍历样式的方法

css ast 主要有 3 种父类型

  • AtRule: @xxx 的这种类型,如 @screen,因为下面会提到变量的使用 @value
  • Comment: 注释
  • Rule: 普通的 css 规则

还有几个个比较重要的子类型:

  • decl: 指的是每条具体的 css 规则
  • rule:作用于某个选择器上的 css 规则集合

不同的类型进行不同的遍历

  • walk: 遍历所有节点信息,无论是 atRule、rule、comment 的父类型,还是 ruledecl 的子类型
  • walkAtRules:遍历所有的 atRule
  • walkComments:遍历注释
  • walkDecls
  • walkRules

作用域样式的实现

// Find any :local classes
// 找到所有的含有 :local 的 classes
css.walkRules(function (rule) {
  var selector = _cssSelectorTokenizer2['default'].parse(rule.selector);
  // 获取 selector
  var newSelector = traverseNode(selector);
  rule.selector = _cssSelectorTokenizer2['default'].stringify(newSelector);
  // 遍历每一条规则,假如匹配到则将类名等转换成作用域名称
  rule.walkDecls(function (decl) {
    var tokens = decl.value.split(/(,|'[^']*'|"[^"]*")/);
    tokens = tokens.map(function (token, idx) {
      if (idx === 0 || tokens[idx - 1] === ',') {
        var localMatch = /^(\s*):local\s*\((.+?)\)/.exec(token);
        if (localMatch) {
          // 获取作用域名称
          return localMatch[1] + exportScopedName(localMatch[2]) + token.substr(localMatch[0].length);
        } else {
          return token;
        }
      } else {
        return token;
      }
    });
    decl.value = tokens.join('');
  });
});

css.walkRules 遍历所有节点信息,无论是 atRule、rule、comment 的父类型,还是 ruledecl 的子类型,获取 selector

// 递归遍历节点,找到目标节点
function traverseNode(node) {
  switch (node.type) {
    case 'nested-pseudo-class':
      if (node.name === 'local') {
        if (node.nodes.length !== 1) {
          throw new Error('Unexpected comma (",") in :local block');
        }
        return localizeNode(node.nodes[0]);
      }
      /* falls through */
    case 'selectors':
    case 'selector':
      var newNode = Object.create(node);
      newNode.nodes = node.nodes.map(traverseNode);
      return newNode;
  }
  return node;
}

walkDecls 遍历每一条规则,生成相应的 Scoped Name

// 生成一个 Scoped Name
function exportScopedName(name) {
  var scopedName = generateScopedName(name, css.source.input.from, css.source.input.css);
  exports[name] = exports[name] || [];
  if (exports[name].indexOf(scopedName) < 0) {
    exports[name].push(scopedName);
  }
  return scopedName;
}

关于实现 composes 的组合语法,有点类似,不再赘述

postcss-modules-values

这个库的主要作用是在模块文件之间传递任意值,主要是为了实现在 CSS Modules 中能够使用变量

它的实现也是只有一个文件,具体查看这里

查看所有的 @value 语句,并将它们视为局部变量或导入的,最后保存到 definitions 对象中

/* Look at all the @value statements and treat them as locals or as imports */
// 查看所有的 @value 语句,并将它们视为局部变量还是导入的
css.walkAtRules('value', atRule => {
  // 类似如下的写法
  // @value primary, secondary from colors
  if (matchImports.exec(atRule.params)) {
    addImport(atRule)
  } else {
    // 处理定义在文件中的 类似如下
    // @value primary: #BF4040;
        // @value secondary: #1F4F7F;
    if (atRule.params.indexOf('@value') !== -1) {
      result.warn('Invalid value definition: ' + atRule.params)
    }

    addDefinition(atRule)
  }
})

假如是导入的,调用的 addImport 方法

const addImport = atRule => {
  // 如果有 import 的语法
  let matches = matchImports.exec(atRule.params)
  if (matches) {
    let [/*match*/, aliases, path] = matches
    // We can use constants for path names
    if (definitions[path]) path = definitions[path]
    let imports = aliases.replace(/^\(\s*([\s\S]+)\s*\)$/, '$1').split(/\s*,\s*/).map(alias => {
      let tokens = matchImport.exec(alias)
      if (tokens) {
        let [/*match*/, theirName, myName = theirName] = tokens
        let importedName = createImportedName(myName)
        definitions[myName] = importedName
        return { theirName, importedName }
      } else {
        throw new Error(`@import statement "${alias}" is invalid!`)
      }
    })
    // 最后会根据这个生成 import 的语法
    importAliases.push({ path, imports })
    atRule.remove()
  }
}

否则则直接 addDefinition,两个的思想大致我理解都是找到响应的变量,然后替换

// 添加定义
const addDefinition = atRule => {
  let matches
  while (matches = matchValueDefinition.exec(atRule.params)) {
    let [/*match*/, key, value] = matches
    // Add to the definitions, knowing that values can refer to each other
    definitions[key] = replaceAll(definitions, value)
    atRule.remove()
  }
}

总结

CSS Modules 并不是 CSS 官方的标准,也不是浏览器的特性,而是使用一些构建工具,比如 webpack,对 CSS 类名和选择器限定作用域的一种方式(类似命名空间)。通过 CSS Modules,我们可以实现 CSS 的局部作用域,Class 的组合等功能。最后我们知道 CSS Loader 实际上是通过两个库进行实现的。其中, postcss-modules-scope —— 实现CSS Modules 的样式隔离(Scope Local)以及继承(Extend)和 postcss-modules-values ——在模块文件之间传递任意值

参考

03-06 00:07