TypeScript 源碼詳細解讀(3)詞法2-標記解析

在上一節主要介紹了單個字符的處理,如今咱們已經有了對單個字符分析的能力,好比:html

  • 判斷字符是不是換行符:isLineBreak
  • 判斷字符是不是空格:isWhiteSpaceSingleLine
  • 判斷字符是不是數字:isDigit
  • 判斷字符是不是標識符(變量名):
    • 標識符開頭部分:isIdentifierStart
    • 標識符主體部分:isIdentifierPart
  • 同時還能夠經過 char === CharacterCodes.hash 方式判斷其它字符

接下來,須要利用字符組裝標記。git

標記(Token)

標記能夠是一個變量名、一個符號或一個關鍵字。正則表達式

 

好比代碼 var x = String.fromCharCode(100); 中,一共可解析出如下標記:typescript

  1. var 關鍵字標記
  2. 標識符標記(內容是 x)
  3. 等號標記(=)
  4. 標識符標記(內容是 String)
  5. 點標記(.)
  6. 標識符標記(內容是 fromCharCode)
  7. 左括號標記(()
  8. 數字標記(內容是 100)
  9. 右括號標記())
  10. 分號標記(;)

爲何有些字符會組成一個標記,而有些字符又不行呢?編程

能夠這麼理解:標記裏的字符必定是不能拆開的,就像「東西」這個詞是一個最小的總體,若是拆成兩個字,就不能表達原來的意思了。數組

好比代碼 0.1.toString 中,包含如下標記:閉包

  1. 數字標記(0.1)
  2. 點標記(.)
  3. 標識符標記(內容是 toString)

 

前面的點緊跟數字,是小數的一部分,因此和數字一塊兒做爲一個標記。當點不緊跟數字時,也能夠做獨立標記使用。編程語言

代碼中的字符串,無論內容有多長,都將被解析爲一個字符串標記。函數

++ 是一個獨立的加加標記,而 + + (中間差一個空格)是兩個加標記。工具

爲何標記須要按這個規則解析?由於 ES 規範就這麼規定的。在英文編程語言中,通常都是用空格來分割標記的,兩個標記若是缺乏空格,它們可能被組成新的標記。固然並非隨便兩個字符就能夠組成新標記,好比 !! 和 ! ! 都被解析成兩個感嘆號標記,由於根本不存在雙感嘆號標記。

 

關鍵字和普通的標識符都是一個單詞,爲何關鍵字有特殊的標記類型,而其它單詞統稱爲標識符呢?

主要爲了方便後續解析,以後判斷單詞是不是關鍵字時,只需判斷標記類型,而不是很麻煩地先判斷是不是標識符再判斷標識符的內容。

 

每一個標記在源碼中都有固定的位置,若是將源碼當作字符串,那麼這個標記第一個字符在字符串中的索引就是標記的開始位置,最後一個字符對應的就是結束位置。

在解析每一個標記時,會跳過標記之間的空格、註釋。若是把每一個標記以前、上一個標記以後的空格、註釋包括進來,這個標記的位置即標記的完整開始位置。一個標記的完整開始位置等同於上一個標記的結束位置。

 

綜上,任何源碼均可以被解析成一串標記組成的數組,每一個標記都有這些屬性:

  • 標記的類型(區分這是關鍵字、仍是標識符、仍是其它的符號)
  • 標記的內容(針對標識符、字符串、數字等標記類型,獲取其真實的內容
  • 標記的開始位置
  • 標記的結束位置
  • 標記的完整開始位置

 

在 TS 源碼中,用 SyntaxKind 枚舉列出了全部標記類型:

export const enum SyntaxKind {
    CloseBraceToken,
    OpenParenToken,
    CloseParenToken,
    OpenBracketToken,
    // ...(略)
}

同時,這些標記類型的值也有一個約定,即關鍵字標記都被放在一塊兒,這樣就能夠很輕鬆地經過標記類型判斷是不是關鍵字:

export function isKeyword(token: SyntaxKind): boolean {
    return SyntaxKind.FirstKeyword <= token && token <= SyntaxKind.LastKeyword;
}

同理還有不少的相似判斷,它們被放在了 tsc/src/compiler/utilities.ts 中。

 

TS 內部統一使用 SyntaxKind 存儲標記類型(SyntaxKind 本質是數字,這樣比較起來性能最高),爲了方便報錯時顯示,TS 還內置了從文本內容獲取標記類型和還原標記類型爲文本內容的工具函數:

const textToToken = createMapFromTemplate<SyntaxKind>({
    ...textToKeywordObj,
    "{": SyntaxKind.OpenBraceToken,
    // ...(略)
})

const tokenStrings = makeReverseMap(textToToken);
export function tokenToString(t: SyntaxKind): string | undefined {
    return tokenStrings[t];
}

/* @internal */
export function stringToToken(s: string): SyntaxKind | undefined {
    return textToToken.get(s);
}

 

掃描器(Scanner)

一份代碼中,通常會解析出上千個標記。若是將每一個標記都存下來就會消耗大量的內存,而就像你讀文章時,你只要盯着當前正在讀的這幾行字,而不須要將全文的字都記下來同樣,解析代碼時,也只須要知道當前正在讀的標記,以前已經理解過的標記不須要再記下來。因此實踐上出於性能考慮,採用掃描的方式逐個讀取標記,而不是一口氣將全部標記先讀出來放在數組裏。

什麼是掃描的方式?即有一個全局變量,每調用一次掃描函數(scan()),這個變量的值就會被更新爲下一個標記的信息。你能夠從這個變量獲取當前標記的信息,而後調用一次 scan() ,再從新從這個變量獲取下一個標記的信息(固然這時候不能再讀取以前的標記信息了)。

Scanner 類提供了以上所說的全部功能:

export interface Scanner {
    setText(text: string, start?: number, length?: number): void; // 設置當前掃描的源碼
    scan(): SyntaxKind; // 掃描下一個標記
    getToken(): SyntaxKind; // 獲取當前標記的類型
    getStartPos(): number; // 獲取當前標記的完整開始位置
    getTokenPos(): number; // 獲取當前標記的開始位置
    getTextPos(): number; // 獲取當前標記的結束位置
    getTokenText(): string; // 獲取當前標記的源碼
    getTokenValue(): string; // 獲取當前標記的內容。若是標記是數字,獲取計算後的值;若是標記是字符串,獲取處理轉義字符後的內容
}

若是你已經理解了 Scanner 的設計原理,那就能夠回答這個問題:如何使用 Scanner 打印一個代碼裏的全部標記?

你能夠先思考幾分鐘,而後看答案:

如下是能夠直接在 Node 運行的代碼,你能夠直接斷點調試看 TS 是如何完成標記解析的任務的。

const ts = require("typescript")

const scanner = ts.createScanner(ts.ScriptTarget.ESNext, true)
scanner.setText(`var x = String.fromCharCode(100);`)
while (scanner.scan() !== ts.SyntaxKind.EndOfFileToken) { // EndOfFileToken 表示結束
    const tokenType = scanner.getToken() // 標記類型編碼
    const start = scanner.getTokenPos() // 開始位置
    const end = scanner.getTextPos() // 結束位置

    const tokenName = ts.tokenToString(tokenType) // 轉爲可讀的標記名

    console.log(`在 ${start}-${end} 發現了標記:${tokenName}`)
}

 

掃描器實現

TS 早期是使用面向對象的類開發的,從 1.0 開始,爲了適配 JS 引擎的性能,全部源碼已經沒有類了,所有改用函數閉包。

export function createScanner(languageVersion: ScriptTarget, skipTrivia: boolean, /**...(略) */): Scanner {
    let text = textInitial!; // 當前要掃描的源碼
    let pos: number; // 當前位置

    // 如下是一些「全局」變量,存儲當前標記的信息
    let end: number;
    let startPos: number;
    let tokenPos: number;
    let token: SyntaxKind;
    let tokenValue!: string;
    let tokenFlags: TokenFlags;

    // ...(略)

    const scanner: Scanner = {
        getStartPos: () => startPos,
        getTextPos: () => pos,
        getToken: () => token,
        getTokenPos: () => tokenPos,
        getTokenText: () => text.substring(tokenPos, pos),
        getTokenValue: () => tokenValue,
        // ...(略)
    };

    return scanner;

    // 這裏是具體實現的函數,函數能夠直接訪問上面這些「全局」變量
}

核心的掃描函數以下:

function scan(): SyntaxKind {
    startPos = pos; // 記錄掃描以前的位置
    while (true) {
        // 這是一個大循環
        // 若是發現空格、註釋,會從新循環(此時從新設置 tokenPos,即讓 tokenPos 忽略了空格)
        // 若是發現一個標記,則退出函數
        tokenPos = pos;
        // 到字符串末尾,返回結束標記
        if (pos >= end) {
            return token = SyntaxKind.EndOfFileToken;
        }
        // 獲取當前字符的編碼
        let ch = codePointAt(text, pos);

        switch (ch) {
            // 接下來就開始判斷不一樣的字符可能並組裝標記
            case CharacterCodes.exclamation: // 感嘆號(!)
                if (text.charCodeAt(pos + 1) === CharacterCodes.equals) { // 後面是否是「=」
                    if (text.charCodeAt(pos + 2) === CharacterCodes.equals) { // 後面是否是仍是「=」
                        return pos += 3, token = SyntaxKind.ExclamationEqualsEqualsToken; // 得到「!==」標記
                    }
                    return pos += 2, token = SyntaxKind.ExclamationEqualsToken; // 得到「!=」標記
                }
                pos++;
                return token = SyntaxKind.ExclamationToken; //得到「!」標記
            case CharacterCodes.doubleQuote:
            case CharacterCodes.singleQuote:
                // ...(略)
        }
    }
}

掃描的步驟很簡單:先判斷是什麼字符,而後嘗試組成標記。

標記的種類繁多,因此這部分源碼也很長,但都是大同小異的判斷,這裏再也不贅述(相信即便寫了你也會快速跳過),有興趣的自行讀源碼。

這裏列出一些須要注意的點:

1. 並非全部字符都是源碼的一部分,因此,可能在掃描時對有些字符報錯。

2. 最開頭的 #! (Shebang)會被忽略(這部分雖然暫時沒入ES 標準(發文時屬於 Stage 2),但多數引擎都會忽略它)

3. 爲了支持自動插入分號,掃描時還同時記錄了當前標記以前有沒有換行的信息。 

4. TS 很貼心地考慮 GIT 合併衝突問題。

若是一個文件出現 GIT 合併衝突,GIT 會自動在該文件插入一些衝突標記,如:

<<<<<<< HEAD
這是個人代碼
=======
這是別人提交的代碼
>>>>>>>

TS 在掃描到 <<<<<<< 後(正常的代碼不太可能出現),會將這段代碼識別爲衝突標記,並在詞法掃描時自動忽略衝突的第二段,至關於屏蔽了衝突代碼,而不是將衝突標記當作代碼的一部分而後報不少錯。這樣,即便代碼存在衝突,當你在修改第一段代碼時,不會受任何影響(包括智能提示等),但由於第二段被直接忽略,因此修改第二段代碼不會有智能提示,只有語法高亮。

 

從新掃描問題

正則表達式和字符串同樣,是不可拆分的一種標記,當碰到 / 後,它多是除號,也多是正則表達式的開頭。在掃描階段還沒法肯定它的真正意義。

有的人可能會說除號也能夠經過掃描後面有沒有新的除號(由於正則表達式確定是一對除號)判斷它是否是正則,這是不對的:

var a = 1 / 2 / 3 // 雖然出現了兩個除號,但不是正則

實際上須要區分除號是否是正則,是看除號以前有沒有存在表達式,這是在語法解析階段才能知道的事情。所以在詞法掃描階段,直接不考慮正則,除號多是除號(/)、除號等於(/=)、註釋(//)。

當在語法掃描時,發現此處須要的是一個獨立的表達式,而不多是除號時,調用 scanner.reScanSlashToken(),將當前除號標記從新按正則掃描。

相似地、< 多是小於號,也多是 JSX 的開頭。模板 `x${...}` 中的 } 多是右半括號,也多是模板字面量的最後一部分,這些都須要在語法分析階段區分,須要提供從新掃描的方法。

 

預覽標記

TS 引入了不少關鍵字,但爲了兼容 JS,這些關鍵字只有在特定場合才能做關鍵字,好比 public 後跟 class,才把 public 做關鍵字(這樣不影響原本是正確的 JS 代碼:var public = 0)。

這時,在語法分析時,就要先預覽下一個標記是什麼,才能決定如何處理當前的標記。

scanner 提供了 lookAhead 和 tryScan 兩個預覽用的函數。

函數的主要原理是:先記住當前標記和掃描的位置,而後執行新的掃描,讀取到後續標記內容後,再還原成以前保存的狀態。

function lookAhead<T>(callback: () => T): T {
    return speculationHelper(callback, /*isLookahead*/ true);
}

function tryScan<T>(callback: () => T): T {
    return speculationHelper(callback, /*isLookahead*/ false);
}

function speculationHelper<T>(callback: () => T, isLookahead: boolean): T {
    const savePos = pos;
    const saveStartPos = startPos;
    const saveTokenPos = tokenPos;
    const saveToken = token;
    const saveTokenValue = tokenValue;
    const saveTokenFlags = tokenFlags;
    const result = callback();

    // If our callback returned something 'falsy' or we're just looking ahead,
    // then unconditionally restore us to where we were.
    if (!result || isLookahead) {
        pos = savePos;
        startPos = saveStartPos;
        tokenPos = saveTokenPos;
        token = saveToken;
        tokenValue = saveTokenValue;
        tokenFlags = saveTokenFlags;
    }
    return result;
}

lookAhead 和 tryScan 的惟一區別是:lookAhead 會始終還原到原始狀態,而 tryScan 則容許不還原。

小結

本節主要介紹了掃描器的具體實現。掃描器提供瞭如下接口:

  • scan()  掃描下一個標記
  • getXXX() 獲取當前標記信息
  • reScanXXX() 從新掃描標記
  • lookAhead() 預覽標記

若是你以爲理解起來比較吃力,那告訴你個不幸的消息——詞法掃描是全部流程中最簡單的。

有些人可能想要開發本身的編譯器,這裏給個提示,若是你設計的語言採用縮進式語法,你在實現詞法掃描步驟中,須要記錄每一個標記以前的縮進數(TAB 按一個縮進處理)。若是這個標記不在行首,縮進數記位 -1。在語法解析階段,若是發現下一個標記的縮進比當前存儲的縮進大,說明增長了縮進,更新當前存儲的縮進。

TS 源碼中的詞法掃描是比較複雜但完整的一種實現,若是僅僅爲了語法高亮,這點複雜的不必的,對語法高亮來講,使用正則匹配已經足夠了,這是另外一種詞法掃描方案。

TS 這部分源碼有 2000 行多,相信領悟文中介紹的方法、概念以後,你能夠本身讀完這些源碼。

下一節將具體介紹語法解析的第一步:語法樹。(不定時更新)

#若是你有問題能夠在評論區提問#

相關文章
相關標籤/搜索