0. 前言
怎么上... 咳咳,大家別想歪,這是一篇純技♂術文章。
0.1 Why TypeScript
什么?尤大要把Vue 3.0全部改成用Typescript來寫?這不是逗我嗎,那我是不是要用TypeScript來寫Vue應用了?

好吧,Vue3.0可能最快也要19年年末才出來,Vue3.0是會對Ts使用者更友好,而不是只能用ts了,尤大使用ts的原因也是因為ts的靜態類型檢測以及ts的表現比flow越來越好了。自從巨硬大步邁向開源,前端圈子多了很多新工具比如VS Code、TypeScript。個人認為TypeScript真正火起來還是因為前端應用的復雜度不斷飆升,這帶來的問題就是維護性以及擴展性會變差。尤其在編寫類庫的時候,更是需要考慮各個類以及方法的復用性和擴展性,所以會使用到設計模式來優化代碼。還有更重要的就是,編碼效率的提高,靜態系統無疑是降低了調試bug的時間。
0.2 Advantages & Disadvantages
優點
-
靜態類型系統,可以借助編譯器幫助在編譯期間處理錯誤,提前避免在運行時可能發生的錯誤,無形中提高了代碼的可靠性。
-
其次是如果程序中確定了數據類型,編譯器可以針對這些信息對程序進行優化。(Typescript是編譯為JavaScript,針對JS的基本數據類型進行優化)。
-
社區上的工具很多, VS code的支持非常給力, 類型提示以及Reference標記都很贊,開發者工具和體驗可以說是JS世界中做得做好。
缺點
-
學習曲線,對於沒有Java/C++等靜態語言背景的程序員可能會需要有適應期。
-
Typescript作為靜態類型語言需要程序員依照契約編寫程序,為每個變量規定類型,除了Javascript本身的string、number等基本類型,還需要通過Interface關鍵字為復合結構聲明類型。
-
類型的聲明會增加更多代碼,在程序編寫過程中,這些細節會將程序員的精力從業務邏輯上分散開來。
let foo = 123;
foo = '456'; // Error: cannot assign `string` to `number
復制代碼
- TypeScript支持ES2015+的新特性,隨着標准的發展,新特性會被不斷加入TypeScript中,使用TypeScript可以通過編譯來規避在一些版本不高的瀏覽器中使用新特性的風險。
1. 工程實踐
1.1 老生常談webpack配置
Webpack已經發布到版本4.41了,相信很多小伙伴已經上了webpack4了,Webpack4對typescript的支持也是8錯的,它最大的變化莫過於"零配置"以及將commonChunks plugin插件嵌入為webpack內置。最新版本:

- 首先是安裝TypeScript,TypeScript是JavaScript的超集,擁有很多原生沒有的特性或者說是語法糖,同時瀏覽器無法直接運行它,需要有一個編譯的過程,即將TypeScript編譯為JavaScript,所以需要先安裝typescript
npm install -g typescript
復制代碼
- 然后來試試編譯,本地安裝完之后,就可以對后綴為.ts的文件進行編譯,輸出為標准的JavaScript文件。 假設我們有一個用TypeScript編寫的Student類。
class Student {
private name: string;
constructor(name: string) {
this.name = name;
}
}
復制代碼
使用typescript compiler來編譯它
tsc student.ts
復制代碼
編譯后的結果是根據編譯選項來生成的標准JavaScript文件。
var Student = /** @class */ (function () {
function Student(name) {
this.name = name;
}
return Student;
}());
復制代碼
- 命令行進行編譯適用於對單個或少量的typescript文件的情況,如果要使用typescript來編寫大型應用或類庫,就需要配置webpack在構建的時候自動編譯整個項目。使用Webpack配置TypeScript項目,遵循的流程是:
TypeScript-->ES Next的Javascript版本-->兼容性較好的JavaScript。
復制代碼
值得注意
之前已經安裝了TypeScript compiler,通常會在compiler option中指定typescript是要編譯到支持ES5/ES6/ES Next的JavaScript版本,但是在實踐中我們還需要利用Babel這個結果再進行一次轉譯,這么做的原因有兩個。
- TypeScript編譯器編譯的結果還不能直接用於生產環境,使用Babel可以通過browserlist來轉譯出兼容性適用於生產環境的js代碼。
- Babel可以引入polyfill,通常會把TypeScript的編譯目標設置為ES Next,然后Babel可以根據需要引入polyfill,使得最后生成的js代碼體積是最少的。
const path = require('path')
const webpack = require('webpack')
const config = {
entry: './src/index.ts',
module: {
rules: [
{
// ts-loader: convert typescript to javascript(esnext),
// babel-loader: converts javascript(esnext) to javascript(backward compatibility)
test: /\.(tsx|ts)?$/,
use: ['babel-loader', 'ts-loader'],
exclude: /node_modules/
},
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'@': path.resolve(__dirname, './src'),
'mobx': path.resolve(__dirname, './node_modules/mobx/lib/mobx.es6.js')
}
},
}
復制代碼
1.2 Typescript 編譯器配置
簡單介紹一下typescript的編譯選項,通常會在這里指定編譯目標JS版本,代碼的模塊化方式以及代碼的檢查規則等。
-
allowJS
表示是否允許編譯JavaScript文件。 -
target
表示ECMAScript目標版本,比如‘ESNext’、'ES2015'。 -
module
表示模塊化的方式,比如'commonjs'、'umd'或'es2105'(es module) -
moduleResolution
表示的是模塊解析的策略,即告訴編譯器在哪里找到當前模塊,指定為'node'時,就采用nodejs的模塊解析策略,完整算法可以在Node.js module documentation找到;當它的值指定為'classic'時則采用TypeScript默認的解析策略,這種策略主要是為了兼容舊版本的typescript。 -
strict
是否啟動所有的嚴格類型檢查選型,包括'noImplicitAny','noImplicitThis'等。 -
lib
表示編譯過程中需要引入的庫文件的列表,根據實際應用場景來引入。 -
experimentalDecorators
是為了支持裝飾器語法的選項,因為在項目中使用了Mobx做狀態管理,所以需要啟用裝飾器語法。 -
include
選項表示編譯的目錄 -
outDir
表示編譯結果輸出的目錄。
{
"compileOnSave": true,
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"sourceMap": true,
"strict": true,
"allowJs": true,
"experimentalDecorators": true,
"outDir": "./dist/",
"lib": [
"es2015", "dom", "es2016", "es2017", "dom.iterable", "scripthost", "webworker"
]
},
"include": [
"src/**/*.ts"
]
}
復制代碼
1.3 tslint實踐
tslint是針對typescript的lint工具,類似eslint遵循Airbnb Style或Standard Style,eslint也可以指定要遵循的typescript規范,目前在tslint官方,給出了三種內置預設,recommended
、latest
以及all
,省去了我們去對tslint每條規則進行配置的麻煩。
-
recommended
是穩定版的規則集,一般的typescript項目中使用它比較好,遵循SemVer。 -
latest
會不斷更新以包含每個TSLint版本中最新規則的配置,一旦TSLint發布了break change,這個配置也會跟隨着一起更新。 -
all
將所有規則配置為最為嚴格的配置。
tslint規則
tslint的規則是有嚴重性等級的划分,每條規則可以配置default
error
warning
或off
。tslint預設提供了很多在代碼實踐中提煉出來的規則,我認為有下面若干的規則,我們會經常遇到,或者需要關注一下。
-
only-arrow-functions
只允許使用箭頭函數,不允許傳統的函數表達式。 -
promise-function-async
任何返回promise的函數或方法,都應該使用'async'標識出來; -
await-promise
在'await'關鍵字后面跟隨的值不是promise時會警告,規范我們異步代碼的編寫。 -
no-console
禁止在代碼中使用'console'方法,便於去除無用的調試代碼。 -
no-debugger
禁止在代碼中使用'debugger'方法,同上。 -
no-shadowed-variable
當在局部作用域和外層作用域存在同名的變量時,稱為shadowing,這會導致局部作用域會無法訪問外層作用域中的同名變量。 -
no-unused-variable
不允許存在,未使用的變量、import或函數等。這個規則的意義在於避免編譯錯誤,同時因為聲明了變量卻不適用,也導致了讀者混淆。 -
max-line-length
要求每行的字數有限制; -
quotemark
指定對字符串常量,使用的符號,一般指定'single';這個看團隊風格了。 -
prefer-const
盡可能用'const'聲明變量,而不是'let',不會被重復賦值的變量,默認使用'const';
其他規則大家可以詳細看tslint官方文檔,使用lint可以更好地規范代碼風格,保持團隊代碼風格的統一,避免容易導致編譯錯誤的問題以及提高可讀性和維護性。
tslint的特殊flags
我們用ts寫代碼的時候,經常會遇到一行代碼的字數過長的情況,此時可以使用tslint提供的flag來使得該行不受規則的約束。
// tslint:disable-next-line:max-line-length
private paintPopupWithFade<T extends THREE.Object3D>(paintObj: T, popupStyleoption: PopupStyleOption, userDataType: number) {
//...
}
復制代碼
實際上,tslint提示是該行的字數違反了 max-line-length規則,此處可以通過增加注釋 // tslint: disable-next-line: rulex
來禁用這個規則。
2. Typescript類型系統避坑tips
2.1 "鴨子"類型
"鴨子"類型??(黑人問號), 第一次看到這名詞我也很懵逼, 其實它說的是結構型類型,而目前類型檢測主要分為結構型(structural)類型以及名義型(nominal)類型。
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
var point2D: Point2D = { x:0, y: 10}
var point3D: Point3D = { x: 0, y: 10, z: 20}
function iTakePoint2D(point: Point2D) { /*do sth*/ }
iTakePoint2D(point2D); // 類型匹配
iTakePoint2D(point3D); // 類型兼容,結構類型
iTakePoint2D({ x:0 }); // 錯誤: missing information `y`
復制代碼
區別
-
結構型類型中的類型檢測和判斷的依據是類型的結構,會看它有哪些屬性,分別是什么類型;而不是類型的名稱或者類型的id。
-
名義類型是靜態語言Java、C等語言所使用的,簡單來說就是,如果兩個類型的類型名不同,那么這兩個類型就是不同的類型了,盡管兩個類型是相同的結構。
-
Typescript中的類型是結構型類型,類型檢查關注的是值的形狀,即鴨子類型duck typing, 而且一般通過interface定義類型,其實就是定義形狀與約束~ 所以定義interface其實是針對結構來定義新類型。對於Typescript來說,兩個類型只要結構相同,那么它們就是同樣的類型。
2.2 類型判斷/區分類型
知道了typescript是個'鴨子類型'后,我們就會想到一個問題,ts這種鴨子類型
怎么判斷類型啊,比如下面這個例子:
public convertString2Image(customizeData: UserDataType) {
if (Helper.isUserData(customizeData)) {
const errorIcon = searchImageByName(this.iconImage, statusIconKey);
if (errorIcon) {
(customizeData as UserData).title.icon = errorIcon;
}
} else if (Helper.isUserFloorData(customizeData)) {
// do nothing
} else {
// UserAlertData
let targetImg;
const titleIcon = (customizeData as UserAlertData)!.title.icon;
if (targetImg) {
(customizeData as UserAlertData).title.icon = targetImg;
}
}
return customizeData;
}
復制代碼
該方法是根據傳入的用戶數據來將傳入的icon字段用實際對應的圖片填充,customizeData
是用戶數據,此時我們需要根據不同類型來調用searchImageByName
方法去加載對應的圖片,所以我們此時需要通過一些類型判斷的方法在運行時判斷出該對象的類型。
基礎的類型判斷
基本的類型判斷方法我們可能會想到typeof
和instanceof
,在ts中,其實也可以使用這兩個操作符來判斷類型,比如:
- 使用
typeof
判斷類型
function doSomething(x: number | string) {
if(typeof x === 'string') {
console.log(x.toFixed()); // Property 'toFixed' does not exist on type 'string'
console.log(x.substr(1));
} else if (typeof x === 'number') {
console.log(x.toFixed());
console.log(x.substr(1)); // Property 'substr' does not exist on type 'number'.
}
}
復制代碼
可以看到使用typeof
在運行時判斷基礎數據類型是可行的,可以在不同的條件塊中針對不同的類型執行不同的業務邏輯,但是對於Class
或者Interface
定義的非基礎類型,就必須考慮其他方式了。
- 使用
instanceof
判斷類型 下面這個例子根據傳入的geo
對象的類型執行不同的處理邏輯:
public addTo(geo: IMap | IArea | Marker) {
this.gisObj = geo;
this.container = this.draw()!;
if (!this.container) {
return;
}
this.mapContainer.appendChild<HTMLDivElement>(this.container!);
if (this.gisObj instanceof IMap) {
this.handleDuration();
} else if(this.gisObj instanceof Marker) {
//
}
}
復制代碼
可以看到,使用instanceof
動態地判斷類型是可行的,而且類型可以是Class
關鍵字聲明的類型,這些類型都擁有復雜的結構,而且擁有構造函數。總地來說,使用instanceof
判斷類型的兩個條件是:
- 必須是擁有構造函數的類型,比如類類型。
- 構造函數
prototype
屬性類型不能為any
。
利用類型謂詞來判斷類型 結合一開始的例子,我們要去判斷一個鴨子類型,在ts中,我們有特殊的方式,就是類型謂詞
(type predicate)的概念,這是typescript的類型保護機制,它會在運行時檢查確保在特定作用域內的類型。針對那些Interface
定義的類型以及映射出來的類型,而且它並不具有構造函數,所以我們需要自己去定義該類型的檢查方法,通常也被稱為類型保護
。
例子中的調用的兩個基於類型保護的方法的實現
public static isUserData(userData: UserDataType): userData is UserData {
return ((userData as UserData).title !== undefined) && ((userData as UserData).subTitle !== undefined)
&& ((userData as UserData).body !== undefined) && ((userData as UserData).type === USER_DATA_TYPE.USER_DATA);
}
public static isUserFloorData(userFloorData: UserDataType): userFloorData is UserFloorData {
return ((userFloorData as UserFloorData).deviceAllNum !== undefined)
&& ((userFloorData as UserFloorData).deviceNormalNum !== undefined)
&& ((userFloorData as UserFloorData).deviceFaultNum !== undefined)
&& ((userFloorData as UserFloorData).deviceOfflineNum !== undefined);
}
復制代碼
實際上,我們要去判斷這個類型的結構,這也是為什么ts的類型系統被稱為鴨子類型
,我們需要遍歷對象的每一個屬性來區分類型。換句話說,如果定義了兩個結構完全相同的類型,即便類型名不同也會判斷為相同的類型~
2.3 索引類型干嘛用?
索引類型(index types),使用索引類型,編譯器就能夠檢查使用了動態屬性名的代碼。ts中通過索引訪問操作符keyof
獲取類型中的屬性名,比如下面的例子:
function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
return names.map(n => o[n]);
}
interface Person {
name: string;
age: number;
}
let person: Person {
name: 'Jarid',
age: 35
}
let strings: string[] = pluck(person, ['name']);
復制代碼
原理 編譯器會檢查name
是否真的為person
的一個屬性,然后keyof T
,索引類型查詢操作符,對於任何類型T, keyof T
的結果為T上已知的屬性名的聯合。
let personProps: keyof Person; // 'name' | 'age'
復制代碼
也就是說,屬性名也可以是任意的interface類型!
索引訪問操作符T[K]
索引類型指的其實ts中的屬性可以是動態類型,在運行時求值時才知道類型。你可以在普通的上下文中使用T[K]
類型,只需要確保K extends keyof T
即可,例如下面:
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
return o[name];
}
復制代碼
原理:o:T
和 name:K
表示o[name]: T[K]
當你返回T[K]
的結果,編譯器會實例化key的真實類型,因此getProperty的返回值的類型會隨着你需要的屬性改變而改變。
let name: string = getProperty(person, 'name');
let age: number = getProperty(person, 'age');
let unknown = getProperty(person, 'unknown'); // error, 'unknown' is not in 'name' | 'age'
復制代碼
索引類型和字符串索引簽名 keyof
和 T[k]
與字符串索引簽名進行交互。 比如:
interface Map<T> {
[key: string]: T; // 這是一個帶有字符串索引簽名的類型, keyof T 是 string
}
let keys: keyof Map<number>; // string
let value: Map<number>['foo']; // number
復制代碼
Map<T>
是一個帶有字符串索引簽名的類型,那么keyof T 會是string。
2.4 映射類型
背景 在使用typescript時,會有一個問題我們是繞不開的 --> 如何從舊的類型中創建新類型即映射類型。
interface PersonPartial {
name?: string;
age?: number;
}
interface PersonReadonly {
readonly name: string;
readonly age: number;
}
復制代碼
可以看到PersonReadOnly
這個類型僅僅是對PersonParial
類型的字段只讀化設置,想象一下 如果這個類型是10個字段那就需要重復寫這10個字段。我們有沒辦法不去重復寫這種樣板代碼,而是通過映射得到新類型? 答案就是映射類型,
映射類型的原理 新類型以相同的形式去轉換舊類型里每個屬性:
type Readonly<T> {
readonly [P in keyof T]: T[P];
}
復制代碼
它的語法類似於索引簽名的語法,有三個步驟:
- 類型變量K, 依次綁定到每個屬性。
- 字符串字面量聯合的
Keys
,包含了要迭代的屬性名的集合 - 屬性的類型。
比如下面這個例子
type Keys = 'option1' | 'option2';
type Flags = { [K in keys]: boolean };
復制代碼
Keys
,是硬編碼的一串屬性名,然后這個屬性的類型是boolean,因此這個映射類型等同於:
type Flags = {
option1: boolean;
option2: boolean;
}
復制代碼
典型用法 我們經常會遇到的或者更通用的是(泛型的寫法):
type Nullable<T> = { [P in keyof T]: T[P] | null }
復制代碼
聲明一個Person類型,一旦用Nullable類型轉換后,得到的新類型的每一個屬性就是允許為null的類型了。
// test
interface Person {
name: string;
age: number;
greatOrNot: boolean;
}
type NullPerson = Nullable<Person>;
const nullPerson: NullPerson = {
name: '123',
age: null,
greatOrNot: true,
};
復制代碼
騷操作 利用類型映射,我們可以做到對類型的Pick
和Omit
,Pick
是ts自帶的類型,比如下面的例子:
export interface Product {
id: string;
name: string;
price: string;
description: string;
author: string;
authorLink: string;
}
export type ProductPhotoProps = Pick<Product, 'id' | 'author'| 'authorlink' | 'price'>;
// Omit的實現
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type ProductPhotoOtherProps = Omit<Product, 'name' | 'description'>;
復制代碼
我們可以把已有的Product
類型中的若干類型pick
出來組成一個新類型;也可以把若干的類型忽略掉,把剩余的屬性組成新的類型。
好處
- keyof T返回的是T的屬性列表,T[P]是結果類型,這種類型轉換不會應用到原型鏈上的其他屬性,意味着映射只會應用到T的屬性上而不會在原型鏈的其他屬性上。編譯器會在添加新屬性之前拷貝所有存在的屬性修飾符。
- 不管是屬性或者方法都可以被映射。
2.5 Never類型 vs Void類型
never 首先,never
類型有兩種場景:
- 作為函數返回值時是表示永遠不會有返回值的函數。
- 表示一個總是拋出錯誤的函數。
// 返回never的函數必須存在無法達到的終點
function error(message: string): never {
throw new Error(message);
}
// 推斷的返回值類型為never
function fail() {
return error("Something failed");
}
復制代碼
void void
也有它的應用場景
- 表示的是沒有任何類型,當一個函數沒有返回值時,通常typescript會自動認為它的返回值時
void
。 - 在代碼中聲明
void
類型或者返回值標記為void
可以提高代碼的可讀性,讓人明確該方法是不會有返回值,寫測試時也可以避免去關注返回值。
public remove(): void {
if (this.container) {
this.mapContainer.removeChild(this.container);
}
this.container = null;
}
復制代碼
小結
never
實質表示的是那些永遠不存在值的類型,也可以表示函數表達式或箭頭函數表達式的返回值。- 我們可以定義函數或變量為
void
類型,變量仍然可以被賦值undefined
或null
,但是never
是只能被返回值為never
的函數賦值。
2.6 枚舉類型
ts中用enum
關鍵字來定義枚舉類型,似乎在很多強類型語言中都有枚舉的存在,然而Javascrip沒有,枚舉可以幫助我們更好地用有意義的命名去取代那些代碼中經常出現的magic number
或有特定意義的值。這里有個在我們的業務里用到的枚舉類型:
export enum GEO_LEVEL {
NATION = 1,
PROVINCE = 2,
CITY = 3,
DISTRICT = 4,
BUILDING = 6,
FLOOR = 7,
ROOM = 8,
POINT = 9,
}
復制代碼
因為值都是number
,一般也被稱為數值型枚舉。
基於數值的枚舉 ts的枚舉都是基於數值類型的,數值可以被賦值到枚舉比如:
enum Color {
Red,
Green,
Blue
}
var col = Color.Red;
col = 0; // 與Color.Red的效果一樣
復制代碼
ts內部實現 我們看看上面的枚舉值為數值類型的枚舉類型會怎樣被轉為JavaScript:
// 轉譯后的Javascript
define(["require", "exports"], function (require, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var GEO_LEVEL;
(function (GEO_LEVEL) {
GEO_LEVEL[GEO_LEVEL["NATION"] = 1] = "NATION";
GEO_LEVEL[GEO_LEVEL["PROVINCE"] = 2] = "PROVINCE";
GEO_LEVEL[GEO_LEVEL["CITY"] = 3] = "CITY";
GEO_LEVEL[GEO_LEVEL["DISTRICT"] = 4] = "DISTRICT";
GEO_LEVEL[GEO_LEVEL["BUILDING"] = 6] = "BUILDING";
GEO_LEVEL[GEO_LEVEL["FLOOR"] = 7] = "FLOOR";
GEO_LEVEL[GEO_LEVEL["ROOM"] = 8] = "ROOM";
GEO_LEVEL[GEO_LEVEL["POINT"] = 9] = "POINT";
})(GEO_LEVEL = exports.GEO_LEVEL || (exports.GEO_LEVEL = {}));
});
復制代碼
非常有趣,我們先不去想為什么要這么轉譯,換個角度思考,其實上面的代碼說明了這樣一個事情:
console.log(GEO_LEVEL[1]); // 'NATION'
console.log(GEO_LEVEL['NATION']) // 1
// GEO_LEVEL[GEO_LEVEL.NATION] === GEO_LEVEL[1]
復制代碼
所以其實我們可以通過這個枚舉變量GEO_LEVEL去將下標表示的枚舉轉為key
表示的枚舉,key
表示的枚舉也可以轉為用下標表示。
3. Reference
advanced typescript classes and types