工作中用到的框架 css 处理有以下问题,需要用 postcss 做些自动处理。
- 同名 class 后者会覆盖前者:
.a{color: #fff}
.a{background: #fff}
,后者生效 - 最多嵌套两层:
.a .b .c {}
不生效
通过本文学习了解什么是 postcss,以及如何通过 postcss 做一些工作。
简介
从其名字 postcss 可以看出早期是被当做后处理器的。也就是处理less/sass 编译后的 css。最常用的插件就是 autoprefixer,根据浏览器版本添加兼容前缀。
postcss 像 babel 一样会把 style 转成 ast,经过一系列插件转换,再将转换后的 as t生成新的 style。随着发展,用后处理器形容postcss 已经不合适了。目前可以使用 postcss-sass/postcss-less 对 less/sass 代码进行转化(将 less/sass 转化为 less/sass,而不是直接转化为 css),也可以使用 precss 代替 sass(感觉还不太成熟)。
所以目前推荐的还是 postcss 和 less/sass 结合使用,在 webpack 配置中,postcss-loader 要写在 sass-loader/less-loader 前面。
module.exports = {
module: {
rules: [
{
test: /\.(css|less)$/i,
use: ['style-loader', 'css-loader', 'postcss-loader', 'less-loader'],
},
]
}
}
关于更多 postcss 的用途,可以参考 github.com/postcss/pos…
工作流程
大致步骤:
- 将 CSS 字符串生成 Tokens
- 将 Tokens 通过规则生成 AST 树
- 将 AST 树传给插件进行处理
- 将处理后的 AST 树生成新的css资源(包括css字符串、sourceMap等)
即如图所示的步骤: CSS Input → Tokenizer → Parser → AST → Plugins → Stringifier 举个?:
@import url('./default.css');
body {
padding: 0;
/* margin: 0; */
}
1. input
'@import url('./default.css');\nbody {\n padding: 0;\n /* margin: 0; */\n}\n'
2. tokenizer
tokenizer 包括以下几个方法:
- back:back方法会设置 nextToken 的下次调用的返回值。
- nextToken:获取下一个 token。
- endOfFile:判断文件是否结束。
- position:获取当前 token 的位置。
// tokenize.js的nextToken方法 简化代码
function nextToken(opts) {
// 如果之前调用了back方法,下一次调用nextToken将会返回back方法设置的token
if (returned.length) return returned.pop()
code = css.charCodeAt(pos)
// 判断每一个字符
switch (code) {
case xxx:
break;
}
pos++
return currentToken
}
nextToken 方法判断每一个字符,并生成如下类型的 token:
space:
- \n:换行
- :空格
- \f :换页
- \r:回车
- \t:水平制表符
// 空格、换行、制表符、回车等,都被当做space类型token
case NEWLINE:
case SPACE:
{
next = pos
// 循环,将连续的空格、换行、回车等作为一个token
do {
next += 1
code = css.charCodeAt(next)
} while (
// 如果是
)
// 截取token的值
currentToken = ['space', css.slice(pos, next)]
pos = next - 1
break
}
string:
- ':单引号
- ":双引号
// 单引号,双引号之间的内容会被当成string类型token
case SINGLE_QUOTE:
case DOUBLE_QUOTE: {
quote = code === SINGLE_QUOTE ? "'" : '"'
next = pos
do {
next = css.indexOf(quote, next + 1)
if (next === -1) {
if (ignore || ignoreUnclosed) {
next = pos + 1
break
} else {
unclosed('string')
}
}
} while (escaped)
currentToken = ['string', css.slice(pos, next + 1), pos, next]
pos = next
break
}
at-word
:@ 和其后面连着的字符会被当成 at-wrod 类型 token
@*
:at符
case AT: {
currentToken = ['at-word', css.slice(pos, next + 1), pos, next]
pos = next
break
}
[和]
:中括号
)
:右括号
{和}
:大括号
;
:分号
:
:冒号
都是独立的 token 类型。
// []{}:;)等都是独立的token类型
case OPEN_SQUARE:
case CLOSE_SQUARE:
case OPEN_CURLY:
case CLOSE_CURLY:
case COLON:
case SEMICOLON:
case CLOSE_PARENTHESES: {
let controlChar = String.fromCharCode(code)
currentToken = [controlChar, controlChar, pos]
break
}
(和brackets
- url():url() 值没有单引号、双引号包裹的,当做brackets类型 token
- url(''):如果没有右括号),或者匹配到正则,当成(类型token,比如url('')
- var(--main-color):否则当成 brackets 类型 token,比如var(--main-color)
// 左括号特殊处理
case OPEN_PARENTHESES: {
prev = buffer.length ? buffer.pop()[1] : ''
n = css.charCodeAt(pos + 1)
// url()值没有单引号、双引号包裹的,当做brackets类型token
if (
prev === 'url' &&
n !== SINGLE_QUOTE &&
n !== DOUBLE_QUOTE &&
) {
next = pos
do {
escaped = false
next = css.indexOf(')', next + 1)
if (next === -1) {
if (ignore || ignoreUnclosed) {
next = pos
break
} else {
unclosed('bracket')
}
}
} while (escaped)
currentToken = ['brackets', css.slice(pos, next + 1), pos, next]
pos = next
} else {
next = css.indexOf(')', pos + 1)
content = css.slice(pos, next + 1)
// 如果没有右括号),或者匹配到正则,当成(类型token,比如url('')
if (next === -1 || RE_BAD_BRACKET.test(content)) {
currentToken = ['(', '(', pos]
} else {
// 否则当成brackets类型token,比如var(--main-color)
currentToken = ['brackets', content, pos, next]
pos = next
}
}
break
}
comment
: 默认会被当成 comment 类型和 word 类型 token
- /:斜线
- *:通配符
word
:
- \:反斜线
default: {
if (code === SLASH && css.charCodeAt(pos + 1) === ASTERISK) {
next = css.indexOf('*/', pos + 2) + 1
if (next === 0) {
if (ignore || ignoreUnclosed) {
next = css.length
} else {
unclosed('comment')
}
}
currentToken = ['comment', css.slice(pos, next + 1), pos, next]
pos = next
} else {
RE_WORD_END.lastIndex = pos + 1
RE_WORD_END.test(css)
if (RE_WORD_END.lastIndex === 0) {
next = css.length - 1
} else {
next = RE_WORD_END.lastIndex - 2
}
currentToken = ['word', css.slice(pos, next + 1), pos, next]
buffer.push(currentToken)
pos = next
}
break
}
经过 tokenizer 处理成如下的 tokens:
[ 'at-word', '@import', 0, 6 ]
[ 'space', ' ' ]
[ 'word', 'url', 8, 10 ]
[ '(', '(', 11 ]
[ 'string', "'./default.css'", 12, 26 ]
[ ')', ')', 27 ]
[ ';', ';', 28 ]
[ 'space', '\n' ]
[ 'word', 'body', 30, 33 ]
[ 'space', ' ' ]
[ '{', '{', 35 ]
[ 'space', '\n ' ]
[ 'word', 'padding', 39, 45 ]
[ ':', ':', 46 ]
[ 'space', ' ' ]
[ 'word', '0', 48, 48 ]
[ ';', ';', 49 ]
[ 'space', '\n ' ]
[ 'comment', '/* margin: 0; */', 53, 68 ]
[ 'space', '\n' ]
[ '}', '}', 70 ]
[ 'space', '\n' ]
可以看到,token是一个数组,以第一个token为例,数据结构如下
[
'at-word', // 类型
'@import', // 值
0, // 起始位置
6 // 终止位置
]
3. parser
parser 会循环调用 tokenizer 的 nextToken 方法,直到文件结束。在循环过程中使用一些算法和条件判断去创建节点然后构建 AST。上面例子生成的 AST 如下:
3.1 节点
Node 和 Container 节点的基础类,其中 Container 继承自 Node。AST 由下面几种节点组成
- Root: 继承自 Container。AST 的根节点,代表整个 css 文件
- AtRule: 继承自 Container。以 @ 开头的语句,核心属性为 params,例如:@import url('./default.css'),@keyframes shaking {}。params 为url('./default.css')
- Rule: 继承自 Container。带有声明的选择器,核心属性为 selector,例如:body {},selector为body
- Declaration:继承自 Node。声明,是一个键值对,核心属性为 prop,value,例如:padding: 0,prop为padding,value为0
- Comment: 继承自 Node。标准的注释/* 注释 */,如图所示 text 为margin: 0;
节点包括一些通用属性
-
type:节点类型
-
parent:父节点
-
source:存储节点的资源信息,计算 sourcemap
- input:输入
- start:节点的起始位置
- end:节点的终止位置
-
raws:存储节点的附加符号,分号、空格、注释等,在 stringify 过程中会拼接这些附加符号
通用:
- before:The space symbols before the node. It also stores
*
and_
symbols before the declaration (IE hack).
作用于Rule:
- after:The space symbols after the last child of the node to the end of the node. 最后一个子节点和节点末尾之间的space符号
- between:The symbols between the selector and
{
for rules 。selector和{之间的符号 - semicolon:Contains
true
if the last child has an (optional) semicolon.最后一个子节点有;则为true - ownSemicolon: Contains
true
if there is semicolon after rule.如果rule后面有;则为true
作用于Comment
- left:The space symbols between
/*
and the comment’s text. /* 和注释内容之间的space符号 - right:The space symbols between the comment’s text. */和注释内容之间的space符号 作用于Declaration
- important:The content of the important statement. 是否是important
- value: Declaration value with comments. 带有注释的声明值。
- before:The space symbols before the node. It also stores
节点有各自的API,具体可以看PostCSS API
3.2 生成过程
class Parser {
parse() {
let token
while (!this.tokenizer.endOfFile()) {
token = this.tokenizer.nextToken()
switch (token[0]) {
case 'space':
this.spaces += token[1]
break
case ';':
this.freeSemicolon(token)
break
case '}':
this.end(token)
break
case 'comment':
this.comment(token)
break
case 'at-word':
this.atrule(token)
break
case '{':
this.emptyRule(token)
break
default:
this.other(token)
break
}
}
this.endFile()
}
}
先创建 root 节点,并将 current(当前节点)设置为 root,使用tokens 变量存储已经遍历但还未使用过的 token。
- 遇到
at-rule
token,创建 atRule 节点 - 遇到
{
token,创建 rule 节点- 将 tokens 中存储的token生成 rule 的 selector 属性
- 将 current 设置为 rule 节点
- 将 rule 节点 push 到 current 的 nodes 中
- 遇到
;
token,创建 decl 节点。有一种特殊情况:declaration 是以;分隔的,如果是最后一条规则,可以不带;,比如.a{color: blue},- 将 decl 节点 push 到 current 的 nodes 中。
- 遇到
comment
token,就创建comment节点 - 遇到
}
token,认为当规则结束- 将 current 设置为 current.parent(当前节点的父节点)
具体过程可以看源码: github.com/postcss/pos…
用到的算法:
4. plugins
然后会调用plugins修改Parser得到的AST树。plugins的执行在lazy-result.js中。
class LazyResult {
get [Symbol.toStringTag]() {
return 'LazyResult'
}
get processor() {
return this.result.processor
}
get opts() {
return this.result.opts
}
// 获取css
get css() {
return this.stringify().css
}
get content() {
return this.stringify().content
}
get map() {
return this.stringify().map
}
// 获取root
get root() {
return this.sync().root
}
get messages() {
return this.sync().messages
}
}
在访问result.css、result.map、result.root时均会执行对应的函数。
stringify() {
if (this.error) throw this.error
if (this.stringified) return this.result
this.stringified = true
// 同步执行插件
this.sync()
let opts = this.result.opts
let str = stringify
if (opts.syntax) str = opts.syntax.stringify
if (opts.stringifier) str = opts.stringifier
if (str.stringify) str = str.stringify
// 生成map和css
let map = new MapGenerator(str, this.result.root, this.result.opts)
let data = map.generate()
this.result.css = data[0]
this.result.map = data[1]
return this.result
}
在访问result.css时会先同步执行插件,然后用处理后的AST去生成css和sourcemap
sync() {
if (this.error) throw this.error
if (this.processed) return this.result
this.processed = true
if (this.processing) {
throw this.getAsyncError()
}
for (let plugin of this.plugins) {
let promise = this.runOnRoot(plugin)
if (isPromise(promise)) {
throw this.getAsyncError()
}
}
// 先收集访问器
this.prepareVisitors()
if (this.hasListener) {
let root = this.result.root
// 如果root脏了,在root节点重新执行插件
while (!root[isClean]) {
root[isClean] = true
this.walkSync(root)
}
if (this.listeners.OnceExit) {
this.visitSync(this.listeners.OnceExit, root)
}
}
return this.result
}
新版插件支持访问器,访问器有两种类型:Enter 和 Exit。Once,Root,AtRule,Rule,Declaration,Comment等会在处理子节点之前调用,OnceExit,RootExit,AtRuleExit...等会在所有子节点处理完成后调用。其中 Declaration 和 AtRule 支持属性的监听,比如:
module.exports = (opts = {}) => {
Declaration: {
color: decl => {}
'*': decl => {}
},
AtRule: {
media: atRule => {}
}
Rule(){}
}
prepareVisitors 方法会搜集这些访问器,添加到 listeners 中,以上面的代码为例,会添加Declaration-color,Declaration*,AtRule-media,Rule等访问器。
prepareVisitors() {
this.listeners = {}
let add = (plugin, type, cb) => {
if (!this.listeners[type]) this.listeners[type] = []
this.listeners[type].push([plugin, cb])
}
for (let plugin of this.plugins) {
if (typeof plugin === 'object') {
for (let event in plugin) {
if (!NOT_VISITORS[event]) {
if (typeof plugin[event] === 'object') {
for (let filter in plugin[event]) {
if (filter === '*') {
add(plugin, event, plugin[event][filter])
} else {
add(
plugin,
event + '-' + filter.toLowerCase(),
plugin[event][filter]
)
}
}
} else if (typeof plugin[event] === 'function') {
add(plugin, event, plugin[event])
}
}
}
}
}
this.hasListener = Object.keys(this.listeners).length > 0
}
然后在 walkSync 过程中,会判断该节点可以拥有的访问器类型,如果是 CHILDREN 则递归调用子节点,如果是其他可执行的访问器比如 Rule,则会执行访问器。
walkSync(node) {
node[isClean] = true
let events = getEvents(node)
for (let event of events) {
if (event === CHILDREN) {
if (node.nodes) {
node.each(child => {
if (!child[isClean]) this.walkSync(child)
})
}
} else {
let visitors = this.listeners[event]
if (visitors) {
if (this.visitSync(visitors, node.toProxy())) return
}
}
}
}
如果在节点上执行一些有副作用的操作,比如append、prepend、remove、insertBefore、insertAfter等,会循环向上标记副作用node[isClean] = false
,直到root[isClean] = false
。会导致再次执行插件,甚至会导致死循环。
5. stringifier
stringifier 从 root 开始,层序遍历 AST 树,根据节点类型,拼接节点的数据为字符串。
// stringifier.js 简化代码
class Stringifier {
constructor(builder) {
this.builder = builder
}
stringify(node, semicolon) {
// 调用对应类型节点
this[node.type](node, semicolon)
}
// root节点处理
root(node) {
this.body(node)
if (node.raws.after) this.builder(node.raws.after)
}
// root节点子节点处理
body(node) {
let last = node.nodes.length - 1
while (last > 0) {
if (node.nodes[last].type !== 'comment') break
last -= 1
}
let semicolon = this.raw(node, 'semicolon')
for (let i = 0; i < node.nodes.length; i++) {
let child = node.nodes[i]
let before = this.raw(child, 'before')
if (before) this.builder(before)
this.stringify(child, last !== i || semicolon)
}
}
// comment类型节点拼接
comment(node) {}
// decl类型节点拼接
decl(node, semicolon) {}
// rule类型节点拼接
rule(node) {}
// at-rule类型节点拼接
atrule(node, semicolon) {}
// block节点处理,rule,at-rule(@media等)
block(node, start){}
// raw信息处理
raw(){}
}
root、body、comment、decl、rule、atrule、block、raw等是不同类型节点、信息的字符串拼接函数。 整个过程从root开始,做层序遍历,root→body→rule/atrule/comment/decl,然后通过 builder 拼接字符串。builder 是一个拼接函数:
const builder = (str, node, type) => {
this.css += str
}
插件plugins
1. 老写法
const postcss = require('postcss');
module.exports = postcss.plugin('postcss-plugin-old', function (opts) {
return function (root) {
root.walkRules((rule) => {
if (rule.selector === 'body') {
rule.append(postcss.decl({ prop: 'margin', value: '0' }));
rule.append(postcss.decl({ prop: 'font-size', value: '14px' }));
}
});
};
});
老写法需要引入 postcss,所以插件需要将 postcss 设置为 peerDependence,然后使用 postcss 的 api 去操作 AST。
2. 新写法
// 使用symbol标记处理过的节点
const processd = Symbol('processed');
module.exports = (opts = {}) => {
return {
postcssPlugin: 'postcss-plugin-new',
Once() {},
OnceExit(root) {
root.walkDecls((decl) => {
// 删除节点
if (decl.prop === 'color') {
decl.value = '#ee3';
}
});
},
Rule(rule, { Declaration }) {
if (!rule[processd]) {
if (rule.selector === 'body') {
rule.append(new Declaration({ prop: 'color', value: '#333' }));
}
rule[processd] = true;
}
},
Declaration: {
padding: (decl) => {},
margin: (decl) => {
if (decl.value === '0') {
decl.value = '10px';
}
},
},
DeclarationExit() {},
prepare(result){
const variables = {};
return {
Declaration(){}
OnceExit(){}
}
}
};
};
module.exports.postcss = true;
新写法不再需要引入 postcss,而且新增了访问器(Visitor)。
- 访问器有 Enter 和 Exit 两种,比如 Declaration 会在访问 decl 节点时执行,DeclarationExit 会在所有 Declaration 访问器处理完之后再处理。
- 可以利用 prepare() 动态生成访问器。
- 访问器的第一个参数是访问的节点 node,可以直接调用 node 的方法进行操作。
- 访问器的第二个参数是{ ...postcss, result: this.result, postcss },方便调用 postcss 上的方法。
更多可以参考官方文档 write-a-plugin
语法syntax
postcss-less 和 postcss-scss 都属于 syntax,只能识别这种语法,然后进行转换,并不会编译生成css
内部实现也都是继承 Tokenizer 和 Parser 类,并重写内部部分方法。
比如 css 是不支持//注释得,如果我们不指定 syntax 为 postcss-scss,postcss会因为不识别//而报错CssSyntaxError: Unknown word,如果写一个 syntax 支持这种语法呢?
- 首先 tokenizer 需要识别//为 comment 类型 token
function nextToken(){
// ...
if(){
} else if (code === SLASH && n === SLASH) {
RE_NEW_LINE.lastIndex = pos + 1
RE_NEW_LINE.test(css)
if (RE_NEW_LINE.lastIndex === 0) {
next = css.length - 1
} else {
next = RE_NEW_LINE.lastIndex - 2
}
content = css.slice(pos, next + 1)
// inline表示是//注释
currentToken = ['comment', content, pos, next, 'inline']
pos = next
}
}
- 然后 parser 需要将其构建为 node 节点,并存储 source,raws 等信息。
class ProParser extends Parser{
comment (token) {
if (token[4] === 'inline') {
let node = new Comment()
this.init(node, token[2])
node.raws.inline = true
let pos = this.input.fromOffset(token[3])
node.source.end = { offset: token[3], line: pos.line, column: pos.col }
let text = token[1].slice(2)
if (/^\s*$/.test(text)) {
node.text = ''
node.raws.left = text
node.raws.right = ''
} else {
let match = text.match(/^(\s*)([^]*\S)(\s*)$/)
let fixed = match[2].replace(/(\*\/|\/\*)/g, '*//*')
node.text = fixed
node.raws.left = match[1]
node.raws.right = match[3]
node.raws.text = match[2]
}
} else {
super.comment(token)
}
}
}
- 最后 stringifier 需要将其拼接为字符串
class ProStringifier extends Stringifier {
comment (node) {
let left = this.raw(node, 'left', 'commentLeft')
let right = this.raw(node, 'right', 'commentRight')
if (node.raws.inline) {
let text = node.raws.text || node.text
this.builder('//' + left + text + right, node)
} else {
this.builder('/*' + left + node.text + right + '*/', node)
}
}
}
解决开篇
针对开篇的场景,思路是:
- 根据 selector 拆分,比如
.a .b{}
拆分成.a{},.b{}
,并将前后同名 selector 的 rule 的 declaration 进行合并。 - 对 selector 进行
split(' ')
,length>2
的进行裁剪处理
module.exports = (options = {}) => {
return {
postcssPlugin: 'postcss-plugin-crop-css',
Once (root, { postcss }) {
const selectorRuleMap = new Map()
root.walkRules((rule) => {
const { selector } = rule
const selectorUnits = selector.split(',')
for (let selectorUnit of selectorUnits) {
let selectorUnitArr = selectorUnit.split(' ')
// 选择器超过两层,报错
if (selectorUnitArr.length > 2) {
throw rule.error('no more than two nested levels')
}
const selectorCrop = selectorUnitArr.join(' ').replace('\n', '')
const existSelectorRule = selectorRuleMap.get(selectorCrop)
const nodes = existSelectorRule ? [existSelectorRule.nodes, rule.nodes] : rule.nodes
const newRule = new postcss.Rule({
selector: selectorCrop,
source: rule.source,
nodes
})
selectorRuleMap.set(selectorCrop, newRule)
}
rule.remove()
})
selectorRuleMap.forEach(selectorRule => {
root.append(selectorRule)
})
}
}
}
module.exports.postcss = true
参考
- postcss
广告
字节跳动游戏发行&小游戏前端组有坑位,简历请发到wangyichen.33@bytedance.com
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!