PostCSS
背景
在项目开发过程中,我们使用 rem
作为单位进行 CSS
样式开发。虽然 rem
用于适配各种型号的浏览器十分方便,但是它也有明显的缺点:
-
在把设计图上的尺寸转换为
rem
时,我们需要自己进行计算,或者给开发工具安装插件,让开发工具来帮我们进行计算。
-
在修改和维护已经开发完成的代码时,面对如下代码:
.container { width: 2.66667rem; height: 1.6rem; }
我们很难直观地知道这个
CSS
样式设置的尺寸具体是多少。
那么有没有什么办法,能够让开发人员不用进行计算,只需要按照设计图上的尺寸写 px
,最终在浏览器渲染时,得到的是 rem
呢?
有!使用 PostCSS
。
1. 什么是 PostCSS ?
我们所说的 PostCSS
其实是由两部分组成的:PostCSS
工具和 PostCSS
插件。
PostCSS
工具有三个功能:
-
把
CSS
代码解析为抽象语法树AST
。 -
提供对
AST
进行操作的API
。 -
把
AST
转换回CSS
。
PostCSS
插件负责调用 API
来改变 AST
。
工具和插件协同工作,实现了对 CSS
代码的转换。
PostCSS
工作原理,如下图所示:
红色的 Parser
和 Stringifier
是 PostCSS
工具的解析器和字符串化工具,负责 CSS
代码和 AST
之间的转换工作。
黑色的 Plugin 1
和 Plugin 2
是 PostCSS
插件,负责从 old AST
到 new AST
的改变。
2. PostCSS 有什么用 ?
PostCSS
可以将 对开发人员友好的 CSS
代码转换为 对浏览器友好的 CSS
代码。
1. 提前使用先进的 CSS 特性。
CSS3
的新特性需要加入浏览器的私有前缀,才能被特定浏览器识别。例如:
.animate {
transform: rotate(180deg);
-ms-transform: rotate(180deg); /* IE 9 */
-moz-transform: rotate(180deg); /* Firefox */
-webkit-transform: rotate(180deg); /* Safari & Chrome */
-o-transform: rotate(180deg); /* Opera */
}
每次遇到这样的属性,我们都要写一大段,非常麻烦。
使用 autoprefixer
插件,开发时只需要编写:
/* CSS input */
.animate {
tranform: rotate(180deg);
}
/* CSS output */
.animate {
transform: rotate(180deg);
-ms-transform: rotate(180deg); /* IE 9 */
-moz-transform: rotate(180deg); /* Firefox */
-webkit-transform: rotate(180deg); /* Safari & Chrome */
-o-transform: rotate(180deg); /* Opera */
}
PostCSS
会帮我们为属性加上针对不同浏览器的私有前缀。
2. 解决全局 CSS
问题。
项目的不同模块之间, CSS
选择器难免会出现命名冲突的情况,这会造成样式污染。~~~~
postcss-modules
插件,可以自动以组件为单位隔绝 CSS
选择器。
/* CSS input */
.name {
color: gray;
}
/* CSS output */
.Logo__name__SVK0g {
color: gray;
}
Vue
中,在 <style>
标签上加入 scoped
来隔绝组件之间的 CSS
样式,就是通过 PostCSS
来实现的。感兴趣的同学可以到 Vue Loader 官网 了解一下。
3. 避免 CSS 代码中的错误。
stylelint
插件,可以检测错误的 CSS
代码,并给予开发人员提示。 stylefmt
插件,可以根据 stylelint
规则自动优化 CSS
的格式。
/* CSS input */
a {
color: #d3;
}
# console output
app.css
2:10 Invalid hex color
是不是很像 eslint
?
4. 其他
除了上面三种主要的功能, PostCSS
还可以把 CSS
代码转换成任意我们期望的形式,只要我们为它提供对应的插件。比如我们在背景中谈到的把 px
转换成 rem
:
/* CSS input */
.container {
width: 200px;
height: 120px;
}
/* CSS output */
.container {
width: 2.66667rem;
height: 1.6rem;
}
还可以转换 @font-face
代码:
/* CSS input */
body {
font-family: "Alice";
}
/* CSS output */
@font-face {
font-family: "Alice";
font-style: normal;
font-weight: 400;
src: local("Alice"), local("Alice-Regular"),
url("http://fonts.gstatic.com/s/alice/v7/sZyKh5NKrCk1xkCk_F1S8A.eot?#") format("eot"),
url("http://fonts.gstatic.com/s/alice/v7/l5RFQT5MQiajQkFxjDLySg.woff2") format("woff2"),
url("http://fonts.gstatic.com/s/alice/v7/_H4kMcdhHr0B8RDaQcqpTA.woff") format("woff"),
url("http://fonts.gstatic.com/s/alice/v7/acf9XsUhgp1k2j79ATk2cw.ttf") format("truetype")
}
以及转换资源路径:
/* CSS input */
body {
background: url('foobar.jpg');
background: url('icons/baz.png');
}
/* CSS output */
body {
background: url('http://example.com/images/foobar.jpg');
background: url('http://example.com/images/baz.png');
}
PostCSS
和不同的插件结合使用,可以成为 CSS
预处理器、后处理器、代码优化工具,或者其他任何工具。
非常灵活。
我们还可以根据需求,开发属于自己的 PostCSS
插件。
3. 怎么使用 PostCSS ?
PostCSS
官方提供了很多种使用方式,而我们最常见的方式是在 Webpack
中用于处理 CSS
。
-
安装
postcss
和postcss-loader
。$ npm i -D postcss postcss-loader
-
在
webpack.config.js
文件中加入配置:module.exports = { module: { rules: [ { test: /\.css$/i, use: [ 'style-loader', 'css-loader', { loader: 'postcss-loader', options: { postcssOptions: { plugins: [ [ 'autoprefixer', { // 其他选项 }, ], ], }, }, }, ], }, ], }, };
-
也可以使用
PostCSS
的配置文件:// postcss.config.js module.exports = { plugins: [ [ 'autoprefixer', { // 其他选项 }, ], ], }; // webpack.config.js module.exports = { module: { rules: [ { test: /\.css$/i, use: ['style-loader', 'css-loader', 'postcss-loader'], }, ], }, };
-
这样
webpack
运行时就会使用PostCSS
来处理CSS
文件了。
4. PostCSS 原理
用 node
读取 CSS
文件内容,把内容交给 PostCSS
:
// node 的 fs 模块读取文件后,再传给 PostCSS
const css = fs.readFileSync("./index.css");
postcss([autoprefixer]).process(css);
1. 解析 CSS
在接到 CSS
代码之后,我们先把它以字符串格式存储起来:
this.css = css.toString();
然后我们需要对字符串进行扫描,把字符串解析成 token
格式:
// token
[
'word', // 类型
'.className', // 匹配的内容
1, // 起始位置
10 // 结束位置
]
-
为什么要解析成
token
格式呢?因为字符串格式的数据操作起来麻烦而且效率低下,而
token
格式对计算机来说易于理解和操作。 -
怎么才能解析成
token
格式呢?如果你对编译原理有所了解,那么就会知道,这个解析的过程被称为词法分析。
词法分析:从左向右逐行扫描源程序的字符,识别出各个单词,确定单词的类型。将识别出的单词转换成统一的词法单元
token
。
那么如何进行词法分析呢?
假设有这样一段 CSS
代码,我们来对它进行词法分析:
.my-font {
color: #FFF;
font-size: 12px;
}
那么我们首先需要定义一些变量,用来存储扫描时的数据:
class Tokenizer {
constructor(css) {
this.css = css.toString(); // 输入的 css 代码
this.length = css.length; // 字符串长度
this.pos = 0; // 储存当前位置
this.next; // 储存下一个位置
this.code = ""; // 当前字符的 charCode
this.currentToken; // 当前 token
this.tokens = []; // 结果 tokens
}
}
还需要声明一些变量,用来判断符号类型:
const NEWLINE = '\n'.charCodeAt(0); // 换行符
const SPACE = ' '.charCodeAt(0); // 空格
const TAB = '\t'.charCodeAt(0); // 制表符
const OPEN_CURLY = '{'.charCodeAt(0); // 左大括号
const CLOSE_CURLY = '}'.charCodeAt(0); // 右大括号
const SEMICOLON = ';'.charCodeAt(0); // 分号
const COLON = ':'.charCodeAt(0); // 冒号
以及查找符号的正则表达式:
const RE_WORD_END = /[\t\n\f\r !"#'():;@[\\\]{}]|\/(?=\*)/g;
接下来,我们再规定几种 token
的类型:
space 空格、换行符、制表符
word 单词
string 字符串
brackets 括号
; 分号
: 冒号
然后我们需要对 CSS
代码从左向右逐个字符进行扫描,一旦发现满足某一种 token
类型,那就把这段字符串截取,存储到 token
中,如下图所示:
我们定义一个方法 nextToken
来实现识别 token
的功能:
nextToken() {
if (this.pos >= this.length) return; // 如果指针位置超过文件长度,终止扫描
this.code = this.css.charCodeAt(this.pos); // 当前字符
// 识别不同类型的 token
switch (this.code) {
// 当前字符是空格、换行符、制表符时,存入类型为 space 的 token 中
// 如果是连续的空格、换行符、制表符的情况,会一并截取,存入一个 token 中
case NEWLINE:
case SPACE:
case TAB: {
this.next = this.pos;
do {
this.next += 1;
this.code = this.css.charCodeAt(this.next);
} while (
this.code === SPACE ||
this.code === NEWLINE ||
this.code === TAB
)
this.currentToken = ['space', this.css.slice(this.pos, this.next)];
this.pos = this.next - 1;
break;
}
// 当前字符如果是大括号,存入类型为 { 或者 } 的 token 中
case OPEN_CURLY:
case CLOSE_CURLY: {
let controlChar = String.fromCharCode(this.code);
this.currentToken = [controlChar, controlChar, this.pos];
break;
}
default: {
// 从当前位置到下一个符号之间的内容,存入类型为 word 的 token
RE_WORD_END.lastIndex = this.pos + 1;
RE_WORD_END.test(this.css);
if (RE_WORD_END.lastIndex === 0) {
this.next = this.css.length - 1
} else {
this.next = RE_WORD_END.lastIndex - 2
}
this.currentToken = ['word', this.css.slice(this.pos, this.next + 1), this.pos, this.next];
this.pos = this.next;
break;
}
}
this.pos ++
return this.currentToken
}
}
最后定义一个 getTokens
方法,让它循环调用 nextToken
方法,直到文件结尾:
// 使用 while 循环,逐个获取 token ,然后保存到 tokens 中,直到文件结尾
getTokens() {
let token;
while (!this.endOfFile()) {
this.nextToken();
this.tokens.push(this.currentToken);
}
return this.tokens;
}
// 判断是否扫描到文件的结尾
endOfFile() {
return this.pos >= this.length;
}
调用 getTokens
方法,我们就会得到 token
格式的数据:
[
[ 'word', '.my-font', 0, 7 ],
[ 'space', ' ' ],
[ '{', '{', 9 ],
[ 'space', ' \n ' ],
[ 'word', 'color', 14, 18 ],
[ 'word', ':', 19, 19 ],
[ 'space', ' ' ],
[ 'word', '#FFF', 21, 24 ],
[ 'word', ';', 25, 25 ],
[ 'space', ' \n\t' ],
[ 'word', 'font-size', 29, 37 ],
[ 'word', ':', 38, 38 ],
[ 'space', ' ' ],
[ 'word', '12px', 40, 43 ],
[ 'word', ';', 44, 44 ],
[ 'space', '\n' ],
[ '}', '}', 46 ],
[ 'space', '\n' ]
]
2. 生成 AST
AST
是由一个个节点 Node
组成的树形数据结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qCDxo0vh-1646982702883)(./images/AST.png)]
在得到 token
格式的数据后,PostCSS
根据不同的 token
的类型,进行不同的处理,生成不同的 Node
节点。
parse() {
let token;
// 逐个获取 token
while (!this.tokenizer.endOfFile()) {
token = this.tokenizer.nextToken();
// 不同的 token 类型,使用不同的处理方式。
switch (token[0]) {
// 评论类型的 token , 生成 commet 节点
case 'comment':
this.comment(token);
break;
// @类型的 token , 生成 atrule 节点
case 'at-word':
this.atrule(token);
break;
// ...
default:
// rule 和 decl 节点,在 other 方法里生成
this.other(token);
break;
}
}
this.endFile();
}
PostCSS
把节点分为 5 种类型:AtRule, Comment, Declaration, Root, Rule
,每种节点存储了详细的描述信息。以Rule
节点为例:
Rule {
raws: { before: '', between: ' ', semicolon: true, after: ' ' }, // 符号信息
type: 'rule', // 节点类型
nodes: [ [Declaration] ], // 子节点列表
parent: Root { // 父节点信息
raws: [Object],
type: 'root',
nodes: [Circular *1],
source: [Object],
[Symbol(isClean)]: false,
[Symbol(my)]: true
},
source: { start: [Object], input: [Input], end: [Object] }, // 源代码信息
selector: '.className', // 选择器
[Symbol(isClean)]: false,
[Symbol(my)]: true
}
并且 PostCSS
提供了操作 AST
的 API
,例如:walkRules
、 insertBefore
、 removeChild
等,详细文档可以查看: PostCSS API 文档
3. 调用插件
得到了完整的 AST
之后,PostCSS
会把它交给插件来处理:
// 用 parser 把 css 解析成 AST ,保存到 root
let root = parser(css, opts)
// 实例化一个 Result ,用来保存结果
this.result = new Result(processor, root, opts)
// 遍历插件列表
for(let i = 0; i < this.plugins.length; i ++) {
let plugin = this.plugins[i]
let promise = this.runOnRoot(plugin);
}
// 把 AST 交给插件处理
runOnRoot(plugin) {
return plugin(this.result.root, this.result)
}
插件内部利用 API
对 AST
进行操作,我们以 postcss-pxtorem
为例:
css => {
// 遍历 AST 的所有属性节点 decl
css.walkDecls((decl, i) => {
// 属性值中没有 px 的不处理
if (decl.value.indexOf("px") === -1) return;
// 有 px 的,把属性值换算为 rem
const value = decl.value.replace(pxRegex, pxReplace);
decl.value = value;
});
}
4. 字符串化
在插件处理完 AST
之后,PostCSS
使用 Stringifier
把 AST
转换回字符串:
// 注释节点,会在两端拼接 /* */
comment(node) {
let left = this.raw(node, 'left', 'commentLeft')
let right = this.raw(node, 'right', 'commentRight')
this.builder('/*' + left + node.text + right + '*/', node)
}
// at选择器会在前面拼接@
atrule(name, semicolon) {
let name = "@" + node.name;
// ...
}
// 属性节点会根据情况在末尾拼接 ; 和 !important
decl(node, semicolon) {
let between = this.raw(node, 'between', 'colon')
let string = node.prop + between + this.rawValue(node, 'value')
if (node.important) {
string += node.raws.important || ' !important'
}
if (semicolon) string += ';'
this.builder(string, node)
}
最后我们再把字符串写入一个 CSS
文件,就得到了新的 CSS
代码。
fs.writeFile("dest/app.css", result.css);