本節文章研究的代碼位於 tsc/src/compiler/scanner.ts
字符
任何源碼都是由很多字符組成的,這些字符可以是字母、數字、空格、符號、漢字等……
每一個字符都有一個編碼值,比如字符“a”的編碼值是97,字符“林”的編碼值是26519。
每個字符對應的編碼值是多少是由編碼表決定的,上面所示的編碼值是全球統一的編碼表 Unicode 中的編碼值,如果沒有特別聲明,所有編碼值都是以 Unicode 為准的。
一般地,字符的編碼值都是有序的,比如字符“a”的編碼值是97,字符“b”的編碼值是98,字符“c”的編碼值是99,漢字則是按照筆划順序排序的。在給字符串排序時,也是根據每個字符的編碼值大小進行排序的。
如果想要判斷一個字符是不是英文字母,只需要判斷這個字符的編碼值是否位於字符“a”的編碼值和字符“z”的編碼值之間即可。
在 JavaScript 中,可以通過 "a".charCodeAt(0) 獲取字符“a”的編碼值;通過 String.fromCharCode(97) 獲取指定編碼值對應的字符。
CharacterCodes 枚舉
在代碼中如果直接寫 99,你可能不清楚這個數字的含義,但如果寫成 CharacterCodes.c,你就可以很快明白。通過枚舉給每個編碼值定義一個名稱,方便讀者理解,同時我們也不需要去記憶每個字符的實際編碼值。CharacterCodes 枚舉位於 tsc/src/compiler/types.ts,源碼如下:
/* @internal */ export const enum CharacterCodes { _0 = 0x30, _1 = 0x31, // ...(略) _9 = 0x39, a = 0x61, b = 0x62,// ...(略) z = 0x7A, A = 0x41,// ...(略) Z = 0x5a, ampersand = 0x26, // & asterisk = 0x2A, // * // ...(略) }
字符判斷
要判斷一個字符是不是數字字符,只需確認它的字符編碼是不是在“0”和“9”的編碼值之間:
function isDigit(ch: number): boolean { // 參數 ch 表示一個編碼值 return ch >= CharacterCodes._0 && ch <= CharacterCodes._9; }
同理,還可以判斷其它字符,比如判斷是不是換行符:
export function isLineBreak(ch: number): boolean { // ES5 7.3: // The ECMAScript line terminator characters are listed in Table 3. // Table 3: Line Terminator Characters // Code Unit Value Name Formal Name // \u000A Line Feed <LF> // \u000D Carriage Return <CR> // \u2028 Line separator <LS> // \u2029 Paragraph separator <PS> // Only the characters in Table 3 are treated as line terminators. Other new line or line // breaking characters are treated as white space but not as line terminators. return ch === CharacterCodes.lineFeed || ch === CharacterCodes.carriageReturn || ch === CharacterCodes.lineSeparator || ch === CharacterCodes.paragraphSeparator; }
根據 ES 規范,換行符一共有 4 個,雖然平常我們只實用前兩個,但對有些語言來說,后兩個也是需要的。
判斷是不是空格:
export function isWhiteSpaceLike(ch: number): boolean { return isWhiteSpaceSingleLine(ch) || isLineBreak(ch); } /** Does not include line breaks. For that, see isWhiteSpaceLike. */ export function isWhiteSpaceSingleLine(ch: number): boolean { // Note: nextLine is in the Zs space, and should be considered to be a whitespace. // It is explicitly not a line-break as it isn't in the exact set specified by EcmaScript. return ch === CharacterCodes.space || ch === CharacterCodes.tab || ch === CharacterCodes.verticalTab || ch === CharacterCodes.formFeed || ch === CharacterCodes.nonBreakingSpace || ch === CharacterCodes.nextLine || ch === CharacterCodes.ogham || ch >= CharacterCodes.enQuad && ch <= CharacterCodes.zeroWidthSpace || ch === CharacterCodes.narrowNoBreakSpace || ch === CharacterCodes.mathematicalSpace || ch === CharacterCodes.ideographicSpace || ch === CharacterCodes.byteOrderMark; }
有的地方需要把換行當空格處理,有的地方不需要,所以 TypeScript 拆成兩個函數,一個包括換行符,一個不包括。
判斷標識符(Identifier)
標識符即俗稱的變量名,我們都知道 JS 中變量名是不能隨便取的,是有規則的,比如開頭不能是數字。
在 ES 規范中,明確地點名了:哪些字符可以做標識符;哪些字符可以做標識符但不能以它開頭。TypeScript 實現了 isUnicodeIdentifierStart 和 isUnicodeIdentifierPart 來分別判斷。
哪些字符可以做標識符,其實是沒有簡單的規律的,這些都是在 ES 規范一個個手動指定的,規范中這個列表很長,最簡單的實現就是:手動記錄每個字符是否允許作標識符,然后查表。
不過字符很多,每個字符單獨記錄要占用很大空間,所以 TypeScript 設計了一個小算法來壓縮內存,算法基於這么一個事實:一般地,允許作為標識符的字符都是連續的一段(比如“a”到“z”)。
只要記錄每段的開頭和結尾部分,就可以比原先的記錄該段的所有字符,要更節約內存。
將所有開始位置和結束位置放在同一個數組,數組的奇數位即圖中的藍色段,表示每段開頭,偶數位即綠色段,表示每段結尾。
當需要查找一個字符是不是標識符時,采用二分搜索算法,快速定位確認它是否在包含的段中。
const unicodeESNextIdentifierStart = [65, 90, 97, 122, 170, /*...(略) */, 194560, 195101] const unicodeESNextIdentifierPart = [48, 57, 65, /*...(略) */, 917999] function lookupInUnicodeMap(code: number, map: readonly number[]): boolean { // 由於代碼中多數字符還是英文字符,如果是就不查表直接判斷 // Bail out quickly if it couldn't possibly be in the map. if (code < map[0]) { return false; } // 以下是標准二分搜索算法,不懂的同學請自己補課 // Perform binary search in one of the Unicode range maps let lo = 0; let hi: number = map.length; let mid: number; while (lo + 1 < hi) { mid = lo + (hi - lo) / 2; // mid has to be even to catch a range's beginning mid -= mid % 2; if (map[mid] <= code && code <= map[mid + 1]) { return true; } if (code < map[mid]) { hi = mid; } else { lo = mid + 2; } } return false; }
接下來就可以看明白 isUnicodeIdentifierStart 和 isUnicodeIdentifierPart 這兩個函數了:
/* @internal */ export function isUnicodeIdentifierStart(code: number, languageVersion: ScriptTarget | undefined) { return languageVersion! >= ScriptTarget.ES2015 ? lookupInUnicodeMap(code, unicodeESNextIdentifierStart) : languageVersion! === ScriptTarget.ES5 ? lookupInUnicodeMap(code, unicodeES5IdentifierStart) : lookupInUnicodeMap(code, unicodeES3IdentifierStart); } function isUnicodeIdentifierPart(code: number, languageVersion: ScriptTarget | undefined) { return languageVersion! >= ScriptTarget.ES2015 ? lookupInUnicodeMap(code, unicodeESNextIdentifierPart) : languageVersion! === ScriptTarget.ES5 ? lookupInUnicodeMap(code, unicodeES5IdentifierPart) : lookupInUnicodeMap(code, unicodeES3IdentifierPart); }
由於 TypeScript 支持不同版本的 ES 代碼,且不同版本的 ES 規范對標識符的定義有細微查表,所以 TypeScript 內部准備了不同版本的表。
通過以上倆函數的結合,也就可以判斷一個字符串是不是合法的標識符了:
/* @internal */ export function isIdentifierText(name: string, languageVersion: ScriptTarget | undefined): boolean { let ch = codePointAt(name, 0); if (!isIdentifierStart(ch, languageVersion)) { return false; } for (let i = charSize(ch); i < name.length; i += charSize(ch)) { if (!isIdentifierPart(ch = codePointAt(name, i), languageVersion)) { return false; } } return true; }
行列號和索引
如果將源碼看成字符串,每個字符都有一個字符串的下標索引,同時這個字符又可以理解為源碼中的第幾行第幾列。
給定一個字符串的索引,可以通過掃描這個索引之前有幾個換行符確定這個索引屬於第幾行第幾列,反過來,通過行列號也可以確認這個位置對應的字符串索引。
在源碼中如果發現一個錯誤,編譯器需要向用戶報告錯誤,並明確指出位置,一般地,編譯器需要將錯誤的行列報出來(如果報的是索引那你自己慢慢數……),為了能夠在報錯時知道這些位置,編譯器在詞法掃描階段就需要保存一切源碼位置了,那編譯器存的是行列號還是索引呢?
有的編譯器選擇了存行列號,因為行列號才是用戶最后需要的,但行列號意味着需要兩個字段存儲這個信息,如果將它們分別處理,每次處理行列號的地方都需要兩行代碼,如果將它們合並為一個對象,這在 JavaScript 引擎中會造成大量的引用對象,影響性能。因此 TypeScript 選擇:存儲索引。出錯的時候,再將索引換算成行列號顯示出來。
TypeScript 用 Position(位置)這個術語表示索引,用 LineAndCharacter(行和字符)這個術語表示行列號。這三者都是從 0 開始計數的,即 line = 0 表示第一行。
為什么是 LineAndCharacter 而不是 LineAndColumn(行列),主要為了和 VSCode 中的 LineColumn 區分,多數情況,LineAndCharacter 和 LineAndColumn 是一樣的,除非碰到制表符(TAB)縮進,一個 TAB 始終是一個字符,但它可能跨越 2 列、4 列、8列等(具體根據用戶配置)。TypeScript 並不在意 TAB 這個字符,統一將它當一個字符處理可以簡單許多,所以為了避免和 VSCode 的行列混淆,改用了別的稱呼。
基於索引計算行列號需要遍歷這個索引之前的所有字符,為了加速計算,TypeScript 作了一個小優化:緩存每行第一個字符的索引,然后通過二分搜索查找對應的行列(又是二分?)
首先計算每行第一個字符的索引表:
/* @internal */ export function computeLineStarts(text: string): number[] { const result: number[] = new Array(); let pos = 0; let lineStart = 0; while (pos < text.length) { const ch = text.charCodeAt(pos); pos++; switch (ch) { case CharacterCodes.carriageReturn: if (text.charCodeAt(pos) === CharacterCodes.lineFeed) { pos++; } // falls through case CharacterCodes.lineFeed: result.push(lineStart); lineStart = pos; break; default: if (ch > CharacterCodes.maxAsciiCharacter && isLineBreak(ch)) { result.push(lineStart); lineStart = pos; } break; } } result.push(lineStart); return result; }
然后檢索索引表查詢行列號:
/* @internal */ /** * We assume the first line starts at position 0 and 'position' is non-negative. */ export function computeLineAndCharacterOfPosition(lineStarts: readonly number[], position: number): LineAndCharacter { let lineNumber = binarySearch(lineStarts, position, identity, compareValues); if (lineNumber < 0) { // If the actual position was not found, // the binary search returns the 2's-complement of the next line start // e.g. if the line starts at [5, 10, 23, 80] and the position requested was 20 // then the search will return -2. // // We want the index of the previous line start, so we subtract 1. // Review 2's-complement if this is confusing. lineNumber = ~lineNumber - 1; Debug.assert(lineNumber !== -1, "position cannot precede the beginning of the file"); } return { line: lineNumber, character: position - lineStarts[lineNumber] }; }
同時使用索引表也可以實現從行列號查詢索引:
/* @internal */ export function computePositionOfLineAndCharacter(lineStarts: readonly number[], line: number, character: number, debugText?: string, allowEdits?: true): number { if (line < 0 || line >= lineStarts.length) { if (allowEdits) { // Clamp line to nearest allowable value line = line < 0 ? 0 : line >= lineStarts.length ? lineStarts.length - 1 : line; } else { Debug.fail(`Bad line number. Line: ${line}, lineStarts.length: ${lineStarts.length} , line map is correct? ${debugText !== undefined ? arraysEqual(lineStarts, computeLineStarts(debugText)) : "unknown"}`); } } const res = lineStarts[line] + character; if (allowEdits) { // Clamp to nearest allowable values to allow the underlying to be edited without crashing (accuracy is lost, instead) // TODO: Somehow track edits between file as it was during the creation of sourcemap we have and the current file and // apply them to the computed position to improve accuracy return res > lineStarts[line + 1] ? lineStarts[line + 1] : typeof debugText === "string" && res > debugText.length ? debugText.length : res; } if (line < lineStarts.length - 1) { Debug.assert(res < lineStarts[line + 1]); } else if (debugText !== undefined) { Debug.assert(res <= debugText.length); // Allow single character overflow for trailing newline } return res; }
小結
本節介紹了 scanner 中的一些獨立函數,這些函數都將被詞法掃描程序中調用。先獨立理解了這些概念,對完全理解詞法掃描會有重大幫助。
下節將介紹:詞法掃描的實現(即 scanner.ts 中剩余的其它函數)【更新於 2020-1-18】
#如果你有問題可以在評論區提問#