[掘竅] 了解這些,更快掌握 TypeScript 在 React 中的使用(Using TypeScript in React)
tl;dr
$ npm init -y # 初始化 npm
$ npm install -D typescript # 安裝 typescript
$ npx tsc --init # 產生 TS 設定檔 tsconfig.json
內文
這篇文章主要是給想要開始學習 TypeScript,並將 TypeScript 整合進 React 專案中的開發者,也是我自己在學習將 TypeScript 使用於 React 前,很希望有人能夠先提示我的先備知識或觀念。
主要是因為若只是單純想要整合 TypeScript 到 React 中使用,你並不需要了解 TypeScript 中所有的語法就可以開始做這件事,但在學習前若沒有人整理哪些東西需要先看,哪些東西可以跳過之後再補的話,在學習上會顯得有些吃力。因此這篇文章的重點就是去蕪存菁的說明「在 React 中使用 TypeScript 時」需要先知道些什麼,相信閱讀這篇文章後,能夠節省你許多繞圈圈的時間。
在這篇文章中不會說太多關於 TypeScript 語法深入的理論或概念,基本上就是用出來給讀者看,若對於 TypeScript 的語法想要有更多了解,文中會提一些實用的資源,方便讀者深入學習。
簡而言之,這篇文章適合的讀者為:
- 已經具備 React 開發經驗
- 想要認識或學習 TypeScript
- 想要開始在 React 專案中使用 TypeScript
- [加分] 已經看過一些和 TypeScript 有關的文件
那麼就讓我們開始吧!
在 React 專案中會用到的 TypeScript
這裡會很快提一些 TypeScript 的基本語法,如同前面段落所說,這篇文章不會深入 TypeScript 的語法,因此不會提到 TypeScript 中的所有語法,而是會整理筆者在實作 React 專案的過程中,有使用到的基本語法。
為了練習與驗證這些基本語法,我們還是要先建立一個單純的 TypeScript 專案來練習與檢視。
初始化 TypeScript 專案
先建立 typescript-sandbox
這個資料夾,並進入資料夾:
$ mkdir typescript-sandbox
$ cd typescript-sandbox
接著在專案資料夾中透過 npm 安裝 typescript:
$ npm init -y # 初始化 npm
$ npm install -D typescript # 安裝 typescript
當我們安裝了 typescript 這個套件後,在該專案的終端機中就可以使用 tsc
這個指令。
這時候其實就已經可以透過 typescript 提供的 tsc
這個 CLI 工具來編譯 .ts
的檔案了,但一般來說,還會在 TypeScript 的專案中建立一支對應的 TypeScript 設定檔。
要建立 TypeScript 設定檔,只需要執行:
$ npx tsc --init # 產生 TS 設定檔 tsconfig.json
💡 TypeScript 可以選擇要安裝在全域(global)或專案中(local package),這裡因為是安裝在專案資料夾中,所以使用
tsc
指令前要加上npx
的指令,若你的 TypeScript 是安裝在全域環境,可以直接執行tsc --init
。
這時候在專案資料夾中就會多一支名為 tsconfig.json
的檔案:
在這支設定檔中會告訴 TypeScript 該如何去編譯這個專案資料夾中的 TS 檔、專案的根目錄為何、編譯後的檔案要放在哪、撰寫風格的檢查等等,設定的項目非常多,有興趣的讀者可以再參考文末的資源連結[1],檢視各個設定的含義,這裡單純作為練習和展示使用,並不需要額外調整設定。
執行 TS 檔的程式內容
使用 tsc 編譯 TS 檔案
現在讓我們先來試著使用 TypeScript 提供的 tsc
這個工具來編譯 TS 檔。首先在專案資料夾中新增一支 index.ts
,要留意副檔名是 ts
不是 js
喔!
接著在裡面輸入:
// index.ts
const greet = 'Hello TypeScript';
console.log(greet);
讀者可能會好奇這不就是 JS 的內容嗎?怎麼會說是 TS 呢?這個部分會在後面再多做說明,但這的確就是 TS。
存檔後,在終端機中輸入:
$ npx tsc index.ts
這時候你會看到 在專案中多了一支 index.js
檔:
透過 tsc
這個指令,將會把 TS 檔編譯成在 Node.js 可以執行的 JS 檔。現在在終端機中就可以如同執行 JS 檔一樣,去執行編譯好的 JS 檔:
$ node index.js
恭喜你成功執行了第一個 TS 檔的程式內容。
使用 ts-node 直接執行 TS 檔
但這時候你可能會覺得非常麻煩,每次要執行 TypeScript 檔案錢,都必須先用 tsc
指令將 TS 檔編譯成 JS 檔後,再用 node
指令去執行編譯後的 JS 檔,不只感到麻煩,連專案中的檔案都可能變成兩倍(如果是一支 TS 檔,對應產生一支 JS 檔)。
這時候好用的套件 ts-node 來了!
在 Node.js 的環境下我們會使用 node index.js
來執行某支 JS 檔;有了 ts-node,就可以不用先透過 tsc
把 TS 檔編譯成 JS 檔後才能執行,而是可以直接使用 ts-node index.ts
來執行該 TS 檔。
先讓我們在專案中安裝 ts-node,接著使用 ts-node 直接執行 index.ts
:
$ npm install -D ts-node # 安裝 ts-node 到專案資料夾中
$ npx ts-node index.ts # 使用 ts-node 直接執行某支 TS 檔
💡 不論是 typescript 或 ts-node 都可以選擇安裝在全域(global)或專案內(local package),如果是安裝在全域的話,則不需要在最前面加上
npx
,可以直接執行ts-node index.ts
。
現在不需要先將 TS 檔編譯成 JS 檔,便可以直接執行這支 TS 檔,是不是簡潔了許多:
現在你已經知道怎麼執行 TS 的檔案了,接下來讓我們看一下在 React 中常會被使用到的 TypeScript 語法。
React 中常用到的 TypeScript 的基本語法
在還沒撰寫 TypeScript 以前,因為知道它是強型別的語言,所以誤以為需要主動去對所有變數定義型別,但這句話其實只對一半。在 TypeScript 中,開發者可以「主動」對變數定義型別,但若不去主動定義型別的話,TS 則會幫 你「自動」推論這個變數的型別,也就是說,不論有沒有主動定義變數型別,每個變數都將有其明確的型別,當程式執行中,型別有錯誤時就會提出警告。
接著讓我們來看幾個實際的使用範例。
TypeScript 中的型別檢驗
在 TS 中多會在冒號(:
)後定義該變數的型別,例如:
/* 宣告型別為字串或數值 */
let occupation: string; // 宣告 occupation 的型別為 string
以 occupation
這個變數來說,我們主動定義這個變數的型別是字串(string),因此未來只要這個變數帶入的值有不是字串的情況,在 TS 編譯時或 VSCode 的編輯器中都會跳出警告。
我們可以在剛剛的 index.ts
中輸入以下內容:
// index.ts
let occupation: string; // 宣告 occupation 的型別為 string
occupation = 817; // 想要把 occupation 的值帶入數值
這時候 VSCode 會有紅色毛毛蟲在 occupation
下方,把滑鼠移過去時會顯示提示:
這裡的意思是我們要把 817
當作 occupation
這個變數的值,但 occupation
已經定義是字串了,因此 817 並不能作為該變數的值。若我們在終端機執行 npx ts-node index.ts
,終端機一樣會跳出警告,並且無法執行:
但若我們是將 occupation 的值設為 "programmer"
,像是這樣:
let occupation: string;
occupation = 'programmer';
世界就會非常和平,可以正常運行:
這裡我們看到如何將變數的型別定義為字串,接著我們來看一下 TypeScript 中基本的型別。
主動宣告變數型別
字串與數值
在上面的例子中,我們是先定義某個變數,之後才對其賦值,但也可以直接宣告變數型別並賦值,例如:
/* 宣告型別為字串或數值 */
let occupation: string = 'programmer'; // 宣告 occupation 的型別為 string,並且同時賦值
let height: number = 170; // 宣告 height 的型別為 number,並且同時賦值
物件
針對物件型別的定義方式很類似,從下面的例子可以看到在變數 person
後一樣透過 :
的方式,讓 TypeScript 知道 person 是一個物件,其中會有兩個屬性,分別是 name
和 age
,而 name
屬性的型別是 string
,age
屬性的型別則是 number
:
/* 宣告型別為物件 */
const person: { name: string; age: number } = {
name: 'pjchender',
age: 32,
};
物件還有一個比較特別的地方,就是有時物件中的屬性並非一定會存在,例如 person 這個物件,name
和 age
可能是必填,但職業(occupation)可以是選填,也就是可有可無,這時候可以在屬性名稱後方使用 ?
來表示,像是這樣:
/* 在 occupation 後有一個 ?,表示該屬性不一定要存在於該物件中 */
const person: {
name: string;
age: number;
occupation?: string; // 在 : 前面加上 ? 表示該屬性不是必填
} = {
name: 'pjchender',
age: 32,
};
這時候雖然後來賦值時的物件中沒有 occupation
這個屬性,TypeScript 也不會報錯。
陣列
陣列的部分比較特別一些,一般在定義陣列的型別時,除非有額外使用後面會提到的聯集(|
),否則都是定義陣列中所有元素的型別會是相同的。舉例來說,這裡將 devices
這個變數的型別定義為 string[]
,意思就是該陣列中所有的元素都是字串:
/* 宣告型別為字串陣列*/
const devices: string[] = ['iphone', 'pixel', 'ipad', 'note 10'];
如果你希望該陣列中所有元素都是數值,自然就是使用 number[]
,例如:
/* 宣告型別為數值陣列*/
const luckyNumber: number[] = [4, 7, 11];
你也可能看到有人會寫這樣 Array<number>
,這是使用了後面會講到的泛型(generic)的用法,但意思和上面那行是一樣的:
/* 宣告型別為數值陣列*/
const luckyNumber: Array<number> = [4, 7, 11];
如果陣列中的元素可能包含兩種以上的型別,可以使用聯集(|
)的寫法,這裡的聯集簡單來說就像「或」的概念:
/* 宣告陣列中的元素可以是字串或數值 */
const luckyItem: (string | number)[] = [4, 7, 11, 'phone', 'pad'];
// 另一種寫法
const luckyItem: Array<string | number> = [4, 7, 11, 'phone', 'pad'];
函式
看完了基本的字串、數值、陣列、物件之後,來看一下函式宣告的方式:
/* 宣告型別為函式 */
const add = (x: number, y: number): number => {
return x + y;
};
console.log(add(3, 5)); // 8
這裡可以看到,宣告了一個變數為 add
它的值是函式,這個函式中可以接兩個參數(x
, y
)這兩個參數的型別都是「數值」,在 :
後面則會定義個這函式會「回傳」的型別,這裡一樣定義為數值。
可以用 ts-node
來執行看看這段程式,但若你帶入的參數不是數值的話,VSCode 一樣會跳出紅色毛毛蟲,滑鼠移到毛毛蟲上將會顯示對應的提示:
另外,在函式中很常會使用物件的解構賦值(object destructuring assignment)的方式來取出參數的內容,型別定義的寫法上會像這樣:
/* 宣告型別為函式,參數的地方使用物件的解構賦值 */
const add = ({ x, y }: { x: number; y: number }): number => {
return x + y;
};
add({ x: 3, y: 5 });
有些時候某個函式只是單純執行某些方法,但並不會有實際會回傳值時,我們會在 :
後用 void
這個型別,表示該函式將不會有回傳值,例如下面這個函式會直接在執行時呼叫 console.log
方法,而不會有實際的回傳值,這時候在 :
後方就會使用 void
:
/* 函式不會有回傳值時使用 void */
const add = (x: number, y: number): void => {
console.log(x + y);
};
另外,假設有些方法一定會拋出錯誤(throw error)時,表示函式執行到這裡後就會被中斷而不會繼續運行,這時候會在 :
使用 never
這個型別,意思就是這個函式不會執行完成:
/* 該函式不會執行完成的話,使用 never */
const add = (x: number, y: number): never => {
throw new Error('This is never');
};
add(3, 5);
undefined, null
在 JavaScript 中的 undefined
或 null
也可以被當作型別定義:
/* 宣告型別為 undefined 或 null */
let foo: undefined;
let bar: null;
literal type
除了上述這些比較有「型別味道」的型別之外,實際上型別也可以是定死某個值,例如這裡我們定義 occupation
這個變數的型別就是 "Programmer"
,如此 occupation 這個變數的值就只能是 "Programmer"
,不能是其他的值:
let occupation: 'Programmer';
occupation = 'Programmer';
是不是很有 JS 中常數的味道?
這種型別在 TS 中稱作 literal type[3],它可以是字串或數值,並且經常會搭配聯集(|
)來使用:
let brand: 'iphone' | 'samsung' | 'sony';
這時候 brand
這個變數的值就只能是這三個字串中的其中一種,否則都會報錯。當你使用 VSCode 時,透過其內建的 typescript intellisense,還會自動建議你可以使用的值有哪些:
最後,TypeScript 中的型別不只這些,但上面提到的這些是筆者開發 React 時比較常用到的,若想了解更多關於 TypeScript 的基本型別,可以參考 TypeScript 官網在 Basic Types 的介紹[2]。
TypeScript 會自己推論型別,不用所有變數都主動定義型別
在段落的最開始曾經提到,在 TypeScript 中每個變數都有其 對應的型別,但開發者並不需要為每一個變數去定義型別,這是因為在開發者沒有主動定義變數型別的情況下,TypeScript 會自動去對變數進行型別推論,並以此作為該變數的型別。簡單來說,就是你不明確定義的話,那 TS 就會幫你定義,所以每一個變數仍然有其明確的型別存在。
還記得在最一開始的範例中,我們直接寫了如同 JS 的程式碼而沒有宣告任何型別,程式一樣可以正常運作,這是因為 TS 會自動幫變數推論型別:
// index.ts
const greet = 'Hello TypeScript';
console.log(greet);
要檢視 TypeScript 對該變數推論的型別,只需要在 VSCode 中把滑鼠移到變數上方:
這裡可以看到 TypeScript 自動推論 greet 的型別是 "Hello TypeScript"
,也就是說當你使用 const
宣告一個變數時,TypeScript 就會推論它為一個 literal type,因此這個變數的值也將只能是 "Hello TypeScript"
這個字串。
如果我們把它改成用 let
來宣告,再把滑鼠移到變數上方看看會顯示的提示:
這時候你會看到 TS 很聰明的將 greet
這個變數型別推論為 string,而不再是 literal type,因為 let
的意思,就是後續還可以把這個變數的值去做修改,但因為一開始賦值的內容是字串,因此 TS 會推論這個變數的型別是字串,後續這個變數的值即使改變了,仍然只能是字串。
💡 在 VSCode 中,檢視 TypeScript 中變數型別的方式非常簡單,只需要把滑鼠移動到變數上方即可。除了可以透過這種方式檢視 TS 自動推論的型別之外 ,若有使用第三方套件,滑鼠移上去後常可以看到套件的選項(options)中接收哪些額外參數。總之,多利用 VSCode 中的提示功能,在寫 TypeScript 時會方便許多。
抽象化:使用型別化名(type alias)
上面我們提到了在 TypeScript 中一些基本的型別定義,一開始可能會針對每個變數去進行各自的定義,但當我們的應用程式越來越複雜時,就需要做到抽象化的動作,具體來說就是把一些可以重複使用的部分抽出來。
舉例來說,假設在專案中會定義許多的書籍,每個書籍都是一個物件,在物件中有對應的書名(name)和價格(price),一開始可能會這樣寫:
const book1: {
name: string;
price: number;
} = {
name: 'Learn TypeScript',
price: 300,
};
當有第二本書時,因為書中的屬性和值是一樣的,所以同樣的型別需要再定義一次:
const book2: {
name: string;
price: number;
} = {
name: 'Learn React from Hooks',
price: 320,
};
特別留意下面程式碼中套色的部分:
你會發現這兩個變數使用的型別基本上是一樣的,這時候我們就可以把這個型別做一個抽象化的動作,有點像是把這個型別也變成一個變數。
在 TypeScript 中可以使用 **Type Alias(型別化名)**的方式來將定義好的 型別命名,只需要使用 type
這個關鍵字即可,像是這樣:
type Book = {
name: string;
price: number;
};
這樣就定義好了一個名為 Book
的型別,使用方式只需要把這個型別的名稱放入 :
後即可:
// 在 : 後放入的是命名過的型別
const book1: Book = {
name: 'Learn TypeScript',
price: 300,
};
是不是簡潔很多呢!?
💡 關於型別化名(Type Alias)的更多說明可以參考文末的資源連結[3]。
使用聯集(union)和交集(intersection)
在 TypeScript 中的 Type 還可以使用聯集(|
)或交集(&
)來做一些變化。這裡的聯集比較像是「或」的概念,而交集比較像是「且」的概念。
聯集(union)
先來說明前面段落中曾經使用過的聯集(|
)。舉例來說,假設現在商店中有兩類的商品,一類是 Book,一類是 Device:
type Book = {
author: string;
publishedAt: string;
};
type Device = {
brand: string;
releasedAt: string;
};
但只符合其中一類的話,就能算是 Product,這時候可以使用聯集:
type Product = Book | Device;
這裡定義了一個新的 Type Alias,它型別可以是 Book 「或」 Device。只要 Product 物件中的屬性和值符合 Book
或 Device
其中之一,TypeScript 就不會報錯:
// book 這個變數符合 Product Type,因為只要符合 Book Type 或 Device Type 其中之一即可符合 Product Type
const book: Product = {
author: 'pjchender',
publishedAt: '20200831',
};
交集(intersection)
前面提到聯集的概念類似「或」,而交集的概念就類似「且」,交集的語法是 &
。舉例來說,一支手機一定會包含硬體(hardware)和軟體(software),可以這樣定義:
type Software = {
platform: string;
releasedAt: string;
};
type Hardware = {
RAM: string;
CPU: string;
};
一隻手機因為需要包含這兩個部分,所以可以用交集來建立 MobilePhone
這個 Type:
type MobilePhone = Software & Hardware;
這時候,如果某個物件要符合 MobilePhone 這個型別,一定要同時符合 Software 的型別和 Hardware 的型別,也就是這兩個型別中的所有物件屬性和值所對應的型別 都要正確包含在內:
const iphone11: MobilePhone = {
platform: 'ios',
releasedAt: '20190919',
RAM: '4GB',
CPU: 'A13',
};
要符合 MobilePhone
這個型別的話,Software 和 Hardware 中的任何一個屬性都不能少。
💡 關於聯集和交集的進一步使用可以參考文末資源連結[4]。
抽象化:使用介面(interface)
在 TypeScript 中,除了使用 Type Alias 來達到抽象化的概念外,另一個很常用的則是介面(interface),其中 interface 的使用和定義的方式與 Type 非常類似。假設我們要定義硬體的 Interface 可以這樣寫:
interface Hardware {
RAM: string;
CPU: string;
}
一樣可以對變數直接套用 interface,像是:
const hardware: Hardware = {
RAM: '4GB',
CPU: 'A13',
};
在 Interface 中也有類似交集的概念,但在 Interface 中是使用 extends
這個關鍵字,也就是延伸的意思。如同上一個段落中交集的例子,可以先定義 Hardware 和 Software 這兩個 Interface:
interface Software {
platform: string;
releasedAt: string;
}
而一個完整的 MobilePhone 需要延伸自 Hardware 和 Software 這兩個介面後,再添加 price
和 brand
的屬性,我們可以使用 extends
這個關鍵字這樣寫:
interface MobilePhone extends Software, Hardware {
price: number;
brand: string;
}
在定義型別為 MobilePhone 的變數時會像這樣,同樣一個屬性都不能少:
const iphone11: MobilePhone = {
price: 24900,
brand: 'apple',
platform: 'ios',
releasedAt: '20190919',
RAM: '4GB',
CPU: 'A13',
};
💡 關於 Interface 的更多說明,可以參考文末資源連結[5]。
Type Alias 和 Interface 基本上是不同的概念,雖然有時可以達到類似的效果。一般來說,若是要在 React 中定義 props 或 state 的型別時,慣例上會使用 Type Alias;當若某個元件是要開發給其他人使用到的套件,則這個元件中的 API 會建議使用 Interface,讓使用此套件的開發者更方便奠基在你提供的方法上再添加其他屬性。
關於 Types 或 Interface 的比較說明,可以進一步參考文末的資源連結[6]。
在 React 中使用 TypeScript
前面花了非常多篇幅在說明 TypeScript 的基本使用,現在讓我們實際進到 React 的部分。
建立使用 TypeScript 的 React 專案
透過 React 提供的 create-react-app 即可以快速的建立可以使用 TypeScript 的 React 專案,只需要在一般建立 React 專案的指令最後加上 --template typescript
即可。
例如,這裡我們可以透過 create-react-app
這個指令建立一個名為 typescript-react-sandbox 的專案:
$ npx create-react-app typescript-react-sandbox --template typescript
$ cd typescript-react-sandbox
很快就建立好了一個可以使用 TypeScript 的 React 專案。如果你本來就熟悉 React,你會發現除了專案資料夾中多了 tsconfig.json
檔案,以及原本的 .js
檔變成 .tsx
檔之外,這裡面的資料夾結構和原本的 React 專案沒有太大差別,一樣可以直接透過 npm start
把專案啟動起來:
你甚至會發現在 App.tsx
中的檔案內容機乎沒有改變,沒錯!通常只要在我們實際處理資料時這些 TypeScript 的東西才會一併近來。
💡 在 TS 中有使用到 JSX 的檔案,副檔名會取名為
.tsx
。
建立 React 元件
現在讓我們把 App.tsx
中的部分內容拆成 React 元件會比較有感覺,這裡為了示範,可以把連結的部分拆成一個 React 元件,也就是下圖中程式碼套色的地方:
先在專案中新增一支名為 Link 的 React 元件,檔名為 Link.tsx
,一開始就像原本使用 JS 撰寫 React 元件一樣即可:
// ./src/Link.tsx
import React from 'react';
const Link = () => {
return (
<a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">
Learn React
</a>
);
};
export default Link;
並且在 App.tsx
中去載入這個元件:
你會發現到目前為止和原本寫 JS 根本沒有任何不同,一樣可以透過 npm start
看看專案能否正常運作。
現在,讓我們試著把連接中的文字部分抽成 props,未來可以接收從 App 元件傳入 props,像是這樣:
你會發現紅色毛毛蟲再次出現,同時瀏覽器也出現 TS 編譯錯誤的警告:
遇到紅色毛毛蟲時就把滑鼠移到上方,VSCode 會自動顯示錯誤訊息:
這裡的意思是說,props
在自動推論的時候,被當作是 any
這個型別,any
是 TypeScript 內建的一個型別,基本上 any
表示的就是「什麼都可以」,也就是不論這個變數的值是什麼我都吃,這等於破壞了 TypeScript 原本期待,因此若非開發者「主動」定義該變數的型別為 any ,而是 TypeScript 自動推論為 any 的情況下,都會跳出錯誤警告。
上面提到,會發生錯誤警告是因為開發者沒有「主動」定義該型別,要解決這種問題,其中一種方式便是主動明確告訴 TS 說這個變數的型別是 any
,像是這樣:
// ❌ 請不要這麼做
const Link = (props: any) => {
// ...
};
如此世界就會恢復和平。但等等,這樣不就又違反了我們之所以要使用 TypeScript 的目的之一,以後所有變數的型別都指定為 any
不就好了。
之所以在 TS 中有 any 這個型別,是因為有些時候真的無法確定會得到什麼樣的結果,例如 JSON.parse()
這個方法的回傳值就會定義為 any
:
但在實際的開發過程中,應該要盡可能避免去使用 any
,如果很多變數的型別都定義為 any
的話,基本上就回去用 JS 就好了,不必繞這一大圈。
因此正確的做法應該是要去定義 props 會傳進什麼型別的資料進來,就像是在 JS 的 React 專案中,我們會去定義 PropTypes 一樣,而不是直接用 any
來敷衍它。
因為 props 中 content 的型別會是字串,因此可以改成這樣:
const Link = (props: { content: string }) => {
const { content } = props;
return (
// ...
)
}
💡 使用 TypeScript 後,即不需要如同在 JS 中替每個元件定義 PropTypes。
補充:不建議使用 React.FCs
感謝 wowdyaln 提供 Since TypeScript 5.1, React.FC is now "fine",自從 TypeScript 5.1 和 React 18 開始,可以正常使用 React.FC。
定義 children 的型別
在沒有使用 React.FC<>
的情況下,當該元件需要使用到 children
時就需要主動明確定義,children 有很多不同型別