前言:
typescript(ts)的使用已经十分普及了,我们日常开发中也应该有所使用,至少有所接触。我们一般都是关注它的用法,比如如何优雅高效的声明复杂的数据结构类型等。然而,我们却很少向下看,即ts是如何工作的,也就是ts是如何编译的。网上资料关于这部分内容的介绍也是少之又少,本文就从原理层面浅谈一些ts的的编译原理。这只是一个敲门砖,更加深入透彻的学习理解其工作机制还需参考ts源码 [github.com/microsoft/T…] 进一步学习。
一、概览
ts编译可以分解为几个大步骤:
- Scanner 扫描器(
scanner.ts
) - Parser 解析器(
parser.ts
) - Binder 绑定器(
binder.ts
) - Checker 检查器(
checker.ts
) - Emitter 发射器(
emitter.ts
)
括号里是源码中对应的文件名。
这几个步骤是如何配合工作的呢?来张流程图会更加清晰!
可以看到整个编译流程大体分为两个步骤从而达到两个目的:
两个目的:
- 类型检查
- 编译成js代码
与之相对应两个流程:
- 源代码 -> 扫描器 -> token流 -> 解析器 -> AST抽象语法树 ->绑定器 -> Symbol(符号),然后AST抽象语法树 -> 检查器+symbols -> 类型检查
- AST -> 检查器 + 发射器 -> js代码
?额,流程貌似比较多,每一步有是什么玩意儿?代表什么意思?怎么工作的?。。。 下面章节里咱们一一道来。
二、流程说明
2.1 扫描器
从流程图中可以看出,扫描器的作用是生成token流。什么是token流呢?简单来说就是词法单元。我们知道js的编译分为三步:词法单元(token流)、抽象语法树(AST)、转换成可执行代码。
举个?:
var a = 2;
//上面赋值语句被分解成为下面这些词法单元:
//var、a、=、2、;。这些词法单元组成了一个词法单元流数组
// 词法分析后的结果
[
"var" : "keyword",
"a" : "identifier",
"=" : "assignment",
"2" : "integer",
";" : "eos" (end of statement)
]
//这个数组就是token数组;根据token流进一步把词法单元流数组转换成一个由元素逐级嵌套所组成的代表程序语法结构的树,即AST树
{
operation: "=",
left: {
keyword: "var",
right: "a"
}
right: "2"
}
复制代码
现在知道了,扫描器就是生成那个token数组的。为什么这样说,因为我有证据,看源码:
export function createScanner(languageVersion: ScriptTarget,
skipTrivia: boolean,
languageVariant = LanguageVariant.Standard,
text?: string,
onError?: ErrorCallback,
start?: number,
length?: number): Scanner {
let pos: number;
let end: number;
let startPos: number;
let tokenPos: number;
let token: SyntaxKind;
// ...
return {
getStartPos: () => startPos,
getTextPos: () => pos,
getToken: () => token,
getTokenPos: () => tokenPos,
// ...
scan,
// ...
};
复制代码
createScanner
创建扫描器,扫描的具体逻辑在scan
函数里:
function scan(): SyntaxKind {
startPos = pos;
hasExtendedUnicodeEscape = false;
precedingLineBreak = false;
tokenIsUnterminated = false;
numericLiteralFlags = 0;
while (true) {
tokenPos = pos;
if (pos >= end) {
return token = SyntaxKind.EndOfFileToken;
}
let ch = text.charCodeAt(pos);
// Special handling for shebang
if (ch === CharacterCodes.hash && pos === 0 && isShebangTrivia(text, pos)) {
pos = scanShebangTrivia(text, pos);
if (skipTrivia) {
continue;
}
else {
return token = SyntaxKind.ShebangTrivia;
}
}
switch (ch) {
// ...
复制代码
我们可以看到scan
函数返回的类型是SyntaxKind,这是一个词法关键词的枚举类型的定义:
// token > SyntaxKind.Identifer => token is a keyword
// Also, If you add a new SyntaxKind be sure to keep the `Markers` section at the bottom in sync
export const enum SyntaxKind {
Unknown,
EndOfFileToken,
SingleLineCommentTrivia,
MultiLineCommentTrivia,
NewLineTrivia,
WhitespaceTrivia,
// We detect and preserve #! on the first line
ShebangTrivia,
// We detect and provide better error recovery when we encounter a git merge marker. This
// allows us to edit files with git-conflict markers in them in a much more pleasant manner.
ConflictMarkerTrivia,
// Literals
NumericLiteral,
StringLiteral,
JsxText,
JsxTextAllWhiteSpaces,
RegularExpressionLiteral,
NoSubstitutionTemplateLiteral,
// Pseudo-literals
TemplateHead,
TemplateMiddle,
TemplateTail,
// Punctuation
OpenBraceToken,
ReturnKeyword,
SuperKeyword,
SwitchKeyword,
// ...
}
复制代码
由上可知,扫描器通过对输入的源代码进行词法分析, 得到对应的SyntaxKind
即“token”。我们可以自己写个例子来生成token流:
//ntypescriptz这个库暴露了很多ts的api,我们引入这个库就可以使用源码内的方法了
import * as ts from 'ntypescript';
//创建扫描器
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true);
//初始化扫描器
function initializeState(text: string) {
scanner.setText(text);
scanner.setScriptTarget(ts.ScriptTarget.ES5);
scanner.setLanguageVariant(ts.LanguageVariant.Standard);
}
const str = 'const a = 1;'
initializeState(str);
var token = scanner.scan();
//调用scan获取token。只要token不是结束的token那么扫描器会一直扫描输入的字符串
while(token != ts.SyntaxKind.EndOfFileToken) {
console.log(token);
console.log(ts.formatSyntaxKind(token));
token = scanner.scan();
}
复制代码
输出结果如下:
76 //对应SyntaxKind的数字索引值,即SyntaxKind[76],ConstKeyword
ConstKeyword //对应 const
71
Identifier //对应 a
58
EqualsToken //对应 =
8
NumericLiteral //对应 1
25
SemicolonToken //对应 ;
复制代码
嗯嗯,我们知道了scanner函数是用来生成token流的,也知道token流长什么样子了
2.2 解析器
猜也猜到了,没错,解析器是根据token流生成AST抽象语法树的!
先来看看一个经典的生成AST的例子:
import * as ts from 'ntypescript';
function printAllChildren(node: ts.Node, depth = 0) {
console.log(new Array(depth + 1).join('----'), ts.formatSyntaxKind(node.kind), node.pos, node.end);
depth++;
node.getChildren().forEach(c => printAllChildren(c, depth));
}
var sourceCode = `const foo = 123;`;
var sourceFile = ts.createSourceFile('foo.ts', sourceCode, ts.ScriptTarget.ES5, true);
printAllChildren(sourceFile);
复制代码
输出:
SourceFile 0 16
---- SyntaxList 0 16
-------- VariableStatement 0 16
------------ VariableDeclarationList 0 15
---------------- ConstKeyword 0 5
---------------- SyntaxList 5 15
-------------------- VariableDeclaration 5 15
------------------------ Identifier 5 9
------------------------ EqualsToken 9 11
------------------------ NumericLiteral 11 15
------------ SemicolonToken 15 16
---- EndOfFileToken 16 16
复制代码
AST其实就是一个大对象,树上的每个节点都表示源代码中的一种结构,比如包含节点的对应的类型type 、节点的起始位置等。
所以解析器的实现应该和上面的代码差别不大。来看ts源码,在parser.ts中也用到了createSourceFile:
export function createSourceFile(fileName: string, sourceText: string, languageVersion: ScriptTarget, setParentNodes = false, scriptKind?: ScriptKind): SourceFile {
performance.mark("beforeParse");
const result = Parser.parseSourceFile(fileName, sourceText, languageVersion, /*syntaxCursor*/ undefined, setParentNodes, scriptKind);
performance.mark("afterParse");
performance.measure("Parse", "beforeParse", "afterParse");
return result;
}
复制代码
很明显了,performance.mark("beforeParse");
、 performance.mark("afterParse")
。它是标记解析前和解析后的时刻,那么中间那句就应该是解析过程了,command+右键点击函数进去看看,代码重要的地方我加了注释:
function parseSourceFileWorker(fileName: string, languageVersion: ScriptTarget, setParentNodes: boolean, scriptKind: ScriptKind): SourceFile {
//创建解析的目标
sourceFile = createSourceFile(fileName, languageVersion, scriptKind);
sourceFile.flags = contextFlags;
// Prime the scanner.
//执行nextToken(), 更新扫描的token
nextToken();
//生成每个token的各种信息(包括起点和终点)
processReferenceComments(sourceFile);
//根据创建节点以及节点的信息,因为AST是由节点构成的
//parseList里函数的调用层级比较深,这里不具体做展开说明
sourceFile.statements = parseList(ParsingContext.SourceElements, parseStatement);
Debug.assert(token() === SyntaxKind.EndOfFileToken);
sourceFile.endOfFileToken = addJSDocComment(parseTokenNode() as EndOfFileToken);
setExternalModuleIndicator(sourceFile);
sourceFile.nodeCount = nodeCount;
sourceFile.identifierCount = identifierCount;
sourceFile.identifiers = identifiers;
sourceFile.parseDiagnostics = parseDiagnostics;
if (setParentNodes) {
fixupParentReferences(sourceFile);
}
return sourceFile;
}
复制代码
所以上面的函数意思是对解析目标的token循环创建节点,从而构成AST
2.3 绑定器
绑定器是用来创建符号symbols的,开局一张图,里面画的有嘛。它是为了协助(检查器执行)类型检查,绑定器将源码的各部分连接成一个相关的类型系统,供检查器使用。
2.3.1 符号
符号将 AST 中声明的节点与其它声明连接到相同的实体上。符号是语义系统的基本构造块。那么符号到底长啥样呢?
function Symbol(flags: SymbolFlags, name: string) {
this.flags = flags;
this.name = name;
this.declarations = undefined;
}
复制代码
SymbolFlags 符号标志是个枚举标志,用于识别额外的符号类别(例如:变量作用域标志 FunctionScopedVariable 或 BlockScopedVariable 等)具体可以查看compiler/types 中SymbolFlags的枚举定义。可以看到,所谓的符号也是一个对象里面包含标志、名称、声明三个信息。
2.3.2 创建符号&绑定节点
源码里bind.ts中的bindSourceFile
是绑定器的入口:
export function bindSourceFile(file: SourceFile, options: CompilerOptions) {
performance.mark("beforeBind");
binder(file, options);
performance.mark("afterBind");
performance.measure("Bind", "beforeBind", "afterBind");
}
复制代码
有点熟悉吗,奥利奥夹心结构呐!和解析器代码结构类似嘛,performance.mark()分别标志着绑定前和后,中间自然是绑定的逻辑了,进去看!
function bind(node: Node): void {
if (!node) {
return;
}
node.parent = parent;
const saveInStrictMode = inStrictMode;
// Even though in the AST the jsdoc @typedef node belongs to the current node,
// its symbol might be in the same scope with the current node's symbol. Consider:
//
// /** @typedef {string | number} MyType */
// function foo();
//
// Here the current node is "foo", which is a container, but the scope of "MyType" should
// not be inside "foo". Therefore we always bind @typedef before bind the parent node,
// and skip binding this tag later when binding all the other jsdoc tags.
if (isInJavaScriptFile(node)) bindJSDocTypedefTagIfAny(node);
// First we bind declaration nodes to a symbol if possible. We'll both create a symbol
// and then potentially add the symbol to an appropriate symbol table. Possible
// destination symbol tables are:
//
// 1) The 'exports' table of the current container's symbol.
// 2) The 'members' table of the current container's symbol.
// 3) The 'locals' table of the current container.
//
// However, not all symbols will end up in any of these tables. 'Anonymous' symbols
// (like TypeLiterals for example) will not be put in any table.
bindWorker(node);
// Then we recurse into the children of the node to bind them as well. For certain
// symbols we do specialized work when we recurse. For example, we'll keep track of
// the current 'container' node when it changes. This helps us know which symbol table
// a local should go into for example. Since terminal nodes are known not to have
// children, as an optimization we don't process those.
if (node.kind > SyntaxKind.LastToken) {
const saveParent = parent;
parent = node;
const containerFlags = getContainerFlags(node);
if (containerFlags === ContainerFlags.None) {
bindChildren(node);
}
else {
bindContainer(node, containerFlags);
}
parent = saveParent;
}
else if (!skipTransformFlagAggregation && (node.transformFlags & TransformFlags.HasComputedFlags) === 0) {
subtreeTransformFlags |= computeTransformFlagsForNode(node, 0);
}
inStrictMode = saveInStrictMode;
}
复制代码
源码里面注释好多,看注释就能大概了解意思了。它做的第一件事是分配 node.parent
(如果 parent
变量已设置,绑定器在 bindChildren
函数的处理中仍会再次设置), 然后交给 bindWorker
根据不同的节点调用与之对应的绑定函数 。最后调用 bindChildren
(该函数简单地将绑定器的状态(如:parent
)存入函数本地变量中,接着在每个子节点上调用 bind
,然后再将状态转存回绑定器中),递归调用。因此这个重点在bindWorker
函数上。
function bindWorker(node: Node) {
switch (node.kind) {
case SyntaxKind.Identifier:
if ((<Identifier>node).isInJSDocNamespace) {
let parentNode = node.parent;
while (parentNode && parentNode.kind !== SyntaxKind.JSDocTypedefTag) {
parentNode = parentNode.parent;
}
bindBlockScopedDeclaration(<Declaration>parentNode, SymbolFlags.TypeAlias, SymbolFlags.TypeAliasExcludes);
break;
}
// ...
}
复制代码
该函数依据 node.kind
(SyntaxKind
类型)进行切换,并将工作委托给合适的 bindXXX
函数(也定义在binder.ts
中)。例如:如果该节点是 Identifier
则调用 bindBlockScopedDeclaration
。bindXXX
系函数有一些通用的模式和工具函数。其中最常用的一个是 createSymbol
函数:
function createSymbol(flags: SymbolFlags, name: string): Symbol {
symbolCount++;
return new Symbol(flags, name);
}
复制代码
这个函数更新了symbolCount,并使用指定的参数创建符号。创建了符号之后需要进行对节点的绑定,实现节点和符号间的链接:
function addDeclarationToSymbol(symbol: Symbol, node: Declaration, symbolFlags: SymbolFlags) {
symbol.flags |= symbolFlags;
// 创建 AST 节点到 symbol 的连接
node.symbol = symbol;
if (!symbol.declarations) {
symbol.declarations = [];
}
// 将该节点添加为该符号的一个声明
symbol.declarations.push(node);
// ...
}
复制代码
上述代码主要执行的操作如下:
- 创建一个从 AST 节点到符号的链接(
node.symbol
) - 将节点添加为该符号的一个声明
至此,源代码 -> 扫描器 -> token流 -> 解析器 -> AST ->绑定器 -> Symbol(符号)这个流程已经完毕。剩下类型检查
和 代码发射
。
2.4 检查器
检查器位于 checker.ts
中,当前有 23k 行以上的代码(编译器中最大的部分),这里也是简化说明。检查器是由程序初始化,下面是调用栈示意:
program.getTypeChecker ->
ts.createTypeChecker(检查器)->
initializeTypeChecker(检查器) ->
for each SourceFile `ts.bindSourceFile`(绑定器)
// 接着
for each SourceFile `ts.mergeSymbolTable`(检查器)
复制代码
我可以发现在initializeTypeChecker
的时候会调用 绑定器的bindSourceFile
以及 检查器本身的
mergeSymbolTable。其实bindSourceFile
在上节提到过,它的功能就是最后给每一个节点都创建了一个符号,将各个节点连接成一个相关的类型系统。那么mergeSymbolTable是干啥的?其实它是将所有的 global 符号合并到 let globals: SymbolTable = {} 符号表中。往后的类型检查都 统一在global上校验即可。真正的类型检查会在调用 getDiagnostics
时才发生。
function getDiagnostics(sourceFile: SourceFile, ct: CancellationToken): Diagnostic[] {
try {
cancellationToken = ct;
return getDiagnosticsWorker(sourceFile);
}
finally {
cancellationToken = undefined;
}
}
复制代码
getDiagnosticsWorker函数里面有调用了好多函数,太复杂了,就不展开说了。这里借用网上的一张图展示一下大致流程,以示大家(在这里重申,大家如果想了解具体的原理还是要看代码,自己多研究,所有的总结内容都是依据源码来的呀!这里是抛砖引玉的
):
检查器源码总结下来: 它就是根据我们生成AST上节点的声明起始节点的位置对传进来的字符串做位置类型语法等的校验与异常的抛出。
这样,AST -> 检查器 ~~ Symbol(符号) -> 类型检查也结束了。
2.5 发射器
TypeScript 编译器提供了两个发射器:
emitter.ts
:它是 TS -> JavaScript 的发射器declarationEmitter.ts
:这个发射器用于为 TypeScript 源文件(.ts
) 创建声明文件(.d.ts
)
Program 提供了一个 emit
函数。该函数主要将功能委托给 emitter.ts
中的 emitFiles
函数。下面是调用栈:
Program.emit ->
`emitWorker` (在 program.ts 中的 createProgram) ->
`emitFiles` (emitter.ts 中的函数)
复制代码
emitWorker
(通过 emitFiles
参数)给发射器emitter提供一个 EmitResolver
。 EmitResolver
由程序的 TypeChecker 提供,基本上它是一个来自 createChecker
的本地函数集合。发射器根据不同的hint发射不同的代码:
function pipelineEmitWithHint(hint: EmitHint, node: Node): void {
switch (hint)
{ case EmitHint.SourceFile: return pipelineEmitSourceFile(node);
case EmitHint.IdentifierName: return pipelineEmitIdentifierName(node);
case EmitHint.Expression: return pipelineEmitExpression(node);
case EmitHint.Unspecified: return pipelineEmitUnspecified(node);
}
}
复制代码
最终完成了 检查器+发射器->js的过程。
三、最后
参考文章:
谢谢大家!