1. 程式人生 > >TypeScript 原始碼詳細解讀(1)總覽

TypeScript 原始碼詳細解讀(1)總覽

TypeScript 由微軟在 2012 年 10 月首發,經過幾年的發展,已經成為國內外很多前端團隊的首選程式語言。前端三大框架中的 Angular 和 Vue 3 也都改用了 TypeScript 開發。即使很多人沒直接用過 TypeScript,他們也在通過 VSCode 提供的智慧提示功能間接享受著 TypeScript 帶來的各項便利。

 

很多人對 TypeScript 背後的原理很感興趣,你可能想要:

  • 更好地理解 TypeScript;
  • 學習編譯原理相關的知識來豐富自己(編譯器和作業系統是很多程式設計師的夢想);
  • 設計一門類似的語言;
  • 定製自己的打包工具;
  • 做一個 VSCode 外掛。

但你上網搜尋,你會發現能搜到的全是 TypeScript 如何使用的教程,即使是英文的資料,也鮮有能完全講解清楚 TypeScript 內部原理的文章。

只有少數的幾篇教程會在文末稍微附帶一下原理說明,但它們只是大概闡述了一下 TypeScript 的整體架構,最核心的型別分析的具體實現幾乎都是一句帶過。

能真正瞭解 TypeScript 內部原理的人極少,多數人對 TypeScript 的想法是:“這方面的東西和我工作沒啥關係,我只管能用就行!”(其實內心的想法是:這東西好煩啊!為什麼大家都在用?求你別加功能了,學不動了……)

 

既然你讀到這裡了,說明你真的想學習 TypeScript 的內部原理。這篇系列文章不教你如何使用 TypeScript(那種你網上隨便一搜,很多),我假設你已經熟悉了 TypeScript 的各種語法,文中不會介紹這些語法的功能,也不探討這些語法的好壞,只重點介紹 TypeScript 是如何實現這個語法的功能的。這篇系列文章可以幫助你在沒學過編譯原理之類的書的前提下,真正地學會編譯相關的知識。但你必須先做好兩點準備:1. 檢查這個頁面的網址,這篇系列文章由徐林迪(xuld)原創,前三篇在部落格園首發,禁隨意轉載、偷爬,如果需要引用文章內容,請直接連結到這篇文章的地址,不要複製內容。2. TypeScript 核心編譯器截止目前一共有 8 萬行以上程式碼,GIT 原始碼倉庫 超過 1 G,學習 TypeScript 原理是一個漫長的過程,不是幾天就可以搞定的,你需要靜下心來,而且原理中包含了大量抽象的邏輯,如果你不打算用太久時間,請儘早放棄。

 

如果有天你問一個“高手”,TypeScript 是怎麼寫的,“高手”就回復你一句“你去看編譯原理的書,比如某某動物書”,那我可以很肯定地告訴你,你的那位“高手”也不懂 TypeScript 的原理。要實現一個 TypeScript ,確實需要一些編譯原理的知識,但並不多,TypeScript 的核心是型別分析、流程分析、ES5 語法轉換,而這些東西,在傳統的 C 編譯器之類的領域都是不存在的。所以你去讀那些教你怎麼寫 parser,怎麼編譯成機器碼的傳統編譯原理的書,是遠遠不夠的。

 

理解 TypeScript 專案

TypeScript 的核心設計者之一也是 C#(念 C 井的請注意了——你的英文能力可能會增加你學習 TypeScript 原理的困難度),Pascal 的核心設計者,TypeScript 有很多地方都借鑑了 C# 的實現,也逐漸給 JavaScript 增加了以前只有 C# 才有的功能。

TypeScript 在微軟擁抱開源的大政策下,和社群有良好的互動,我曾經給 TypeScript 提過三個 BUG,TypeScript 團隊都能在當天回覆,並且在下一個版本中立即修復了。

TypeScript 的目標是:

  1. 相容所有 JavaScript 語法,並在此基礎擴充套件語法;
  2. 靜態分析程式碼,找出那些很有可能有 BUG 的程式碼;
  3. 生成純淨的、可讀的 JavaScript 程式碼,並且不會對程式碼作任何優化、處理,甚至連原始碼中的錯誤都保留到生成的程式碼中;
  4. 不影響最後執行程式碼的環境。

靜態分析程式碼是 TypeScript 的主要職責,通過靜態分析,我們可以得到這些功能:

  • 開發中提前知道程式碼中的可能錯誤
  • IDE 中的語法高亮、智慧提示、轉到定義等功能
  • 重新命名變數、提取函式、自動新增匯入等高階功能

但對於動態指令碼語言來說,靜態分析是無法做到 100% 準確的,TypeScript 的策略是平衡正確性和實用性。TypeScript 不會確保每處靜態分析的結果都是正確的,而是多數情況都是正確的,但他們也提供了兩個應對少數情況的解決方案:1. 提供語法允許使用者手動修復靜態分析的結果(比如用你熟悉的 any)2. 即使原始碼中存在錯誤,也可以正常編譯。從使用者的角度,只要你不是故意找茬,一般也不會碰到問題。關於正確性和實用性的平衡,你在讀後續教程時將會有更深的體會。

 

專案結構

TypeScript 的專案結構如下:

├── bin         最終給使用者用的 tsc 和 tsserver 命令
├── doc         語言規範文件
├── lib         系統標準庫
├── loc         錯誤文案翻譯
├── scripts     開發專案時的一些工具指令碼
├── src         原始碼
│   ├── compiler        編譯器原始碼
│   └── services        語言服務,主要為 VSCode 使用,比如查詢定義之類的功能
└── tests       測試檔案

TypeScript 編譯器的核心程式碼在 src/compiler,其中以下檔案是研究的重點(已經按閱讀順序排序):

  ├── core.ts
  ├── sys.ts
  ├── types.ts
  ├── scanner.ts
  ├── parser.ts
  ├── utilities.ts
  ├── utilitiesPublic.ts
  ├── binder.ts
  ├── checker.ts
  ├── transformer.ts
  ├── transformers/
  ├── emitter.ts
  └── program.ts

核心部分的架構如圖:

 

編譯流程

TypeScript 編譯器的目標是把 TypeScript 編譯成 JavaScript,這其實和把“英文”翻譯成“中文”沒有任何區別。

當我們在翻譯一段英文到中文時,要做這些事情:

  1. 理解原文的意義。
  2. 將原文的意思用中文重新表述出來。

說的再具體一些,是這樣的流程:

  1. 識別原文中的單詞、短語;
  2. 將這些單詞和短語組成一個句子。
  3. 參考這個句子所闡述的意義,將原文中的單詞和短語全部換成中文的詞語;
  4. 用中文的語法將這些詞語重新組裝成一個句子。

所以編譯器也在做同樣的事情,只不過每個步驟我們都給他一個專業的稱呼。

 

1. 組詞——詞法分析

比如句子“我在2009首次創辦個人網站”中的個,我們在理解時,會自然地將“個人”連在一起,而不是將“辦個”連在一起。這就是一個組詞的過程。這個句子組詞後的結果如圖:

 

 TypeScript 是英文程式語言,編譯器的組詞流程,即將字母拼成單詞:

 

這個過程中,還會跳過程式碼中的註釋、空格,拆出來的詞有關鍵字、有變數名、有數字、也有符號,這些我們統稱為標記(Token)。

解析標記列表的過程稱為詞法分析,也叫詞法掃描,TypeScript 原始碼中 scanner.ts 負責完成詞法掃描。

 

2. 組句——語法解析

比如句子“我在2009首次創辦個人網站”,在詞數有限時怎麼說和原文意義最接近?結果是——“我創辦網站”。你會發現,“在2009”、“首次”是用於修飾“創辦”的,“個人”是用於修飾“網站”的,一個句子裡面,總是先由一些詞構成句子的基本結構,然後再新增其它詞使得句子變得更豐富起來,每個詞有不同的重要等級。“我”,“創辦”,“網站”是一級詞,“在2009”、“首次”和“個人”是修飾用的二級詞。

如上圖,一個句子是由有上下級關係的片語成的一個樹結構。什麼是樹結構?樹結構就是指先有一個根節點,然後這個節點下面有很多子節點,每個子節點下面又有很多其它子節點(就像中國有很多省,每個省下面有很多市,市下面有你的村)。

在編譯器中,將最後語法解析得到的結果稱為語法樹,TypeScript 原始碼中 parser.ts 負責解析語法生成語法樹。

語法樹的根節點代表一份原始碼檔案,根節點下面有很多語句,語句即程式碼中需要用分號隔開的部分(一般一行一個語句),每個語句下面可能還嵌套了別的語句(比如一個函式,內部包含別的語句)。

 

如果你之前完全沒學過編譯原理,你可能對語法樹還不能很好的理解,沒關係,等到第4節的時候,我會更詳細地介紹。

如果你之前有學過編譯原理相關的東西,你會覺得上面這些講的太簡單了,那準備好了,接下來的內容,是你在其它地方很難學到的東西。

 

3. 提取符號表——作用域分析

比如句子“我在2009首次創辦個人網站,這個網站到現在都還在維護”,其中的“這個”是一個代詞,指代前面出現過的詞。平時我們也經常使用代詞,甚至有的時候連代詞都省略了(比如你的女朋友一開始會跟你說“你滾!”,後來,變成了“滾!”)。在談話中我們需要記住一些概念的定義,才能理解後續的代詞的含義(比如你先得記住前半句提到的個人網站,然後才能理解後面的“這個網站”的含義)。

在程式碼中,變數是非常常見的,一個變數名稱可能指代了一個值、一個函式,或者一個類,這些統稱為符號(Symbol)。

當用戶定義一個變數、函式或類時,就同時定義了一個符號。編譯器會先將所有的符號收集起來,建立符號表。當在其他地方使用一個名稱時,就查表找出這個名稱所代表的符號。

在同一個函式中,不能定義兩個同名的變數,但可以定義一個變數和上層的變數重名。函式有一個符號表,其外層也有一個符號表,兩個符號表是上下級關係。在函式內部使用一個名稱,會先在函式對應的符號表搜尋,如果找不到再在外層的符號表繼續搜尋,如果都找不到就報告:“變數未定義”。

擁有符號表的區域成為詞法作用域(Lexical Scope),比如一個原始檔、一個函式、一個語句塊({})都是一個作用域。在同一個作用域中,不允許有同名的符號,但兩個作用域可以存在同名的符號。

TypeScript 原始碼中 binder.ts 負責解析作用域,建立符號表。

 

4. 提取流程圖——流程分析

在程式碼 fn() + gn() 對應的語法樹中,fn() 和 gn() 是相鄰的兩個節點,在執行的時候,它們是有先後順序的,按每行程式碼的執行順序,可以繪製出一份執行流程圖。為什麼稱為流程圖而不是流程樹?因為習慣上就這麼叫——如果回答真這麼簡單,那你高考咋不考個滿分?圖——是數學中的專業術語,圖和樹類似,都是由多個節點及它們之間的關係組成的結構,樹結構是一級一級層層向下的,兩個節點之間的上下級關係是明確的。如果存在兩個節點存在互為上下級關係(即迴圈引用),那就變成了圖。

 

程式碼中存在迴圈,在執行的時候,可能存在回到之前執行過的節點的情況,所以稱為流程圖。

分析流程圖有什麼用?

  1. 檢測程式碼中的永遠不會執行的程式碼(比如在 return 之後或者 while(true) 死迴圈之後的程式碼)(這些程式碼在 VSCode 會通過減淡的方式顯示)
  2. 通過流程分析推導變數的型別,比如出現 if (typeof x === "number") ,就可以知道 if 內部,x 的型別是數字。

 

其中實現的功能我想大家都應該都能理解,這裡你可能會很好奇流程分析是怎麼做到的,這會在後續章節解釋。

TypeScript 原始碼中 binder.ts 也負責分析流程,建立流程圖。

 

5. 檢查型別錯誤——語義分析

比如句子“我是你爹”,從語法上是沒有錯的,它確實表達出了一個意思,但這個意思你可能認為是不對的,這就叫語義錯誤。

比如 var x = null; x.toString(); 從語法上是正確的程式碼,可以執行,但執行的時候會發現 x 是空,然後就報錯了。

語義分析的目的就是在不執行程式碼的前提下找出可能在執行過程中出錯的程式碼。

你可能會想,為什麼不直接執行程式碼,執行一下程式碼錯誤不就出來了嗎?假如你正在哈皮地碼程式碼的時候,還沒等你測試,它就已經開始工作——並且刪除了你珍藏多年的電影——你會不會奔潰?因為 VSCode 需要有實時分析錯誤的功能,它為了分析錯誤,把你寫到一半的程式碼直接拿來執行了……

語義分析的錯誤種類很多,比如:

  • 給 const 變數賦值
  • 呼叫函式時少了一個引數
  • 一個類繼承了自己
  • ……

TypeScript 原始碼中 checker.ts 負責語義分析。checker.ts 的行數超過 3 萬,也將是本篇系列文章中重點研究的物件。

 

6. 語法轉換——程式碼優化

TypeScript 提供了編譯成 JavaScript 的功能,而且可以編譯成 ES3、ES5、ES2020 等不同版本的程式碼。

要實現這個功能,就必須先將 TypeScript 程式碼翻譯為 ESNext 的語法(即刪除所有型別資訊),如果使用者需要的是舊版本的語法,再將 ESNext 中舊不版本不支援的語法替換掉。

每次轉換都是通過一個轉換器(Transformer)實現的,轉換器的輸入是語法樹,輸出是新的語法樹。每個轉換器就像工廠裡的一道加工流程,原料在流水線被不斷加工並最後包裝成產品。輸入的原始的 TypeScript 語法樹也會被不同的轉換器處理,最後得到標準的 JavaScript 語法樹。

TypeScript 內建了 TS→ESNext、ES7→ES6、ES6→ES5、ES5→ES3 等轉換器,使用者也可以開發自己的轉換器,生成更實用的程式碼。

TypeScript 原始碼中 transformer.ts 負責語法轉換,不同的轉換器原始碼則在 transformer 資料夾。

 

7. 寫入檔案——程式碼生成

經過以上步驟後,現在已經得到了一個最終的語法樹,接下來要做的事情很簡單——將語法樹重新拼裝回程式碼,並儲存到檔案。

TypeScript 為了保證對程式碼的影響最少,生成最終程式碼時還會保留原始碼的註釋,同時對齊了縮排。

TypeScript 原始碼中 emitter.ts 負責生成程式碼。

此外,TypeScript 還會同時生成源對映(Source Map),以及型別描述檔案(.d.ts),這些都將在相關章節詳細介紹。

 

8. 小結

完整的編譯流程如圖:

 

接下來是什麼

上面初步介紹了整個編譯流程,以及相關的專業術語。接下來我將按每個階段分別解讀 TypeScript 原始碼中是如何實現這些流程的。

下一節將具體介紹詞法掃描的第一步:字元處理。(更新於 2020-1-13)

#如果你有問題可以在評論區提問#