原文。html
本文敘述瞭如何使用 TypeScript 從頭建立一個 100% 類型安全的依賴注入框架。git
在我做爲專業 TypeScript 講師的日子裏,開發者們常常問我:「爲何咱們須要這麼複雜的高級類型系統?」他們在實際項目中並無感覺到對常量類型、交叉類型、條件類型和元組式的剩餘參數的需求。這是一個很好的問題,若是沒有一個合適的場景,是很難回答的。github
這就促使我去尋找一個合適的場景。幸運的是,我確實找到了一個場景:依賴注入,或者簡稱爲 DI。typescript
本文,我將帶着你一塊兒探索。首先我會解釋類型安全的依賴注入是什麼意思。接下來我會展現最終代碼形態,這樣你就知道具體要達到什麼目標了。而後,咱們逐一解決靜態類型的依賴注入框架所遇到的挑戰。數組
閱讀本文的前提是你已經具有了 TypeScript 基礎知識。安全
個人目標是在 TypeScript 中建立 100% 類型安全的依賴注入(DI)框架。若是你還不知道 DI,建議先閱讀 samueleresca 寫的這篇文章,文章介紹了什麼是 DI,以及爲何要使用 DI。同時文章中也介紹了 InversifyJS,它是目前最流行的 TypeScript DI 框架,藉助 TypeScript 的裝飾器和reflect-metadata在運行時解析依賴。app
InversifyJS 確實實現了依賴注入……可是,卻不是類型安全的。如下面代碼爲例:框架
@injectable() class Foo { constructor(@inject('bar') bar: string) { console.log(bar.substr(2)); } } const context = new Context(); context.bind('bar').toConstantValue(42); context.bind(Foo).toSelf(); context.get(Foo); // Error: bar.substr is not a function
在上述示例中,能夠看到 bar
被聲明爲 string
類型,可是在運行時它倒是一個 number
類型。實際上,在 DI 配置中很容易犯相似這樣的錯誤。因爲 DI 的緣故而失去類型安全性,這太糟糕了。ide
個人目標就是調研「是否能讓編譯器知道依賴及其類型」。若是你的代碼有編譯過程,那麼這會頗有用:字符串就是字符串,數字就是數字,Foo 就是 Foo,不會出現任何其它可能性。函數
若是你對最終結果感興趣,那麼我能夠告訴你:我成功了!你能夠看看 GitHub 上的這個項目。下面是從 README 中提取出來的一段最簡化代碼:
import { rootInjector, tokens } from 'typed-inject'; class Logger { info(message: string) { console.log(message); } } class HttpClient { constructor(private log: Logger) { } public static inject = tokens('logger'); } class MyService { constructor(private http: HttpClient, private log: Logger) { } public static inject = tokens('httpClient', 'logger'); } const appInjector = rootInjector .provideValue('logger', new Logger()) .provideClass('httpClient', HttpClient); const myService = appInjector.injectClass(MyService); // Dependencies for MyService validated and injected
在類的 inject
靜態屬性中聲明依賴。可使用 Injector
的 injectClass
方法實例化一個類,任何構造器參數或者 inject
屬性中的錯誤都會引發編譯錯誤。
很好奇原理吧?這就對了。
爲了讓編譯器給出編譯錯誤,有三個挑戰:
Injector
,用於根據類型生成實例?咱們逐一解決上述挑戰。
咱們從靜態聲明依賴開始。InversifyJS 使用裝飾器,好比:@inject('bar')
用於尋找一個叫作 bar
的依賴並將其注入,因爲裝飾器動態運行方式(裝飾器僅僅是一個運行時執行的函數),沒辦法在編譯階段肯定 bar
依賴存在。
因此咱們不能使用裝飾器,咱們找找其餘方式來聲明依賴。
在 Angular 仍叫 AngularJS 的時代,咱們在類(當時咱們稱之爲構造函數)上面的 $inject
靜態屬性上聲明依賴。在 $inject
屬性上的值,咱們稱之爲「tokens」,$inject
數組中聲明的 tokens 順序與構造函數中參數的順序保持一一對應關係。咱們用 MyService
舉個類似的例子:
class MyService { constructor(private http: HttpClient, private log: Logger) { } public static inject = ['httpClient', 'logger']; }
這是一個好的開始,可是咱們還沒達到目標。經過字符串數組的方式初始化 inject
屬性,編譯器只會將其解析爲普通的字符串數組類型,編譯器沒辦法將 bar
token 與 Bar
類型關聯起來。
當寫錯代碼的時候,咱們指望編譯器會報錯。爲了在編譯時能知道 token 數組的值,咱們須要將其類型聲明爲字符串字面量:
class MyService { constructor(private http: HttpClient, private log: Logger) { } public static inject: ['httpClient', 'logger'] = ['httpClient', 'logger']; }
咱們告訴了 TypeScript 數組的類型是一個值爲 ['httpClient', 'logger']
的 元組,如今咱們有了一絲進展。可是,咱們是懶惰的開發者,咱們不想寫重複的代碼。讓咱們使其更加符合 DRY 原則。
咱們能夠建立一個簡單的輔助方法,它接收任意數量的字面量字符串參數,返回相應的字面量元組值,看起來大體這樣:
function tokens<Tokens extends string[]>(...theTokens: Tokens): Tokens { return theTokens; }
如上所示,theTokens
參數聲明爲剩餘參數,它能匹配到函數的全部參數,同時類型被定義爲 Tokens
,繼承自 string[]
,所以能匹配到任何字符串類型。返回值是 theTokens
,其類型是字面量字符串元組。這樣一來,咱們就能避免以前例子中的重複編碼:
class MyService { constructor(private http: HttpClient, private log: Logger) { } public static inject = tokens('httpClient', 'logger'); }
如上所示,只須要列舉 tokens 一次就行,inject
的類型就會是 ['httpClient','logger']
。變得更棒了,你以爲呢?
TypeScript 中有望引入顯式的元組語法,所以之後咱們再也不須要額外的 tokens
輔助函數。
說到了有趣的部分:確保可注入類的構造函數的參數與聲明的 tokens 相匹配。
首先,咱們聲明 MyService
類(或者任何可注入的類)的靜態接口:
interface Injectable { new(...args: any): any; inject: string[]; }
Injectable
接口描述了一種類:有一個接收任意數量參數的構造函數;有一個靜態 inject
數組屬性,包含了注入 tokens,類型爲 string[]
。這僅僅是個開始,實際上用處不大,不可以將 tokens 值與構造函數參數的類型關聯起來。
所以,咱們須要告訴 TypeScript 編譯器,哪一個 token 對應哪一種類型。幸運的是,TypeScript 支持查詢類型:它是一種沒必要直接做爲類型使用的簡單 interface
,咱們將其用做查詢類型的字典。聲明一個 Context
查詢類型,其值可用於注入:
interface Context { httpClient: HttpClient; logger: Logger; }
任什麼時候候你想聲明一個 Logger
實例,均可以使用 Context
查詢類型,例如 let log: Context['logger']
。有了這個接口,咱們能夠指定 MyService
類的 inject
屬性必須是 Context
的鍵:
interface Injectable { new(...arg: (Context[keyof Context])[]): any; inject: (keyof Context)[]; }
這更加接近目標了。咱們收窄了 inject
的有效值到一個 keyof Context
數組,所以只能使用 'logger' 或者 'httpClient' 做爲 token。構造函數中的每個參數的類型都是 Context[keyof Context]
,所以要麼是 Logger
,要麼是 HttpClient
。
可是,並無達到目的。咱們仍然須要精確關聯值,這就要用到泛型了。
展現一個泛型魔法:
interface Injectable<Token extends keyof Context, R> { new(arg: Context[Token]): R; inject: [Token]; }
如今咱們有了新的進展!咱們聲明瞭一個泛型變量 Token
,限定了取值只能是 Context
中的鍵。咱們也在構造函數中用 Context[Token]
關聯了肯定的類型。同時,咱們也添加了一個類型參數 R
,指代 Injectable
(例如 MyService
實例)實例類型。
仍然存在一個問題,若是咱們想讓構造函數支持更多的參數,咱們就須要爲每一種參數數量聲明一個類型:
interface Injectable2<Token extends keyof Context, Token2 extends keyof Context, R> { new(arg: Context[Token], arg2: Context[Token2]): R; inject: [Token, Token2]; }
這是不可持續的。理想狀況下,對於不一樣數量的構造函數參數,咱們只須要定義一種類型就好了。
咱們已經知道了如何實現!直接使用元組類型的剩餘參數:
interface Injectable<Tokens extends (keyof Context)[], R> { new(...args: CorrespondingTypes<Tokens>): R; inject: Tokens; }
咱們先仔細看一下 Tokens
。經過將 Tokens
聲明爲 keyof Context
數組,咱們可以靜態地將 inject
屬性定義爲一種元組類型,TypeScript 編譯器會保持跟蹤每個 token。舉個例子,對於 inject = tokens('httpClient', 'logger')
,Tokens
類型會被解析爲 ['httpClient', 'logger']
。
構造函數的剩餘參數使用 CorrespondingTypes<Tokens>
映射類型,在下面一節中咱們詳細介紹這塊。
CorrespondingTypes
被實現爲條件映射類型,代碼實現以下:
type CorrespondingTypes<Tokens extends (keyof Context)[]> = { [I in keyof Tokens]: Tokens[I] extends keyof Context ? Context[Tokens[I]] : never; }
上述代碼「一言難盡」,咱們逐層分析。
首先,咱們須要知道 CorrespondingTypes
是映射類型:新類型的屬性名與源類型一致,可是是一種不一樣的類型。在上面代碼中,咱們映射了 Tokens
的屬性。Tokens
是一個泛型元組類型(extends (keyof Context)[]
)。
可是,元組類型的屬性名是什麼呢?好吧,你能夠認爲就是它的索引。所以,對於 ['foo', 'bar']
,屬性名就是 0
和 1
。實際上,對於元組類型和映射類型的搭配支持,已經在最近單獨的 PR 中支持了。一個超棒的特性。
如今,看下關聯屬性值,咱們使用了類型判斷:Tokens[I] extends keyof Context? Context[Tokens[I]] : never
。所以,若是 token 是 Context
的一個鍵,就會返回對應鍵的類型;不然,返回 nerver
類型,意思就是告知 TypeScript 不會出現這種狀況。
既然咱們有了 Injectable
接口,是時候用起來了。先建立核心類:Injector
。
class Injector { injectClass<Tokens extends (keyof Context)[]>(Injectable: Injectable<Tokens, R>): R { const args = /* resolve inject tokens */; return new Injectable(...args); } }
Injector
類有一個 injectClass
方法,接收一個 Injectable
類做爲參數,建立並返回須要的實例。該方法的具體實現已經超出了本文的範疇,可是你能夠思考一下:經過迭代 inject
屬性配置的 tokens 來查詢須要注入的值。
到目前爲止,咱們靜態聲明瞭 Context
類型,它是一個查詢類型,用於關聯 token 和其它類型。若是你在項目中須要這樣寫,會不怎麼光彩。由於這意味着整個 DI 上下文須要一次性初始化,後續不再能配置,一點都不實用。
爲了使 Context
動態化,咱們將其做爲另一個泛型傳入(我保證這會是最後一個泛型)。新的類型聲明以下:
interface Injectable<TContext, Tokens extends (keyof TContext)[], R> { new(...args: CorrespondingTypes<TContext, Tokens>): R; inject: Tokens; } type CorrespondingTypes<TContext, Tokens extends (keyof TContext)[]> = { [Token in keyof Tokens]: Tokens[Token] extends keyof TContext ? TContext[Tokens[Token]] : never; } class Injector<TContext> { inject<Tokens extends (keyof TContext)[]>(injectable: Injectable<TContext, Tokens, R>): R { /* out of scope */ } }
好了,全部的內容看起來都仍是比較熟悉的。咱們引入了 TContext
,用於表示 DI 上下文的查詢接口。
如今,還剩最後一個問題,咱們想要經過動態添加值的方式來配置 Injector
。看下這塊的示例代碼:
const appInjector = rootInjector .provideValue('logger', logger) .provideClass('httpClient', HttpClient);
如上所示,Injector
有 provideXXX
方法,每一個 provide 方法都會向 TContext
泛型中添加鍵,咱們須要另一個 TypeScript 特性來實現這個效果。
在 TypeScript 中,能夠很輕鬆地用 &
組合兩種類型,所以 Foo & Bar
是一種同時擁有 Foo
和 Bar
屬性的類型,這種類型被稱爲交叉類型。這有點像 C++ 的多重繼承或者 Scala 中的 traits。咱們將 TContext
與使用字符串字面量 token 的映射類型關聯起來:
class Injector<TContext> { provideValue<Token extends string, R>(token: Token, value: R) : Injector<{ [K in Token]: R } & TContext> { /* out of scope */ } }
如上所示,provideValue
有兩個泛型參數:一個是 token 常量類型(Token
),一個是注入的值的類型(R
)。該方法返回了一個新的 Injector
實例,其上下文爲 { [K in Token]: R } & TContext
。也就是說,能夠注入任何當前注入器支持的值,也能夠是新提供的 token。
你可能想知道爲何新的 TContext
要和 { [k in Token]: R }
作交叉而不是簡單地用 { [Token]: R }
。這是由於 Token
自己能夠表示一個字符串字面量聯合類型,舉個例子,'foo'| 'bar'
。雖然從 TypeScript 角度來看沒什麼問題,可是若是在調用 provideValue
的時候顯示地傳入一個聯合類型(provideValue<'foo' | 'bar', _>('foo', 42)
)將會破壞類型安全,它會在編譯時同時註冊 'foo'
和 'bar'
做爲 token,並關聯同一個數字,可是在運行時僅僅註冊了 'foo'
。因此,在實際項目中不要這麼作。
其它 provideXXX
方法也是相似的道理,它們返回新的 Injector
實例,提供新的 token,同時合併進了全部舊的 token。
TypeScript 的類型系統很強大,在本文中咱們結合了:
來建立類型安全的依賴注入框架。
雖然,你不會老是遇到這些特性,可是對這些特性保持關注是值得的,畢竟它們爲更好地編碼提供了可能性。