4

前面讲 泛型 的时候,提到了接口。和泛型一样,接口也是目前 JavaScript 中并不存在的语法。

由于泛型语法总是附加在类或函数语法中,所以从 TypeScript 转译成 JavaScript 之后,至少还存在类和函数(只是去掉了泛型定义,类似 Java 泛型的类型擦除)。然而,如果在某个 .ts 文件中只定义了接口,转译后的 .js 文件将是一个空文件——接口被完全“擦除”了。

那么,TypeScript 中为什么要出现接口语法?而对于没接触过强类型语法的 JSer 来说,接口到底是个什么东西?

什么是接口

现实生活中我们会遇到这么一个问题:出国旅游之前,往往需要了解目的地的电源插座的情况:

  1. 是什么形状,是三插还是双插,是平插还是圆插?

  2. 如果形状相同,电压多少,110V 还是 220V 或者 380V?

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

后记

不管什么语言,接口的主要目的是为了在供应者和消费者之前创建一个契约,其意义更倾向于设计而非程序本身,所以接口在各种设计模式中应用非常广泛。不要为了接口而接口,在设计需要的时候使用它。对复杂的应用来说,定义一套好的接口很有必要,但是对于一些小程序来说,似乎并无必要。


相关阅读


关注作者的公众号“边城客栈” →


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!