Interface

129 阅读10分钟

TS的核心概念之一就是类型检验主要集中在值的结构上。有时这也被称为‘duck typing(鸭式打字)’和‘structural subtyping(结构亚型)’。在TS中,接口主要是来命名这些值的类型,同时也是一种在项目内和项目外定义变量类型的强大方法。

第一个接口

为方便理解我们先创造一个简单的接口:

function printLabel(lableObject: { label: string }) {
    console.log(labelObject.label);
}

let myObj = {
    size: 10,
    label: 'Size 10 Object',
}
printLabel(myObj);

类型检测会检查printLable.这个方法只有一个参数,是有一个字符串类型的属性名为label的对象。注意我们的调用对象实际上比起所需的参数对象有更多的属性,但是类型检查只会关注这个调用对象起码有这个属性且值的类型符合条件。有些情况下TS不那么宽大,我们稍后再谈。

再次使用上面的例子:

interface LabeledValue {
    label: string
}

function printLabel(labelObj: LabeledValue) {
    console.log(lableObj.label)
}

let obj  = {size: 10, lael: '10'}
printLabel(obj)

LabelValue接口用来描述我们在第一个例子中参数的要求。注意到我们没有明确的声明对象需要继承接口。这TS中我们只关注结构。如果被调用的对象满足要求,函数才允许调用。

值的注意的是类型检测不关注调用对象的类型的顺序,只看有没有这个属性且它的值是否符合条件。

可选属性

接口中并非所有的属性都是必要的。这些可选属性在我们构建类似‘选项包’这类东西的时候会很有效。

比如这种:

interface SquareConfig{
    color?: string,
    width?: number,
}

function createSquare(config: SquareConfig): {color: string, area: number} {
  let newSquare  = {
    color: 'white',
    area: 100
  };
  if (config.color) {
    newSquare.color = config.color; 
  }
  if (config.width) {
    newSquare.area = config.width * config.width;
  }
  return newSquare;
}

let mySquare = createSquare({ color: "black" });

和其他接口的写法类似,可选属性只是要在定义的时候在后面加一个'?'进行标注。

可选属性的优点就是您可以描述这些可能可用的属性,同时还可以防止使用不属于接口一部分的属性。比如说我们在上一个例子中打错了变量名,在编译的时候我们就会得到一条错误信息:

if (config.clor) {
// Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?    // Error: Property 'clor' does not exist on type 'SquareConfig'
  newSquare.color = config.clor;
// Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?Property 'clor' does not exist on type 'SquareConfig'. Did you mean 'color'?  }

只读属性

部分属性理当只能在创建的时候可以修改。你可以通过添加readonly字段指定这些属性。

interface Point{
  readonly x:number;
  readonly y:number;
}

你可以通过直接赋予字面量对象建造一个point对象。在声明之后,xy都是不能变的。

let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error!
//Cannot assign to 'x' because it is a read-only property.

TS附带一个ReadonlyArray类型,他与Array类似,只是移除了所有的突变方法,这样就可以保证数组在创建之后不会被改写。

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;

ro[0] = 12;
// error!
//Index signature in type 'readonly number[]' only permits reading.
ro.push(5); 
// error!
//Property 'push' does not exist on type 'readonly number[]'.
ro.length = 100; 
// error!
//Cannot assign to 'length' because it is a read-only property.
a = ro; 
// error!
//The type 'readonly number[]' is 'readonly' and cannot be assigned to 
//the mutable type 'number[]'.

在代码段最后一行你会发现连将Readonly属性的数组赋值给一个普通数组都是不允许的。你只能通过制定一个新的类型声明强制改写它:

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;

a = ro as number[];

readonly vs const: const 定义变量, readonly定义属性。

额外属性检查

在我们的第一个例子中, 拥有额外属性的对象也通过了接口的检查。之后我们也学习了可选属性和所谓的属性包。

但若是天真地把两者结合就会产生错误。以我们最后一个例子为例:

interface SquareConfig {
  color?: string;
  width?: number;
}

function createSquare(config: SquareConfig): { color: string; area: number } {
  return { color: config.color || "red", area: config.width ? config.width*config.width : 20 };
}

let mySquare = createSquare({ colour: "red", width: 100 });
// Argument of type '{ colour: string; width: number; }' 
// is not assignable to parameter of type 'SquareConfig'.
// Object literal may only specify known properties,
// but 'colour' does not exist in type 'SquareConfig'.
// Did you mean to write 'color'?

注意我们最后传的参数对象的属性名是错误的。在纯JS中,这种错误都是不容易被发现的。

你可以这么说:这个项目被正确的规范了类型,因为width属性是兼容的,没有现存的color属性,只有一个不重要的colour属性。

但TS采取的立场是这可能存在一个Bug.对象字面值得到特殊处理,并在将其分配给其他变量或作为参数传递时进行过多的属性检查。若一个对象字面量值拥有任何目标类型中不包含的属性,会返回一个错误。

绕过这些检查实际上很简单。最简单的方法就是使用一个类型断言;

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig); // valid

但我们推荐当你确定目标对象存在有效且有作用的属性时你可以添加一个字符串索引签名,比如像这样:

interface SquareConfig {
    color? : string;
    width? : number;
    [propName: string]: any
}

我们之后再谈索引签名,但在此之前你会发现,接口SquareConfig可以有任意条属性且当属性名不为colorwidth时,他们的值的类型也没有限制。

绕过这些检查的最后一种方法可能有点令人惊讶,那就是将对象分配给另一个变量:由于squareOptions不会进行过多的属性检查,编译器不会给您错误。

let squareOptions = { colour: "red", width: 100 };

let mySquare = createSquare(squareOptions);

上述方法只有当对象存在一个与接口中描述的属性名相同时才会起作用。但是如果一个都没有,那就会报错了~

let squareOptions = { colour: "red" };
let mySquare = createSquare(squareOptions);
//Type '{ colour: string; }' has no properties in common with type 'SquareConfig'.

要记住向上面这种代码,你大可不必绕过检查。但对那些复杂的有自己的方法和状态的对象字面量而言,你大概就需要用到这些技术了,但是大量的额外属性错误实际上是bug。这意味着当你在像选项包之类的东西上执行额外属性类型检查时,你就要修改一些类型声明了。这种情况下,若允许在createSquare中传一个既有color又有colour属性的对象,你应该修正SquareConfig的定义来描述这点。

函数类型

接口可以描述各种各样的JS对象。除此之外,接口还可以用来描述函数类型。

为了使用接口描述对象类型,我们为接口增加一个调用签名。这有点想函数在声明时只规定参数列表和返回值类型。每个参数都需要声明其属性名和属性值类型。

interface SearcgFunc{
    (source: string, subString: string): boolean;
}

一旦定义,我们可以像其他接口一样使用这种函数类型接口。在这我们将演示如何创建一个函数类型的变量,并为其分配相同类型的函数值。

let mySearch: SearchFunc;

mySearch = function(source: string, subString: string): boolean{
    let result = source.search(subString);    return result > -1;
}

为了使函数类型正确地进行类型检查,参数的名称不需要匹配。上面的例子也可以这么写:

let mySearch: SearchFunc;

mySearch = function (src: string, sub: string): boolean {
  let result = src.search(sub);
  return result > -1;
};

每次检查一个函数参数,每个相应参数位置的类型相互检查。如果您根本不想指定类型,那么TypeScript的上下文类型可以推断参数类型,因为函数值直接分配给SearchFunc类型的变量。在这里,函数表达式的返回类型是由它返回的值(这里是falsetrue)隐含的。

let mySearch: SearchFunc;

mySearch = function (src, sub) {
  let result = src.search(sub);
  return result > -1;
};

如果函数表达式返回数字或字符串,则类型检查器将出错,指示返回类型与SearchFunc接口中描述的返回类型不匹配。

let mySearch: SearchFunc;

mySearch = function (src, sub) {
  // Type '(src: string, sub: string) => string' is not assignable to type 'SearchFunc'.
  // Type 'string' is not assignable to type 'boolean'.
 
 let result = src.search(sub);
  return "string";
}

可索引类型

类似于我们如何使用接口来描述函数类型,我们也可以描述可以“索引到”的类型,比如a[10],或者ageMap[“daniel”]。可索引类型有一个索引签名,它描述了我们可以用来索引到对象中的类型,以及索引时相应的返回类型。举个例子:

interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;

myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

在上面这个例子中,StringArray接口有一个索引签名。此索引签名声明,当StringArray用数字编制索引时,它将返回一个字符串。

有两种支持的索引签名:string和number。实际上TS可以同时支持这两种类型的索引器,但从数字索引器返回的类型必须是从字符串索引器返回的类型的子类型。这是因为当用一个数字建立索引时,JavaScript实际上会在索引到对象之前将其转换为字符串。这意味着,用100(数字)索引与用“100”(字符串)索引相同,因此这两个索引需要一致。

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
  [x: number]: Animal;
  // Numeric index type 'Animal' is not assignable to string index type 'Dog'.
  [x: string]: Dog;

虽然字符串索引签名是描述“字典”模式的一种强大方法,但它们也强制要求所有属性都与它们的返回类型匹配。这是因为一个字符串索引声明了obj.proterty也可写成obj['propterty'].在下面这个地方,类型name不匹配字符串索引的类型,因此会返回一个错误:

interface NumberDictionary {
  [index: string]: number;
  length: number; // ok, length is a number
  name: string; // error, the type of 'name' is not a subtype of the indexer
//Property 'name' of type 'string' is not assignable to string index type 'number'.
}

针对这点你可以在声明索引变量值类型的时候修改写法:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // ok, length is a number
  name: string; // ok, name is a string
}

最后,你可以使用readonly标志防止索引签名的属性值被改写:

interface ReadonlyStringArray {
  readonly [index: number]: string;
}

let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!
// Index signature in type 'ReadonlyStringArray' only permits reading.

类的类型(Class types)

当然,类也可以继承接口:

interface A{
  currentTime: String;}

class now extends A{
  currentTime: String = '2020-11-12'
  constructor(a: number, b: string){
    this.age = a;
    // ...
  }
}

除了类的public变量之外,方法也是可以类型继承的。需要注意的是,类继承接口只会对类的公有变量和方法造成影响,私有变量不在此列。

interface instanceWay{
  new (a: number, b: number);
}

class simple implements instanceWay{
  c: number;
  constructor(a: number, b: number) {
    // ...
  }
}

上面这个类会在编译的时候返回一个错误。

这是因为在类中进行检查的时候,他会只检查类的实例端,而constructor函数属于静态端,ts会跳过去,因而由于没有检查到类里面其他符合条件的函数,TS返回了一个空匹配的错误。(自己试验了一下,发现在这种模式下,无论定义什么函数似乎都会返回空匹配错误)。

interface instanceWay{
    new (a: number, b: number): instanceInterFace;
}

interface instanceInterFace{
    tick(): void;
}

function createInstance(
    constructors: instanceWay,
    a: number,
    b: number,
): instanceInterFace {
    return new constructors(a, b);
}

class Again implements instanceInterFace{
    constructor(h: number, t: number){
        // ...
    }
    tick(){    
        console.log('bilibili')
    }
}

我一直觉得这个instanceWay表现的很奇怪,表现得不像一个正常的类的接口;这样看起来,其实更像一个方法的接口。毕竟new方法是在构造函数上的,而类继承不会直白的去检查constructor.

let instance = createInstance(Again, 11, 12);

这样我们就能正确的检查到这个类的构造函数是否符合那个奇怪的new类型了。

此外,我们还可以这样调用:

const obj: instanceWay= class Again implements instanceInterFace{
  constructor(h: number, m: number) {}
  tick() {
    console.log("beep beep");
  }
};

接口拓展

  当然,接口也可以继承接口。

interface Shape{
    color: string;
}
interface Square extends Shape{  
    sideLength: number;
}
let square = {} as Square;
square.color = 'blue';
square.sideLength = 10;

  一个接口可以继承多个接口。

interface Square extends Shape,Another{...}

混合类型

  就接口创造的作用而言,他可以描述复数个类型。因为函数本身就是一个对象(在JS里面),所以接口这么操作也是允许的。

interface Counter {  
    (start: number): string;  
    interval: number;
    reset(): void;
    extra: '11';
}

function getCounter(): Counter{  
    let counter = function (start: number){} as Counter;  
    counter.interval = 123;  
    counter.reset = function(){};  
    counter.extra = '11';  
    return counter;
}

let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;

接口继承类

我人傻了。

  仔细想想,接口继承类在逻辑上确实没有什么问题。在TS中,接口继承一个类可能和继承一个接口是类似的。

class A{  
    name: 'makabaka';  
    myName: string;  
    age: number;  
    sayHello(){    
        console.log('oh no');  
    }  
    constructor($name: string, age: number){    
        this.myName = $name;    
        this.age = age;  
    }
}

interface APlus extends A{

}

class B implements APlus{

}
//如果直接这样写会返回一个错误标示 B没有实现A中间的属性和方法

此外,如果A类中有私有变量,那情况又不一样了。

class A{
    private name: any;
}

interface APlus extends A {
}

class B implements APlus{
    private name: any;
}
// error TS2420: Class 'B' incorrectly implements interface 'APlus'.
// Types have separate declarations of a private property 'name'.

这个时候你只能强迫自己变成A的子类了。

class B extends A implements APlus{}// 这时候即使你不声明name属性 也不会报错了 因为会继承过来。

以上。

Tips:

接口和类型声明的区别/差异:

  1. 接口可以重新声明,但是类型声明不可以
    interface Window {
        name: string
    }
    
    interface Window {
        age: number
    }
    
    // ok
    
    type Window = {
        name: string
    }
    
    type Window = {
        age: number
    }
    
    // nope, duplicate indentifier 'Window'
  1. 接口只能用来声明对象的形状/结构, 不能重命名基础类型。