新聞中心
我們知道,瀏覽器里的 JS 之前沒有模塊的概念,都是通過不同的全局變量(命名空間)來隔離,后來出現(xiàn)了 AMD、CMD、CommonJS、ESM 等規(guī)范。

同心網(wǎng)站制作公司哪家好,找創(chuàng)新互聯(lián)!從網(wǎng)頁設(shè)計(jì)、網(wǎng)站建設(shè)、微信開發(fā)、APP開發(fā)、成都響應(yīng)式網(wǎng)站建設(shè)公司等網(wǎng)站項(xiàng)目制作,到程序開發(fā),運(yùn)營維護(hù)。創(chuàng)新互聯(lián)成立與2013年到現(xiàn)在10年的時(shí)間,我們擁有了豐富的建站經(jīng)驗(yàn)和運(yùn)維經(jīng)驗(yàn),來保證我們的工作的順利進(jìn)行。專注于網(wǎng)站建設(shè)就選創(chuàng)新互聯(lián)。
通過這些模塊規(guī)范組織的 JS 代碼經(jīng)過編譯打包之后,運(yùn)行時(shí)依然會有模塊級別的作用域隔離(通過函數(shù)作用域來實(shí)現(xiàn)的)。
組件就可以放在不同的模塊中,來實(shí)現(xiàn)不同組件的 JS 的作用域隔離。
但是組件除了 JS 還有 CSS 呀,CSS 卻一直沒有模塊隔離的規(guī)范。
如何給 css 加上模塊的功能呢?
有的同學(xué)會說 CSS 不是有 @import 嗎?
那個(gè)只是把不同的 CSS 文件合并到一起,并不會做不同 CSS 的隔離。
CSS 的隔離主要有兩類方案,一類是運(yùn)行時(shí)的通過命名區(qū)分,一類是編譯時(shí)的自動轉(zhuǎn)換 CSS,添加上模塊唯一標(biāo)識。
運(yùn)行時(shí)的方案最典型的就是 BEM,它是通過 .block__element--modifier 這種命名規(guī)范來實(shí)現(xiàn)的樣式隔離,不同的組件有不同的 blockName,只要按照這個(gè)規(guī)范來寫 CSS,是能保證樣式不沖突的。
但是這種方案畢竟不是強(qiáng)制的,還是有樣式?jīng)_突的隱患。
編譯時(shí)的方案有兩種,一種是 scoped,一種是 css modules。
scoped 是 vue-loader 支持的方案,它是通過編譯的方式在元素上添加了 data-xxx 的屬性,然后給 css 選擇器加上[data-xxx] 的屬性選擇器的方式實(shí)現(xiàn) css 的樣式隔離。
比如:
hi
會被編譯成:
hi
通過給 css 添加一個(gè)全局唯一的屬性選擇器來限制 css 只能在這個(gè)范圍生效,也就是 scoped 的意思。
css-modules 是 css-loader 支持的方案,在 vue、react 中都可以用,它是通過編譯的方式修改選擇器名字為全局唯一的方式來實(shí)現(xiàn) css 的樣式隔離。
比如:
hi
會被編譯成:
hi
和 scoped 方案的區(qū)別是 css-modules 修改的是選擇器名字,而且因?yàn)槊质蔷幾g生成的,所以組件里是通過 style.xx 的方式來寫選擇器名。
兩種方案都是通過編譯實(shí)現(xiàn)的,但是開發(fā)者的使用感受還是不太一樣的:
scoped 的方案是添加的 data-xxx 屬性選擇器,因?yàn)?data-xx 是編譯時(shí)自動生成和添加的,開發(fā)者感受不到。
css-modules 的方案是修改 class、id 等選擇器的名字,那組件里就要通過 styles.xx 的方式引用這些編譯后的名字,開發(fā)者是能感受到的。但是也有好處,配合編輯器可以做到智能提示。
此外,除了 css 本身的運(yùn)行時(shí)、編譯時(shí)方案,還可以通過 JS 來組織 css,利用 JS 的作用域來實(shí)現(xiàn) css 隔離,這種是 css-in-js 的方案。
比如這樣:
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 50px;
color: red;
`;
function Guang {
return (
內(nèi)部文件寫法
);
}
這些方案中,css-modules 的編譯時(shí)方案是用的最多的,vue、react 都可以用。
那它是怎么實(shí)現(xiàn)的呢?
打開 css-loader 的 package.json,你會發(fā)現(xiàn)依賴了 postcss(css 的編譯工具,類似編譯 js 的babel):
其中這四個(gè) postcss-modules 開頭的插件就是實(shí)現(xiàn) css-modules 的核心代碼。
這四個(gè)插件里,實(shí)現(xiàn)作用域隔離的是 postcss-modules-scope,其他的插件不是最重要的,比如 postcss-modules-values 只是實(shí)現(xiàn)變量功能的。
所以說,我們只要能實(shí)現(xiàn) postcss-modules-scope 插件,就能搞懂 css-modules 的實(shí)現(xiàn)原理了。
我們?nèi)タ聪?postcss-modules-scope 的 README,發(fā)現(xiàn)它實(shí)現(xiàn)了這樣的轉(zhuǎn)換:
:local(.continueButton) {
color: green;
}編譯成:
:export {
continueButton: __buttons_continueButton_djd347adcxz9;
}
.__buttons_continueButton_djd347adcxz9 {
color: green;
}用 :local 這樣的偽元素選擇器包裹的 css 會做選擇器名字的編譯,并且把編譯前后的名字的映射關(guān)系放到 :export 這個(gè)選擇器下。
再來個(gè)復(fù)雜點(diǎn)的案例:
.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 的多個(gè)選擇器合并到一起。也就是一對多的映射關(guān)系。
實(shí)現(xiàn)了 :local 的選擇器名字的轉(zhuǎn)換,實(shí)現(xiàn)了 compose 的樣式組合,最后會把映射關(guān)系都放到 :export 這個(gè)樣式下。
這樣 css-loader 調(diào)用 postcss-modules-scope 完成了作用域的編譯之后,不就能從 :export 拿到映射關(guān)系了么?
然后就可以用這個(gè)映射關(guān)系生成 js 模塊,組件里就可以用 styles.xxx 的方式引入對應(yīng)的 css 了。
這就是 css-modules 的實(shí)現(xiàn)原理。
那 css-modules 具體是怎么實(shí)現(xiàn)呢?
我們先來分析下思路:
實(shí)現(xiàn)思路分析
我們要做的事情就是兩方面,一個(gè)是轉(zhuǎn)換 :local 包裹的選擇器名字,變成全局唯一的,二是把這個(gè)映射關(guān)系收集起來,放到 :export 樣式里。
postcss 完成了從 css 到 AST 的 parse,和 AST 到目標(biāo)代碼和 soucemap 的 generate。我們在插件里只需要完成 AST 的轉(zhuǎn)換就可以了。
轉(zhuǎn)換選擇器的名字就是遍歷 AST,找到 :local 包裹的選擇器,轉(zhuǎn)換并且收集到一個(gè)對象里。并且要處理下 composes-with,也就是一對多的映射關(guān)系。
轉(zhuǎn)換完成之后,映射關(guān)系也就有了,然后生成 :export 樣式添加到 AST 上就可以了。
思路理清了,我們來寫下代碼吧:
代碼實(shí)現(xiàn)
首先搭一個(gè) postcss 插件的基本結(jié)構(gòu):
const plugin = (options = {}) => {
return {
postcssPlugin: "my-postcss-modules-scope",
Once(root, helpers) {
}
}
}
plugin.postcss = true;
module.exports = plugin;postcss 插件的形式是一個(gè)函數(shù)返回一個(gè)對象,函數(shù)接收插件的 options,返回的的對象里包含了 AST 的處理邏輯,可以指定對什么 AST 做什么處理。
這里的 Once 代表對 AST 根節(jié)點(diǎn)做處理,第一個(gè)參數(shù)是 AST,第二個(gè)參數(shù)是一些輔助方法,比如可以創(chuàng)建 AST。
postcss 的 AST 主要有三種:
- atrule:以 @ 開頭的規(guī)則,比如:
@media screen and (min-width: 480px) {
body {
background-color: lightgreen;
}
}
- rule:選擇器開頭的規(guī)則,比如:
ul li {
padding: 5px;
}
- decl:具體的樣式,比如:
padding: 5px;
這些可以通過 astexplorer.net 來可視化的查看
轉(zhuǎn)換選擇器名字的實(shí)現(xiàn)大概這樣的:
Once(root, helpers) {
const exports = {};
root.walkRules((rule) => {
rule.selector = 轉(zhuǎn)換選擇器名字();
rule.walkDecls(/composes|compose-with/i, (decl) => {
// 處理 compose
}
});
root.walkAtRules(/keyframes$/i, (atRule) => {
// 轉(zhuǎn)換選擇器名字
});
}
先遍歷所有的 rule,轉(zhuǎn)換選擇器的名字,并把轉(zhuǎn)換前后選擇器名字的映射關(guān)系放到 exports 里。還要處理下 compose。
然后遍歷 atrule,做同樣的處理。
具體實(shí)現(xiàn)選擇器的轉(zhuǎn)換需要對 selector也做一次 parse,用 postcss-selector-parser,然后遍歷選擇器的 AST 實(shí)現(xiàn)轉(zhuǎn)換:
const selectorParser = require("postcss-selector-parser");
root.walkRules((rule) => {
// parse 選擇器為 AST
const parsedSelector = selectorParser().astSync(rule);
// 遍歷選擇器 AST 并實(shí)現(xiàn)轉(zhuǎn)換
rule.selector = traverseNode(parsedSelector.clone()).toString();
});
比如 .guang 選擇器的 AST 是這樣的:
選擇器 AST 的根是 Root,它的 first 屬性是 Selector 節(jié)點(diǎn),然后再 first 屬性就是 ClassName 了。
根據(jù)這樣的結(jié)構(gòu),就需要分別對不同 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,那就繼續(xù)遞歸處理,如果是 id、class,說明是全局樣式,那就收集到 exports 里。
如果是偽元素選擇器(pseudo),并且是 :local 包裹的,那就要做轉(zhuǎn)換了,調(diào)用 localizeNode 實(shí)現(xiàn)選擇器名字的轉(zhuǎn)換,然后替換原來的選擇器。
localizeNode也要根據(jù)不同的類型做不同處理:
- selector 節(jié)點(diǎn)就繼續(xù)遍歷子節(jié)點(diǎn)。
- id、class 節(jié)點(diǎn)就做對名字做轉(zhuǎn)換,然后生成新的選擇器.
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;
}
}
這里調(diào)用了 exportScopedName 來修改選擇器名字,然后分別生成了新的 className 和 id 節(jié)點(diǎn)。
exportScopedName 除了修改選擇器名字之外,還要把修改前后選擇器名字的映射關(guān)系收集到 exports 里:
function exportScopedName(name) {
const scopedName = generateScopedName(name);
exports[name] = exports[name] || [];
if (exports[name].indexOf(scopedName) < 0) {
exports[name].push(scopedName);
}
return scopedName;
}
具體的名字生成邏輯我寫的比較簡單,就是加了一個(gè)隨機(jī)字符串:
function generateScopedName(name) {
const randomStr = Math.random().toString(16).slice(2);
return `_${randomStr}__${name}`;
};這樣,我們就完成了選擇器名字的轉(zhuǎn)換和收集。
然后再處理 compose:
compose 的邏輯也比較簡單,本來 exports 是一對一的關(guān)系,比如:
{
aaa: 'xxxx_aaa',
bbb: 'yyyy_bbb',
ccc: 'zzzz_ccc'
}compose 就是把它變成了一對多:
{
aaa: ['xxx_aaa', 'yyy_bbb'],
bbbb: 'yyyy_bbb',
ccc: 'zzzz_ccc'
}也就是這樣的:
所以 compose 的處理就是如果遇到同名的映射就放到一個(gè)數(shù)組里:
rule.walkDecls(/composes|compose-with/i, (decl) => {
// 因?yàn)檫x擇器的 AST 是 Root-Selector-Xx 的結(jié)構(gòu),所以要做下轉(zhuǎn)換
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,因?yàn)樗?Root、Selector、ClassName(或 Id 等)的三層結(jié)構(gòu),所以要先映射一下。這就是選擇器原本的名字。
然后對 compose 的值做下 split,對每一個(gè)樣式做下判斷:
- 如果 compose 的是 global 樣式,那就給每一個(gè) exports[選擇器原來的名字] 添加上當(dāng)前 composes 的 global 選擇器的映射。
- 如果 compose 的是 local 的樣式,那就從 exports 中找出它編譯之后的名字,添加到當(dāng)前的映射數(shù)組里。
- 如果 compose 的選擇器沒找到,就報(bào)錯(cuò)。
最后還要用 decl.remove 把 composes 的樣式刪除,生成后的代碼不需要這個(gè)樣式。
這樣,我們就完成了選擇器的轉(zhuǎn)換和 compose,以及收集。
用上面的案例測試一下這段邏輯:
可以看到 選擇器的轉(zhuǎn)換和 compose 的映射都正常收集到了。
接下來繼續(xù)處理 keyframes 的部分,這個(gè)和上面差不多,如果是 :local 包裹的選擇器,就調(diào)用上面的方法做轉(zhuǎn)換即可:
root.walkAtRules(/keyframes$/i, (atRule) => {
const localMatch = /^:local\((.*)\)$/.exec(atRule.params);
if (localMatch) {
atRule.params = exportScopedName(localMatch[1]);
}
});轉(zhuǎn)換完成之后,接下來做第二步,把收集到的 exports 生成 AST 并添加到 css 原本的 AST上。
這部分就是調(diào)用 helpers.rule 創(chuàng)建 rule 節(jié)點(diǎn),遍歷 exports,調(diào)用 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 把這個(gè) rule 的 AST 添加到根節(jié)點(diǎn)上。
這樣就完成了 css-modules 的選擇器轉(zhuǎn)換和 compose 還有 export 的收集和生成的全部功能。
我們來測試一下:
測試
上面的代碼實(shí)現(xiàn)細(xì)節(jié)還是比較多的,但是大概的思路應(yīng)該能理清。
我們測試一下看看它的功能是否正常:
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);
調(diào)用 postcss,傳入插件組織好編譯 pipeline,然后調(diào)用 process 方法,傳入處理的 css,打印生成的 css:
經(jīng)測試,global 樣式?jīng)]有做轉(zhuǎn)換,:local 樣式做了選擇器的轉(zhuǎn)換,轉(zhuǎn)換的映射關(guān)系放到了 :export 樣式里,并且 compose 也確實(shí)實(shí)現(xiàn)了一對多的映射。
這樣,我們就實(shí)現(xiàn)了 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;
總結(jié)
CSS 實(shí)現(xiàn)模塊隔離主要有運(yùn)行時(shí)和編譯時(shí)兩類方案:
- 運(yùn)行時(shí)通過命名空間來區(qū)分,比如 BEM 規(guī)范。
- 編譯時(shí)自動轉(zhuǎn)換選擇器名字,添加上唯一標(biāo)識,比如 scoped 和 css-modules
scoped 是通過給元素添加 data-xxx 屬性,然后在 css 中添加 [data-xx] 的屬性選擇器來實(shí)現(xiàn)的,對開發(fā)者來說是透明的。是 vue-loader 實(shí)現(xiàn)的,主要用在 vue 里。
css-modules 則是通過編譯修改選擇器名字為全局唯一的方式實(shí)現(xiàn)的,開發(fā)者需要用 styles.xx 的方式來引用編譯后的名字,對開發(fā)者來說不透明,但是也有能配合編輯器實(shí)現(xiàn)智能提示的好處。是 css-loader 實(shí)現(xiàn)的,vue、react 都可用。
當(dāng)然,其實(shí)還有第三類方案,就是通過 JS 來管理 css,也就是 css-in-js。
css-modules 的方案是用的最多的,我們看了它的實(shí)現(xiàn)原理:
css-loader 是通過 postcss 插件來實(shí)現(xiàn) css-modules 的,其中最核心的是 postcss-modules-scope 插件。
我們自己寫了一個(gè) postcss-modules-scope 插件:
- 遍歷所有選擇器,對 :local 偽元素包裹的選擇器做轉(zhuǎn)化,并且收集到 exports 中。
- 對 composes 的選擇器做一對多的映射,也收集到 exports 中。
- 根據(jù) exports 收集到的映射關(guān)系生成 :exports 樣式
這就是 css-modules 的作用域隔離的實(shí)現(xiàn)原理。
文中代碼部分細(xì)節(jié)比較多,可以把代碼下載下來跑一下,相信如果你能自己實(shí)現(xiàn) css-modules 的核心編譯功能,那一定是徹底理解了 css-modules 了。
分享名稱:手寫Css-Modules來深入理解它的原理
當(dāng)前路徑:http://fisionsoft.com.cn/article/dppieec.html


咨詢
建站咨詢
