Acorn 的 Tokenizer 工作流程( 一 )

Acorn 源码阅读笔记

Posted by Nicodechal on 2020-05-22

Acorn 的 Tokenizer 是解析器对象 Parser 上的一个静态方法,该方法可以根据输入的字符串和相关选项对输入进行处理得到 Token:

1
2
3
export function tokenizer(input, options) {
return Parser.tokenizer(input, options)
}

Parser.tokenizer 直接返回一个 Parser 对象,对象的原型上有两种方式得到 Token 序列,一种是调用 getToken 方法,该方法会返回下一个 Token,另一种是通过迭代器访问 ( 如果支持 ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// pp = Parser.prototype
pp.getToken = function() {
this.next()
return new Token(this) // 每次根据 Parser 的状态返回当前 token
}

// If we're in an ES6 environment, make parsers iterable
if (typeof Symbol !== "undefined") // 如果支持迭代器
pp[Symbol.iterator] = function() {
return {
next: () => {
let token = this.getToken() // 一样调用 getToken
return {
done: token.type === tt.eof,
value: token
}
}
}
}

Token 的构造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export class Token {
// p 是 Parser
constructor(p) {
this.type = p.type // Token 的类型
this.value = p.value // Token 的值
this.start = p.start // Token 开始处的偏移
this.end = p.end // end 结束的偏移
// Token 的行列信息
if (p.options.locations)
this.loc = new SourceLocation(p, p.startLoc, p.endLoc)
// 是偏移构造的 Token 的偏移范围
if (p.options.ranges)
this.range = [p.start, p.end]
}
}

由此可知,Acorn 主要通过 next 方法解析输入字符串,并根据解析结果修改 Parser 上的状态 ( typevaluestartend 等等 ),再根据这些数据构造 Token 并返回,所以 Token 主要就是通过这些更新的属性构造得到。

next 方法主要做两件事:

  1. 将上一个 Token 的偏移和位置记录下来。
  2. 调用 nextToken 读取下一个 Token。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pp.next = function(ignoreEscapeSequenceInKeyword) {
// 处理 keyword 中出现的转义字符
if (!ignoreEscapeSequenceInKeyword && this.type.keyword && this.containsEsc)
this.raiseRecoverable(this.start, "Escape sequence in keyword " + this.type.keyword)

// 如果有 listener,触发,
if (this.options.onToken)
this.options.onToken(new Token(this))

// 记录上一个 Token 的 offset
this.lastTokEnd = this.end
this.lastTokStart = this.start
// 记录上一个 Token 的 location
this.lastTokEndLoc = this.endLoc
this.lastTokStartLoc = this.startLoc
// 解析下一个 token
this.nextToken()
}

nextToken 读取下一个 Token 并更新 Parser 上和 Token 相关的数据,只有使用 new Token(this) 读取更新的数据生成 Token。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pp.nextToken = function() {
// 一些和上下文相关的属性
let curContext = this.curContext()
// 如果当前上下文不保留空白就跳过空白
if (!curContext || !curContext.preserveSpace) this.skipSpace()
// 设置 Token 的 start
this.start = this.pos
// 设置 location
if (this.options.locations) this.startLoc = this.curPosition()
// 如果结束返回一个 EOF token
if (this.pos >= this.input.length) return this.finishToken(tt.eof)

// 如果上下文有指定的读取 token 的方法则调用该方法覆盖 readToken
if (curContext.override) return curContext.override(this)
// 正常直接读取下一个 Token
else this.readToken(this.fullCharCodeAtPos())
}

curContext 用来保存和当前上下文相关的属性,例如下面用到的 preserveSpace,如果是在字符串的上下文中,空白会被保留,大多数情况下,空白 ( 包括注释 ) 会被跳过。

startstartLoc 分别设置即将读取的 Token 的起始 offset 和 location。

如果上下文中指定了用于覆盖默认 Token 解析方法的方法,则调用 curContext.override,否则直接调用 readToken 读取下一个 Token,下面看 readToken 这条路径。

该方法先调用 fullCharCodeAtPos 得到当前位置 ( 即代码中的 pos ) 处的 Unicode 字符的编号。JavaScript 采用了 Unicode 字符集,同时 EMCAScript 规定了 JavaScript 源码被看做是 UTF-16 的码元序列 ( 但 JS 只能处理 UCS-2 编码 ) ,因此 charCodeAt 方法会返回指定位置的一个 2 字节 ( 16 bit ) 的码元。为了得到指定位置的 Unicode 字符编码,Acorn 自己进行转换 ( 在 ES6 下也可以用 codePointAt )。

在 JS 中,对于 Unicode 的编码,如果编码在 U+0000 ~ U+FFFF 中,使用两个字节编码,如果在 U+100000 ~ U+10FFFF 使用四个字节编码,使用下面规则编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function toUTF16(codePoint) {
var TEN_BITS = parseInt('1111111111', 2);
function u(codeUnit) {
return '\\u'+codeUnit.toString(16).toUpperCase();
}
// 如果小于 0xFFFF 两个字节表示,Unicode 码点就是编码值
if (codePoint <= 0xFFFF) {
return u(codePoint);
}

// 否则码点先减 0x10000,此时码点的最多为 20 bit
codePoint -= 0x10000;

// 取高 10 bit 计算得到前两个字节
// Shift right to get to most significant 10 bits
var leadingSurrogate = 0xD800 | (codePoint >> 10);

// 低 10 bit 计算得到后两个字节
// Mask to get least significant 10 bits
var trailingSurrogate = 0xDC00 | (codePoint & TEN_BITS);

// 返回编码的两个两字节码元
return u(leadingSurrogate) + u(trailingSurrogate);
}

使用下面表格说明转换的具体情况 (每一个符号表示 1 或 0):

码点 UTF-16 码元
xxxxxxxxxxxxxxxx (16 bits) xxxxxxxxxxxxxxxx
pppppxxxxxxyyyyyyyyyy (21 bits = 5+6+10 bits) 110110qqqqxxxxxx 110111yyyyyyyyyy (qqqq = ppppp − 1)

对于 16 位以内的码点,UTF-16 码元和其码点值相等。对于 21 位以内的码点,首先减去 0x100000,此时后 16 位不受影响 xxxxxxyyyyyyyyyyppppp 则因此变为 qqqq (少一位),接着将得到的 20 位分为两部分 qqqqxxxxxxyyyyyyyyyy 分别加上前缀 110110110111 得到 UTF-16 编码的两个两字节结果。

为了避免冲突,U+0000 ~ U+FFFF 中的 U+D800 ~ U+DFFF 部分为空,这样根据一个码元的取值就可以知道当前位置的码点是两个字节还是四个字节,可以将 UTF-16 编码还原为码点。Acorn 中的 fullCharCodeAtPos 就是做这件事,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
pp.fullCharCodeAtPos = function() {
let code = this.input.charCodeAt(this.pos)
// 如果第一个码元没有落在 0xD800 ~ 0xDFFF,说明是一个两字节的码点直接返回 Unicode 编码
if (code <= 0xd7ff || code >= 0xe000) return code
// 否则把下一个码元读出来。
let next = this.input.charCodeAt(this.pos + 1)
// 将两个码元还原出一个码点。
return (code << 10) + next - 0x35fdc00
// (code << 10) + next - 0x360dc00 + 0x10000
// = (code << 10) + next - (0x360dc00 - 0x10000)
// = (code << 10) + next - 0x35fdc00
}

下面说明 0x35fdc00 得到的方法:

1
2
3
4
5
  0011 0110 qqqq xxxx xx              # code << 10
1101 11yy yyyy yyyy # next
0011 0110 0000 1101 1100 0000 0000 # 相加之后多出来的部分
- 1 0000 0000 0000 0000 # 减去 0x10000
= 0011 0101 1111 1101 1100 0000 0000 # 得到 0x35fdc00

得到当前的字符以后,使用 readToken 读取下一个 Token:

1
2
3
4
5
6
7
8
9
pp.readToken = function(code) {
// Identifier or keyword. '\uXXXX' sequences are allowed in
// identifiers, so '\' also dispatches to that.
// 如果是 identifierStart 调用 readWord 读取一个标识符
if (isIdentifierStart(code, this.options.ecmaVersion >= 6) || code === 92 /* '\' */)
return this.readWord()
// 处理不是标识符的情况
return this.getTokenFromCode(code)
}

下面是标准中说明的可行的 identifierStart,可以是指定范围内的 Unicode 字符、$_ 和 以 \ 开头的 Unicode 转义序列:

1
2
3
4
5
IdentifierStart ::
UnicodeLetter
$
_
\ UnicodeEscapeSequence

所以 readToken 先确认 code 是否是可行的 identifierStart,如果是接下来读取一个标识符,否则读取其他 Token。

参考资料

Chapter 24. Unicode and JavaScript - Speaking Javascript
Unicode与JavaScript详解