從 JavaScript 到 TypeScript - 接口

2018-09-03 10:04 更新

前面講 泛型 的時(shí)候,提到了接口。和泛型一樣,接口也是目前 JavaScript 中并不存在的語法。

由于泛型語法總是附加在類或函數(shù)語法中,所以從 TypeScript 轉(zhuǎn)譯成 JavaScript 之后,至少還存在類和函數(shù)(只是去掉了泛型定義,類似 Java 泛型的類型擦除)。然而,如果在某個(gè) .ts 文件中只定義了接口,轉(zhuǎn)譯后的 .js 文件將是一個(gè)空文件——接口被完全“擦除”了。

那么,TypeScript 中為什么要出現(xiàn)接口語法?而對(duì)于沒接觸過強(qiáng)類型語法的 JSer 來說,接口到底是個(gè)什么東西?

什么是接口

現(xiàn)實(shí)生活中我們會(huì)遇到這么一個(gè)問題:出國旅游之前,往往需要了解目的地的電源插座的情況:

  1. 是什么形狀,是三插還是雙插,是平插還是圓插?
  2. 如果形狀相同,電壓多少,110V 還是 220V 或者 380V?
  3. 直流電還是交流電?

大家都知道,國內(nèi)的電源插頭常見的有兩種,三平插(比如多數(shù)筆記本電腦電源插頭)和雙平插(比如多數(shù)手機(jī)電源插頭),家用電壓都是 220V。但是近年來電子產(chǎn)品與國際接軌,電源適配器和充電器一般都支持 100~220V 電壓。

那么上面就出現(xiàn)了兩類標(biāo)準(zhǔn),一類是插座的標(biāo)準(zhǔn),另一類是插頭的標(biāo)準(zhǔn)。如果這兩類標(biāo)準(zhǔn)一樣,我們就可以提包上路,不用擔(dān)心到地方后手機(jī)充不上電,電腦找不到合適電源的問題。但是,如果標(biāo)準(zhǔn)不一樣,就必須去買個(gè)轉(zhuǎn)換插頭,甚至是帶變壓功能的轉(zhuǎn)換插頭。

這里提到的轉(zhuǎn)換插頭在軟件開發(fā)中屬于“適配器模式”,這里不深研。我們要研究的是插座和插頭的標(biāo)準(zhǔn)。插座就是留在墻上的接口,它有自身的標(biāo)準(zhǔn),而插頭為了能使用這個(gè)插座,就必須符合它的標(biāo)準(zhǔn),換句話說,得匹配接口。工業(yè)上這像插座這樣的標(biāo)準(zhǔn)必須成文、審批、公布并執(zhí)行,而編程上的接口也類似,需要定義接口、類型檢查(編譯器)、公布文檔,實(shí)現(xiàn)接口。

所以回到 TypeScript,我們以關(guān)鍵字 interface,用類似于 class 聲明的語法在定義接口 (還記得聲明類型一文中提到的類成員聲明嗎)。所以一個(gè)接口看起來可能是這樣的

interface INamedLogable {
    name: string;
    log(...args: any[]);
}

通過實(shí)例講接口

假設(shè)我們的業(yè)務(wù)中有這樣一部分 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

我們還不懂接口,所以先定義一個(gè)類,包含 name 屬性和 log() 方法。有了這個(gè)類就可以在 doWith() 和其它定義中使用它來進(jìn)行類型約束(檢查)。

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");
}

調(diào)用示例:

const logger = new JsLogger("jsLogger");
doWith(logger);

給 log() 方法加點(diǎn)料

上面的示例中,輸出的日志只有日志內(nèi)容本身,但是我們希望能在日志信息每行前面綴上日志名稱,比如像這樣的輸出

[jsLogger] begin to do

所以我們從 JsLogger 繼承出來一個(gè) PoweredJsLogger 來用:

class PoweredJsLogger extends JsLogger {
    log(...args: any[]) {
        console.log(`[${this.name}]`, ...args);
    }
}


const logger = new PoweredJsLogger("jsLogger");
doWith(logger);

換個(gè)第三方 Logger

甚至我們可以換個(gè)第三方 Logger,與 JsLogger 毫無關(guān)系,但成員定義相同

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);

你以為它會(huì)報(bào)錯(cuò)?沒有,它轉(zhuǎn)譯正常,運(yùn)行正常,輸出

[Logger] oops
[Another(oops)] begin to do
[Another(oops)] all done

看到這個(gè)結(jié)果,Java 和 C# 程序員要抓狂了。不過 JSer 覺得這沒什么啊,我們平時(shí)經(jīng)常這么干。

從類 (class) 聲明接口

理論上來說,接口是一個(gè)抽象概念,類是一個(gè)更具體的抽象概念——是的,類不是實(shí)體 (instance),從類產(chǎn)生的對(duì)象才是實(shí)體。一般情況下,我們的設(shè)計(jì)過程是從具體到抽象,但開發(fā)(編程)過程正好相反,是從抽象到具體。所以一般在開發(fā)過程中都是先定義接口,再定義實(shí)現(xiàn)這個(gè)接口的類。

當(dāng)然有例外,我相信多數(shù)開發(fā)者會(huì)有相反的體驗(yàn),尤其是一邊設(shè)計(jì)一邊開發(fā)的時(shí)候:先根據(jù)業(yè)務(wù)需要定義類,再從這個(gè)類抽象出接口,定義接口并聲明之前的類實(shí)現(xiàn)這個(gè)接口。如果接口元素(比如:方法)發(fā)生變化,往往也是先在類中實(shí)現(xiàn),再進(jìn)行抽象補(bǔ)充到接口定義中。這種情況下我們多么希望能直接從類生成接口……當(dāng)然有工具可以實(shí)現(xiàn)這個(gè)過程,但多數(shù)語言本身并不支持——?jiǎng)e再問我原因,剛才已經(jīng)講過了。

不過 TypeScript 帶來了不一樣的體驗(yàn),我們可以從類聲明接口,比如這樣

interface ILogger extends JsLogger {
    // 還可以補(bǔ)充其它接口元素
}

這里定義的 ILogger 和最前面定義的 INamedLogable 具有相同的接口元素,是一樣的效果。

為什么 TypeScript 支持這種反向的定義……也許真的只是為了方便。但是對(duì)于大型應(yīng)用開發(fā)來說,這并不見得是件好事。如果以后因?yàn)槟承┰蛐枰獮?JsLogger 添加公共方法,那就悲劇了——所有實(shí)現(xiàn)了 ILogger 接口的類都得實(shí)現(xiàn)這個(gè)新加的方法。也許以后某個(gè)版本的 TypeScript 會(huì)處理這個(gè)問題,至少現(xiàn)在 Java 已經(jīng)找到辦法了,這就是 Java 8 帶來的默認(rèn)方法,而且 C# 馬上也要實(shí)現(xiàn)這一特性了 。

回到上面的問題

現(xiàn)在回到上面的問題,為什么向 doWith() 傳入 AnotherLogger 對(duì)象毫不違和,甚至連個(gè)警告都沒有。

前面我們已經(jīng)提到了“鴨子辨型法”,對(duì)于 doWith(logger: JsLogger) 來說,它需要的并不真的是 JsLogger,而是 interface extends JsLogger {}。只要傳入的這參數(shù)符合這個(gè)接口約束,方法體內(nèi)的任何語句都不會(huì)產(chǎn)生語法錯(cuò)誤,語法上絕對(duì)沒有問題。因此,傳入 AnotherLogger 不會(huì)有問題,它所隱含的接口定義完全符合 ILogger 接口的定義。

然而,語義上也許會(huì)有些問題,這也是我作為一個(gè)十多年經(jīng)驗(yàn)的靜態(tài)語言使用者所不能完全理解的。有可能這是 TypeScript 為了適應(yīng)動(dòng)態(tài)的 JavaScript 所做出的讓步,也有可能這是 TypeScript 特意引入的特性。我對(duì)多數(shù)動(dòng)態(tài)語言和函數(shù)式語言并不了解,但我相信,這肯定不是 TypeScript 首創(chuàng)。

TypeScript 接口詳述

上面大量的內(nèi)容只是為了將大家通過 class 的定義引入到對(duì) interface 的了解。但是接口到底該怎么定義?

常規(guī)接口

常規(guī)接口的定義和類的定義幾乎沒有區(qū)別,上面已經(jīng)存在例子,歸納起來需要注意幾點(diǎn):

  • 使用 interface 關(guān)鍵字;
  • 接口名稱一般按規(guī)范前綴 I
  • 接口中不包含實(shí)現(xiàn)
    • 不對(duì)成員變量賦初始值
    • 沒有構(gòu)造函數(shù)
    • 沒有方法體

而對(duì)接口的實(shí)現(xiàn)可以通過 implemnets 關(guān)鍵字,比如

class MyLogger implements INamedLogable {
    name: string;
    log(...args: any[]) {
        console.log(...args);
    }
}

這是顯式地實(shí)現(xiàn),還有隱式的。

const myLogger: INamedLogable = {
    name: "my-loader",
    log(...args: any[]) {
        console.log(...args);
    }
};

另外,在所有聲明接口類型的地方傳值或賦值,TypeScript 會(huì)通過對(duì)接口元素一一對(duì)比來對(duì)傳入的對(duì)象進(jìn)行檢查。

函數(shù)類型接口

曾經(jīng)我們定義一個(gè)函數(shù)類型,是使用 type 關(guān)鍵字,以類似 Lambda 的語法來定義。比如需要定義一個(gè)參數(shù)是 number,返回值是 string 的函數(shù)類型:

// 聲明類型
type NumberToStringFunc = (n: number) => string;


// 定義符合這個(gè)類型的 hex
const hex: NumberToStringFunc = n => n.toString(16);

現(xiàn)在可以用接口語法來定義

// tslint:disable-next-line:interface-name
interface NumberToStringFunc {
    (n: number): string;
}


const hex: NumberToStringFunc = n => n.toString(16);

這種定義方式和 Java 8 的函數(shù)式接口語法類似,而且由于它表示一個(gè)函數(shù)類型,所以一般不會(huì)前綴 I,而是后綴 Func(有參) 或者 Action(無參)。不過 TSLint 可不吃這一套,所以這里通過注釋關(guān)閉了 TSLint 對(duì)該接口的命名檢查。

這樣的接口不能由類實(shí)現(xiàn)。上例中的 hex 是直接通過一個(gè) Lambda 實(shí)現(xiàn)的。它還可以通過函數(shù)、函數(shù)表達(dá)式來實(shí)現(xiàn)。另外,它可以擴(kuò)展為混合類型的接口。

混合類型接口

JSer 們應(yīng)該經(jīng)常會(huì)用到一種技巧,定義一個(gè)函數(shù),再為這個(gè)函數(shù)賦值某些屬性——這沒毛病,JavaScript 的函數(shù)本身就是對(duì)象,而 JavaScript 的對(duì)象可以動(dòng)態(tài)修改。最常見的例子應(yīng)該就是 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 關(guān)鍵字,所以說成是繼承也并無不可。

另外,接口還可以繼承自其它接口,比如

interface INewLogger: ILogger {
    suplier: string;
}

接口還允許從多個(gè)接口繼承,比如上面提到的 INamedLogable 可以拆分一下

interface INamed {
    name: string;
}


interface ILogable {
    log(...args: any[]);
}


interface INamedLogable extends INamed, ILogable {}

這樣定義 INamedLogable 是不是更合理一些?

后記

不管什么語言,接口的主要目的是為了在供應(yīng)者和消費(fèi)者之前創(chuàng)建一個(gè)契約,其意義更傾向于設(shè)計(jì)而非程序本身,所以接口在各種設(shè)計(jì)模式中應(yīng)用非常廣泛。不要為了接口而接口,在設(shè)計(jì)需要的時(shí)候使用它。對(duì)復(fù)雜的應(yīng)用來說,定義一套好的接口很有必要,但是對(duì)于一些小程序來說,似乎并無必要。

相關(guān)閱讀

此文首發(fā)于 SegmentFault - 邊城客棧 專欄

歡迎關(guān)注作者的公眾號(hào)“邊城客?!?,閱讀邊城原創(chuàng)開發(fā)技術(shù)類博文 ?

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)