引言
作為一門強大的靜態類型檢查工具,如今在許多中大型應用程序以及流行的JS庫中均能看到TypeScript的身影。JS作為一門弱類型語言,在我們寫代碼的過程中稍不留神便會修改掉變量的類型,從而導致一些出乎意料的運行時錯誤。然而TypeScript在編譯過程中便能幫我們解決這個難題,不僅在JS中引入了強類型檢查,並且編譯后的JS代碼能夠運行在任何瀏覽器環境,Node環境和任何支持ECMAScript 3(或更高版本)的JS引擎中。最近公司剛好准備使用TypeScript來對現有系統進行重構,以前使用TypeScript的機會也不多,特別是一些有用的高級用法,所以借着這次機會,重新鞏固夯實一下這方面的知識點,如果有錯誤的地方,還請指出。
1、類繼承
在ES5中,我們一般通過函數或者基於原型的繼承來封裝一些組件公共的部分方便復用,然而在TypeScript中,我們可以像類似Java語言中以面向對象的方式使用類繼承來創建可復用的組件。我們可以通過class
關鍵字來創建類,並基於它使用new
操作符來實例化一個對象。為了將多個類的公共部分進行抽象,我們可以創建一個父類並讓子類通過extends
關鍵字來繼承父類,從而減少一些冗余代碼的編寫增加代碼的可復用性和可維護性。示例如下:
class Parent {
readonly x: number;
constructor() {
this.x = 1;
}
print() {
console.log(this.x);
}
}
class Child extends Parent {
readonly y: number;
constructor() {
// 注意此處必須優先調用super()方法
super();
this.y = 2;
}
print() {
// 通過super調用父類原型上的方法,但是方法中的this指向的是子類的實例
super.print();
console.log(this.y);
}
}
const child = new Child();
console.log(child.print()) // -> 1 2
在上述示例中,Child
子類中對父類的print
方法進行重寫,同時在內部使用super.print()
來調用父類的公共邏輯,從而實現邏輯復用。class
關鍵字作為構造函數的語法糖,在經過TypeScript編譯后,最終會被轉換為兼容性好的瀏覽器可識別的ES5代碼。class
在面向對象的編程范式中非常常見,因此為了弄清楚其背后的實現機制,我們不妨多花點時間來看下經過編譯轉換之后的代碼是什么樣子的(當然這部分已經比較熟悉的同學可以直接跳過)。
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return extendStatics(d, b);
}
return function (d, b) {
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var Parent = /** @class */ (function () {
function Parent() {
this.x = 1;
}
Parent.prototype.print = function () {
console.log(this.x);
};
return Parent;
}());
var Child = /** @class */ (function (_super) {
__extends(Child, _super);
function Child() {
var _this =
// 注意此處必須優先調用super()方法
_super.call(this) || this;
_this.y = 2;
return _this;
}
Child.prototype.print = function () {
// 通過super調用父類原型上的方法,但是方法中的this指向的是子類的實例
_super.prototype.print.call(this);
console.log(this.y);
};
return Child;
}(Parent));
var child = new Child();
console.log(child.print()); // -> 1 2
以上就是轉換后的完整代碼,為了方便對比,這里將原來的注釋信息保留,仔細研究這段代碼我們會發現以下幾個要點:
-
子類
Child
的構造函數中super()
方法被轉換成了var _this = _super.call(this) || this
,這里的_super
指的就是父類Parent
,因此這句代碼的含義就是調用父類構造函數並將this
綁定到子類的實例上,這樣的話子類實例便可擁有父類的x
屬性。因此為了實現屬性繼承,我們必須在子類構造函數中調用super()
方法,如果不調用會編譯不通過。 -
子類
Child
的print
方法中super.print()
方法被轉換成了_super.prototype.print.call(this)
,這句代碼的含義就是調用父類原型上的print
方法並將方法中的this
指向子類實例,由於在上一步操作中我們已經繼承到父類的x
屬性,因此這里我們將直接打印出子類實例的x
屬性的值。 -
extends
關鍵字最終被轉換為__extends(Child, _super)
方法,其中_super
指的是父類Parent
,為了方便查看,這里將_extends
方法單獨提出來進行研究。
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return extendStatics(d, b);
}
return function (d, b) {
// 第一部分
extendStatics(d, b);
// 第二部分
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
在以上代碼中,主要可以分為兩個部分來進行理解,第一部分為extendStatics(d, b)
方法,第二部分為該方法后面的兩行代碼。
第一部分
:
在extendStatics
方法內部雖然代碼量相對較多,但是不難發現其實還是主要為了兼容ES5版本的執行環境。在ES6中新增了Object.setPrototypeOf
方法用於手動設置對象的原型,但是在ES5的環境中我們一般通過一個非標准的__proto__
屬性來進行設置,Object.setPrototypeOf
方法的原理其實也是通過該屬性來設置對象的原型,其實現方式如下:
Object.setPrototypeOf = function(obj, proto) {
obj.__proto__ = proto;
return obj;
}
在extendStatics(d, b)
方法中,d
指子類Child
,b
指父類Parent
,因此該方法的作用可以解釋為:
// 將子類Child的__proto__屬性指向父類Parent
Child.__proto__ = Parent;
可以將這行代碼理解為構造函數的繼承,或者叫靜態屬性和靜態方法的繼承,即屬性和方法不是掛載到構造函數的prototype
原型上的,而是直接掛載到構造函數本身,因為在JS中函數本身也可以作為一個對象,並可以為其賦予任何其他的屬性,示例如下:
function Foo() {
this.x = 1;
this.y = 2;
}
Foo.bar = function() {
console.log(3);
}
Foo.baz = 4;
console.log(Foo.bar()) // -> 3
console.log(Foo.baz) // -> 4
因此當我們在子類Child
中以Child.someProperty
訪問屬性時,如果子類中不存在就會通過Child.__proto__
尋找父類的同名屬性,通過這種方式來實現靜態屬性和靜態方法的路徑查找。
第二部分
:
在第二部分中僅包含以下兩行代碼:
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
其中d
指子類Child
,b
指父類Parent
,這里對於JS中實現繼承的幾種方式比較熟悉的同學可以一眼看出,這里使用了寄生組合式繼承的方式,通過借用一個中間函數__()
來避免當修改子類的prototype
上的方法時對父類的prototype
所造成的影響。我們知道,在JS中通過構造函數實例化一個對象之后,該對象會擁有一個__proto__
屬性並指向其構造函數的prototype
屬性,示例如下:
function Foo() {
this.x = 1;
this.y = 2;
}
const foo = new Foo();
foo.__proto__ === Foo.prototype; // -> true
對於本例中,如果通過子類Child
來實例化一個對象之后,會產生如下關聯:
const child = new Child();
child.__proto__ === (Child.prototype = new __());
child.__proto__.__proto__ === __.prototype === Parent.prototype;
// 上述代碼等價於下面這種方式
Child.prototype.__proto__ === Parent.prototype;
因此當我們在子類Child
的實例child
對象中通過child.someMethod()
調用某個方法時,如果在實例中不存在該方法,則會沿着__proto__
繼續往上查找,最終會經過父類Parent
的prototype
原型,即通過這種方式來實現方法的繼承。
基於對以上兩個部分的分析,我們可以總結出以下兩點:
// 表示構造函數的繼承,或者叫做靜態屬性和靜態方法的繼承,總是指向父類
1. Child.__proto__ === Parent;
// 表示方法的繼承,總是指向父類的prototype屬性
2. Child.prototype.__proto__ === Parent.prototype;
2、訪問修飾符
TypeScript為我們提供了訪問修飾符(Access Modifiers)來限制在class
外部對內部屬性的訪問,訪問修飾符主要包含以下三種:
public
:公共修飾符,其修飾的屬性和方法都是公有的,可以在任何地方被訪問到,默認情況下所有屬性和方法都是public
的。private
:私有修飾符,其修飾的屬性和方法在class
外部不可見。protected
:受保護修飾符,和private
比較相似,但是其修飾的屬性和方法在子類內部是被允許訪問的。
我們通過一些示例來對幾種修飾符進行對比:
class Human {
public name: string;
public age: number;
public constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const man = new Human('tom', 20);
console.log(man.name, man.age); // -> tom 20
man.age = 21;
console.log(man.age); // -> 21
在上述示例中,由於我們將訪問修飾符設置為public
,因此我們通過實例man
來訪問name
和age
屬性是被允許的,同時對age
屬性重新賦值也是允許的。但是在某些情況下,我們希望某些屬性是對外不可見的,同時不允許被修改,那么我們就可以使用private
修飾符:
class Human {
public name: string;
private age: number; // 此處修改為使用private修飾符
public constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.
我們將age
屬性的修飾符修改為private
后,在外部通過man.age
對其進行訪問,TypeScript在編譯階段就會發現其是一個私有屬性並最終將會報錯。
注意:在TypeScript編譯之后的代碼中並沒有限制對私有屬性的存取操作。
編譯后的代碼如下:
var Human = /** @class */ (function () {
function Human(name, age) {
this.name = name;
this.age = age;
}
return Human;
}());
var man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age); // -> 20
使用private
修飾符修飾的屬性或者方法在子類中也是不允許訪問的,示例如下:
class Human {
public name: string;
private age: number;
public constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
class Woman extends Human {
private gender: number = 0;
public constructor(name: string, age: number) {
super(name, age);
console.log(this.age);
}
}
const woman = new Woman('Alice', 18);
// -> Property 'age' is private and only accessible within class 'Human'.
在上述示例中由於在父類Human
中age
屬性被設置為private
,因此在子類Woman
中無法訪問到age
屬性,為了讓在子類中允許訪問age
屬性,我們可以使用protected
修飾符來對其進行修飾:
class Human {
public name: string;
protected age: number; // 此處修改為使用protected修飾符
public constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
class Woman extends Human {
private gender: number = 0;
public constructor(name: string, age: number) {
super(name, age);
console.log(this.age);
}
}
const woman = new Woman('Alice', 18); // -> 18
當我們將private
修飾符用於構造函數時,則表示該類不允許被繼承或實例化,示例如下:
class Human {
public name: string;
public age: number;
// 此處修改為使用private修飾符
private constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
class Woman extends Human {
private gender: number = 0;
public constructor(name: string, age: number) {
super(name, age);
}
}
const man = new Human('Alice', 18);
// -> Cannot extend a class 'Human'. Class constructor is marked as private.
// -> Constructor of class 'Human' is private and only accessible within the class declaration.
當我們將protected
修飾符用於構造函數時,則表示該類只允許被繼承,示例如下:
class Human {
public name: string;
public age: number;
// 此處修改為使用protected修飾符
protected constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
class Woman extends Human {
private gender: number = 0;
public constructor(name: string, age: number) {
super(name, age);
}
}
const man = new Human('Alice', 18);
// -> Constructor of class 'Human' is protected and only accessible within the class declaration.
另外我們還可以直接將修飾符放到構造函數的參數中,示例如下:
class Human {
// public name: string;
// private age: number;
public constructor(public name: string, private age: number) {
this.name = name;
this.age = age;
}
}
const man = new Human('tom', 20);
console.log(man.name); // -> tom
console.log(man.age);
// -> Property 'age' is private and only accessible within class 'Human'.
3、接口與構造器簽名
當我們的項目中擁有很多不同的類時並且這些類之間可能存在某方面的共同點,為了描述這種共同點,我們可以將其提取到一個接口(interface)中用於集中維護,並使用implements
關鍵字來實現這個接口,示例如下:
interface IHuman {
name: string;
age: number;
walk(): void;
}
class Human implements IHuman {
public constructor(public name: string, public age: number) {
this.name = name;
this.age = age;
}
walk(): void {
console.log('I am walking...');
}
}
上述代碼在編譯階段能順利通過,但是我們注意到在Human
類中包含constructor
構造函數,如果我們想在接口中為該構造函數定義一個簽名並讓Human
類來實現這個接口,看會發生什么:
interface HumanConstructor {
new (name: string, age: number);
}
class Human implements HumanConstructor {
public constructor(public name: string, public age: number) {
this.name = name;
this.age = age;
}
walk(): void {
console.log('I am walking...');
}
}
// -> Class 'Human' incorrectly implements interface 'HumanConstructor'.
// -> Type 'Human' provides no match for the signature 'new (name: string, age: number): any'.
然而TypeScript會編譯出錯,告訴我們錯誤地實現了HumanConstructor
接口,這是因為當一個類實現一個接口時,只會對實例部分進行編譯檢查,類的靜態部分是不會被編譯器檢查的。因此這里我們嘗試換種方式,直接操作類的靜態部分,示例如下:
interface HumanConstructor {
new (name: string, age: number);
}
interface IHuman {
name: string;
age: number;
walk(): void;
}
class Human implements IHuman {
public constructor(public name: string, public age: number) {
this.name = name;
this.age = age;
}
walk(): void {
console.log('I am walking...');
}
}
// 定義一個工廠方法
function createHuman(constructor: HumanConstructor, name: string, age: number): IHuman {
return new constructor(name, age);
}
const man = createHuman(Human, 'tom', 18);
console.log(man.name, man.age); // -> tom 18
在上述示例中通過額外創建一個工廠方法createHuman
並將構造函數作為第一個參數傳入,此時當我們調用createHuman(Human, 'tom', 18)
時編譯器便會檢查第一個參數是否符合HumanConstructor
接口的構造器簽名。
4、聲明合並
在聲明合並中最常見的合並類型就是接口了,因此這里先從接口開始介紹幾種比較常見的合並方式。
4.1 接口合並
示例代碼如下:
interface A {
name: string;
}
interface A {
age: number;
}
// 等價於
interface A {
name: string;
age: number;
}
const a: A = {name: 'tom', age: 18};
接口合並的方式比較容易理解,即聲明多個同名的接口,每個接口中包含不同的屬性聲明,最終這些來自多個接口的屬性聲明會被合並到同一個接口中。
注意:所有同名接口中的非函數成員必須唯一,如果不唯一則必須保證類型相同,否則編譯器會報錯。對於函數成員,后聲明的同名接口會覆蓋掉之前聲明的同名接口,即后聲明的同名接口中的函數相當於一次重載,具有更高的優先級。
4.2 函數合並
函數的合並可以簡單理解為函數的重載,即通過同時定義多個不同類型參數或不同類型返回值的同名函數來實現,示例代碼如下:
// 函數定義
function foo(x: number): number;
function foo(x: string): string;
// 函數具體實現
function foo(x: number | string): number | string {
if (typeof x === 'number') {
return (x).toFixed(2);
}
return x.substring(0, x.length - 1);
}
在上述示例中,我們對foo
函數進行多次定義,每次定義的函數參數類型不同,返回值類型不同,最后一次為函數的具體實現,在實現中只有在兼容到前面的所有定義時,編譯器才不會報錯。
注意:TypeScript編譯器會優先從最開始的函數定義進行匹配,因此如果多個函數定義存在包含關系,則需要將最精確的函數定義放到最前面,否則將始終不會被匹配到。
4.3 類型別名聯合
類型別名聯合與接口合並有所區別,類型別名不會新建一個類型,只是創建一個新的別名來對多個類型進行引用,同時不能像接口一樣被實現(implements)
和繼承(extends)
,示例如下:
type HumanProperty = {
name: string;
age: number;
gender: number;
};
type HumanBehavior = {
eat(): void;
walk(): void;
}
type Human = HumanProperty & HumanBehavior;
let woman: Human = {
name: 'tom',
age: 18,
gender: 0,
eat() {
console.log('I can eat.');
},
walk() {
console.log('I can walk.');
}
}
class HumanComponent extends Human {
constructor(public name: string, public age: number, public gender: number) {
this.name = name;
this.age = age;
this.gender = gender;
}
eat() {
console.log('I can eat.');
}
walk() {
console.log('I can walk.');
}
}
// -> 'Human' only refers to a type, but is being used as a value here.
5、keyof 索引查詢
在TypeScript中的keyof
有點類似於JS中的Object.keys()
方法,但是區別在於前者遍歷的是類型中的字符串索引,后者遍歷的是對象中的鍵名,示例如下:
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
type keys = keyof Rectangle;
// 等價於
type keys = "x" | "y" | "width" | "height";
// 這里使用了泛型,強制要求第二個參數的參數名必須包含在第一個參數的所有字符串索引中
function getRectProperty<T extends object, K extends keyof T>(rect: T, property: K): T[K] {
return rect[property];
}
let rect: Rectangle = {
x: 50,
y: 50,
width: 100,
height: 200
};
console.log(getRectProperty(rect, 'width')); // -> 100
console.log(getRectProperty(rect, 'notExist'));
// -> Argument of type '"notExist"' is not assignable to parameter of type '"width" | "x" | "y" | "height"'.
在上述示例中我們通過使用keyof
來限制函數的參數名property
必須被包含在類型Rectangle
的所有字符串索引中,如果沒有被包含則編譯器會報錯,可以用來在編譯時檢測對象的屬性名是否書寫有誤。
6、Partial 可選屬性
在某些情況下,我們希望類型中的所有屬性都不是必需的,只有在某些條件下才存在,我們就可以使用Partial
來將已聲明的類型中的所有屬性標識為可選的,示例如下:
// 該類型已內置在TypeScript中
type Partial<T> = {
[P in keyof T]?: T[P]
};
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
type PartialRectangle = Partial<Rectangle>;
// 等價於
type PartialRectangle = {
x?: number;
y?: number;
width?: number;
height?: number;
}
let rect: PartialRectangle = {
width: 100,
height: 200
};
在上述示例中由於我們使用Partial
將所有屬性標識為可選的,因此最終rect
對象中雖然只包含width
和height
屬性,但是編譯器依舊沒有報錯,當我們不能明確地確定對象中包含哪些屬性時,我們就可以通過Partial
來聲明。
7、Pick 部分選擇
在某些應用場景下,我們可能需要從一個已聲明的類型中抽取出一個子類型,在子類型中包含父類型中的部分或全部屬性,這時我們可以使用Pick
來實現,示例代碼如下:
// 該類型已內置在TypeScript中
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
};
interface User {
id: number;
name: string;
age: number;
gender: number;
email: string;
}
type PickUser = Pick<User, "id" | "name" | "gender">;
// 等價於
type PickUser = {
id: number;
name: string;
gender: number;
};
let user: PickUser = {
id: 1,
name: 'tom',
gender: 1
};
在上述示例中,由於我們只關心user
對象中的id
,name
和gender
是否存在,其他屬性不做明確規定,因此我們就可以使用Pick
從User
接口中揀選出我們關心的屬性而忽略其他屬性的編譯檢查。
8、never 永不存在
never
表示的是那些永不存在的值的類型,比如在函數中拋出異常或者無限循環,never
類型可以是任何類型的子類型,也可以賦值給任何類型,但是相反卻沒有一個類型可以作為never
類型的子類型,示例如下:
// 函數拋出異常
function throwError(message: string): never {
throw new Error(message);
}
// 函數自動推斷出返回值為never類型
function reportError(message: string) {
return throwError(message);
}
// 無限循環
function loop(): never {
while(true) {
console.log(1);
}
}
// never類型可以是任何類型的子類型
let n: never;
let a: string = n;
let b: number = n;
let c: boolean = n;
let d: null = n;
let e: undefined = n;
let f: any = n;
// 任何類型都不能賦值給never類型
let a: string = '123';
let b: number = 0;
let c: boolean = true;
let d: null = null;
let e: undefined = undefined;
let f: any = [];
let n: never = a;
// -> Type 'string' is not assignable to type 'never'.
let n: never = b;
// -> Type 'number' is not assignable to type 'never'.
let n: never = c;
// -> Type 'true' is not assignable to type 'never'.
let n: never = d;
// -> Type 'null' is not assignable to type 'never'.
let n: never = e;
// -> Type 'undefined' is not assignable to type 'never'.
let n: never = f;
// -> Type 'any' is not assignable to type 'never'.
9、Exclude 屬性排除
與Pick
相反,Pick
用於揀選出我們需要關心的屬性,而Exclude
用於排除掉我們不需要關心的屬性,示例如下:
// 該類型已內置在TypeScript中
// 這里使用了條件類型(Conditional Type),和JS中的三目運算符效果一致
type Exclude<T, U> = T extends U ? never : T;
interface User {
id: number;
name: string;
age: number;
gender: number;
email: string;
}
type keys = keyof User; // -> "id" | "name" | "age" | "gender" | "email"
type ExcludeUser = Exclude<keys, "age" | "email">;
// 等價於
type ExcludeUser = "id" | "name" | "gender";
在上述示例中我們通過在ExcludeUser
中傳入我們不需要關心的age
和email
屬性,Exclude
會幫助我們將不需要的屬性進行剔除,留下的屬性id
,name
和gender
即為我們需要關心的屬性。一般來說,Exclude
很少單獨使用,可以與其他類型配合實現更復雜更有用的功能。
10、Omit 屬性忽略
在上一個用法中,我們使用Exclude
來排除掉其他不需要的屬性,但是在上述示例中的寫法耦合度較高,當有其他類型也需要這樣處理時,就必須再實現一遍相同的邏輯,不妨我們再進一步封裝,隱藏這些底層的處理細節,只對外暴露簡單的公共接口,示例如下:
// 使用Pick和Exclude組合實現
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
interface User {
id: number;
name: string;
age: number;
gender: number;
email: string;
}
// 表示忽略掉User接口中的age和email屬性
type OmitUser = Omit<User, "age" | "email">;
// 等價於
type OmitUser = {
id: number;
name: string;
gender: number;
};
let user: OmitUser = {
id: 1,
name: 'tom',
gender: 1
};
在上述示例中,我們需要忽略掉User
接口中的age
和email
屬性,則只需要將接口名和屬性傳入Omit
即可,對於其他類型也是如此,大大提高了類型的可擴展能力,方便復用。
總結
在本文中總結了幾種TypeScript的使用技巧,如果在我們的TypeScript項目中發現有很多類型聲明的地方具有共性,那么不妨可以使用文中的幾種技巧來對其進行優化改善,增加代碼的可維護性和可復用性。筆者之前使用TypeScript的機會也不多,所以最近也是一邊學習一邊總結,如果文中有錯誤的地方,還希望能夠在評論區指正。
交流
如果你覺得這篇文章的內容對你有幫助,能否幫個忙關注一下筆者的公眾號[前端之境],每周都會努力原創一些前端技術干貨,關注公眾號后可以邀你加入前端技術交流群,我們可以一起互相交流,共同進步。
文章已同步更新至Github博客,若覺文章尚可,歡迎前往star!
你的一個點贊,值得讓我付出更多的努力!
逆境中成長,只有不斷地學習,才能成為更好的自己,與君共勉!