手寫 css-modules 來深入理解它的原理
我們知道,瀏覽器裏的 JS 之前沒有模塊的概念,都是通過不同的全局變量(命名空間)來隔離,後來出現了 AMD、CMD、CommonJS、ESM 等規範。
通過這些模塊規範組織的 JS 代碼經過編譯打包之後,運行時依然會有模塊級別的作用域隔離(通過函數作用域來實現的)。
組件就可以放在不同的模塊中,來實現不同組件的 JS 的作用域隔離。
但是組件除了 JS 還有 CSS 呀,CSS 卻一直沒有模塊隔離的規範。
如何給 css 加上模塊的功能呢?
有的同學會說 CSS 不是有 @import 嗎?
那個只是把不同的 CSS 文件合併到一起,並不會做不同 CSS 的隔離。
CSS 的隔離主要有兩類方案,一類是運行時的通過命名區分,一類是編譯時的自動轉換 CSS,添加上模塊唯一標識。
運行時的方案最典型的就是 BEM,它是通過 .block__element--modifier 這種命名規範來實現的樣式隔離,不同的組件有不同的 blockName,只要按照這個規範來寫 CSS,是能保證樣式不衝突的。
但是這種方案畢竟不是強制的,還是有樣式衝突的隱患。
編譯時的方案有兩種,一種是 scoped,一種是 css modules。
scoped 是 vue-loader 支持的方案,它是通過編譯的方式在元素上添加了 data-xxx 的屬性,然後給 css 選擇器加上 [data-xxx] 的屬性選擇器的方式實現 css 的樣式隔離。
比如:
<style scoped>
.guang {
color: red;
}
</style>
<template>
<div class="guang">hi</div>
</template>
會被編譯成:
<style>
.guang[data-v-f3f3eg9]
{
color: red;
}
</style>
<template>
<div class="guang" data-v-f3f3eg9>hi</div>
</template>
通過給 css 添加一個全局唯一的屬性選擇器來限制 css 只能在這個範圍生效,也就是 scoped 的意思。
css-modules 是 css-loader 支持的方案,在 vue、react 中都可以用,它是通過編譯的方式修改選擇器名字爲全局唯一的方式來實現 css 的樣式隔離。
比如:
<style module>
.guang {
color: red;
}
</style>
<template>
<p :class="$style.guang">hi</p>
</template>
會被編譯成:
<style module>
._1yZGjg0pYkMbaHPr4wT6P__1 {
color: red;
}
</style>
<template>
<p class="_1yZGjg0pYkMbaHPr4wT6P__1">hi</p>
</template>
和 scoped 方案的區別是 css-modules 修改的是選擇器名字,而且因爲名字是編譯生成的,所以組件裏是通過 style.xx 的方式來寫選擇器名。
兩種方案都是通過編譯實現的,但是開發者的使用感受還是不太一樣的:
scoped 的方案是添加的 data-xxx 屬性選擇器,因爲 data-xx 是編譯時自動生成和添加的,開發者感受不到。
css-modules 的方案是修改 class、id 等選擇器的名字,那組件裏就要通過 styles.xx 的方式引用這些編譯後的名字,開發者是能感受到的。但是也有好處,配合編輯器可以做到智能提示。
此外,除了 css 本身的運行時、編譯時方案,還可以通過 JS 來組織 css,利用 JS 的作用域來實現 css 隔離,這種是 css-in-js 的方案。
比如這樣:
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 50px;
color: red;
`;
function Guang {
return (
<div>
<Wrapper>內部文件寫法</Wrapper>
</div>
);
}
這些方案中,css-modules 的編譯時方案是用的最多的,vue、react 都可以用。
那它是怎麼實現的呢?
打開 css-loader 的 package.json,你會發現依賴了 postcss(css 的編譯工具,類似編譯 js 的 babel):
其中這四個 postcss-modules 開頭的插件就是實現 css-modules 的核心代碼。
這四個插件裏,實現作用域隔離的是 postcss-modules-scope,其他的插件不是最重要的,比如 postcss-modules-values 只是實現變量功能的。
所以說,我們只要能實現 postcss-modules-scope 插件,就能搞懂 css-modules 的實現原理了。
我們去看下 postcss-modules-scope 的 README,發現它實現了這樣的轉換:
:local(.continueButton) {
color: green;
}
編譯成
:export {
continueButton: __buttons_continueButton_djd347adcxz9;
}
.__buttons_continueButton_djd347adcxz9 {
color: green;
}
用 :local 這樣的僞元素選擇器包裹的 css 會做選擇器名字的編譯,並且把編譯前後的名字的映射關係放到 :export 這個選擇器下。
再來個複雜點的案例:
.guang {
color: blue;
}
:local(.dong){
color: green;
}
:local(.dongdong){
color: green;
}
:local(.dongdongdong){
composes-with: dong;
composes: dongdong;
color: red;
}
@keyframes :local(guangguang) {
from {
width: 0;
}
to {
width: 100px;
}
}
會被編譯成:
.guang {
color: blue;
}
._input_css_amSA5i__dong{
color: green;
}
._input_css_amSA5i__dongdong{
color: green;
}
._input_css_amSA5i__dongdongdong{
color: red;
}
@keyframes _input_css_amSA5i__guangguang {
from {
width: 0;
}
to {
width: 100px;
}
}
:export {
dong: _input_css_amSA5i__dong;
dongdong: _input_css_amSA5i__dongdong;
dongdongdong: _input_css_amSA5i__dongdongdong _input_css_amSA5i__dong _input_css_amSA5i__dongdong;
guangguang: _input_css_amSA5i__guangguang;
}
可以看到以 :local 包裹的纔會被編譯,不是 :local 包裹的會作爲全局樣式。
composes-with 和 composes 的作用相同,都是做樣式的組合,可以看到編譯之後會把 compose 的多個選擇器合併到一起。也就是一對多的映射關係。
實現了 :local 的選擇器名字的轉換,實現了 compose 的樣式組合,最後會把映射關係都放到 :export 這個樣式下。
這樣 css-loader 調用 postcss-modules-scope 完成了作用域的編譯之後,不就能從 :export 拿到映射關係了麼?
然後就可以用這個映射關係生成 js 模塊,組件裏就可以用 styles.xxx 的方式引入對應的 css 了。
這就是 css-modules 的實現原理。
那 css-modules 具體是怎麼實現呢?
我們先來分析下思路:
實現思路分析
我們要做的事情就是兩方面,一個是轉換 :local 包裹的選擇器名字,變成全局唯一的,二是把這個映射關係收集起來,放到 :export 樣式裏。
postcss 完成了從 css 到 AST 的 parse,和 AST 到目標代碼和 soucemap 的 generate。我們在插件裏只需要完成 AST 的轉換就可以了。
轉換選擇器的名字就是遍歷 AST,找到 :local 包裹的選擇器,轉換並且收集到一個對象裏。並且要處理下 composes-with,也就是一對多的映射關係。
轉換完成之後,映射關係也就有了,然後生成 :export 樣式添加到 AST 上就可以了。
思路理清了,我們來寫下代碼吧:
代碼實現
首先搭一個 postcss 插件的基本結構:
const plugin = (options = {}) => {
return {
postcssPlugin: "my-postcss-modules-scope",
Once(root, helpers) {
}
}
}
plugin.postcss = true;
module.exports = plugin;
postcss 插件的形式是一個函數返回一個對象,函數接收插件的 options,返回的的對象裏包含了 AST 的處理邏輯,可以指定對什麼 AST 做什麼處理。
這裏的 Once 代表對 AST 根節點做處理,第一個參數是 AST,第二個參數是一些輔助方法,比如可以創建 AST。
postcss 的 AST 主要有三種:
- atrule:以 @ 開頭的規則,比如:
@media screen and (min-width: 480px) {
body {
background-color: lightgreen;
}
}
- rule:選擇器開頭的規則,比如:
ul li {
padding: 5px;
}
- decl:具體的樣式,比如:
padding: 5px;
這些可以通過 astexplorer.net 來可視化的查看
轉換選擇器名字的實現大概這樣的:
Once(root, helpers) {
const exports = {};
root.walkRules((rule) => {
rule.selector = 轉換選擇器名字();
rule.walkDecls(/composes|compose-with/i, (decl) => {
// 處理 compose
}
});
root.walkAtRules(/keyframes$/i, (atRule) => {
// 轉換選擇器名字
});
}
先遍歷所有的 rule,轉換選擇器的名字,並把轉換前後選擇器名字的映射關係放到 exports 裏。還要處理下 compose。
然後遍歷 atrule,做同樣的處理。
具體實現選擇器的轉換需要對 selector 也做一次 parse,用 postcss-selector-parser,然後遍歷選擇器的 AST 實現轉換:
const selectorParser = require("postcss-selector-parser");
root.walkRules((rule) => {
// parse 選擇器爲 AST
const parsedSelector = selectorParser().astSync(rule);
// 遍歷選擇器 AST 並實現轉換
rule.selector = traverseNode(parsedSelector.clone()).toString();
});
比如 .guang 選擇器的 AST 是這樣的:
選擇器 AST 的根是 Root,它的 first 屬性是 Selector 節點,然後再 first 屬性就是 ClassName 了。
根據這樣的結構,就需要分別對不同 AST 做不同處理:
function traverseNode(node) {
switch (node.type) {
case "root":
case "selector": {
node.each(traverseNode);
break;
}
case "id":
case "class":
exports[node.value] = [node.value];
break;
case "pseudo":
if (node.value === ":local") {
const selector = localizeNode(node.first, node.spaces);
node.replaceWith(selector);
return;
}
}
return node;
}
如果是 root 或者 selector,那就繼續遞歸處理,如果是 id、class,說明是全局樣式,那就收集到 exports 裏。
如果是僞元素選擇器(pseudo),並且是 :local 包裹的,那就要做轉換了,調用 localizeNode 實現選擇器名字的轉換,然後替換原來的選擇器。
localizeNode 也要根據不同的類型做不同處理:
-
selector 節點就繼續遍歷子節點。
-
id、class 節點就做對名字做轉換,然後生成新的選擇器.
function localizeNode(node) {
switch (node.type) {
case "class":
return selectorParser.className({
value: exportScopedName(
node.value,
node.raws && node.raws.value ? node.raws.value : null
),
});
case "id": {
return selectorParser.id({
value: exportScopedName(
node.value,
node.raws && node.raws.value ? node.raws.value : null
),
});
}
case "selector":
node.nodes = node.map(localizeNode);
return node;
}
}
這裏調用了 exportScopedName 來修改選擇器名字,然後分別生成了新的 className 和 id 節點。
exportScopedName 除了修改選擇器名字之外,還要把修改前後選擇器名字的映射關係收集到 exports 裏:
function exportScopedName(name) {
const scopedName = generateScopedName(name);
exports[name] = exports[name] || [];
if (exports[name].indexOf(scopedName) < 0) {
exports[name].push(scopedName);
}
return scopedName;
}
具體的名字生成邏輯我寫的比較簡單,就是加了一個隨機字符串:
function generateScopedName(name) {
const randomStr = Math.random().toString(16).slice(2);
return `_${randomStr}__${name}`;
};
這樣,我們就完成了選擇器名字的轉換和收集。
然後再處理 compose:
compose 的邏輯也比較簡單,本來 exports 是一對一的關係,比如:
{
aaa: 'xxxx_aaa',
bbb: 'yyyy_bbb',
ccc: 'zzzz_ccc'
}
compose 就是把它變成了一對多:
{
aaa: ['xxx_aaa', 'yyy_bbb'],
bbbb: 'yyyy_bbb',
ccc: 'zzzz_ccc'
}
也就是這樣的:
所以 compose 的處理就是如果遇到同名的映射就放到一個數組裏:
rule.walkDecls(/composes|compose-with/i, (decl) => {
// 因爲選擇器的 AST 是 Root-Selector-Xx 的結構,所以要做下轉換
const localNames = parsedSelector.nodes.map((node) => {
return node.nodes[0].first.first.value;
})
const classes = decl.value.split(/\s+/);
classes.forEach((className) => {
const global = /^global\(([^)]+)\)$/.exec(className);
if (global) {
localNames.forEach((exportedName) => {
exports[exportedName].push(global[1]);
});
} else if (Object.prototype.hasOwnProperty.call(exports, className)) {
localNames.forEach((exportedName) => {
exports[className].forEach((item) => {
exports[exportedName].push(item);
});
});
} else {
throw decl.error(
`referenced class name "${className}" in ${decl.prop} not found`
);
}
});
decl.remove();
});
用 wakDecls 來遍歷所有 composes 和 composes-with 的樣式,對它的值做 exports 的合併。
首先,parsedSelector.nodes 是我們之前 parse 出的選擇器的 AST,因爲它是 Root、Selector、ClassName(或 Id 等)的三層結構,所以要先映射一下。這就是選擇器原本的名字。
然後對 compose 的值做下 split,對每一個樣式做下判斷:
-
如果 compose 的是 global 樣式,那就給每一個 exports[選擇器原來的名字] 添加上當前 composes 的 global 選擇器的映射
-
如果 compose 的是 local 的樣式,那就從 exports 中找出它編譯之後的名字,添加到當前的映射數組裏。
-
如果 compose 的選擇器沒找到,就報錯
最後還要用 decl.remove 把 composes 的樣式刪除,生成後的代碼不需要這個樣式。
這樣,我們就完成了選擇器的轉換和 compose,以及收集。
用上面的案例測試一下這段邏輯:
可以看到 選擇器的轉換和 compose 的映射都正常收集到了。
接下來繼續處理 keyframes 的部分,這個和上面差不多,如果是 :local 包裹的選擇器,就調用上面的方法做轉換即可:
root.walkAtRules(/keyframes$/i, (atRule) => {
const localMatch = /^:local\((.*)\)$/.exec(atRule.params);
if (localMatch) {
atRule.params = exportScopedName(localMatch[1]);
}
});
轉換完成之後,接下來做第二步,把收集到的 exports 生成 AST 並添加到 css 原本的 AST 上。
這部分就是調用 helpers.rule 創建 rule 節點,遍歷 exports,調用 append 方法添加樣式即可。
const exportedNames = Object.keys(exports);
if (exportedNames.length > 0) {
const exportRule = helpers.rule({ selector: ":export" });
exportedNames.forEach((exportedName) =>
exportRule.append({
prop: exportedName,
value: exports[exportedName].join(" "),
raws: { before: "\n " },
})
);
root.append(exportRule);
}
最後用 root.append 把這個 rule 的 AST 添加到根節點上。
這樣就完成了 css-modules 的選擇器轉換和 compose 還有 export 的收集和生成的全部功能。
我們來測試一下:
測試
上面的代碼實現細節還是比較多的,但是大概的思路應該能理清。
我們測試一下看看它的功能是否正常:
const postcss = require('postcss');
const modulesScope = require("./src/index");
const input = `
.guang {
color: blue;
}
:local(.dong){
color: green;
}
:local(.dongdong){
color: green;
}
:local(.dongdongdong){
composes-with: dong;
composes: dongdong;
color: red;
}
@keyframes :local(guangguang) {
from {
width: 0;
}
to {
width: 100px;
}
}
@media (max-width: 520px) {
:local(.dong) {
color: blue;
}
}
`
const pipeline = postcss([modulesScope]);
const res = pipeline.process(input);
console.log(res.css);
調用 postcss,傳入插件組織好編譯 pipeline,然後調用 process 方法,傳入處理的 css,打印生成的 css:
經測試,global 樣式沒有做轉換,:local 樣式做了選擇器的轉換,轉換的映射關係放到了 :export 樣式裏,並且 compose 也確實實現了一對多的映射。
這樣,我們就實現了 css-modules 的核心功能。
插件完整代碼上傳到了 github: https://github.com/QuarkGluonPlasma/postcss-plugin-exercize,也在這裏貼一份:
const selectorParser = require("postcss-selector-parser");
function generateScopedName(name) {
const randomStr = Math.random().toString(16).slice(2);
return `_${randomStr}__${name}`;
};
const plugin = (options = {}) => {
return {
postcssPlugin: "my-postcss-modules-scope",
Once(root, helpers) {
const exports = {};
function exportScopedName(name) {
const scopedName = generateScopedName(name);
exports[name] = exports[name] || [];
if (exports[name].indexOf(scopedName) < 0) {
exports[name].push(scopedName);
}
return scopedName;
}
function localizeNode(node) {
switch (node.type) {
case "selector":
node.nodes = node.map(localizeNode);
return node;
case "class":
return selectorParser.className({
value: exportScopedName(
node.value,
node.raws && node.raws.value ? node.raws.value : null
),
});
case "id": {
return selectorParser.id({
value: exportScopedName(
node.value,
node.raws && node.raws.value ? node.raws.value : null
),
});
}
}
}
function traverseNode(node) {
switch (node.type) {
case "root":
case "selector": {
node.each(traverseNode);
break;
}
case "id":
case "class":
exports[node.value] = [node.value];
break;
case "pseudo":
if (node.value === ":local") {
const selector = localizeNode(node.first, node.spaces);
node.replaceWith(selector);
return;
}
}
return node;
}
// 處理 :local 選擇器
root.walkRules((rule) => {
const parsedSelector = selectorParser().astSync(rule);
rule.selector = traverseNode(parsedSelector.clone()).toString();
rule.walkDecls(/composes|compose-with/i, (decl) => {
const localNames = parsedSelector.nodes.map((node) => {
return node.nodes[0].first.first.value;
})
const classes = decl.value.split(/\s+/);
classes.forEach((className) => {
const global = /^global\(([^)]+)\)$/.exec(className);
if (global) {
localNames.forEach((exportedName) => {
exports[exportedName].push(global[1]);
});
} else if (Object.prototype.hasOwnProperty.call(exports, className)) {
localNames.forEach((exportedName) => {
exports[className].forEach((item) => {
exports[exportedName].push(item);
});
});
} else {
throw decl.error(
`referenced class name "${className}" in ${decl.prop} not found`
);
}
});
decl.remove();
});
});
// 處理 :local keyframes
root.walkAtRules(/keyframes$/i, (atRule) => {
const localMatch = /^:local\((.*)\)$/.exec(atRule.params);
if (localMatch) {
atRule.params = exportScopedName(localMatch[1]);
}
});
// 生成 :export rule
const exportedNames = Object.keys(exports);
if (exportedNames.length > 0) {
const exportRule = helpers.rule({ selector: ":export" });
exportedNames.forEach((exportedName) =>
exportRule.append({
prop: exportedName,
value: exports[exportedName].join(" "),
raws: { before: "\n " },
})
);
root.append(exportRule);
}
},
};
};
plugin.postcss = true;
module.exports = plugin;
總結
CSS 實現模塊隔離主要有運行時和編譯時兩類方案:
-
運行時通過命名空間來區分,比如 BEM 規範。
-
編譯時自動轉換選擇器名字,添加上唯一標識,比如 scoped 和 css-modules
scoped 是通過給元素添加 data-xxx 屬性,然後在 css 中添加 [data-xx] 的屬性選擇器來實現的,對開發者來說是透明的。是 vue-loader 實現的,主要用在 vue 裏。
css-modules 則是通過編譯修改選擇器名字爲全局唯一的方式實現的,開發者需要用 styles.xx 的方式來引用編譯後的名字,對開發者來說不透明,但是也有能配合編輯器實現智能提示的好處。是 css-loader 實現的,vue、react 都可用。
當然,其實還有第三類方案,就是通過 JS 來管理 css,也就是 css-in-js。
css-modules 的方案是用的最多的,我們看了它的實現原理:
css-loader 是通過 postcss 插件來實現 css-modules 的,其中最核心的是 postcss-modules-scope 插件。
我們自己寫了一個 postcss-modules-scope 插件:
-
遍歷所有選擇器,對 :local 僞元素包裹的選擇器做轉化,並且收集到 exports 中。
-
對 composes 的選擇器做一對多的映射,也收集到 exports 中。
-
根據 exports 收集到的映射關係生成 :exports 樣式
這就是 css-modules 的作用域隔離的實現原理。
文中代碼部分細節比較多,可以把代碼下載下來跑一下,相信如果你能自己實現 css-modules 的核心編譯功能,那一定是徹底理解了 css-modules 了。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/CX-LC014iZ4vpTko59Sf7A