css-module模块化原理实现
css-module 模块化原理实现
CSS 的模块化是前端 CSS 进行样式隔离的方案之一,其通过生成独一无二的选择器名来保证其样式的唯一性。
css module 主要依赖 postcss 的这四个库:
"postcss-modules-extract-imports": "^3.0.0",
"postcss-modules-local-by-default": "^4.0.0",
"postcss-modules-scope": "^3.0.0",
"postcss-modules-values": "^4.0.0",
2
3
4
其中,
- postcss-modules-scope 是实现作用域隔离的插件
- postcss-modules-extract-imports 是处理引入导出如 composes: b from "./b.css"; 的插件
- postcss-modules-local-by-default 是将我们的类名通过:local 选择器包裹,形成局部作用域,从而避免样式冲突,关于该选择器可以看底部的 QA。
- postcss-modules-values 是用来实现模块化的变量功能
这里,我们想要了解如何实现 css 样式隔离,我们主要只需要关注(postcss-modules-scope)[https://github.com/css-modules/postcss-modules-scope]插件即可,我们点开其 git 仓库,可以看到它做了一个这样的操作:
:local(.continueButton) {
color: green;
}
.globalbutton {
color: red;
}
:local(.continueButton2) {
font-size: 4px;
}
:local(.continueButton3) {
composes-with: continueButton;
composes: continueButton2;
color: blue;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
将上文编译成
.__buttons_continueButton_djd347adcxz9 {
color: green;
}
.globalbutton {
color: red;
}
.__buttons_continueButton2_vwd347asdaz2 {
font-size: 4px;
}
.__buttons_continueButton3_gsd684zxqwq5 {
color: blue;
}
:export {
continueButton: __buttons_continueButton_djd347adcxz9;
continueButton2: __buttons_continueButton2_vwd347asdaz2;
continueButton3: __buttons_continueButton_djd347adcxz9
__buttons_continueButton2_vwd347asdaz2
__buttons_continueButton3_gsd684zxqwq5;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
用 :local 这样的伪元素选择器包裹的 css 会做选择器名字的编译,并且把编译前后的名字的映射关系放到 :export 这个选择器下,而不被 :local 包裹的会被当作全局样式处理。
composes-with 和 composes 的作用相同,都是做样式的组合,可以看到编译之后会把 compose 的多个选择器合并到一起。也就是一对多的映射关系,然后将这些映射关系都放到 export 选择器下
这个时候,我们就可以通过在:export 选择器中拿到 css 的映射关系,从而根据这个关系去生成 js 模块,组件里就可以用 styles.xxx 的方式引入对应的 css 了。
# 实现原理
从以上我们可以想到,要实现 css module, 首先我们需要转换被:local 选择器包裹的 css 名字(增加 hash 值等方式),然后生成对应的映射并存放到 export 选择器里。这里我们使用 postcss 的核心能力来解析 css,我们只需要找到目标代码并将其匹配并转换成对应的代码即可。这里的做法可以回顾 postcss 的使用一文。
下面,我们根据其源码,来实现一个模块化插件, 附上链接: postcss-module-scope 源码 (opens new window)
首先,我们定义一个 postcss 插件的结构
const plugin = (options = {}) => {
return {
postcssPlugin: "postcss-modules-scope",
Once(root, { rule }) {},
};
};
plugin.postcss = true;
module.exports = plugin;
2
3
4
5
6
7
8
9
10
这里的Once (opens new window)指的是其将会在根节点调用一次,第一个参数是 根节点的 AST,第二个参数是一些辅助方法,比如可以创建 AST。
我们现在可打开ast (opens new window)界面,看 css AST 树的类型,可以分为以下几种:
- comment 类型:为 css 文件中的注释
- atrule 类型:以@开头的规则,比如@media
- rule 类型 :正常选择器开头的规则,比如 .xxx :last-child
- decl 类型:具体的样式, 比如: height: 5px
接下来,我们了解几个方法
- Root#walkRules():遍历容器的子节点,为每个规则节点调用回调。
- Root#walkDecls():遍历容器的子节点,为每个声明节点调用回调。你可以传入某一个正则或是字符串规则,函数则会在匹配的规则上进行过滤修改操作。
/**
* @param {string|RegExp=} rule
* @param {function} function
*/
root.walkDecls((decl) => {
checkPropertySupport(decl.prop);
});
root.walkDecls("border-radius", (decl) => {
decl.remove();
});
root.walkDecls(/^background/, (decl) => {
decl.value = takeFirstColorFromGradient(decl.value);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- Root#walkAtRules():遍历容器的子节点,在规则节点为每个子节点调用回调。同样可以传入两个参数,第一个为匹配规则,第二个为遍历节点的回调函数。
/**
* @param {string|RegExp=} rule
* @param {function} function
*/
root.walkAtRules((rule) => {
if (isOld(rule.name)) rule.remove();
});
let first = false;
root.walkAtRules("charset", (rule) => {
if (!first) {
first = true;
} else {
rule.remove();
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
了解了这些方法,我们可以大概形成一个思路,首先遍历所有的节点,转换选择器的名字,并保存名字前后的映射关系,以及 compose-with 的处理。
下面我们先来寻找以:local 包裹的选择器
具体实现选择器的转换需要对 selector 也做一次 parse,用 postcss-selector-parser,然后遍历选择器的 AST 实现转换:
const selectorParser = require("postcss-selector-parser");
const plugin = (options = {}) => {
// 存储名字转换的映射关系
let exports = {};
return {
postcssPlugin: "postcss-modules-scope",
Once(root, { rule }) {
root.walkRules((rule) => {
// parse 选择器为 AST
let parsedSelector = selectorParser().astSync(rule);
// 遍历选择器 AST 并实现转换
rule.selector = traverseNode(parsedSelector.clone()).toString();
});
},
};
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
根据这样的结构,就需要分别对不同 AST 做不同处理:
function traverseNode(node) {
switch (node.type) {
case "pseudo":
if (node.value === ":local") {
if (node.nodes.length !== 1) {
throw new Error('Unexpected comma (",") in :local block');
}
// 如果是伪元素选择器(pseudo),并且是 :local 包裹的,那就要做转换了,调用 localizeNode 实现选择器名字的转换,然后替换原来的选择器。
const selector = localizeNode(node.first, node.spaces);
// 新的选择器继承原有的值
selector.first.spaces = node.spaces;
node.replaceWith(selector);
return;
}
case "root":
/* 如果是selector 节点就继续遍历子节点 */
case "selector": {
node.each(traverseNode);
break;
}
/** 如果是id/class则保留映射关系 */
case "id":
case "class":
if (exportGlobals) {
exports[node.value] = [node.value];
}
break;
}
return node;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
localizeNode 函数内也根据不同的类型做不同处理:
- selector 节点就继续遍历子节点。
- id、class 节点就做对名字做转换,然后生成新的选择器.
const selectorParser = require("postcss-selector-parser");
// ...
function localizeNode(node) {
switch (node.type) {
// 继续遍历子节点
case "selector":
node.nodes = node.map(localizeNode);
return node;
case "class":
return selectorParser.className({
value: exportScopedName(node.value),
});
case "id": {
return selectorParser.id({
value: exportScopedName(node.value),
});
}
}
throw new Error(`${node.type} ("${node}") is not allowed in a :local block`);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这里调用了 exportScopedName 来生成新的名字,使用 postcss-selector-parser 来产生新的 id/class 节点,并保存下转换前后名字的映射关系
// ...
const randomString = (len) => {
const length = len || 32;
const $chars =
"ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; /** **默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
const maxPos = $chars.length;
let pwd = "";
for (let i = 0; i < length; i++) {
pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
}
return pwd + `${new Date().getTime()}`;
};
const plugin = (options = {}) => {
let exports = {};
return {
Once(root, { rule }) {
//...
function exportScopedName(name) {
/** 根据定义的规则生成新的名称,这里我们给他生成一些随机数保证其唯一性,真实的源码这里会更复杂一些 */
const scopedName = `${name}_${randomString(10)}`;
exports[name] = exports[name] || [];
/** 将同名的映射放进数组里 */
if (exports[name].indexOf(scopedName) < 0) {
exports[name].push(scopedName);
}
return scopedName;
}
},
};
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
这样,我们就完成了选择器名字的转换和收集。
接下来我们来处理 composes 和 compose-with compose 的处理则是将映射关系从一对一变为一对多, 即上面例子中的
:export {
continueButton: __buttons_continueButton_djd347adcxz9;
continueButton2: __buttons_continueButton2_vwd347asdaz2;
// 多个映射关系
continueButton3: __buttons_continueButton_djd347adcxz9
__buttons_continueButton2_vwd347asdaz2
__buttons_continueButton3_gsd684zxqwq5;
}
2
3
4
5
6
7
8
即遇到了同名的映射,就放进同一个数组里。
const plugin = (options = {}) => {
return {
Once(root, { rule }) {
//...
root.walkRules((rule) => {
let parsedSelector = selectorParser().astSync(rule);
// ... 省略之前traverseNode之后生成新的selector的逻辑
// 对该节点下的所有子节点做处理
rule.walkDecls(/composes|compose-with/i, (decl) => {
// 拿到所有的子节点名称,由于经过AST之后的parsedSelector是 Root-Selector-Xx 的结构,所以要做下转换
const localNames = getSingleLocalNamesForComposes(parsedSelector);
const classes = decl.value.split(/\s+/);
classes.forEach((className) => {
const global = /^global\(([^)]+)\)$/.exec(className);
/** 这里省略了对@import的处理 */
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();
});
});
},
};
};
/** map遍历逐层判断,将node节点移动到指定的value下 */
function getSingleLocalNamesForComposes(root) {
return root.nodes.map((node) => {
if (node.type !== "selector" || node.nodes.length !== 1) {
throw new Error(
`composition is only allowed when selector is single :local class name not in "${root}"`
);
}
node = node.nodes[0];
if (
node.type !== "pseudo" ||
node.value !== ":local" ||
node.nodes.length !== 1
) {
throw new Error(
'composition is only allowed when selector is single :local class name not in "' +
root +
'", "' +
node +
'" is weird'
);
}
node = node.first;
if (node.type !== "selector" || node.length !== 1) {
throw new Error(
'composition is only allowed when selector is single :local class name not in "' +
root +
'", "' +
node +
'" is weird'
);
}
node = node.first;
if (node.type !== "class") {
// 'id' is not possible, because you can't compose ids
throw new Error(
'composition is only allowed when selector is single :local class name not in "' +
root +
'", "' +
node +
'" is weird'
);
}
return node.value;
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
用 wakDecls 来遍历所有 composes 和 composes-with 的样式,对它的值做 exports 的合并。
首先,parsedSelector.nodes 是我们之前 parse 出的选择器的 AST,因为它是 Root、Selector、ClassName(或 Id 等选择器)的三层结构,所以要先映射一下,这里通过 getSingleLocalNamesForComposes 函数得到了选择器原本的名字。
然后对 compose 的值按空格 split 一下,对每一个样式做下判断:
如果 compose 的是 global 样式,那就给每一个 exports[选择器原来的名字] 添加上当前 composes 的 global 选择器的映射
如果 compose 的是 local 的样式,那就从 exports 中找出它编译之后的名字,添加到当前的映射数组里。
如果 compose 的选择器没找到,就报错。最后还要用 decl.remove 把 composes 的样式删除,生成后的代码不需要这个样式。
这样,我们就完成了选择器的转换和 compose,以及收集。
最后,我们处理 keyframes 的部分
// Find any :local keyframes
root.walkAtRules(/keyframes$/i, (atRule) => {
const localMatch = /^\s*:local\s*\((.+?)\)\s*$/.exec(atRule.params);
if (!localMatch) {
return;
}
atRule.params = exportScopedName(localMatch[1]);
});
2
3
4
5
6
7
8
9
10
转换完名字规则映射后,我们最后将收集到的 exports 生成 ast 并添加到原本的 ast 上
// If we found any :locals, insert an :export rule
const exportedNames = Object.keys(exports);
if (exportedNames.length > 0) {
// 生成export选择器
const exportRule = rule({ selector: ":export" });
exportedNames.forEach((exportedName) =>
exportRule.append({
prop: exportedName,
value: exports[exportedName].join(" "),
raws: { before: "\n " },
})
);
//用 root.append 把这个 rule 的 AST 添加到根节点上。
root.append(exportRule);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这样,我们就完成了一整个最简版的 css-module 的功能。
# 测试
接下来我们给出完整的插件代码以及测试代码,可以在本地 node 调用断点调试来不断加深理解.
const selectorParser = require("postcss-selector-parser");
const postcss = require("postcss");
const randomString = (len) => {
const length = len || 32;
const $chars =
"ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; /** **默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
const maxPos = $chars.length;
let pwd = "";
for (let i = 0; i < length; i++) {
pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
}
return pwd + `${new Date().getTime()}`;
};
const plugin = (options = {}) => {
return {
postcssPlugin: "postcss-modules-scope",
Once(root, { rule }) {
let exports = {};
function exportScopedName(name) {
/** 根据定义的规则生成新的名称,这里我们给他生成一些随机数保证其唯一性,真实的源码这里会更复杂一些 */
const scopedName = `${name}_${randomString(10)}`;
exports[name] = exports[name] || [];
/** 将同名的映射放进数组里 */
if (exports[name].indexOf(scopedName) < 0) {
exports[name].push(scopedName);
}
return scopedName;
}
function traverseNode(node) {
switch (node.type) {
case "pseudo":
if (node.value === ":local") {
if (node.nodes.length !== 1) {
throw new Error('Unexpected comma (",") in :local block');
}
// 如果是伪元素选择器(pseudo),并且是 :local 包裹的,那就要做转换了,调用 localizeNode 实现选择器名字的转换,然后替换原来的选择器。
const selector = localizeNode(node.first, node.spaces);
// 新的选择器继承原有的值
selector.first.spaces = node.spaces;
node.replaceWith(selector);
return;
}
case "root":
/* 如果是selector 节点就继续遍历子节点 */
case "selector": {
node.each(traverseNode);
break;
}
/** 如果是id/class则保留映射关系 */
case "id":
case "class":
exports[node.value] = [node.value];
break;
}
return node;
}
function localizeNode(node) {
switch (node.type) {
// 继续遍历子节点
case "selector":
node.nodes = node.map(localizeNode);
return node;
case "class":
return selectorParser.className({
value: exportScopedName(node.value),
});
case "id": {
return selectorParser.id({
value: exportScopedName(node.value),
});
}
}
throw new Error(
`${node.type} ("${node}") is not allowed in a :local block`
);
}
function getSingleLocalNamesForComposes(root) {
return root.nodes.map((node) => {
if (node.type !== "selector" || node.nodes.length !== 1) {
throw new Error(
`composition is only allowed when selector is single :local class name not in "${root}"`
);
}
node = node.nodes[0];
if (
node.type !== "pseudo" ||
node.value !== ":local" ||
node.nodes.length !== 1
) {
throw new Error(
'composition is only allowed when selector is single :local class name not in "' +
root +
'", "' +
node +
'" is weird'
);
}
node = node.first;
if (node.type !== "selector" || node.length !== 1) {
throw new Error(
'composition is only allowed when selector is single :local class name not in "' +
root +
'", "' +
node +
'" is weird'
);
}
node = node.first;
if (node.type !== "class") {
// 'id' is not possible, because you can't compose ids
throw new Error(
'composition is only allowed when selector is single :local class name not in "' +
root +
'", "' +
node +
'" is weird'
);
}
return node.value;
});
}
root.walkRules((rule) => {
// parse 选择器为 AST
let parsedSelector = selectorParser().astSync(rule);
// 遍历选择器 AST 并实现转换
rule.selector = traverseNode(parsedSelector.clone()).toString();
rule.walkDecls(/composes|compose-with/i, (decl) => {
// 拿到所有的子节点名称,由于经过AST之后的parsedSelector是 Root-Selector-Xx 的结构,所以要做下转换
const localNames = getSingleLocalNamesForComposes(parsedSelector);
const classes = decl.value.split(/\s+/);
classes.forEach((className) => {
const global = /^global\(([^)]+)\)$/.exec(className);
/** 这里省略了对@import的处理 */
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();
});
root.walkAtRules(/keyframes$/i, (atRule) => {
const localMatch = /^\s*:local\s*\((.+?)\)\s*$/.exec(atRule.params);
if (!localMatch) {
return;
}
atRule.params = exportScopedName(localMatch[1]);
});
});
const exportedNames = Object.keys(exports);
if (exportedNames.length > 0) {
const exportRule = rule({ selector: ":export" });
exportedNames.forEach((exportedName) =>
exportRule.append({
prop: exportedName,
value: exports[exportedName].join(" "),
raws: { before: "\n " },
})
);
root.append(exportRule);
}
},
};
};
plugin.postcss = true;
const input = `
:local(.continueButton) {
color: green;
}
.globalbutton {
color: red;
}
:local(.continueButton2) {
font-size: 4px;
}
:local(.continueButton3) {
composes-with: continueButton;
composes: continueButton2;
color: blue;
}
@keyframes :local(abcde) {
from {
width: 0;
}
to {
width: 100px;
}
}
@media (max-width: 100px) {
:local(.globalbutton) {
color: gary;
}
}
`;
const pipeline = postcss([plugin]);
const res = pipeline.process(input);
console.log(res.css);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
输出:
.continueButton_rFeCTdR5Nk1679398338200 {
color: green;
}
.globalbutton {
color: red;
}
.continueButton2_SHrfKhJpJ21679398338201 {
font-size: 4px;
}
.continueButton3_YkihjPtsG41679398338201 {
color: blue;
}
@keyframes abcde_RmGYn4Aarr1679398338201 {
from {
width: 0;
}
to {
width: 100px;
}
}
@media (max-width: 100px) {
.globalbutton_cC3rHTSp431679398338203 {
color: gary;
}
}
:export {
continueButton: continueButton_rFeCTdR5Nk1679398338200;
abcde: abcde_RmGYn4Aarr1679398338201;
globalbutton: globalbutton globalbutton_cC3rHTSp431679398338203;
continueButton2: continueButton2_SHrfKhJpJ21679398338201;
continueButton3: continueButton3_YkihjPtsG41679398338201 continueButton_rFeCTdR5Nk1679398338200 continueButton2_SHrfKhJpJ21679398338201;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# QA
# css 中:local 的作用
在 CSS 中,:local 是 CSS Modules 中的伪类选择器之一。CSS Modules 是一种用于编写 CSS 的方法,它可以帮助避免全局 CSS 样式冲突的问题。
:local 用于指定一个 CSS 类选择器的作用域,使得这个选择器只在当前模块中起作用,而不会影响到其他模块中的相同选择器。
例如,假设有两个 CSS 模块 A 和 B,它们都定义了名为 "button" 的类选择器。如果这两个模块都在同一个页面中使用,那么它们定义的 "button" 类选择器就会产生冲突,导致样式不符合预期。为了解决这个问题,可以使用 :local 选择器:
/* A.module.css */
:local(.button) {
/* A模块中的样式 */
}
/* B.module.css */
:local(.button) {
/* B模块中的样式 */
}
2
3
4
5
6
7
8
9
这样定义之后,A 模块中的 ".button" 类选择器只在 A 模块内起作用,不会影响到 B 模块中的 ".button" 类选择器。同理,B 模块中的 ".button" 类选择器也只在 B 模块内起作用,不会影响到 A 模块中的 ".button" 类选择器。
总之,:local 选择器可以帮助我们更好地管理 CSS 样式,避免全局样式冲突的问题。