博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

TS 原理详细解读(5)语法2-语法解析

Posted on 2020-02-08 22:50  xuld  阅读(3031)  评论(3编辑  收藏  举报

上一节介绍了语法树的结构,本节则介绍如何解析标记组成语法树。

对应的源码位于 src/compiler/parser.ts。

 

入口函数

要解析一份源码,输入当然是源码内容(字符串),同时还提供路径(用于报错)、语言版本(比如ES3 和 ES5 在有些细节不同)。

createSourceFile 是负责将源码解析为语法树的入口函数,用户可以直接调用:比如 ts.createSourceFile(‘<stdio>’, 'var xld;')。

export function createSourceFile(fileName: string, sourceText: string, languageVersion: ScriptTarget, setParentNodes = false, scriptKind?: ScriptKind): SourceFile {
        performance.mark("beforeParse");
        let result: SourceFile;

        perfLogger.logStartParseSourceFile(fileName);
        if (languageVersion === ScriptTarget.JSON) {
            result = Parser.parseSourceFile(fileName, sourceText, languageVersion, /*syntaxCursor*/ undefined, setParentNodes, ScriptKind.JSON);
        }
        else {
            result = Parser.parseSourceFile(fileName, sourceText, languageVersion, /*syntaxCursor*/ undefined, setParentNodes, scriptKind);
        }
        perfLogger.logStopParseSourceFile();

        performance.mark("afterParse");
        performance.measure("Parse", "beforeParse", "afterParse");
        return result;
    }

入口函数内部除了某些性能测试代码,主要是调用 Parser.parseSourceFile 完成解析。

解析源文件对象

export function parseSourceFile(fileName: string, sourceText: string, languageVersion: ScriptTarget, syntaxCursor: IncrementalParser.SyntaxCursor | undefined, setParentNodes = false, scriptKind?: ScriptKind): SourceFile {
    scriptKind = ensureScriptKind(fileName, scriptKind);
    /* ...(略)... */

    initializeState(sourceText, languageVersion, syntaxCursor, scriptKind);

    const result = parseSourceFileWorker(fileName, languageVersion, setParentNodes, scriptKind);

    clearState();

    return result;
}

function initializeState(_sourceText: string, languageVersion: ScriptTarget, _syntaxCursor: IncrementalParser.SyntaxCursor | undefined, scriptKind: ScriptKind) {
    NodeConstructor = objectAllocator.getNodeConstructor();
    TokenConstructor = objectAllocator.getTokenConstructor();
    IdentifierConstructor = objectAllocator.getIdentifierConstructor();
    SourceFileConstructor = objectAllocator.getSourceFileConstructor();

    sourceText = _sourceText;
    syntaxCursor = _syntaxCursor;

    parseDiagnostics = [];
    parsingContext = 0;
    identifiers = createMap<string>();
    identifierCount = 0;
    nodeCount = 0;

    switch (scriptKind) {
        case ScriptKind.JS:
        case ScriptKind.JSX:
            contextFlags = NodeFlags.JavaScriptFile;
            break;
        case ScriptKind.JSON:
            contextFlags = NodeFlags.JavaScriptFile | NodeFlags.JsonFile;
            break;
        default:
            contextFlags = NodeFlags.None;
            break;
    }
    parseErrorBeforeNextFinishedNode = false;

    // Initialize and prime the scanner before parsing the source elements.
    scanner.setText(sourceText);
    scanner.setOnError(scanError);
    scanner.setScriptTarget(languageVersion);
    scanner.setLanguageVariant(getLanguageVariant(scriptKind));
}
如果你仔细读了这段代码,你可能会有这些疑问:

1. NodeConstructor 等是什么

你可以直接将它看成 Node 类的构造函数,new NodeConstructor 和 new Node 是一回事。那为什么不直接用 new Node? 这是一种性能优化手段。TS 设计的目的是用于任何 JS 引擎,包括浏览器、Node.js、微软自己的 JS 引擎,而 Node 代表语法树节点,数目会非常多,TS 允许针对不同的环境使用不同的 Node 类型,以达到最节约内存的效果。

2. syntaxCursor 是什么

这是用于增量解析的对象,如果不执行增量解析,它是空的。增量解析是指如果之前已解析过一次源码,第二次解析时可以复用上次解析的结果,主要在编译器场景使用:编辑完源码后,源码要重新解析为语法树,如果通过增量解析,可以大幅减少解析次数。增量解析将在下一节中详细介绍。

3. identifiers 是什么

一般地,我们认为:源码中的单词都会用两次以上(变量名总会有定义和使用的时候,这里就有两次),如果将相同内容的字符串共用相同的引用,可以节约内存。identifiers 就保存了每个字符串内存的唯一引用。

4. parsingContext 是什么

用于指代当前解析所在的标记位,比如当前函数是否有 async,这样可以判断 await 是否合法。

5. parseErrorBeforeNextFinishedNode 是什么

每个语法树节点,都通过 createNode 创建,然后结束时会调用 finishNode,如果在解析一个语法树节点时出现错误(可能是词法扫描错误、也可能是语法错误),都会把 parseErrorBeforeNextFinishedNode 改成 true,在 finishNode 中会判断这个变量,然后标记这个语法树节点存在语法错误。TypeScript 比其它语法解析器强大的地方在于碰到语法错误后并不会终止解析,而是尝试修复源码。(因为在编辑器环境,不可能因为存在错误就停止自动补全)。这里标记节点语法错误,是为了下次增量解析时禁止重用此节点。

解析过程

虽然这篇文章叫 TypeScript 源码解读,但其实主要是介绍编译器的实现原理,知道了这些原理,无论什么语言的编译器你都能弄明白,反过来如果你之前没有什么基础想要自己读懂 TypeScript,那是很难的。源码就像水果,你需要自己剥;这篇文章就像果汁,营养吸收地更快。插图是展示原理的最好方式,因此文中会包含大量的插图,如果你现在读的这个网页是纯文字,一张插图都没有,那么这个网站就是盗版侵权的,请重新百度。原版都是有插图的,插图可以让你快速理解原理!这类文章目前不止中文版的稀缺,英文版的同样稀缺,毕竟了解编译原理、且真正能开发出语言的人是非常少的。有兴趣读这些文章的也绝不是只知道搬砖赚钱的菜鸟,请支持原版!

 

解析器每次读取一个标记,并根据这个标记判断接下来是什么语法,比如碰到 if 就知道是 if 语句,碰到 var 知道是变量声明。

当发现 if 之后,根据 if 语句的定义,接下来会强制读取一个 “(” 标记,如果读不到就报错:语法错误,缺少“(”。读完 “(” 后解析一个表达式,然后再解析一个“)”,然后再解析一个语句,如果这时接下来发现一个 else,就继续读一个语句,否则直接终止,然后重新判断下一个标记的语法。 

 

 

 

if 语句的语法定义是这样的:

IfStatement:
    if ( Expression ) Statement
    if ( Expression ) Statement else Statement

这个定义的意思是:if 语句(IfStatement)有两种语法,当然无论哪种,开头都是 if ( Expression ) Statement

为什么是这样定义的呢,这是因为JS是遵守ECMA-262规范的,而ECMA-262规范就像一种协议,规定了 if 语句要怎么定义。

ECMA-262 规范也有很多版本,熟悉的ES3,ES5,ES6 这些其实就是这个规范的版本。ES10的版本可以在这里查看:http://www.ecma-international.org/ecma-262/10.0/index.html#sec-grammar-summary

 

代码实现

源文件由语句组成,首先读取下一个标记(nextToken);然后解析语句列表(parseList, parseStatement)

function parseSourceFileWorker(fileName: string, languageVersion: ScriptTarget, setParentNodes: boolean, scriptKind: ScriptKind): SourceFile {
    const isDeclarationFile = isDeclarationFileName(fileName);

    sourceFile = createSourceFile(fileName, languageVersion, scriptKind, isDeclarationFile);
    sourceFile.flags = contextFlags;

    // Prime the scanner.
    nextToken();
    // A member of ReadonlyArray<T> isn't assignable to a member of T[] (and prevents a direct cast) - but this is where we set up those members so they can be readonly in the future
    processCommentPragmas(sourceFile as {} as PragmaContext, sourceText);
    processPragmasIntoFields(sourceFile as {} as PragmaContext, reportPragmaDiagnostic);

    sourceFile.statements = parseList(ParsingContext.SourceElements, parseStatement);
    Debug.assert(token() === SyntaxKind.EndOfFileToken);
    sourceFile.endOfFileToken = addJSDocComment(parseTokenNode());

    setExternalModuleIndicator(sourceFile);

    sourceFile.nodeCount = nodeCount;
    sourceFile.identifierCount = identifierCount;
    sourceFile.identifiers = identifiers;
    sourceFile.parseDiagnostics = parseDiagnostics;

    if (setParentNodes) {
        fixupParentReferences(sourceFile);
    }

    return sourceFile;

    function reportPragmaDiagnostic(pos: number, end: number, diagnostic: DiagnosticMessage) {
        parseDiagnostics.push(createFileDiagnostic(sourceFile, pos, end, diagnostic));
    }
}

解析一个语句:

 function parseStatement(): Statement {
    switch (token()) {
        case SyntaxKind.SemicolonToken:
            return parseEmptyStatement();
        case SyntaxKind.OpenBraceToken:
            return parseBlock(/*ignoreMissingOpenBrace*/ false);
        case SyntaxKind.VarKeyword:
            return parseVariableStatement(<VariableStatement>createNodeWithJSDoc(SyntaxKind.VariableDeclaration));
        // ...(略)
    }
 }

规则很简单:

先看现在标记是什么,比如是 var,说明是一个 var 语句,那就继续解析 var 语句:

function parseVariableStatement(node: VariableStatement): VariableStatement {
    node.kind = SyntaxKind.VariableStatement;
    node.declarationList = parseVariableDeclarationList(/*inForStatementInitializer*/ false);
    parseSemicolon();
    return finishNode(node);
}

var 语句的解析过程为先解析一个声明列表,然后解析分号(parseSemicolon)

 

再看一个 while 语句的解析:

function parseWhileStatement(): WhileStatement {
    const node = <WhileStatement>createNode(SyntaxKind.WhileStatement);
    parseExpected(SyntaxKind.WhileKeyword);  // while
    parseExpected(SyntaxKind.OpenParenToken); // (
    node.expression = allowInAnd(parseExpression); // *Expession*
    parseExpected(SyntaxKind.CloseParenToken); // )
    node.statement = parseStatement(); // *Statement*
    return finishNode(node);
}

所谓语法解析,就是把每个不同的语法都这样解析一次,然后得到语法树。

 

其中,最复杂的应该是解析列表(parseList):

 // Parses a list of elements
 function parseList<T extends Node>(kind: ParsingContext, parseElement: () => T): NodeArray<T> {
    const saveParsingContext = parsingContext;
    parsingContext |= 1 << kind;
    const list = [];
    const listPos = getNodePos();

    while (!isListTerminator(kind)) {
        if (isListElement(kind, /*inErrorRecovery*/ false)) {
            const element = parseListElement(kind, parseElement);
            list.push(element);

            continue;
        }

        if (abortParsingListOrMoveToNextToken(kind)) {
            break;
        }
    }

    parsingContext = saveParsingContext;
    return createNodeArray(list, listPos);
}

parseList 的核心就是一个循环,只要列表没有结束,就一直解析同一种语法。

比如解析参数列表,碰到“)”表示列表结束,否则一直解析“参数”;比如解析数组表达式,碰到“]”结束。

如果理论接下来应该解析参数时,但下一个标记又不是参数,则会出现语法错误,但接下来应该解析解析参数,还是不再继续参数列表,这时候用 abortParsingListOrMoveToNextToken 判断。
 
其中,kind: ParsingContext 用于区分不同的列表(是参数,还是数组?或者别的?)
 

列表结束

// True if positioned at a list terminator
function isListTerminator(kind: ParsingContext): boolean {
    if (token() === SyntaxKind.EndOfFileToken) {
        // Being at the end of the file ends all lists.
        return true;
    }

    switch (kind) {
        case ParsingContext.BlockStatements:
        case ParsingContext.SwitchClauses:
        case ParsingContext.TypeMembers:
            return token() === SyntaxKind.CloseBraceToken;
        // ...(略)
    }
}

总结:对于有括号的标记,只有碰到右半括号,才能停止解析,其它的比如继承列表(extends A, B, C) 碰到 “{” 就结束。

 

解析元素

function parseListElement<T extends Node>(parsingContext: ParsingContext, parseElement: () => T): T {
    const node = currentNode(parsingContext);
    if (node) {
        return <T>consumeNode(node);
    }

    return parseElement();
}

这里本质是使用了 parseElement,其它代码是为了增量解析(后面详解)

 

继续列表?

function abortParsingListOrMoveToNextToken(kind: ParsingContext) {
    parseErrorAtCurrentToken(parsingContextErrors(kind));
    if (isInSomeParsingContext()) {
        return true;
    }

    nextToken();
    return false;
}
// True if positioned at element or terminator of the current list or any enclosing list
function isInSomeParsingContext(): boolean {
    for (let kind = 0; kind < ParsingContext.Count; kind++) {
        if (parsingContext & (1 << kind)) { // 只要是任意一种上下文
            if (isListElement(kind, /*inErrorRecovery*/ true) || isListTerminator(kind)) {
                return true;
            }
        }
    }

    return false;
}

总结:如果接下来的标记是合法的元素,就继续解析,此时解析器认为用户只是忘打逗号之类的分隔符。如果不是说明这个列表根本就是有问题的,不再继续犯错。

 

上面重点介绍了 if 的语法,其它都大同小异,就不再介绍。

现在你应该知道语法树产生的大致过程了,如果仍不懂的,可在此处停顿往回复习,并对照源码,加以理解。

 

语法上下文

有些语法的使用是有要求的,比如 await 只在 async 函数内部才作关键字。

源码中用闭包内全局的变量存储这些信息。思路是:先设置允许 await 标记位,然后解析表达式(这时标记位已设置成允许 await),解析完成则清除标记位。

function doInAwaitContext<T>(func: () => T): T {
    return doInsideOfContext(NodeFlags.AwaitContext, func);
}

function doInsideOfContext<T>(context: NodeFlags, func: () => T): T {
    // contextFlagsToSet will contain only the context flags that
    // are not currently set that we need to temporarily enable.
    // We don't just blindly reset to the previous flags to ensure
    // that we do not mutate cached flags for the incremental
    // parser (ThisNodeHasError, ThisNodeOrAnySubNodesHasError, and
    // HasAggregatedChildData).
    const contextFlagsToSet = context & ~contextFlags;
    if (contextFlagsToSet) {
        // set the requested context flags
        setContextFlag(/*val*/ true, contextFlagsToSet);
        const result = func();
        // reset the context flags we just set
        setContextFlag(/*val*/ false, contextFlagsToSet);
        return result;
    }

    // no need to do anything special as we are already in all of the requested contexts
    return func();
}

这也是为什么规范中每个语法名称后面都带了一个小括号的原因:表示此处的表达式是否包括 await 这样的意义。

IfStatement[Yield, Await, Return]:
   if ( Expression[+In, ?Yield, ?Await] ) Statement[?Yield, ?Await, ?Return] else Statement[?Yield, ?Await, ?Return]
   if ( Expression[+In, ?Yield, ?Await] ) Statement[?Yield, ?Await, ?Return]

其中,+In 表示允许 in 标记,?yield 表示 yield 标记保持不变,- in 表示禁止 in 标记。

 

通过上下文语法,下面这样的代码是不允许的:

for(var x = key in item; x; ) {
      
}

(虽然 in 是本身也是可以直接使用的运算符,但不能用于 for 的初始值,否则按 for..in 解析。)

 

后瞻(lookahead)

上面举的例子,都是可以通过第一个标记就可以确定后面的语法(比如碰到 if 就按 if 语句处理)。那有没可能只看第一个标记无法确定之后的语法呢?

早期的 JS 版本是没有的(毕竟这样编译器做起来简单),但随着 JS 功能不断增加,就出现了这样的情况。

比如直接 x 是变量,如果后面有箭头, x =>,就成了参数。

这时需要用到语法后瞻,所谓的后瞻就是提前看一下后面的标记,然后决定。

/** Invokes the provided callback then unconditionally restores the parser to the state it
 * was in immediately prior to invoking the callback.  The result of invoking the callback
 * is returned from this function.
 */
function lookAhead<T>(callback: () => T): T {
    return speculationHelper(callback, /*isLookAhead*/ true);
}

function speculationHelper<T>(callback: () => T, isLookAhead: boolean): T {
    // Keep track of the state we'll need to rollback to if lookahead fails (or if the
    // caller asked us to always reset our state).
    const saveToken = currentToken;
    const saveParseDiagnosticsLength = parseDiagnostics.length;
    const saveParseErrorBeforeNextFinishedNode = parseErrorBeforeNextFinishedNode;

    // Note: it is not actually necessary to save/restore the context flags here.  That's
    // because the saving/restoring of these flags happens naturally through the recursive
    // descent nature of our parser.  However, we still store this here just so we can
    // assert that invariant holds.
    const saveContextFlags = contextFlags;

    // If we're only looking ahead, then tell the scanner to only lookahead as well.
    // Otherwise, if we're actually speculatively parsing, then tell the scanner to do the
    // same.
    const result = isLookAhead
        ? scanner.lookAhead(callback)
        : scanner.tryScan(callback);

    Debug.assert(saveContextFlags === contextFlags);

    // If our callback returned something 'falsy' or we're just looking ahead,
    // then unconditionally restore us to where we were.
    if (!result || isLookAhead) {
        currentToken = saveToken;
        parseDiagnostics.length = saveParseDiagnosticsLength;
        parseErrorBeforeNextFinishedNode = saveParseErrorBeforeNextFinishedNode;
    }

    return result;
}

说的比较简单,具体实现稍微麻烦:如果预览的时候发现标记错误咋办?所以需要先记住当前的错误信息,然后使用扫描器的预览功能读取之后的标记,之后完全恢复到之前的状态。

 

TypeScript 中有哪些语法要后瞻呢?

  • <T> 可能是类型转换(<any>x)、箭头函数( <T> x => {})或 JSX (<T>X</T>)
  • public 可能是修饰符(public class A {}),或变量(public++)
  • type 在后面跟标识符时才是别名类型,否则作变量
  • let 只有在后面跟标识符时才是变量声明,否则是变量,但 let let = 1 是不对的。

可以看到语法后瞻增加了编译器的复杂度,也浪费了一些性能。

虽然语法设计者尽量避免出现这样的后瞻,但还是有一些地方,因为兼容问题不得不采用这个方案。

 

语法歧义

/ 的歧义

之前提到的 / 可能是除号或正则表达式,在词法阶段还无法分析,但在语法解析阶段,因为已知道现在需要什么语法,可以正确地处理这个符号。

比如需要表达式的时候,碰到 /,因为 / 不能是表达式的开头,只能把 / 重新按正则表达式标记解析。如果在表达式后面碰到 /,那就作除号。

 

但也有些歧义是语法解析阶段都很难处理的。

< 的歧义

比如 call<number,  any>(x),你可能觉得是调用 call 泛型函数(参数  x),但它也可以理解成: (call < number) , (any > (x))

所有支持泛型的C风格语言都有类似的问题,多数编译器的做法是:和你想的一样,按泛型看,毕竟一般人很少在 > 后面写括号。

 

在 TS 设计之初,<T>x 是表示类型转换的,这个设计源于 C。但后来为了支持 JSX,这个语法就和 JSX 彻底冲突了。

因此 TS 选择的方案是:引入 as 语法,<T>x 和 x as T 完全相同。同时引入 tsx 扩展名,在 tsx 中,<T> 当 JSX,在普通 ts,<T> 依然是类型转换(为兼容)。

 

插入分号

JS 一向允许省略分号,在需要解析分号的地方判断后面的标记是否是“}”,或包含空行。 

function canParseSemicolon() {
    // If there's a real semicolon, then we can always parse it out.
    if (token() === SyntaxKind.SemicolonToken) {
        return true;
    }

    // We can parse out an optional semicolon in ASI cases in the following cases.
    return token() === SyntaxKind.CloseBraceToken || token() === SyntaxKind.EndOfFileToken || scanner.hasPrecedingLineBreak();
}

JSDoc

TS 为了尽可能兼容 JS,允许用户直接使用 JS + JSDoc 的方式备注类型,所以 JS 里的 JSDoc 注释也按源码的一部分解析。

/** @type {string} */
var x = 120
export function parseIsolatedJSDocComment(content: string, start: number | undefined, length: number | undefined): { jsDoc: JSDoc, diagnostics: Diagnostic[] } | undefined {
    initializeState(content, ScriptTarget.Latest, /*_syntaxCursor:*/ undefined, ScriptKind.JS);
    sourceFile = <SourceFile>{ languageVariant: LanguageVariant.Standard, text: content };
    const jsDoc = doInsideOfContext(NodeFlags.None, () => parseJSDocCommentWorker(start, length));
    const diagnostics = parseDiagnostics;
    clearState();

    return jsDoc ? { jsDoc, diagnostics } : undefined;
}

export function parseJSDocComment(parent: HasJSDoc, start: number, length: number): JSDoc | undefined {
    const saveToken = currentToken;
    const saveParseDiagnosticsLength = parseDiagnostics.length;
    const saveParseErrorBeforeNextFinishedNode = parseErrorBeforeNextFinishedNode;

    const comment = doInsideOfContext(NodeFlags.None, () => parseJSDocCommentWorker(start, length));
    if (comment) {
        comment.parent = parent;
    }

    if (contextFlags & NodeFlags.JavaScriptFile) {
        if (!sourceFile.jsDocDiagnostics) {
            sourceFile.jsDocDiagnostics = [];
        }
        sourceFile.jsDocDiagnostics.push(...parseDiagnostics);
    }
    currentToken = saveToken;
    parseDiagnostics.length = saveParseDiagnosticsLength;
    parseErrorBeforeNextFinishedNode = saveParseErrorBeforeNextFinishedNode;

    return comment;
}

 

小结

本节介绍了语法解析,并提到了 TS 如何在碰到错误后继续解析。

下节将重点介绍增量解析。 #不定时更新#

 

时间有限,文章未校验,如果发现错误请指出。