手写CSS处理器

779 阅读4分钟

CSS处理器

什么是CSS预处理器?

CSS预处理定义了一种新的语言,其基本思想是:用一种专门的编程语言,为CSS增加了一些编程的特性,将CSS作为目标生成文件,然后开发者就只要使用这种语言进行编码工作。

通俗的说,CSS预处理器用一种专门的编程语言,进行Web页面样式设计,然后在编译程正常的CSS文件。

百花齐放

CSS处理器是一个能让你通过预处理器自己独有的预发来生成CSS程序。

市面上有很多CSS预处理可供选择,且绝大多数CSS预处理器会增加一些原生CSS不具备或不完善的高级特性,这些特性让CSS的结构更加具有可读性且易于维护。当前社区代表的CSS预处理器 主要有一下几种:

  • Sass 2007年诞生,最早也是最成熟的CSS预处理器,拥有Ruby社区和支持的Compass这一最强大的CSS框架,目前受LESS影响,已经进化到了全面兼容CSS的SCSS。
  • Less: 2009年出现,受SASS的影响比较大,但又使用CSS的语法,让大部分开发者和设计师更容易上手,再Ruby社区之外支持者远超过SASS,其缺点是比起SASS来,可编程功能不够,不过优点是简单和兼容CSS,反过来也影响来SASS演变到来SCSS的时代,著名大Twitter Bootstrap就是采用LESS做底层语言大。
  • Stylus: Stylus 是一个CSS 的预处理框架,2010年产生,来自Node.js社区,主要用来给Node项目进行CSS预处理支持,所以Stylus是一种新型语言,可以创建健壮的、动态的、富有表现力的CSS。比较年轻,其本质上做的事情与SASS/LESS等类似。

实现原理

1、取到DSL源代码的分析树
2、将含有 动态生成 相关节点的 分析树 转换为 静态分析树
3、将静态分析树转换为 CSS的静态分析树
4、将CSS的静态分树转换为 CSS代码

优缺点

优点:语言级逻辑处理,动态特性,改善项目结构
缺点: 采用特殊语言,框架耦合度高,复杂度高

手写CSS预处理

功能需求

这次分享我们来写一个CSS预处理器,它的功能可以理解为精简版的 stylus, 主要实现的功能有:

  • 用空格和换行符替代 花括号、冒号和分号;
  • 支持选择器的嵌套组合;
  • 支持以“$” 符号开头的变量定义和使用。
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;}


上述目标CSS代码 ,一共有5条样式规则。

第1条和第5条样式规则是最简单的,使用1个选择器,定义来1条样式属性;

第2条规则多用了一个标签选择器,样式属性值为多个字符串组成;

第3条规则使用了 类选择器;

第4条规则 增加了属性选择器,并且样式属性增加为2条;

再来看看 下面“源代码”,首先声明了两个变量,然后通过换行缩进定义了上述样式规则中的 选择器和样式

$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

像上面这种强制锁进换行的风格应用非常广泛,比如编程语言Python、HTML模版pug、预处理器Sass(以.sass为后缀的文件)

这种风格可能有些工程师并不适应,因为锁进空格数不一致就会导致程序解析失败或执行出错。

但它也有一些优点:比如格式整齐,省去了花括号等冗余字符,减少了代码量。

编译器

对于预处理器这种能将一种语言(法)转换成另一种语言(法)的程序一般称之为“编译器”。我们平常所知的高级语言都离不开编译器,如C++、Java、JavaScript。

不同语言的编译器的工作流程有些差异,但是大体上可以分为三个步骤:

  • 解析(Parsing)
  • 转换(Transformation)
  • 代码生成(Code Generation)

解析

解析步骤一般分为两个阶段: 词法分析语法分析

词法分析就是将接收到的源代码 转换成令牌(Token),完成这个过程的函数或工具被称之为词法分析器(Tokenizer或者Lexer)/

令牌由一些代码语句的碎片生成,它们可以是数字、标签、标点符号、运算符、或者其他任何东西。

将代码令牌化之后会进入语法分析,这个过程会将之前生成的令牌转换成一种带有令牌关系描述的抽象表示,这种抽象表示称之为抽象语法树(Abstract Syntax Tree,AST)。完成这个过程的函数或工具被成为词法分析器(Parser)。

抽象语法树通常是一个深度嵌套的对象,这种数据结构不仅更贴合代码逻辑,在后面的操作效率方面相对于令牌数组也是更有优势。

在解析HTML流程和JS代码流程中也是包含了这两个步骤的。

转换

解析完成之后都下一步就是转换,即把AST拿过来然后做一些修改,完成这个过程的函数或工具被称为转换器(Transformer)

这个过程中,AST中都节点可以被修改和删除,也可以新增节点。根本目的就是为了代码生产都时候更加方便。

代码生成

编译器都最后一步就是根据转换后的AST来生成目标代码,这个阶段做的事情有时候会和转换重叠,但是代码生成最主要都部分还是根据转换后的AST来输出代码。完成这个过程都函数或工具被称之为生成器(Generator)。

代码生成有几种不同的工作方式,有些编译器将会重用之前生成的令牌,有些会创建独立代码表示,以便于线性地输出代码。

但是接下来我们还是要着重于使用之前生成好的AST。

代码生成器必须知道如何“打印”转换后的AST中所有类型的节点,然后递归地调用自身,直到所有代码都被打印到一个很长的字符串中。

代码实现

学习了编译器相关知识之后,我们再来按照上述步骤编写代码。

一、词法分析

在进行词法分析前,首先要考虑字符串可以被拆分成多少种类型的令牌,然后再确定令牌的判断条件及解析方式。

通过分析源代码,可以将字符串分为:变量、变量值、选择器、属性、属性值 5种类型。但其中属性值和变量可以合并成一类进行处理,为了方便后面语法分析,变量可以拆分成变量定义和变量引用。

由于缩进会对语法分析产生影响(样式规则缩进空格数决定了属于哪个选择器),所以也要加入令牌对象。

因此一个令牌对象结构如下,type属性表示令牌类型,value属性存储令牌字符内容,indent 属性记录缩进空格数:

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

然后确定各种类型令牌点判断条件:

  • variableDef(变量定义), 以“$”符号开头,该行前面无其他非空字符;
  • variableRef(变量引用),以“$”符号开头,该行前面有非空字符串;
  • selector(选择器),独占一行,改行无其他非空字符串;
  • property(属性),以字母开头,该行前面无其他非空字符串;
  • value(属性值),非该行第一个字符串,且该行第一个字符串为property(属性)或 variableDef(变量定义)类型。

最后再来确定令牌解析方式。

一般进行词法解析的时候,可以逐歌字符进行解析判断,但考虑到源代码语法点特殊性---换行符和 空格缩进会影响语法解析,所以可以考虑逐行逐个单词进行解析。

step 01: 词法分析代码如下所示:

// step 01: 词法分析

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;
    },[])
    
}

二、语法分析(parse)

现在我们来分析如何将上一步生成的令牌数组转化为抽象语法树(AST),树结构相对于数组而言,最大的特点是具有层级关系,哪些令牌具有层级关系呢?

从缩进中不难看出,选择器与选择器、选择器与属性都存在层级关系,那么我们可以分别通过children属性rules属性来描述这两类层级关系。

要判断层级关系需要借助缩进空格数,所以节点需要增加一个属性 indent。

考虑到构建树时可能会产生回溯,那么可以设置一个数组来记录当前构建路径。当遇到非父子关系的节点时,沿着当前路径往上找到其父节点。

最后为了简化树结构,这一步也可以将变量值进行替换,从而减少变量节点。

所以抽象语法树可以写成如下结构。首先定义一个根节点,在其children属性中添加选择器节点,选择器节点相对令牌而言增加了2个属性:

  • rules, 存储当前选择器的样式属性 和值组成的对象,其中值以字符串数组的形势存储。
  • children, 子选择器节点。
// 最终抽象语法树(AST)结构如下:
{
  type'root',
  children: [{
    type'selector',
    value: string
    rules: [{
      property: string,
      value: string[],
    }],
    indent: number,
    children: []
  }]

}

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

语法分析代码如下:

// step02 语法分析
function parse(tokens) {
    var ast = {
      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]
        }
        continue;
      }
      // 对于属性,在指针指向的结点rules属性中添加属性
      if (node.type === 'property') {
        // 当前节点空格数大于 上一个节点空格数,则插入到上一个选择器节点的 rules 属性中
        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 {
            //遇到值,插入到当前选择器节点 rules 属性数组最后一个对象的 value 数组中
          preNode.rules[preNode.rules.length - 1].value.push(node.value);
        } catch (e) {
          console.error(preNode)
        }
        continue;
      }
      // 对于变量引用,直接替换成对应的值
      if (node.type === 'variableRef') {
          //插入到当前选择器节点 rules 属性数组最后一个对象的 value 数组中,在插入之前需要借助缓存对象的变量值进行替换
        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;
  }

首先定义一个根节点,然后按照先进先出的方式遍历令牌数组;

遇到变量定义时,将变量名和对应的值存入到缓存对象中;

当遇到属性时,插入到当前选择器节点的rules属性中,

遇到值和变量引用时都将插入到当前选择器节点rules属性数组最后一个对象的 value数组中。

但是变量引用在插入之前需要借助缓存对象的变量值进行替换。

当遇到选择器节点时,则需要往对应的父选择器节点children属性中插入,并将指针指向被插入的节点,同时记得将被插入的节点添加到用于存储遍历路径的数组中。

三、转换

在转换之前我们先来看看要生成的目标代码结构,其更像是一个由一条条样式规则组成的数组,所以我们考虑将抽象语法树转换成“抽象语法数组”。

在遍历树节点时,需要记录当前遍历路径,以方便选择器的拼接;同时可以考虑将“值”类型的节点拼接在一起。

最后形成下面的数组结构,数组中每个元素对象包括两个属性,selector 属性值为当前规则的选择器,rules 属性为数组,数组中每个元素对象包含 property 和 value 属性:

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

具体代码实现如下,递归遍历抽象语法树,遍历的时候完成选择器拼接以及属性值的拼接,最终返回一个与 CSS 样式规则相对应的数组:


// step03:转换
 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;
  }
  

实现方式比较简单,通过函数递归遍历树,然后重新拼接选择器和属性的值,最终返回数组结构。

四、代码生成

有了新的“抽象语法数组”,生成目标代码就只需要通过 map 操作对数组进行遍历,然后将选择器、属性、值拼接成字符串返回即可。

具体代码如下:

// step 04:代码生成
// 生成CSS样式规则:
// div { color:darkkhaki; }
 function generate(nodes) {
    // 遍历抽象语法数组,拼接成CSS代码
    return nodes.map(n => {
      let rules = n.rules.reduce((acc, item) => acc += `${item.property}:${item.value};`, '')
      return `${n.selector} {${rules}}`
    }).join('\n')
  }