本文來源於團隊內分享。TypeScript的官方文件雖然較為全面,但通讀下來卻要耗時不少;另外,TypeScript中文資料本身也比較缺乏,本文可作為準備嘗試TypeScript的同學入門使用,涵蓋了上手TypeScript之前所需要的所有基礎知識。
為什麼JS需要型別檢查
TypeScript的設計目標在這裡可以檢視到,簡單概括為兩點:
- 為JavaScript提供一個可選擇的型別檢查系統;
- 為JavaScript提供一個包含將來新特性的版本。
TypeScript的核心價值體現在第一點,第二點可以認為是TypeScript的向後相容性保證,也是TypeScript必須要做到的。
那麼為什麼JS需要做靜態型別檢查呢?在幾年前這個問題也許還會存在比較大的爭議,在前端日趨複雜的今天,經過像Google、Microsoft、FaceBook這樣的大公司實踐表明,型別檢查對於程式碼可維護性和可讀性是有非常大的幫助的,尤其針對於需要長期維護的規模性系統。
TypeScript優勢
在我看來,TypeScript能夠帶來最直觀上的好處有三點:
- 幫助更好地重構程式碼;
- 型別宣告本身是最好查閱的文件。
- 編輯器的智慧提示更加友好。
一個好的程式碼習慣是時常對自己寫過的程式碼進行小的重構,讓程式碼往更可維護的方向去發展。然而對於已經上線的業務程式碼,往往測試覆蓋率不會很高,當我們想要重構時,經常會擔心自己的改動會產生各種不可預知的bug。哪怕是一個小的重新命名,也有可能照顧不到所有的呼叫處造成問題。
如果是一個TypeScript專案,這種擔心就會大大降低,我們可以依賴於TypeScript的靜態檢查特性幫助找出一個小的改動(如重新命名)帶來的其他模組的問題,甚至對於模組檔案來說,我們可以直接藉助編輯器的能力進行“一鍵重新命名”
操作。
另外一個問題,如果你接手過一個老專案,肯定會頭痛於各種文件的缺失和幾乎沒有註釋的程式碼,一個好的TypeScript專案,是可以做到程式碼即文件的,通過宣告檔案我們可以很好地看出各個欄位的含義以及哪些是前端必須欄位:
// 砍價使用者資訊
export interface BargainJoinData {
curr_price: number; // 當前價
curr_ts: number; // 當前時間
init_ts: number; // 建立時間
is_bottom_price: number; // 砍到底價
}
複製程式碼
TypeScript對開發者是友好的
TypeScript在設計之初,就確定了他們的目標並不是要做多麼嚴格完備的型別強校驗系統,而是能夠更好地相容JS,更貼合JS開發者的開發習慣。可以說這是MS的商業戰略,也是TS能夠成功的關鍵性因素之一。它對JS的相容性主要表現為以下三個方面:
隱式的型別推斷
var foo = 123;
foo = "456"; // Error: cannot assign `string` to `number`
複製程式碼
當我們對一個變數或函式等進行賦值時,TypeScript能夠自動推斷型別賦予變數,TypeScript背後有非常強大的自推斷演算法幫助識別型別,這個特性無疑可以幫助我們簡化一些宣告,不必像強型別的語言那樣處處是宣告,也可以讓我們看程式碼時更加輕鬆。
結構化的型別
TypeScript旨在讓JS開發者更簡單地上手,因此將型別設計為“結構化”(Structural)的而非“名義式”(Nominal)的。
什麼意思呢?意味著TypeScript的型別並不根據定義的名字繫結,只要是形似的型別,不管名稱相不相同,都可以作為相容型別(這很像所謂的duck typing),也就是說,下面的程式碼在TypeScript中是完全合法的:
class Foo { method(input: string) { /* ... */ } }
class Bar { method(input: string) { /* ... */ } }
let test: Foo = new Bar(); // no Error!
複製程式碼
這樣實際上可以做到型別的最大化複用,只要形似,對於開發者也是最好理解的。(當然對於這個示例最好的做法是抽出一個公共的interface)
知名的JS庫支援
TypeScript有強大的DefinitelyTyped社群支援,目前型別宣告檔案基本上已經覆蓋了90%以上的常用JS庫,在編寫程式碼時我們的提示是非常友好的,也能做到安全的型別檢查。(在使用第三方庫時,可以現在這個專案中檢索一下有沒有該庫的TS宣告,直接引入即可)
回顧兩個基礎知識
在進入正式的TS型別介紹之前,讓我們先回顧一下JS的兩個基礎:
相等性判斷
我們都知道,在JS裡,兩個等號的判斷會進行隱式的型別轉換,如:
console.log(5 == "5"); // true
console.log(0 == ""); // true
複製程式碼
在TS中,因為有了型別宣告,因此這兩個結果在TS的型別系統中恆為false,因此會有報錯:
This condition will always return 'false' since the types '5' and '"5"' have no overlap.
複製程式碼
所以在程式碼層面,一方面我們要避免這樣兩個不同型別的比較,另一方面使用全等來代替兩個等號,保證在編譯期和執行期具有相同的語義。
對於TypeScript而言,只有null
和undefined
的隱式轉換是合理的:
console.log(undefined == undefined); // true
console.log(null == undefined); // true
console.log(0 == undefined); // false
console.log('' == undefined); // false
console.log(false == undefined); // false
複製程式碼
類(Class)
對於ES6的Class,我們本身已經很熟悉了,值得一提的是,目前對於類的靜態屬性、成員屬性等有一個提案——proposal-class-fields已經進入了Stage3,這個提案包含了很多東西,主要是類的靜態屬性、成員屬性、公有屬性和私有屬性。其中,私有屬性的提案在社群內引起了非常大的爭議,由於它的醜陋和怪異遭受各路人馬的抨擊,現TC39委員會已決定重新思考該提案。
現在讓我們來看看TypeScript對屬性訪問控制的情況:
可訪問性 | public | protected | private |
---|---|---|---|
類本身 | 是 | 是 | 是 |
子類 | 是 | 是 | 否 |
類的例項 | 是 | 否 | 否 |
可以看到,TS中的類成員訪問和其他語言非常類似:
class FooBase {
public x: number;
private y: number;
protected z: number;
}
複製程式碼
對於類的成員建構函式初始化,TS提供了一個簡單的宣告方式:
class Foo {
constructor(public x:number) {
}
}
複製程式碼
這段程式碼和下面是等同的:
class Foo {
x: number;
constructor(x:number) {
this.x = x;
}
}
複製程式碼
TS型別系統基礎
基本性準則
在正式瞭解TypeScript之前,首先要明確兩個基本概念:
- TypeScript的型別系統設計是可選的,意味著JavaScript就是TypeScript。
- TypeScript的報錯並不會阻止JS程式碼的生成,你可以漸進式地將JS逐步遷移為TS。
基本語法
:<TypeAnnotation>
複製程式碼
TypeScript的基本型別語法是在變數之後使用冒號進行型別標識,這種語法也揭示了TypeScript的型別宣告實際上是可選的。
原始值型別
var num: number;
var str: string;
var bool: boolean;
複製程式碼
TypeScript支援三種原始值型別的宣告,分別是number
、string
和boolean
。
對於這三種原始值,TS同樣支援以它們的字面量為型別:
var num: 123;
var str: '123';
var bool: true;
複製程式碼
這類字面量型別配合上聯合型別還是十分有用的,我們後面再講。
陣列型別
對於陣列的宣告也非常簡單,只需要加上一個中括號宣告型別即可:
var boolArray: boolean[];
複製程式碼
以上就簡單地定義了一個布林型別的陣列,大多數情況下,我們陣列的元素型別是固定的,如果我們陣列記憶體在不同型別的元素怎麼辦?
如果元素的個數是已知有限的,可以使用TS的元組型別:
var nameNumber: [string, number];
複製程式碼
該宣告也非常的形象直觀,如果元素個數不固定且型別未知,這種情況較為罕見,可直接宣告成any型別:
var arr: any[]
複製程式碼
介面型別
介面型別是TypeScript中最常見的組合型別,它能夠將不同型別的欄位組合在一起形成一個新的型別,這對於JS中的物件宣告是十分友好的:
interface Name {
first: string;
second: string;
}
var personName:Name = {
first: '張三'
} // Property 'second' is missing in type '{ first: string; }' but required in type 'Name'
複製程式碼
上述例子可見,TypeScript對每一個欄位都做了檢查,若未定義介面宣告的欄位(非可選),則檢查會丟擲錯誤。
內聯介面
對於物件來說,我們也可以使用內聯介面來快速宣告型別:
var personName:{ first: string, second: string } = {
first: '張三'
} // Property 'second' is missing in type '{ first: string; }' but required in type 'Name'
複製程式碼
內聯介面可以幫助我們快速宣告型別,但建議謹慎使用,對於可複用以及一般性的介面宣告建議使用interface宣告。
索引型別
對於物件而言,我們可以使用中括號的方式去存取值,對TS而言,同樣支援相應的索引型別:
interface Foo {
[key:string]: number
}
複製程式碼
對於索引的key型別,TypeScript只支援number
和string
兩種型別,且Number是string的一種特殊情況。
對於索引型別,我們在一般化的使用場景上更方便:
interface NestedCSS {
color?: string;
nest?: {
[selector: string]: NestedCSS;
}
}
const example: NestedCSS = {
color: 'red',
nest: {
'.subclass': {
color: 'blue'
}
}
}
複製程式碼
類的介面
對於介面而言,另一個重要作用就是類可以實現介面:
interface Point {
x: number; y: number;
z: number; // New member
}
class MyPoint implements Point { // ERROR : missing member `z`
x: number; y: number;
}
複製程式碼
對類而言,實現介面,意味著需要實現介面的所有屬性和方法,這和其他語言是類似的。
函式型別
函式是TypeScript中最常見的組成單元:
interface Foo {
foo: string;
}
// Return type annotated as `: Foo`
function foo(sample: Foo): Foo {
return sample;
}
複製程式碼
對於函式而言,本身有引數型別和返回值型別,都可進行宣告。
可選引數
對於引數,我們可以宣告可選引數,即在宣告之後加一個問號:
function foo(bar: number, bas?: string): void {
// ..
}
複製程式碼
void和never型別
另外,上述例子也表明,當函式沒有返回值時,可以用void
來表示。
當一個函式永遠不會返回時,我們可以宣告返回值型別為never
:
function bar(): never {
throw new Error('never reach');
}
複製程式碼
callable和newable
我們還可以使用介面來定義函式,在這種函式實現介面的情形下,我們稱這種定義為callable
:
interface Complex {
(bar?: number, ...others: boolean[]): number;
}
var foo: Complex;
複製程式碼
這種定義方式在可複用的函式宣告中非常有用。
callable還有一種特殊的情況,該宣告中指定了new
的方法名,稱之為newable
:
interface CallMeWithNewToGetString {
new(): string
}
var foo: CallMeWithNewToGetString;
new foo();
複製程式碼
這個在建構函式的宣告時非常有用。
函式過載
最後,一個函式可以支援多種傳參形式,這時候僅僅使用可選引數的約束可能是不夠的,如:
unction padding(a: number, b?: number, c?: number, d?: number) {
if (b === undefined && c === undefined && d === undefined) {
b = c = d = a;
}
else if (c === undefined && d === undefined) {
c = a;
d = b;
}
return {
top: a,
right: b,
bottom: c,
left: d
};
}
複製程式碼
這個函式可以支援四個引數、兩個引數和一個引數,如果我們粗略的將後三個引數都設定為可選引數,那麼當傳入三個引數時,TS也會認為它是合法的,此時就失去了型別安全,更好的方式是宣告函式過載:
function padding(all: number);
function padding(topAndBottom: number, leftAndRight: number);
function padding(top: number, right: number, bottom: number, left: number);
function padding(a: number, b?: number, c?: number, d?: number) {
//...
}
複製程式碼
函式過載寫法也非常簡單,就是重複宣告不同引數的函式型別,最後一個宣告包含了相容所有過載宣告的實現。這樣,TS型別系統就能準確的判斷出該函式的多型性質了。
使用callable
的方式也可以宣告過載:
interface Padding {
(all: number): any
(topAndBottom: number, leftAndRight: number): any
(top: number, right: number, bottom: number, left: number): any
}
複製程式碼
特殊型別
any
any
在TypeScript中是一個比較特殊的型別,宣告為any
型別的變數就像動態語言一樣不受約束,好像關閉了TS的型別檢查一般。對於any
型別的變數,可以將其賦予任何型別的值:
var power: any;
power = '123';
power = 123;
複製程式碼
any
對於JS程式碼的遷移是十分友好的,在已經成型的TypeScript專案中,我們要慎用any
型別,當你設定為any
時,意味著告訴編輯器不要對它進行任何檢查。
null和undefined
null
和undefined
作為TypeScript的特殊型別,它同樣有字面量的含義,之前我們已經瞭解到。
值得注意的是,null
和undefined
可以賦值給任意型別的變數:
var num: number;
var str: string;
// 賦值給任意型別的變數都是合法的
num = null;
str = undefined;
複製程式碼
void和never
在函式型別中,我們已經介紹了兩種型別,專門修飾函式返回值。
readonly
readonly
是隻讀屬性的修飾符,當我們的屬性是隻讀時,可以用該修飾符加以約束,在類中,用readonly
修飾的屬性僅可以在建構函式中初始化:
class Foo {
readonly bar = 1; // OK
readonly baz: string;
constructor() {
this.baz = "hello"; // OK
}
}
複製程式碼
一個實用場景是在react
中,props
和state
都是隻讀的:
interface Props {
readonly foo: number;
}
interface State {
readonly bar: number;
}
export class Something extends React.Component<Props,State> {
someMethod() {
this.props.foo = 123; // ERROR: (props are immutable)
this.state.baz = 456; // ERROR: (one should use this.setState)
}
}
複製程式碼
當然,React
本身在類的宣告時會對傳入的props
和state
做一層ReadOnly
的包裹,因此無論我們是否在外面顯式宣告,賦值給props
和state
的行為都是會報錯的。
注意,readonly
聽起來和const
有點像,需要時刻保持一個概念:
readonly
是修飾屬性的const
是宣告變數的
泛型
在更加一般化的場景,我們的型別可能並不固定已知,它和any
有點像,只不過我們希望在any
的基礎上能夠有更近一步的約束,比如:
function reverse<T>(items: T[]): T[] {
var toreturn = [];
for (let i = items.length - 1; i >= 0; i--) {
toreturn.push(items[i]);
}
return toreturn;
}
複製程式碼
reverse
函式是一個很好的示例,對於一個通用的函式reverse
來說,陣列元素的型別是未知的,可以是任意型別,但reverse
函式的返回值也是個陣列,它和傳入的陣列型別是相同的,對於這個約束,我們可以使用泛型,其語法是尖括號,內建泛型變數,多個泛型變數用逗號隔開,泛型變數名稱沒有限制,一般而言我們以大寫字母開頭,多個泛型變數使用其語義命名,加上T
為字首。
在呼叫時,可以顯示的指定泛型型別:
var reversed = reverse<number>([1, 2, 3]);
複製程式碼
也可以利用TypeScript的型別推斷,進行隱式呼叫:
var reversed = reverse([1, 2, 3]);
複製程式碼
由於我們的引數型別是T[]
,而傳入的陣列型別是一個number[]
,此時T
的型別被TypeScript自動推斷為number
。
對於泛型而言,我們同樣可以作用於介面和類:
interface Array<T> {
reverse(): T[];
// ...
}
複製程式碼
聯合型別
在JS中,一個變數的型別可能擁有多個,比如:
function formatCommandline(command: string[]|string) {
var line = '';
if (typeof command === 'string') {
line = command.trim();
} else {
line = command.join(' ').trim();
}
}
複製程式碼
此時我們可以使用一個|
分割符來分割多種型別,對於這種複合型別,我們稱之為聯合型別
。
交叉型別
如果說聯合型別的語義等同於或者
,那麼交叉型別的語義等同於集合中的並集
,下面的extend
函式是最好的說明:
function extend<T, U>(first: T, second: U): T & U {
let result = <T & U> {};
for (let id in first) {
result[id] = first[id];
}
for (let id in second) {
if (!result.hasOwnProperty(id)) {
result[id] = second[id];
}
}
return result;
}
複製程式碼
該函式最終以T&U
作為返回值值,該型別既包含了T
的欄位,也包含了U
的欄位,可以看做是兩個型別的並集
。
型別別名
TypeScript為型別的複用提供了更便捷的方式——型別別名。當你想複用型別時,可能在該場景下要為已經宣告的型別換一個名字,此時可以使用type關鍵字來進行型別別名的定義:
interface state {
a: 1
}
export type userState = state;
複製程式碼
我們同樣可以使用type來宣告一個型別:
type Text = string | { text: string };
type Coordinates = [number, number];
type Callback = (data: string) => void;
複製程式碼
對於type和interface的取捨:
- 如果要用交叉型別或聯合型別,使用type。
- 如果要用extend或implement,使用interface。
- 其餘情況可看個人喜好,個人建議type更多應當用於需要起別名時,其他情況儘量使用interface。
列舉型別
對於組織一系列相關值的集合,最好的方式應當是列舉,比如一系列狀態集合,一系列歸類集合等等。
在TypeScript中,列舉的方式非常簡單:
enum Color {
Red,
Green,
Blue
}
var col = Color.Red;
複製程式碼
預設的列舉值是從0開始,如上述程式碼,Red=0
,Green=1
依次類推。
當然我們還可以指定初始值:
enum Color {
Red = 3,
Green,
Blue
}
複製程式碼
此時Red=3
, Green=4
依次類推。
大家知道在JavaScript中是不存在列舉型別的,那麼TypeScript的列舉最終轉換為JavaScript是什麼樣呢?
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color[Color["Green"] = 1] = "Green";
Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));
複製程式碼
從編譯後的程式碼可以看到,轉換為一個key-value的物件後,我們的訪問也非常方便:
var red = Color.Red; // 0
var redKey = Color[0]; // 'Red'
var redKey = Color[Color.Red]; // 'Red'
複製程式碼
既可以通過key來訪問到值,也可以通過值來訪問到key。
Flag標識位
對於列舉,有一種很實用的設計模式是使用位運算來標識(Flag)狀態:
enum EnvFlags {
None = 0,
QQ = 1 << 0,
Weixin = 1 << 1
}
function initShare(flags: EnvFlags) {
if (flags & EnvFlags.QQ) {
initQQShare();
}
if (flags & EnvFlags.Weixin) {
initWeixinShare();
}
}
複製程式碼
在我們使用標識位時,可以遵循以下規則:
- 使用
|=
增加標誌位 - 使用
&=
和~
清除標誌位 - 使用
|
聯合標識位
如:
var flag = EnvFlags.None;
flag |= EnvFlags.QQ; // 加入QQ標識位
Flag &= ~EnvFlags.QQ; // 清除QQ標識位
Flag |= EnvFlags.QQ | EnvFlags.Weixin; // 加入QQ和微信標識位
複製程式碼
常量列舉
在列舉定義加上const
宣告,即可定義一個常量列舉:
enum Color {
Red = 3,
Green,
Blue
}
複製程式碼
對於常量列舉,TypeScript在編譯後不會產生任何執行時程式碼,因此在一般情況下,應當優先使用常量列舉,減少不必要程式碼的產生。
字串列舉
TypeScript還支援非數字型別的列舉——字串列舉
export enum EvidenceTypeEnum {
UNKNOWN = '',
PASSPORT_VISA = 'passport_visa',
PASSPORT = 'passport',
SIGHTED_STUDENT_CARD = 'sighted_tertiary_edu_id',
SIGHTED_KEYPASS_CARD = 'sighted_keypass_card',
SIGHTED_PROOF_OF_AGE_CARD = 'sighted_proof_of_age_card',
}
複製程式碼
這類列舉和我們之前使用JavaScript定義常量集合的方式很像,好處在於除錯或日誌輸出時,字串比數字要包含更多的語義。
名稱空間
在沒有模組化的時代,我們為了防止全域性的命名衝突,經常會以名稱空間的形式組織程式碼:
(function(something) {
something.foo = 123;
})(something || (something = {}))
複製程式碼
TypeScript內建了namespace
變數幫助定義名稱空間:
namespace Utility {
export function log(msg) {
console.log(msg);
}
export function error(msg) {
console.error(msg);
}
}
複製程式碼
對於我們自己的工程專案而言,一般建議使用ES6模組的方式去組織程式碼,而名稱空間的模式可適用於對一些全域性庫的宣告,如jQuery:
namespace $ {
export function ajax(//...) {}
}
複製程式碼
當然,名稱空間還可以便捷地幫助我們宣告靜態方法,如和enum
的結合使用:
enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
}
namespace Weekday {
export function isBusinessDay(day: Weekday) {
switch (day) {
case Weekday.Saturday:
case Weekday.Sunday:
return false;
default:
return true;
}
}
}
const mon = Weekday.Monday;
const sun = Weekday.Sunday;
console.log(Weekday.isBusinessDay(mon)); // true
console.log(Weekday.isBusinessDay(sun)); // false
複製程式碼
關於命名規範
變數名、函式和檔名
- 推薦使用駝峰命名。
// Bad
var FooVar;
function BarFunc() { }
// Good
var fooVar;
function barFunc() { }
複製程式碼
類、名稱空間
- 推薦使用帕斯卡命名。
- 成員變數和方法推薦使用駝峰命名。
// Bad
class foo { }
// Good
class Foo { }
// Bad
class Foo {
Bar: number;
Baz() { }
}
// Good
class Foo {
bar: number;
baz() { }
}
複製程式碼
Interface、type
- 推薦使用帕斯卡命名。
- 成員欄位推薦使用駝峰命名。
// Bad
interface foo { }
// Good
interface Foo { }
// Bad
interface Foo {
Bar: number;
}
// Good
interface Foo {
bar: number;
}
複製程式碼
關於模組規範
export default
的爭論
關於是否應該使用export default
在這裡有詳盡的討論,在AirBnb規範中也有prefer-default-export
這條規則,但我認為在TypeScript中應當儘量不使用export default
:
關於連結中提到的重新命名問題, 甚至自動import,其實export default也是可以做到的,藉助編輯器和TypeScript的靜態能力。所以這一點還不是關鍵因素。
不過使用一般化的export
更讓我們容易獲得智慧提示:
import /* here */ from 'something';
複製程式碼
在這種情況下,一般編輯器是不會給出智慧提示的。 而這種:
import { /* here */ } from 'something';
複製程式碼
我們可以通過智慧提示做到快速引入。
除了這一點外,還有以下幾點好處:
- 對CommonJS是友好的,如果使用export default,在commonJS下需要這樣引入:
const {default} = require('module/foo');
複製程式碼
多了個default無疑感覺非常奇怪。
- 對動態import是友好的,如果使用export default,還需要顯示的通過default欄位來訪問:
const HighChart = await import('https://code.highcharts.com/js/es-modules/masters/highcharts.src.js');
Highcharts.default.chart('container', { ... }); // 注意 `.default`
複製程式碼
- 對於
re-exporting
是友好的,如果使用export default,那麼進行re-export
會比較麻煩:
import Foo from "./foo"; export { Foo }
複製程式碼
相比之下,如果沒有export default
,我們可以直接使用:
export * from "./foo"
複製程式碼
實踐中的一些坑
實踐篇即將到來,敬請期待~