深入淺出 CSS Modules

2021-04-19 09:00:14

CSS Modules 是什麼?

官方檔案的介紹如下:

A CSS Modules is a CSS file in which all class names and animation names are scoped locally by default.

所有的類名和動畫名稱預設都有各自的作用域的 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 ——在模組檔案之間傳遞任意值

參考