| 導語 工程化,旨在提升多人項目開發的效率,保證代碼質量,那麼在實際的 TS 工程中會遇到哪些問題呢?html
本文是《約束即類型、TypeScript 編程內參》系列第三篇:工程化和運行時,主要記述 TypeScript 在工程中的應用和實際問題。node
在 TS 開發中的工程化包含兩個,一個是 js 自己的工程化,一個則爲有關於 TS 自身的工程化,本文着重談下 TS 裏的工程化(編碼規範、git、lint、模塊加載等等)react
tsconfig.json 用於指定項目中 TypeScript 編譯器 tsc 的一些編譯配置,一般放在項目根目錄。能夠用 --init 參數來讓 tsc 自動地生成一個 tsconfig.json 模版:webpack
$ tsc --init
複製代碼
這個命令會在當前工做目錄下生成一個 tsconfig.json,裏面會寫出 tsc 的所有可用配置,在對應的註釋裏基本說明了每個配置基本用法、做用,這裏很少贅述,只簡要說明幾個經常使用配置:git
compilerOptions.target
編譯到哪一個 JS 版本,我通常填 「ES5」。compilerOptions.module
編譯後用哪一個模塊系統,舉例:"commonjs"
compilerOptions.lib
用哪一個宿主環境,舉例:["es2017","DOM"]
compilerOptions.esModuleInterop
開啓這個能夠避免引入 import * as xxx from 'xxx'
的寫法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,可是都沒答到電子上,其實用 interface 的緣由在於 interface 能夠重名合併,也就是 interface 能夠被拓展,在 TS 裏只有 namespace interface module 能被拓展:
declare interface Window {}
declare namespace NodeJS {
export interface Global {}
}
declare module "xxx" {}
複製代碼
能被拓展的東西就能夠像前文那樣被其餘人修改定義,而若是用了不少 type 來定義對象,其餘人就不能拓展了,只能修改原始定義去拓展,形成各類各樣的 issues。
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。 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 寫斷言才能正確使用。
對於 ts 項目來講,必定須要 webpack 嗎?不必定,我我的傾向是 node 項目直接用 tsc 就好,而打包這個步驟對於服務端應用來講沒那麼重要,所以 webpack 是可選的。
那有人會問了,靜態圖片、pb 等靜態資源要如何處理?
這種狀況下推薦用 webpack 去處理了,固然對於 node 的 proto 文件來講,用後置腳本去複製文件也是一種辦法。
若是須要用到 webpack,可使用 typescript-starter
create-react-app-typescript
這些開源腳手架。
tslint
官方已經不維護了,目前若是想引入代碼檢查只有 eslint
這一種方案了,具體的配置網絡上有不少,這裏再也不贅述。
熟練了寫 ts 的開發者永遠不會再想去寫 js 了,由於對於標好類型的 TS 項目來講, IDE 實在是太好用了:
靜態類型的定義在於從類型的角度上證實程序的正確性,通俗的來講就是: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:
本文的下一篇是「經常使用類型舉例、TypeScript 編程內參(四)」主要舉例一些狀況下類型的寫法、套路等等,敬請期待