0. 回顾
上文我们介绍了 TypeScript 编译过程的宏观步骤:
(1)先从 lib/tsc
开始,处理命令行调用
(2)然后分两个大的步骤完成编译:源码解析、写文件
(3)源码解析过程,会创建 Program
和 SourceFile
两个关键对象
(4)TypeScript 会为每个文件创建一个 SourceFile
,通过调用 parser 来生成
下文我们来看一下,源码解析的过程到底是怎么完成的,SourceFile
到底是怎样生成的。
由于这一块内容会比较复杂,因此拆分成了两篇文章,
本文先介绍词法分析部分,下一篇介绍语法分析。
1. 词法分析器的状态:nextToken() & token()
TypeScript 的词法分析器,有两个方法用的特别多,nextToken()
和 token()
。
词法分析器会在字符流中记住当前处理的位置,
token()
没有副作用,每次执行,都只会返回当前 token 的种类 SyntaxKind
(对的)。
function token(): SyntaxKind {
return currentToken;
}
nextToken()
的逻辑会比较长,包含了词法分析的所有细节,表示把词法分析器状态往后推移一个 token。
上一篇,代码执行到了 src/compiler/parser.ts
中,这是 parser 的入口,
createSourceFile
,src/compiler/parser.ts#L515,
export function createSourceFile(...): SourceFile {
...
if (languageVersion === ScriptTarget.JSON) {
...
}
else {
result = Parser.parseSourceFile(fileName, sourceText, languageVersion, /*syntaxCursor*/ undefined, setParentNodes, scriptKind);
}
...
return result;
}
它会调用 Parser.parseSourceFile
,src/compiler/parser.ts#L692,
export function parseSourceFile(...): SourceFile {
...
const result = parseSourceFileWorker(fileName, languageVersion, setParentNodes, scriptKind);
...
return result;
}
接着执行 parseSourceFileWorker
,src/compiler/parser.ts#L843,
function parseSourceFileWorker(fileName: string, languageVersion: ScriptTarget, setParentNodes: boolean, scriptKind: ScriptKind): SourceFile {
...
sourceFile = createSourceFile(fileName, languageVersion, scriptKind, isDeclarationFile);
...
nextToken();
...
sourceFile.statements = parseList(ParsingContext.SourceElements, parseStatement);
...
return sourceFile;
...
}
这里会调用 createSourceFile
创建 SourceFile
对象,但实际上只创建了 AST 的根节点。
接着调用的就是 nextToken()
了,词法分析器的状态会向后移,开始处理下一个 token,
而 parseList
是最顶层的解析函数,进行递归下降解析,sourceFile
就是完全解析后的结果。
2. 状态后移:nextToken()
2.1 扫描并积累字符
我们看 nextToken()
的执行过程,src/compiler/parser.ts#1098,
function nextToken(): SyntaxKind {
...
return nextTokenWithoutCheck();
}
调用了 nextTokenWithoutCheck
,src/compiler/parser.ts#L1094,
function nextTokenWithoutCheck() {
return currentToken = scanner.scan();
}
这里调用了 scanner.scan()
,获取 token 值,
scanner.scan
,src/compiler/scanner.ts#L1490,这个函数比较长,有 443
行,
scanner
会在字符流中记住当前处理的位置 pos
,然后往后扫描 token。
function scan(): SyntaxKind {
startPos = pos;
...
while (true) {
...
let ch = codePointAt(text, pos);
...
switch (ch) {
...
case CharacterCodes.plus:
...
default:
if (isIdentifierStart(ch, languageVersion)) {
pos += charSize(ch);
while (pos < end && isIdentifierPart(ch = codePointAt(text, pos), languageVersion)) pos += charSize(ch);
tokenValue = text.substring(tokenPos, pos);
...
return token = getIdentifierToken();
}
else if (isWhiteSpaceSingleLine(ch)) {
...
}
else if (isLineBreak(ch)) {
...
}
...
}
}
}
以上 scan
函数,会从当前位置 pos
读取一个字符 ch
,
然后判断它的 CharacterCodes
类型,分别进行处理。
我们示例代码 debug/index.ts
中,第一字符是 c
,
const i: number = 1;
因此,scan
会跑到 switch
的 default
分支。
if (isIdentifierStart(ch, languageVersion)) {
pos += charSize(ch);
while (pos < end && isIdentifierPart(ch = codePointAt(text, pos), languageVersion)) pos += charSize(ch);
tokenValue = text.substring(tokenPos, pos);
...
return token = getIdentifierToken();
}
else if (isWhiteSpaceSingleLine(ch)) {
...
}
else if (isLineBreak(ch)) {
...
}
这里的代码逻辑是,判断 ch
是否一个标识符的开始符号,这里 c
确实是这种情况,
然后开始往后积累字符,直到不构成标识符为止,这样就从字符流中读取出了一个完整的标识符了。
我们示例源代码中,c
开头的标识符是 const
,因此,这里 tokenValue
就是 const
了。
2.2 返回 token 的种类,而不是 tokenValue
最后,scan
函数并没有返回 tokenValue
,而是返回了一个 SyntaxKind
枚举,表示该 token 的种类。
这是在 getIdentifierToken
中完成的。
getIdentifierToken
,src/compiler/scanner.ts#L1414
function getIdentifierToken(): SyntaxKind.Identifier | KeywordSyntaxKind {
// Reserved words are between 2 and 11 characters long and start with a lowercase letter
const len = tokenValue.length;
if (len >= 2 && len <= 11) {
const ch = tokenValue.charCodeAt(0);
if (ch >= CharacterCodes.a && ch <= CharacterCodes.z) {
const keyword = textToKeyword.get(tokenValue);
if (keyword !== undefined) {
return token = keyword;
}
}
}
...
}
这个函数里,区分了关键字 keyword
和普通的标识符。
在我们的例子中,const
是一个关键字,
因此,会根据 textToKeyword.get(tokenValue)
,获得 const
关键字对应的 SyntaxKind
。
映射关系位于 textToKeywordObj
里,src/compiler/scanner.ts#L66,
const textToKeywordObj: MapLike<KeywordSyntaxKind> = {
...
const: SyntaxKind.ConstKeyword,
...
};
这个枚举值 SyntaxKind.ConstKeyword
为 80
。
这就是 parseSourceFileWorker
,src/compiler/parser.ts#L843 中,nextToken()
的执行结果,
function parseSourceFileWorker(fileName: string, languageVersion: ScriptTarget, setParentNodes: boolean, scriptKind: ScriptKind): SourceFile {
...
sourceFile = createSourceFile(fileName, languageVersion, scriptKind, isDeclarationFile);
...
nextToken();
...
sourceFile.statements = parseList(ParsingContext.SourceElements, parseStatement);
...
return sourceFile;
...
}
接下来就调用 parseList
,就从第一个 token 开始解析了。
解析过程中,随时可以使用 token()
来获取当前 token。
总结
本文只是粗略探索了 TypeScript 词法分析器的冰山一角,
印象比较深刻的是,词法分析器内部保存了状态。
在进行词法分析时,TypeScript 会先根据下一个字符分情况处理,
在每一种情况中,都会不断的 “吃掉” 字符,直到不再满足条件的字符出现。
例如,const
关键字的扫描过程,词法分析器会先扫描到字符 c
,判定这是一个标识符或者关键字,
然后往后读取字符 o
,n
,s
,t
,都满足标识符的定义,
接着再读入的字符就是空格了,不再满足标识符的定义了,就返回 const
,作为扫描结果。
其他 token 的扫描过程,大同小异,只是处理细节会非常繁琐。