Postcss了解一下

4,095 阅读2分钟

工作中用到的框架 css 处理有以下问题,需要用 postcss 做些自动处理。

  • 同名 class 后者会覆盖前者:.a{color: #fff} .a{background: #fff},后者生效
  • 最多嵌套两层:.a .b .c {}不生效 通过本文学习了解什么是 postcss,以及如何通过 postcss 做一些工作。

简介

postcss:PostCSS is a tool for transforming styles with JS plugins. These plugins can lint your CSS, support variables and mixins, transpile future CSS syntax, inline images, and more. postcss是使用用js插件转换样式的工具。这些插件可以校验css,支持variables和mixins,编译未来css语法,内联图片等等。

从其名字 postcss 可以看出早期是被当做后处理器的。也就是处理less/sass 编译后的 css。最常用的插件就是 autoprefixer,根据浏览器版本添加兼容前缀。

流程图.png

postcss 像 babel 一样会把 style 转成 ast,经过一系列插件转换,再将转换后的 ast 生成新的 style。随着发展,用后处理器形容postcss 已经不合适了。目前可以使用 postcss-sass/postcss-less 对 less/sass 代码进行转化(将 less/sass 转化为 less/sass,而不是直接转化为 css),也可以使用 precss 代替 sass(感觉还不太成熟)。

未命名文件 (1).png

所以目前推荐的还是 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…

工作流程

未命名文件 (3).png

大致步骤:

  • 将 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 如下:

image.png

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. 带有注释的声明值。 节点有各自的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…

用到的算法:

  1. 深度优先遍历
  2. 匹配的括号

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 支持这种语法呢?

  1. 首先 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
    }
}
  1. 然后 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)
        }
    }
}
  1. 最后 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)
        }
     }
 }

解决开篇

针对开篇的场景,思路是:

  1. 根据 selector 拆分,比如.a .b{}拆分成.a{},.b{},并将前后同名 selector 的 rule 的 declaration 进行合并。
  2. 对 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

参考

  1. postcss