1 引言
精讀原文是 typescript 2.0-2.9 的文件:
我發現,許多寫了一年以上 Typescript 開發者,對 Typescript 對理解和使用水平都停留在入門階段。造成這個現象的原因是,Typescript 知識的積累需要 刻意練習,使用 Typescript 的時間與對它的瞭解程度幾乎沒有關係。
這篇文章精選了 TS 在 2.0-2.9
版本中最重要的功能,並配合實際案例解讀,幫助你快速跟上 TS 的更新節奏。
對於 TS 內部優化的使用者無感部分並不會羅列出來,因為這些優化都可在日常使用過程中感受到。
2 精讀
由於 Typescript 在嚴格模式下的許多表現都與非嚴格模式不同,為了避免不必要的記憶,建議只記嚴格模式就好了!
嚴格模式導致的大量邊界檢測程式碼,已經有解了
直接訪問一個變數的屬性時,如果這個變數是 undefined
,不但屬性訪問不到,js 還會丟擲異常,這幾乎是業務開發中最高頻的報錯了(往往是後端資料異常導致的),而 typescript 的 strict
模式會檢查這種情況,不允許不安全的程式碼出現。
在 2.0
版本,提供了 “非空斷言標誌符” !.
解決明確不會報錯的情況,比如配置檔案是靜態的,那肯定不會丟擲異常,但在 2.0
之前的版本,我們可能要這麼呼叫物件:
const config = {
port: 8000
};
if (config) {
console.log(config.port);
}
複製程式碼
有了 2.0
提供的 “非空斷言標誌符”,我們可以這麼寫了:
console.log(config!.port);
複製程式碼
在 2.8
版本,ts 支援了條件型別語法:
type TypeName<T> = T extends string ? "string"
複製程式碼
當 T 的型別是 string 時,TypeName 的表示式型別為 "string"。
這這時可以構造一個自動 “非空斷言” 的型別,把程式碼簡化為:
console.log(config.port);
複製程式碼
前提是框架先把 config
指定為這個特殊型別,這個特殊型別的定義如下:
export type PowerPartial<T> = {
[U in keyof T]?: T[U] extends object ? PowerPartial<T[U]> : T[U]
};
複製程式碼
也就是 2.8
的條件型別允許我們在型別判斷進行遞迴,把所有物件的 key 都包一層 “非空斷言”!
此處靈感來自 egg-ts 總結
增加了 never
object
型別
當一個函式無法執行完,或者理解為中途中斷時,TS 2.0
認為它是 never
型別。
比如 throw Error
或者 while(true)
都會導致函式返回值型別時 never
。
和 null
undefined
特性一樣,never
等於是函式返回值中的 null
或 undefined
。它們都是子型別,比如型別 number
自帶了 null
與 undefined
這兩個子型別,是因為任何有型別的值都有可能是空(也就是執行期間可能沒有值)。
這裡涉及到很重要的概念,就是預定義了型別不代表型別一定如預期,就好比函式執行時可能因為 throw Error
而中斷。所以 ts 為了處理這種情況,將 null
undefined
設定為了所有型別的子型別,而從 2.0
開始,函式的返回值型別又多了一種子型別 never
。
TS 2.2
支援了 object
型別, 但許多時候我們總把 object
與 any
型別弄混淆,比如下面的程式碼:
const persion: object = {
age: 5
};
console.log(persion.age); // Error: Property 'age' does not exist on type 'object'.
複製程式碼
這時候報錯會出現,有時候閉個眼改成 any
就完事了。其實這時候只要把 object
刪掉,換成 TS 的自動推導就搞定了。那麼問題出在哪裡?
首先 object
不是這麼用的,它是 TS 2.3
版本中加入的,用來描述一種非基礎型別,所以一般用在型別校驗上,比如作為引數型別。如果引數型別是 object
,那麼允許任何物件資料傳入,但不允許 3
"abc"
這種非物件型別:
declare function create(o: object | null): void;
create({ prop: 0 }); // 正確
create(null); // 正確
create(42); // 錯誤
create("string"); // 錯誤
create(false); // 錯誤
create(undefined); // 錯誤
複製程式碼
而一開始 const persion: object
這種用法,是將能精確推導的物件型別,擴大到了整體的,模糊的物件型別,TS 自然無法推斷這個物件擁有哪些 key
,因為物件型別僅表示它是一個物件型別,在將物件作為整體觀察時是成立的,但是 object
型別是不承認任何具體的 key
的。
增加了修飾型別
TS 在 2.0
版本支援了 readonly
修飾符,被它修飾的變數無法被修改。
在 TS 2.8
版本,又增加了 -
與 +
修飾修飾符,有點像副詞作用於形容詞。舉個例子,readonly
就是 +readonly
,我們也可以使用 -readonly
移除只讀的特性;也可以通過 -?:
的方式移除可選型別,因此可以延伸出一種新型別:Required<T>
,將物件所有可選修飾移除,自然就成為了必選型別:
type Required<T> = { [P in keyof T]-?: T[P] };
複製程式碼
可以定義函式的 this 型別
也是 TS 2.0
版本中,我們可以定製 this
的型別,這個在 vue
框架中尤為有用:
function f(this: void) {
// make sure `this` is unusable in this standalone function
}
複製程式碼
this
型別是一種假引數,所以並不會影響函式真正引數數量與位置,只不過它定義在引數位置上,而且永遠會插隊在第一個。
引用、定址支援萬用字元了
簡單來說,就是模組名可以用 *
表示任何單詞了:
declare module "*!text" {
const content: string;
export default content;
}
複製程式碼
它的型別可以輻射到:
import fileContent from "./xyz.txt!text";
複製程式碼
這個特性很強大的一個點是用在擴充模組上,因為包括 tsconfig.json
的模組查詢也支援萬用字元了!舉個例子一下就懂:
最近比較火的 umi
框架,它有一個 locale
外掛,只要安裝了這個外掛,就可以從 umi/locale
獲取國際化內容:
import { locale } from "umi/locale";
複製程式碼
其實它的實現是建立了一個檔案,通過 webpack.alias
將引用指了過去。這個做法非常棒,那麼如何為它加上型別支援呢?只要這麼配置 tsconfig.json
:
{
"compilerOptions": {
"paths": {
"umi/*": ["umi", "<somePath>"]
}
}
}
複製程式碼
將所有 umi/*
的型別都指向 <somePath>
,那麼 umi/locale
就會指向 <somePath>/locale.ts
這個檔案,如果外掛自動建立的檔名也恰好叫 locale.ts
,那麼型別就自動對應上了。
跳過倉庫型別報錯
TS 在 2.x
支援了許多新 compileOptions
,但 skipLibCheck
實在是太耀眼了,筆者必須單獨提出來說。
skipLibCheck
這個屬性不但可以忽略 npm 不規範帶來的報錯,還能最大限度的支援型別系統,可謂一舉兩得。
拿某 UI 庫舉例,某天釋出的小版本 d.ts
檔案出現一個漏洞,導致整個專案構建失敗,你不再需要提 PR 催促作者修復了!skipLibCheck
可以忽略這種報錯,同時還能保持型別的自動推導,也就是說這比 declare module "ui-lib"
將型別設定為 any
更強大。
對型別修飾的增強
TS 2.1
版本可謂是針對型別操作革命性的版本,我們可以通過 keyof
拿到物件 key 的型別:
interface Person {
name: string;
age: number;
}
type K1 = keyof Person; // "name" | "age"
複製程式碼
基於 keyof
,我們可以增強物件的型別:
type NewObjType<T> = { [P in keyof T]: T[P] };
複製程式碼
Tips:在 TS 2.8
版本,我們可以以表示式作為 keyof
的引數,比如 keyof (A & B)
。
Tips:在 TS 2.9
版本,keyof
可能返回非 string
型別的值,因此從一開始就不要認為 keyof
的返回型別一定是 string
。
NewObjType
原封不動的將物件型別重新描述了一遍,這看上去沒什麼意義。但實際上我們有三處擴充的地方:
- 左邊:比如可以通過
readonly
修飾,將物件的屬性變成只讀。 - 中間:比如將
:
改成?:
,將物件所有屬性變成可選。 - 右邊:比如套一層
Promise<T[P]>
,將物件每個key
的value
型別覆蓋。
基於這些能力,我們擴充出一系列上層很有用的 interface
:
- Readonly。把物件 key 全部設定為只讀,或者利用
2.8
的條件型別語法,實現遞迴設定只讀。 - Partial。把物件的 key 都設定為可選。
- Pick<T, K>。從物件型別 T 挑選一些屬性 K,比如物件擁有 10 個 key,只需要將 K 設定為
"name" | "age"
就可以生成僅支援這兩個 key 的新物件型別。 - Extract<T, U>。是 Pick 的底層 API,直到
2.8
版本才內建進來,可以認為 Pick 是挑選物件的某些 key,Extract 是挑選 key 中的 key。 - Record<K, U>。將物件某些屬性轉換成另一個型別。比較常見用在回撥場景,回撥函式返回的型別會覆蓋物件每一個 key 的型別,此時型別系統需要
Record
介面才能完成推導。 - Exclude<T, U>。將 T 中的 U 型別排除,和 Extract 功能相反。
- Omit<T, K>(未內建)。從物件 T 中排除 key 是 K 的屬性。可以利用內建型別方便推匯出來:
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>
- NonNullable。排除
T
的null
與undefined
的可能性。 - ReturnType。獲取函式
T
返回值的型別,這個型別意義很大。 - InstanceType。獲取一個建構函式型別的例項型別。
以上型別都內建在 lib.d.ts 中,不需要定義就可直接使用,可以認為是 Typescript 的 utils 工具庫。
單獨拿 ReturnType
舉個例子,體現出其重要性:
Redux 的 Connect 第一個引數是 mapStateToProps
,這些 Props 會自動與 React Props 聚合,我們可以利用 ReturnType<typeof currentMapStateToProps>
拿到當前 Connect 注入給 Props 的型別,就可以打通 Connect 與 React 元件的型別系統了。
對 Generators 和 async/await 的型別定義
TS 2.3
版本做了許多對 Generators 的增強,但實際上我們早已用 async/await 替代了它,所以 TS 對 Generators 的增強可以忽略。需要注意的一塊是對 for..of
語法的非同步迭代支援:
async function f() {
for await (const x of fn1()) {
console.log(x);
}
}
複製程式碼
這可以對每一步進行非同步迭代。注意對比下面的寫法:
async function f() {
for (const x of await fn2()) {
console.log(x);
}
}
複製程式碼
對於 fn1
,它的返回值是可迭代的物件,並且每個 item 型別都是 Promise 或者 Generator。對於 fn2
,它自身是個非同步函式,返回值是可迭代的,而且每個 item 都不是非同步的。舉個例子:
function fn1() {
return [Promise.resolve(1), Promise.resolve(2)];
}
function fn2() {
return [1, 2];
}
複製程式碼
在這裡順帶一提,對 Array.map
的每一項進行非同步等待的方法:
await Promise.all(
arr.map(async item => {
return await item.run();
})
);
複製程式碼
如果為了執行順序,可以換成 for..of
的語法,因為陣列型別是一種可迭代型別。
泛型預設引數
瞭解這個之前,先介紹一下 TS 2.0
之前就支援的函式型別過載。
首先 JS 是不支援方法過載的,Java 是支援的,而 TS 型別系統一定程度在對標 Java,當然要支援這個功能。好在 JS 有一些偏方實現偽方法過載,典型的是 redux 的 createStore
:
export default function createStore(reducer, preloadedState, enhancer) {
if (typeof preloadedState === "function" && typeof enhancer === "undefined") {
enhancer = preloadedState;
preloadedState = undefined;
}
}
複製程式碼
既然 JS 有辦法支援方法過載,那 TS 補充了函式型別過載,兩者結合就等於 Java 方法過載:
declare function createStore(
reducer: Reducer,
preloadedState: PreloadedState,
enhancer: Enhancer
);
declare function createStore(reducer: Reducer, enhancer: Enhancer);
複製程式碼
可以清晰的看到,createStore
想表現的是對引數個數的過載,如果定義了函式型別過載,TS 會根據函式型別自動判斷對應的是哪個定義。
而在 TS 2.3
版本支援了泛型預設引數,可以某些場景減少函式型別過載的程式碼量,比如對於下面的程式碼:
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
element: T,
children: U[]
): Container<T, U[]>;
複製程式碼
通過列舉表達了範型預設值,以及 U 與 T 之間可能存在的關係,這些都可以用泛型預設引數解決:
declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>(
element?: T,
children?: U
): Container<T, U>;
複製程式碼
尤其在 React 使用過程中,如果用泛型預設值定義了 Component
:
.. Component<Props = {}, State = {}> ..
複製程式碼
就可以實現以下等價的效果:
class Component extends React.PureComponent<any, any> {
//...
}
// 等價於
class Component extends React.PureComponent {
//...
}
複製程式碼
動態 Import
TS 從 2.4
版本開始支援了動態 Import,同時 Webpack4.0 也支援了這個語法(在 精讀《webpack4.0%20 升級指南》 有詳細介紹),這個語法就正式可以用於生產環境了:
const zipUtil = await import("./utils/create-zip-file");
複製程式碼
準確的說,動態 Import 實現於 webpack 2.1.0-beta.28,最終在 TS
2.4
版本獲得了語法支援。
在 TS 2.9
版本開始,支援了 import()
型別定義:
const zipUtil: typeof import('./utils/create-zip-file') = await import('./utils/create-zip-file')
複製程式碼
也就是 typeof
可以作用於 import()
語法,而不真正引入 js 內容。不過要注意的是,這個 import('./utils/create-zip-file')
路徑需要可被推導,比如要存在這個 npm 模組、相對路徑、或者在 tsconfig.json
定義了 paths
。
好在 import
語法本身限制了路徑必須是字面量,使得自動推導的成功率非常高,只要是正確的程式碼幾乎一定可以推匯出來。好吧,所以這也從另一個角度推薦大家放棄 require
。
Enum 型別支援字串
從 Typescript 2.4
開始,支援了列舉型別使用字串做為 value:
enum Colors {
Red = "RED",
Green = "GREEN",
Blue = "BLUE"
}
複製程式碼
筆者在這提醒一句,這個功能在純前端程式碼內可能沒有用。因為在 TS 中所有 enum
的地方都建議使用 enum
接收,下面給出例子:
// 正確
{
type: monaco.languages.types.Folder;
}
// 錯誤
{
type: 75;
}
複製程式碼
不僅是可讀性,enum
對應的數字可能會改變,直接寫 75
的做法存在風險。
但如果前後端存在互動,前端是不可能傳送 enum
物件的,必須要轉化成數字,這時使用字串作為 value 會更安全:
enum types {
Folder = "FOLDER"
}
fetch(`/api?type=${monaco.languages.types.Folder}`);
複製程式碼
陣列型別可以明確長度
最典型的是 chart 圖,經常是這樣的二維陣列資料型別:
[[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]]
複製程式碼
一般我們會這麼描述其資料結構:
const data: string[][] = [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]];
複製程式碼
在 TS 2.7
版本中,我們可以更精確的描述每一項的型別與陣列總長度:
interface ChartData extends Array<number> {
0: number;
1: number;
length: 2;
}
複製程式碼
自動型別推導
自動型別推導有兩種,分別是 typeof
:
function foo(x: string | number) {
if (typeof x === "string") {
return x; // string
}
return x; // number
}
複製程式碼
和 instanceof
:
function f1(x: B | C | D) {
if (x instanceof B) {
x; // B
} else if (x instanceof C) {
x; // C
} else {
x; // D
}
}
複製程式碼
在 TS 2.7
版本中,新增了 in
的推導:
interface A {
a: number;
}
interface B {
b: string;
}
function foo(x: A | B) {
if ("a" in x) {
return x.a;
}
return x.b;
}
複製程式碼
這個解決了 object
型別的自動推導問題,因為 object
既無法用 keyof
也無法用 instanceof
判定型別,因此找到物件的特徵吧,再也不要用 as
了:
// Bad
function foo(x: A | B) {
// I know it's A, but i can't describe it.
(x as A).keyofA;
}
// Good
function foo(x: A | B) {
// I know it's A, because it has property `keyofA`
if ("keyofA" in x) {
x.keyofA;
}
}
複製程式碼
4 總結
Typescript 2.0-2.9
文件整體讀下來,可以看出還是有較強連貫性的。但我們可能並不習慣一步步學習新語法,因為新語法需要時間消化、同時要連線到以往語法的上下文才能更好理解,所以本文從功能角度,而非版本角度梳理了 TS 的新特性,比較符合學習習慣。
另一個感悟是,我們也許要用追月刊漫畫的思維去學習新語言,特別是 TS 這種正在發展中,並且迭代速度很快的語言。
5 更多討論
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。