2

一、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");
}

JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师