自我介绍:大家好,我是吉帅振的网络日志(其他平台账号名字相同),互联网前端开发工程师,工作5年,去过上海和北京,经历创业公司,加入过阿里本地生活团队,现在郑州北游教育从事编程培训。
一、前言
TypeScript不仅能帮助前端改变思维方式,还能强化面向接口编程的思维和能力,而这正是得益于Interface接口类型。通过接口类型,我们可以清晰地定义模块内、跨模块、跨项目代码的通信规则。TypeScript对对象的类型检测遵循一种被称之为“鸭子类型”(ducktyping)或者“结构化类型(structuralsubtyping)”的准则,即只要两个对象的结构一致,属性和方法的类型一致,则它们的类型就是一致的。
二、Interface接口类型
```
functionStudy(language:{name:string;age:()=number}){
console.log(`ProgramLanguage${language.name}created${language.age()}yearsago.`);
}
Study({
name:TypeScript,
age:()=newDate().getFullYear()-
});
```
在上述代码中,我们定义了一个拥有string类型属性name、函数类型属性age的对象language作为参数(形参Parameter)的函数。同时,我们还使用类似定义JavaScript对象字面量的语法定义了一个内联接口类型来约束参数对象的类型。然后,我们传递了一个name属性为TypeScript的字符串、age属性为计算年份差函数的对象字面量作为参数(argument)来调用这个函数。在调用函数的过程中,TypeScript静态类型检测到传递的对象字面量类型为string的name属性和类型为()=number的age属性与函数参数定义的类型一致,于是不会抛出一个类型错误。如果我们传入一个name属性是number类型或者缺少age属性的对象字面量,如下代码所示:
```
Study({
name:2,
age:()=newDate().getFullYear()-
});
Study({
name:TypeScript
});
```
这时,第2行会提示错误:ts()number不能赋值给string,第7行也会提示错误:ts()实参(Argument)与形参(Parameter)类型不兼容,缺少必需的属性age。同样,如果我们传入一个包含了形参类型定义里没有的id属性的对象字面量作为实参,也会得到一个类型错误ts(),实参(Argument)与形参(Parameter)类型不兼容,不存在的属性id,如下代码所示:
```
/**ts()实参(Argument)与形参(Parameter)类型不兼容,不存在的属性id*/
Study({
id:2,
name:TypeScript,
age:()=newDate().getFullYear()-
});
```
有意思的是,在上边的示例中,如果我们先把这个对象字面量赋值给一个变量,然后再把变量传递给函数进行调用,那么TypeScript静态类型检测就会仅仅检测形参类型中定义过的属性类型,而包容地忽略任何多余的属性,此时也不会抛出一个ts()类型错误。如下代码所示,第6行不会提示错误。
```
letts={
id:2,
name:TypeScript,
age:()=newDate().getFullYear()-
};
Study(ts);//ok
```
这并非一个疏忽或bug,而是有意为之地将对象字面量和变量进行区别对待,我们把这种情况称之为对象字面量的freshness(在12讲中会再次详细介绍)。因为这种内联形式的接口类型定义在语法层面与熟知的JavaScript解构颇为神似,所以很容易让我们产生混淆。下面我们通过如下示例对比一下解构语法与内联接口类型混用的效果。
```
/**纯JavaScript解构语法*/
functionStudyJavaScript({name,age}){
console.log(name,age);
}
/**TypeScript里解构与内联类型混用*/
functionStudyTypeScript({name,age}:{name:string,age:()=number}){
console.log(name,age);
}
/**纯JavaScript解构语法,定义别名*/
functionStudyJavaScript({name:aliasName}){//定义name的别名
console.log(aliasName);
}
/**TypeScript*/
functionStudyTypeScript(language:{name:string}){
//console.log(name);//不能直接打印name
console.log(language.name);
}
```
从上述代码中我们可以看到,在函数中,对象解构和定义接口类型的语法很类似(如第12行和17行所示),注意不要混淆。实际上,定义内联的接口类型是不可复用的,所以我们应该更多地使用interface关键字来抽离可复用的接口类型。在TypeScript中,接口的语法和其他类型的语言并没有太大区别,我们通过如下所示代码一起看看接口是如何定义的:
```
/**关键字接口名称*/
interfaceProgramLanguage{
/**语言名称*/
name:string;
/**使用年限*/
age:()=number;
}
```
在上述代码中,我们定义了一个描述编程语言的接口,它包含一个字符类型的属性name和一个函数类型的属性age。从中我们发现接口的语法格式是在interface关键字的空格后+接口名字,然后属性与属性类型的定义用花括弧包裹。在前边示例中,通过内联参数类型定义的Study函数就可以直接使用ProgramLanguage接口来定义参数language的类型了。
```
functionNewStudy(language:ProgramLanguage){
console.log(`ProgramLanguage${language.name}created${language.age()}yearsago.`);
}
```
我们还可以通过复用接口类型定义来约束其他逻辑。比如,我们通过如下所示代码定义了一个类型为ProgramLanguage的变量TypeScript。
```
letTypeScript:ProgramLanguage;
```
接着,我们把满足接口类型约定的一个对象字面量赋值给了这个变量,如下代码所示,此时也不会提示类型错误。
```
TypeScript={
name:TypeScript,
age:()=newDate().getFullYear()-
}
```
而任何不符合约定的情况,都会提示类型错误。比如我们通过如下所示代码输入了一个空对象字面量,此时也会提示一个对象字面量类型{}缺少name和age属性的ts()错误。
```
TypeScript={
}
```
按照如下所示代码添加name属性后,还是会提示一个对象字面量类型{name:string;}缺少必需的age属性的ts()错误。
```
TypeScript={
name:TypeScript
}
```
此外,如下代码所示,如果我们把一个name属性是2、age属性是WrongType的对象赋值给TypeScript,在第2行会提示错误:ts()number类型不能赋值给string,第3行会提示错误:ts()string不能赋值给函数类型。
```
TypeScript={
name:2,
age:WrongType
}
```
又或者如以下示例中额外多出了一个接口并未定义的属性id,也会提示一个ts()错误:对象字面量不能赋值给ProgramLanguage类型的变量TypeScript。
```
TypeScript={
name:TypeScript,
age:()=newDate().getFullYear()-,
id:1
}
```
三、可缺省属性
在前边的例子中,如果我们希望缺少age属性的对象字面量也能符合约定且不抛出类型错误,确切地说在接口类型中age属性可缺省,那么我们可以在属性名之后通过添加如下所示的?语法来标注可缺省的属性或方法。如以下示例中,OptionalProgramLanguage接口就拥有一个可缺省的函数类型的age属性。
```
/**关键字接口名称*/
interfaceOptionalProgramLanguage{
/**语言名称*/
name:string;
/**使用年限*/
age?:()=number;
}
letOptionalTypeScript:OptionalProgramLanguage={
name:TypeScript
};//ok
```
当属性被标注为可缺省后,它的类型就变成了显式指定的类型与undefined类型组成的联合类型(详见08讲),比如示例中OptionalTypeScript的age属性类型就变成了如下所示内容:
```
(()=number)
undefined;
```
既然如此,我们就来发散思考一下:你觉得如下所示的接口类型OptionalTypeScript2和OptionalTypeScript等价吗?
```
/**关键字接口名称*/
interfaceOptionalProgramLanguage2{
/**语言名称*/
name:string;
/**使用年限*/
age:(()=number)
undefined;
}
```
答案当然是不等价,这与05讲中提到函数可缺省参数和参数类型可以是undefined一样,可缺省意味着可以不设置属性键名,类型是undefined意味着属性键名不可缺省。既然值可能是undefined,如果我们需要对该对象的属性或方法进行操作,就可以使用类型守卫(详见11讲)或OptionalChain(在第5行的属性名后加?),如下代码所示:
```
if(typeofOptionalTypeScript.age===function){
OptionalTypeScript.age();
}
OptionalTypeScript.age?.();
```
通过第1行所示的typeof条件判断,在确保了age属性是函数的情况下我们才会调用,这样就避免了运行时提示age不是函数的错误。
四、只读属性
我们可能还会碰到这样的场景,希望对对象的某个属性或方法锁定写操作,比如前面例子中,定义了TypeScriptLanguage变量之后,name属性的值肯定是稳定不可变更的TypeScript,而不能再被变更为JavaScript或AnyScript。这时,我们可以在属性名前通过添加readonly修饰符的语法来标注name为只读属性。
```
interfaceReadOnlyProgramLanguage{
/**语言名称*/
readonlyname:string;
/**使用年限*/
readonlyage:(()=number)
undefined;
}
letReadOnlyTypeScript:ReadOnlyProgramLanguage={
name:TypeScript,
age:undefined
}
/**ts()错误,name只读*/
ReadOnlyTypeScript.name=JavaScript;
```
需要注意的是,这仅仅是静态类型检测层面的只读,实际上并不能阻止对对象的篡改。因为在转译为JavaScript之后,readonly修饰符会被抹除。因此,任何时候与其直接修改一个对象,不如返回一个新的对象,这会是一种比较安全的实践。
五、定义函数类型
在以上示例中,你可能会觉得接口类型仅能用来定义对象的类型,但是如05讲中提到接口类型还可以用来定义函数的类型(备注:仅仅是定义函数的类型,而不包含函数的实现),具体示例如下。
```
interfaceStudyLanguage{
(language:ProgramLanguage):void
}
/**单独的函数实践*/
letStudyInterface:StudyLanguage
=language=console.log(`${language.name}${language.age()}`);
```
在示例第1~3行,我们定义了一个接口类型StudyLanguage,它有一个函数类型的匿名成员,函数参数类型ProgramLanguage,返回值的类型是void,通过这样的格式定义的接口类型又被称之为可执行类型,也就是一个函数类型。在第6行中,我们声明了一个StudyLanguage类型的变量,并赋给它一个箭头函数作为值。回想一下04讲中提到的上下文类型推断,赋值操作左侧的StudyLanguage类型是可以约束箭头函数的类型,所以即便我们没有显式指定函数参数language的类型,TypeScript也能推断出它的类型就是ProgramLanguage。实际上,我们很少使用接口类型来定义函数的类型,更多使用内联类型或类型别名(本讲后半部分讲解)配合箭头函数语法来定义函数类型,具体示例如下:
```
typeStudyLanguageType=(language:ProgramLanguage)=void
```
我们给箭头函数类型指定了一个别名StudyLanguageType,在其他地方就可以直接复用StudyLanguageType,而不用重新声明新的箭头函数类型定义。
六、索引签名
在实际工作中,使用接口类型较多的地方是对象,比如React组件的PropsState、HTMLElement的Props,这些对象有一个共性,即所有的属性名、方法名都确定。实际上,我们经常会把对象当Map映射使用,比如下边代码示例中定义了索引是任意数字的对象LanguageRankMap和索引是任意字符串的对象LanguageMap。
```
letLanguageRankMap={
1:TypeScript,
2:JavaScript,
...
};
letLanguageMap={
TypeScript:,
JavaScript:,
...
};
```
这个时候,我们需要使用索引签名来定义上边提到的对象映射结构,并通过“[索引名:类型]”的格式约束索引的类型。索引名称的类型分为string和number两种,通过如下定义的LanguageRankInterface和LanguageYearInterface两个接口,我们可以用来描述索引是任意数字或任意字符串的对象。
```
interfaceLanguageRankInterface{
[rank:number]:string;
}
interfaceLanguageYearInterface{
[name:string]:number;
}
{
letLanguageRankMap:LanguageRankInterface={
1:TypeScript,//ok
2:JavaScript,//ok
WrongINdex://ts()不存在的属性名
};
letLanguageMap:LanguageYearInterface={
TypeScript:,//ok
JavaScript:,//ok
1://ok
};
}
```
注意:在上述示例中,数字作为对象索引时,它的类型既可以与数字兼容,也可以与字符串兼容,这与JavaScript的行为一致。因此,使用0或0索引对象时,这两者等价。同样,我们可以使用readonly注解索引签名,此时将对应属性设置为只读就行,如下代码所示:
```
{
interfaceLanguageRankInterface{
readonly[rank:number]:string;
}
interfaceLanguageYearInterface{
readonly[name:string]:number;
}
}
```
在上述示例中,LanguageRankInterface和LanguageYearInterface任意的数字或者字符串类型的属性都是只读的。注意:虽然属性可以与索引签名进行混用,但是属性的类型必须是对应的数字索引或字符串索引的类型的子集,否则会出现错误提示。下面我们通过如下所示的示例具体来看一下。
```
{
interfaceStringMap{
[prop:string]:number;
age:number;//ok
name:string;//ts()name属性的string类型不能赋值给字符串索引类型number
}
interfaceNumberMap{
[rank:number]:string;
1:string;//ok
0:number;//ts()0属性的number类型不能赋值给数字索引类型string
}
interfaceLanguageRankInterface{
name:string;//ok
0:number;//ok
[rank:number]:string;
[name:string]:number;
}
}
```
在上述示例中,因为接口StringMap属性name的类型string不是它所对应的字符串索引(第3行定义的prop:string)类型number的子集,所以会提示一个错误。同理,因为接口NumberMap属性0的类型number不是它所对应的数字索引(第8行定义的rank:number)类型string的子集,所以也会提示一个错误。另外,由于上边提到了数字类型索引的特殊性,所以我们不能约束数字索引属性与字符串索引属性拥有截然不同的类型,具体示例如下:
```
{
interfaceLanguageRankInterface{
[rank:number]:string;//ts()数字索引类型string类型不能赋值给字符串索引类型number
[prop:string]:number;
}
}
```
这里我们定义了LanguageRankInterface的数字索引rank的类型是string,与定义的字符串索引prop的类型number不兼容,所以会提示一个ts()错误。这里埋个伏笔:如果我们确实需要使用age是number类型、其他属性类型是string的对象数据结构,应该如何定义它的类型且不提示错误呢?比如如下示例中定义的age属性是数字、其他任意属性是字符串的对象,我们应该怎么定义它的类型呢?
```
{
age:1,//数字类型
anyProperty:str,//字符串
...
}
```
由于属性和索引签名的类型限制,使得我们不能通过单一的接口来描述这个对象,这时我们该怎么办呢?请继续保持你的好奇心,08讲中我们会解决这个问题。
七、继承与实现
在TypeScript中,接口类型可以继承和被继承,比如我们可以使用如下所示的extends关键字实现接口的继承。
```
{
interfaceDynamicLanguageextendsProgramLanguage{
rank:number;//定义新属性
}
interfaceTypeSafeLanguageextendsProgramLanguage{
typeChecker:string;//定义新的属性
}
/**继承多个*/
interfaceTypeScriptLanguageextendsDynamicLanguage,TypeSafeLanguage{
name:TypeScript;//用原属性类型的兼容的类型(比如子集)重新定义属性
}
}
```
在上述示例中,从第2~8行我们定义了两个继承了ProgramLanguage的接口DynamicLanguage和TypeSafeLanguage,它们会继承ProgramLanguage所有的属性定义。第11行我们定义了同时继承了DynamicLanguage和TypeSafeLanguage的接口TypeScriptLanguage,它会继承DynamicLanguage和TypeSafeLanguage所有的属性定义,并且使用同名的name属性定义覆盖了继承过来的name属性定义。注意:我们仅能使用兼容的类型覆盖继承的属性,如下代码所示。
```
{
/**ts()错误的继承,name属性不兼容*/
interfaceWrongTypeLanguageextendsProgramLanguage{
name:number;
}
}
```
在上述代码中,因为ProgramLanguage的name属性是string类型,WrongTypeLanguage的name属性是number,二者不兼容,所以不能继承,也会提示一个ts()错误。如06讲中提到,我们既可以使用接口类型来约束类,反过来也可以使用类实现接口,那两者之间的关系到底是什么呢?这里,我们通过使用如下所示的implements关键字描述一下类和接口之间的关系。
```
/**类实现接口*/
{
classLanguageClassimplementsProgramLanguage{
name:string=;
age=()=newDate().getFullYear()-
}
}
```
在上述代码中,类LanguageClass实现了ProgramLanguage接口约定的name、age等属性和方法,如果我们移除name或者age的实现,将会提示一个类型错误。
八、Type类型别名
接口类型的一个作用是将内联类型抽离出来,从而实现类型可复用。其实,我们也可以使用类型别名接收抽离出来的内联类型实现复用。此时,我们可以通过如下所示“type别名名字=类型定义”的格式来定义类型别名。
```
/**类型别名*/
{
typeLanguageType={
/**以下是接口属性*/
/**语言名称*/
name:string;
/**使用年限*/
age:()=number;
}
}
```
在上述代码中,乍看上去有点像是在定义变量,只不过这里我们把let、const、var关键字换成了type罢了。此外,针对接口类型无法覆盖的场景,比如组合类型、交叉类型(详见08讲),我们只能使用类型别名来接收,如下代码所示:
```
{
/**联合*/
typeMixedType=string
number;
/**交叉*/
typeIntersectionType={id:number;name:string;}
{age:number;name:string};
/**提取接口属性类型*/
typeAgeType=ProgramLanguage[age];
}
```
在上述代码中,我们定义了一个IntersectionType类型别名,表示两个匿名接口类型交叉出的类型;同时定义了一个AgeType类型别名,表示抽取的ProgramLanguageage属性的类型。注意:类型别名,诚如其名,即我们仅仅是给类型取了一个新的名字,并不是创建了一个新的类型。
九、Interface与Type的区别
通过以上介绍,我们已经知道适用接口类型标注的地方大都可以使用类型别名进行替代,这是否意味着在相应的场景中这两者等价呢?实际上,在大多数的情况下使用接口类型和类型别名的效果等价,但是在某些特定的场景下这两者还是存在很大区别。比如,重复定义的接口类型,它的属性会叠加,这个特性使得我们可以极其方便地对全局变量、第三方库的类型做扩展,如下代码所示:
```
{
interfaceLanguage{
id:number;
}
interfaceLanguage{
name:string;
}
letlang:Language={
id:1,//ok
name:name//ok
}
}
```
在上述代码中,先后定义的两个Language接口属性被叠加在了一起,此时我们可以赋值给lang变量一个同时包含id和name属性的对象。不过,如果我们重复定义类型别名,如下代码所示,则会提示一个ts()错误。
```
{
/**ts()重复的标志*/
typeLanguage={
id:number;
}
/**ts()重复的标志*/
typeLanguage={
name:string;
}
letlang:Language={
id:1,
name:name
}
}
```
在上述代码中,我们重复定义了一个类型别名Language,此时就提示了一个错误。
十、总结
接口类型是TypeScript最核心的知识点之一,掌握好接口类型,养成面向接口编程思维方式和惯性,将让我们的编程之路愈发顺利、高效。类型别名使得类型可以像值一样能赋予另外一个变量(别名),大大提升了类型复用性,最终也提升了我们的编程效率。