前面講 泛型 的時候,提到了接口。和泛型同樣,接口也是目前 JavaScript 中並不存在的語法。html
因爲泛型語法老是附加在類或函數語法中,因此從 TypeScript 轉譯成 JavaScript 以後,至少還存在類和函數(只是去掉了泛型定義,相似 Java 泛型的類型擦除)。然而,若是在某個 .ts
文件中只定義了接口,轉譯後的 .js
文件將是一個空文件——接口被徹底「擦除」了。程序員
那麼,TypeScript 中爲何要出現接口語法?而對於沒接觸過強類型語法的 JSer 來講,接口究竟是個什麼東西?typescript
現實生活中咱們會遇到這麼一個問題:出國旅遊以前,每每須要瞭解目的地的電源插座的狀況:編程
是什麼形狀,是三插仍是雙插,是平插仍是圓插?小程序
若是形狀相同,電壓多少,110V 仍是 220V 或者 380V?segmentfault
直流電仍是交流電?設計模式
你們都知道,國內的電源插頭常見的有兩種,三平插(好比多數筆記本電腦電源插頭)和雙平插(好比多數手機電源插頭),家用電壓都是 220V。可是近年來電子產品與國際接軌,電源適配器和充電器通常都支持 100~220V 電壓。模塊化
那麼上面就出現了兩類標準,一類是插座的標準,另外一類是插頭的標準。若是這兩類標準同樣,咱們就能夠提包上路,不用擔憂到地方後手機充不上電,電腦找不到合適電源的問題。可是,若是標準不同,就必須去買個轉換插頭,甚至是帶變壓功能的轉換插頭。函數
這裏提到的轉換插頭在軟件開發中屬於「適配器模式」,這裏不深研。咱們要研究的是插座和插頭的標準。插座就是留在牆上的接口,它有自身的標準,而插頭爲了能使用這個插座,就必須符合它的標準,換句話說,得匹配接口。工業上這像插座這樣的標準必須成文、審批、公佈並執行,而編程上的接口也相似,須要定義接口、類型檢查(編譯器)、公佈文檔,實現接口。工具
因此回到 TypeScript,咱們以關鍵字 interface
,用相似於 class
聲明的語法在定義接口 (還記得聲明類型一文中提到的類成員聲明嗎)。因此一個接口看起來多是這樣的
interface INamedLogable { name: string; log(...args: any[]); }
假設咱們的業務中有這樣一部分 JavaScript 代碼
function doWith(logger) { console.log(`[Logger] ${logger.name}`); logger.log("begin to do"); // ... logger.log("all done"); } doWith({ name: "jsLogger", log(...args) { console.log(...args); } })
咱們還不懂接口,因此先定義一個類,包含 name
屬性和 log()
方法。有了這個類就能夠在 doWith()
和其它定義中使用它來進行類型約束(檢查)。
class JsLogger { name: string; constructor(name: string) { this.name = name; } log(...args: any[]) { console.log(...args); } }
而後定義 doWith
:
function doWith(logger: JsLogger) { console.log(`[Logger] ${logger.name}`); logger.log("begin to do"); // ... logger.log("all done"); }
調用示例:
const logger = new JsLogger("jsLogger"); doWith(logger);
上面的示例中,輸出的日誌只有日誌內容自己,可是咱們但願能在日誌信息每行前面綴上日誌名稱,好比像這樣的輸出
[jsLogger] begin to do
因此咱們從 JsLogger
繼承出來一個 PoweredJsLogger
來用:
class PoweredJsLogger extends JsLogger { log(...args: any[]) { console.log(`[${this.name}]`, ...args); } } const logger = new PoweredJsLogger("jsLogger"); doWith(logger);
甚至咱們能夠換個第三方 Logger,與 JsLogger
毫無關係,但成員定義相同
function doWith(logger: JsLogger) { console.log(`[Logger] ${logger.name}`); logger.log("begin to do"); // ... logger.log("all done"); } const logger = new AnotherLogger("oops"); doWith(logger);
你覺得它會報錯?沒有,它轉譯正常,運行正常,輸出
[Logger] oops [Another(oops)] begin to do [Another(oops)] all done
看到這個結果,Java 和 C# 程序員要抓狂了。不過 JSer 以爲這沒什麼啊,咱們平時常常這麼幹。
理論上來講,接口是一個抽象概念,類是一個更具體的抽象概念——是的,類不是實體 (instance),從類產生的對象纔是實體。通常狀況下,咱們的設計過程是從具體到抽象,但開發(編程)過程正好相反,是從抽象到具體。因此通常在開發過程當中都是先定義接口,再定義實現這個接口的類。
固然有例外,我相信多數開發者會有相反的體驗,尤爲是一邊設計一邊開發的時候:先根據業務須要定義類,再從這個類抽象出接口,定義接口並聲明以前的類實現這個接口。若是接口元素(好比:方法)發生變化,每每也是先在類中實現,再進行抽象補充到接口定義中。這種狀況下咱們多麼但願能直接從類生成接口……固然有工具能夠實現這個過程,但多數語言自己並不支持——別再問我緣由,剛纔已經講過了。
不過 TypeScript 帶來了不同的體驗,咱們能夠從類聲明接口,好比這樣
interface ILogger extends JsLogger { // 還能夠補充其它接口元素 }
這裏定義的 ILogger
和最前面定義的 INamedLogable
具備相同的接口元素,是同樣的效果。
爲何 TypeScript 支持這種反向的定義……也許真的只是爲了方便。可是對於大型應用開發來講,這並不見得是件好事。若是之後由於某些緣由須要爲 JsLogger
添加公共方法,那就悲劇了——全部實現了 ILogger
接口的類都得實現這個新加的方法。也許之後某個版本的 TypeScript 會處理這個問題,至少如今 Java 已經找到辦法了,這就是 Java 8 帶來的默認方法,並且 C# 立刻也要實現這一特性了 。
如今回到上面的問題,爲何向 doWith()
傳入 AnotherLogger
對象絕不違和,甚至連個警告都沒有。
前面咱們已經提到了「鴨子辨型法」,對於 doWith(logger: JsLogger)
來講,它須要的並不真的是 JsLogger
,而是 interface extends JsLogger {}
。只要傳入的這參數符合這個接口約束,方法體內的任何語句都不會產生語法錯誤,語法上絕對沒有問題。所以,傳入 AnotherLogger
不會有問題,它所隱含的接口定義徹底符合 ILogger
接口的定義。
然而,語義上也許會有些問題,這也是我做爲一個十多年經驗的靜態語言使用者所不能徹底理解的。有可能這是 TypeScript 爲了適應動態的 JavaScript 所作出的讓步,也有可能這是 TypeScript 特地引入的特性。我對多數動態語言和函數式語言並不瞭解,但我相信,這確定不是 TypeScript 獨創。
上面大量的內容只是爲了將你們經過 class
的定義引入到對 interface
的瞭解。可是接口到底該怎麼定義?
常規接口的定義和類的定義幾乎沒有區別,上面已經存在例子,概括起來須要注意幾點:
使用 interface
關鍵字;
接口名稱通常按規範前綴 I
;
接口中不包含實現
不對成員變量賦初始值
沒有構造函數
沒有方法體
而對接口的實現能夠經過 implemnets
關鍵字,好比
class MyLogger implements INamedLogable { name: string; log(...args: any[]) { console.log(...args); } }
這是顯式地實現,還有隱式的。
const myLogger: INamedLogable = { name: "my-loader", log(...args: any[]) { console.log(...args); } };
另外,在全部聲明接口類型的地方傳值或賦值,TypeScript 會經過對接口元素一一對比來對傳入的對象進行檢查。
曾經咱們定義一個函數類型,是使用 type
關鍵字,以相似 Lambda 的語法來定義。好比須要定義一個參數是 number
,返回值是 string
的函數類型:
// 聲明類型 type NumberToStringFunc = (n: number) => string; // 定義符合這個類型的 hex const hex: NumberToStringFunc = n => n.toString(16);
如今能夠用接口語法來定義
// tslint:disable-next-line:interface-name interface NumberToStringFunc { (n: number): string; } const hex: NumberToStringFunc = n => n.toString(16);
這種定義方式和 Java 8 的函數式接口語法相似,並且因爲它表示一個函數類型,因此通常不會前綴 I
,而是後綴 Func
(有參) 或者 Action
(無參)。不過 TSLint 可不吃這一套,因此這裏經過註釋關閉了 TSLint 對該接口的命名檢查。
這樣的接口不能由類實現。上例中的 hex
是直接經過一個 Lambda 實現的。它還能夠經過函數、函數表達式來實現。另外,它能夠擴展爲混合類型的接口。
JSer 們應該常常會用到一種技巧,定義一個函數,再爲這個函數賦值某些屬性——這沒毛病,JavaScript 的函數自己就是對象,而 JavaScript 的對象能夠動態修改。最多見的例子應該就是 jQuery 和 Lodash 了。
這樣的類型在 TypeScript 中就經過混合類型接口來定義,此次直接引用官方文檔的示例:
interface Counter { (start: number): string; interval: number; reset(): void; } function getCounter(): Counter { let counter = <Counter>function (start: number) { }; counter.interval = 123; counter.reset = function () { }; return counter; } let c = getCounter(); c(10); c.reset(); c.interval = 5.0;
前面咱們提到能夠從類聲明接口,其語法採用 extends
關鍵字,因此說成是繼承也並沒有不可。
另外,接口還能夠繼承自其它接口,好比
interface INewLogger: ILogger { suplier: string; }
接口還容許從多個接口繼承,好比上面提到的 INamedLogable
能夠拆分一下
interface INamed { name: string; } interface ILogable { log(...args: any[]); } interface INamedLogable extends INamed, ILogable {}
這樣定義 INamedLogable
是否是更合理一些?
無論什麼語言,接口的主要目的是爲了在供應者和消費者以前建立一個契約,其意義更傾向於設計而非程序自己,因此接口在各類設計模式中應用很是普遍。不要爲了接口而接口,在設計須要的時候使用它。對複雜的應用來講,定義一套好的接口頗有必要,可是對於一些小程序來講,彷佛並沒有必要。
相關閱讀
關注做者的公衆號「邊城客棧」 →