手寫一個簡單的CSS預處理器

583 阅读2分钟

看拉勾教育的 前端高手进阶 教人怎样写CSS预处理器,觉得学到不少知识,打算写篇笔记记录下来。

目标

CSS预处理器要实现的功能如下:

  1. 用空格和换行符替代花括号丶冒号和分号;
  2. 支持选择器的嵌套组合;
  3. 支持以“$”符号开头的变量定义和使用。

举一个例子以更好了解要实现的功能。我们希望能把下面的样式表:

$ib inline-block

$borderColor lightgreen

div

  p

    border 1px solid $borderColor

  color darkkhaki

  .a-b

    background-color lightyellow

    [data]

      padding 15px

      font-size 12px

.d-ib

  display $ib

转换成普通CSS样式表:

div {color:darkkhaki;}

div p {border:1px solid lightgreen;}

div .a-b {background-color:lightyellow;}

div .a-b [data] {padding:15px;font-size:12px;}

.d-ib {display:inline-block;}

因此我们需要编写一个 编译器,所谓编译器就是把一种语言转换为另一种语言,例如gcc把c语言转换为exe文档等。

不同语言的编译器的工作流程有些差异,但大体上可以分成三个步骤:解析(Parsing)丶转换(Transformation)及代码生成(Code Generation)。

解析: 词法分析

解析分为两步,词法分析和语法分析。

词法分析就是把字符串分成一个个单词,然後把它们分类,例如高级语言的编译器中,把单词分为变量,常量和运算符等等。这些分类好的单词叫 令牌 (token) 。 把样式表分类好的单词如下:

{
  type: "variableDef" | "variableRef" | "selector" | "property" | "value", //枚举值,分别对应变量定义丶变量引用丶选择器丶属性丶值
  value: string, // token字符值,即被分解的字符串
  indent: number // 缩进空格数,需要根据它判断从属关系
}

现在的问题是要怎样用代码实现? 词法分析比较简单,把字符串拆分一个个单词,用正则表达式判断单词属於哪一类。当然,因为这个CSS预处理器比较简单,所以可以用正则表达式,如果类别非常多,可能需要用其他方式实现。 把判断条件列出来:

  1. variableDef,以 $ 符号开头,该行前面无其他非空字符串,即$hello: world$hello
  2. variableRef,以 $ 符号开头,该行前面有非空字符串,即 background-color: $hello$hello
  3. selector,独占一行,该行无其他非空字符串;
  4. property,以字母开头,该行前面无其他非空字符串,即 background-color: $hellobackground-color
  5. value,非该行第一个字符串,且该行第一个字符串为 property 或 variableDef 类型。

实现代码如下:

function tokenize(text) {
  // 去除多余的空格,逐行解析
  return text.trim().split(/\n|\r\n/).reduce((tokens, line, idx) => {
    // 计算缩进空格数
    const spaces = line.match(/^\s+/) || ['']  //匹配开头的空格
    const indent = spaces[0].length //找出匹配到的空格数
    // 将字符串去首尾空给
    const input = line.trim()
    // 通过空格分割字符串成数组
    const words = input.split(/\s/)
    let value = words.shift()
    // 选择器为单个单词
    if (words.length === 0) {
      tokens.push({
        type: 'selector',
        value,
        indent
      })
    } else { //不是单词
      // 这里对变量定义和变量引用做一下区分,方便后面语法分析
      let type = ''
      if (/^\$/.test(value)) { //单词首字符为 $
        type = 'variableDef'
      } else if (/^[a-zA-Z-]+$/.test(value)) {
        type = 'property'
      } else {
        throw new Error(`Tokenize error:Line ${idx} "${value}" is not a vairable or property!`)
      }
      tokens.push({
        type,
        value,
        indent
      })
      // 为了后面解析方便这里对变量引用和值进行区分
      while (value = words.shift()) {
        tokens.push({
          type: /^\$/.test(value) ? 'variableRef' : 'value',
          value,
          indent: 0
        })
      }
    }
    return tokens;
  }, [])
}

解析: 句法分析

句法分析就是把之前的令牌之间的关系抽象表示出来,通常就是把它们转换成 抽象语法树 (AST),前端中使用AST最典型的例子就是 Babel。CSS的句法中,最需要关注的是 选择器的属性父子关系,因此需要rule和children表示:

  1. rules,存储当前选择器的样式属性和值组成的对象,其中值以字符串数组的形式存储;
  2. children,子选择器节点。

AST的数据结构如下:

{
  type'root',
  children: [{
    type'selector',
    value: string
    rules: [{
      property: string,
      value: string[],
    }],
    indent: number,
    children: []
  }]
}

由于考虑到一个属性的值可能会由多个令牌组成,比如 border 属性的值由“1px” “solid” “$borderColor” 3 个令牌组成,所以将 value 属性设置为字符串数组。

语法分析代码如下所示。首先定义一个根节点,然后按照先进先出的方式遍历令牌数组,遇到变量定义时,将变量名和对应的值存入到缓存对象中;当遇到属性时,插入到当前选择器节点的 rules 属性中,遇到值和变量引用时都将插入到当前选择器节点 rules 属性数组最后一个对象的 value 数组中,但是变量引用在插入之前需要借助缓存对象的变量值进行替换。当遇到选择器节点时,则需要往对应的父选择器节点 children 属性中插入,并将指针指向被插入的节点,同时记得将被插入的节点添加到用于存储遍历路径的数组中。

以上是引用至课程的段落,文字有点复杂,代码更复杂 (=_=),我在代码里用注释慢慢解释,尽可能说清楚:

// token 参数是之前解析的令牌
function parse(tokens) {
  var ast = {  // ast是要生成的树,最初的根节点为root
    type: 'root',
    children: [],
    indent: -1
  };
  // 记录当前遍历路径
  let path = [ast]
  // 指针,指向上一个选择器结点
  let preNode = ast
  // 便利到的当前结点
  let node
  // 用来存储变量值的对象
  let vDict = {}
  while (node = tokens.shift()) {
    // 对于变量引用,直接存储到vDict中
    if (node.type === 'variableDef') {
      if (tokens[0] && tokens[0].type === 'value') {
        const vNode = tokens.shift()
        vDict[node.value] = vNode.value
      } else { // 如果不是变量定义,即为变量定义的值是对另一变量的引用
        preNode.rules[preNode.rules.length - 1].value = vDict[node.value]
        // 先把之前缓存变量对应的值取出,然後赋值给之前节点rules的值
      }
      continue;
    }
    // 对于属性,在指针指向的结点rules属性中添加属性
    if (node.type === 'property') {
      if (node.indent > preNode.indent) { // 当前节点的缩进空格大於之前节点的空格,即当前节点为子节点
        preNode.rules.push({
          property: node.value,
          value: []
        })
      } else { // 小於的情况,即不为子节点
        let parent = path.pop() // 需要找出当前节点的父节点
        while (node.indent <= parent.indent) {
          parent = path.pop()
        }
        parent.rules.push({
          property: node.value,
          value: []
        })
        preNode = parent
        path.push(parent) 
      }
      continue;
    }
    // 对于值,添加到value数组中
    if (node.type === 'value') {
      try {
        preNode.rules[preNode.rules.length - 1].value.push(node.value); // 然後赋值给之前节点rules的值
      } catch (e) {
        console.error(preNode)
      }
      continue;
    }
    // 对于变量引用,直接替换成对应的值,道理与之前 'variableDef'的情况类似
    if (node.type === 'variableRef') {
      preNode.rules[preNode.rules.length - 1].value.push(vDict[node.value]);
      continue;
    }
    // 对于选择器需要创建新的结点,并将指针
    if (node.type === 'selector') {
      const item = {
        type: 'selector',
        value: node.value,
        indent: node.indent,
        rules: [],
        children: []
      }
      if (node.indent > preNode.indent) { // 当前节点的缩进空格大於之前节点的空格,即当前节点为子节点
        path[path.length - 1].indent === node.indent && path.pop() // 缩进空格相等,即为兄弟节点
        path.push(item)
        preNode.children.push(item);
        preNode = item;
      } else { // 小於的情况,即不为子节点
        let parent = path.pop()
        while (node.indent <= parent.indent) {
          parent = path.pop()
        }
        parent.children.push(item)
        path.push(item)
      }
    }
  }
  return ast;
}

转换

之後开始比较简单,在这个过程中,AST 中的节点可以被修改和删除,也可以新增节点。根本目的就是为了代码生成的时候更加方便。它算是一个 中转点,把AST转变成目标样式表的结构。数据结构如下:

{
 selector: string,
 rules: {
   property: string,
   value: string
 }[]
}[]

实现代码主要方法是递回遍历AST,先把相应资料抽取,合并成新的对象,然後找一下有没有子节点,有的话,遍历子节点,递归调用函数。代码如下:

function transform(ast) {
let newAst = [];
/**
 * 遍历AST转换成数组,同时将选择器和值拼接起来
 * @param node AST结点
 * @param result 抽象语法数组
 * @param prefix 当前遍历路径上的选择器名称组成的数组
 */
function traverse(node, result, prefix) {
  let selector = ''
  if (node.type === 'selector') {
    selector = [...prefix, node.value]; //  把选择器合并
    result.push({
      selector: selector.join(' '),
      rules: node.rules.reduce((acc, rule) => {  
        acc.push({
          property: rule.property,
          value: rule.value.join(' ')
        })
        return acc;
      }, [])
    })
  }
  for (let i = 0; i < node.children.length; i++) {  // 有子节点,递归处理
    traverse(node.children[i], result, selector) 
  }
}
traverse(ast, newAst, [])
return newAst;
}

代码生成

把刚才转换的新的抽象语法结构变成目标代码:

function generate(nodes) {
// 遍历抽象语法数组,拼接成CSS代码
return nodes.map(n => {
  let rules = n.rules.reduce((acc, item) => acc += `${item.property}:${item.value};`, '') // 先把对象rules属性变成css样式
  return `${n.selector} {${rules}}` // 返回数组形式的css样式表
}).join('\n') 
}