將30K行Flow程式碼移植到TypeScript - davidgom

banq發表於2019-01-15

我們最近移植了MemSQL Studio的3萬行JavaScript,從使用Flow到TypeScript。在本文中,我描述了為什麼我們移植了程式碼庫,它是如何發生的以及它是如何為我們工作的。

免責宣告:我在這篇博文中的目標不是譴責Flow或Flow的使用。我非常欣賞這個專案,我認為JavaScript社群中有足夠的空間用於兩種型別的檢查器。在一天結束時,每個團隊都應該研究他們所有的選擇,並選擇最適合他們的選擇。我真誠地希望這篇文章能幫助你做出這樣的選擇。

讓我們從一些背景開始。在MemSQL,我們是靜態和強烈型別的忠實粉絲,這樣避免動態和弱型別的常見問題,例如:

  1. 由於程式碼的不同部分不同的隱式型別合同而導致的執行時型別錯誤。
  2. 花費太多時間來處理諸如引數型別檢查之類的微不足道的事情(執行時型別檢查也增加了包大小)。
  3. 缺乏編輯器/ IDE整合,因為在沒有靜態型別的情況下,獲取跳轉到定義,機械重構要困難得多。
  4. 能夠圍繞資料模型編寫程式碼,這意味著我們可以先設計我們的資料型別,然後我們的程式碼基本上只是“每個人自己編寫”。

這些只是靜態型別的一些優點,我在最近關於Flow的部落格文章中描述了一些。

在2016年初,我們開始使用tcomb來確保我們的一個內部JavaScript專案中的執行時型別安全性(免責宣告:我不是該專案的一部分)。雖然執行時型別檢查有時很有用,它甚至不會開始破壞靜態型別的功能。考慮到這一點,我們決定開始將Flow用於我們在2016年開始的另一個專案。當時,Flow是一個很好的選擇,因為:
  • 以Facebook為後盾,在發展React和React社群方面做得非常出色(他們還使用 Flow 開發了React )。
  • 我們沒有必要購買一個全新的JavaScript開發生態系統。刪除tsc(TypeScript編譯器)的Babel是可怕的,因為它不會給我們在將來切換到Flow或其他型別檢查器的靈活性(顯然這已經改變了)。
  • 我們沒有必要輸入我們的整個程式碼庫(我們想要在全押之前感受靜態型別的JavaScript),而是我們可以只鍵入檔案的子集。請注意,Flow和TypeScript現在允許您執行此操作。
  • TypeScript(當時)缺少Flow已經支援的一些基本功能,例如查詢型別通用引數預設值等。

當我們在2017年末開始使用MemSQL Studio時,我們開始在整個應用程式中實現完整的型別覆蓋(所有這些都是用JavaScript編寫的,前端和後端都在瀏覽器中執行)。我們決定使用Flow作為我們過去成功使用的東西。
但是,使用TypeScript支援釋出的Babel 7肯定引起了我的注意。此版本意味著採用TypeScript不再意味著購買整個TypeScript生態系統,我們可以繼續使用Babel釋出JavaScript。更重要的是,這意味著我們實際上可以使用TypeScript作為型別檢查器,而不僅僅是“語言”本身。

就個人而言,我認為將型別檢查器與發射器emitter分離是在JavaScript中實現靜態(和強)型別的更優雅方式,因為:
  1. 在釋出ES5和型別檢查的內容之間進行一些關注點分離是一個好主意。這樣可以減少型別檢查器周圍的鎖定,並加快開發速度(如果型別檢查器因任何原因而變慢,則程式碼將以正確的方式發射)。
  2. Babel擁有令人驚歎的外掛和TypeScript的發射器所沒有的強大功能。例如,Babel允許您指定要支援的瀏覽器,它將自動發出在這些瀏覽器上有效的程式碼。這實現起來非常複雜,只有讓Babel實現它而不是在兩個不同的專案中在社群中重複這種努力更有意義。
  3. 我喜歡JavaScript作為一種程式語言(除了缺少靜態型別),我不知道TypeScript會持續多長時間,而我相信ECMAScript會存在很長一段時間。出於這個原因,我更喜歡在JavaScript中繼續寫作和思考。(請注意,我總是說“使用Flow”或“使用TypeScript”而不是“in Flow”或“in TypeScript”,因為我總是將它們視為工具而不是程式語言)。

當然,這種方法有一些缺點:
  1. 理論上,TypeScript編譯器可以根據型別執行包最佳化,並且透過使用單獨的發射器和型別檢查器而缺少它。
  2. 當您擁有更多工具和開發依賴項時,專案配置會變得有點複雜。我認為這是一個比大多數人所做的更弱的論點,因為Babel + Flow在我們的專案中從來都不是配置問題的來源。

研究TypeScript作為Flow的替代方案
我一直注意到線上和本地JavaScript社群對TypeScript越來越感興趣。因此,當我第一次發現Babel 7支援TypeScript時,我開始研究是否可能會遠離Flow。最重要的是,我們遇到了Flow的各種挫敗:

  1. 較低質量的編輯器/ IDE整合(與TypeScript相比)。不推薦使用 Nuclide(Facebook擁有最好的Flow整合的IDE)並沒有幫助。
  2. 較小的社群,因此各種庫包的質量型別定義較少且總體較低(稍後將詳細介紹)。
  3. Facebook和社群的Flow團隊之間缺乏公共路線圖和很少的互動。您可以透過Facebook員工閱讀此評論以獲取更多詳細資訊。
  4. 高記憶體消耗和頻繁的記憶體洩漏 - 我們團隊中的各種工程師經歷了Flow,它偶爾會佔用近10 GB的RAM。

當然,我們還必須研究TypeScript是否適合我們。這非常複雜,但它涉及到對文件的全面閱讀,這有助於我們弄清楚Flow中的每個功能在TypeScript中都具有相同的功能。然後,我研究了TypeScript公共路線圖,並對前面提到的功能非常滿意(例如,部分型別引數推斷是我們在Flow中使用的一個特性)。

將30K +程式碼行從Flow移植到TypeScript
實際將所有程式碼從使用Flow移植到TypeScript的第一步是將Babel從6升級到7.這有點簡單但是我們花了大約2個工程師時間,因為我們決定同時將Webpack 3升級到4 。由於我們的原始碼中存在一些遺留的依賴項,因此對於絕大多數JavaScript專案來說,這應該比它應該更難。
完成此操作後,我們可以使用新的TypeScript預設替換Babel的Flow預設,然後針對使用Flow編寫的完整原始碼首次執行TypeScript編譯器。它導致8245個語法錯誤(在您有0個語法錯誤之前,tsc CLI不會為整個專案提供真正的錯誤)。
這個數字起初嚇到了我們(很多),但我們很快發現其中大部分都與TypeScript不支援.js檔案有關。經過一番調查,我發現TypeScript檔案必須以“.ts”或“.tsx”結尾(如果它們中有JSX)。我不想考慮我正在建立的新檔案是否應該具有“.ts”或“.tsx”副檔名,我認為這是一個糟糕的開發人員體驗。出於這個原因,我只是將每個單元重新命名為“.tsx”(理想情況下,我們所有的檔案都會像Flow一樣具有“.js”副檔名,但我也可以使用“.ts”)。
在更改之後,我們有大約4000個語法錯誤。它們中的大多數與匯入型別有關,可以使用替換為TypeScript的“import”,也可以使用Flow({||}vs {})中的密封物件表示法替換。在幾個快速的RegExes之後,我們發現了414個語法錯誤。其餘的都必須手動修復:

  • 我們用於部分泛型型別引數推斷的存在型別必須替換為顯式命名各種型別引數或使用未知型別告訴TypeScript我們不關心某些型別引數。
  • $Keys 型和其他先進的流量型別有不同的語法在打字稿(例如$Shape<>對應於TypeScript的Partial<>)。

修復了所有語法錯誤之後,tsc(TypeScript編譯器)最終告訴我們程式碼庫有多少實際型別錯誤 - 大概只有1300左右。這時我們不得不坐下來決定繼續執行是否合理。畢竟,如果我們花費數週的開發時間,那麼繼續使用該埠是不值得的。但是,我們認為單個工程師停擺的時間不應超過1周,因此我們需要加快速度提前。
請注意,在轉換期間,我們不得不停止此程式碼庫上的其他工作。但是,應該可以為這樣的停擺並行地貢獻新的工作 - 但是你必須處理潛在的數百種型別錯誤,這不是一件容易的事。

這些型別的錯誤是什麼?​​​​​​​
TypeScript和Flow對許多不同的東西做出了不同的假設,這在實踐中意味著它們讓你的JavaScript程式碼做不同的事情。Flow對某些事情更嚴格,TypeScript對其他事情更嚴格。兩種型別檢查器之間的全面深入比較會非常長,所以在這篇博文中我們只研究一些例子。
注意:本文中的所有TypeScript playground連結都假設所有“嚴格”設定都已開啟,但遺憾的是,當您共享TypeScript playground連結時,這些設定不會儲存在URL中。因此,您必須在開啟本文中的任何TypeScript playground連結時手動設定它們。

invariant.js
我們的原始碼中一個非常常見的函式是invariant函式,看文件更好,所以我在這裡引用它:

var invariant = require('invariant');

invariant(someTruthyVal, 'This will not throw');
// No errors

invariant(someFalseyVal, 'This will throw an error with this message');
// Error raised: Invariant Violation: This will throw an error with this message


這個想法很簡單 - 一個簡單的函式,可能會根據某些條件丟擲錯誤。讓我們看看我們如何實現它並將其與Flow一起使用

type Maybe<T> = T | void;

function invariant(condition: boolean, message: string) {
  if (!condition) {
    throw new Error(message);
  }
}

function f(x: Maybe<number>, c: number) {
  if (c > 0) {
    invariant(x !== undefined, "When c is positive, x should never be undefined");

    (x + 1); // works because x has been refined to "number"
  }
}


現在讓我們透過TypeScript執行完全相同的程式碼片段。正如您在連結中看到的那樣,我們從TypeScript中收到錯誤,因為最後一行它無法確定“x”實際上保證不是undefined。
這實際上是TypeScript的一個已知問題 - 它無法透過函式執行此類推理(尚未)。但是,由於它是我們程式碼庫中非常常見的模式,我們不得不用更多的手動程式碼替換每個不變數的例項(超過150個),這些程式碼只會在現場丟擲錯誤:

type Maybe<T> = T | void;

function f(x: Maybe<number>, c: number) {
  if (c > 0) {
    if (x === undefined) {
      throw new Error("When c is positive, x should never be undefined");
    }

    (x + 1); // works because x has been refined to "number"
  }
}

這不是很好,但invariant它也不是一個大問題。

$ExpectError vs @ts-ignore
Flow有一個非常有趣的功能@ts-ignore,如果下一行不是錯誤它將會出錯。這對於編寫“型別測試”非常有用,這些測試確保我們的型別檢查器(無論是TypeScript還是Flow)找到我們希望它找到的某些型別錯誤。
不幸的是,TypeScript沒有這個功能,這意味著我們的型別測試失去了一些價值。這是我期待 TypeScript實現的東西。

一般型別錯誤和型別推斷​​​​​​​
通常,TypeScript可以比Flow更明確,如下例所示

type Leaf = {
  host: string;
  port: number;
  type: "LEAF";
};

type Aggregator = {
  host: string;
  port: number;
  type: "AGGREGATOR";
}

type MemsqlNode = Leaf | Aggregator;

function f(leaves: Array<Leaf>, aggregators: Array<Aggregator>): Array<MemsqlNode> {
  // The next line errors because you cannot concat aggregators to leaves.
  return leaves.concat(aggregators);
}


Flow 將leaves.concat(aggregators)的型別推斷為Array<Leaf | Aggregator> ,我認為這是一個很好的例子,有時Flow有點聰明,而TypeScript有時需要一些幫助(在這種情況下我們可以使用型別斷言來幫助TypeScript,但是使用型別斷言是危險的,應該做得很好小心)。
即使沒有我說的這一點證據,我認為Flow在型別推斷方面比TypeScript更優越。我非常希望TypeScript能夠達到Flow的水平,因為它是非常積極的開發,並且最近對TypeScript的許多改進都在這個確切的領域。在我們的原始碼的許多部分中,我們必須透過註釋或型別斷言給TypeScript一些幫助(我們儘可能避免使用型別斷言)。讓我們再看一個例子(我們可能有超過200個這種型別錯誤的例項):

type Player = {
    name: string;
    age: number;
    position: "STRIKER" | "GOALKEEPER",
};

type F = () => Promise<Array<Player>>;

const f1: F = () => {
    return Promise.all([
        {
            name: "David Gomes",
            age: 23,
            position: "GOALKEEPER",
        }, {
            name: "Cristiano Ronaldo",
            age: 33,
            position: "STRIKER",
        }
    ]);
};

TypeScript不會讓你寫這個,因為它不能讓你將{ name: "David Gomes", age: 23, type: "GOALKEEPER" }轉換到作為Player型別的物件(開啟Playground連結以檢視確切的錯誤)。這是另一個我認為TypeScript不夠“足夠智慧”的例子(至少與理解這段程式碼的 Flow相比)。
為了使這項工作,你有幾個選擇:
  • 將“STRIKER”斷言為“STRIKER”,以便TypeScript理解該字串是型別的有效列舉"STRIKER" | "GOALKEEPER"。
  • 斷言整個物件as Player。
  • 或者我認為是最好的解決方案,只需透過編寫幫助TypeScript而不使用任何型別的斷言Promise.all<Player>(...)。

另一個例子是以下(TypeScript),其中Flow再次出現,因為它具有更好的型別推斷

type Connection = { id: number };

declare function getConnection(): Connection;

function resolveConnection() {
  return new Promise(resolve => {
    return resolve(getConnection());
  })
}

resolveConnection().then(conn => {
  // TypeScript errors in the next line because it does not understand
  // that conn is of type Connection. We have to manually annotate
  // resolveConnection as Promise<Connection>.
  (conn.id);
});

一個非常小但但有趣的例子是Flow型別Array<T>.pop()作為T,而TypeScript 認為它是T|void,這是支援TypeScript的一個要點,因為它會強制您仔細檢查該項是否存在(如果該陣列為空,則Array.pop返回undefined)。還有一些像這樣的小例子,其中TypeScript比Flow更勝一籌。

TypeScript定義用於第三方依賴項
當然,在編寫任何JavaScript應用程式時,您可能至少有少數依賴項。這些都需要輸入,否則你將失去靜態型別分析的大部分功能(如本文開頭所述)。
從npm匯入的庫可以附帶Flow型別定義,TypeScript型別定義,包含這兩者或都不包含兩者。很常見的是(較小的)庫沒有任何含義,你必須為它們編寫自己的型別定義或者從社群中獲取一些。Flow和TypeScript社群都有一個標準的JavaScript包第三方型別定義儲存庫:flow-typedDefinitelyTyped
我不得不說我們用DefinitelyTyped度過了更好的時光。使用flow-typed,我們必須使用其CLI工具將各種依賴項的型別定義引入到專案中。DefinitelyTyped透過在npm的軟體包儲存庫中傳送@ types / package-name軟體包,找到了將此功能與npm的CLI工具合併的方法。這是驚人的,它使我們更容易為我們的依賴項引入型別定義(jest,react,lodash,react-redux僅舉幾例)。
除此之外,我還有很多時間貢獻給DefinitelyTyped(當將程式碼從Flow移植到TypeScript時,不要指望型別定義是等價的)。我已經發出 一些pull請求和所有這些請求都是輕而易舉的。只需克隆,編輯型別定義,新增測試併傳送拉取請求。
DefinitelyTyped GitHub bot將標記為您為評論編輯的型別,這樣可定義那些做出貢獻的人員。如果他們都沒有在7天內提供評論,那麼DefinitelyTyped維護者將稽核PR。合併到master後,依賴項包的新版本將傳送到npm。例如,當我第一次更新@ types / redux-form包時,版本7.4.14在合併到master後自動被推送到npm。這使我們可以非常輕鬆地更新我們的package.json檔案以獲取新的型別定義。如果您不能等待PR被接受,您可以隨時覆蓋專案中使用的型別定義,正如我在最近的部落格文章中所解釋的那樣
總體而言,DefinitelyTyped中的型別定義的質量要好得多,因為TypeScript背後的社群更大,更繁榮。事實上,在將我們的專案從Flow移植到TypeScript後,我們的型別覆蓋率從88%增加到96%,主要是因為更好的第三方依賴型別定義,其中包含更少的any型別。

Linting和測試

  1. 我們從eslint轉移tslint(我們發現開始使用TypeScript的eslint比較複雜,所以我們只使用了tslint)。
  2. 我們使用ts-jest來執行使用TypeScript的測試。我們的一些測試是打字的,而其他測試是無型別的(當輸入測試的工作太多時,我們將它們儲存為.js檔案)。

我們修復了所有型別錯誤後發生了什麼?
經過一個工程師一週的工作後,我們得到了最後一個型別錯誤,我們在短期內推遲了@ts-ignore。
在解決了一些程式碼審查註釋並修復了一些錯誤之後(不幸的是,我們不得不改變很少的執行時程式碼來修復TypeScript無法理解的邏輯),PR登陸以後,從那時起我們就一直在使用TypeScript。
除了編輯器整合之外,使用TypeScript與使用Flow非常相似。Flow的伺服器的效能稍微快一點,但這並不是一個大問題,因為它們同樣快速地為你正在檢視的檔案提供內聯錯誤。唯一的效能差異是TypeScript需要更長的時間(約0.5到1秒)來告訴您儲存檔案後專案中是否有任何新錯誤。伺服器啟動時間大約相同(約2分鐘),但這並不重要。到目前為止,我們沒有任何記憶體消耗問題,並且tsc似乎一直使用大約600兆位元組的RAM。
可能看起來Flow的型別推斷使它比TypeScript好得多,但有兩個原因可以解釋為什麼這不是什麼大問題:

  1. 我們是從Flow轉換到TypeScript的。這意味著我們顯然只會發現Flow可以表達的東西但TypeScript不能(反過來呢?)。我相信我們會發現TypeScript可以在推斷/表達比Flow更好的東西。
  2. 型別推斷很重要,它有助於保持我們的程式碼更簡潔。然而,在一天結束時,像強大的社群和型別定義的可用性之類的東西更重要,因為弱型別推斷可以透過更多地“手持”型別檢查器來解決。

程式碼覆蓋率統計:
$ npm run type-coverage # https://github.com/plantain-00/type-coverage 43330 / 45047 96.19%
 

相關文章