前言

在前端日益发展的今天,抽象语法树(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:


1String



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: (/\));