從React遷移到TypeScript:忍受了15年的JavaScript錯誤從此走遠

本文最初發佈於 executeprogram 網站,經網站授權由 InfoQ 中文站翻譯並分享。這篇譯文是第三方翻譯版本,未經原文作者審覈。

Beta 版的 Execute Program 是用 Ruby 和 JavaScript 編寫的。之後,我們分幾步將整個應用完全移植到了 TypeScript 上。本文介紹的是移植的第一步,也就是前端的部分。

在 Execute Program 的原始 JavaScript 前端中,我經常會犯一些小錯誤。例如,我會將錯誤的 prop 名稱傳遞給 React 組件,或者遺漏某個 prop,抑或傳遞錯誤的數據類型。(Prop 是作爲參數發送到 React 組件的數據。組件將一些 props 傳遞給自己的某個子組件,以此類推,這是很常見的。)

對於像 JavaScript 和 Ruby 這樣的動態語言來說,這不是個小問題。過去 15 年來我一直在學習該如何應對這種錯誤問題。我在之前談論的是 2011 年代的情況,其中討論的緩解措施確實有些用途,但它們無法隨着系統的發展順利地擴展下去,而且我們忘掉它們時也沒有安全網可用。

我覺得 15 年時間已經夠長了。我想回到靜態類型系統的懷抱,畢竟這種系統中根本不會出現這類錯誤。彼時我們有幾個選項:Elm、Reason、Flow、TypeScript 和 PureScript。(這裏舉了一部分例子。)最後我決定使用 TypeScript 是因爲:

  1. TypeScript 是 JavaScript 的超集,因此移植起來很容易。移植回來也更容易些:只需刪除類型定義即可,然後我們又回到了 JavaScript。

  2. TypeScript 編譯器使用 TypeScript 編寫,並作爲已編譯的 JavaScript 代碼分發,因此我們可以在自己的 Web 應用中運行它。我們的 TypeScript 課程正是這樣做的:在瀏覽器中評估用戶的 TypeScript 代碼,以避免網絡延遲。

  3. 這一條是我們的業務特有的:TypeScript 比其他選項更受歡迎。這意味着更多的人希望從像我們這樣的課程中學習 TypeScript。用 TypeScript 編寫 Execute Program,讓我們可以製作出更好的 TypeScript 課程。

在 2018 年 10 月,我們用了大約兩天時間將前端 JavaScript 代碼移植到了 TypeScript。下面的圖表顯示了在移植前和移植後每種語言擁有的代碼量。

當時我們還是 pre-beta 版本,所以系統還很小,只有大約 6,000 行。這張圖上沒有涉及移植後的情況;我們將在以後的文章中具體介紹相關內容。

在這次移植之後,React prop 問題消失了。下面我們會看幾個示例,首先是一個簡單的例子。以下是渲染“Continue”按鈕的代碼,這個按鈕出現在我們課程的每個文本段落之後:

<Button
  autofocus={true}
  icon="arrowRight"
  onClick={continue}
  primary
>
  Continue
</Button>

這個 Button 組件的 props 的類型如下所示。當讀取諸如 autofocus?: boolean 之類的屬性類型時:“autofocus”是屬性的名稱;“?”表示它是可選的;“:”將屬性名稱與其類型分開;而“boolean”是類型。最後一個屬性類型 onClick 表示“一個不帶參數且不返回任何內容的函數”。如果你不熟悉 TypeScript 的函數類型語法,可以在我們的課程中全面瞭解 TypeScript 的函數類型。

type ButtonProps = {
  autofocus?: boolean
  icon?: IconName
  primary?: boolean
  onClick: () => void
}

如果將“autofocus”prop 從 true 更改爲 1,會發生什麼?現在,我們在類型系統期望一個布爾值的地方傳遞了一個數字值。不到一秒鐘後,編譯器將在下面顯示錯誤。(這裏刪除了一些不相關的細節;本系列文章中所有涉及到錯誤的地方都會這樣處理。)

src/client/components/explanation.tsx(13,27):
  error: Type 'number' is not assignable to type 'boolean | undefined'.

有害代碼在 vim 中也變成了紅色。修好它後,紅色消失了。解決錯誤只需要幾秒鐘。在 Ruby 或 JavaScript 中,我可能會花幾分鐘的時間手動測試應用程序,並反覆瀏覽它的狀態才能知道到底發生了什麼事情。我也可以依靠自動化測試,但是我們在另一篇文章中介紹了測試 vs 類型的問題。

這個整數到布爾的更改是對類型系統的一次簡單而低風險的測試。Button 的 icon 屬性顯示了更高級的用法。下面還是 Button 調用:

<Button
  autofocus={true}
  icon="arrowRight"
  onClick={continue}
  primary
>
  Continue
</Button>

看起來 icon prop 只是一個字符串:“arrowRight”。在運行時,在已編譯的 JavaScript 代碼中,它將是一個字符串。但是在上面顯示的 ButtonProps 類型中,我們將其定義爲 IconName,後者是在其他地方定義的。在查看其定義之前,讓我們先看看這個類型的作用。假設我們將“icon”prop 更改爲“banana”。我們實際上沒有名爲“banana”的圖標。

<Button
  autofocus={true}
  icon="banana"
  onClick={continue}
  primary
>
  Continue
</Button>

不到一秒鐘後,TypeScript 編譯器拒絕了這一更改:

src/client/components/explanation.tsx(13,44):
  error: Type '"banana"' is not assignable to type
    '"menu" | "arrowDown" | "arrowLeft" | ... 21 more ... | undefined'.

編譯器說“icon”不能是任意字符串。它必須是我們定義爲 icon 名稱的 24 個字符串之一。編譯器將拒絕任何使我們引用不存在圖標的更改;這不是有效的程序,甚至無法開始執行。

有多種方法可以實現 IconName 類型。一種是編寫一種類型,該類型顯式列出所有可能的 icon 名稱。然後,我們必須使 icon 名稱與其在磁盤上的圖像文件保持同步。這種類型可能是這樣的:

type IconName =
  "menu" |
  "arrowDown" |
  "arrowLeft" |
  "arrowRight" |
  ...

翻譯成中文:“這裏會靜態地保證 IconName 類型的一個值是此處指定的字符串之一,但不能是其他任何字符串。”(這個類型是我們兩堂課程涵蓋的兩個主題的組合:字面量類型和類型聯合)

我們的 IconName 未被定義爲字面量類型的簡單聯合。讓圖標名稱列表與文件列表保持同步是很無聊的工作,我們可以讓計算機來完成它!相反,我們的 icon.tsx 文件如下所示:

export const icons = {
  arrowDown: {
    label: "Down Arrow",
    data() {
      return <path ... />
    }
  },
  arrowLeft: {
    label: "Left Arrow",
    data() {
      return <path ... />
    }
  },
  ...
}

實際的 SVG < path/> 標籤就在源代碼中,在以 icon 名稱爲鍵的對象中。(也可以在不將 SVG 內聯到源文件中的情況下執行此操作。例如,我們可以使用一些 Webpack 技巧將圖像保存在它們自己的文件中,但仍然可以確保列表中的每個圖標也都存在於磁盤上。到目前爲止,這種簡單的解決方案對我們來說是很好用的。)

通過這種方式定義 icon 後,我們可以使用一行代碼自動提取其名稱的聯合類型(union type):

export type IconName = keyof typeof icons

(這裏的意思是,你可以認爲該類型表示“每當某物的類型爲”IconName”時,它必須是與 icons 對象的鍵之一匹配的字符串。)

這樣就搞定了;並不需要其他類型層面的工作。剩下的代碼只是一個簡單的 Icon React 組件,它在列表中查找圖標並返回其 SVG 路徑。這個函數中沒有明確的 TypeScript 類型。它看起來像是純粹的 JavaScript 代碼,但它也經過了類型檢查。這是一個最小版本,其中刪除了所有無關的細節:

export function Icon(props: {
  name: IconName
}) {
  return <svg>
    {icons[props.name].data()}
  </svg>
}

現在,我們可以將 SVG 標籤放入這個源文件中,並將新 icon 拖放到“icons”列表中。當我們這樣做時,這個 icon 就可以在 Button 組件,以及系統內接受 icon 名稱的其他任何部分中使用。如果我們從列表中刪除一個 icon,則系統中引用該 icon 的所有部分都將立即無法編譯,從而確保沒有過時的 icon 引用在運行時導致錯誤。

這些示例按照靜態類型標準來說是很簡單的,但我認爲它們證明了 Web 應用程序中有多少可以輕鬆實現的改進之處。一個應用程序中的大多數代碼都不涉及高級類型系統功能;多數需求僅僅是“確保我們傳遞正確的 props”和“確保我們的圖標確實存在”之類的簡單事情。

我們在整個系統中都做了這種事情。其他的一些示例:

  1. 我們在整個系統中使用了一個 Note 組件。它具有一個 tone prop 來確定提示的樣式:“info”“warning”“error”等。如果我們不再使用其中某個 tone 選項,則我們將從聯合類型中將其刪除,並且所有引用這個 tone 的 Note 將出錯,直到我們更新它們。

  2. 我們鏈接到的每個 URL 都將靜態保證存在。當我們重命名或刪除 URL 時,鏈接到它的每個組件都無法編譯,直到我們對其進行更新以匹配爲止。

  3. 當我們鏈接到這些 URL 時,類型系統可確保我們填充 URL 中的所有空缺。例如,路徑“/courses/:courseId/lessons/:lessonId”具有兩個 hole,“courseId”和“lessonId”。如果我們嘗試鏈接到該路徑,但忘記提供“courseId”,則代碼將無法編譯。

  4. 我們在客戶端上發出的每個 API 請求都會被靜態確保與相應服務端 API 端點的負載結構匹配。如果我們在端點中重命名一個屬性,哪怕屬性在嵌套的 API 對象的內部深處,引用該端點屬性的任何代碼也都將無法編譯,直到我們對其更新以匹配爲止。我們在另一篇文章中介紹了細節。

諸如此類的問題經常會出現在編程工作中,尤其是在動態語言中非常常見;但我們無需編寫任何自動測試,也用不着什麼手動測試,就可以從靜態上避免這些問題。有些問題解決起來需要費些功夫。我們的 API 路由器驗證寫起來很麻煩。但是寫多了就順手了。上面的單行“IconName”類型實際上是問題的完整解決方案。如果將其複製到 TypeScript 文件中,它就能起作用。

將我們的前端代碼移植到 TypeScript 僅僅是個開始。那之後,我們又將後端從 Ruby 移植到了 TypeScript,然後在移植後的 9 個月內對其進行了擴展和維護。

英文原文

Porting a React Frontend to TypeScript

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