在 JavaScript 中,函數是構建應用的一塊基石,我們可以使用函數抽離可復用的邏輯、抽象模型、封裝過程。在 TypeScript 中,雖然有類、命名空間、模塊,但是函數同樣是最基本、最重要的元素之一。
在 TypeScript 里,我們可以通過 function 字面量和箭頭函數的形式定義函數,示例如下:
function add() {}
const add = () => {}
我們還可以顯式指定函數參數和返回值的類型,示例如下。
const add = (a: number, b: number): number => {
? ? return a + b;
}
如上述示例中,參數名后的 ':number' 表示參數類型都是數字類型,圓括號后的 ': number' 則表示返回值類型也是數字類型。下面我們具體介紹一下返回值類型和參數類型。
返回值類型
在 JavaScript 中,我們知道一個函數可以沒有顯式 return,此時函數的返回值應該是 undefined:
function fn() {
? // TODO
}
console.log(fn()); // => undefined
需要注意的是,在 TypeScript 中,如果我們顯式聲明函數的返回值類型為 undfined,將會得到如下所示的錯誤提醒。
function fn(): undefined { // ts(2355) A function whose declared type is neither 'void' nor 'any' must return a value
? // TODO
}
此時,正確的做法是使用 void 類型來表示函數沒有返回值的類型(這是“廢柴” void 類型唯一有用的場景),示例如下:
function fn1(): void {
}
fn1().doSomething(); // ts(2339) Property 'doSomething' does not exist on type 'void'.
我們可以使用類似定義箭頭函數的語法來表示函數類型的參數和返回值類型,此時=> 類型僅僅用來定義一個函數類型而不用實現這個函數。
需要注意的是,這里的=>與 ES6 中箭頭函數的=>有所不同。TypeScript 函數類型中的=>用來表示函數的定義,其左側是函數的參數類型,右側是函數的返回值類型;而 ES6 中的=>是函數的實現。
如下示例中,我們定義了一個函數類型(這里我們使用了類型別名 type),并且使用箭頭函數實現了這個類型。
type Adder = (a: number, b: number) => number; // TypeScript 函數類型定義
const add: Adder = (a, b) => a + b; // ES6 箭頭函數
這里請注意:右側的箭頭函數并沒有顯式聲明類型注解,不過可以根據上下文類型進行推斷。
在對象(即接口類型)中,除了使用這種聲明語法,我們還可以使用類似對象屬性的簡寫語法來聲明函數類型的屬性,如下代碼所示:
interface Entity {
? ? add: (a: number, b: number)?=> number;
? ? del(a: number, b: number): number;
}
const entity: Entity = {
? ? add: (a, b) => a + b,
? ? del(a, b) {
? ? ? return a - b;
? ? },
};
在某種意義上來說,這兩種形式都是等價的。但是很多時候,我們不必或者不能顯式地指明返回值的類型,這就涉及可缺省和可推斷的返回值類型的講解。
可缺省和可推斷的返回值類型
幸運的是,函數返回值的類型可以在 TypeScript 中被推斷出來,即可缺省。
函數內是一個相對獨立的上下文環境,我們可以根據入參對值加工計算,并返回新的值。從類型層面看,我們也可以通過類型推斷加工計算入參的類型,并返回新的類型,示例如下:
function computeTypes(one: string, two: number) {
? const nums = [two];
? const strs = [one]
? return {
? ? nums,
? ? strs
? } // 返回 { nums: number[]; strs: string[] } 的類型
}
請記?。哼@是一個很重要也很有意思的特性,函數返回值的類型推斷結合泛型可以實現特別復雜的類型計算(本質是復雜的類型推斷,這里稱之為計算是為了表明其復雜性),比如 Redux Model 中 State、Reducer、Effect 類型的關聯。
一般情況下,TypeScript 中的函數返回值類型是可以缺省和推斷出來的,但是有些特例需要我們顯式聲明返回值類型,比如 Generator 函數的返回值。
Generator 函數的返回值
ES6 中新增的 Generator 函數在 TypeScript 中也有對應的類型定義。
Generator 函數返回的是一個 Iterator 迭代器對象,我們可以使用 Generator 的同名接口泛型或者 Iterator 的同名接口泛型表示返回值的類型(Generator 類型繼承了 Iterator 類型),示例如下:
type?AnyType?=?boolean;
type?AnyReturnType?=?string;
type?AnyNextType?=?number;
function?*gen():?Generator<AnyType, AnyReturnType, AnyNextType> {
? const nextValue = yield true; // nextValue 類型是 number,yield 后必須是 boolean 類型
? return `${nextValue}`; // 必須返回 string 類型
}
注意:TypeScript 3.6 之前的版本不支持指定 next、return 的類型,所以在某些有點歷史的代碼中,我們可能會看到 Generator 和 Iterator 類型不一樣的表述。
參數類型
了解了定義函數的基本語法以及返回值類型后,我們再來詳細看一下可選參數、默認參數、剩余參數的幾個特性。
可選參數和默認參數
在實際工作中,我們可能經常碰到函數參數可傳可不傳的情況,當然 TypeScript 也支持這種函數類型表達,如下代碼所示:
function log(x?: string) {
? return x;
}
log(); // => undefined
log('hello world'); // => hello world
在上述代碼中,我們在類型標注的:前添加?表示 log 函數的參數 x 就是可缺省的。
也就是說參數 x 的類型可能是 undefined類型或者是 string 類型,那是不是意味著可缺省和類型是 undefined 等價呢?我們來看看以下的示例:
function log(x?: string) {
? console.log(x);
}
function log1(x: string | undefined) {
? console.log(x);
}
log();
log(undefined);
log1(); // ts(2554) Expected 1 arguments, but got 0
log1(undefined);
答案顯而易見:這里的 ?: 表示參數可以缺省、可以不傳,也就是說調用函數時,我們可以不顯式傳入參數。但是,如果我們聲明了參數類型為 xxx | undefined(這里使用了聯合類型 |),就表示函數參數是不可缺省且類型必須是 xxx 或者 undfined。
因此,在上述代碼中,log1 函數如果不顯示傳入函數的參數,TypeScript 就會報一個 ts(2554) 的錯誤,即函數需要 1 個參數,但是我們只傳入了 0 個參數。
在 ES6 中支持函數默認參數的功能,而 TypeScript 會根據函數的默認參數的類型來推斷函數參數的類型,示例如下:
function log(x = 'hello') {
? ? console.log(x);
}
log(); // => 'hello'
log('hi'); // => 'hi'
log(1); // ts(2345) Argument of type '1' is not assignable to parameter of type 'string | undefined'
在上述示例中,根據函數的默認參數 'hello' ,TypeScript 推斷出了 x 的類型為 string | undefined。
當然,對于默認參數,TypeScript 也可以顯式聲明參數的類型(一般默認參數的類型是參數類型的子集時,我們才需要這么做)。不過,此時的默認參數只起到參數默認值的作用,如下代碼所示:
function log1(x: string = 'hello') {
? ? console.log(x);
}
// ts(2322) Type 'string' is not assignable to type 'number'
function log2(x: number = 'hello') {
? ? console.log(x);
}
log2();
log2(1);
log2('1'); // ts(2345) Argument of type '"1"' is not assignable to parameter of type 'number | undefined'
上例函數 log2 中,我們顯式聲明了函數參數 x 的類型為 number,表示函數參數 x 的類型可以不傳或者是 number 類型。因此,如果我們將默認值設置為字符串類型,編譯器就會拋出一個 ts(2322) 的錯誤。
同理,如果我們將函數的參數傳入了字符串類型,編譯器也會拋出一個 ts(2345) 的錯誤。
這里請注意:函數的默認參數類型必須是參數類型的子類型,下面我們看一下如下具體示例:
function log3(x: number | string = 'hello') {
? ? console.log(x);
}
在上述代碼中,函數 log3 的函數參數 x 的類型為可選的聯合類型 number | string,但是因為默認參數字符串類型是聯合類型 number | string 的子類型,所以 TypeScript 也會檢查通過。
剩余參數
在 ES6 中,JavaScript 支持函數參數的剩余參數,它可以把多個參數收集到一個變量中。同樣,在TypeScript 中也支持這樣的參數類型定義,如下代碼所示:
function sum(...nums: number[]) {
? ? return nums.reduce((a, b) => a + b, 0);
}
sum(1, 2); // => 3
sum(1, 2, 3); // => 6
sum(1, '2'); // ts(2345) Argument of type 'string' is not assignable to parameter of type 'number'
在上述代碼中,sum 是一個求和的函數,...nums將函數的所有參數收集到了變量 nums 中,而 nums 的類型應該是 number[],表示所有被求和的參數是數字類型。因此,sum(1, '2') 拋出了一個 ts(2345) 的錯誤,因為參數 '2' 并不是 number 類型。
如果我們將函數參數 nums 聚合的類型定義為 (number | string)[],如下代碼所示:
function sum(...nums: (number | string)[]): number {
? ? return nums.reduce<number>((a, b) => a + Number(b), 0);
}
sum(1, '2', 3); // 6
那么,函數的每一個參數的類型就是聯合類型 number | string,因此 sum(1, '2', 3) 的類型檢查也就通過了。
介紹完函數的參數,我們再來了解一下函數中另外一個重要的知識點 this。
this
眾所周知,在 JavaScript 中,函數 this 的指向一直是一個令人頭痛的問題。因為 this 的值需要等到函數被調用時才能被確定,更別說通過一些方法還可以改變 this 的指向。也就是說 this 的類型不固定,它取決于執行時的上下文。
但是,使用了 TypeScript 后,我們就不用擔心這個問題了。通過指定 this 的類型(嚴格模式下,必須顯式指定 this 的類型),當我們錯誤使用了 this,TypeScript 就會提示我們,如下代碼所示:
function say() {
? ? console.log(this.name); // ts(2683) 'this' implicitly has type 'any' because it does not have a type annotation
}
say();
在上述代碼中,如果我們直接調用 say 函數,this 應該指向全局 window 或 global(Node 中)。但是,在 strict 模式下的 TypeScript 中,它會提示 this 的類型是 any,此時就需要我們手動顯式指定類型了。
那么,在 TypeScript 中,我們應該如何聲明 this 的類型呢?
在 TypeScript 中,我們只需要在函數的第一個參數中聲明 this 指代的對象(即函數被調用的方式)即可,比如最簡單的作為對象的方法的 this 指向,如下代碼所示:
function say(this: Window, name: string) {
? ? console.log(this.name);
}
window.say = say;
window.say('hi');
const obj = {
? ? say
};
obj.say('hi'); // ts(2684) The 'this' context of type '{ say: (this: Window, name: string) => void; }' is not assignable to method's 'this' of type 'Window'.
在上述代碼中,我們在 window 對象上增加 say 的屬性為函數 say。那么調用window.say()時,this 指向即為 window 對象。
調用obj.say()后,此時 TypeScript 檢測到 this 的指向不是 window,于是拋出了如下所示的一個 ts(2684) 錯誤。
say('captain'); // ts(2684) The 'this' context of type 'void' is not assignable to method's 'this' of type 'Window'
需要注意的是,如果我們直接調用 say(),this 實際上應該指向全局變量 window,但是因為 TypeScript 無法確定 say 函數被誰調用,所以將 this 的指向默認為 void,也就提示了一個 ts(2684) 錯誤。
此時,我們可以通過調用 window.say() 來避免這個錯誤,這也是一個安全的設計。因為在 JavaScript 的嚴格模式下,全局作用域函數中 this 的指向是 undefined。
同樣,定義對象的函數屬性時,只要實際調用中 this 的指向與指定的 this 指向不同,TypeScript 就能發現 this 指向的錯誤,示例代碼如下:
interface Person {
? ? name: string;
? ? say(this: Person): void;
}
const person: Person = {
? ? name: 'captain',
? ? say() {
? ? ? ? console.log(this.name);
? ? },
};
const fn = person.say;
fn(); // ts(2684) The 'this' context of type 'void' is not assignable to method's 'this' of type 'Person'
注意:顯式注解函數中的 this 類型,它表面上占據了第一個形參的位置,但并不意味著函數真的多了一個參數,因為 TypeScript 轉譯為 JavaScript 后,“偽形參” this 會被抹掉,這算是 TypeScript 為數不多的特有語法。
當然,初次接觸這個特性時讓人費解,這就需要我們把它銘記于心。前邊的 say 函數轉譯為 JavaScript 后,this 就會被抹掉,如下代碼所示:
function say(name) {
? ? console.log(this.name);
}
同樣,我們也可以顯式限定類函數屬性中的 this 類型,TypeScript 也能檢查出錯誤的使用方式,如下代碼所示:
class Component {
? onClick(this: Component) {}
}
const component = new Component();
interface UI {
? addClickListener(onClick: (this: void) => void): void;
}
const ui: UI = {
? addClickListener() {}
};
ui.addClickListener(new Component().onClick); // ts(2345)
上面示例中,我們定義的 Component 類的 onClick 函數屬性(方法)顯式指定了 this 類型是 Component,在第 14 行作為入參傳遞給 ui 的 addClickListener 方法中,它指定的 this 類型是 void,兩個 this 類型不匹配,所以拋出了一個 ts(2345) 錯誤。
此外,在鏈式調用風格的庫中,使用 this 也可以很方便地表達出其類型,如下代碼所示:
class Container {
? private val: number;
? constructor(val: number) {
? ? this.val = val;
? }
? map(cb: (x: number) => number): this {
? ? this.val = cb(this.val);
? ? return this;
? }
? log(): this {
? ? console.log(this.val);
? ? return this;
? }
}
const instance = new Container(1)
? .map((x) => x + 1)
? .log() // => 2
? .map((x) => x * 3)
? .log(); // => 6?
因為 Container 類中 map、log 等函數屬性(方法)未顯式指定 this 類型,默認類型是 Container,所以以上方法在被調用時返回的類型也是 Container,this 指向一直是類的實例,它可以一直無限地被鏈式調用。
介紹完函數中 this 的指向和類型后,我們再來了解一下它的另外一個特性函數多態(函數重載)。
函數重載
JavaScript 是一門動態語言,針對同一個函數,它可以有多種不同類型的參數與返回值,這就是函數的多態。
而在 TypeScript 中,也可以相應地表達不同類型的參數和返回值的函數,如下代碼所示:
function convert(x: string | number | null): string | number | -1 {
? ? if (typeof x === 'string') {
? ? ? ? return Number(x);
? ? }
? ? if (typeof x === 'number') {
? ? ? ? return String(x);
? ? }
? ? return -1;
}
const x1 = convert('1'); // => string | number
const x2 = convert(1); // => string | number
const x3 = convert(null); // => string | number
在上述代碼中,我們把 convert 函數的 string 類型的值轉換為 number 類型,number 類型轉換為 string 類型,而將 null 類型轉換為數字 -1。此時, x1、x2、x3 的返回值類型都會被推斷成 string | number 。
那么,有沒有一種辦法可以更精確地描述參數與返回值類型約束關系的函數類型呢?有,這就是函數重載(Function Overload),如下示例中 1~3 行定義了三種各不相同的函數類型列表,并描述了不同的參數類型對應不同的返回值類型,而從第 4 行開始才是函數的實現。
function convert(x: string): number;
function convert(x: number): string;
function convert(x: null): -1;
function convert(x: string | number | null): any {
? ? if (typeof x === 'string') {
? ? ? ? return Number(x);
? ? }
? ? if (typeof x === 'number') {
? ? ? ? return String(x);
? ? }
? ? return -1;
}
const x1 = convert('1'); // => number
const x2 = convert(1); // => string
const x3 = convert(null); // -1
注意:函數重載列表的各個成員(即示例中的 1 ~ 3 行)必須是函數實現(即示例中的第 4 行)的子集,例如 “function convert(x: string): number”是“function convert(x: string | number | null): any”的子集。
在 convert 函數被調用時,TypeScript 會從上到下查找函數重載列表中與入參類型匹配的類型,并優先使用第一個匹配的重載定義。因此,我們需要把最精確的函數重載放到前面。例如我們在第 14 行傳入了字符串 '1',查找到第 1 行即匹配,而第 15 行傳入了數字 1,則查找到第 2 行匹配。
為了方便你理解這部分內容, 下面我們通過以下一個示例進行具體說明。
interface P1 {
? ? name: string;
}
interface P2 extends P1 {
? ? age: number;
}
function convert(x: P1): number;
function convert(x: P2): string;
function convert(x: P1 | P2): any {}
const x1 = convert({ name: "" } as P1); // => number
const x2 = convert({ name: "", age: 18 } as P2); // number
因為 P2 繼承自 P1,所以類型為 P2 的參數會和類型為 P1 的參數一樣匹配到第一個函數重載,此時 x1、x2 的返回值都是 number。
function convert(x: P2): string;
function convert(x: P1): number;
function convert(x: P1 | P2): any { }
const x1 = convert({ name: '' } as P1); // => number
const x2 = convert({ name: '', age: 18 } as P2); // => string
而我們只需要將函數重載列表的順序調換一下,類型為 P2 和 P1 的參數就可以分別匹配到正確的函數重載了,例如第 5 行匹配到第 2 行,第 6 行匹配到第 1 行。
類型謂詞(is)
在 TypeScript 中,函數還支持另外一種特殊的類型描述,如下示例 :
function?isString(s):?s?is?string?{ // 類型謂詞
??return?typeof?s?===?'string';
}
function?isNumber(n:?number)?{
??return?typeof?n?===?'number';
}
function?operator(x:?unknown)?{
??if(isString(x))?{?//?ok?x?類型縮小為?string
??}
??if?(isNumber(x))?{?//?ts(2345)?unknown?不能賦值給?number
??}
}
在上述代碼中,在添加返回值類型的地方,我們通過“參數名 + is + 類型”的格式明確表明了參數的類型,進而引起類型縮小,所以類型謂詞函數的一個重要的應用場景是實現自定義類型守衛。