如何將三萬行程式碼從Flow移植到TypeScript?

AI科技大本營發表於2019-01-29

640?wx_fmt=jpeg


作者 | David Gomes

譯者 | 彎月
責編 | 郭芮
來源 |  CSDN(ID:CSDNnews)

【編者按】在記憶體安全中,型別安全是很重要的一個命題。為了確保JavaScript專案執行的型別安全,本文的作者介紹了2016年時使用Flow的經歷:由Facebook支援的Flow方案,不僅擁有查詢型別、泛型引數預設值等基本功能,還有著較為完善的JavaScript開發生態系統。但是隨著專案的不斷複雜,以及TypeScript功能的逐漸優化,就對專案提出了更多的要求。本文就詳解了將三萬行程式碼從Flow移植到TypeScript的全過程。


以下為譯文:

最近我們將MemSQL Studio的3萬行JavaScript程式碼從Flow移植到了TypeScript。在本文中,我將介紹我們移植程式碼庫的原因以及移植的全過程。


事先宣告,我寫這篇文章的目的不在於譴責Flow或Flow的使用。我非常欣賞Flow這個專案,我認為在JavaScript社群中Flow和TypeScript這兩種型別檢查器都有足夠的發展空間。但是,每個團隊都需要仔細研究並選擇最適合自己的。因此我真誠地希望這篇文章對你的選擇能有所幫助。


背景


首先介紹一下背景故事。在MemSQL,我們都喜歡靜態強型別的JavaScript程式碼,這是為了避免動態弱型別的常見問題。例如:


  • 動態弱型別問題中不同部分的程式碼對於隱式型別契約的假設不一致,會引發執行時的型別錯誤;

  • 而且動態弱型別在測試小問題上花費的時間太多,比如引數型別檢查(執行時型別檢查也會增大打包檔案的尺寸);

  • 此外動態弱型別還缺乏編輯器/ IDE整合,因為在沒有靜態型別的情況下,很難實現跳轉定義、自動重構以及其他功能;

  • 靜態強型別還具有動態弱型別問題所缺失的圍繞資料寫程式碼的能力,這意味著我們可以先設計資料型別,然後我們的程式碼就會“自然成型”。


這些還只是靜態型別部分的優點。


2016年初,我們開始在一個內部的JavaScript專案上使用tcomb,以確保執行時的型別安全(宣告:我並沒有參與那個專案)。雖然有時執行時的型別檢查很有用,但它與靜態型別毫不沾邊。考慮到這一點,2016年的時候我們決定在另一個專案中使用Flow。當時,Flow是一個很好的選擇,因為:


  • Flow由Facebook支援,在日益壯大的React社群中收到了相當多的好評(React也是用Flow開發的);

  • 我們沒有必要嘗試一個全新的JavaScript開發生態系統,拋棄Babel轉向tsc(TypeScript編譯器)的風險會很大,還會失去切換到Flow或其他型別檢查器的靈活性(顯然後來情況發生了變化);

  • 我們也沒有必要在整個程式碼庫上採用型別(因為我們想先嚐試一下靜態型別的JavaScript),我們只想在部分檔案上採用型別(不過請注意,現在的Flow和TypeScript都允許開發者這麼做);

  • 當時的TypeScript缺少Flow支援的一些基本功能,例如查詢型別、泛型引數預設值等。


當2017年年末開始開發MemSQL Studio時,我們準備在整個應用程式中實現完整的型別覆蓋(整個應用程式都是用JavaScript編寫的,前端和後端都在瀏覽器中執行)。因為以前成功使用的經驗,所以我們決定此次也使用Flow。


然而,最新發布的Babel 7已經開始支援TypeScript了,這引起了我的注意。這個釋出意味著採用TypeScript不再需要引入整個TypeScript生態系統,我們可以繼續通過Babel來生成JavaScript。更重要的是,這意味著實際上我們可以將TypeScript作為型別檢查器,而不是作為一種“語言”。


就個人而言,我認為將型別檢查與JavaScript的生成分離是在JavaScript中實現靜態(強)型別的更優雅的方式,因為:


  • 將生成ES5和型別檢查從思想上進行某種程度的分離是一個好主意。如此一來可以減少型別檢查鎖定的範圍,並加快開發速度(即使型別檢查因某些原因而變慢,你的程式碼生成也不會受到影響)。

  • Babel擁有一些非常優秀的外掛和了不起的功能,這些都是TypeScript的生成器所沒有的。例如,Babel允許開發者指定想要支援的瀏覽器,它將自動生成在這些瀏覽器上有效的程式碼。不過這實現起來非常複雜,因此應當讓Babel做這一切,而不是讓社群在兩個不同專案上重複這種努力。

  • 我喜歡JavaScript這種程式語言(除了它缺少靜態型別),我不知道TypeScript會最終存活多久,但我相信ECMAScript會長期存在。出於這個原因,我更喜歡用JavaScript思考和寫程式碼。


注意,我一直在說“使用Flow”或“使用TypeScript”,是因為我總是把它們當成工具,而非程式語言。


當然,這種方法也有一些缺點:


  • 理論上,TypeScript編譯器可以根據型別執行優化,但如果將生成與型別檢查分離就失去這個優勢了;

  • 如果需要依賴很多工具和開發,那麼專案配置會變得稍複雜。不過我認為這個不足為慮,因為在我們的專案中Babel + Flow從來都沒出現過配置的問題。


TypeScript 能替代 Flow 方案嗎?


我注意到網上和本地JavaScript社群對TypeScript的興趣越來越濃厚。因此,當發現Babel 7支援TypeScript時,我就開始調查代替Flow的可能性。最重要的是,在使用Flow的時候我們遇到了很多挫折:


  • 編輯器/ IDE整合的質量很低(與TypeScript相比)。Nuclide(Facebook自己的IDE,擁有最好的Flow整合)已經不再維護,所以沒什麼用了。

  • 社群很小。各種程式碼庫數量較少,且總體的型別定義質量較低。

  • Facebook和社群的Flow團隊之間缺乏公共的規劃,且互動很少。

  • 記憶體消耗很高且記憶體洩漏頻繁,我們團隊的工程師偶爾會經歷Flow佔用10GB的RAM的現象。


當然,我們還必須研究TypeScript是否合適我們。調查的過程非常複雜,但通過全面地閱讀文件,我們發現Flow的每個功能在TypeScript中都有相應的支援。之後,我又研究了TypeScript的專案規劃,發現上面提到的功能都有非常滿意的支援(例如,我們在Flow中使用的一個部分型別引數推斷的功能)。


將三萬多行程式碼從 Flow 移植到 TypeScript


實際上,將所有程式碼從Flow移植到TypeScript的第一步是將Babel從6升級到7。這項工作看似簡單,但由於我們決定將Webpack 3升級到4,所以最後花了兩天的時間。由於我們的程式碼中有一些遺留的依賴,所以此次的難度要比絕大多數JavaScript專案都高。


完成這一步後,我們就可以用新的TypeScript預設替換Babel的Flow預設,然後在用Flow編寫的完整原始碼上執行TypeScript編譯器——結果發生了8245個語法錯誤(只有在沒有語法錯誤的情況下tsc的命令列工具才會報告專案中的真正的錯誤)。


我們被這個數字嚇到了,但是很快我們就發現其中大部分是由於TypeScript不支援.js檔案導致的。經過一番調查,我發現TypeScript檔案必須以“.ts”或“.tsx”結尾(包含JSX的情況)。我不想在建立新檔案的時候猶豫是應該使用“.ts”還是“.tsx”的副檔名,因為這是一種糟糕的開發體驗。所以,我決定將所有檔案都重新命名為“.tsx”(理想情況下,應當像Flow一樣所有的檔案都具有“.js”副檔名,但我也可以接受使用“.ts”)。


經過這次修改後,我們有大約4000個語法錯誤。其中大多數都與匯入型別有關,我們可以TypeScript的“import”替換,也可以使用Flow({||} vs {})中的密封物件表示法替換。在使用了幾個正規表示式替換之後,我們的語法錯誤數量降到了414個。剩下的部分只能手動修復了:


  • 部分泛型型別引數推斷中使用的既存型別必須替換為顯式命名的各種型別的引數,或通過unknown型別告訴TypeScript我們並不關心某些型別的引數;

  • $Keys型別和其他Flow高階型別在TypeScript中具有不同的語法,例如,$Shape <>與TypeScript中的Partial<>對應)。


修復了所有語法錯誤之後,tsc(TypeScript編譯器)終於告訴我們,程式碼庫中大約有1300個真正的型別錯誤。這時我們不得不坐下商量是否還應該繼續,畢竟,如果要花費數週的開發時間,此次移植就得不償失了。但是,我們發現只需花費不到1周的時間就可以完成移植,所以我們決定繼續。


注意,在轉換期間,我們必須停止程式碼庫的開發工作。當然,在移植期間依然可以繼續開發新程式碼,但是你必須在可能有數百種之多的型別錯誤上進行工作,這不是一件易事。


都有哪些型別錯誤?


在很多方面,TypeScript和Flow都做出了不同的假設,在實踐中這意味著JavaScript程式碼的行為會有所不同。在某些方面Flow更嚴格,而TypeScript在其他方面又更為嚴格。深入比較兩種型別檢查會花費大量時間,所以在本文中我只舉幾個例子。


注意:本文中所有的TypeScript練習環境(http://www.typescriptlang.org/play/)的連結都假設所有的“嚴格”設定都被開啟了,但遺憾的是在分享TypeScript練習環境時,這些設定都不會儲存到URL中。因此,可以點選上面的連線開啟TypeScript練習環境之後再手動設定。


invariant.js


我們的原始碼中有一個很常用的函式invariant,這個文件(https://github.com/zertosh/invariant#invariantcondition-message)很好地解釋了它的功能:


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的問題——它無法在函式中進行這類的推理。但是,由於這樣的程式碼在我們程式碼庫中很常見,所以我們就被迫手動替換每一個invariant的例項(有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> ,然後將其轉換為Array<MemsqlNode>。我認為這是一個很好的例子,說明有的地方Flow很聰明,而TypeScript可能需要一點幫助(在這種情況下,我們可以用型別斷言來幫助TypeScript,但是型別斷言的使用很危險,請小心謹慎)。


儘管沒有正式的證據,但是我還是想說我認為在型別推斷方面Flow比TypeScript更優越。我非常希望TypeScript能夠向Flow看齊, 因為TypeScript正處於非常積極的開發中,並且最近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",
            age23,
            position"GOALKEEPER",
        }, {
            name"Cristiano Ronaldo",
            age33,
            position"STRIKER",
        }
    ]);
};


在TypeScript你不能這樣寫,因為它不允許你將{ name: "David Gomes", age: 23, type: "GOALKEEPER" }當作Player型別的物件(開啟練習環境可以看到確切的錯誤)。這是另一個我覺得TypeScript“不夠聰明”的地方——至少與Flow相比不夠聰明。


為了修正這個錯誤,開發者有幾個選擇:


  • 斷言"STRIKER"為"STRIKER",這樣TypeScript就可以理解該字串是個有效的列舉型別"STRIKER" | "GOALKEEPER";

  • 斷言整個物件為“Player”(as Player);

  • 或者(我認為的最佳解決方案)無需任何型別的斷言,只需寫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對於第三方依賴的定義


當然,在編寫任何JavaScript應用程式時都有可能會有一些依賴。這些依賴都需要型別定義,否則開發者就失去了靜態型別分析的大部分威力(如本文開頭所述)。


從npm匯入的庫可以附帶Flow型別定義或TypeScript型別定義,也可以兩者兼有或兩者都沒有。許多小型庫不帶有任何方式的型別,所以必須自己編寫型別定義,或從社群中找。Flow和TypeScript社群都有一個標準的JavaScript包的第三方型別定義程式碼倉庫:flow-typed和DefinitelyTyped。


我不得不說使用DefinitelyTyped的體驗更好。在使用flow-typed的時候,我們必須通過它的命令列工具將各種依賴的型別定義引入到專案中。DefinitelyTyped找到了一個很好的方法與npm的命令列整合,即它的軟體包均以@types/package-name的方式命名。這一點非常了不起,有了它我們就可以很容易地為依賴引入型別定義了(jest、react、lodash、react-redux等等)。


除此之外,我花了大量時間向DefinitelyTyped貢獻程式碼(當將程式碼從Flow移植到TypeScript時,不要指望型別定義是等價的)。我已經傳送了幾個拉取請求,所有工作都易如反掌。開發者只需要克隆、編輯型別定義、新增測試,然後傳送拉取請求,DefinitelyTyped GitHub會將曾向這個型別定義貢獻過程式碼的人標記為稽核者。如果7日之內沒有人稽核程式碼,那麼DefinitelyTyped的維護者會稽核PR。在合併到master分支後,新版本的依賴包將會傳送到npm。例如,當我第一次更新@types/redux-form包時,在合併到master分支後版本7.4.14自動被推送到了npm。我們可以非常容易地更新package.json檔案,就可以獲取新的型別定義。如果等不到PR被接受,那麼也可以隨時覆蓋專案中使用的型別定義。


總的來說,DefinitelyTyped中型別定義的質量更好,這要歸功於TypeScript背後的社群更大、更繁榮。事實上,在將我們的專案從Flow移植到TypeScript之後,我們的型別覆蓋率從88%提高到了96%,主要是由於更好的第三方依賴型別定義,“any”型別的依賴減少了。


Linting與測試


在移植過程中,我們發現使用TypeScript的eslint比較複雜,所以我們就選擇了tslint,從eslint轉移到了tslint。


此外,我們還使用ts-jest來執行TypeScript的測試。有些測試是有型別的,而有些是無型別的(如果給測試用例新增型別的工作量太大,我們就將它們儲存成.js檔案)。


修復了所有型別錯誤後,情況怎樣了?


經過歷時一週的修復工作後,我們遇到了最後一個型別錯誤,我們決定利用@ts-ignore將其暫且擱置。


在解決了一些程式碼審查註釋並修復了一些錯誤之後(不幸的是,我們不得不修改少量執行時來修復TypeScript無法理解的邏輯),在這個PR被合併後,我們就開始使用TypeScript了。(還有,我們在後續的PR中修復了最後一個@ts-ignore)。


除了編輯器整合之外,TypeScript的使用體驗與Flow非常相似。Flow伺服器的效能稍微快一點,但這並不是一個大問題,因為在為你正在檢視的檔案提供內聯錯誤時它們一樣快。唯一的效能差異在於TypeScript需要更長的時間(約0.5到1秒)才能告訴你在儲存某個檔案後,專案中是否有新的錯誤。伺服器啟動時間大約相同(約2分鐘),但這並不重要。到目前為止,我們還沒遇到過記憶體消耗的問題,tsc使用的記憶體一直穩定在大約600Mb。


可能看起來Flow的型別推斷比TypeScript更好,但是兩個原因可以解釋為什麼這不是什麼大問題:


  • 我們將程式碼庫從Flow移植到了TypeScript,這意味著我們在其中發現了Flow可以表達但TypeScript卻不能表達的地方。如果這次移植是從TypeScript到Flow的,那麼我們可能就會發現TypeScript的推斷/表達比Flow更好。

  • 型別推斷很重要,它有助於保持我們的程式碼更簡潔。但是強大的社群、型別定義的可用性等更為重要,因為弱型別推斷只需要加強下型別檢查就可以解決。


程式碼統計


$ npm run type-coverage # https://github.com/plantain-00/type-coverage
43330 / 45047 96.19%

$ cloc # ignoring tests and dependencies
--------------------------------------------------------------------------------
Language                      files          blank        comment           code
--------------------------------------------------------------------------------
TypeScript                      330           5179           1405          31463


下一步計劃?


雖然移植完成了,但是程式碼中的靜態型別分析還沒有完成。


MemSQL還有其他專案也打算棄用Flow、轉而投入TypeScript的懷抱(有些JavaScript專案可能一開始就使用TypeScript),所以我們希望使我們的TypeScript配置更加嚴格。


目前我們已經開啟了“strictNullChecks”,但“noImplicitAny”仍處於禁用狀態,這也需要後續解決。


此外,我們還打算刪除程式碼中的一些危險的型別斷言。


原文連結:https://davidgom.es/porting-30k-lines-of-code-from-flow-to-typescript/


(本文為 AI大本營轉載文章,轉載請聯絡作者。)


公開課預告

今晚8點直播


如何用AI技術為黑白老照片上色?本次公開課中,百度高階研發工程師李超將講述對抗生成網路相關,學術界的研究現狀和應用場景,以及GAN在百度視覺+百度PR+新華社合作的煥彩專案中的應用。


640?wx_fmt=png


推薦閱讀

640?wx_fmt=png

相關文章