Babel 的核心之一就是其转译模块,包括 babel-core、babel-parser 等。Babel 的转译过程分为"解析/转换/生成"三步。
| 转译环节 | 操作 | 功能 |
|---|---|---|
| 解析 | 词法分析 | 生成标记数组(tokens) |
| 语法分析 | 根据 tokens 数组生成源码的树形结构,即抽象语法树 | |
| 语义分析 | 向抽象语法树补充源码的语义信息,如作用域、重复定义检查等 | |
| 转换 | 借助 Babel 插件对抽象语法树进行转换 | 生成目标抽象语法树 |
| 生成 | 根据抽象语法树对象,重新生成代码字符串 | 声明目标代码字符串 |
本章主要分为两部分讲解 Babel 相关的转译原理,分别是"理论知识"和"Babel的转译过程"。阅读顺序建议先了解转译原理的理论知识。
《编译原理》本身是大学的计算机相关课程之一,Babel 用到的理论也是基于其中的部分原理,可惜笔者学过的知识早就还给老师了。
为了帮助开发者了解 Babel 的基本原理,Babel 官网推荐了一个项目: the-super-tiny-compiler,除去注释代码仅 200 行,可读性很好,建议阅读,作为了解 Babel 原理的前菜。
# the-super-tiny-compiler
# 解析目标
the-super-tiny-compiler 项目解析的目标是将 lisp 风格的函数调用转换为 C 风格。
假设有两个函数,add 和 subtract,那么它们的写法将会是下面这样:
LISP C
2 + 2 (add 2 2) add(2, 2)
4 - 2 (subtract 4 2) subtract(4, 2)
2 + (4 - 2) (add 2 (subtract 4 2)) add(2, subtract(4, 2))
2
3
4
5
# 解析过程
# 词法单元
the-super-tiny-compiler 定义了如下词法单元:
| 语素 | 标记类型 |
|---|---|
( | paren |
) | paren |
/[0-9]/ | number |
/[a-z]/i | name |
其中,简略起见,空格没有作为输出的 tokens 中的一员;name 也仅限于字母组成,没有覆盖 add3 这种含字母和数字的情况。
# 代码逻辑
the-super-tiny-compiler 定义了方法 tokenizer(input: string): Array<{ type: String; value: String }> 作为词法分析器。
这个方法不到 100 行,整体运行流程如下:
function tokenizer(input) {
// `current` 字符串扫描过程中指向当前位置的指针
var current = 0;
// `tokens` 数组,也是要返回的对象,数组项为各个 token 单元
var tokens = [];
// 首先创建一个 `while` 循环, `current` 变量会在循环中自增
while (current < input.length) {
// 我们在这里储存了 `input` 中 `current` 位置的字符
var char = input[current];
// token 类型: `paren`,左圆括号
// 检查一下是不是一个左圆括号
if (char === '(') {
// 如果是,那么我们 push 一个 type 为 `paren`,value 为左圆括号的对象。
tokens.push({
type: 'paren',
value: '('
});
// 发现了一个 token,自增 `current`
current++;
// 结束本次循环,进入下一次循环
continue;
}
// token 类型: `paren`,右圆括号
// 然后我们检查是不是一个右圆括号。这里做的时候和之前一样: 检查右圆括号、加入新的 token、
// 自增 `current`,然后进入下一次循环。
if (char === ')') {
tokens.push({
type: 'paren',
value: ')'
});
// 发现了一个 token,自增 `current`
current++;
// 结束本次循环,进入下一次循环
continue;
}
// 过滤空格,空格对 token 而言是无意义的
var WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
// 自增 `current`
current++;
// 结束本次循环,进入下一次循环
continue;
}
// token 类型: `number`,数字
// 下一个 token 的类型是数字。它和之前的一个字符的 token 不同,因为数字可以由多个数字字符组成,
// 但是只能把它们识别为一个 token。
//
// (add 123 456)
// ^^^ ^^^
// 这里只有两个 token
//
// 当遇到一个数字字符时,会进入判断逻辑
var NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
// 创建一个 `value` 字符串,记录数字字符串
var value = '';
// 进入子循环,直到遇到的字符不是数字字符为止,
// 将循环途中遇到的每个数字拼接到 `value` 字符串中,并且自增 `current`
while (NUMBERS.test(char)) {
value += char;
char = input[++current];
}
// 然后将类型为 `number` 的 token 放入 `tokens` 数组中
tokens.push({
type: 'number',
value: value
});
// 结束本次循环,进入下一次循环
continue;
}
// token 类型: `name`,函数名
// 最后一种类型的 token 是 `name`。它由一系列的字母组成,在 lisp 语法中代表函数。
//
// (add 2 4)
// ^^^
// Name token
//
var LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
var value = '';
// 进入子循环,直到遇到的字符不是字母为止。注意,这里没有考虑含有非字母的函数名写法。
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
// 添加一个类型为 `name` 的 token
tokens.push({
type: 'name',
value: value
});
// 结束本次循环,进入下一次循环
continue;
}
// 最后如果没有匹配上任何类型的 token,则抛出一个错误。
throw new TypeError('I dont know what this character is: ' + char);
}
// 词法分析器的最后返回 tokens 数组。
return tokens;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120

# 词法分析
the-super-tiny-compiler 项目里,用到了"词法分析"。
词法分析(lexical analysis)是将字符序列转换为标记(token)的过程。进行词法分析的程序或函数叫做词法分析器(lexical analyzer,简称 lexer),也叫扫描器(scanner)。
以英文句子 "today is comfortable." 为例,词法分析用于这个句子中是否有不合格的"单词",以及将"单词"进行分类,这个"单词"也就是标记(token)。
# 相关概念
# 标记(token)
标记(token)是构成源码的最小单位。从输入的字符串流中生成标记的过程叫标记化(tokenization),在这个过程中,词法分析器也会对标记进行分类。
标记(token)通常是一个对象,包括了"种别码"和"属性值"(token<种别码, 属性值>)。
以 the-super-tiny-compiler 输出的 token 为例,"种别码"也就是 "number/paren/name" 等类型的描述,"属性值"(也就是 "123" 等值)。
词法分析器通常不会关注标记之间的关系(这由语法分析器关注),比如,词法分析可以将括号识别为标记(token),但无法保证括号间是否匹配。
在词法分析过程中,会将代码字符串解析为一个个扁平的不可分割的词法单元,比如:
const a = 1;
这段代码被词法分析后,会分割为如下词法单元。
| 属性值 | 种别码 |
|---|---|
const | const |
a | name |
= | eq |
1 | num |
; | semi |
这里采用的是 Babel 内部定义的种别码,不是普遍标准。
语法分析器读取输入字符流、从中识别出语素、最后生成不同类型的标记。其间一旦发现无效标记,便会报错。
https://tc39.es/ecma262/#prod-ReservedWord
# 标识符(Identifier)
标识符指的是变量、函数、属性的名字、函数的参数。
标识符的书写规则: (规则是哪里来的?)
第一个字符必须是字母、下划线(
_)或者是美元符号($),不能为数字name √ _say √ $hi √ 3name ×1
2
3
4第一个字符之后,可以是字母、下划线(
_)或者是美元符号($)、数字name1 √ n_ame √ n$ √1
2
3不能将关键字、保留字用作标识符
name √ function ×1
2可以使用 Unicode 转义序列。例如,字符
a可以使用"\u0061"表示\u0061 = 1 √1
# 关键字和保留字
关键字
关键字是 ECMA-262 规定的 JavaScript 语言内部使用的一组名称(或称为命令)。这些名称具有特定的用途。
关键字首先是标识名(
IdentifierName),同时具有语义(也就是在 JavaScript 中有特殊用途),比如if/while/async/await等。关键字不能作为标识符(
Identifier),比如变量、标签和函数名,以下这些写法是错误的:const if = 1; × var while = 2; × function await() {} ×1
2
3
4
5保留字
保留字是 ECMA-262 规定的 JavaScript 语言内部预备使用的一组名称(或称为命令)。这些名称目前还没有具体的用途,是为 JavaScript 升级版本预留备用的,建议用户不要使用。
保留字从字面意思来看,就是"未来的关键字",本身还不具有特殊含义,但将来是可能成为关键字的。
保留字不能作为标识符(
Identifier)。保留字分为无条件保留字和有条件保留字。无条件保留字指的是本身严格为保留字,没有任何条件,如
if/while;有条件保留字指的是在特定的上下文中才被视为保留字,比如await只是在async函数/模块中,才被视为保留字。关键字与保留字列举
部分关键字同时也是保留字,部分并不是。有些关键词只在特定的上下文语义中被识别为保留字。
名称 是关键字 是保留字 await× 在 async函数/模块中,才被视为保留字break√ √ case√ √ catch√ √ class√ √ const√ √ continue√ √ debugger√ √ default√ √ delete√ √ do√ √ else√ √ enum√ √ export√ √ extends√ √ false√ √ finally√ √ for√ √ function√ √ if√ √ import√ √ in√ √ instanceof√ √ new√ √ null√ √ return√ √ super√ √ switch√ √ this√ √ throw√ √ true√ √ try√ √ typeof√ √ var√ √ void√ √ while√ √ with√ √ yield√ √ async√ × abstract× √ double× √ goto× √ native× √ static× √ boolean× √ implements× √ package× √ byte× √ private× √ synchronized× √ char× √ extends× √ int× √ protected× √ throws× √ final× √ interface× √ public× √ transient× √ float× √ long× √ short× √ volatile× √
# 词法分析过程
总的来说,词法分析分为 2 个步骤: "扫描、标记"。
# 第 1 步: 扫描识别 token
扫描是词法分析的第一阶段。词法分析器会扫描代码字符串,将其分隔为一个个 token,同时也是边扫描边识别 token 的。
这里涉及 2 个问题:
代码字符串是怎样被分隔为一个个 token 的?
利用有限状态机。
识别 token 的依据是什么?
利用字符串匹配和正则表达式。
# 有限状态机
问题拆解
有限状态机(Finite State Mechine),又称有限状态自动机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。
它有如下特征:
状态总数(state)是有限的 任一时刻,只处在一种状态之中 某种条件下,会从一种状态转变(transition)到另一种状态
在词法分析中,扫描源码时,遇到不同的字符,会进入不同的"状态",这个"状态"有标识符、操作符、数字、字符串等。整个源码的扫描过程就是不同"状态"的迁移过程。
比如,待扫描的源码为:
height >= 20,我们自定义的状态转译过程:
其中,涉及的状态有:
- 标识符: 标记为
name。第一个字符必须是字母,后面的字符可以是字母或数字 - 比较操作符: 标记为
largerthan,就是>= - 数字字面量: 标记为
num,全部由数字构成
对于 JavaScript 而言,很显然这些状态的定义不是很完整,比如"数字字面量"还可以包含小数点,我们自定义的状态定义里缺失了这一点,不过对于我们理解有限状态机已经足够了。
并且,上图并不是真正的优先状态机的描述方式,正确的描述方式是:

- 标识符: 标记为
状态转移过程
空状态
刚开始启动或从其他状态返回的默认空状态。
标识符状态
初始状态时,遇到的第一个字符是字母的话,会转移到"标识符状态"。
然后,继续"预扫描"后续的字符。
如果"预扫描"到的下一个字符是字母或数字,则存储该字符到 token 对象的
value属性,并停留在"标识符状态";如果"预扫描"到的下一个字符不是字母或数字,则回退到上一个字符(也就是"回溯"),记录当前 token 为"标识符",并退回"空状态"。
数字字面量状态
空状态时,遇到的第一个字符是数字的话,会转译到"数字字面量状态"。
然后,继续"预扫描"后续的字符。
如果"预扫描"到的下一个字符是数字,则存储该字符到 token 对象的
value属性,并停留在"数字字面量状态";如果"预扫描"到的下一个字符不是数字,则回退到上一个字符(也就是"回溯"),记录当前 token 为"数字字面量状态",并退回"空状态"。
回溯
在描述状态转译过程时,提到了"回溯"。
回溯法是一种暴力搜索法,采用“试错”的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现,现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。
总结
通过上述分析过程,可以看到,词法分析的过程,其实就是根据构造的有限状态自动机,通过回溯等方式,持续进行状态转移,从而解析出各个
token。例子中只是包含了 3 个状态的有限状态自动机,要构造一个强大的词法分析器,可以根据场景扩展状态数量。
# 正则表达式
这里的"正则表达式"就是 JavaScript 中的正则表达式,在进行状态判断时,通常用正则表达式判断状态类型,比如上文中我们自定义的状态对应的正则如下:
- 标识符:
/[a-zA-Z]([a-zA-Z_]|[0-9])*/ - 数字字面量:
/\d+/ - 赋值操作符:
/=/
当然,也可以用字符串匹配来识别各类状态,词法分析器大量采用了正则表达式和字符串匹配进行状态识别。
Babel 的词法分析器组合使用了正则表达式/字符串匹配(重度使用了 charcodes 这个 npm 包)进行状态识别,后文会详细介绍。
# 第2步: 标记 token
扫描器识别出可标记的字符序列后,会交由标记生成器处理,标记生成器处理的过程叫标记化(tokenization)。
标记化处理时,会将输入字符串分割为标记、进而将标记进行分类。这样生成的标记会被用于语法分析。
比如字符串: The quick brown fox jumps over the lazy dog。
计算机并不知道这是以空格分隔的九个英语单词,只知道这是普通的 43 个字符构成的字符串。可以通过一定的方法(这里使用空格作为分隔符)将语素(这里即英语单词)从输入字符串中分割出来。分割后的结果用 XML 可以表示如下:
<sentence>
<word>The</word>
<word>quick</word>
<word>brown</word>
<word>fox</word>
<word>jumps</word>
<word>over</word>
<word>the</word>
<word>lazy</word>
<word>dog</word>
</sentence>
2
3
4
5
6
7
8
9
10
11
尽管在某些情况下需要手工编写词法分析器,一般情况下词法分析器都用自动化工具生成。
TODO
# Babel 的词法分析
Babel 的词法分析模块定义在 Tokenizer 模块,对应的目录是 babel/packages/babel-parser/src/tokenizer/,并且,Tokenizer 会作为后续语法分析、语义分析的一个中间模块提供服务。
# Babel 的词法单元
Babel 的 Tokenizer 模块定义了基本的词法单元,并深度依赖了第三方模块 charcodes 辅助进行词法分析。
内置词法单元
文件
babel/packages/babel-parser/src/tokenizer/types.js列举了 Babel 支持的所有词法单元。语素 Babel代码中的助记符 简介 分类 numnum数字 bigintbigint用于表示大于 2^53 - 1 的整数 decimaldecimal更语义化地表示数字,如 1_000_000regexpregexp正则 stringstring字符串 namename名称 eofeofend of filebracketLbracketL[字符标点符号 bracketHashLbracketHashL#[字符标点符号 bracketBarLbracketBarL[\|字符标点符号 bracketRbracketR]字符标点符号 bracketBarRbracketBarR\|]字符标点符号 bracketRbracketR}字符标点符号 braceLbraceL{字符标点符号 braceBarLbraceBarL{\|字符标点符号 braceHashLbraceHashL#{字符标点符号 braceRbraceR}字符标点符号 braceBarRbraceBarR\|}字符标点符号 parenLparenL(字符标点符号 parenRparenR)字符标点符号 commacomma,字符标点符号 semisemi;字符标点符号 coloncolon:字符标点符号 doubleColondoubleColon::字符标点符号 dotdot.字符标点符号 questionquestion?字符标点符号 questionDotquestionDot?.字符标点符号 arrowarrow=>字符标点符号 templatetemplatetemplate字符标点符号 ellipsisellipsis...字符标点符号 backQuotebackQuote``` 字符 标点符号 dollarBraceLdollarBraceL${字符标点符号 atat@字符hashhash#字符标点符号 interpreterDirectiveinterpreterDirective#!字符eqeq=字符操作符 assignassign_=字符操作符 slashAssignslashAssign_=字符操作符 incDecincDec++/--字符操作符 bangbang!字符操作符 tildetilde~字符操作符 pipelinepipeline\|>字符操作符 nullishCoalescingnullishCoalescing??字符操作符 logicalORlogicalOR\|\|字符操作符 logicalANDlogicalAND&&字符操作符 bitwiseORbitwiseOR\|字符操作符 bitwiseXORbitwiseXOR^字符操作符 bitwiseANDbitwiseAND&字符操作符 equalityequality==/!=/===/!==字符操作符 relationalrelational</>/<=/>=字符操作符 bitShiftbitShift<</>>/>>>字符操作符 plusMinplusMin+/-字符操作符 modulomodulo%字符操作符 starstar*字符操作符 slashslash/字符操作符 exponentexponent**字符操作符 break_breakbreak字符关键词 case_casecase字符关键词 catch_catchcatch字符关键词 continue_continuecontinue字符关键词 debugger_debuggerdebugger字符关键词 default_defaultdefault字符关键词 do_dodo字符关键词 else_elseelse字符关键词 finally_finallyfinally字符关键词 for_forfor字符关键词 function_functionfunction字符关键词 if_ifif字符关键词 return_returnreturn字符关键词 switch_switchswitch字符关键词 throw_throwthrow字符关键词 try_trytry字符关键词 var_varvar字符关键词 const_constconst字符关键词 while_whilewhile字符关键词 with_withwith字符关键词 new_newnew字符关键词 this_thisthis字符关键词 super_supersuper字符关键词 class_classclass字符关键词 extends_extendsextends字符关键词 export_exportexport字符关键词 import_importimport字符关键词 null_nullnull字符关键词 true_truetrue字符关键词 false_falsefalse字符关键词 in_inin字符关键词 instanceof_instanceofinstanceof字符关键词 typeof_typeoftypeof字符关键词 void_voidvoid字符关键词 delete_deletedelete字符关键词 charcodes
Babel 利用社区的 npm 包
charcodes帮助进行词法单元的识别,charcodes定义的词法单元如下:https://compart.com/en/unicode/U+2029
charcodes 命名 编码 十进制数字 说明 backSpaceU+00088 tabU+00099 \tlineFeedU+000A10 \ncarriageReturnU+000D13 \rshiftOutU+000E14 spaceU+002032 空格 exclamationMarkU+002133 !quotationMarkU+002234 "numberSignU+002335 #dollarSignU+002436 $percentSignU+002537 %ampersandU+002638 &apostropheU+002739 'leftParenthesisU+002840 (rightParenthesisU+002941 )asteriskU+002A42 *plusSignU+002B43 +commaU+002C44 ,dashU+002D45 -dotU+002E46 .slashU+002F47 /digit0U+003048 0digit1U+003149 1digit2U+003250 2digit3U+003351 3digit4U+003452 4digit5U+003553 5digit6U+003654 6digit7U+003755 7digit8U+003856 8digit9U+003957 9colonU+003A58 :semicolonU+003B59 \|lessThanU+003C60 <equalsToU+003D61 =greaterThanU+003E62 >questionMarkU+003F63 ?atSignU+004064 @uppercaseAU+004165 AuppercaseBU+004266 BuppercaseCU+004367 CuppercaseDU+004468 DuppercaseEU+004569 EuppercaseFU+004670 FuppercaseGU+004771 GuppercaseHU+004872 HuppercaseIU+004973 IuppercaseJU+004A74 JuppercaseKU+004B75 KuppercaseLU+004C76 LuppercaseMU+004D77 MuppercaseNU+004E78 NuppercaseOU+004F79 OuppercasePU+005080 PuppercaseQU+005181 QuppercaseRU+005282 RuppercaseSU+005383 SuppercaseTU+005484 TuppercaseUU+005585 UuppercaseVU+005686 VuppercaseWU+005787 WuppercaseXU+005888 XuppercaseYU+005989 YuppercaseZU+005A90 ZleftSquareBracketU+005B91 [backslashU+005C92 \rightSquareBracketU+005D93 ]caretU+005E94 ^underscoreU+005F95 _graveAccentU+006096 ` lowercaseAU+006197 alowercaseBU+006298 blowercaseCU+006399 clowercaseDU+0064100 dlowercaseEU+0065101 elowercaseFU+0066102 flowercaseGU+0067103 glowercaseHU+0068104 hlowercaseIU+0069105 ilowercaseJU+006A106 jlowercaseKU+006B107 klowercaseLU+006C108 llowercaseMU+006D109 mlowercaseNU+006E110 nlowercaseOU+006F111 olowercasePU+0070112 plowercaseQU+0071113 qlowercaseRU+0072114 rlowercaseSU+0073115 slowercaseTU+0074116 tlowercaseUU+0075117 ulowercaseVU+0076118 vlowercaseWU+0077119 wlowercaseXU+0078120 xlowercaseYU+0079121 ylowercaseZU+007A122 zleftCurlyBraceU+007B123 {verticalBarU+007C124 \|rightCurlyBraceU+007D125 }tildeU+007E126 ~nonBreakingSpaceU+00A0160 不换行空格 oghamSpaceMarkU+16805760 lineSeparatorU+20288232 paragraphSeparatorU+20298233
# Babel tokenizer 源码解析
# 文件结构
Babel Tokenizer 的核心代码位于目录 github/babel/packages/babel-parser/src/tokenizer/,其中 /tokenizer/index.js 定义了 Tokenizer 类。
文件结构:
|-- babel-parser
|-- src
|-- tokenizer
|-- context.js: 上下文对象 context,与 token 示例关联
|-- index.js: 定义了 Tokenizer 类,包括各种 token 相关的处理方法和属性
|-- state.js: 维护了当前 token 实例的状态信息
|-- types.js: 维护了所有 token 类型
2
3
4
5
6
7
# tokendizer/index.js 重点代码
总体而言,tokendizer/index.js 中定义的 Tokenizer 类继承自 PaserErrors 对象,同时定义了关于 token 的各种操作方法和属性。
tokens: Array<Token | N.Comment> = [];token数组。数组项可以是Token类的实例或者是注释对象。nextToken()访问下一个
token。笔者在源码中添加了部分注释,便于理解。
nextToken(): void { const curContext = this.curContext(); // 如果不保留空白符和注释,则跳过它们 // 一般情况下,均认为空格是可以忽略的,不需要识别为 token。有几种类型的 token 是例外: // 1. `template`: 模板字符串中的空格是有意义的 // 2. jsx 中的 `expr`: `<tag>...</tag>` 标签内的空格也是有意义的 if (!curContext.preserveSpace) this.skipSpace(); this.state.start = this.state.pos; // 记录访问开始的位置信息 if (!this.isLookahead) this.state.startLoc = this.state.curPosition(); // 如果目标访问位置超过了源码长度,则认为进入源码文件末尾,也就是 "end-of-file" if (this.state.pos >= this.length) { this.finishToken(tt.eof); return; } // 区分是在读取 template 语法还是非 template 语法(数字、操作符等) if (curContext === ct.template) { this.readTmplToken(); } else { this.getTokenFromCode(this.codePointAtPos(this.state.pos)); } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27lookahead()读取并返回下一个
token对象。这里用到了简单的"回溯"写法,先保存当前 token 对象,再直接进入并获取下一个 token 对象,最后恢复为当前 token 对象。lookahead(): LookaheadState { // 保留状态 const old = this.state; // 创建一个新的 token state this.state = this.createLookaheadState(old); // 访问下一个 token this.isLookahead = true; this.nextToken(); this.isLookahead = false; const curr = this.state; // 恢复状态 this.state = old; // 返回下一个 token return curr; }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20codePointAtPos()TODO
# Babel 定义的 token 类型
词法文法: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Lexical_grammar JavaScript 参考: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference
在介绍词法分析时提到过一个案例:
const a = 1;
这段代码被词法分析后,会分割为如下词法单元。
| 词法单元 | token 类型 |
|---|---|
const | const |
a | name |
= | eq |
1 | num |
; | semi |
很显然这些类型对 Babel 来说是不够的,笔者整理了 Babel tokendizer 中的 token 类型。
# "空白"
"空白"包括: 格式控制符、空白符、行终止符:
格式控制符
用于控制对源码文本的解释,但是并不会显示出来。
空白符
用于提升源码的可读性,并不影响源码的功能,通常用于将标记(tokens)分开。
行终止符
除了"空白符"之外,"行终止符"也可以提高源码的可读性。
不同的是,"行终止符"可以影响 JavaScript 代码的执行。"行终止符"也会影响自动分号补全的执行。在正则表达式中,"行终止符"会被
\s匹配。
下表是各种"空白"的介绍:
| 分类 | 编码 | 名称 | 缩写 | 说明 | 转义序列 |
|---|---|---|---|---|---|
| 格式控制符 | U+200C | 零宽不连字 | <ZWNJ> | 放置在一些经常会被当成连字的字符之间,用于将它们分别以独立形式显示 | |
| 格式控制符 | U+200D | 零宽连字 | <ZWJ> | 放置在一些通常不会被标记为连字的字符之间,用于将这些字符以连字形式显示 | |
| 格式控制符 | U+FEFF | 字节流方向标识 | <BOM> | 在脚本开头使用,除了将脚本标记为 Unicode 格式以外,还用来标记文本的字节流方向 | |
| 空白符 | U+0009 | 制表符 | <HT> | 水平制表符,就是TAB | \t |
| 空白符 | U+000B | 垂直制表符 | <VT> | 垂直制表符,就是垂直方向的TAB | \v |
| 空白符 | U+000C | 分页符 | <FF> | 分页符 | \f |
| 空白符 | U+0020 | 空格 | <SP> | 空格 | |
| 空白符 | U+00A0 | 不换行空格 | <NBSP> | <SP> 的变体,非换行空格,在文字排版中可以避免因为空格在此处发生断行,在 HTML 中使用的   会生成它 | |
| 空白符 | Others | 其他 Unicode 空白 | <USP> | ||
| 行终止符 | U+000A | 换行符 | <LF> | 在 UNIX 系统中起新行 | \n |
| 行终止符 | U+000D | 回车符 | <CR> | 在 Commodore 和早期的 Mac 系统中起新行 | \r |
| 行终止符 | U+2028 | 行分隔符 | <LS> | Unicode 中的行分隔符 | |
| 行终止符 | U+2029 | 段分隔符 | <PS> | Unicode 中的段落分隔符 |
Babel 中,有一个单独的文件提供了"空白"相关的工具方法: babel/packages/babel-parser/src/util/whitespace.js。
其核心代码如下:
const lineBreak = /\r\n?|[\n\u2028\u2029]/;
// https://tc39.github.io/ecma262/#sec-line-terminators
export function isNewLine(code: number): boolean {
switch (code) {
case charCodes.lineFeed: // "\n" <LF>
case charCodes.carriageReturn: // "\r" <CR>
case charCodes.lineSeparator: // 行分隔符 <LS>
case charCodes.paragraphSeparator: // 段分隔符 <PS>
return true;
default:
return false;
}
}
// https://tc39.github.io/ecma262/#sec-white-space
export function isWhitespace(code: number): boolean {
switch (code) {
case 0x0009: // CHARACTER TABULATION
case 0x000b: // LINE TABULATION
case 0x000c: // FORM FEED
case charCodes.space: // 空格 <SP>
case charCodes.nonBreakingSpace: // 不换行空格 <NBSP>
case charCodes.oghamSpaceMark: // ?
case 0x2000: // EN QUAD
case 0x2001: // EM QUAD
case 0x2002: // EN SPACE
case 0x2003: // EM SPACE
case 0x2004: // THREE-PER-EM SPACE
case 0x2005: // FOUR-PER-EM SPACE
case 0x2006: // SIX-PER-EM SPACE
case 0x2007: // FIGURE SPACE
case 0x2008: // PUNCTUATION SPACE
case 0x2009: // THIN SPACE
case 0x200a: // HAIR SPACE
case 0x202f: // NARROW NO-BREAK SPACE
case 0x205f: // MEDIUM MATHEMATICAL SPACE
case 0x3000: // IDEOGRAPHIC SPACE
case 0xfeff: // ZERO WIDTH NO-BREAK SPACE
return true;
default:
return false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 注释
注释用来在源码中增加提示、笔记、建议、警告等信息,可以帮助阅读和理解源码。在调试时,可以用来将一段代码屏蔽掉,防止其运行。
在 JavaScript 中,有 3 种添加注释的方法。
单行注释:
//后的文本都视为注释// 这是单行注释 console.log('hello world!');1
2多行注释:
/* *//* * 这是多行注释 1 * 这是多行注释 2 */ console.log('hello world!');1
2
3
4
5Hashbang 注释:
#!#!/usr/bin/env node console.log('hello world!');1
2JavaScript 中的 hashbang 注释模仿 Unix 中的
shebangs,用于指定适当的解释器运行文件,它仅在脚本或模块的绝对开头有效。在计算领域中,Shebang(也称为 Hashbang)是一个由井号和叹号构成的字符序列
#!,其出现在文本文件的第一行的前两个字符。 在文件中存在Shebang的情况下,类 Unix 操作系统的程序加载器会分析Shebang后的内容,将这些内容作为解释器指令,并调用该指令,并将载有Shebang的文件路径作为该解释器的参数。例如,以指令
#!/bin/sh开头的文件在执行时会实际调用/bin/sh程序。下面列出了一些典型的 shebang 解释器指令:
#!/bin/sh— 使用sh,即 Bourne shell或其它兼容shell` 执行脚本#!/bin/csh— 使用csh,即 C shell 执行#!/usr/bin/perl -w— 使用带警告的 Perl 执行#!/usr/bin/python -O— 使用具有代码优化的 Python 执行#!/usr/bin/php— 使用 PHP 的命令行解释器执行
文件 babel/packages/babel-parser/src/tokenizer/index.js 中有与注释相关的处理方法,tokenizer 对注释的处理方式分为两种:
单行和多行注释,视为"空白"
skipSpace()方法会识别注释并添加到commentStack数组中。识别时,以
/为关注点,检测下一个字符是*或/。如果下一个字符是
*,进入skipBlockComment()的执行流程;如果下一个字符是
/,进入skipLineComment()的执行流程。Hashbang 注释的处理方法
readToken_interpreter(): boolean { // 如果字符起始位置不是第一个字符,就直接返回,不做处理 // 如果长度不够 2,也不做处理 if (this.state.pos !== 0 || this.length < 2) return false; let ch = this.input.charCodeAt(this.state.pos + 1); if (ch !== charCodes.exclamationMark) return false; // ! const start = this.state.pos; this.state.pos += 1; while (!isNewLine(ch) && ++this.state.pos < this.length) { ch = this.input.charCodeAt(this.state.pos); } // 取出 #! xxx 中的 xxx 作为 value 值 const value = this.input.slice(start + 2, this.state.pos); this.finishToken(tt.interpreterDirective, value); return true; }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22该方法会识别并取出
#! xxx中的xxx作为该 token 的值。
# 标识符: name
标识符是代码中用来标识变量、函数、或属性的字符序列。
在 JavaScript 中,标识符只能包含字母或数字或下划线("_")或美元符号("$"),且不能以数字开头。
文件 babel/packages/babel-parser/src/tokenizer/index.js 中与标识符识别相关的方法是:
readWord(firstCode: number | void): void {
const word = this.readWord1(firstCode);
const type = keywordTypes.get(word) || tt.name;
this.finishToken(type, word);
},
readWord1(firstCode: numbder | void): string {
...
}
2
3
4
5
6
7
8
# 数字直接量: num
JavaScript 中表示数字的方式
字面量
使用字面量(literal)直接表示一个数值时,JavaScript 对整数提供四种进制的表示方法: 十进制、十六进制、八进制、二进制。
十进制: 没有前导
0的数值。只有十进制可以带小数和用科学计数法表示。
带小数时小数点前后部分都可以省略(不能同时省略),如
0.1 / 1. / 2.4。八进制: 有前缀
0o或0O的数值,或者有前导0、且只用到0-7的八个阿拉伯数字的数值。十六进制: 有前缀
0x或0X的数值。二进制: 有前缀
0b或0B的数值。
科学计数法
科学计数法表示允许字母
e或E的后面,跟着一个整数,表示这个数值的指数部分。123e3 // 123000 123e-3 // 0.123 -3.1E+12 .1e-231
2
3
4特殊数值
正零和负零
在 JavaScript 的 64 位浮点数中,有一个二进制位是符号位,那么,0 也是有一个对应的负值的。
+0和-0都指的是0,只是它们的64位浮点数表示法的符号位不同。一般状态下,
+0和-0是等价的,会被当做正常的0:+0 // 0 -0 // 0 (-0).toString() // '0' (+0).toString() // '0'1
2
3
4只是,如果
+0和-0作为分母,则有一些区别:(1 / +0) === (1 / -0) // false1因为
1 / +0的结果是+Infinity,1 / -0的结果是-Infinity。NaNInfinity
文件 babel/packages/babel-parser/src/tokenizer/index.js 对数字的处理
readInt()TODO
readRadixNumber()TODO
readNumber()TODO
# 正则表达式直接量: regexp
正则的
flag正则有如下
flag:g/m/s/i/y/u/dcharCodes.lowercaseG charCodes.lowercaseM charCodes.lowercaseS charCodes.lowercaseI charCodes.lowercaseY charCodes.lowercaseU charCodes.lowercaseD1
2
3
4
5
6
7tokenizer 对正则的处理
readRegexp(): void { const start = this.state.start + 1; let escaped, inClass; let { pos } = this.state; for (; ; ++pos) { if (pos >= this.length) { throw this.raise(start, Errors.UnterminatedRegExp); } const ch = this.input.charCodeAt(pos); if (isNewLine(ch)) { // 遇到换行符,抛出错误,遇到未完结的正则表达式 throw this.raise(start, Errors.UnterminatedRegExp); } if (escaped) { escaped = false; } else { if (ch === charCodes.leftSquareBracket) { // [ inClass = true; } else if (ch === charCodes.rightSquareBracket && inClass) { // ] inClass = false; } else if (ch === charCodes.slash && !inClass) { // / break; } escaped = ch === charCodes.backslash; // \ } } const content = this.input.slice(start, pos); // 内容 ++pos; let mods = ""; while (pos < this.length) { const cp = this.codePointAtPos(pos); // It doesn't matter if cp > 0xffff, the loop will either throw or break because we check on cp const char = String.fromCharCode(cp); if (VALID_REGEX_FLAGS.has(cp)) { if (mods.includes(char)) { this.raise(pos + 1, Errors.DuplicateRegExpFlags); // 重复的 flag } } else if (isIdentifierChar(cp) || cp === charCodes.backslash) { this.raise(pos + 1, Errors.MalformedRegExpFlags); // 非法正则 } else { break; } ++pos; mods += char; } this.state.pos = pos; this.finishToken(tt.regexp, { pattern: content, flags: mods, }); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# 字符串直接量: string
readString(quote: number): void {
let out = "",
chunkStart = ++this.state.pos;
for (;;) {
if (this.state.pos >= this.length) {
throw this.raise(this.state.start, Errors.UnterminatedString);
}
const ch = this.input.charCodeAt(this.state.pos);
if (ch === quote) break;
if (ch === charCodes.backslash) {
out += this.input.slice(chunkStart, this.state.pos);
// $FlowFixMe
out += this.readEscapedChar(false);
chunkStart = this.state.pos;
} else if (
ch === charCodes.lineSeparator ||
ch === charCodes.paragraphSeparator
) {
++this.state.pos;
++this.state.curLine;
this.state.lineStart = this.state.pos;
} else if (isNewLine(ch)) {
throw this.raise(this.state.start, Errors.UnterminatedString);
} else {
++this.state.pos;
}
}
out += this.input.slice(chunkStart, this.state.pos++);
this.finishToken(tt.string, out);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 模板字符串: template
模板字符串是允许嵌入表达式的字符串字面量,可以使用多行、表达式引入、标签引入功能。
它有如下形态:
// 单行
`string text`
// 多行
`string text line 1
string text line 2`
// 表达式引入
`string text ${expression} string text`
// 标签引入
tag`string text ${expression} string text`
2
3
4
5
6
7
8
9
10
11
12
token 解析时,需要对以下情况做处理。
- 该代码段第一批字符是 ` 开头的话,会记录为一个
tt.backQuote类型 - 该代码段第一批字符是
${开头的话,会记录为一个tt.dollarBraceL类型 - 在扫描该代码段途中,遇到转译标志
\、换行\r及\n时,会继续扫描检查是否满足对目标格式的要求
# 符号
JavaScript 中有如下符号:
{ ( ) [ ] . ... ; , < > <= >= == != === !== + - * % ** ++ -- << >> >>> & | ^ ! ~ && || ? : = += -= *= %= **= <<= >>= >>>= &= |= ^= => / /= }
因需识别字符串模板,
{和}也被视为可能有意义的符号 因需识别除法和正则,/也被视为可能有意义的符号
.:dot当源码遍历过程中,遇到
.字符时,会调用readToken_dot方法。.开头的合法 token 的值有两种: 数字(如.123)、扩展运算符(...)。readToken_dot(): void { const next = this.input.charCodeAt(this.state.pos + 1); // 识别 ".123" if (next >= charCodes.digit0 && next <= charCodes.digit9) { this.readNumber(true); return; } // 识别 "..." if (next === charCodes.dot && this.input.charCodeAt(this.state.pos + 2) === charCodes.dot) { this.state.pos += 3; this.finishToken(tt.ellipsis); } else { ++this.state.pos; this.finishToken(tt.dot); } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18/:slash当源码遍历过程中,遇到
/字符时,会调用readToken_slash方法。readToken_slash(): void { const next = this.input.charCodeAt(this.state.pos + 1); if (next === charCodes.equalsTo) { // /= this.finishOp(tt.slashAssign, 2); } else { this.finishOp(tt.slash, 1); } }1
2
3
4
5
6
7
8该方法可以识别2种token:
/=、/。*:star或%:modulo当源码遍历过程中,遇到
*或%字符时,会调用readToken_mult_modulo方法。readToken_mult_modulo(code: number): void { // '%*' let type = code === charCodes.asterisk ? tt.star : tt.modulo; // asterisk: * let width = 1; let next = this.input.charCodeAt(this.state.pos + 1); // Exponentiation operator ** if (code === charCodes.asterisk && next === charCodes.asterisk) { width++; next = this.input.charCodeAt(this.state.pos + 2); type = tt.exponent; // ** } if (next === charCodes.equalsTo && !this.state.inType) { width++; type = tt.assign; // *= / %= } this.finishOp(type, width); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20该方法可以识别 3 种 token:
**、*=、%=。|或&当源码遍历过程中,遇到
|或&字符时,会调用readToken_pipe_amp方法。该方法可以识别 3 种 token:
||、、&&、、||=、、&&=、|>、|}、|]。值得注意的是,该方法内部有对部分插件的引入要求:
// '|}' if (this.hasPlugin("recordAndTuple") && next === charCodes.rightCurlyBrace) { // 要求引入插件 babel-plugin-syntax-record-and-tuple, // 并且插件的 syntaxType 配置项的值不能是 "bar" if (this.getPluginOption("recordAndTuple", "syntaxType") !== "bar") { // 没有引入插件,抛出 throw this.raise( this.state.pos, Errors.RecordExpressionBarIncorrectEndSyntaxType, ); } ... }1
2
3
4
5
6
7
8
9
10
11
12
13
14^当源码遍历过程中,遇到
^字符时,会调用readToken_caret方法。readToken_caret(): void { const next = this.input.charCodeAt(this.state.pos + 1); if (next === charCodes.equalsTo) { // ^= this.finishOp(tt.assign, 2); } else { this.finishOp(tt.bitwiseXOR, 1); // 异或运算符 } }1
2
3
4
5
6
7
8该方法可以识别 2 种 token:
^=、^。+或-当源码遍历过程中,遇到
+或-字符时,会调用readToken_plus_min方法。readToken_plus_min(code: number): void { // '+-' const next = this.input.charCodeAt(this.state.pos + 1); if (next === code) { this.finishOp(tt.incDec, 2); // ++ -- return; } if (next === charCodes.equalsTo) { // += -= this.finishOp(tt.assign, 2); } else { this.finishOp(tt.plusMin, 1); // + - } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15该方法可以识别6种token:
++、--、+=、-=、+、-。<或>当源码遍历过程中,遇到
<或>字符时,会调用readToken_lt_gt方法。readToken_lt_gt(code: number): void { // '<>' const next = this.input.charCodeAt(this.state.pos + 1); let size = 1; if (next === code) { size = code === charCodes.greaterThan && this.input.charCodeAt(this.state.pos + 2) === charCodes.greaterThan ? 3 : 2; if (this.input.charCodeAt(this.state.pos + size) === charCodes.equalsTo) { this.finishOp(tt.assign, size + 1); return; } this.finishOp(tt.bitShift, size); // << >> return; } if (next === charCodes.equalsTo) { // <= | >= size = 2; } this.finishOp(tt.relational, size); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27readToken_lt_gt方法可以识别 6 种 token:<<、>>、<、>、<=、>=。=或!当源码遍历过程中,遇到
=或!时,会调用readToken_eq_excl方法。readToken_eq_excl(code: number): void { // = ! const next = this.input.charCodeAt(this.state.pos + 1); if (next === charCodes.equalsTo) { // == != this.finishOp( tt.equality, this.input.charCodeAt(this.state.pos + 2) === charCodes.equalsTo ? 3 : 2, ); // === !== == != return; } if (code === charCodes.equalsTo && next === charCodes.greaterThan) { // '=>' this.state.pos += 2; this.finishToken(tt.arrow); return; } this.finishOp(code === charCodes.equalsTo ? tt.eq : tt.bang, 1); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18readToken_lt_gt方法可以识别7种token:=、!、==、!=、===、!==、==>。?当源码遍历过程中,遇到
?,会调用readToken_eq_excl方法。readToken_question(): void { const next = this.input.charCodeAt(this.state.pos + 1); const next2 = this.input.charCodeAt(this.state.pos + 2); if (next === charCodes.questionMark) { // ?? if (next2 === charCodes.equalsTo) { // ??= this.finishOp(tt.assign, 3); } else { this.finishOp(tt.nullishCoalescing, 2); // ?? } } else if ( next === charCodes.dot && !(next2 >= charCodes.digit0 && next2 <= charCodes.digit9) // ?. ) { // '.' not followed by a number this.state.pos += 2; this.finishToken(tt.questionDot); } else { ++this.state.pos; this.finishToken(tt.question); // ? } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21readToken_lt_gt方法可以识别 4 种 token:?、??、??=、?.。
# 词法错误
词法错误发生的时机
Tokenizer使用继承自babel/packages/babel-parser/src/parser/error.js的raise()方法抛出词法分析中扫描到的错误。Tokenizer中抛出词法错误的时机有如下几种场景:严格模式中发现错误
严格模式(
"use strict;")下有一系列的限制条件,Tokenizer在分析过程中,会尝试发现这些错误并抛出。- 禁止出现
0开头的数字,如"use strict"; 010;,在词法分析阶段就会发现并抛出该错误,但允许\\0开头的数字 - 禁止出现八进制数字
- 禁止出现
扫描到的疑似词法单元不满足格式要求
比如块级注释
/* */缺乏闭合标签、hashbang 注释#!匹配到了不合适的字符如#2、正则表达式缺乏闭合标签等。缺乏必要的 Babel 插件引入
对于某些特殊词法单元,需要显示引入对应的插件才会开启支持,否则会抛出错误。
Tokenizer在解析时,出现过多次对recordAndTuple插件的引入要求。#{语法要求引入recordAndTuple插件且syntaxType配置项设置为hash{|和|}语法要求引入recordAndTuple插件且syntaxType配置项设置为bar
未知的词法单元,也就是常见的
UnexpectedToken如果 Babel 定义的词法单元都没有被识别出的话,就会抛出
UnexpectedToken类型的错误,文案描述为"Unexpected character ..."。比如,源码中出现了一个不属于任何词法单元类型的字符时,会抛出
UnexpectedToken的错误:const · = 1;1在词法解析时就会抛出错误:
Unexpected character '·'.,因为·并不是一个合法的词法单元字符。
词法错误列表
在词法分析时,是能够发现源码中的一些问题的,比如括号缺少闭合标签等。
这里列举了 Babel 在词法分析阶段能够发现的错误:
错误描述定义在文件
babel/packages/babel-parser/src/parser/error-message.jsRecordExpressionBarIncorrectStartSyntaxTypeRecord expressions starting with '{|' are only allowed when the 'syntaxType' option of the 'recordAndTuple' plugin is set to 'bar'.1{|语法要求引入recordAndTuple插件且syntaxType配置项设置为bar。InvalidOrUnexpectedTokenUnexpected character '%0'.1出现了非法的 token 字符。
UnterminatedRegExpUnterminated regular expression.1未闭合的正则表达式。
DuplicateRegExpFlagsDuplicate regular expression flag.1重复的正则 flag。
MalformedRegExpFlagsInvalid regular expression flag.1非法的正则 flag
UnexpectedNumericSeparatorA numeric separator is only allowed between two digits.11_a1SyntaxError: unknown: A numeric separator is only allowed between two digits. (1:1) > 1 | 1_a | ^1
2
3
4NumericSeparatorInEscapeSequenceNumeric separators are not allowed inside unicode escape sequences or hex escape sequences.1InvalidDigitExpected number in radix %0.1InvalidDecimalInvalid decimal.1NumberIdentifierIdentifier directly after number.1无效的小数。
InvalidNumberInvalid number.1非法数字。
ZeroDigitNumericSeparatorNumeric separator can not be used after leading 0.1InvalidOrMissingExponentFloating-point numbers require a valid exponent after the 'e'.1InvalidBigIntLiteralInvalid BigIntLiteral.1非法的大数字面量。
InvalidCodePointCode point out of bounds.1UnterminatedStringUnterminated string constant.1未闭合的字符串。
InvalidEscapeSequenceBad character escape sequence.1MissingUnicodeEscapeExpecting Unicode escape sequence \\uXXXX.1EscapedCharNotAnIdentifierInvalid Unicode escape.1InvalidEscapedReservedWordEscape sequence in keyword %0.1