大規(guī)模采用 TypeScript 之后的 10 個(gè)見(jiàn)解
幾年前,彭博社工程部決定采用 TypeScript 作為首選開(kāi)發(fā)語(yǔ)言。在這篇文章中,將分享我們?cè)谶@次遷移過(guò)程中學(xué)到的經(jīng)驗(yàn)教訓(xùn)以及一些見(jiàn)解。
總體而言,我們認(rèn)為 TypeScript 是個(gè)完全正向的升級(jí)。當(dāng)你讀到那些我們發(fā)現(xiàn)的困擾時(shí),請(qǐng)記住這一點(diǎn)。作為工程師,我們天然的會(huì)對(duì)發(fā)現(xiàn)、解決和分享問(wèn)題給吸引,即使在娛樂(lè)的時(shí)候。??

背景
在 TypeScript 問(wèn)世以前,彭博社就已經(jīng)對(duì) JavaScript 有著巨量的開(kāi)發(fā)投入—— 5000 萬(wàn)行以上的 JS 代碼。我們的主要產(chǎn)品是包含了一萬(wàn)多個(gè)應(yīng)用的彭博社終端。這些應(yīng)用的種類差異性巨大,從顯示密集的實(shí)時(shí)財(cái)務(wù)數(shù)據(jù)和新聞,到交互式交易解決方案以及多種格式的消息傳遞等。早在 2005 年,公司就開(kāi)始將這些應(yīng)用的實(shí)現(xiàn)方式從 Fortran 和 C/C++ 遷移至基于服務(wù)器端的 JavaScript,而在 2012 年左右,已經(jīng)遷移為基于客戶端 JavaScript 的版本。

將這樣大規(guī)模的純 JavaScript 代碼轉(zhuǎn)換為 TypeScript 是一個(gè)很大的工程。我們下了很大的功夫,確保在遷移時(shí)有一個(gè)穩(wěn)妥的過(guò)程 —— 既遵循代碼標(biāo)準(zhǔn)又能保證我們既有的功能可以快速安全的轉(zhuǎn)化和部署。
如果你在一個(gè)大公司經(jīng)歷過(guò)技術(shù)遷移,那你一定對(duì)那種用嚴(yán)格的項(xiàng)目管理來(lái)迫使技術(shù)團(tuán)隊(duì)向前推進(jìn)的做法不會(huì)感到陌生,因?yàn)橥@些團(tuán)隊(duì)寧可去開(kāi)發(fā)新的功能也不愿意來(lái)做這件事。但是我們發(fā)現(xiàn),TypeScript 的遷移過(guò)程卻完全不同。工程師們會(huì)自發(fā)的進(jìn)行代碼轉(zhuǎn)換,并且非常的支持這個(gè)過(guò)程!當(dāng)我們發(fā)布 beta 版的 TypeScript 平臺(tái)支持時(shí),僅第一年就有超過(guò) 200 個(gè)項(xiàng)目選擇切換到了 TypeScript,并且沒(méi)有一個(gè)回頭。
是什么讓這次 TypeScript 實(shí)踐如此特別呢?
除了規(guī)模之外,這次集成 TypeScript 的特別之處在于,我們有自己的 JavaScript 運(yùn)行時(shí)環(huán)境。也就是說(shuō),除了像瀏覽器和 Node 這類眾所周知的 JavaScript 運(yùn)行環(huán)境外,我們也直接嵌入了 V8 引擎以及 Chromium 內(nèi)核來(lái)建造自己的 JavaScript 平臺(tái)。這樣帶來(lái)的好處就是,我們的平臺(tái)和軟件包生態(tài)原生支持 TypeScript,這使得我們可以向開(kāi)發(fā)者提供一個(gè)簡(jiǎn)單的開(kāi)發(fā)體驗(yàn)。Ryan Dahl 的 Deno 通過(guò)將 TypeScript 編譯到運(yùn)行時(shí)中來(lái)實(shí)現(xiàn)相似的目標(biāo),而我們將其保留為獨(dú)立于運(yùn)行時(shí)外的可以進(jìn)行版本控制的工具。一個(gè)有趣的結(jié)論是,我們開(kāi)始探索在跨客戶端和服務(wù)端且不滿足 Node 使用規(guī)則(例如,沒(méi)有 node_modules 目錄)的獨(dú)立 JS 環(huán)境中使用 TypeScript 編譯器將會(huì)如何。
我們的平臺(tái)支持使用通用的加工和發(fā)布系統(tǒng)的內(nèi)部的軟件包生態(tài)系統(tǒng)。這使我們可以促進(jìn)和實(shí)施最佳的開(kāi)發(fā)實(shí)踐,比如默認(rèn)使用 TypeScript 的“嚴(yán)格模式”,來(lái)保障全局的不變量。例如,我們保證了所有發(fā)布的類型是模塊化的,而不是全局性的。同時(shí)也代表著,工程師們可以專注于編寫代碼,而不是去花精力解決如何讓 TypeScript 去兼容某個(gè)打包器或者測(cè)試框架。開(kāi)發(fā)者工具和錯(cuò)誤堆棧可以正確的使用 sourcemaps。測(cè)試也可以用 TypeScript 編寫,并依據(jù)原始 TypeScript 代碼準(zhǔn)確的展示代碼覆蓋率。就是這么好用。
我們的目標(biāo)是讓常規(guī)的 TypeScript 文件成為我們 API 實(shí)質(zhì)上的唯一來(lái)源,從而不需要手動(dòng)維護(hù)聲明文件。這意味著我們有大量的代碼非常依賴于 TypeScript 編譯器從源碼中自動(dòng)生成的 .d.ts 聲明文件。如你所見(jiàn),當(dāng)聲明文件沒(méi)有像預(yù)想的一樣產(chǎn)生時(shí),我們會(huì)立刻發(fā)現(xiàn)它。
原則
我來(lái)列出我們追求的三條關(guān)鍵原則:
可擴(kuò)展性:隨著越多的包采用 TypeScript,項(xiàng)目的開(kāi)發(fā)速度應(yīng)該越來(lái)越快。花費(fèi)在安裝、編譯和檢查代碼上的時(shí)間應(yīng)該最小化。
系統(tǒng)一致性:軟件包工作時(shí)需要互相兼容;升級(jí)依賴需要可以無(wú)痛進(jìn)行。
遵循標(biāo)準(zhǔn):我們堅(jiān)持和標(biāo)準(zhǔn)保持一致,例如 ECMAScript,隨時(shí)準(zhǔn)備好接受他們的下一個(gè)版本。
一些讓我們感到驚訝的發(fā)現(xiàn),通常都是來(lái)自于一些我們不確定是否能夠維持這些原則的案例。
10 個(gè)重點(diǎn)
1. TypeScript 可以是 JavaScript + Types
多年以來(lái),TypeScript 團(tuán)隊(duì)一直積極的追求采用和兼容標(biāo)準(zhǔn)的 ECMAScript 語(yǔ)法和運(yùn)行時(shí)語(yǔ)義。這使得 TypeScript 專注于在 JavaScript 之上提供一層定義類型的語(yǔ)法和檢查類型的語(yǔ)義。代碼的職能被清晰的劃分開(kāi)來(lái):TypeScript = JavaScript + Types!
這是一個(gè)極好的模型。這意味著編譯出來(lái)的代碼是可讀的 JavaScript,就像編程人員自己寫的一樣。這也使得即使在沒(méi)有原始代碼的情況下,調(diào)試生產(chǎn)環(huán)境下的代碼也會(huì)變得容易。你不需要擔(dān)心選擇了 TypeScript 之后會(huì)斬?cái)嗄阍趯?lái)使用 ECMAScript 新功能的可能性。TypeScript 為當(dāng)前的運(yùn)行時(shí)敞開(kāi)了大門,甚至于將來(lái)的 JavaScript 引擎也許可以忽略類型定義語(yǔ)法,原生“運(yùn)行” TypeScript 代碼。一種更簡(jiǎn)單的開(kāi)發(fā)體驗(yàn)指日可待!
在發(fā)展過(guò)程中,TypeScript 擴(kuò)展了一小部分不太適合這個(gè)模型的功能。enum, namespace, parameter properties 以及 experimental decorators 都需要有將他們擴(kuò)展為運(yùn)行時(shí)代碼的語(yǔ)義,而 JavaScript 引擎很可能永遠(yuǎn)都不會(huì)為這些功能提供支持。
這不是大問(wèn)題。TypeScript Design Goals 明確表示了避免在未來(lái)引入更多的運(yùn)行時(shí)特征。TypeScript 團(tuán)隊(duì)的一名成員 Orta 制作了一個(gè) MEME 幻燈片來(lái)強(qiáng)調(diào)了對(duì)這一說(shuō)法的認(rèn)可。

我們的工具鏈通過(guò)阻止使用這些功能來(lái)避免這些不良的設(shè)計(jì),以確保我們不斷增長(zhǎng)的 TypeScript 代碼庫(kù)是真正的 JS + Types。
2. 持續(xù)更新編譯器的版本是值得的
TypeScript 發(fā)展的很快。新版本一般會(huì)引入新的類型層面的功能、對(duì) JavaScript 功能的支持、提升性能和穩(wěn)定性,同時(shí)也會(huì)增強(qiáng)類型檢測(cè)器,用以發(fā)現(xiàn)更多的類型錯(cuò)誤。所以,使用新版本是一件非常吸引人的事情!
當(dāng) TypeScript 努力保持兼容性時(shí),改進(jìn)的類型檢查會(huì)對(duì)構(gòu)建過(guò)程表現(xiàn)出一些破壞性改變,因?yàn)樾碌腻e(cuò)誤會(huì)在過(guò)去沒(méi)有錯(cuò)誤的代碼中被識(shí)別出來(lái)。因此,需要一些干預(yù)才能完成對(duì) TypeScript 版本的升級(jí),從而獲得新版帶來(lái)的優(yōu)勢(shì)。
還有另一種兼容性問(wèn)題需要考慮,也就是跨項(xiàng)目的兼容性。隨著 JavaScript 和 TypeScript 語(yǔ)法的發(fā)展,聲明文件也需要容納新的語(yǔ)法。
假設(shè)某一個(gè)庫(kù)升級(jí)了 TypeScript 版本并且使用新的語(yǔ)法輸出了聲明文件。而引用了這個(gè)庫(kù)的項(xiàng)目,如果它們的 TypeScript 版本無(wú)法理解這些語(yǔ)法,那么這些項(xiàng)目將會(huì)編譯失敗。例如 TypeScript 3.5 或更早的版本就無(wú)法理解 TypeScript 3.7 新增的 getter/setter 存取器方法。這也就意味著,在同一個(gè)生態(tài)系統(tǒng)中如果各個(gè)項(xiàng)目使用不同版本的編譯器,情況就會(huì)很不理想。
在彭博社,代碼分布在各種使用通用工具的 Git 倉(cāng)庫(kù)中。盡管沒(méi)有使用 Monorepo 來(lái)統(tǒng)一管理,但我們使用一個(gè)注冊(cè)表集中式管理 TypeScript 項(xiàng)目。這樣就使我們可以創(chuàng)建一個(gè)持續(xù)集成的任務(wù)來(lái) “構(gòu)建一切”,并且檢驗(yàn)升級(jí)后的編譯器對(duì)每個(gè) TypeScript 項(xiàng)目構(gòu)建和運(yùn)行時(shí)的影響。
這個(gè)全局檢查很強(qiáng)大,我們用它評(píng)估 TypeScript 的 Beta 版和 RC 版,以便在常規(guī)版本發(fā)布前發(fā)現(xiàn)問(wèn)題。擁有多樣的真實(shí)代碼作為資料集意味著我們可以找到邊際情況。我們用這個(gè)系統(tǒng)來(lái)引導(dǎo)項(xiàng)目為編譯器升級(jí)做好準(zhǔn)備,使得它們完美的完成升級(jí)。到目前為止,這個(gè)策略運(yùn)行的很好,因此我們可以使整個(gè)代碼庫(kù)保持在最新的 TypeScript 之上。這樣就意味著我們不需要采取諸如對(duì) DTS 文件降級(jí)之類的緩解措施來(lái)應(yīng)對(duì)版本升級(jí)。
3. 保持一致的 tsconfig 設(shè)置是非常重要的
tsconfig配置文件提供了很大的靈活性,使得你可以根據(jù)運(yùn)行時(shí)平臺(tái)來(lái)調(diào)整 TypeScript。但是在一個(gè)追求多項(xiàng)目共存且長(zhǎng)時(shí)間持續(xù)運(yùn)行的環(huán)境中,對(duì)每個(gè)項(xiàng)目單獨(dú)配置卻是極具風(fēng)險(xiǎn)的事情。
因此,我們讓工具鏈來(lái)負(fù)責(zé)在編譯時(shí)基于 “最優(yōu)” 設(shè)置生成 tsconfig 。例如,默認(rèn)啟用 "Strict" 模式來(lái)增強(qiáng)類型的安全性;強(qiáng)制使用 "isolatedModules" 則可以確保我們的代碼可以使用簡(jiǎn)單轉(zhuǎn)義器每次對(duì)單個(gè)文件進(jìn)行快速編譯。
將 tsconfig 視作被生成的文件而不是源文件的另一個(gè)好處就是,它允許高級(jí)工具通過(guò)不同選項(xiàng)(如 “references”,“paths” 等)靈活地將多個(gè)項(xiàng)目的 “工作區(qū)” 鏈接在一起。
也有一些例外情況,少數(shù)項(xiàng)目需要自定義的配置,比如使用寬松模式來(lái)減少遷移負(fù)擔(dān)。
舉個(gè)例子。最初我們?cè)噲D使用更少的選項(xiàng)來(lái)滿足一致性的要求。但后來(lái)我們發(fā)現(xiàn)了軟件包間的沖突,當(dāng)使用某組選項(xiàng)構(gòu)建的軟件包被使用其他選項(xiàng)構(gòu)建的軟件包引用時(shí),這些沖突就發(fā)生了。
合理的做法是創(chuàng)建一個(gè)帶條件的類型,用于指向被 "strictNullChecks" 檢測(cè)到的類型值。
type?A?=?unknown?extends?{}???string?:?number;
如果啟用了 “strictNullChecks”,那么 A 的類型就是 number;如果沒(méi)啟用,則是 string。如果軟件包導(dǎo)出的這個(gè)類型和它導(dǎo)入的軟件包沒(méi)有使用相同的嚴(yán)格模式設(shè)置,那么程序?qū)?huì)出錯(cuò)。
這是在現(xiàn)實(shí)中我們碰到問(wèn)題的一個(gè)簡(jiǎn)單事例。最終我們放棄了嚴(yán)格模式,選擇犧牲靈活性來(lái)保持所有項(xiàng)目配置的一致性。
4. 如何指定依賴關(guān)系的位置很重要
我們需要顯式地向 TypeScript 代碼聲明依賴的位置。這是因?yàn)槲覀兊?ES 模塊系統(tǒng)不會(huì)像通常的 Node 程序那樣,向上遞歸查找 node_modules 文件夾。
我們需要對(duì)修飾符(例如 “l(fā)odash”)和其在磁盤上的目錄位置(“c:\dependencies\lodash”)的映射進(jìn)行聲明。這類似于在 Web 中引入 maps 的方式來(lái)解決映射問(wèn)題。最初,我們嘗試在 tsconfig 中使用 "paths" 這一選項(xiàng):
//?tsconfig.json
??"paths":?{
????"lodash":?[?"../../dependencies/lodash"?]
??}
這種方式在幾乎所有的用例中都運(yùn)行的很好。盡管如此,我們還是發(fā)現(xiàn)這種方式降低了自動(dòng)生成的聲明文件的質(zhì)量。TypeScript 編譯器必須在聲明文件中注入復(fù)合的導(dǎo)入語(yǔ)句,以實(shí)現(xiàn)復(fù)合類型的聲明 —— 某些類型的定義依賴于其他模塊下的類型。當(dāng)復(fù)合引用依賴中的類型時(shí),我們發(fā)現(xiàn) "paths" 并未使用已經(jīng)定義的修飾符(import "lodash"),而是引入了相對(duì)路徑(import("../../dependencies/lodash"))。對(duì)于我們的系統(tǒng)來(lái)說(shuō),一些類型定義引入自外部軟件包,而他們的相對(duì)位置是可能會(huì)發(fā)生改變,這種情況是不可接受的。
我們最終的解決方案是使用 Ambient Modules
//?ambient-modules.d.ts
declare?module?"lodash"?{
??export?*?from?"../../dependencies/lodash";
??export?default?from?"../../dependencies/lodash";
}
Ambient Modules 特別之處在于,TypeScript 在發(fā)表聲明時(shí)保持對(duì)修飾符的引用,從而避免將它們轉(zhuǎn)化為相對(duì)路徑。
5. 類型去重很重要
程序的性能很關(guān)鍵,所以我們要盡量使在運(yùn)行時(shí)中的 JS 保持最小的體積。我們的平臺(tái)會(huì)確保在運(yùn)行時(shí)中每個(gè)包只有一個(gè)版本的存在。通過(guò)這種方式,確保了給定的包不會(huì)因?yàn)榘姹静煌i定和引入不同的依賴。因此,這也使得軟件包必須隨時(shí)保持對(duì)系統(tǒng)的兼容性。
我們希望對(duì)類型提供一種 “精確且唯一” 的定義,以確保對(duì)于給定的編譯項(xiàng)目,類型檢查只需要對(duì)依賴進(jìn)行單一版本的檢查。除了增加編譯時(shí)的效率以外,這么做的另一個(gè)動(dòng)機(jī)就是確保類型檢查能夠更好的反應(yīng)運(yùn)行時(shí)環(huán)境。我們尤其希望避免落入失效定義問(wèn)題和 “聲明地獄”,即通過(guò) “菱形模式” 導(dǎo)入同一類型聲明的多個(gè)版本。隨著生態(tài)采用的聲明增加,這個(gè)問(wèn)題的危害會(huì)被放大。
我們編寫了一個(gè)決策式解析器用來(lái)根據(jù)正在構(gòu)建的包約束中正確的選擇出一個(gè)依賴的版本。
這就意味著依賴的類型關(guān)系圖是動(dòng)態(tài)生成的——而不是靜態(tài)的鎖定某個(gè)版本。雖然這種不鎖依賴版本的方法帶來(lái)了很多優(yōu)點(diǎn)并且回避了很多危險(xiǎn),但我們后來(lái)發(fā)現(xiàn),這個(gè)方式會(huì)因?yàn)?TypeScript 編譯器的一些古怪行為引入一些不一樣的危險(xiǎn)。在 9. 聲明文件中生成的類型會(huì)內(nèi)聯(lián)傳遞自依賴中的類型 中會(huì)詳細(xì)說(shuō)明。
這些權(quán)衡和選擇并不是特定于我們的平臺(tái)。它們同樣適用于任何基于類型定義的 npm 項(xiàng)目,并且應(yīng)當(dāng)根據(jù) package.json 文件 "dependencies" 中每個(gè)包版本約束的綜合影響進(jìn)行判斷。
6. 應(yīng)該避免隱式類型依賴關(guān)系
在 TypeScript 中引入全局類型很容易,依賴全局類型更容易。如果不加檢查,就很有可能在不相關(guān)的包之間發(fā)生隱式耦合。TypeScript 手冊(cè)將這種行為稱為 “有些危險(xiǎn)”。
//?A?declaration?that?injects?global?types
declare?global?{
??interface?String?{
????fancyFormat(opts?:?StringFormatOptions):?string;
??}
}
//?Somewhere?in?a?file?far,?far?away...
String.fancyFormat();??//?no?error!
解決這個(gè)問(wèn)題的方法顯而易見(jiàn):使用顯示依賴而不是全局狀態(tài)棧。TypeScript 很早以前就為 ECMAScript 導(dǎo)入和導(dǎo)出語(yǔ)句提供了支持,從而實(shí)現(xiàn)了這一目標(biāo)。
剩下我們唯一要防止就是意外創(chuàng)建的全局類型。幸運(yùn)的是,我們?cè)?TypeScript 中可以靜態(tài)地檢測(cè)到每一個(gè)局類型的引入用例。因此我們可以通過(guò)升級(jí)工具鏈來(lái)發(fā)現(xiàn)這些全局類型的每個(gè)用例并拋出錯(cuò)誤,因此我們可以安全地依賴于無(wú)副作用的包類型引入。
7. 聲明文件有三種輸出模式
不同的聲明文件并不完全等價(jià)。內(nèi)容的不同決定了一個(gè)聲明文件屬于以下三種形式中的哪一種。特別是 import 和 export 關(guān)鍵字的使用:
全局 —— 不使用
import和export關(guān)鍵字的聲明文件就被認(rèn)為是全局聲明。頂級(jí)聲明都是輸出在全局作用域。模塊 —— 至少包含一個(gè)
export關(guān)鍵字的聲明文件即為模塊聲明。只有export關(guān)鍵字引導(dǎo)的聲明會(huì)被輸出,而且其作用域不會(huì)是全局。隱式輸出 —— 不使用關(guān)鍵字
export引導(dǎo)聲明,但使用import關(guān)鍵字導(dǎo)入時(shí)會(huì)觸發(fā)未文檔化的已定義行為。也就是不再將頂級(jí)聲明視作全局作用域,而是作為命名空間的聲明導(dǎo)出。
我們不使用模式一。我們使用工具鏈防止全局作用域的聲明文件(詳見(jiàn) 6. 隱式類型依賴關(guān)系應(yīng)當(dāng)避免)。所有的聲明文件均遵循 ES Module 語(yǔ)法。
有些令人驚訝的是,我們發(fā)現(xiàn)看上去有些令人不安的第三種模式卻非常有用。通過(guò)在聲明文件的頂部添加一行 “自引導(dǎo)” 的方式,就可以防止它們污染全局命名空間:import {} from "./。這個(gè)單行使得將第三方聲明(如 lib.dom.d.ts)模塊化變得非常容易,而且可以避免去維護(hù)一個(gè)更復(fù)雜的代碼克隆。
然而 TypeScript 團(tuán)隊(duì)看上去并不喜歡第三種模式,所以盡可能的回避這種方式。
8. 包的封裝可能會(huì)被破壞
如在前文中表述的(5. 類型去重很重要),我們不鎖定依賴版本,這意味著我們的包不僅需要保持對(duì)運(yùn)行時(shí)的兼容性,同時(shí)也要保證在版本更迭時(shí)保持類型的兼容性。這是一個(gè)挑戰(zhàn),為了實(shí)現(xiàn)對(duì)兼容性的保護(hù),我們必須真正的了解哪些類型是公開(kāi)的,且需要對(duì)版本加以限制。第一件要干的事情,就是明確區(qū)分公共模塊和私有模塊。
Node 通過(guò)在 package.json 中的 exports 字段來(lái)實(shí)現(xiàn)這個(gè)功能。通過(guò)顯示列出可從包外訪問(wèn)文件的方式,定義封裝邊界。
目前,TypeScript 并不在意包的導(dǎo)出,因此也不知道依賴中哪些文件是公開(kāi)的,哪些是不公開(kāi)的。在聲明生成過(guò)程中,TypeScript 將導(dǎo)入的語(yǔ)句合成并傳遞為類型定義再封裝成 .d.ts 文件,這時(shí)就會(huì)產(chǎn)生問(wèn)題 —— 我們的 .d.ts 文件中可能引用了其他包里的私有文件,這是不可接受的。下面是一個(gè)錯(cuò)誤的示例:
//?index.ts
import?boxMaker?from?"another-package"
export?const?box?=?boxMaker();
上面引入的源文件可能導(dǎo)致 tsc 發(fā)出以下不正確的聲明。
//?index.d.ts
export?const?box?:?import("another-package/private").Box
這是很糟糕的。“來(lái)自另一個(gè)私有包” 不能保證兼容性,因?yàn)檫@些聲明很可能在某個(gè)微小的改動(dòng)時(shí)就被移除或者重命名了。到目前為止,TypeScript 仍無(wú)法知曉它生成的文件中是否存在不安全的導(dǎo)入。
我們通過(guò)兩個(gè)步驟來(lái)緩解這個(gè)問(wèn)題:
我們的工具鏈會(huì)將試圖公開(kāi)的修飾符指向的路徑(例如:"lodash/public1", "lodash/public2")告知 TypeScript 解析器。在 TypeScript 文件進(jìn)行編譯之前在它的尾部添加允許導(dǎo)入的類型聲明,通過(guò)這種方式確保 TypeScript 知曉所有合法的依賴入口。
//?user's?source?code
//?injected?by?toolchain?to?assist?declaration?emit
import?type?*?as?__fake_name_1?from?"lodash/public1";
import?type?*?as?__fake_name_2?from?"lodash/public2";
當(dāng)生成引用文件的推斷類型時(shí),TypeScript 的聲明執(zhí)行器將使用這些已知的命名空間修飾符來(lái)代替路徑方式實(shí)現(xiàn)對(duì)私有文件的導(dǎo)入。
如果 TypeScript 生成了一個(gè)路徑,而我們已知其為某個(gè)依賴的私有文件,這時(shí)我們的工具鏈就會(huì)拋出一個(gè)錯(cuò)誤。這就像是 TypeScript 自己意識(shí)到它正在將一個(gè)有潛在風(fēng)險(xiǎn)的路徑指向到某個(gè)依賴一樣,拋出一個(gè) TypeScript 的錯(cuò)誤。
error?TS2742:?The?inferred?type?of?'...'?cannot?be?named?without?a?reference?to?'...'.
This?is?likely?not?portable.?A?type?annotation?is?necessary.
這樣就會(huì)通知到用戶需要注釋掉這個(gè)輸出才能解決這個(gè)錯(cuò)誤。或者,在某些情況下,它們可以更新依賴,直接從公共包入口輸出內(nèi)部類型。
我們期待 TypeScript 能夠?qū)θ肟邳c(diǎn)問(wèn)題提供更好的支持,這也就不需要使用這種替代方案了。
9. 聲明文件中生成的類型會(huì)內(nèi)聯(lián)傳遞自依賴中的類型
軟件包需要輸出 .d.ts 聲明文件給用戶使用。我們選擇用 TypeScript 的 declaration 選項(xiàng)依照原始 .ts 文件生成 .d.ts 文件。盡管也可以在編寫代碼的同時(shí)手寫和維護(hù) .d.ts 文件,但這種做法并不科學(xué),因?yàn)闀r(shí)刻維護(hù)它們的一致性是件非常難做的事情。
TypeScript 在大多數(shù)情況下自動(dòng)生成聲明文件都沒(méi)問(wèn)題。我們發(fā)現(xiàn)的其中一個(gè)問(wèn)題是有時(shí) TypeScript 會(huì)將依賴中的類型內(nèi)聯(lián)傳遞給當(dāng)前的類型。這就意味著相對(duì)于用 import 語(yǔ)句標(biāo)識(shí)為引用,這種方式的類型定義被重定向了,并且存在潛在的重復(fù)定義。對(duì)于結(jié)構(gòu)化的類型定義,編譯器不會(huì)強(qiáng)制性驗(yàn)證被引用的類型是否和源定義一致 —— 因此重復(fù)的類型定義是不會(huì)報(bào)錯(cuò)的。
我們見(jiàn)過(guò)一些更極端的例子,由于這些重復(fù)的類型定義,聲明文件的大小從 7KB 膨脹到了 700KB。這使得程序運(yùn)行時(shí)需要下載和解析大量的冗余代碼。
包內(nèi)的內(nèi)聯(lián)類型定義不會(huì)造成系統(tǒng)性問(wèn)題,因?yàn)樗鼈儗?duì)外是不可見(jiàn)的。但是當(dāng)類型定義在不同包里使用了不同的版本時(shí),問(wèn)題就出現(xiàn)了。在我們這樣不鎖定包版本的系統(tǒng)中,各個(gè)包可以獨(dú)立演化,這帶來(lái)了類型兼容性的風(fēng)險(xiǎn),特別是類型失效的風(fēng)險(xiǎn)。
通過(guò)實(shí)驗(yàn),我們找到了一個(gè)能防止內(nèi)聯(lián)類型聲明的潛在技術(shù):
使用
interface代替type(interface 接口是不存在內(nèi)聯(lián)問(wèn)題的)如果一個(gè)
interface沒(méi)有在聲明中輸出,tsc 不會(huì)去內(nèi)聯(lián)查詢這個(gè)類型,而是拋出一個(gè)異常(例如:TS4023: Exported variable has or is using name from external module but cannot be named.)。如果一個(gè)
type沒(méi)有在聲明中輸出,tsc 會(huì)在依賴中內(nèi)聯(lián)尋找這個(gè)類型的定義。Nicholas Jamieson 寫了一篇文章來(lái)推薦使用接口來(lái)替代類型,以及相應(yīng)的 Eslint 規(guī)則。
使用標(biāo)明類型定義(像
enum,class這些有私有成員的標(biāo)明類型是不會(huì)內(nèi)聯(lián)定義的)對(duì)輸出添加類型注釋
沒(méi)有類型注釋時(shí),發(fā)生了內(nèi)聯(lián)引用
使用顯示類型注釋后,我們強(qiáng)制指定了引用的行為
這種內(nèi)聯(lián)行為似乎沒(méi)有被嚴(yán)格的指出。這只是構(gòu)造聲明文件方式的副作用,因此上述方式有可能會(huì)在將來(lái)失效。希望這能在 TypeScript 中被正式化。在此之前,我們將依靠用戶教育來(lái)降低這種風(fēng)險(xiǎn)。
10. 生成的聲明文件有可能會(huì)包含不必要的依賴
TypeScript 聲明文件的使用者通常只關(guān)心包的公有類型的API。TypeScript 聲明生成器會(huì)對(duì)項(xiàng)目中的每個(gè) TypeScript 文件只產(chǎn)生一個(gè)聲明文件。其中一些內(nèi)容可能與用戶無(wú)關(guān),并可能會(huì)暴露私有部分的實(shí)現(xiàn)細(xì)節(jié)。這種行為可能會(huì)讓 TypeScript 新手感到驚訝,他們往往會(huì)期待類型定義應(yīng)該像 Definitely Typed 那樣,僅僅展示公有API。
這種情況的一個(gè)例子是:生成的聲明文件中包含了僅僅用于內(nèi)部測(cè)試方法的類型。
由于我們的包管理系統(tǒng)知曉所有的公共包入口,我們的工具可以在可訪問(wèn)類型圖中爬取所有不需要公開(kāi)的類型。這是 Dead Type Elimination(DTE),或者更準(zhǔn)確的說(shuō),Tree-Shaking。我們編寫了一個(gè)工具來(lái)做這件事 —— 它僅通過(guò)消除聲明文件中的冗余來(lái)完成最小化代碼的工作。它不會(huì)去重寫或者重定向代碼 —— 他不是一個(gè)打包器。也就是說(shuō),最終發(fā)布的聲明文件是 TypeScript 自動(dòng)生成的聲明文件的子集。
軟件包減少發(fā)布類型的體積有以下好處:
減少了與其他包的耦合性(一些包不會(huì)對(duì)依賴中的類型重新輸出)
它通過(guò)防止完全私有類型的泄漏來(lái)幫助封裝
在發(fā)布中減少了用戶需要下載并解壓的聲明文件的數(shù)量和體積
減少了 TypeScript 編譯器在類型檢查時(shí)需要解析的代碼量
"Shaking" 有時(shí)會(huì)效果極為顯著。我們?cè)?jīng)遇到過(guò)一些包中超過(guò) 90% 文件中有超過(guò) 90% 的類型定義行是可以去掉的。
一些選項(xiàng)有嚴(yán)格的使用場(chǎng)景
我們發(fā)現(xiàn)一些 tsconfig 選項(xiàng)中的語(yǔ)義是令人驚訝的。
tsconfig?中強(qiáng)行使用 baseUrl
在 TypeScript 4.0 中。如果你希望使用項(xiàng)目引用或者某個(gè)“路徑”,你就同時(shí)需要指定一個(gè) baseUrl。這樣做的副作用就是會(huì)導(dǎo)致所有的修飾符導(dǎo)入相對(duì)路徑時(shí)都會(huì)被補(bǔ)全為相對(duì)于根目錄的路徑形式。
//?package-a/main.ts
import?"sibling"???//?Will?auto-complete?and?type-check?if?`package-a/sibling.js`?exists
這樣做的危險(xiǎn)在于,如果你想引入任何形式的“路徑”,它會(huì)帶來(lái)一個(gè)額外的結(jié)果, import "sibling" 會(huì)被TypeScript 自動(dòng)補(bǔ)全為 。
為了解決這個(gè)問(wèn)題,我們使用了一個(gè)糟糕的 baseUrl。使用 null 來(lái)防止不必要的自動(dòng)補(bǔ)全。我們不建議你在家里嘗試這樣做。
我們?cè)?TypeScript issue 上報(bào)告了這個(gè)問(wèn)題,很高興地看到 Andrew 已經(jīng)在 TypeScript 4.1 解決了這個(gè)問(wèn)題,這將使我們告別 null 字符!
JSON 模塊導(dǎo)入沒(méi)有默認(rèn)開(kāi)啟
如果你希望使用 resloveJsonModules,你就同時(shí)需要開(kāi)啟 useSyntheticDefaultImports 選項(xiàng),從而使 TypeScript 識(shí)別導(dǎo)入 JSON 模塊。在將來(lái),使用導(dǎo)入的方式處理 JSON 模塊,很可能成為 Node 和 Web 的標(biāo)準(zhǔn)方式。
啟用 useSyntheticDefaultImports 會(huì)有一個(gè)不幸的結(jié)果,即允許導(dǎo)入沒(méi)有默認(rèn)輸出的常規(guī) ES 模塊!這是一種風(fēng)險(xiǎn),你只有在運(yùn)行代碼時(shí)才會(huì)發(fā)現(xiàn),并且它一閃而過(guò)。
理想情況下,應(yīng)該有一種不需要啟用 useSyntheticDefaultImports 而能夠?qū)隞SON模塊的方法。
非常好的部分
從工具化的角度來(lái)看,TypeScript 中展現(xiàn)出的一些特別好的東西是值得一提的。
增量構(gòu)建成為基本功能。TypeScript 3.6 的 API 對(duì)增量構(gòu)建提供支持對(duì)我們來(lái)說(shuō)是一件有巨大推動(dòng)作用的事,這使得自定義工具鏈可以進(jìn)行快速重建。在我們報(bào)告了將 incremental 和 noEmitOnError 結(jié)合使用而產(chǎn)生的性能問(wèn)題時(shí),Sheetal 使它們?cè)?TypeScript 4.0 中運(yùn)行速度更快了。
"isolatedModules" 在確保我們可以執(zhí)行快速的獨(dú)立(一個(gè)進(jìn),一個(gè)出)置換時(shí)至關(guān)重要。TypeScript團(tuán)隊(duì)修復(fù)了一系列問(wèn)題來(lái)改進(jìn)這個(gè)選項(xiàng),包括:
同時(shí)允許
emitDeclaration?和isolatedModules同時(shí)允許
noEmitOnError和isolatedModules當(dāng)啟用
isolatedModules?時(shí),類型必須顯式輸出
項(xiàng)目引用是提供無(wú)縫 IDE 體驗(yàn)的關(guān)鍵。我們利用它們極大地提升了基于多個(gè)包工作區(qū)的項(xiàng)目開(kāi)發(fā)體驗(yàn),使它變得像單個(gè)項(xiàng)目開(kāi)發(fā)一樣靈活。多虧了 Sheetal,它們現(xiàn)在更好了,并且支持不需要文件的 "Solution Style"的tsconfigs。
僅類型導(dǎo)入是非常有用的。我們?cè)趯?dǎo)出都會(huì)用到它們來(lái)安全地區(qū)分運(yùn)行時(shí)導(dǎo)入還是編譯時(shí)導(dǎo)入。它們?cè)趩⒂?"isolatedModules" 模式時(shí)必不可少,并且允許我們使用 "importsNotUsedAsValues":"error" 來(lái)獲得最佳的安全性。感謝 Andrew 提交了這個(gè)功能!
"useDefineForClassFields" 對(duì)于確保我們發(fā)布的 ESNext 代碼不會(huì)被重寫,保持語(yǔ)言的JS + 類型特性非常重要。這使得我們可以原生的使用 Class 字段。感謝 Nathan 提供了這個(gè)功能,并盡可能順利地進(jìn)行了遷移。
TypeScript 中的新增特性有時(shí)會(huì)有驚喜。每當(dāng)我們意識(shí)到我們需要一個(gè)特性時(shí),我們經(jīng)常發(fā)現(xiàn)它已經(jīng)在下一個(gè)版本中交付了。
總結(jié)
最終,TypeScript 現(xiàn)在是我們應(yīng)用平臺(tái)的首選語(yǔ)言了。在需要將 TypeScript 與另一種運(yùn)行時(shí)集成在一起時(shí),語(yǔ)言和編譯器表現(xiàn)的似乎和 JavaScript 一樣靈活 —— 它們都可以在任何地方使用。
雖然一路上我們遇到了很多問(wèn)題,但沒(méi)有什么是不可逾越的。當(dāng)我們需要支持時(shí),我們?yōu)閬?lái)自社區(qū)和 TypeScript 團(tuán)隊(duì)本身的響應(yīng)感覺(jué)驚喜。使用共享開(kāi)源技術(shù)的一個(gè)明顯好處是,當(dāng)您遇到問(wèn)題時(shí),您通常會(huì)發(fā)現(xiàn)您并不孤單。當(dāng)你找到了答案,你就能從分享中得到樂(lè)趣。
