[Node] 淡如止水 TypeScript (三):词法分析

0. 回顾

上文我们介绍了 TypeScript 编译过程的宏观步骤:

(1)先从 lib/tsc 开始,处理命令行调用
(2)然后分两个大的步骤完成编译:源码解析、写文件
(3)源码解析过程,会创建 ProgramSourceFile 两个关键对象
(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 的入口,
createSourceFilesrc/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.parseSourceFilesrc/compiler/parser.ts#L692

export function parseSourceFile(...): SourceFile {
  ...
  const result = parseSourceFileWorker(fileName, languageVersion, setParentNodes, scriptKind);
  ...
  return result;
}

接着执行 parseSourceFileWorkersrc/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();
}

调用了 nextTokenWithoutChecksrc/compiler/parser.ts#L1094

function nextTokenWithoutCheck() {
  return currentToken = scanner.scan();
}

这里调用了 scanner.scan(),获取 token 值,
scanner.scansrc/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 会跑到 switchdefault 分支。

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 中完成的。

getIdentifierTokensrc/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.ConstKeyword80

这就是 parseSourceFileWorkersrc/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,判定这是一个标识符或者关键字,
然后往后读取字符 onst,都满足标识符的定义,
接着再读入的字符就是空格了,不再满足标识符的定义了,就返回 const,作为扫描结果。

其他 token 的扫描过程,大同小异,只是处理细节会非常繁琐。

参考

TypeScript v3.7.3

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,163评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,301评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,089评论 0 352
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,093评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,110评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,079评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,005评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,840评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,278评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,497评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,394评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,980评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,628评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,649评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,548评论 2 352

推荐阅读更多精彩内容