前面讲 泛型 的时候,提到了接口。和泛型同样,接口也是目前 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
是否是更合理一些?
无论什么语言,接口的主要目的是为了在供应者和消费者以前建立一个契约,其意义更倾向于设计而非程序自己,因此接口在各类设计模式中应用很是普遍。不要为了接口而接口,在设计须要的时候使用它。对复杂的应用来讲,定义一套好的接口颇有必要,可是对于一些小程序来讲,彷佛并没有必要。
相关阅读
关注做者的公众号“边城客栈” →