参考资料
TS 官网 Object Types:链接
6-1. 接口的概念
前言
任务列表
- 理解 TS 中接口的概念
理解笔记中所说的「弱标准」、「强标准」是啥意思。
notes
扩展类型:类型别名(type)、枚举(enum)、接口(interface)、类(class)
我们之前学习过 type、enum,本章节将要介绍的是 interface 接口。
首先,来了解一下接口的概念。
接口:interface,它是一种拓展类型 ,是用于约束类、对象、函数的契约(标准)。
契约(标准)的常见形式:
- API 文档(弱标准)
- 代码约束(强标准)
API 文档(弱标准):
平时我们接触到的 API 文档,其实就是一种契约、一种标准。前后端开发者都按照接口文档中的规定进行开发,遵循其中的规定。
但是这种契约仅仅是一种弱标准,没有强制的约束,开发者写出来的程序是否符合接口文档中的规定是不一定的。
:::tips 这里所说的「不一定」,主要是指,如果我们没有按照 API 文档的要求来写,代码并不会报错。强制要求我们必须按照标准来。
所以说这种契约(标准)是一种弱标准。 :::
代码约束(强标准):
代码约束其实就是使用 TS 提供的类型系统来约束。代码约束是强标准,我们写的代码都必须遵循这些标准,否则程序没法跑。
6-2. 接口的使用
前言
任务列表
- 掌握接口的使用
- 掌握接口的继承
- 掌握类型别名中的交叉类型的语法
- 掌握约束一个函数类型的多种写法
- 理解定界符
{}
PS:定界符这种叫法,上网搜了好久,依旧没有搜到。直接看笔记叭,袁老对定界符的介绍还是很容易理解的。
版本不同导致的差异:
对于类型别名使用交叉类型出现冲突的现象,袁进老师视频中展示的效果是两个类型合并,实际结果的是 never
类型。
type A = {
T1: string;
};
type B = {
T1: number;
T2: number;
} & A;
const b: B = {
T1: "123",
T2: 123,
}
以该 demo 为例,对于 b.T1 的数据类型:
- 袁老版本:
string & number
- 我的版本:
never
notes
接口是用来约束类、对象、函数的契约。
interface
定义接口使用 interface
关键字
下面先介绍如何使用接口来约束「对象」和「函数」,暂不介绍「类」。
:::tips 有关「TS 的类」的相关知识,在后续课程中「7. TS中的类」会介绍到,当讲解类的相关知识点时,会介绍如何使用接口来约束类。 :::
约束「对象」
interface User {
name: string;
age: number;
sayHello: () => void; // 等效写法 sayHello(): void
}
let u: User = {
name: "dahuyou",
age: 23,
sayHello() {
console.log("hello world");
},
};
type User = {
name: string;
age: number;
sayHello: () => void;
};
let u: User = {
name: "dahuyou",
age: 23,
sayHello() {
console.log("hello world");
},
};
let u = {
name: "dahuyou",
age: 23,
sayHello() {
console.log("hello world");
},
};
sayHello: () => viod
、sayHello(): void
这两种写法都是等效的,都是约束 sayHello 函数:
- 没有参数
- 没有返回值
约束「函数」
需求介绍:求和函数,针对满足特定条件的成员进行求和。
:::tips 这里说的满足特定条件,比如说:
- 仅对数组的奇数项进行求和
- 仅对数组的偶数项进行求和
- 仅对数组的正数进行求和
- 等等。。。
特定条件具体是啥,由用户指定。这种场景其实在我们写代码的时候非常常见,比如数组的 filter 方法,对数组进行过滤,过滤的条件是由我们自行指定的。
这时候,就得用到回调函数。 :::
需求实现:
- 定义一个 sum 函数
- sum 函数的第一个参数是一个数字数组
- sum 函数的第二个参数是一个回调函数,将回调返回为 true 的所有成员进行求和返回
function sum(numbers: number[], callBack: (n: number) => boolean) {
let s = 0;
numbers.forEach((n) => {
if (callBack(n)) {
s += n;
}
});
return s;
}
(n: number) => boolean
这一部分其实可以单独提取一个类型别名或接口出来
// 写法1
interface Condition {
(n: number): boolean
}
// 写法2
// type Condition = (n: number) => boolean
// 写法3
// type Condition = {
// (n: number): boolean
// }
function sum(numbers: number[], callBack: Condition) {
let s = 0;
numbers.forEach((n) => {
if (callBack(n)) {
s += n;
}
});
console.log(s);
return s;
}
sum([1, 2, -3, 4], n => n > 0); // => 7
sum([1, 2, -3, 4], function(n) {
return n > 0 && n % 2 === 0;
}); // => 6
定界符 {}
interface Condition { // 定界符
(n: number): boolean
}
type Condition = { // 定界符
(n: number): boolean
}
定界符的概念:
在定义一个接口、类型别名时,使用到了 {}
。如果里面没有一个属性名,放的是一个具体的约束内容,那么 {}
表示定界符,并不表示当前约束的是一个对象。
:::tips Q:定界符是啥?
就是以上定义「函数接口」语法中的 {}
符号。
上网搜过,没找到答案,袁老在视频中也没详细介绍,估计这东西不重要。 :::
继承和交叉类型
- 接口,使用
extends
使用 extends 关键字,可以实现接口的继承,通过接口之间的继承,实现多种接口的组合
- 类型别名,使用
&
使用类型别名可以实现类似的组合效果,需要通过&
,它叫做交叉类型
interface A {
T1: string;
}
interface B extends A {
T2: number;
}
interface C extends A, B {
T3: boolean;
}
let b: B = {
T1: "123",
T2: 123,
};
let c: C = {
T1: "1",
T2: 1,
T3: false,
};
type A = {
T1: string;
};
type B = {
T2: number;
} & A;
type C = {
T3: boolean;
} & A &
B;
let b: B = {
T1: "123",
T2: 123,
};
let c: C = {
T1: "1",
T2: 1,
T3: false,
};
区别 - 对冲突项的处理:
- 子接口不能覆盖父接口的成员
- 交叉类型会把相同成员的类型进行交叉
interface A {
T1: string;
}
interface B extends A {
T1: number,
T2: number;
}
从提示信息告诉我们:属性 T1 的类型不能共存(incompatible),类型 number
不能分配(assignable)给类型 string
。
这种报错是合情合理的,也是我们希望看到的,可以让我们尽早地发现问题,修改错误。
type A = {
T1: string;
};
type B = {
T1: number;
T2: number;
} & A;
const b: B = {
T1: "123", // 123
T2: 123,
}
要求 T1 既是一个 string
类型,又是一个 number
类型。
没有值能够赋值给 T1,它将被识别为 never
类型。
要求一个成员,即是 number
又是 string
,该成员同时拥有 number
和 string
原型上的成员。没有任何一个值可以满足要求,即是 number
又是 string
,所以 T1 的结果是 never 类型。
报错
虽然也能提供报错信息,但是提供报错的时间点不同。类型别名 B 在定义的时候,并没有报错。当我们在使用类型别名 B 来约束变量的类型时,才会意识到出现问题了。
Q & A
🤔 接口和类型别名之间的区别? 就目前阶段来看,接口和类型别名非常类似,除了写法上的一些细微差异外,似乎没啥区别。
type 和 interface 之间的区别主要体现在约束「类」。
就编译结果而言,接口和类型别名都不会出现在编译结果中。
这个问题在本节所介绍的内容中,会简单提及一些,若要了解更多,还得学习完下一章「TS 的类」之后才清楚。
PS:进阶阶段,才会介绍「接口」和「类」如何连用,才会知道「接口」和「类型别名」之间真正的区别,现阶段就简单地认为它俩是一致的即可。
🤔 如果某些场景,即可以使用接口,也可以使用类型别名,那么我们应该使用哪个? 袁进老师推荐使用「接口」
虽然有些教程说,根据习惯来,喜欢哪个用哪个。
不过蛮多主流的第三方库,它们采用的大多都是 interface。这个就看个人习惯叭,个人感觉既然都行,正好现在还没养成习惯,那就按袁老的建议来。
6-3. readonly 修饰符
前言
任务列表
- 掌握
readonly
修饰符的用法
notes
本节介绍 readonly
修饰符,它是本教程中介绍的第一个修饰符,在之后介绍「类」时,还会介绍其它修饰符。
readonly
是一个修饰符,表示只读,被 readonly
修饰符修饰的目标是无法修改的。readonly
修饰符不会出现在编译结果中。
重点关注在使用它修饰对象的属性时的作用位置。这里先抛出一个问题:
interface A {
T1: readonly number[]
}
interface A {
readonly T1: readonly number[]
}
codes
修饰属性
interface User {
id: string
readonly name: string
age: number
}
let u: User = {
id: "123",
name: "dahuyou",
age: 23
}
u.id = "abc"; // 允许修改
u.name = "xiaohuyou"; // 报错
错误提示:我们无法给只读的属性 name 重新赋值。
修饰变量
let arr: readonly number[] = [1, 2, 3];
arr = [4, 5, 6]; // √
arr[0] = 10; // ×
arr.push(10); // ×
被允许的操作:arr = [4, 5, 6]
若我们重新给数组赋值,这是允许的。
:::tips 决定是否能给变量 arr 重新赋值的是:是否使用了 const 关键字来声明变量。
这压根就不需要借助 ts 来帮我们搞定,使用 js 就完全够了。 :::
不被允许的操作:arr[0] = 4
、arr.push(10)
若我们想要修改数组中某个成员的值,或者改变数组内部的成员,这是不被允许的。
对于被 readonly 修饰符修饰的数组,它们身上的那些但凡是可以改变原数组的 api 均没法访问。比如:pop
、push
、splice
等等。
小结:
这里所说的只读数组,是指数组的内部不可变,并不意味着不能重新给变量 arr 赋值。
等效写法:
let arr: readonly number[]
let arr: ReadonlyArray<number>
对比 readonly 修饰符作用于不同的位置
interface A {
T1: readonly number[]
}
const a: A = {
T1: [1, 2, 3]
}
a.T1 = ['a', 'b', 'c']; // √
a.T1[0] = 'a'; // ×
interface A {
readonly T1: readonly number[]
}
const a: A = {
T1: [1, 2, 3]
}
a.T1 = ['a', 'b', 'c']; // ×
a.T1[0] = 'a'; // ×
6-4. 类型兼容性
前言
任务列表
- 理解类型兼容性的概念
- 鸭子辨型法
- 理解基本类型的类型兼容性判断逻辑 - 严格按照类型约束来
- 理解对象类型的两种类型兼容性判断逻辑
- 变量赋值 - 鸭子辨型法
- 字面量赋值 - 严格按照约束来
- 理解函数类型的类型兼容性判断逻辑
- 参数:可少不可多
- 返回值:有要求则严格按照要求来,若没要求(也就是 void 类型)则随意
- 类型断言
xxx as xxx
<xxx>xxx
notes
类型兼容性
类型兼容性,就是指 TS 中是如何判断不同类型之间的数据是否能够完成赋值。
- 若类型检查能够通过,那么意味着可以赋值
- 若类型检查无法通过,那么意味着不能赋值
将变量 B 赋值给变量 A,如果能够完成赋值,则称 B 和 A 类型兼容。
鸭子辨型法
鸭子辨型法,也称为子结构辨型法。
目标类型需要具备某一些特征,赋值的类型只要能满足(包含)这些特征即可。
基本类型
要求完全匹配
对象类型
赋的值来自于一个:
- 变量:鸭子辨型法
- 字面量:必须要严格满足类型要求
函数类型
- 参数:传递给目标函数的参数可以少,但不可以多
- 返回值:
- 若有要求返回值,那么必须按照约束条件返回指定类型的数据
- 若没有要求返回值(也就是
void
),那么我们可以随意处理
类型断言
xxx as xxx
- 前:值
- 后:类型
类型断言的其它写法:<xxx>xxx
- 前:类型
- 后:值
codes
对象类型
变量赋值
interface Duck {
sound: "嘎嘎嘎" // "嘎嘎嘎" 表示字面量类型
swin(): void
}
let person = {
name: "伪装成鸭子的人",
age: 11,
sound: "嘎嘎嘎" as "嘎嘎嘎", // 第一个 "嘎嘎嘎" 表示值,第二个 "嘎嘎嘎" 表示字面量类型
// sound: <"嘎嘎嘎">"嘎嘎嘎"
swin() {
console.log(`${this.name}正在游泳,并发出了${this.sound}的声音。`);
}
}
let duck: Duck = person; // √
let duck: Duck = person
这条语句是能够通过类型检查的,因为我们约束变量 duck
是一个 Duck
类型,并且 person
中包含 Duck
类型所需要的所有成员,类型也都满足要求。
由于我们使用接口 Duck
限制的 duck
的类型,所以此时我们仅能访问 sound
、swin
如果我们尝试访问 name
、age
,那么会提示 Duck
类型中不存在这些属性。
虽然我们在目标文件 .ts
中无法访问这些属性,但是并不意味着它们在编译结果中不存在。
在编译结果中 duck 还是含有 4 个属性的
let person = {
name: "伪装成鸭子的人",
age: 11,
sound: "嘎嘎嘎",
swin() {
console.log(`${this.name}正在游泳,并发出了${this.sound}的声音。`);
}
};
let duck = person;
TS 这么处理是非常合理的
假设现在有这么一个场景:有一个用于获取用户信息的 api,但是这个 api 请求到的用户信息中有大量字段,而我们目前正在写的需求需要调用这个 api 获取用户信息数据,我们仅需要其中的某几个字段的值即可。
很多情况下,我们所需要的数据都是调用某个 api 才获取到的,对于后端返回给我们的数据,我们只需要用到其中的一小部分即可。
我们知道的是:我们需要哪些字段;
我们不想关心的是:api 拿到的数据的详细数据结构信息;
那么接下来我们可以这么做:
- 依据我们所需要的字段来定义接口
- 调用 api 获取到相关数据 data
- 定义一个变量 a,要求变量 a 实现第一步定义的接口
- 使用 data 给 a 进行赋值
(async() => {
interface Idemo {
// ...(罗列出我们需要的字段)
}
const data = await getDatas();
const a: Idemo = data;
})();
通过上述步骤,就能帮助我们在开发阶段(书写 TS 代码)时,过滤掉那些我们不需要的字段。
字面量赋值
interface Duck {
sound: "嘎嘎嘎" // "嘎嘎嘎" 表示字面量类型
swin(): void
}
let duck: Duck = {
name: "伪装成鸭子的人",
age: 11,
sound: "嘎嘎嘎" as "嘎嘎嘎", // 第一个 "嘎嘎嘎" 表示值,第二个 "嘎嘎嘎" 表示字面量类型
swin() {
console.log(`${this.name}正在游泳,并发出了${this.sound}的声音。`);
}
}; // ×
这段程序和上边的程序看似相同,但是是不被允许的,类型检查无法通过。
如果我们在定义一个变量时,通过字面量的形式来对其进行赋值。说明我们显然知道接下来都要写哪些属性,这时候再刻意的去写接口 Duck 中不存在的属性,显然是不合规矩的。
对比:字面量赋值、变量赋值
interface User {
name?: string
age: number
}
let u: User = {
nema: "dahuyou", // ×
age: 23
}
User 接口约束:(通过**字面量**形式赋值)
- 必须要有 age 属性,属性值必须是 number 类型
- 可以有 name 属性,也可以没有 name 属性,若有 name 属性,属性值必须是 string 类型
- 不能有其它属性
通过字面量的形式来赋值,类型检查很严格,必须和接口约束完全相同才行。
interface User {
name?: string
age: number
}
let user = {
nema: "dahuyou",
age: 23
};
let u: User = user; // √
此时采用的是鸭子辨型法来判断。
这么写是不会报错的,因为 user 中含有必须的数值属性 age,可选属性 name 是可有可无的。
User 接口约束:(通过**变量**形式赋值)
- 必须要有 age 属性,属性值必须是 number 类型
- 可以有 name 属性,也可以没有 name 属性,若有 name 属性,属性值必须是 string 类型
不能有其它属性
函数类型
interface Condition {
(n: number, i: number): boolean;
}
function sum(numbers: number[], callBack: Condition) {
let s = 0;
numbers.forEach((n, i) => {
if (callBack(n, i)) s += n;
});
return s;
}
console.log(sum([1, 2, 3, 4], (n) => n % 2 === 0));
- 参数可少不可多;
- 如果有要求返回值类型,那么返回值类型必须满足要求;
如果在约束时,要求的返回值类型是 void
类型,那么返回值可随意
6-5. 练习:用接口改造扑克牌程序
基于上一章最后一节的练习继续优化
任务列表
- 独立完成本节练习
import {createDeck, printDeck} from "./funcs"
const deck = createDeck();
printDeck(deck);
// 黑桃(Spade)、红桃(Heart)、方块(Diamond)、梅花(Club)
export enum Colors { // 花色
Heart = "❤",
Spade = "♠",
Club = "♣",
Diamond = "◇",
}
// one,two,three,four,five,six,seven,eight,nine,ten,eleven,twelve,thirteen
// 牌编号
export enum Nums {
one = "A",
two = "2",
three = "3",
four = "4",
five = "5",
six = "6",
seven = "7",
eight = "8",
nine = "9",
ten = "10",
eleven = "J",
twelve = "Q",
thirteen = "K",
}
import { Colors, Nums } from "./enums";
export type Poker = {
// 一张牌
number: Nums | "JOKER" | "joker";
color?: Colors;
};
export type Deck = Poker[]; // 一副牌
import { Colors, Nums } from "./enums";
export interface Poker { // 一张牌
getString: () => void
}
export interface JokerPoker extends Poker { // 一张大、小王
type: "big" | "small"
}
export interface NormalPoker extends Poker { // 一张普通的牌
number: Nums;
color: Colors;
};
export type Deck = Poker[]; // 一副牌
import { Colors, Nums } from "./enums";
export interface JokerPoker { // 一张大、小王
type: "big" | "small"
getString: () => void
}
export interface NormalPoker { // 一张普通的牌
number: Nums;
color: Colors;
getString: () => void
};
export type Deck = (JokerPoker | NormalPoker)[];
import { Colors, Nums } from "./enums";
import { Deck } from "./types";
// 创建一副牌
export function createDeck() {
const deck: Deck = [];
// 插入大小王
deck.push({
number: "joker",
});
// 插入大王
deck.push({
number: "JOKER",
});
// 插入 A ~ K
const colorKeys = Object.keys(Colors);
const numKeys = Object.keys(Nums);
for (let i = 0; i < colorKeys.length; i++) {
for (let j = 0; j < numKeys.length; j++) {
deck.push({
number: Nums[numKeys[j]],
color: Colors[colorKeys[i]]
});
}
}
deck.sort(() => Math.random() - 0.5); // 打乱牌序
return deck;
}
// 打印一副扑克
export function printDeck(deck: Deck) {
let result = "底牌:";
const desk = deck.splice(0, 3); // 底牌
desk.forEach((poker, i) => {
result += poker.color ? ` ${poker.color}${poker.number}` : ` ${poker.number}`;
});
result += '\n用户1:'
deck.forEach((poker, i) => {
if(i % 17 === 0 && i !== 0) result += `\n用户${i / 17 + 1}:`;
result += poker.color ? ` ${poker.color}${poker.number}` : ` ${poker.number}`;
});
console.log(result);
}
import { Colors, Nums } from "./enums";
import { Deck, JokerPoker, NormalPoker } from "./types";
// 创建一副牌
export function createDeck() {
const deck: Deck = [];
// 插入大小王
let jo: JokerPoker = {
type: "small",
getString() {
return "joker";
},
},
JO: JokerPoker = {
type: "big",
getString() {
return "JOKER";
},
};
deck.push(jo, JO);
// 插入 A ~ K
const colorKeys = Object.keys(Colors);
const numKeys = Object.keys(Nums);
for (let i = 0; i < colorKeys.length; i++) {
for (let j = 0; j < numKeys.length; j++) {
const normalPoker: NormalPoker = {
number: Nums[numKeys[j]],
color: Colors[colorKeys[i]],
getString() {
return `${this.color}${this.number}`;
},
};
deck.push(normalPoker);
}
}
deck.sort(() => Math.random() - 0.5); // 打乱牌序
return deck;
}
// 打印一副扑克
export function printDeck(deck: Deck) {
let result = "底牌:";
const desk = deck.splice(0, 3); // 底牌
desk.forEach((poker, i) => {
result += poker.getString() + " ";
});
result += "\n用户1:";
deck.forEach((poker, i) => {
if (i % 17 === 0 && i !== 0) result += `\n用户${i / 17 + 1}:`;
result += poker.getString() + " ";
});
console.log(result);
}
// 写法1
deck.push({
number: Nums[numKeys[j]],
color: Colors[colorKeys[i]],
getString() {
return `${this.color}${this.number}`;
},
} as NormalPoker);
// 写法2
deck.push(<NormalPoker>{
number: Nums[numKeys[j]],
color: Colors[colorKeys[i]],
getString() {
return `${this.color}${this.number}`;
},
});