TypeScript 原始碼詳細解讀(3)詞法2-標記解析
在上一節主要介紹了單個字元的處理,現在我們已經有了對單個字元分析的能力,比如:
- 判斷字元是否是換行符:isLineBreak
- 判斷字元是否是空格:isWhiteSpaceSingleLine
- 判斷字元是否是數字:isDigit
- 判斷字元是否是識別符號(變數名):
- 識別符號開頭部分:isIdentifierStart
- 識別符號主體部分:isIdentifierPart
- 同時還可以通過 char === CharacterCodes.hash 方式判斷其它字元
接下來,需要利用字元組裝標記。
標記(Token)
標記可以是一個變數名、一個符號或一個關鍵字。
比如程式碼 var x = String.fromCharCode(100); 中,一共可解析出以下標記:
- var 關鍵字標記
- 識別符號標記(內容是 x)
- 等號標記(=)
- 識別符號標記(內容是 String)
- 點標記(.)
- 識別符號標記(內容是 fromCharCode)
- 左括號標記(()
- 數字標記(內容是 100)
- 右括號標記())
- 分號標記(;)
為什麼有些字元會組成一個標記,而有些字元又不行呢?
可以這麼理解:標記裡的字元一定是不能拆開的,就像“東西”這個詞是一個最小的整體,如果拆成兩個字,就不能表達原來的意思了。
比如程式碼 0.1.toString 中,包含以下標記:
- 數字標記(0.1)
- 點標記(.)
- 識別符號標記(內容是 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 行多,相信領悟文中介紹的方法、概念之後,你可以自己讀完這些原始碼。
下一節將具體介紹語法解析的第一步:語法樹。(不定時更新)
#如果你有問題可以在評論區提問#
&n