用typescript完成倒计时_「干货」将数十万行CoffeeScript代码迁移到TypeScript
作者 | David Goldstein
譯者 | 王強
策劃 | 小智
轉發鏈接:https://mp.weixin.qq.com/s/TK7kWXX4hR3e-jtpVMuBnw
序言
2017 年 5 月,我首度加入 Dropbox 的時候,從 CoffeeScript 向 TypeScript 遷移的工作已經接近尾聲。彼時,需要對已有的 CoffeeScript 文件更改時,一般都會先將它轉換為 TypeScript。我們的部分代碼庫仍在使用 react-dom-factories,并且在 Redux 之前有一個自定義的 flux 實現。
那時我們的 Web 平臺團隊正全速向 TypeScript 遷移,但這一工作的規模或復雜性尚不為外人所知。如今 TypeScript 已成為 JavaScript 事實上的超集,我們的這段往事也是時候公之于眾了。故事主要發生在 2017 年,在今天依舊頗具參考價值。
我聯絡到該項目的首席工程師之一 David Goldstein 來撰寫本文。此外,還找到了另一位見證者,Web 平臺工程師 Samer Masterson 來補充細節。
將數十萬行 CoffeeScript 代碼遷移到 TypeScript 是一項龐大的工程,本文將涉及其中的方方面面。我們將介紹一開始為什么選擇了 TypeScript,如何規劃遷移工作,還有那些計劃外的各種細節。
遷移在 2017 年秋季結束。在此過程中我們開發了一些優秀的工具,并成為了首批大規模采用 TypeScript 的公司之一。——Matthew Gerstman
歷史:采用 CoffeeScript
早在 2012 年,我們還是一家只有約 150 名員工的新興公司。當時瀏覽器中的最新技術是 jQuery 和 ES5。HTML5 還有兩年才會正式登臺,而 ES6 還要等三年。由于 JavaScript 技術似乎停滯不前,我們想要找到一種更先進的 Web 開發方法。
當時,CoffeeScript 非常流行。它支持箭頭函數,智能 this 綁定,甚至可選鏈,都比標準 JavaScript 領先數年。最后,我們的兩名工程師在 2012 年的“黑客周”中將整個 dropbox.com Web 應用程序從 JavaScript 遷移到了 CoffeeScript 上。彼時 dropbox 規模不大,所以遷移很容易。我們從 CoffeeScript 社區獲得了指導,并采納了他們的樣式建議,最終將 coffeelint 集成到了工作流程中。
在 CoffeeScript 中,花括號、圓括號,有時甚至逗號都是非必須的,是可有可無的選項。
例如,foo 12 與 foo(12) 是等同的。
多行數組可以不用逗號:
// CoffeeScript["foo""bar"]// JavaScript["foo",?"bar"]這種語法方法在那時很流行,我們甚至采納了社區的“可選的符號就不用寫”建議。
當時,代碼庫包含約 100,000 行 JavaScript。所有文件按預先指定的順序串聯在一起打包發布。盡管公司的許多工程師都可以看到這些代碼,但其中全職的 Web 工程師卻不到 10 位。
自然,這種方法無法很好地擴展;在 2013 年我們采用了 RequireJS 模塊系統,并開始編寫新代碼以符合“異步模塊定義”(簡稱 AMD)規范。我們確實考慮過 CommonJS,但那時 npm 和 Node 生態系統尚未成熟,因此我們選擇了專為在瀏覽器中使用而設計的工具。如果是幾年后再做同樣的決策,我們可能會改用 CommonJS。
語言遷移的號角聲
一開始還好,但到了 2015 年底,產品工程師開始對 CoffeeScript 愈加不滿。ES6 于當年早些時候發布,覆蓋了 CoffeeScript 的那些最佳特性,與 CoffeeScript 相比,它具備更多優勢。它支持對象和數組解構、類語法和箭頭函數。結果一些團隊搶先一步,開始在自己的獨立項目中使用 ES6。
與此同時,CoffeeScript 代碼庫維護難度在加大。由于 CoffeeScript(和標準 JavaScript)都沒有類型,因此很容易在無意間破壞某些內容。防御式編程隨處可見,但卻使代碼難以理解。我們為 null 和 undefined 添加了額外的保護措施,還針對某種極端場景采用了特殊對策,無需 new 操作便可以安全構造一個函數。
class?URI????constructor:?(x)?->??????## enable URI as a global function that returns a new URI instance??????unless?@?instanceof?URI????????return?new?URI(x)??????...此外,CoffeeScript 是一種基于空格的語言,即 tab 和空格具備不同的含義的,這與用 Python 構建 Dropbox 類似。然而,CoffeeScript 對標點卻過于寬容。通常,“可選的標點”實際上意味著“CoffeeScript 會將其編譯為意想不到的含義。”
舉個例子:在 2013 年的秋天,曾經遇到過一個關于標點符號的 bug,Python 無法編譯通過,CoffeeScript 將它進行了錯誤的編譯。雖然 Coffee Script 與 Python 的相似性可能有助于 Dropbox 的應用,但這些差異往往會出問題。一些更有經驗的開發人員選擇通過將 JavaScript 與 CoffeeScript 代碼并排打開來工作。
2015 年 11 月,對 Dropbox 的前端工程師進行了一項調查,發現只有 15%的受訪者認為應該繼續使用 CoffeeScript,而 62%的受訪者則認為應該放棄它:
開發人員經常抱怨:
- 缺少分隔符
- 過于固執己見的句法糖
- 缺乏社區對語言的支持
- 由于語法密集而難以理解
- 由于句法歧義而容易出錯
基于開發人員的這些反饋,于是我們將目光轉向業界,決定試用 TypeScript 和標準 ES6。我們將它們都集成到了 dropbox.com 技術棧中以選出更適合的選項。我們也考慮過 Flow,但它不如 TypeScript 流行,相關支持也較少。最后我們決定,如果要用類型語言就用 TypeScript,這在 2015 年是不尋常的決策。
2016 年上半年,有一位工程師將 Babel 和 Type 腳本集成到我們的構建腳本中。我們現在可以在主網站試用兩種語言。經過生產測試,我們認為 TypeScript 實際上是帶有類型的 ES6。由于團隊偏愛類型,最終選擇了 TypeScript。
但是有一個小問題:那時我們的代碼庫已增長到 329,000 行 CoffeeScript;我們的工程團隊也大幅擴張,不再由單個團隊負責整個網站。所以我們的遷移速度不會像上次那么快了。
樂觀的遷移計劃
最初的計劃有 5 大里程碑:
M1:基本支持
- 添加 TypeScript 編譯器。
- 詩 TypeScript 和 CoffeeScript 代碼可以互操作。
- TypeScript 的基本測試、國際化和 linting。
M2:選定 TypeScript 為新代碼的默認語言
- 優化開發人員體驗。
- 遷移核心庫。
- 為最佳實踐編寫文檔。
- 為代碼遷移編寫文檔。
M3:TypeScript 成為代碼庫的主成員
- 在 M2 基礎上更進一步,通過更多的教育過程,完整 linting 和測試支持,將其余重要的庫進行轉換。
M4:預期在 2017 年 4 月,將編輯最多的一組文件遷移到 TypeScript
- 手動將約 100 個經常編輯的文件從 Coffeescript 轉換為 TypeScript。原始的 CoffeeScript 將在 git 歷史中可用。
M5:預期在 2017 年 7 月,刪除 CoffeeScript 編譯器
- 將所有剩余 CoffeeScript 代碼轉換成 JavaScript。源 CoffeeScript 將在 git 歷史中可用。
- 更改這些 JavaScript 代碼前需將整個文件遷移到 TypeScript。
2016 年下半年,M1、M2 和 M3 順利完成。我們成功構建了穩健的 Coffee/TypeScript 互操作程序。測試很簡單:重用現有的基于 Jasmine 的基礎架構來測試兩種語言(之后遷移到了 Jest,但這是另一個故事了)。我們整合了 TSLint 并編寫了樣式指南。
M4 和 M5 遇到了不少障礙,因為產品團隊需要將已有代碼移植到 TypeScript 上。我們希望各個團隊負責遷移各自開發的代碼,并決定給產品團隊留出一年中 20%的時間用于“基礎工作”,后文會詳細說明。
CoffeeScript/TypeScript 的互操作性
我們實現了 CoffeeScript 和 TypeScript 的互操作,如下所示:對于每個 CoffeeScript 文件,在類型文件夾中創建了一個相應的.d.ts 聲明文件。這些都是自動創建的,如:
declare?module?"foo/bar" {??const?exports:?any;??export?= exports;}也就是說所有內容都變成了 any 類型。重要模塊可以轉換為 TypeScript,或者逐步改變類型。對于流行的外部庫(如 jQuery 或 React),可以從 DefinitelyTyped 找出可用的類型。對于不太常見的庫,采用與默認存根相同的方法。
將所有 TypeScript 和 CoffeeScript 文件放在同一文件夾中,所以兩種語言的文件模塊 ID 都一樣。在學習 AMD import/export 與 TypeScript 的語法如何對應時我們遇到了些麻煩,還好問題不大。我們沒有使用 --esModuleInterop。
等效的 import 語句如下:
TypeScript(推薦)
import?*?as?foo?from?"foo";TypeScript(不推薦)
import?foo =?require("foo");與 AMD JavaScript(或等效的 CoffeeScript)相同
define(["foo", ...],?function(foo, ...)?{ ... }將導出命名為 export const foo 類;可以導入模塊然后解構{foo},實現在 CoffeeScript 中讀取。這樣就和標準的 ES6 命名 import 建立了良好的語法關系。TypeScript 的 export default 導入到 AMD 模塊后,等效于對象{default: ...},真是令人驚訝。
大多數模塊都可以用這些等效方法,但有些模塊會動態確定它們將導出的內容。我們從每個文件導出了所有可能的導出,如果沒有返回的話就改為 undefined。
之前
define([...],?function(...)?{??...??if?(foo) {????return?{bar};??}?else?{????return?{baz};??}})之后
let?foo, bar;if?(foo) {??bar =?// define bar;}?else?{??baz =?// define baz;}// Export both regardless.export?{bar, baz}禁用 CoffeeScript 新文件
M2 階段代碼庫不再接收新的 CoffeeScript 文件。已有 CoffeeScript 的編輯不受影響,但多數工程師也因此開始學習 TypeScript 了。
一開始我們編寫了一個遍歷代碼庫的測試,找到所有.coffee 文件并將其路徑加入白名單。對此測試文件的任何更改都需要經過一位 Web 平臺工程師的審核。
同時我們采用了 Bazel 作為構建系統。在遷移到 Bazel 期間這一測試暫時失效了,為已有的 CoffeeScript 文件返回了一個空列表,還斷言該空列表是已有 CoffeeScript 文件白名單的子集。還好我們很快修復了這個問題,沒有造成嚴重影響。
我們在這里學到了一個教訓:如果測試中帶有任何假設,請試著確保它們能夠測試這些假設并在中斷時報錯。原始測試應該斷言 CoffeeScript 文件列表為非空,這樣一旦出錯時,就能立刻發現問題。
修復這個問題時,我們對白名單加入了嚴格的檢查,這樣文件刪除時也必須從白名單中移除,且不能重新引入(除非明確地重新添加文件)。這種方法之后用在了所有白名單相關工作上,既能讓不符合測試假設的問題快速暴露,又能避免人們無意間回退遷移工作。這里有一個小的缺陷:縮小白名單會阻斷代碼審核,但問題不大,我們會盡快(在一個工作日內)接受這些審核。
早期:沒有遺漏CoffeeScript的語法糖
最初選擇要遷移的語言時,我們擔心的一個問題是:ES6 和 TypeScript 并沒有包括 CoffeeScript 的所有特性,比如說沒有? 和?. 運算符。
起初,我們以為會遺漏這些:但當采用了 TypeScript2.0 的 --strictNullChecks 后,這就不是問題了。可選鏈運算符主要用來處理 undefined 或 null 之類的不確定性,而 TypeScript 幫助我們消除了這種不確定性。
有趣的是,optional chaining 和 nulllish coallescing 最近都被重新添加到 vanilla Java 腳本中,并以類型腳本語言顯示,盡管有一些小的語法變化與原始 CoffeeScript 變量之間略有差異。
優先級競爭
2016 年下半年,公司成立了一個并行團隊,用 React 重新設計和重構我們的網站。他們的目標是:到 2017 年第一季度末(時間接近最初的 M4 里程碑)發布新網站。該項目稱為“Maestro”,優先級比將他們負責的部分遷移到 TypeScript 的工作更高。此外其他一些團隊也會參與其中。
經過討價還價,Maestro 團隊最終承諾在第二季度完成遷移工作。前面他們就用 React 和 TypeScript 重寫了很多功能,剩下的文件則在第二季度遷移完畢。
遷移過程中用到“highly edited ”這個工具,強烈鼓勵社區轉換它們。可惜 100 個文件好像太多了,這個里程碑沒有按時交付。
這樣來看,刪除 CoffeeScript 編譯器的計劃也得推遲了。除了這 100 個熱門文件,后面還有 2000 多個雖然沒那么常用,但也時不時用得上的 CoffeeScript 老文件呢。
推遲 M5
M5 里程碑在組織中引起了很多混亂,通常把它總結為“去除 CoffeeScript 編譯器”。
公司內卻出現了另一種解釋。許多人認為,雖然無法在截止日期之后編寫 CoffeeScript,但產品團隊可以編輯本應該只讀的代碼,甚至可以編輯 CoffeeScript,然后檢查新的編譯后的代碼。
可如果只 check in 已編譯的代碼,那么大部分代碼就不會有 i18n 與 linting 支持了;不想追加投資的話,應假設代碼沒變才能找回這些支持。
此外,從平臺的角度來看,這個里程碑意義不大。去除編譯器主要是為了有一個單語言的代碼庫,并讓注意力集中在 TypeScript 工具鏈上。
不知道“只讀 JavaScript”是否比保留為 CoffeeScript 文件更好,用 Bazel 重新實現構建系統的工作即將完成,并已對 CoffeeScript 和 TypeScript 編譯器都提供了支持。
因此在 6 月,TypeScript 的遷移工作被無限期推遲,完成時間沒有 ETA。
事后看來這一決定似乎是不可避免的。假設每個工程日(包括測試和代碼審查)大約要轉換 1000 行代碼,那么一位工程師要花一年的時間才能完成遷移。這個速度實際上是非常樂觀的,因為實際報告的進度每天大約是 100 行,指望一兩個月就完成根本做不到。
至于之前承諾的“20%的時間用于基礎工作”,我們也沒有達成共識。有的人知道這是用來滿足基礎架構需求的時間,有的人則認為這些時間可以用來償還自己的技術債。而且 20% 這個限制也形同虛設,沒人真的遵守它。
2017 年后,我們再做遷移時就不再開這種空頭支票了。
使用 decaffeinate 的新方案
對 decaffeinate 的早期測試
早在 2017 年 1 月,一些工程師就曾使用 decaffeinate 來簡化代碼轉換工作,甚至開始圍繞它構建一些工具來處理 AMD,并通過一些開源代碼來清理 React 樣式。
不幸的是,我們首次嘗試 decaffeinate 時出現了嚴重的故障。我們轉換了 i18n 庫,然后審查,測試并交付生產,結果發現 decaffeinate 誤轉換了未測試的,可識別語言環境的排序函數。只有一個頁面用了這個函數,但在 Safari 中這個頁面完全錯亂了。之后我們查看了 decaffeinate 的錯誤積壓,結果發現了幾十個類似問題。我們也不知道需要花多久才能真正信任 decaffeinate,所以當時沒打算用這種方法。
不過一些工程師還是決定使用它來手動轉換代碼,我們在文檔中將其記為一種可行的工作流程。基于 decaffeinate 的腳本通常會生成明顯無效的代碼,這沒什么大不了的,因為 TypeScript 在編譯時會報告它們。真正的問題是潛在的 bug,它們改變了代碼的語義,編譯器卻發現不了。
六個月后
2017 年夏天,decaffeinate 聲明自己做到了無 bug。于是我們開始重新考慮這一選項,經過研究發現:
- decaffeinate 的聲明應該是可信的
- 更令人信服的是,我們的內部開發人員報告說,使用基于 decaffeinate 的腳本比手動轉換的結果更加可靠。
于是我們制定了新計劃:將剩余的遷移工作自動化。
現在對于 decaffeinate 無法提供類型的情況,可以添加為 any,直到 TypeScript 滿意為止。這種方法有以下優點:
- 工程師(尤其是新員工)不必再學習閱讀(或編輯)CoffeeScript
- Web 平臺無需再支持 CoffeeScript linting、國際化和編譯器
- codemod 或靜態分析之類工具的改進只需應對一種語言
遷移結束后,團隊可以按自己的進度修復代碼中的類型;無需再維護指向未轉換的 CoffeeScript 的聲明文件。
此時,產品團隊的空閑時間不多了,遷移得不到代碼所屬團隊的大量支持。而且要完成目標就要盡量減少引入的錯誤,有超過 2000 個文件要遷移,但錯誤超過一打就可能讓項目延遲或取消。這意味著我們必須在保持保持現有代碼語義的同時進行轉換。
兩階段計劃
需要針對所有文件創建一個多步流水線方法來完成遷移。
首先,運行 decaffeinate 以生成有效的 ES6。該代碼沒有類型,甚至包括了 pre-JSX React。然后我們用一個自制的 ES6 到 TypeScript 轉換器處理這段 ES6 代碼。
全面 decaffeinate
decaffeinate 有一些選項可以生成更漂亮的代碼,代價是降低代碼的正確率。這些選項以 --loose 開頭。最初包括以下選項:
- --loose-for-expressions
- --loose-for-includes
- --loose-includes
這樣就無需用 Array.from() 包裝代碼的大部分內容。但嘗試并測試后,我們發現了很多足以讓我們對這些選項失去信心的錯誤——它們很可能引入了回歸。
而下面這些選項引發錯誤為數不多,因此最終使用了它們:
- --prefer-const
- --loose-default-params
- --disable-babel 構造方法
decaffeinate 會留下有關潛在樣式問題的注釋,例如,
/*?* decaffeinate suggestions:?* DS102: Remove unnecessary code created because of implicit returns?* DS207: Consider shorter variations of null checks?* Full docs:?https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md?*/此后,我們使用了幾個 codemod 來清理生成的代碼。首先,使用 JavaScript-codemod 轉換函數,例如 function() {}.bind(this) 轉換為箭頭函數:() => {}。接下來,對于導入了 React 的文件,使用 react-codemod 更新了舊的 React.createElement 調用,并將 React.createClass 的實例轉換為 class MyComponent extends React.Component。
這一過程生成了可運行的 Javascript,但仍使用 AMD 模塊格式。就算修復了這個問題,它也沒有使用我們的設置進行類型檢查。我們希望最終的 TypeScript 代碼使用與其余代碼相同的標志,尤其是 noImplicitAny 和 strictNullChecks。
我們必須編寫自己的自定義轉換才能進行類型檢查。
構建一個 ES6 到 TypeScript 轉換器
自制轉換器有很多工作要做:通過迭代便能解決影響文件的所有問題,為此需要編寫一種工具來自動處理以下問題。
為了開發這些工具,我們主要使用 https://astexplorer.net/ 來探索在構建原型轉換時將要使用的抽象語法樹。
將 AMD 轉換為 ES6 模塊格式
首先,需要將 AMD import 更新為 ES6 import。
下面的代碼:
define(['library1',?'library2'],?function(lib1, lib2)?{})會變成:
import?*?as?lib1?from?'library1';import?*?as?lib2?from?'library2';在 CoffeeScript 中,銷毀 import 是一種常見的模式,與 named import 關系很近。因此我們將:
define(['m1',?'m2'],?function(M1, {somethingFromM2})?{??var?tmp = M1(somethingFromM2);});轉換為:
import?*?as?M1?from?'m1';import?{somethingFromM2}?from?'m2';var?tmp = M1(somethingFromM2);對導出進行轉換。如下代碼:
define(function()?{??return?{hello:?1}}變為:
export?{1?as?hello}當無法轉換為 named export 時,便回退到使用 export = 。例如:
define([],?function()?{??let?Something;??return?Something = (function()?{????Something =?class?Something?{????}????return?Something;??})();});變為:
let?Something;?Something = (function()?{???Something =?class?Something?{???}???return?Something;?})();?export?= Something;對于未用到的導入,之后會再做清理,以避免某些模塊會產生全局副作用。因此我們改為將其轉換為 import "x"; 樣式,并注釋說這可能是沒必要的。
類型簽名
接下來,我們必須將每個函數參數和 var 聲明注解為 any 類型。例如,function(hello) {} 變為 function(hello: any) {} 。
類
我們還需要為在類內部分配給 this 的每個屬性添加一個類屬性。例如:
class?Hello {??constructor() {????this.hi =?1;??}??someFunc() {????this.sup =?1;??}}會轉換為:
class?Hello {??hi:?any;??sup:?any;??...為 React 添加類型
另外,需要使用帶有類型的 React.Component 對 React 類組件進行注解。這些更改消除了許多 TypeScript 錯誤。
為轉換編寫文檔
因為不想丟失任何給定文件的版本控制歷史,所以我們自動在每個文件的頂部添加了一條消息,說明如何查找原始 coffeescript 版本。
NOTE This file was converted from a Coffeescript file.// The original content is available through git with the command:// git show 21e537318b56:metaserver/static/js/legacy_js/widgets/bubble.coffee//修復類型錯誤
我們不想添加不必要的 any;但就算經過上述管道處理,仍然會遇到數千種類型錯誤。因此,轉換管道中的最后一步是一個腳本,其運行類型檢查,解析類型檢查輸出,然后根據每個錯誤代碼嘗試在關聯的 AST 節點上插入適當的 any 用法。
一開始,我們在腳本里使用了 node-falafel,但發現用它時需要解析 TypeScript,所以我們 fork 了 falafel,進而使用 tslint-eslint-parser 來替代它;這樣我們只需重寫需要更改的代碼即可。
保持專注
我們的目標不是要做出最優秀的轉換工具,而是要轉換代碼庫。首先,從小的內部功能入手來測試工具,用它們來捕獲轉換工具中的崩潰以及讀取輸出時發現的明顯錯誤,當不再出現轉換崩潰之后,便開始在隨機的代碼庫子集中查看數據類型錯誤。這暴露出一些非常常見的問題,例如無效變量和復雜表達式中的類型錯誤,這些問題都不難解決:可以直接刪除無效變量,盡管在默認狀態下,保留它們的初始化器,以防表達式會產生其它副作用 - 將類似這樣的復雜表達式封裝成:(this as any).foo 。但是:這種方法變得越來越低效,所以后來我們開始改變策略。
當將整個代碼庫可靠地轉換為 TypeScript 后,便開始在整個代碼庫上試運行,并對結果進行類型檢查。我們將類型錯誤按代碼分組 (例如。“TS7030”),并統計了發生的情況。這樣就可以專心針對最常見的錯誤開發修復程序,避免浪費時間和精力了。
這是一個重大轉折點。在此之前,我們一直在不停地編寫修補程序,以修復我們決定手動測試的各個文件中不時出現的各種錯誤。即便這樣,我們還是不能確定能否得到一個成熟的工具。通過對每個錯誤代碼的出現情況進行分組和計數,我們能夠了解到還有多少工作要做,并且能夠集中精力處理發生了十幾次以上的類型錯誤。
對于那些發生頻次比較少或至少頻次少到不足以需要費力去通過工具修復的類型錯誤,我們計劃稍后再手動進行修復。有一個令人難忘的例子是,在我們更改策略之前我們發現的一個問題:ES6 類構造器在調用 super() 之前無法執行任何操作。在 CoffeeScript 類構造器中隨時調用 super() 都是合法的,因此當將它們轉換為 ES6 類似 TypeScript 會報錯。下面這種 CoffeeScript 代碼最容易出這種問題:
class?Foo?extends?Bar??constructor: (@bar,?@baz)?->????super()decaffeinate 后變成:
class?Foo?extends?Bar?{??constructor(bar, baz) {????this.bar = bar;????this.baz = baz;????super();?// illegal: must come first??}}在幾乎每個這樣的實例中,在作業之前調用 super() 都是有效的,但是需要幾分鐘讀取超類構造器以對此進行檢查。我們發現的 super() 函數的誤調用只有一兩次真正存在問題, 這種情況對于自動更新代碼庫過程中發生的錯誤來說,錯誤次數不算太多(大約有 20 多次),所以手工對它們修復的難度不是太大。將容易修復的代碼單列出來,安全地進行重新排序,對于那些較為復雜的情況,需要人工反復檢查,不值得花時間重寫。
轉換完成時,我們的類型錯誤率約為:每個轉換的文件有 0.5–1 個類型檢查錯誤,需要手動修復。
因工具提升了信心
在編寫工具的后期階段,我們更關注如何安全地部署轉換后的代碼。只對轉換后的代碼進行類型檢查是不夠的,特別是考慮到我們要自動添加很多 any。
因此,在代碼通過管道之前和之后,都會對代碼運行的所有單元測試。這樣就可以找出更多的錯誤,主要是隱藏的 CoffeeScript 錯誤代碼,轉換為 ts 后就會報錯。每當發現一個錯誤,都會在整個代碼庫中搜索類似的模式來修復它。這種辦法不行的時候,我們會在轉換工具中添加一個斷言,讓它們在遇到可疑代碼時迅速失效。
談談一個有趣的錯誤
這個錯誤是意外覆蓋了導出的函數。
CoffeeScript 與大多數語言的不同之處在于:它沒有變量陰影的概念。例如在 Javascript 中,如果你運行:
let?myVar =?"top-level";function?testMyVar()?{??let?myVar =?"shadowed";??console.log(myVar);}testMyVar();console.log(myVar);它會打印出來:
shadowedtop-level盡管它們共享相同的名稱,但在 testMyVar 中創建的 myVar 與頂級 myVar 是不同的。這在 CoffeeScript 中是不可能做到的。等效代碼如下所示:
myVar =?"top-level"testMyVar ->??myVar =?"shadowed"??console.log(myVar)testMyVar()console.log(myVar)打印出來:
shadowedshadowed在代碼中找到一個實例,如下所示:
define(()?->??sortedEntries?=?(...)?->????...????sortedEntries = entries.sortBy(getSortKey, cmpSortKey)????...??return?{????sortedEntries??}sortedEntries 被聲明為一個函數,但其自身的函數主體被一個實體數組覆蓋。第一次調用該模塊后,對模塊內部 sortedEntries 的任何調用都將失敗;但由于 sortedEntries 函數導出的是副本,因此我們從未發現此問題。該代碼翻譯為:
let?sortedEntries =?function()?{??...??sortedEntries = entries.sortBy(getSortKey, cmpSortKey)}export?{ sortedEntries };由于 TypeScript 代碼使用的是 ES6 模塊而不是 AMD 模塊,因此 sortedEntries 將作為引用而不是副本導出。這意味著當另一個模塊導入 sortedEntries 并調用它時,sortedEntries 成為了一個數組,隨后對其進行的任何調用均將無效。
遇到過一次這個錯誤后,我們在翻譯代碼中添加了一個 assert ,如果發現導出的函數被重新分配時就能解決問題。
降低從稀松模式轉換為嚴格模式的風險
在構建這些工具的過程中,我們意識到從 AMD 轉換為 ES6 模塊的副作用是:將有史以來第一次為絕大多數代碼啟用嚴格模式。
乍聽起來,這似乎很可怕;為此我們通讀了 嚴格模式的 MDN 文檔,并制作了可預期行為的更改清單,然后逐一瀏覽清單,并找出減輕它們影響的方法。
對于大多數更改,我們發現 TypeScript 解析器或類型檢查器就能處理了 -——TypeScript 會正常抱怨新的語法錯誤。有些更改則可以通過我們的代碼搜索工具輕松驗證。還有些更改則不是問題,因為 CoffeeScript 實際上在其代碼生成中并未使用有問題的結構。
關于 eval、.caller 和.callee 的更改:我們在代碼庫中很少使用 eval,在 CoffeeScript 中都沒有使用。并且我們沒有使用.caller 和.callee,因此不必擔心它們。
剩下的最后一類:只能通過運行代碼來驗證的更改。其中,與 eval 有關的更改是無關緊要的,而 arguments 很少用,很容易處理。這下需要擔心的行為更改只剩下 3 種:
1、給不可寫屬性、getter-only 屬性以及非擴展對象的屬性的分配時會報錯。向由 Object.freeze 凍結的對象寫入屬性是我們最有可能遇到的形式。
2、刪除不可刪除的屬性現在會報錯。
3、對 this 行為的更改——不再有 boxing,也不再有隱式 this=window 行為。
我們實際上無法提前知道這三個更改是否會帶來問題,但現在這份簡短的清單使我們更容易管理風險了。
還值得一提的是,代碼庫中最古老的部分是在引入 AMD 和 RequireJS 之前就以非模塊化代碼編寫的內容,其中我們最擔心的是非嚴格模式的行為可能是代碼正常運行所必需的。
我們發現可以將代碼轉換為 TypeScript,而無需將其轉換為 ES6 模塊。這樣一來便可以保持稀松模式。雖然這意味著我們在這部分代碼中基本上沒有跨模塊的類型檢查,但我們認為這是可以接受的折衷方案。
第一次轉換后的特征
我們首先對 Jasmine 測試套件開始了大規模轉換(后來我們遷移到了 Jest),這樣一來,便可以確保以后的遷移不會同時更改測試和代碼,于是更有信心不引入靜默錯誤。轉換了 Jasmine 測試之后,我們開始尋找生產代碼中第一個轉換的候選者。
在 Dropbox,我們有一種在發布功能之前進行 bug 修復的文化:QA 和團隊的許多工程師會坐在一起,嘗試手動找出功能的 bug。與 QA 和許多團隊討論之后,我們決定首先轉換內部工具和共享鏈接頁面的評論 UI。
然后開始轉換內部崩潰報告、功能 gating 和電子郵件發送工具,接著開始大批量開始轉換其余面向用戶的代碼庫。
附帶說明:因為我們最近投資采用了 Bazel 作為構建工具,并且以此工具作為我們開發和集成測試框架的基礎,所以很容易確定一個 bug 是否是由更改引起的。由于我們使用 Bazel 和自己的 itest 工具提供服務,我們可以輕松查看之前的版本,并對其運行 itest。通過在代碼的確切版本上重建和啟動 dev 服務的副本,很容易看到錯誤是否是由更改引入的。Dropbox 工程師本杰明·彼得森(Benjamin Peterson)在 2017 年 Bazel 大會上發表的關于集成測試的演講中談到了 itest 是如何運行的。
從這里開始轉換內部崩潰報告、功能門控和電子郵件發送工具,然后開始批量轉換其余面向用戶的代碼庫。
嚴謹的意義
編寫代碼轉換器時我們學到的一條經驗是:你必須嚴謹,涵蓋每個角落才行。明確指出哪些內容沒有覆蓋是非常重要的,因為錯過的任何場景都可能會出錯。如果要編寫自己的轉換工具,請參考以下提示:
- 每當你為一個 node 類型添加轉換時,請在文檔中查看需要覆蓋的所有情況。
- 如果你認為某個 node 類型不太可能出現并且不值得覆蓋,請拋出一個錯誤;這樣一來,如果它確實出現在代碼中,你就不會感到驚訝了。為此,我們高度依賴 ESTree 規范 和 ts-estree 源代碼。
- 每當你發現錯誤時,請搜索你的代碼庫以查找該錯誤模式的其他實例并修復它們。否則,你會在生產中不停遇到類似的錯誤,結果焦頭爛額。
尾聲
在項目的最后幾周,我們一次轉換大約 100-200 個文件。通過改進工具,讓這種規模的轉換可以在幾個小時的工程時間內完成。這意味著可以在一兩天內就從零開始集成到主分支中,盡量降低重新部署的開銷。大部分時間都花在類型檢查和調整上了,因為在前期驗證工作中已經解決了 Jasmine 和 Selenium 測試的大多數問題。
我們的一個技巧是在代碼庫上運行 tsc --noEmit --watch 快速迭代,這樣就可以在大約 10 秒內獲得增量類型檢查結果。之所以能這么快,部分是因為在遷移過程中從 TypeScript 2.5 升級到了 2.6,后者大幅提升了 --watch 的速度。
為了保持專注,我們還在團隊區的白板上寫上了剩余的 CoffeeScript 文件的計數,并在每次將代碼合并到 master 分支時更新數據。
轉換完最后的 CoffeeScript 之后,我們與內部客戶一起暢飲咖啡,歡送 CoffeeScript。
只有兩個錯誤
我們一開始就知道,如果引發了太多錯誤,整個項目最后都會報銷。結果,我只記得有兩個錯誤進入了生產環境。大多數潛在錯誤是在手動修復類型檢查錯誤時引入的,盡管我們的測試覆蓋率不高,但它們并沒有闖過我們 Jasmine 和 Selenium 測試的考驗。
因此,大多數團隊除了意識到他們的代碼現在是 TypeScript 之外,并沒有感到有什么變化。雖然他們需要重做一些工作,但他們很滿意新的 TypeScript 環境,因此我們沒有收到太多抱怨。
我們最后才轉換那些最擔心出問題的團隊的代碼,這樣就能用之前零錯誤的表現說服他們了。但有一個團隊還是不放心,于是我們承諾說:即便出現了重大錯誤,我們也會 24 小時快速響應并修復(只要他們告訴我們如何重現),還會在一個工作日內解決次要錯誤。
之所以做出這一承諾,是因為我們對轉換腳本充滿信心。結果他們并沒有遇到重大錯誤,唯一一個小錯誤我們也是在異常報告中發現的,在他們第二天上班之前就解決掉了。
還有一些錯誤一開始他們說是我們的轉換造成的,但最后都被我們證明來自于其他原因。
回顧
最終,自動遷移過程僅花費了大約兩個月時間,有三名工程師參與,花費了大約 19 個工程師周。當然,遷移輸出的不是大多數人最初想要的理想的 TypeScript,而是一些雜亂無章,遍布 any 的 TypeScript。
這一代價是值得的。它讓我們更快地擺脫了 CoffeeScript,這樣就不用繼續支持 CoffeeScript,也不用讓新員工學習這種語言。可以在所有地方使用 TypeScript,同時逐步改進代碼樣式和類型安全。
在整個過程中我們吸取了很多技術教訓,其中可能最重要的教訓是:應該將政治和組織資源省下來,用在不能為所有人自動化的那些任務上。盡管沒有人特別喜歡 CoffeeScript,而且有些團隊可能已經自愿將代碼轉換為 TypeScript,但讓其他人在一年時間里手動轉換到 TypeScript 的要求太不切實際了。
事后看來,我們應該盡量自動化那些重復性的勞動,遇到無法自動化,真正需要專業編程知識的問題時才去動用寶貴的人力資源。
現今
后記:快進到 2020 年,Dropbox 已經有了 200 萬幸 TypeScript 代碼。我們的整個代碼庫都是靜態類型的,并且內部有一個繁榮的 TypeScript 社區。TypeScript 使我們能夠擴展工程組織,使各個團隊可以獨立工作,同時在整個代碼庫中保持清晰的聯系。
TypeScript 這種語言已迅速普及,我們很幸運能成為最早遷移的大公司之一。因此我們得以發展這一領域的專業知識并與外界分享。我們的 JS 公會定期分享 TypeScript 的技巧和竅門,我們的工程師喜歡他們使用的語言。一位工程師甚至撰寫了一份案例研究,總結 TypeScript 不是 JavaScript 嚴格超集的那些情況。
仍然有少數文件帶有“此文件從 coffeescript 遷移過來”的注釋,但這些文件僅占代碼庫的一小部分。我們現在的代碼有良好的類型,并且一般會 push back 那些 any。最近,我們將所有代碼庫都升級到了 TypeScript 3.8。——Matthew Gerstman
推薦TypeScript知識點文章
TypeScript中的類型斷言詳解
深入淺出TypeScript在Model中的高級應用
讓人眼前一亮的 10 大 TypeScript 項目
拿6個案例講解TypeScript 知識點「干貨」
TypeScript 中的頂級類型:any 和 unknown
1500行TypeScript代碼在React中實現組件keep-alive
「TypeScript」詳解一個了不起的 tsconfig.json 指南
用TypeScript編寫React的優雅實踐「干貨」
了不起的 TypeScript 入門教程「實踐篇」
了不起的 TypeScript 入門教程「基礎篇」
TypeScript 常見問題整理(60多個)「上」
TypeScript 常見問題整理(60多個)「下」
深入TypeScript難點梳理講解
Vue3.0之前你必須知道的TypeScript實戰技巧
深入TypeScript難點梳理講解
Vue3.0之前你必須知道的TypeScript實戰技巧
你需要的 React + TypeScript 50 條規范和經驗
TypeScript詳細概括【思維導圖】
Vue3.0 嘗鮮 Hook TypeScript 取代 Vuex【項目實踐】
TypeScript詳細概括【思維導圖】
「新消息」基于JavaScript/TypeScript 編程環境Deno1.0 即將發布
「干貨」一張頁面引起的項目架構思考(rax+Typescript+hooks)
深入淺出Vue3 跟著尤雨溪學 TypeScript 之 Ref 【實踐】
作者 | David Goldstein
譯者 | 王強
策劃 | 小智
轉發鏈接:https://mp.weixin.qq.com/s/TK7kWXX4hR3e-jtpVMuBnw
總結
以上是生活随笔為你收集整理的用typescript完成倒计时_「干货」将数十万行CoffeeScript代码迁移到TypeScript的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: goland go test_Go单元测
- 下一篇: 从零开始写javaweb框架 pdf_大