看拉勾教育的 前端高手进阶 教人怎样写CSS预处理器,觉得学到不少知识,打算写篇笔记记录下来。
目标
CSS预处理器要实现的功能如下:
- 用空格和换行符替代花括号丶冒号和分号;
- 支持选择器的嵌套组合;
- 支持以“$”符号开头的变量定义和使用。
举一个例子以更好了解要实现的功能。我们希望能把下面的样式表:
$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
转换成普通CSS样式表:
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;}
因此我们需要编写一个 编译器,所谓编译器就是把一种语言转换为另一种语言,例如gcc把c语言转换为exe文档等。
不同语言的编译器的工作流程有些差异,但大体上可以分成三个步骤:解析(Parsing)丶转换(Transformation)及代码生成(Code Generation)。
解析: 词法分析
解析分为两步,词法分析和语法分析。
词法分析就是把字符串分成一个个单词,然後把它们分类,例如高级语言的编译器中,把单词分为变量,常量和运算符等等。这些分类好的单词叫 令牌 (token) 。 把样式表分类好的单词如下:
{
type: "variableDef" | "variableRef" | "selector" | "property" | "value", //枚举值,分别对应变量定义丶变量引用丶选择器丶属性丶值
value: string, // token字符值,即被分解的字符串
indent: number // 缩进空格数,需要根据它判断从属关系
}
现在的问题是要怎样用代码实现? 词法分析比较简单,把字符串拆分一个个单词,用正则表达式判断单词属於哪一类。当然,因为这个CSS预处理器比较简单,所以可以用正则表达式,如果类别非常多,可能需要用其他方式实现。 把判断条件列出来:
- variableDef,以 $ 符号开头,该行前面无其他非空字符串,即
$hello: world
的 $hello ; - variableRef,以 $ 符号开头,该行前面有非空字符串,即
background-color: $hello
的 $hello ; - selector,独占一行,该行无其他非空字符串;
- property,以字母开头,该行前面无其他非空字符串,即
background-color: $hello
的 background-color; - value,非该行第一个字符串,且该行第一个字符串为 property 或 variableDef 类型。
实现代码如下:
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;
}, [])
}
解析: 句法分析
句法分析就是把之前的令牌之间的关系抽象表示出来,通常就是把它们转换成 抽象语法树 (AST),前端中使用AST最典型的例子就是 Babel。CSS的句法中,最需要关注的是 选择器的属性 和 父子关系,因此需要rule和children表示:
- rules,存储当前选择器的样式属性和值组成的对象,其中值以字符串数组的形式存储;
- children,子选择器节点。
AST的数据结构如下:
{
type: 'root',
children: [{
type: 'selector',
value: string
rules: [{
property: string,
value: string[],
}],
indent: number,
children: []
}]
}
以上是引用至课程的段落,文字有点复杂,代码更复杂 (=_=),我在代码里用注释慢慢解释,尽可能说清楚:
// token 参数是之前解析的令牌
function parse(tokens) {
var ast = { // ast是要生成的树,最初的根节点为root
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]
// 先把之前缓存变量对应的值取出,然後赋值给之前节点rules的值
}
continue;
}
// 对于属性,在指针指向的结点rules属性中添加属性
if (node.type === 'property') {
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 {
preNode.rules[preNode.rules.length - 1].value.push(node.value); // 然後赋值给之前节点rules的值
} catch (e) {
console.error(preNode)
}
continue;
}
// 对于变量引用,直接替换成对应的值,道理与之前 'variableDef'的情况类似
if (node.type === 'variableRef') {
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;
}
转换
之後开始比较简单,在这个过程中,AST 中的节点可以被修改和删除,也可以新增节点。根本目的就是为了代码生成的时候更加方便。它算是一个 中转点,把AST转变成目标样式表的结构。数据结构如下:
{
selector: string,
rules: {
property: string,
value: string
}[]
}[]
实现代码主要方法是递回遍历AST,先把相应资料抽取,合并成新的对象,然後找一下有没有子节点,有的话,遍历子节点,递归调用函数。代码如下:
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;
}
代码生成
把刚才转换的新的抽象语法结构变成目标代码:
function generate(nodes) {
// 遍历抽象语法数组,拼接成CSS代码
return nodes.map(n => {
let rules = n.rules.reduce((acc, item) => acc += `${item.property}:${item.value};`, '') // 先把对象rules属性变成css样式
return `${n.selector} {${rules}}` // 返回数组形式的css样式表
}).join('\n')
}
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!