一、Sass预处理器简介

Sass 是一款强化 CSS 的辅助工具,它在 CSS 语法的基础上增加了变量 (variables)、嵌套 (nested rules)、混合 (mixins)、导入 (inline imports) 等高级功能,这些拓展令 CSS 更加强大与优雅。使用 Sass 以及 Sass 的样式库(如 Compass)有助于更好地组织管理样式文件,以及更高效地开发项目。

// index.scss sass源码$redColor: red;$yellowBg: yellow;nav {    height: 100px;    border: 1px solid $redColor;}#content {    height: 300px;    p {        margin: 10px;        .selected {            backgournd: $yellowBg;        }    }}

经过sass编译后,生成的代码结果如下:

// sass编译后的输出的css代码nav {  height: 100px;  border: 1px solid red; }#content {  height: 300px; }  #content p {    margin: 10px; }    #content p .selected {      backgournd: yellow; }

接下来我们将实现一个简单的Sass预处理,其基本功能包括:

  • 能够解析变量
  • 能够使用嵌套

二、编译器简介

Sass预处理器本质是一个编译器,Sass的源文件是.scss文件,里面的内容包含了Sass自己的语法,是无法直接执行的,必须经过编译转换为.css文件后才能执行,其编译过程就是:

读取sass源码,然后对sass源码进行词法分析,生成一个一个的token;

然后对这些token进行语法分析,生成抽象语法树(Abstract Syntax Tree,AST),解析成抽象语法树后,就可以很方便的拿到我们需要的数据并进行相应的处理

然后遍历抽象语法树,对抽象语法树进行转换,转换成我们需要的代码输出结构,方便输出最终代码,比如,因为Sass源码采用了嵌套,所以我们需要将选择器变回链式结构

虽然对抽象语法树进行了相应的转换,但是转换后的结果仍然是对象的形式,所以我们还需要进行代码的生成,将对象形式转换为字符串形式输出

三、实现Sass预处理器

① 词法分析
词法分析就是要找出源码中包含的token,这个token也是一个对象,其中包含所属的类型type对应的值value(词在源码中对应的字符串内容)当前token在源码中的缩进值indent。其中type类型有变量定义变量引用选择器属性

{    type: "variableDef" | "variableRef" | "selector" | "property" | "value", // 当前词所属类型    value: string, // Sass源码中对应的字符串内容    indent: number // 当前词在Sass源码中的缩进值}
  • 对Sass源码字符串进行以换行符进行分割,分割成数组,每一行的内容作为数组中的一个元素
const sassSourceCode = ``; // Sass的源码// 对Sass源码以换行符进行分割const lines = sassSourceCode.trim().split(/\n/); 
  • 拿到每一行的内容后,需要对每一行的内容进行遍历,拿到每一行内容前面的空格数,即缩进,接着对每一行的内容以冒号进行分割,分割成数组,将每一行中的词(word)作为数组的一个元素
// 遍历每一行中的内容,将生成的token放到tokens数组中,最初为[]lines.reduce((tokens, line) => {    const spaces = line.match(/^\s+/) || [""]; // 匹配每行开头的空格部分    const indent = spaces[0].length; // 拿到每行的缩进空格数量    const input = line.trim(); // 去除首尾空格    let words = input.split(/:/); // 用冒号进行分割,拿到每一行中的所有词}, []);
  • 拿到每一行中包含的词后,我们就可以对每一个词进行处理了,通过查看上面的Sass源码,可以看到,每一行以冒号分割后,如果是选择器,如#content {,那么分割后的words数组中只有一个元素,我们可以以此找到选择器,如:
let firstWord = words.shift(); // 取出并删除每行的第一个词const selectorReg = /([0-9a-zA-Z-.#\[\]]+)\s+\{$/; // 选择器匹配正则if (words.length === 0) { // 如果取出并删除第一个词后,words数组长度变为为0,说明该行只有一个词,那么这个词就是选择器    const result = firstWord.match(selectorReg); // 有可能是 },冒号分割后words的长度也会变成0,所以需要进行正则匹配    if (result) {        tokens.push({ // 将选择器放到tokens中            type: "selector",            value: result[1],            indent        });    }}
  • 接下来就是处理变量定义属性变量引用这些类型了,如果当前行的第一个词是以美元符开头,那么这个词就是变量定义否则就是属性,因为值和变量引用不可能是第一个词,而是在第一个词之后。
if (words.length === 0) {} else { // 变量定义、属性、变量引用、值    let type = "";    if (/^\$/.test(firstWord)) { // 如果每行的第一个词是以$开头的,那么这个词就是一个变量定义        type = "variableDef"; // 那么type就是变量定义,即variableDef    } else {        type = "property";    }    tokens.push({ // 将变量定义或者属性放到tokens中        type,        value: firstWord,        indent    });}
  • 至此,第一个词已经处理完毕,接着开始处理之后的词了,剩下的词要么是要么是变量引用,并且有些词比较特殊,如 1px solid red,其中包含了3个值,所以需要用空格进行分割成数组分成3个词处理,如:
// 继续取出words中剩余的词进行分析,剩下的词可能是值或者是变量引用两种类型while (firstWord = words.shift()) { // 取出下一个词更新firstWord    firstWord = firstWord.trim(); // 去除词的首尾空格    const values = firstWord.split(/\s/); // 有些词(1px solid red)可能包含多个值,所以需要用空格进行分割, 拿到所有的值    if (values.length > 1) { // 如果值有多个        words = values; // 将所有的值作为words继续遍历        continue;    }    firstWord = firstWord.replace(/;/, ""); // 去除值中包含的分号    tokens.push({ // 将值或者变量引用加入到tokens中        type: /^\$/.test(firstWord) ? "variableRef" : "value",        value: firstWord,        indent: 0    });}

经过一层一层遍历,源码中的所有词都被解析成了token并且放到了tokens数组中,完整代码如下:

/** 将Sass源码传入进行词法分析生成tokens数组*/function tokenize(sassSourceCode) {    return sassSourceCode.trim().split(/\n/).reduce((tokens, line) => {        const spaces = line.match(/^\s+/) || [""]; // 匹配空格开头的行        const indent = spaces[0].length; // 拿到每行的缩进空格数量        const input = line.trim(); // 去除首尾空格        let words = input.split(/:/); // 用冒号进行分割,拿到每一行中的所有词        let firstWord = words.shift(); // 取出并删除每行的第一个词        const selectorReg = /([0-9a-zA-Z-.#\[\]]+)\s+\{$/;        if (words.length === 0) { // 如果取出并删除第一个词后,words数组长度变为为0,说明该行只有一个词,那么这个词就是选择器            const result = firstWord.match(selectorReg);            if (result) {                tokens.push({ // 将选择器放到tokens中                    type: "selector",                    value: result[1],                    indent                });            }        } else { // 变量定义、变量引用、属性、值            let type = "";            if (/^\$/.test(firstWord)) { // 如果每行的第一个词是以$开头的,那么这个词就是一个变量定义                type = "variableDef"; // 那么type就是变量定义,即variableDef            } else { // 如果每行的第一个次是非美元符开头,那么就是属性                type = "property";            }            tokens.push({ // 将变量定义或者属性放到tokens中                type,                value: firstWord,                indent            });            // 继续取出words中剩余的词进行分析,剩下的词可能是值或者是变量引用两种类型            while (firstWord = words.shift()) {                firstWord = firstWord.trim(); // 去除词的首尾空格                const values = firstWord.split(/\s/); // 有些词(1px solid red)可能包含多个值,所以需要用空格进行分割, 拿到所有的值                if (values.length > 1) { // 如果值有多个                    words = values; // 将所有的值作为words继续遍历                    continue;                }                firstWord = firstWord.replace(/;/, ""); // 去除值中包含的分号                tokens.push({ // 将值或者变量引用加入到tokens中                    type: /^\$/.test(firstWord) ? "variableRef" : "value",                    value: firstWord,                    indent: 0                });            }        }        return tokens;    }, []);}

用上面的源码测试一下词法分析的结果如下:

[ { type: 'variableDef', value: '$redColor', indent: 0 },  { type: 'value', value: 'red', indent: 0 },  { type: 'variableDef', value: '$yellowBg', indent: 0 },  { type: 'value', value: 'yellow', indent: 0 },  { type: 'selector', value: 'nav', indent: 0 },  { type: 'property', value: 'height', indent: 4 },  { type: 'value', value: '100px', indent: 0 },  { type: 'property', value: 'border', indent: 4 },  { type: 'value', value: '1px', indent: 0 },  { type: 'value', value: 'solid', indent: 0 },  { type: 'variableRef', value: '$redColor', indent: 0 },  { type: 'selector', value: '#content', indent: 0 },  { type: 'property', value: 'height', indent: 4 },  { type: 'value', value: '300px', indent: 0 },  { type: 'selector', value: 'p', indent: 4 },  { type: 'property', value: 'margin', indent: 8 },  { type: 'value', value: '10px', indent: 0 },  { type: 'selector', value: '.selected', indent: 8 },  { type: 'property', value: 'backgournd', indent: 12 },  { type: 'variableRef', value: '$yellowBg', indent: 0 } ]

② 语法分析
语法分析就是对tokens进行遍历将其解析成一个树形结构。整个树有一个根节点,根节点下有children子节点数组,只有选择器类型才能成为一个节点,并且每一个节点下有一个rules属性用于存放当前节点的样式规则,根节点如下:

const ast = { // 定义一个抽象语法树AST对象,一开始只有根节点    type: "root", // 根节点    value: "root",    children: [],    rules: [],    indent: -1};

每一条规则也是一个对象,结构如下:

// 样式规则{     property: "border",    value: ["1px", "solid", "red"],    indent: 8}
  • 解析前,首先初始化一个root根节点,和解析路径,用于定位样式所属的节点,接着准备按顺序遍历每一个token,如:
function parse(tokens) {    const ast = { // 定义一个抽象语法树AST对象        type: "root", // 根节点        value: "root",        children: [],        rules: [],        indent: -1    };    const path = [ast]; // 将抽象语法树对象放到数组中,即当前解析路径,最后一个元素为父元素    let parentNode = ast; // 将当前根节点作为父节点    // 遍历所有的token    while (token = tokens.shift()) {    }    return ast;}
  • 首先处理变量的定义,如果该token的类型是variableDef,并且它的下一个token的类型是value,那么就是变量的定义,将变量的名称和值保存到变量字典中,以便后面变量引用的时候可以从变量字典中读取变量的值,如:
const variableDict = {}; // 保存定义的变量字典while (token = tokens.shift()) {    if (token.type === "variableDef") { // 如果这个token是变量定义        if (tokens[0] && tokens[0].type === "value") { // 并且如果其下一个token的类型是值定义,那么这两个token就是变量的定义            const variableValueToken = tokens.shift(); // 取出包含变量值的token            variableDict[token.value] = variableValueToken.value; // 将变量名和遍历值放到vDict对象中        }        continue;    }}
  • 接着处理类型为selector的token,对于selector选择器类型,我们需要创建一个新节点,然后和当前父节点的缩进值进行比较,如果当前创建的新节点的缩进值比当前父节点,说明是当前父节点的子节点,直接将当前创建的新节点push到父节点的children数组中,并且更新当前创建的新节点为父节点。如果当前创建的新节点的缩进值比当前父节点,说明不是当前父节点的子节点,那么我们就需要从当前解析路径中逐个取出最后一个节点,直到找到当前创建节点的父节点,即找到缩进值比当前创建节点小的那个节点作为父节点,找到父节点后将当前创建的新节点放到父节点的children数组中,同时将父节点和当前创建的新节点push到解析路径中,同样更新当前创建的新节点为父节点
if (token.type === "selector") { // 如果是选择器    const selectorNode = { // 创建一个选择器节点,然后填充children和rules即可        type: "selector",        value: token.value,        indent: token.indent,        rules: [],        children: []    }    if (selectorNode.indent > parentNode.indent) { // 当前节点的缩进大于其父节点的缩进,说明当前选择器节点是父节点的子节点        path.push(selectorNode); // 将当前选择器节点加入到path中,路径变长了,当前选择器节点作为父节点        parentNode.children.push(selectorNode); // 将当前选择器对象添加到父节点的children数组中        parentNode = selectorNode; // 当前选择器节点作为父节点    } else { // 缩进比其父节点缩进小,说明是非其子节点,可能是出现了同级的节点         parentNode = path.pop(); // 移除当前路径的最后一个节点        while (token.indent <= parentNode.indent) { // 同级节点            parentNode = path.pop(); // 拿到其父节点的父节点        }        // 找到父节点后,因为父节点已经从path中移除,所以还需要将父节点再次添加到path中        path.push(parentNode, selectorNode);        parentNode.children.push(selectorNode); // 找到父节点后,将当前选择器节点添加到父节点children中        parentNode = selectorNode; // 当前选择器节点作为父节点    }}
  • 接着处理类型为property的token,对于属性类型,和选择器类型差不多,我们需要创建一个rule对象,然后和当前父节点的缩进值进行比较,如果当前属性token的缩进值比当前父节点的缩进值,说明是当前父节点的样式,直接将创建的rule对象添加到当前父节点的rules数组即可。如果当前属性token的缩进值比当前父节点的缩进值小,说明不是当前父节点的样式,那么我们就需要从当前解析路径中逐个取出最后一个节点,直到找到当前属性token的父节点,即找到缩进值比当前token缩进值小的那个节点作为父节点,找到父节点后,直接将创建的rule对象添加到父节点的rules数组中,同时将父节点再次放回到解析路径中即可
if (token.type === "property") { // 如果是属性节点    if (token.indent > parentNode.indent) { // 如果该属性的缩进大于父节点的缩进,说明是父节点选择器的样式        parentNode.rules.push({ // 将样式添加到rules数组中 {property: "border", value:[]}            property: token.value,            value: [],            indent: token.indent        });    } else { // 非当前父节点选择器的样式        parentNode = path.pop(); // 取出并移除最后一个选择器节点,拿到当前父节点        while (token.indent <= parentNode.indent) { // 与当前父节点的缩进比较,如果等于,说明与当前父节点同级,如果小于,则说明比当前父节点更上层            parentNode = path.pop(); // 比当前父节点层次相等或更高,取出当前父节点的父节点,再次循环判其父节点,直到比父节点的缩进大为止        }        // 拿到了其父节点        parentNode.rules.push({ // 将该样式添加到其父选择器节点中            property: token.value,            value: [],            indent: token.indent        });        path.push(parentNode); // 由于父节点已从path中移除,需要再次将父选择器添加到path中    }    continue;}
  • 最后就是处理类型为valuevariableRef的token了,这两个本质都属于值,只不过变量引用真实的值需要到变量字典中去取,对于值,我们不需要像上面一个通过缩进值去判断父节点,当前这个值肯定是属于当前父节点的,直接将值放到当前父节点的最后一个rule对象的value数组中即可。
if (token.type === "value") { // 如果是值节点    // 拿到上一个选择器节点的rules中的最后一个rule的value将值添加进去    parentNode.rules[parentNode.rules.length - 1].value.push(token.value);    continue;}if (token.type === "variableRef") { // 如果是变量引用,从变量字典中取出值并添加到父节点样式的value数组中    parentNode.rules[parentNode.rules.length - 1].value.push(variableDict[token.value]);    continue;}

tokens经过一个个遍历后,就按照上面的规则添加到了由根节点开始的树结构上,完整代码如下:

function parse(tokens) {    const ast = { // 定义一个抽象语法树AST对象        type: "root", // 根节点        value: "root",        children: [],        rules: [],        indent: -1    };    const path = [ast]; // 将抽象语法树对象放到数组中,即当前解析路径,最后一个元素为父元素    let parentNode = ast; // 将当前根节点作为父节点    let token;    const variableDict = {}; // 保存定义的变量字典    // 遍历所有的token    while (token = tokens.shift()) {        if (token.type === "variableDef") { // 如果这个token是变量定义            if (tokens[0] && tokens[0].type === "value") { // 并且如果其下一个token的类型是值定义,那么这两个token就是变量的定义                const variableValueToken = tokens.shift(); // 取出包含变量值的token                variableDict[token.value] = variableValueToken.value; // 将变量名和遍历值放到vDict对象中            }            continue;        }        if (token.type === "selector") { // 如果是选择器            const selectorNode = { // 创建一个选择器节点,然后填充children和rules即可                type: "selector",                value: token.value,                indent: token.indent,                rules: [],                children: []            }            if (selectorNode.indent > parentNode.indent) { // 当前节点的缩进大于其父节点的缩进,说明当前选择器节点是父节点的子节点                path.push(selectorNode); // 将当前选择器节点加入到path中,路径变长了,当前选择器节点作为父节点                parentNode.children.push(selectorNode); // 将当前选择器对象添加到父节点的children数组中                parentNode = selectorNode; // 当前选择器节点作为父节点            } else { // 缩进比其父节点缩进小,说明是非其子节点,可能是出现了同级的节点                 parentNode = path.pop(); // 移除当前路径的最后一个节点                while (token.indent <= parentNode.indent) { // 同级节点                    parentNode = path.pop(); // 拿到其父节点的父节点                }                // 找到父节点后,因为父节点已经从path中移除,所以还需要将父节点再次添加到path中                path.push(parentNode, selectorNode);                parentNode.children.push(selectorNode); // 找到父节点后,将当前选择器节点添加到父节点children中                parentNode = selectorNode; // 当前选择器节点作为父节点            }        }        if (token.type === "property") { // 如果是属性节点            if (token.indent > parentNode.indent) { // 如果该属性的缩进大于父节点的缩进,说明是父节点选择器的样式                parentNode.rules.push({ // 将样式添加到rules数组中 {property: "border", value:[]}                    property: token.value,                    value: [],                    indent: token.indent                });            } else { // 非当前父节点选择器的样式                parentNode = path.pop(); // 取出并移除最后一个选择器节点,拿到当前父节点                while (token.indent <= parentNode.indent) { // 与当前父节点的缩进比较,如果等于,说明与当前父节点同级,如果小于,则说明比当前父节点更上层                    parentNode = path.pop(); // 比当前父节点层次相等或更高,取出当前父节点的父节点,再次循环判其父节点,直到比父节点的缩进大为止                }                // 拿到了其父节点                parentNode.rules.push({ // 将该样式添加到其父选择器节点中                    property: token.value,                    value: [],                    indent: token.indent                });                path.push(parentNode); // 由于父节点已从path中移除,需要再次将父选择器添加到path中            }            continue;        }        if (token.type === "value") { // 如果是值节点            // 拿到上一个选择器节点的rules中的最后一个rule的value将值添加进去            parentNode.rules[parentNode.rules.length - 1].value.push(token.value);            continue;        }        if (token.type === "variableRef") { // 如果是变量引用,从变量字典中取出值并添加到父节点样式的value数组中            parentNode.rules[parentNode.rules.length - 1].value.push(variableDict[token.value]);            continue;        }    }    return ast;}

对上一步生成的tokens解析后的结果如下:

{    "type": "root",    "value": "root",    "children": [{        "type": "selector",        "value": "nav",        "indent": 0,        "rules": [{            "property": "height",            "value": ["100px"],            "indent": 4        }, {            "property": "border",            "value": ["1px", "solid", "red"],            "indent": 4        }],        "children": []    }, {        "type": "selector",        "value": "#content",        "indent": 0,        "rules": [{            "property": "height",            "value": ["300px"],            "indent": 4        }],        "children": [{            "type": "selector",            "value": "p",            "indent": 4,            "rules": [{                "property": "margin",                "value": ["10px"],                "indent": 8            }],            "children": [{                "type": "selector",                "value": ".selected",                "indent": 8,                "rules": [{                    "property": "backgournd",                    "value": ["yellow"],                    "indent": 12                }],                "children": []            }]        }]    }],    "rules": [],    "indent": -1}

③ 转换
所谓转换就是对抽象语法树进行处理,将树结构对象转换成我们最终需要的数据对象,根据上面Sass编译后输出的源码,可以发现我们最终需要生成每个选择器下的样式,并且这个选择器是呈链式结构的,所以我们需要遍历抽象语法树,找到每个选择器及其样式,并记录当前选择器的父链,重新生成一个对象,如下:

// 根据这个对象我们就可以输出一条样式 #content p {margin: 10px}{    selector: "#content p", // 链式结构的选择器    rules:[{"property":"margin","value":"10px","indent":8}], // 链式选择器最右边选择器的样式,每条样式包含属性名和属性值,以及该样式的缩进值    indent: 4 // 链式选择器最右边选择器的缩进值}

我们只需要传入上面生成的抽象语法树即根节点,然后进行递归遍历其子节点,如果节点的type类型为selector,我们就需要进行处理,拿到当前选择器下的所有样式组成的rules数组和选择器链一起生成上面结构的对象作为一条样式并放到styles数组中即可。

function transform(ast) {    const styles = []; // 存放要输出的每一条样式    function traverse(node, styles, selectorChain) {        if (node.type === "selector") { // 如果是选择器节点            selectorChain = [...selectorChain, node.value]; // 解析选择器层级关系,拿到选择器链            if (node.rules.length > 0) {                styles.push({                    selector: selectorChain.join(" "),                    rules: node.rules.reduce((rules, rule) => { // 遍历其rules, 拿到当前选择器下的所有样式                        rules.push({ // 拿到该样式规则的属性和属性值并放到数组中                            property: rule.property,                            value: rule.value.join(" "),                            indent: rule.indent                        });                        return rules;                    }, []),                    indent: node.indent                });            }        }        // 遍历根节点的children数组        for (let i = 0; i < node.children.length; i++) {            traverse(node.children[i], styles, selectorChain);        }    }    traverse(ast, styles, []);    return styles;}

用上面的抽象语法树转换后生成的styles数组如下:

[{    "selector": "nav",    "rules": [{        "property": "height",        "value": "100px",        "indent": 4    }, {        "property": "border",        "value": "1px solid red",        "indent": 4    }],    "indent": 0}, {    "selector": "#content",    "rules": [{        "property": "height",        "value": "300px",        "indent": 4    }],    "indent": 0}, {    "selector": "#content p",    "rules": [{        "property": "margin",        "value": "10px",        "indent": 8    }],    "indent": 4}, {    "selector": "#content p .selected",    "rules": [{        "property": "backgournd",        "value": "yellow",        "indent": 12    }],    "indent": 8}]

④ 代码生成
上面经过转换后仍然是对象的形式,所以我们需要遍历每一条样式,对其rules数组中的每一个rule的属性和值用冒号拼接起来,然后将rules数组中的所有rule用换行符拼接起来生成样式规则字符串,然后与选择器一起拼接成一条字符串形式的样式即可。

function generate(styles) {    return styles.map(style => { // 遍历每一条样式        const rules = style.rules.reduce((rules, rule) => { // 将当前样式的所有rules合并起来            return rules += `\n${" ".repeat(rule.indent)}${rule.property}:${rule.value};`;        }, "");        return `${" ".repeat(style.indent)}${style.selector} {${rules}}`;    }).join("\n");}