前言
在前端日益发展的今天,抽象语法树(AST,Abstract Syntax Tree)已经成为了前端学者们耳熟能详的概念。趋于好奇心,在百忙之中窥探PostCss源码,好在代码量不多,消化整理了一下,望大牛们多多指教。
词法分析是什么
首先我们要搞清楚抽象语法树的解析流程,抽象语法树从形成一共经历了如下阶段:
字符流=>词法分析=>语法分析=>抽象语法树
是不是很简单?词法分析主要是读取字符流并根据词法规则组成一个个Token,以便于语法分析阶段基于Token流进行语法分析。此处先拿PostCss的 String Token 来举例子,String我们知道,其组成规则是两个成对的引号包裹起来的字符序列,词法分析阶段要做的是分析识别出当前读取的字符流是一个类型为String的Token,并且其内容是匹配到的字符序列。这样语法分析器只要针对Token类型来判断是否合法,而不需要直接针对字符流来判断。
总结一下,词法分析要读取字符流并且提供Token的类型、内容,然后由语法分析通过读取Token流来判断当前Token组合是否合法,一切都检查通过后,最终由语法解析阶段生成抽象语法树(AST,Abstract Syntax Tree)
PostCss词法分析
试想一下,除了前面我们提到的String Token,还有哪儿些Token需要被解析出来?我们不妨可以访问 https://astexplorer.net 来辅助我们寻找答案。我们在该网站中,我们选中要解析的语言为Css,并指定使用的解析库为PostCss,并且输入以下内容:
.a {
background: url(/*\\));
};
.b {
background: url("asdfasdf");
};
在前面我们说到,语法分析是通过Token流来判断合法。那我们试想一下,我们平时开发遇到过的CSS语法错误都有哪儿些?
我们随意地删掉一些内容,比如删掉最后一行的 } ,此时我们可以看到报错::1:1: Unclosed block,明眼人一看就明白:这个 { } 得成对出现,无论他们之间是否包裹了css代码,都需要去检查。那我们粗略地得出结论:有一个Token,类型为brackets,其内容是被{}包裹起来的字符序列,在本例中就是 background: url(/*));
OK,这乍一看没啥问题,但是有个潜在问题,因为语法分析的对象是Token,负责判断Token组合是否合法,你将background: url(/*)); 作为Token brackets的一部分,那么其内容代码是否合法,语法解析器是不管的。所以咱们得乖乖拆分出来,将上面代码分为三个部分:
1、名叫 { 的Token,其值和名字一样也是 {
2、other-tokens
3、名叫 } 的Token,其值和名字一样也是 }
这样才合理,当other-tokens存在语法错误时,语法分析器将会吐出错误。那么我们是否可以推出字符串的解析也是分为这样的三个部分:
1、名叫 " 的Token,其值和名字一样也是 "
2、字符序列
3、名叫 " 的Token,其值和名字一样也是 "
其实不然,因为字符串里的内容不能由语法解析器去解析语法问题,即使你在里头写了错误的CSS代码也不管。所以需要在词法解析器阶段就专门设置一个Token,名字为String,这样就规避了语法解析器去检查字符串内的CSS语法问题了。
综合以上,我们可以猜测PostCss提供了如下token:
1、String
2、{
3、}
4、(
5、)
6、[
7、]
现在,我们在 https://astexplorer.net 额外输入代码后如下:
.a {
background: url(/*\\));
};
.b {
background: url("asdfasdf");
};
.c {
background: url((();
};
.d {
background: url());
};
.e {
background: ()));
};
.f {
background: ((();
};
关于Token:( )、[ ] 的存在,不必多说,因为( ),[ ] 内的语法需要语法解析器去检查语法,但是以上的代码报错位置却是 选择器 .f 的内容,难不成在此之前的代码语法都是对的吗?
其实PostCss还新增了一种Token,叫做brackets,它的生成条件如下:
1、以url(开头,并且后面紧跟的字符不为特定的不可见字符并且不为单引号,双引号时,会生成brackets Token
2、以(开头,并且直到匹配到)时的字符序列不符合正则 /.[/("'\n]/ 时,会生成一个brackets
而生成一个brackets有什么效果?就是在语法解析阶段不会去检查brackets内容的语法,就不会报错了。
这就解释了上面的现象。
还有一些Token,比如space,at-word等等,这里就不再赘述,这里列出所有Token后直接上代码解释:
space
string
at-word
word
comment
-
{ } : ; ( )
brackets
PostCss Token 解析流程浅析
解析过程
space
case NEWLINE:
case SPACE:
case TAB:
case CR:
case FEED:
next = pos
do {
next += 1
code = css.charCodeAt(next)
if (code === NEWLINE) {
offset = next
line += 1
}
} while (
code === SPACE ||
code === NEWLINE ||
code === TAB ||
code === CR ||
code === FEED
) {
currentToken = ['space', css.slice(pos, next)]
}
pos = next - 1
break
这里的代码主要是将连续的 空格( ),制表符(\t),换行符(\n),回车符(\r),换页符(\f)转化为space token,代码比较简单
string
case SINGLE_QUOTE:
case DOUBLE_QUOTE:
quote = code === SINGLE_QUOTE ? '\'' : '"'
next = pos
do {
escaped = false
next = css.indexOf(quote, next + 1)
if (next === -1) {
if (ignore || ignoreUnclosed) {
next = pos + 1
break
} else {
unclosed('string')
}
}
escapePos = next
while (css.charCodeAt(escapePos - 1) === BACKSLASH) {
escapePos -= 1
escaped = !escaped
}
} while (escaped)
content = css.slice(pos, next + 1)
lines = content.split('\n')
last = lines.length - 1
if (last > 0) {
nextLine = line + last
nextOffset = next - lines[last].length
} else {
nextLine = line
nextOffset = offset
}
currentToken = ['string', css.slice(pos, next + 1), line, pos - offset, nextLine, next - nextOffset]
offset = nextOffset
line = nextLine
pos = next
break
这里可能有的理解困难点是针对转义字符的处理。代码如下:
do {
escaped = false
next = css.indexOf(quote, next + 1)
if (next === -1) {
if (ignore || ignoreUnclosed) {
next = pos + 1
break
} else {
unclosed('string')
}
}
escapePos = next
while (css.charCodeAt(escapePos - 1) === BACKSLASH) {
escapePos -= 1
escaped = !escaped
}
} while (escaped)
比如有字符串:
"你好,我是\\"小小前端攻城狮,我未来要成为\\"大大前端攻城狮" 外循环是不断寻找",内循环不断判断是否为转义引号,从而进行string token 的解析
at-word
case AT:
RE_AT_END.lastIndex = pos + 1
RE_AT_END.test(css)
if (RE_AT_END.lastIndex === 0) {
next = css.length - 1
} else {
next = RE_AT_END.lastIndex - 2
}
currentToken = ['at-word', css.slice(pos, next + 1), line, pos - offset, line, next - offset]
pos = next
break
其中:
const RE_AT_END = /[ \n\t\r\f{}()'";/[]#]/g
这里主要是通过@字符来解析出跟随在其后的name,比如
@import 解析出对应的Token为 ['at-word','import', line, pos - offset,line, next - offset]
word
case BACKSLASH:
next = pos
escape = true
while (css.charCodeAt(next + 1) === BACKSLASH) {
next += 1
escape = !escape
}
code = css.charCodeAt(next + 1)
if (
escape &&
code !== SLASH &&
code !== SPACE &&
code !== NEWLINE &&
code !== TAB &&
code !== CR &&
code !== FEED
) {
next += 1
if (RE_HEX_ESCAPE.test(css.charAt(next))) {
while (RE_HEX_ESCAPE.test(css.charAt(next + 1))) {
next += 1
}
if (css.charCodeAt(next + 1) === SPACE) {
next += 1
}
}
}
currentToken = ['word', css.slice(pos, next + 1), line, pos - offset, line, next - offset]
pos = next
break
default:
if (code === SLASH && css.charCodeAt(pos + 1) === ASTERISK) {
……
} else {
RE_WORD_END.lastIndex = pos + 1
RE_WORD_END.test(css)
if (RE_WORD_END.lastIndex === 0) {
next = css.length - 1
} else {
next = RE_WORD_END.lastIndex - 2
}
currentToken = ['word', css.slice(pos, next + 1),line, pos - offset,line, next - offset]
buffer.push(currentToken)
pos = next
}
break
这里的BACKSLASH就是反斜杆\,以反斜杆开头的,有如下可能:
1、转义字符
2、转义序列
转义序列,就好比Unicode编码,就是转义序列。
这里的代码比较简单,不再赘述。
comment
default:
if (code === SLASH && css.charCodeAt(pos + 1) === ASTERISK) {
next = css.indexOf('*/', pos + 2) + 1
if (next === 0) {
if (ignore || ignoreUnclosed) {
next = css.length
else {
unclosed('comment')
}
}
content = css.slice(pos, next + 1)
lines = content.split('\n')
last = lines.length - 1
if (last > 0) {
nextLine = line + last
nextOffset = next - lines[last].length
} else {
nextLine = line
nextOffset = offset
}
currentToken = ['comment', content, line, pos - offset, nextLine, next - nextOffset]
offset = nextOffset
line = nextLine
pos = next
} else {
……
}
break;
对comment的解析,就是直接对 /* */ 进行匹配。
[ ] { } : ; )
case OPEN_SQUARE:
case CLOSE_SQUARE:
case OPEN_CURLY:
case CLOSE_CURLY:
case COLON:
case SEMICOLON:
case CLOSE_PARENTHESES:
let controlChar = String.fromCharCode(code)
currentToken = [controlChar, controlChar, line, pos - offset]
break
对这类的处理就比较简单,直接将读到的字符作为Token返回给语法解析器。
( 和 brackets
case OPEN_PARENTHESES:
prev = buffer.length ? buffer.pop()[1] : ''
n = css.charCodeAt(pos + 1)
if (
prev === 'url' &&
n !== SINGLE_QUOTE && n !== DOUBLE_QUOTE &&
n !== SPACE && n !== NEWLINE && n !== TAB &&
n !== FEED && n !== CR
) {
next = pos
do {
escaped = false
next = css.indexOf(')', next + 1)
if (next === -1) {
if (ignore || ignoreUnclosed) {
next = pos
break
} else {
unclosed('bracket')
}
}
escapePos = next
while (css.charCodeAt(escapePos - 1) === BACKSLASH) {
escapePos -= 1
escaped = !escaped
}
} while (escaped)
currentToken = ['brackets', css.slice(pos, next + 1), line, pos - offset, line, next - offset]
pos = next
} else {
next = css.indexOf(')', pos + 1)
content = css.slice(pos, next + 1)
if (next === -1 || RE_BAD_BRACKET.test(content)) {
currentToken = ['(', '(', line, pos - offset]
} else {
currentToken = ['brackets', content,line, pos - offset,line, next - offset]
pos = next
}
}
break
PostCss 将 括号归类为:带有url前缀的括号 、普通括号
普通括号:如果括号范围内的字符串符合正则 /.[\/("'\n]/ ,就将括号内的语法合法判断交给语法解析器,否则就生成一个brackets token,这样就间接性规避了语法解析器去检查括号内的内容,这也就说明了为什么background: (red)))))); 语法解析器认为是合法的。
带有url前缀的括号:括号内部语法检查只进行括号关闭校验,其他的统一默认合法,并生成一个brackets token,这样语法解析器就不会解析你内部语法是否合法,即使你在括号里头输入background: url(/\)); ,但是这种语法在普通括号里是非法的,比如 background: (/\));
1条评论