第三週第一至五天:將現有專案改寫成 TypeScript
專案原始碼:anna-luyufeng/whereintheworld-fe-challenge
這週會將以前用 Create React App(CRA)的專案改寫成 TypeScript 來複習前兩週所學,並把每天的進度 commit 到新的分支 typescript
,讓日後有興趣的人仍能從 master
複製一份來實作練習。
我個人習慣在導入或實驗新東西時,制定計畫按部就班來執行,避免自己做到一半不知道在幹嘛,也比較好掌握進度。
三階段漸進式改寫
這是 React TypeScript Cheatsheets 的 3 Step Process 提到的改寫流程,會大致依照這個流程進行改寫,過程中遇到的問題或想法會直接放在「每日改寫記錄」內:
Just Make It Work 讓專案能啟動就好
新增
tsconfig.json
關閉「不允許隱含任意值」選項
noImplicitAny
(預設即為關閉)改用 TypeScript 副檔名
.ts
、.tsx
Be Explicit 讓型別明確
啟用「不允許隱含任意值」選項
noImplicitAny
常見型別
第三方函式庫型別
Strict Mode 嚴格模式
- 啟用「所有嚴格型別檢查」選項
strict
- 啟用「所有嚴格型別檢查」選項
每日改寫記錄
Just Make It Work 讓專案能啟動就好
安裝 TypeScript 和相關宣告檔案
# npm
npm install --save typescript @types/node @types/react @types/react-dom @types/jest
# yarn
yarn add typescript @types/node @types/react @types/react-dom @types/jest
將 jsconfig.json
重新命名為 tsconfig.json
,並新增選項
本來直接新增 tsconfig.json
,但試著 yarn start
後報錯 Error: You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.
才發現 jsconfig.json
與 tsconfig.json
不能同時存在。
{
"compilerOptions": {
"allowJs": true, // 允許編譯器編譯 JavaScript 文件
"checkJs": true // 允許對 JavaScript 文件(.js 和 .jsx 文件)進行型別檢查
}
}
將檔案改用 TypeScript 副檔名
將 JavaScript 文件的文件副檔名更改為 .tsx
(對於包含 JSX 的文件)或 .ts
(對於不包含 JSX 的文件)。例如,將 App.jsx
重新命名為 App.tsx
,將 index.js
重新命名為 index.ts
。
# 快速將 src 目錄底下副檔名為 .js 與 .jsx 更改為 .ts 與 .tsx
find src -name "*.js" -exec sh -c 'mv "$0" "${0%.js}.ts"' {} \;
find src -name "*.jsx" -exec sh -c 'mv "$0" "${0%.jsx}.tsx"' {} \;
改完副檔名後共 19 個地方報錯。本來以為會不能 commit,結果發現專案還沒有安裝 ESLint,之後再來安裝。
新增 "allowSyntheticDefaultImports": true
選項
允許在 TypeScript 中使用預設導入語法來導入 CommonJS 模組。
讓 TypeScript 能夠識別 CSS Modules 的 .scss
檔案
在專案根目錄或適當的子目錄中,新增一個名為
typings
的資料夾。這個資料夾將存放自定義型別宣告。在
typings
資料夾內,新增一個名為scss-modules.d.ts
的檔案。這個檔案將包含用於定義 CSS Modules 的型別宣告。在
scss-modules.d.ts
檔案中,添加以下內容:declare module '*.module.scss' { const classes: { [key: string]: string } export default classes }
這段程式碼告訴 TypeScript 編譯器,所有具有
.module.scss
副檔名的檔案將導出一個物件,其鍵為string
類型,值也為string
類型。這表示 CSS 樣式名稱與其對應的值。確保
tsconfig.json
檔案中的include
選項包含了剛剛新增的typings
資料夾。例如:{ "compilerOptions": { // 編譯選項... }, "include": ["src", "typings"] }
ERROR TS2345: Type ‘string’ is not assignable to type ‘PaletteMode’
這個錯誤是因為 TypeScript 無法確定 palette.mode
的型別。PaletteMode
字串字面量型別預期值為 'light'
或 'dark'
,但在程式碼中它被推斷為 string
型別。因此只要讓 TypeScript 知道 palette.mode
只會是 'light'
或 'dark'
不會是其他字串,就不會出現型別錯誤。
參考 MUI 官網說明,用 declare module
來將 palette
的型別 PaletteOptions
加上自定義顏色 neutral
型別。
declare module '@mui/material/styles' {
interface PaletteOptions {
neutral: PaletteOptions['primary']
}
}
const lightTheme: PaletteOptions = {
// ...
}
const darkTheme: PaletteOptions = {
// ...
}
function getDesignTokens(isDarkMode: boolean): ThemeOptions {
return {
palette: {
...(isDarkMode ? darkTheme : lightTheme),
},
// ...
}
}
ERROR TS2803: Type ‘IterableIterator<number>’ can only be iterated through when using ‘—downlevelIteration’ flag or with a ‘—target’ of ‘es2015’ or higher
要可以迭代 Array(8).keys()
需要 ECMAScript 2015(即 ES6)標準,將 tsconfig.json
的 target
設定為 'es2015'
或是更新的版本,如 'es2017'
、'es2018'
等。
// tsconfig.json
{
"compilerOptions": {
// ...其他編譯選項
"target": "es2015"
// 或
// "target": "es2017",
// 或
// "target": "es2018",
},
"include": ["src"]
}
ERROR TS2792: Did you mean to set the ‘moduleResolution’ option to ‘nodenext’, or to add aliases to the ‘paths’ option?
這個錯誤表示 TypeScript 編譯器無法導入模組,需要設定適合的模組解析方式。以下是 ChatGPT 對於不同模組解析方式的解釋:
node
:這是大多數情況下的推薦選擇。node
選項將導入解析過程與 Node.js 的模組解析算法保持一致。它將尋找.ts
、.tsx
、.js
、.jsx
和.json
副檔名的文件,並支持解析package.json
中的main
和module
屬性。它適用於 Node.js 應用程序和使用 Webpack 或其他打包器的前端應用程序。classic
:這是 TypeScript 較早版本的預設解析策略。它首先尋找與導入語句相同的文件名(不包括副檔名),然後尋找具有相同名稱的文件夾,最後檢查文件夾中是否有index.ts
或index.js
。通常,不需要使用classic
,除非正在使用舊版本的 TypeScript 或特定的構建工具。nodenext
:這個選項是針對支持 ECMAScript 模組的 Node.js 12+ 版本。它遵循較新的模組解析策略,要求在導入路徑中包含文件副檔名。它還支持一些新功能,如條件導出和導入。
選擇適當的 moduleResolution
取決於專案需求和目標環境。在大多數情況下,應該選擇 node
,因為它提供了最好的兼容性和靈活性。對於支持 ECMAScript 模組的 Node.js 12+ 版本,可以選擇 nodenext
。只有在處理舊的 TypeScript 專案或特定構建工具時,才需要考慮使用 classic
。
// tsconfig.json
{
"compilerOptions": {
// ...其他編譯選項
"moduleResolution": "node"
},
"include": ["src"]
}
ERROR TS1323: Dynamic imports are only supported when the ‘—module’ flag is set to ‘es2020’, ‘es2022’, ‘esnext’, ‘commonjs’, ‘amd’, ‘system’, ‘umd’, ‘node16’, or ‘nodenext’.
這個錯誤表明 TypeScript 編譯器無法識別動態導入(import()
),需要將 tsconfig.json
的模組系統 module
設定為支援動態導入的值之一。以下是 ChatGPT 對於不同模組系統的解釋:
commonjs
:這是 Node.js 的標準模組系統,適用於 Node.js 應用程序和具有網頁打包器(如 Webpack)的瀏覽器應用程序。es2020
,es2022
,esnext
:這些選項表示使用 ECMAScript 模組系統,它們分別針對 ES2020、ES2022 和最新的 ECMAScript 草案。適用於現代瀏覽器和支援 ECMAScript 模組的 Node.js 版本。amd
和system
:這些選項適用於需要使用非標準模組加載器(如 RequireJS)的應用程序。umd
:這個選項表示通用模組定義,它允許您的代碼在 CommonJS、AMD 和全局變量的環境下運行。這對於要在多種環境下運行的庫或框架非常有用。node16
和nodenext
:這些選項專為支援 ECMAScript 模組的 Node.js 12+ 版本而設計。nodenext
還提供了更好的支援,如條件導出和導入。
在大多數情況下,commonjs
和 esnext
是最常用的選項。如果專案主要針對 Node.js,可以選擇 commonjs
;如果主要針對現代瀏覽器並使用了打包器,則可以選擇 esnext
。對於支援 ECMAScript 模組的 Node.js 12+ 版本,可以選擇 node16
或 nodenext
。
// tsconfig.json
{
"compilerOptions": {
// ...其他編譯選項
"module": "esnext"
},
"include": ["src"]
}
ERROR TS2554: reportWebVitals()
Expected 1 arguments, but got 0.
根據註解內的 CRA 官網說明,因為我只要單純紀錄,按照官網說明改成:
reportWebVitals(console.log)
ERROR TS2769: No overload matches this call.
MUI Button 使用自定義的顏色時,除了擴充 '@mui/material/styles'
模組的型別 PaletteOptions
也需要在 '@mui/material/Button'
模組的 ButtonPropsVariantOverrides
型別加上自定義的顏色。
import '@mui/material/Button' // 記得匯入原本模組的宣告檔案,不然會變成完全覆蓋,導致找不到原本 Button 的型別定義而報錯
declare module '@mui/material/Button' {
interface ButtonPropsColorOverrides {
neutral: true
}
}
ERROR TS2554: useGetAllCountriesQuery()
Expected 1-2 arguments, but got 0.
我這裡是直接拿所有的國家資料,不需要傳參數,經 ChatGPT 指點,可以傳一個空物件作為參數:
const { data = [], isLoading } = useGetAllCountriesQuery({})
ERROR in src/reduxModules/country/countryApi.ts
TS2339: Property ‘status’ does not exist on type ‘unknown’.
countryResult.data.status
發現寫錯報錯的物件結構,另外參考官網的寫法改成:
if (countryResult.error)
return { error: countryResult.error as FetchBaseQueryError }
TS2339: Property ‘borders’ does not exist on type ‘unknown’.
TS2698: Spread types may only be created from object types.
這兩個錯誤則是要用型別斷言讓 TypeScript 知道 countryResult.data
的資料型別 ,因此需要新增宣告檔案來定義國家資料型別 Country
後指給 countryResult.data
。
// typings 底下新增 query.d.ts
interface Language {
iso639_1: string
iso639_2: string
name: string
nativeName: string
}
interface Currency {
code: string
name: string
symbol: string
}
interface Country {
alpha3Code: string
borders: string[]
capital: string
currencies: Currency[]
flag: string
languages: Language[]
name: string
nativeName: string
population: number
region: string
subregion: string
topLevelDomain: string[]
}
// src/reduxModules/country/countryApi.ts
// 第 30 行
const countryBorders = (countryResult.data as Country).borders;
// 第 50 行
...(countryResult.data as Country),
TS2339: Property ‘then’ does not exist on type
這個錯誤是因為 TypeScript 編譯器無法確定 fetchWithBQ
的回傳型別 MaybePromise
是否有 then
屬性。我原本的寫法沒有很好,因為已經使用 async/await
語法,其實可以直接用 await
來取得 fetchWithBQ
的結果。會將這段重構讓它更容易閱讀與理解:
// 封裝成獨立函式
async function fetchCountryBorderData(border) {
const response = await fetchWithBQ({
url: `/alpha/${border}`,
params: {
fields: 'name,alpha3Code',
},
})
return response.data
}
const countryBordersResults = await Promise.all(
countryBorders.map(border => fetchCountryBorderData(border))
)
TS2339: Property ‘data’ does not exist on type ‘unknown[]’.
這邊是透過 API 來取得 Country 內 Border Countries 對應的 alpha3Code
,因為 Country 資料需要用 alpha3Code
取得。思考過後覺得如果拿不到資料就把對應的 Border Country 濾掉不顯示,不需要因為它發生錯誤而不顯示整個 Country 的資料:
return {
data: transformCountry({
...(countryResult.data as Country),
borders: countryBordersResults.filter(Boolean),
}),
}
Be Explicit 讓型別明確
啟用「不允許隱含任意值」選項 noImplicitAny
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": true,
// 其他編譯器選項...
},
"include": ["src"]
}
啟用後出現共 23 個錯誤,因為每個錯誤都紀錄怎麼修的會花太多時間,簡單紀錄改錯誤時的學習與心得:
如何將同個資料拆分成列表與細節不同用途的型別
// typings/query.d.ts interface CountryList { alpha3Code: string name: string capital: string flag: string independent: boolean population: number region: string } interface CountryDetail extends CountryList { borders: CountryBorder['name'][] currencies: Currency[] languages: Language[] subregion: string topLevelDomain: string[] nativeName: string }
轉換不同用途的資料直接拆成兩個獨立轉換函式各別處理比較容易維護與閱讀
// src/reduxModules/country/transform.ts export function transformCountryList(country: CountryList) { // ... } export function transformCountry(country: CountryWithFullBorders) { // ... }
學到用
Omit<Type, Keys>
來將原本的定義好的型別來排除部分屬性後重新定義(直接繼承的話,重複定義的屬性型別需要相容於原本的型別)interface TransformedCountryDetail extends Omit< CountryWithFullBorders, "currencies" | "languages" | "population" | "topLevelDomain" >
型別斷言很常用
如果是在使用第三方函式庫的地方報錯,先到函式庫的官網看有沒有 TypeScript 的說明可以參考,不要埋頭苦幹。
自定義的型別要特別注意其正確性,一千髮而動全身,才不會錯誤越改越多。
Strict Mode 嚴格模式
啟用「所有嚴格型別檢查」選項 strict
// tsconfig.json
{
"compilerOptions": {
// ...其他編譯選項
"strict": true
},
"include": ["src"]
}
啟用後一共發現了 9 個錯誤,不過這次修正起來速度明顯加快了,有熟能生巧的感覺:
學會
useState()
用泛型顯式指定型別useState<CountryList[]>([])
被 TypeScript 抓到
CountryCard
元件屬性的型別指錯的問題
安裝 ESLint
複習與更新第二週第五天:程式碼檢查
除了原本 Getting Started | typescript-eslint 提到的編譯選項,下一頁的說明 Linting with Type Information | typescript-eslint 建議還需要在 .eslintrc.js
增加下列選項:
/* eslint-env node */
module.exports = {
// ...
parserOptions: {
project: true, // 告訴 ESLint 如何解析 TypeScript 程式碼。將它設定為 true 表示啟用對 TypeScript 程式碼的支援。
tsconfigRootDir: __dirname, // 這個選項指定 TypeScript 設定檔案(tsconfig.json)所在的根目錄。在這個例子中,__dirname 是一個 Node.js 全域變數,代表目前模組的目錄路徑。
},
extends: [
// ...
// 包含了一些額外的 TypeScript 規則,這些規則需要型別檢查才能正確運行。
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
}
但加完以上的選項 .eslintrc.js
報錯:
我翻找 typescript-eslint
的官方文件與 GitHub Issues 沒有特別提到要做額外的設定,本來是用錯誤提示第二個方法「Change that TSConfig to include this file」,但越想越不對勁,也有問 ChatGPT 它也不推薦,比較推薦的做法是第三個「Create a new TSConfig that includes this file and include it in your parserOptions.project」。
意思就是創建一個單獨的 TypeScript 配置檔案(例如 tsconfig.eslint.json
),專門用於 ESLint。在這個檔案中,將包含原始的 tsconfig.json
檔案以及 .eslintrc.js
檔案。
// tsconfig.eslint.json
{
"extends": "./tsconfig.json", // 繼承原始 tsconfig.json
"include": ["src", "typings", ".eslintrc.js"] // 包含 .eslintrc.js 文件
}
然後,在 .eslintrc.js
中將 parserOptions.project
指向新的 TypeScript 配置檔案。
// .eslintrc.js
module.exports = {
// ...
parserOptions: {
project: './tsconfig.eslint.json', // 指向新的配置檔案
// ...
},
// ...
}
專案的改寫終於告一個段落了!看到 Terminal 沒有任何紅色的 Error 時真的好感動!
準備來寫這三週學習計畫的心得總結啦!咱們明天見 👋