跳至主要内容

Typescript Overview

· 閱讀時間約 15 分鐘
Ivan Tsai
Backend engineer

我第一個使用的前端框架是 Angular,自然也接觸了 Typescript,當時並不太能體會它的好處,只覺得增加了些學習門檻罷了。後來發現習慣以後,再也沒有辦法回到原本的 JS 了 ...,就算換了框架、使用 Node,都還是以 TS 為主,這篇文章來簡單介紹 TS 基本用法,回顧看看他到底為甚麼這麼令人著迷。

Typescript 是甚麼

TypeScript is a typed superset of Javascript that complies to plain JavaScript

這是官網上的簡介,TS 其實就是 JS 的超集,JS 有的所有語法在 TS 中都可以使用,也就是說現在找一個 JS 檔案改副檔名就可以直接編譯的過 (當然這樣做其實沒什麼意義) 。除了原本的語法外,TS 又加了其他的功能,最重要的也就是型別 (Types) 定義了。

由 JavaScript 開始,也以 JavaScript 結束

TS 使用 JS 中同樣的語法,你不用從頭來過,這也意味著所有過去以 JS 開發的函式庫都可以直接使用,只需要學習增加的功能就好。使用 TS 開發過後,必須要經過編譯回到原生 JS 後才可以使用,任何能跑 JS 的地方,都可以跑使用 TS 來開發的程式。

為甚麼我們需要「型別」?

為甚麼我們要在一個弱型別語言上加上型別定義呢,來看個簡單的例子吧!

JS 原生寫法

今天有個簡單的存錢小程式,呼叫 giveMeMore 後會印出加總過後的存款

let money = 10;

function giveMeMore(count) {
money = money + count;
console.log("Awesome, now I have $" + money);
}

giveMeMore(10); // Awesome, now I have $20
giveMeMore(10); // Awesome, now I have $30

看起來沒甚麼問題,直到今天有個人這樣用了你的函式:

giveMeMore("10"); // Awesome, now I have $3010

JS 會先把 money 轉換型別,然後把字串 10 附加在後面,所以得到的結果會是 3010,此時就算使用正確的型別輸入也於事無補了 ...

giveMeMore(10); // Awesome, now I have $301010

加入型別檢查

當然,這是個簡單的問題,非常好預防,只需要在 function 執行前做檢查就好了

function giveMeMore(count) {
if (typeof count !== "number") {
throw new Error("Go away...");
}
money = money + count;
console.log("Awesome, now I have $" + money);
}

檢查的部分通常也需要個測試來涵蓋她

it("should throw except when input type is not number", function () {
// some assert
});

使用 Typescript

有了型別以後,編譯器會自動擋下不符合要求的程式,不需要再額外測試。

TS 會在編譯階段自動作型別檢查,而不是到執行階段才處理,這可以提升一些效能。

JS 的弱型別特性給了我們很大的彈性 (相對於強行別語言,需要先思考架構 ),使我們可以快速開發,在小型網頁上使用使合情合理的。但隨著各種框架出現,JS 被應用在更大型、需要長期維護的專案,這可能就會增加維護的難度,TS 在型別定義上做了很大的改善,縱使需要寫更多的程式、需要經過編譯,依然是個值得考慮的選擇。

Getting Started

Compile

TS 的副檔名會改為 .ts ,我們必須將它轉回 .js 檔案

信息

TS 編譯時需要有設定檔 tsconfig.json ,請參考官網來設定。

使用 TSC

官方預設的編譯器,在沒有使用 Webpack 的時候會這麼做 (像是 Node JS 環境 )

安裝

npm install -g typescript

編譯

tsc helloworld.ts

使用 Loader

通常前端開發會使用 Webpack ,這時候就可以使用 ts-loader 來轉換檔案

module: {
rules: [
// all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
{ test: /\.tsx?$/, loader: "ts-loader" },
];
}

基本型別

現有的 JS 檔案把檔名都改為 TS 後,應該就可編譯過了 (JS 可以寫的東西,在 TS 中一定沒問題),我們先從 TS 中的基本型別開始加入

Any

所有的型別,和不定型別是一樣的意思,此時 TS 不會檢查 -> 除了這個,否則型別定了之後是改不了的

let a: any;
a = "1";
a = 1;

Boolean / String / Number

let a: boolean;
a = true; // ok
a = 1; // error

let b: number;
b = 3; // ok
b = 3.33; // ok

Array

必須要先說好,陣列裡面會有啥

let a: number[];
a = [1, 2, 3, 4]; // ok
a = [1, "2"]; // error

let b: any[];
b = [1, "2"]; // ok

定義型別

宣告時定義

let a: number;

若有初始值,可以省略,TS 會自動判斷

let a = 1;

Function

這是原本的 function

function add(x, y) {
return x + y;
}

const add = (x, y) => x + y;

在 TS 中,必須清楚的定義每個輸入參數的類型,輸出類型會自動按照 return 語句來判斷

function add(x: number, y: number): number {
return x + y;
}

const add = (x: number, y: number): number => x + y;

Interface

實際上我們會遇到很多非基本型別的物件,像這個例子

let tsBook = {
title: "Typescript 101",
sellout: false,
price: 100,
};
function buy(target) {
// ...
}
buy(tsBook);

在 TS 中,也可以對這種物件進行檢查,但 TS 只在意物件的形狀 (shape),屬於 duck typing

並非像強行別語言,利用 Class 的繼承來判斷

let tsBook = {
title: "Typescript 101",
sellout: false,
price: 100,
};
interface Book {
title: string;
sellout: boolean;
price: number;
}
function buy(target: Book) {
// ...
}
buy(tsBook);

上面的例子,只要你有 title , sellout 和 price 三個屬性並且型別符合,就能通過檢查

// error -> missing price
let tsBook1 = {
title: "Typescript 101",
sellout: false,
};

// error -> type error
let tsBook2 = {
title: "Typescript 101",
sellout: "NO",
price: 100,
};

// ok -> allow move properties
let tsBook3 = {
title: "Typescript 101",
sellout: false,
price: 100,
description: "bla bla bla",
};

TS 允許有多出來的資料,但建議使用 optional properties 來定好型別

interface Book {
title: string;
sellout: boolean;
price: number;
description?: string;
}

Class

以往的 JS 是沒有 Class 這個概念的,但我們可以使用 function 以及 properties 傳遞的方式達到類似繼承的功能,TS 加入了 Class,讓以前寫強行別語言的人,能比較舒服的使用物件導向設計開發

在 ES6 中,加入了 Class 的語法糖,這已經不是 TS 獨有的功能

class Book {
title: string;
constructor(title: string) {
this.title = title;
}
read() {
console.log(this.title);
}
}

const book1 = new Book("Typescript 101");
book1.read();

特別的是,如同 C# 等語言,Class 中 this 中的變數必須要使先宣告

class Book {
constructor(title: string) {
// error
this.title = title;
}
}

有了清楚的型別宣告,IDE 能告訴你所有可用的屬性,不必在回去爬 class 內的東西了

Private, Public, Protect

我們終於可以在前端也有強制的存取限制了

在過去,我們可能會有些共同的默契來定義私有屬性

class Book {
constructor(title: string) {
this._title = title;
}
}

現在,再也不用啦

class Book {
private title: string;
constructor(title: string) {
this.title = title;
}
read() {
console.log(this.title);
}
}

const book1 = new Book("Typescript 101");
// Error: Property 'title' is private and only accessible within class 'Book'.
book1.title;

TS 預設是 Public,上面的程式可以用 Parameter properties 來簡化

class Book {
constructor(private title: string) {}
read() {
console.log(this.title);
}
}

繼承

有了 Class ,很多物件導向的設計模式就可以實現,比如說繼承

class Animal {
move(distanceInMeters: number = 0) {
console.log(`Animal moved ${distanceInMeters}m.`);
}
}

class Dog extends Animal {
bark() {
console.log("Woof! Woof!");
}
}

const dog = new Dog();
dog.bark();
dog.move(10);
dog.bark();

和 ES Class 語法糖不一樣的是,TS 可以很清楚的知道,那些屬性是繼承而來的,那些屬性是本身的

Move

Bark

抽象類別

abstract class Department {
constructor(public name: string) {}
printName(): void {
console.log("Department name: " + this.name);
}
abstract printMeeting(): void;
}

class AccountingDepartment extends Department {
constructor() {
super("Accounting and Auditing");
}
// 一定要實作
printMeeting(): void {
console.log("The Accounting Department meets each Monday at 10am.");
}
}

案例展示

API 回應型別定義

前端對 API 溝通時,可以先把後端會產生的回應定義型別

  • 前後同時開發時,也正好先把格式講清楚
  • 當接到已經完成的後端時(例如第三方服務),可以利用 線上工具 快速產生模型 若有缺欄位也會自動變為 optional
import "./style.css";
import axios from "axios";
import { ApiResponse } from "./model";

async function getBooksData(): Promise<void> {
const apiResponse =
(await axios.get) <
ApiResponse >
"https://bookshelf.goodideas-studio.com/api";
// 我們不必再反覆的查詢文件來看回傳的內容到底是什麼
if (apiResponse.status === 200) {
const data = apiResponse.data;

// Write to document
data.list
.slice(0, 10)
.forEach((book) => document.write(book.name + "<br>"));
}
}

getBooksData();

Union Type

在 JS 中,我們可以隨意的改變變數的型別,這雖然會衍生些問題,但也是語言的精神

在 TS 中,除了使用 any 忽略檢查外,也可以使用 Union Type

let a: string;
a = "3";
// error
a = 3;

假設我們要建立一個 Function ,當她收到 Blob 物件時,把他轉為 Object Url 輸出,原本就是 Url 格式(string)時,原封不動的輸出

function blobToUrl(blob: Blob): string {
// transform blob to url string
return "";
}

function createUrl(data: Blob | string): string {
if (typeof data === "string") {
// TS 會知道 data 現在為 string
return data;
}
// TS 會知道 data 現在絕對是 Blob
return blobToUrl(data);
}

模組定義檔案 (Declaration files)

TS 最後編譯成原始的 JS 檔案,此時所有的型別宣告和其他資訊都會消失,如果是要給其他人使用的模組(例如上傳到 NPM),要怎麼樣保留這些資訊呢

我們需要定義檔案來還原資訊,用 TS 寫的專案在編譯的過程就可以自動產生這個檔案,至於 JS 寫的專案則會由作者或社群手動補充

例如 Axios 雖然是由 JS 寫成的,但專案內就有 index.d.ts 檔案 ( 定義檔案都會是 d.ts 結尾 ),所以我才可以輕易地在 TS 中使用,並且有嚴格的型別限制

export interface AxiosRequestConfig {
url?: string;
method?: Method;
baseURL?: string;
transformRequest?: AxiosTransformer | AxiosTransformer[];
transformResponse?: AxiosTransformer | AxiosTransformer[];
headers?: any;
params?: any;
paramsSerializer?: (params: any) => string;
data?: any;
timeout?: number;
withCredentials?: boolean;
adapter?: AxiosAdapter;
auth?: AxiosBasicCredentials;
responseType?: ResponseType;
xsrfCookieName?: string;
xsrfHeaderName?: string;
onUploadProgress?: (progressEvent: any) => void;
onDownloadProgress?: (progressEvent: any) => void;
maxContentLength?: number;
validateStatus?: (status: number) => boolean;
maxRedirects?: number;
socketPath?: string | null;
httpAgent?: any;
httpsAgent?: any;
proxy?: AxiosProxyConfig | false;
cancelToken?: CancelToken;
}

至於沒有定義檔的專案呢? https://aka.ms/types 收集了很多 Library 的 type 定義

例如 Lodash ,只需要安裝 Package,TS 預設就會自動找到定義

總結

這篇文章其實只是想要推大家進坑,並沒有提到開發時的環境設定,其實我覺得這部分才是最麻煩的地方,像是如何和 React 搭配,如何自動編譯和偵錯等等,使用 TS 開發其實會讓前期的開發速度慢下來,得為物件建立型別後才能繼續,也可能增加其他問題(例如定義檔和實際 JS 版本不同)。

值不值得導入道專案中還是得看專案規模、團隊成員來決定。