從 JavaScript 到 TypeScript - 接口

前面講 泛型 的時候,提到了接口。和泛型同樣,接口也是目前 JavaScript 中並不存在的語法。html

因爲泛型語法老是附加在類或函數語法中,因此從 TypeScript 轉譯成 JavaScript 以後,至少還存在類和函數(只是去掉了泛型定義,相似 Java 泛型的類型擦除)。然而,若是在某個 .ts 文件中只定義了接口,轉譯後的 .js 文件將是一個空文件——接口被徹底「擦除」了。程序員

那麼,TypeScript 中爲何要出現接口語法?而對於沒接觸過強類型語法的 JSer 來講,接口究竟是個什麼東西?typescript

什麼是接口

現實生活中咱們會遇到這麼一個問題:出國旅遊以前,每每須要瞭解目的地的電源插座的狀況:編程

  1. 是什麼形狀,是三插仍是雙插,是平插仍是圓插?小程序

  2. 若是形狀相同,電壓多少,110V 仍是 220V 或者 380V?segmentfault

  3. 直流電仍是交流電?設計模式

你們都知道,國內的電源插頭常見的有兩種,三平插(好比多數筆記本電腦電源插頭)和雙平插(好比多數手機電源插頭),家用電壓都是 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);
    }
})

翻譯成 TypeScript

咱們還不懂接口,因此先定義一個類,包含 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);

給 log() 方法加點料

上面的示例中,輸出的日誌只有日誌內容自己,可是咱們但願能在日誌信息每行前面綴上日誌名稱,好比像這樣的輸出

[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

甚至咱們能夠換個第三方 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 以爲這沒什麼啊,咱們平時常常這麼幹。

從類 (class) 聲明接口

理論上來講,接口是一個抽象概念,類是一個更具體的抽象概念——是的,類不是實體 (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 獨創。

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 是否是更合理一些?

後記

無論什麼語言,接口的主要目的是爲了在供應者和消費者以前建立一個契約,其意義更傾向於設計而非程序自己,因此接口在各類設計模式中應用很是普遍。不要爲了接口而接口,在設計須要的時候使用它。對複雜的應用來講,定義一套好的接口頗有必要,可是對於一些小程序來講,彷佛並沒有必要。


相關閱讀


關注做者的公衆號「邊城客棧」 →

相關文章
相關標籤/搜索