將數十萬行CoffeeScript代碼遷移到TypeScript

本文最初發佈於 Dropbox 技術博客,經 Dropbox 授權由 InfoQ 中文站翻譯並分享。譯文經過了 Dropbox 團隊的審覈和修訂。

序言

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);

它會打印出來:

shadowed
top-level

儘管它們共享相同的名稱,但在 testMyVar 中創建的 myVar 與頂級 myVar 是不同的。這在 CoffeeScript 中是不可能做到的。等效代碼如下所示:

myVar = "top-level"
testMyVar ->
  myVar = "shadowed"
  console.log(myVar)
    
testMyVar()
console.log(myVar)

打印出來:

shadowed
shadowed

在代碼中找到一個實例,如下所示:

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

英文原文

The Great CoffeeScript to Typescript Migration of 2017

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章