精讀《Typescript2.0 - 2.9》

黃子毅發表於2018-05-28

1 引言

精讀原文是 typescript 2.0-2.9 的文件:

2.0-2.82.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 等於是函式返回值中的 nullundefined它們都是子型別,比如型別 number 自帶了 nullundefined 這兩個子型別,是因為任何有型別的值都有可能是空(也就是執行期間可能沒有值)。

這裡涉及到很重要的概念,就是預定義了型別不代表型別一定如預期,就好比函式執行時可能因為 throw Error 而中斷。所以 ts 為了處理這種情況,null undefined 設定為了所有型別的子型別,而從 2.0 開始,函式的返回值型別又多了一種子型別 never

TS 2.2 支援了 object 型別, 但許多時候我們總把 objectany 型別弄混淆,比如下面的程式碼:

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]>,將物件每個 keyvalue 型別覆蓋。

基於這些能力,我們擴充出一系列上層很有用的 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。排除 Tnullundefined 的可能性。
  • 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 更多討論

討論地址是:精讀《Typescript2.0 - 2.9》 · Issue #85 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。

相關文章