工程化和運行時、TypeScript 編程內參(三)

| 導語 工程化,旨在提升多人項目開發的效率,保證代碼質量,那麼在實際的 TS 工程中會遇到哪些問題呢?html

本文是《約束即類型、TypeScript 編程內參》系列第三篇:工程化和運行時,主要記述 TypeScript 在工程中的應用和實際問題。node

  1. 約束即類型、TypeScript 編程內參(一)
  2. 構造類型抽象、TypeScript 編程內參(二)
  3. 工程化和運行時、TypeScript 編程內參(三)

在 TS 開發中的工程化包含兩個,一個是 js 自己的工程化,一個則爲有關於 TS 自身的工程化,本文着重談下 TS 裏的工程化(編碼規範、git、lint、模塊加載等等)react

tsconfig.json

tsconfig.json 用於指定項目中 TypeScript 編譯器 tsc 的一些編譯配置,一般放在項目根目錄。能夠用 --init 參數來讓 tsc 自動地生成一個 tsconfig.json 模版:webpack

$ tsc --init

複製代碼

這個命令會在當前工做目錄下生成一個 tsconfig.json,裏面會寫出 tsc 的所有可用配置,在對應的註釋裏基本說明了每個配置基本用法、做用,這裏很少贅述,只簡要說明幾個經常使用配置:git

  1. compilerOptions.target 編譯到哪一個 JS 版本,我通常填 「ES5」。
  2. compilerOptions.module 編譯後用哪一個模塊系統,舉例:"commonjs"
  3. compilerOptions.lib 用哪一個宿主環境,舉例:["es2017","DOM"]
  4. compilerOptions.esModuleInterop 開啓這個能夠避免引入 import * as xxx from 'xxx' 的寫法
  5. compilerOptions.strict TS 嚴格模式,強烈建議打開,這是寫出 TS Style Code 的前提。

其餘的配置項能夠參考官方文檔介紹:TypeScript - compilerOptionsgithub

💡💡💡 關於 TS 嚴格模式 若是你開啓了 strict 通常就不用開 noImplicitAny 等這類開關了,strict 模式下 TS 會自動幫你開啓這些的,詳見官方文檔介紹web

宿主環境的類型定義

ECMAScript 是一種須要宿主環境注入 API 的語言,在瀏覽器上宿主環境就是瀏覽器自己提供的 DOM、BOM 接口;而在 node 上,宿主環境就是 node 提供的一整套標準 API,如 fs 模塊,require 模塊等。面試

通常編寫 JS 的過程大機率會調用到宿主環境的相關 API,TS 開發也不例外,須要用到宿主環境的類型定義,否則會寫出不少 any 出來,好比我想本身覆蓋重寫瀏覽器的 fetch 方法:算法

// 不建議這樣作
<any> window.fetch = myMockFecth

複製代碼

正確的作法應該是:typescript

const F: typeof window.fetch = (/*略*/) => {/*略*/}
// 由於 F 類型跟 window.fetch 同樣, 因此這裏不用 any
window.fetch = F;

複製代碼

相似的例子還有 DOM 的 Event 定義等等 … 這些宿主環境的 TS 定義並非憑空而來的,而是 TS 官方及社區提供的 @type/* 一塊兒定義出來的,好比瀏覽器的宿主環境定義,咱們利用 compilerOptions.lib 中的 DOM 來引入;而 node 的定義咱們通常用 @types/node 來定義各類 node 原生模塊:

$ npm i --save @types/node

複製代碼

在安裝了上述的模塊以後,就能夠直接訪問到 node 相關的對象了,如 Buffer。

拓展類型定義

TS 的類型定義主要分兩類,一類是宿主環境的定義,一類是模塊的類型定義,在此以前,咱們須要瞭解一下 TS 的環境上下文的概念。


環境上下文

咱們能夠把 TSC 當作一個編譯函數,執行它的時候咱們須要傳入兩個參數:

一個是咱們本身的代碼,另一個則是一些環境聲明(如 Promise 的聲明定義)對於這類環境聲明產生類型定義,咱們稱之爲 環境上下文;另外,ts 若是在用戶代碼中發現了以 d.ts 結尾的代碼,也會把 這類文件當成環境聲明的一部分加入到環境上下文中,換言之,文件名後綴是有語義的。

環境上下文的做用是給用戶代碼提供類型聲明,不會被實際編譯,所以在環境上下文(d.ts)中不容許出現含有語義的計算,好比不能出現 1 + 1 這樣的表達式運算。

建議讀者自行建一個 d.ts 文件出來,而後在裏面試試。

更多關於環境上下文的內容能夠看看這個: jkchao.github.io/typescript-…

💡💡💡 瀏覽器的宿主環境、node 的宿主環境的定義是環境上下文定義的真子集。


拓展環境上下文(拓展宿主環境)

有時候咱們須要改造、修飾宿主環境,比方說瀏覽器裏我想加個 UMD 變量,即在 window.xxx 下掛一個個人變量而 TS 能識別出來;亦或者,在 node 下面有可能須要往 global 加東西,這種狀況如何處理?

compilerOptions.lib 裏有 DOM 的時候,ts 會加載內置的 lib.dom.d.ts,裏面定義瀏覽器的各類 API,屬於環境上下文,若是想要拓展他們,能夠利用 declare 語句拓展,像這樣:

// umd.d.ts d.ts 結尾的環境聲明
declare interface Window {
ECZN_FLAG: 'ECZN_FLAG'
}
declare var ECZN_FLAG: 'ECZN_FLAG';

// index.ts
window.ECZN_FLAG;
ECZN_FLAG;

複製代碼

寫完了上面的聲明以後,TS 項目中 window.ECZN_FLAG 就不會報錯了,並且能正確提示信息。

下面是 Node 環境下的拓展聲明。

// app-global.d.ts
declare namespace NodeJS {
export interface Global {
ECZN_FLAG: 'ECZN_FLAG'
}
}
declare var ECZN_FLAG: 'ECZN_FLAG';

// index.ts
window.ECZN_FLAG;
ECZN_FLAG;

複製代碼

拓展模塊類型定義

有時候別人模塊的類型定義不必定符合咱們的需求,這時候須要拓展他們的定義,而這些第三方模塊有的是 TS 寫的,有的是 JS 寫的,有的是你本身的模塊,有的是別人的模塊 … 引用別人的模塊有不少狀況,大致來講主要分下面五種狀況:


狀況之一:我本身寫的純 js 項目怎麼添加 d.ts 給其餘 JS/TS 項目使用

這個可參考 TehShrike/deepmerge, 注意其中的 package.json 中的 types 字段指向的 d.ts 文件


狀況之二:我本身寫的純 TS 項目怎麼添加 d.ts 給其餘 JS/TS 項目使用

這個容易,編輯修改 tsconfig.json 中的 declaration 爲 true 便可讓 ts 自動生成對應的 d.ts 環境上下文聲明文件(記得還須要修改 package.json 中的 types 指向這個文件)

或者,能夠將 package.json 的 main 設爲 src/index.ts 也能夠,main 設爲 ts 文件,這個要看構建是否支持。


狀況之三: jQuery 等這類有 UMD 需求的模塊

這類模塊通常是一個經常使用工具庫,須要掛在全局來用(方便),而後其大機率會提供一個 d.ts,但這個 d.ts 沒有幫你把模塊掛在 UMD 上,所以你須要本身掛上去,這個請參考前文進行拓展。


狀況之四: 別人的模塊沒有編寫 d.ts,須要本身編寫

這個稍微有點棘手,須要本身在本地項目中編寫類型聲明:

declare module "js-fetch-get" {
type Fetch<T> = (url: string) => Promise<T>;
var fetch: Fetch;
export = fetch;
}

// 有了上面的定義以後
// 下面這個就不會報找不到定義的錯誤了
import fetchGet from 'js-fetch-get'

複製代碼

一般上面的聲明寫在 xxx.d.ts 裏,xxx 能夠隨意,但這個文件須要放在 src/ 下,更確切的說應放在 compilerOptions.rootDir 下 (這個選項默認是 ./)


狀況之五 別人的 JS/TS 項目雖然提供了 d.ts,但它寫的不夠好,不能知足個人需求

利用 declare module 的寫法一樣能夠用於拓展模塊的定義,這個建議讀者本身試試看看(參考前文所述的宿主環境的拓展)

多用 interface 少用 type

談到該用 interface 仍是 type,你們都常說盡可能用 interface,可是都沒答到電子上,其實用 interface 的緣由在於 interface 能夠重名合併,也就是 interface 能夠被拓展,在 TS 裏只有 namespace interface module 能被拓展:

declare interface Window {}
declare namespace NodeJS {
export interface Global {}
}
declare module "xxx" {}

複製代碼

能被拓展的東西就能夠像前文那樣被其餘人修改定義,而若是用了不少 type 來定義對象,其餘人就不能拓展了,只能修改原始定義去拓展,形成各類各樣的 issues。

不要傳播 any

TS = 靜態類型系統 + js 反過來講就是: JS = TS + any

當咱們討論不要隨便用 any 的時候,其實最擔憂的是怕 any 傳播出去,而不是說咱們必定不能用,有些狀況不得不用,好比在一個 JSON 配置加載器裏:

function loadConfig() {
try {
const rawJson = fs.readFileSync('xxxx.json', 'utf-8');
return JSON.parse(rawJson);
} catch (err) {
console.warn('load config error', err);
return null;
}
}

複製代碼

上面這個函數的簽名 TS 會自動推斷爲: () => any (JSON.parse 返回 any),這樣的話在其餘地方調用的時候就會產生額外的 any(這種狀況算做隱式 any)

// http.Server 是 http.createServer 的返回結果
function initServer(app: http.Server) {
const conf = loadConfigFromDist();
app.listen(conf.port);
// 這裏變量 conf 是 any
// 所以 conf.port 也是 any
// 這段代碼被污染了 (傳播了 any)
}

複製代碼

這樣就形成了 any 的傳播,這個東西傳播多了,至關於退化爲 js。所以不要隨便用 any,即便要用,也應該切斷傳播,好比顯式指定簽名

interface AppConf { /* 系統配置定義 */ }
function loadConfigFromDist(): AppConf {
// 注意,這裏顯式地欽點了類型,從而切斷了 any 的傳播
/* 具體實現省略 */
}

複製代碼

同理,在咱們拉取接口請求的響應也同樣,要顯式標註類型,不要用 any。

💡💡💡 關於 tsconfig implictAny 選項 這個選項要求你將有 any 的地方所有標出來,不能出現隱式的狀況,可讓 tsc 來幫你檢測 any 的傳播,從而避免上述問題 (開啓了 strict 以後這個選項會被默認開啓)

unknown 和 any

若是不肯定某處的類型,建議用 unknown 而不是 any。 any 的語義是:任何對於 any 的類型推導都是經過的;而 unknown 則是:unknown 是任何可能的類型,類型是不肯定的,除非有斷言才能肯定其具體類型。

前者很好理解,你們都寫過,但後者提到的斷言是啥,簡單來講斷言就是欽點某變量爲某類型的語義:

var aVar: unknown;
aVar.toUpperCase(); // 報錯

if (typeof aVar === 'string') {
aVar.toUpperCase();
// 不報錯,typeof aVar === 'string' 語句是 string 斷言
// 也就是說,這個分支下 aVar 的類型爲 string
}
if (aVar instanceof Date) {
aVar.getTime();
// 這裏斷言爲 Date 對象
// 這裏的 instanceof 是一種 Date 斷言
}

type Person = { name: string };
// 本身爲某類型聲明斷言函數
// 注意這裏的簽名返回值
function isPerson(x: any): x is Person {
return typeof x.name === 'string'
}

if (isPerson(aVar)) {
aVar.name;
// 不會報錯,由於 isPerson 是斷言函數
// (仔細看看 isPerson 的簽名)
// 由於有 Person 斷言,因此這個分支下 aVar 的類型爲 Person
}

複製代碼

而若是一開頭 aVar 聲明爲 any, 那不管是 aVar.toUpperCase aVar.getTime 都不會報錯了,所以引入 unknown 的意義在於讓你多本身寫斷言檢查類型,減小錯誤。 (也有多是代碼沒寫完,寫個 unknown 佔位)

題外話,老版本的 ts 是沒有 unknown 的,所以有個 polyfill :

type AnyObj = { [key: string]: any }

type unknown = (
AnyObj |
object |
number |
string |
boolean |
symbol |
undefined |
null |
void
);

複製代碼

可見,unknown 的語義是 任何可能的類型組合而成的複合類型,這也能解釋爲啥要給 unknown 寫斷言才能正確使用。

腳手架、打包、編譯、ESLint 問題

對於 ts 項目來講,必定須要 webpack 嗎?不必定,我我的傾向是 node 項目直接用 tsc 就好,而打包這個步驟對於服務端應用來講沒那麼重要,所以 webpack 是可選的。

那有人會問了,靜態圖片、pb 等靜態資源要如何處理?

這種狀況下推薦用 webpack 去處理了,固然對於 node 的 proto 文件來講,用後置腳本去複製文件也是一種辦法。

若是須要用到 webpack,可使用 typescript-starter create-react-app-typescript 這些開源腳手架。


tslint 官方已經不維護了,目前若是想引入代碼檢查只有 eslint 這一種方案了,具體的配置網絡上有不少,這裏再也不贅述。

放心地編寫代碼

熟練了寫 ts 的開發者永遠不會再想去寫 js 了,由於對於標好類型的 TS 項目來講, IDE 實在是太好用了:

  1. 能夠正確地、安全地、批量地修改變量名
  2. 自動修改 import 路徑、自動 import ts 文件
  3. 代碼智能提示至關於活的開發文檔
  4. … 等等等等

靜態類型的定義在於從類型的角度上證實程序的正確性,通俗的來講就是:TS 裏的每一行、每一處、每個函數的調用,都是受類型規則約束的;若是你的代碼能正確標好類型,那基本上你的程序的出錯的機率會大大下降,而出現的錯誤通常是算法的邊界條件、觸發條件這些邏輯性錯誤,也就是說,你標的類型實際上是一種單元測試,類型是對程序的證實。

固然,一切的前提在於,你得標好類型。

謹慎地處理錯誤

TS 有個大坑,好比錯誤處理的問題,在 TS 裏咱們不能給 catch 子句的 error 標類型,error 的類型被強制定爲 any:

try {
const err = new MyError();
throw err;
} catch (e: MyError) {
// ^^^^^^^^^^^ 這裏會報錯
// ts 不容許用戶定義 e 的類型
// e 被 ts 強制設定爲 any
}

複製代碼

這個問題最先提出在 TS 官方倉庫的 issues 裏: github.com/microsoft/T…

目前 TS 在語意上強制了 try catch 的 error 類型爲 any,所以裏面的錯誤處理會很不 TS Style,很容易傳播 any。 那解決方案呢?若是說 Error 類型的拋出、捕獲也要走靜態類型標註、推導,那這類特性大概最終會演化成相似 Java 的 Checked Exception (CE):

function loadFile(path: string) throws IOError {
return fs.readFile(path);
}

try {
loadFile()
} catch (err) {
// 這裏 err 會自動推斷爲 IOError
}

複製代碼

該不應引入 CE ?這是一個見仁見智的問題,換我來講,這很好,能夠標好 Error 的類型,同時不標的話就默認爲 any/unknown 類型,這樣開發者能夠選擇標或者不標,在這樣的體系下 TS 總體類型系統的設計也會比較完整,何樂不爲? 不過,要加的話,基本整套 TS 生態裏面的代碼都要 review 重構了,這個又是一個很大很重的工做量了。

固然了,按照社區尿性,必定會有大量 throws any 的寫法出來的,但若是不得不這麼作,建議你寫成 throws unknown,少用 any。

錯誤處理的問題必定會隨着 TS 的發展以及在大型項目中的使用而變得愈來愈明顯。

本篇末

本篇主要講述的是如何構造類型抽象以便描述/生成更多的類型,如下是 Checklist:

  1. tsconfig.json
  2. 環境上下文
  3. 拓展環境/模塊類型定義
  4. unknown 和 any
  5. TS 打包、eslint 等等
  6. 錯誤處理及其坑

本文的下一篇是「經常使用類型舉例、TypeScript 編程內參(四)」主要舉例一些狀況下類型的寫法、套路等等,敬請期待

相關文章
相關標籤/搜索