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