一、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;
}
- 最后就是处理类型为value和variableRef的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");
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。