在上一節主要介紹了單個字符的處理,現在我們已經有了對單個字符分析的能力,比如:
- 判斷字符是否是換行符: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 行多,相信領悟文中介紹的方法、概念之后,你可以自己讀完這些源碼。
下一節將具體介紹語法解析的第一步:語法樹。(於 2020-1-28 更新)
#如果你有問題可以在評論區提問#