0%

介绍

今天闲来无事想改Teams头像,但是找了半天也没有发现Mac该如何拍照,于是同事说:都是干前端的,何不用javascript来拍照呢?于是一顿操作猛如虎,终于拍到了照片。一起来看看。

javascript中如何操作摄像头

首先要确认你的电脑有摄像头,然后我们先写一个打开摄像头的函数。在javascript中,我们可以使用navigator.mediaDevices.getUserMedia来访问摄像头。拿到摄像头的stream之后,将它赋值给一个提前定义好的video元素,这样就可以在页面上显示摄像头的画面了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function openCamera(videoElement) {
if (navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia({ video: true })
.then((stream) => {
videoElement.srcObject = stream;
})
.catch((error) => {
alert(error.message);
});
} else {
alert("You don't have a web camera");
}
}

在来写一个关闭摄像头的函数。关闭摄像头其实就是停止stream中的所有轨道,并将video元素的srcObject设置为null

1
2
3
4
5
6
7
8
function closeCamera(videoElement) {
const stream = videoElement.srcObject;
const tracks = stream.getTracks();
for (let i = 0; i < tracks.length; i++) {
tracks[i].stop();
}
videoElement.srcObject = null;
}

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Live Camera</title>
<style>
#container {
width: 600px;
height: 400px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #333;
}

#videoElement {
width: 500px;
height: 300px;
background-color: #666;
}

#controlContainer {
width: 600px;
margin: 16px auto;
}
</style>
</head>
<body>
<h1>Show live stream from web camera</h1>
<div id="container">
<video autoplay id="videoElement"></video>
<img id="imageElement" style="display: none" />
</div>
<div id="controlContainer">
<button id="openCamera">Open Camera</button>
<button id="closeCamera">Close Camera</button>
</div>
<script>
function openCamera(videoElement) {
if (navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia({ video: true })
.then((stream) => {
videoElement.srcObject = stream;
})
.catch((error) => {
alert(error.message);
});
} else {
alert("You don't have a web camera");
}
}

function closeCamera(videoElement) {
const stream = videoElement.srcObject;
const tracks = stream.getTracks();
for (let i = 0; i < tracks.length; i++) {
tracks[i].stop();
}

videoElement.srcObject = null;
}

const videoElement = document.getElementById("videoElement");
const openButton = document.getElementById("openCamera");
const closeButton = document.getElementById("closeCamera");

openButton.addEventListener("click", () => {
openCamera(videoElement);
});

closeButton.addEventListener("click", () => {
closeCamera(videoElement);
});
</script>
</body>
</html>

介绍

在前端项目开发过程中,经常要生成列表数据,用于展示或者测试之用,今天介绍一个快速生成列表数据的方法。假设我们正在开发一个用户管理系统,有一个页面用于展示系统中的用户,用户的类型定义如下:

1
2
3
4
5
interface User = {
id: number;
name: string;
email: string;
}

在项目开发初期,API还未开发完成,前端只能先自己Mock数据用来展示页面。假设我们要生成50条用户数据,你会怎么做?大多数人都会想到如下方案。

1
2
3
4
5
6
7
8
const users = [];
for (let i = 0; i < 50; i++) {
users.push({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
});
}

中规中矩的方法,也是我以前常用的方法,今天在做code review时发现了另一个写法,感觉非常新颖,与大家分享。

1
2
3
4
5
const users = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
}));

想要看懂上面的代码,首先需要了解Array.from方法,这个方法可以根据一个已经存在的数组或者类数组对象创建一个新的数组,它的定义如下,我们用的是第二种形式。

1
2
3
Array.from(items)
Array.from(items, mapFn)
Array.from(items, mapFn, thisArg)

看一下参数:
items: 可以是一个数组或者类数组对象。
mapFn: 可选参数,是一个映射函数,用于对每个元素进行转换。
thisArg: 可选参数,指定mapFn函数中的this上下文。

还需要了解一下类数组对象,可以看看这篇:xxx, 类数组对象有两个特征:

  1. 具有length属性。
  2. 可以通过索引访问元素。

好了,基础知识讲解完毕,回到上面的代码。

1
2
3
4
5
const users = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
}));
  • {length: 50}实际上创建了一个类数组对象,它具有50个元素,但这些元素都是undefined
  • (_, i) => ({ ... })是一个映射函数,它接收两个参数,第一个参数是当前元素(在这里是undefined),第二个参数是当前元素的索引。

注意:javascript中有一个不成文的约定,如果一个变量没有用到但是必须占位,那么就用_作为变量名。仔细观察代码可知,我们只需要下标i,所以第一个参数用_占位。函数内部的实现就比较简单了,直接根据下标i生成对应的用户数据对象。由于使用的是箭头函数且返回的是对象,所以需要用括号包裹对象字面量。

今天的内容比较简单,但也涉及了类数组对象,Array.from方法以及箭头函数的用法。每一段优雅代码的背后,都是无数个小知识点的积累。

祝大家编程愉快,我们明天再见。

今天滨海的天气特别凉快,弹弓小队三人刚刚结束练弓,回家就写了这篇,一会儿还要洗衣服,准备迎接明天的训练。

介绍

今天接到一个需求,需要自定义一个Input输入框,我们的项目使用React + Ant Design进行开发,按理说Ant Design已经提供了非常丰富的组件样式,但有时候还是无法满足特定的需求,比如我们的输入框要求渲染成下面的样子

customize-input

而Ant Design的Input组件默认的渲染结果是这样的。
antd-input-normal

仔细观察这两个输入框,他们的区别如下:

  1. 输入框的样式不同,自定义的输入框要求用下划线。
  2. 必选标志(红色星号)位置不同,自定义的输入框必选标志在label的右侧。

下面我们依次来实现这两个需求:

Input组件使用下划线样式

Ant Design的Input提供了variant属性来控制输入框的样式,我们可以将其设置为underline来实现下划线样式。

1
2
3
4
5
<Input
variant="underlined" // 设置下划线样式
style={{ width: '200px' }}
placeholder="请输入内容"
/>

调整必选标志位置

Ant Design的Input组件并未提供相关设置,但是Form组件提供了requiredMark属性来控制必选标志的样式,我们可以通过这个属性来自行渲染必选标志,这个方式非常灵活,可以渲染成任何你想要的样子。

首先定义一个函数用来渲染自定义的必选标志,这个函数接受两个参数,一个是标签,另一个是boolean变量,用来指示当前控件是否为必选项。

函数逻辑也十分简单,首先渲染label。然后当requiredtrue时,渲染一个红色的星号,否则不渲染任何内容。

1
2
3
4
5
6
const customizeRequiredMark = (label: React.ReactNode, { required }: { required: boolean }) => (
<>
{label}
{required ? <span style={{color: 'red'}}>*</span> : null}
</>
);

接下来,将Input组件用Form包裹起来,并应用上面的自定义函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
// 使用requiredMark属性来应用自定义的必选标志
<Form requiredMark={customizeRequiredMark}>
<Form.Item
label='name' name='name'
rules={[{ required: true }]}>
<Input
variant="underlined"
style={{ width: '200px' }}
placeholder="请输入内容"
/>
</Form.Item>
</Form>

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Form, Input } from 'antd';
import React from 'react';

const customizeRequiredMark = (label: React.ReactNode, { required }: { required: boolean }) => (
<>
{label}
{required ? <span style={{color: 'red'}}>*</span> : null}
</>
);

export default function CustomizeInput() {
return (
<Form requiredMark={customizeRequiredMark}>
<Form.Item
label='name' name='name'
rules={[{ required: true }]}>
<Input
variant="underlined"
style={{ width: '200px' }}
placeholder="请输入内容"
/>
</Form.Item>
</Form>
);
}

今天就到这里了,我们明天见,码字不易,如果觉得有用就关注一下,您的关注,是我持续输出的动力!

介绍

typescript类型体操是近两年非常流行的编程实践,所谓类型体操,是指通过类型系统来实现一些复杂的逻辑操作。

TypeScript中的逻辑操作

分支操作

typescript使用extendinfer来实现分支操作。通过条件类型,可以根据类型的不同进行不同的处理。

循环遍历操作

typescript使用K in T来实现循环遍历操作。通过映射类型,可以对类型进行遍历和转换。

获取类型的属性

typescript使用keyof来获取类型的属性。通过keyof操作符,可以获取一个类型的所有属性名,并将其转换为联合类型。

获取类型的值

typescript使用T[keyof T]来获取类型的值。通过索引访问类型,可以获取一个类型的所有属性值,并将其转换为联合类型。

介绍

在昨天的文章中,我们介绍了如何使用typescript中的枚举类型Enum来处理状态值。今天,我们将探讨一个更好的实践:使用const代替枚举。为什么说const是更好的方案呢,因为typeScript中的枚举类型有一些弊端。

枚举类型的弊端

枚举类型无法实现真正的类型安全

对于数字类型的枚举,无法实现真正的类型安全,比如下面的枚举类型定义:

1
2
3
4
5
6
7
enum JobStatus {
PENDING = 1,
RUNNING = 2
SUCCESS = 3,
FAILED = 4,
CANCELED = 5,
}

这个类型定义了五种状态,分别用数字1到5来表示。但是这里有一个隐藏的问题,你可以将任意数字赋值给JobStatus类型的变量,而不管这个数字是否在1到5之间。例如:

1
2
let status: JobStatus = 1; // 这是合法的
status = 6; // 这也是合法的,虽然6不是枚举定义的

以上代码可以成功编译,没有任何问题,这是一个很大的隐患,如果我们手滑写错了一个数字,那就会导致程序出现bug。

一个更好的做法是使用字符串类型代替数字类型:

1
2
3
4
5
6
7
const enum JobStatus {
PENDING = 'PENDING',
RUNNING = 'RUNNING',
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
CANCELED = 'CANCELED',
}

这样就可以将状态值限制为枚举类型定义的字符串,任何其他字符串都会引起编译错误。

1
2
const status: JobStatus = JobStatus.PENDING; // 这是合法的
const status: JobStatus = 'INVALID'; // 这将导致编译错误

枚举类型不支持Tree-Shaking

Tree-Shaking是指在打包时去除未使用的代码。TypeScript的枚举类型在编译后会生成一个对象,这个对象包含了所有枚举成员的映射关系。即使程序中没有使用这个枚举类型,编译后的代码中仍然会保留这个对象。这会导致打包后的代码体积增大。

使用const代替枚举

上面提到的两点问题,使用const都可以很好地解决。下面使用const来实现同样的功能,首先定义Job状态常量,因为要兼顾后端接口的整数类型和前端显示的字符串类型,我们索性将他们封装到一起。用code表示后端返回的状态值,用label表示前端要显示的状态名称。

1
2
3
4
5
6
7
8
9
const JobStatus = {
PENDING: {code: 1, label: 'PENDING'},
RUNNING: {code: 2, label: 'RUNNING'},
SUCCESS: {code: 3, label: 'SUCCESS'},
FAILED: {code: 4, label: 'FAILED'},
CANCELED: {code: 5, label: 'CANCELED'},
} as const;

type JobStatusType = typeof JobStatus[keyof typeof JobStatus];

接下来定义Job接口,使用JobStatusType来描述状态类型。

1
2
3
4
5
interface Job {
id: string;
name: string;
status: JobStatusType;
}

后端返回的数据和先前一样用数字类型来表示Job状态。

1
2
3
4
5
6
7
const jobs = [
{ id: '1', name: 'Job 1', status: 1 },
{ id: '2', name: 'Job 2', status: 2 },
{ id: '3', name: 'Job 3', status: 3 },
{ id: '4', name: 'Job 4', status: 4 },
{ id: '5', name: 'Job 5', status: 5 },
];

最后是打印Job的函数,这里涉及到一个问题,我们不能像之前一样直接使用字符串来访问状态的显示名称,因为现在状态是一个对象,我们需要通过状态的code来获取对应的label

1
2
3
4
function printJobStatus(job: Job) {
const status = Object.values(JobStatus).find(s => s.code === job.status);
console.log(`Job ${job.id} (${job.name}) is currently ${status?.label ?? 'UNKNOWN'}.`);
}

现在我们可以调用这个函数来打印Job的状态:

1
jobs.forEach(printJobStatus);

之前的映射函数getJobDisplayName也可以省略掉了,因为我们在定义JobStatus时已经将状态码(code)和显示名称(label)封装在一起了。

1
2
3
4
5
6
7
8
// 该函数可以省略
const getJobDisplayName: Record<JobStatus, string> = {
[JobStatus.PENDING]: 'Pending',
[JobStatus.RUNNING]: 'In progress',
[JobStatus.SUCCESS]: 'Success',
[JobStatus.FAILED]: 'Failed',
[JobStatus.CANCELED]: 'Canceled',
};

弊端是多了一个查找的过程,但这个查找过程是非常轻量级的,因为我们只需要遍历一次JobStatus对象来找到对应的状态。

const代替枚举的好处在于:

  1. 类型安全:使用const可以确保状态值只能是预定义的状态,而不能是其他任意值。
  2. Tree-Shakingconst支持Tree-Shaking,只有实际使用才会被保留,从而减小打包后的代码体积。

好了,今天就到这里了,我们明天见。以上文章为纯古法手打,如果觉得有用,就关注一下,感谢支持。

介绍

这篇文章来谈一下 TypeScript 中的枚举类型(Enum)以及一些最佳实践。事情的起因是这样的,今天看到自己之前写的一段代码,感觉不是很好,于是想优化一下,期间用到了枚举类型,遂记录一下。为了方便理解,我将原来的例子简化一下。

业务需求

业务需求是这样的:我们要实现一个Job系统,你可以将其想象为Jenkins Job类似的东西,每个Job有一个状态,状态可以是以下几种:

  • PENDING:等待执行
  • RUNNING:正在执行
  • SUCCESS:执行成功
  • FAILED:执行失败
  • CANCELED:执行被取消

Job的状态信息由后端返回,前端只负责展示,也不需要实时刷新。很简单的需求,对吧?我的原始代码如下:

原始代码

前端数据类型定义, 首先定义一个字面量用来保存Job状态,然后定义一个Job接口来描述Job对象。

1
2
3
4
5
6
7
8
9
// 定义Job状态字面量
type JobStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELED';

// 每个Job包含id、name和status三个属性
interface Job {
id: string;
name: string;
status: JobStatus;
}

后端返回数据如下,可以看到后端是用数字类型来表示状态的。

1
2
3
4
5
6
7
const jobData = [
{ id: '1', name: 'Job 1', status: 1 }, // PENDING
{ id: '2', name: 'Job 2', status: 2 }, // RUNNING
{ id: '3', name: 'Job 3', status: 3 }, // SUCCESS
{ id: '4', name: 'Job 4', status: 4 }, // FAILED
{ id: '5', name: 'Job 5', status: 5 }, // CANCELED
];

为了将后端返回的数字类型和前端定义的Job Status对应起来,我又额外写了一个映射函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function mapJobStatus(status: number): JobStatus {
switch (status) {
case 1:
return 'PENDING';
case 2:
return 'RUNNING';
case 3:
return 'SUCCESS';
case 4:
return 'FAILED';
case 5:
return 'CANCELED';
default:
throw new Error(`Unknown status: ${status}`);
}
}

接下来就是展示了,展示Job状态时,用户不想看到全大写的状态,而是想看到首字母大写的状态,所以我又写了一个函数来处理这个问题:

1
2
3
4
5
6
7
8
9
10
11
function getJobDisplayName(status: JobStatus): string {
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
}

/* 转换后的状态字符串如下:
PENDING -> Pending
RUNNING -> Running
SUCCESS -> Success
FAILED -> Failed
CANCELED -> Canceled
*/

好了,下面我们停下来思考一下,以上这些代码都解决了哪些问题,为什么需要两个转换函数,有没有更好的解决方式?

问题分析

为了完成这个需求,上述代码做了以下几件事:

  1. 后端状态码到前端状态的转换(1,2,3,4,5 -> PENDING, RUNNING, SUCCESS, FAILED, CANCELED)
  2. 前端状态字面量到展示字符串的转换(PENDING, RUNNING, SUCCESS, FAILED, CANCELED -> Pending, Running, Success, Failed, Canceled)

对于第一点,可以使用枚举类型来实现,这样就不需要手动维护状态码和状态字面量之间的映射关系了。

对于第二点,原本的实现是将全大写的状态转换为首字母大写的形式,这种转换方式比较简单,但实际业务中,可能会有更复杂的需求,比如用户希望看到不同的展示字符串(例如将RUNNING显示为In progress)。因此,使用一个映射表来处理这种转换会更加灵活。

优化后的代码

我们可以使用 TypeScript 的枚举类型来简化代码。首先定义一个枚举来表示 Job 状态:

1
2
3
4
5
6
7
enum JobStatus {
PENDING = 1,
RUNNING = 2,
SUCCESS = 3,
FAILED = 4,
CANCELED = 5,
}

这样就可以省去第一个转换函数mapJobStatus,因为枚举本身就提供了状态码到状态字面量的映射,可以直接使用这个枚举来定义 Job 接口:

1
2
3
4
5
interface Job {
id: string;
name: string;
status: JobStatus; // 使用枚举类型
}

接下来,重写getJobDisplayName, 这里使用typescript的Record类型来创建一个映射表(Record类型相当于一个键值对的映射,只不过键和值都是类型化的),将枚举值映射到展示字符串,与原本的实现方式相比,这种方式更加简洁易维护。

1
2
3
4
5
6
7
const getJobDisplayName: Record<JobStatus, string> = {
[JobStatus.PENDING]: 'Pending',
[JobStatus.RUNNING]: 'In progress',
[JobStatus.SUCCESS]: 'Success',
[JobStatus.FAILED]: 'Failed',
[JobStatus.CANCELED]: 'Canceled',
};

最后是调用代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const jobs = [
{ id: '1', name: 'Job 1', status: 1 },
{ id: '2', name: 'Job 2', status: 2 },
{ id: '3', name: 'Job 3', status: 3 },
{ id: '4', name: 'Job 4', status: 4 },
{ id: '5', name: 'Job 5', status: 5 },
];

jobs.forEach((job) => {
console.log(
`Job ID: ${job.id}, Name: ${job.name}, Status: ${
getJobDisplayName[job.status as JobStatus]
}`
);
});

使用枚举类型的好处是:

  1. 可读性:枚举提供了更清晰的语义,
  2. 类型安全:TypeScript 的枚举类型可以确保状态值的合法性,避免了手动维护映射关系的错误。
  3. 简化代码:减少了转换函数的数量,使代码更简洁
  4. 易于维护:如果需要添加新的状态,只需在枚举中添加即可,不需要修改多个地方。

有没有更好的实现方式?很想听听大家的想法,欢迎留言讨论。

今天就到这里了,我们明天见。

介绍

昨天在网上看到一道面试题,是关于JavaScript中的+元算符的,如下:

1
2
[] + 0 = ?
{} + 0 = ?

要解决这道题,我们首先要了解JavaScript中+运算符的行为,+元算符在JavaScript中主要有三种用途:一是用于数字相加,二是用于字符串连接,三是用于类型转换。

1
2
3
1 + 2 = 3 // 数字相加
'Hello, ' + 'World!' = 'Hello, World!' // 字符串连接
+'1' = 1 // 字符串转换为数字

再回到面试题,可以看出,这并非常规的加法操作,因为运算符两侧的操作数并非都是数字类型,而是包含了数组和对象。难道是字符串连接吗?不确定,是类型转换?好像也不是。

追本溯源,我们先看看MDN上关于+的运行规则吧:

如果+元算符的操作数包含非基本类型(比如对象,数组等),先将其转换为基本类型(primitive type)。

在JavaScript中,基本类型包括undefinednullbooleannumberstringBigIntSymbol

+元算符两侧都是基本类型时,执行规则如下:

  1. 有一个操作数是字符串时,将另一个操作数也转换为字符串,并执行字符串连接;
  2. 有一个操作数是BigInt时,将另一个操作数也转换为BigInt,并执行加法;
  3. 否则,将两个操作数都转换为数字,并执行加法。

举几个列子:

1
2
3
1 + `2` = '12' // 满足规则1,将数字1转换为字符串'1',执行字符串连接
1 + 2n = 3n // 满足规则2,将数字1转换为
null + true = 1 // 满足规则3,将null转换为数字0,将true转换为数字1,执行加法

注意以上3条规则是按顺序执行的,字符串连接的优先级高于数字加法,所以字符串和数字相加时,永远会转换为字符串连接。

现在来看[] + 0该如何执行,首先[]是数组,不属于基本类型,所以先将它转换为基本类型,对象类型转换为基本类型的操作如下:

  1. 调用对象的toPrimitive方法;
  2. 如果没有toPrimitive方法,则调用valueOf方法;
  3. 如果valueOf方法返回的值不是基本类型,则调用toString方法;
  4. 如果toString方法返回的值仍不是基本类型,则抛出错误。

所以[] + 0的执行过程如下:

  1. []没有toPrimitive方法,所以调用valueOf方法。
  2. valueOf方法返回值仍然是数组对象[]
  3. 接着调用toString方法,返回空字符串''

因此,[] + 0等价于'' + 0, 此时+两侧都是基本类型了,并且满足有一侧是字符串的条件,所以将另一侧的操作数0也转换为字符串,执行字符串连接,结果为'' + '0' = '0'

再来看{} + 0, {}[]一样,都是对象类型,所以先将其转换为基本类型。

  1. {}没有toPrimitive方法,所以调用valueOf方法,返回值仍然是对象{}
  2. 接着调用toString方法,返回字符串'[object Object]'
  3. 然后将'[object Object]'0进行字符串连接,结果为'[object Object]' + '0' = '[object Object]0'

哈哈,但是我要告诉你,这个答案是错误的,这个分析是没有问题的,但是JavaScript解释器不同意,当它看到{}时,会将其解释为一个空的代码块,而不是一个空对象,因此,{} + 0实际上等于下面的代码:

1
2
{}
+ 0

{}被视为一个空代码块, 没有返回任何结果,而+ 0被解释为一条独立的语句,返回值是0,最终结果是0

如果要让代码按照我们上面分析的过程执行,那么就要防止JavaScript将{}解释为空代码块,可以用()将其包裹起来。

1
({}) + 0 // 结果为 '[object Object]0'

总结

1
2
[] + 0 = '0'
{} + 0 = 0

说实话这道题目比较偏门,但是对于了解JavaScript中+运算符的行为还是很有帮助的,通过一道题,能了解一个知识点,还是很值得的。

有的时候,不要光纠结问题的答案,更应该关注的是问题背后的原理的规则,就比如这道题,在没有写这篇文章之前,如果让我回答,我是答不上来的,我需要查阅资料,了解+运算符的行为规则,才能得出正确的答案。我觉得相比知道答案,更有意思的是分析的过程,这个过程体现了一个程序员处理问题的逻辑思维能力,小到一道面试题,大到一个复杂的系统设计,都是如此。那么如何培养这种能力呢,我也一直在寻找答案…

最后给大家留几道思考题:

1
2
3
4
[] + [] = ?
{} + [] = ?
[] + {} = ?
{} + {} = ?

今天就到这里了,我们明天见。

今天周六,准备出去逛逛,奈何天气太热,动也不想动,只能呆在家里了。锁凤十代打了两天,手感非常不错,准备留下了,之前买的猛禽就退了吧,现在赚钱不易,还是要精打细算的好。

介绍

各位老铁上午好,昨天我们学习了TypeScript中的类型断言(as操作符),今天我们来学习TypeScript中的类型谓词(Type Predicate)(is操作符)。

为什么需要类型谓词呢?我们先看一个例子,假设我们要写一个函数,判断一个变量是否为字符串类型。

1
2
3
function isString(value: unknown): boolean {
return typeof value === 'string';
}

调用这个函数

1
2
3
4
5
6
7
8
function example(foo: any) {
if (isString(foo)) {
console.log(foo.toUpperCase()); // 这里会报错,因为foo的类型仍然是any
}
}

example("hello"); // 输出 "HELLO"
example(123); // 不会输出任何内容

看起来一切正常,但是假如我们疏忽了,不小心在example中调用了一个不属于字符串的方法,比如toFixed,TypeScript会报错,因为字符串上没有toFixed方法。

1
2
3
4
5
6
7
function example(foo: any) {
if (isString(foo)) { // foo的类型是any,通过检查
console.log(foo.toFixed(2)); // 编译时没有问题
}
}

example("hello"); // 运行时报错,因为字符串上没有toFixed方法

在上面的代码中,虽然满足了isString()的判断,但是在编译期间,foo还是它本来的类型,typescript无法在编译期检查到错误。

但是,如果我们加上类型谓词,IDE就会提示字符串上没有toFixed方法。也就是说,类型谓词可以在编译时就杜绝类型错误,这就是它核心的功能。

1
2
3
function isString(value: unknown): value is string {
return typeof value === 'string';
}

上面的代码中,typescript在编译期就会将value的类型窄化为string,如果调用的方法不属于string类型,IDE会提示错误。

1
2
3
4
5
6
function example(foo: any) {
if (isString(foo)) {
// TS2551: Property 'toFixed' does not exist on type 'string'.
console.log(foo.toFixed(2)); // 编译期报错
}
}

类型谓词的语法规则

类型谓词要写在函数的返回值上,形式为:value is Type,其中value是函数的参数,Type是要判断的类型,比如一个判断number类型的函数可以这样写:

1
2
3
function isNumber(value: unknown): value is number {
return typeof value === 'number';
}

类型谓词的作用范围

需要注意的是,类型谓词只在isString函数的作用域内有效。也就是说,只有在isString函数返回true的情况下,TypeScript才会将传入的参数类型窄化为string

1
2
3
4
5
6
7
8
9
function example(foo: any) {
if (isString(foo)) {
console.log(foo.toFixed()); // 编译期报错,因为foo的类型被窄化为string
}

console.log(foo.toFixed()); // 运行时报错,因为foo的类型仍然是any
}

example("hello");

好了,今天就到这里了,我们明天见。

今天又买了一把新弹弓:锁凤十代,握感舒适,重量适中。理查德说这个弓可以留着,我再试两天再说。

什么是可选属性?

今天我们来看一下如何从一个 TypeScript 类型中提取可选属性。那么什么是可选属性呢?
可选属性是指在类型定义中使用问号(?)标记的属性,这些属性在对象中可以存在也可以不存在。

以下面的User类型为例:其中id和name是必需的属性,而age和email是可选的属性。

1
2
3
4
5
6
interface User {
id: number;
name: string;
age?: number; // 可选属性
email?: string; // 可选属性
}

对于必须属性,我们在定义变量的时候必须提供这些属性的值。

下面这个变量定义是正确的,因为id和name这两个必须属性都出现了。

1
2
3
4
const user1: User = {
id: 1,
name: 'Alice',
}

下面这个变量定义则是错误的,因为缺少了id属性。

1
2
3
4
const user2: User = {
name: 'Philip',
age: 18
};

TypeScript对于上面这个类型定义会给出如下错误:

1
TS2741: Property 'id' is missing in type '{ name: string; }' but required in type 'User'.

对于可选属性,当它未出现在变量中时,它的值就是undefined(对应的类型也是undefined),这个特性非常重要,后面我们会用到。比如对于上面的user1来说,user.age和user.email的值都是undefined

可选属性的类型

可选属性出现时,那么它的类型就是定义的类型,比如上面的age属性的类型是numberemail属性的类型是string

如果可选属性未出现,那么它的类型就是undefined,比如上面的user1变量中,user1.ageuser1.email的类型都是undefined

综合下来,我们可以得出一个结论:可选属性的类型是T | undefined,其中T是可选属性的定义类型。

User类型复制到IDE中,可以将鼠标悬停在ageemail属性上,查看它们的类型。

1
2
age 对应的类型是 number | undefined
email 对应的类型是 string | undefined

提取可选属性

可选属性介绍完毕,,现在问题来了,如何从一个类型定义中提取出所有可选属性,对应上面的User类型,我们需要提取出ageemail这两个属性。

我们可以分步骤解决这个问题,每个步骤解决一个问题

第一步:获取所有属性

要提取可选属性,我们首先需要获取类型中所有的属性。TypeScript提供了内置的keyof操作符,可以获取一个类型的所有键(属性)。

假设给定的是一个类型T,那么keyof T将返回一个联合类型,包含T的所有属性名。

1
type UserKeys = keyof User; // "id" | "name" | "age" | "email"

假设给定的不是一个类型,而是一个变量,那么首先要用typeof操作符获取变量对应的的类型。再使用keyof获取该类型所有属性。

1
2
3
4
5
6
7
const user = {
id: 1,
name: 'Alice',
age: 30,
};

type UserKeys = keyof typeof user; // "id" | "name" | "age"

可以看到虽然user变量属于User类型,但是两者返回的属性并不完全相同,因为user变量并未包含email属性。

第二步:判断一个类型是否是可选的

typescript中并没有提供内置的操作符判断一个属性是否是可选的,但是我们可以通过条件类型来实现。

前面说过当可选类型未出现时,他的值就是undefined(类型也是undefined),所以我们可以通过判断一个属性的类型是否包含undefined来判断它是否是可选的。

下面我们来定义一个类型IsOptional,它接受两个参数:一个类型T和一个属性K。它将返回一个布尔值,表示属性K是否是类型T的可选属性。

代码大概是这个样子的

1
type IsOptional<T, K> = undefined extends T[K] ? true : false;

如果K是可选的,那么T[K]的值就有可能是undefined,因此undefined extends T[K]将返回true,否则返回false

举个例子:我们将T和K对应到文章开头的类型定义中,令T = User, 假设K = age,那么就有:

  • age属性存在时:T[K] = number
  • age属性不存在时:T[K] = undefined
    所以T[K] = number | undefined,因此undefined extends T[K]将返回true

假设K = name,因为name属性是必需的,所以T[K] = string,因此undefined extends T[K]将返回false

似乎问题就要解决了,但是这里还有一个严重的问题,那就是K必须是T的属性,否则T[K]将会报错。所以我们还要限制一下K的值。

1
type IsOptional<T, K extends keyof T> = undefined extends T[K] ? true : false;

K extends keyof T的意思是K必须是T的属性之一,这样可以避免T[K]报错。

第三步:提取可选属性

现在我们已经有了获取所有属性的类型UserKeys和判断一个属性是否是可选的类型IsOptional,接下来我们只需要把这两步结合起来即可:

  1. 对于一个给定的类型T,我们首先获取其所有属性。
  2. 遍历步骤1中获取的所有属性,对于每个属性K,使用IsOptional<T, K>判断它是否是可选的。
  3. 如果是可选的,就将其包含在结果中,否则将其排除。

代码如下:

1
2
3
type OptionalKeys<T> = keyof {
[K in keyof T as IsOptional<T, K> extends true ? K : never]: any;
};

这段代码的核心部分在于[K in keyof T as IsOptional<T, K> extends true ? K : never] - 这一行代码对应了我们上面的三个步骤。

  • keyof T 获取所有属性。
  • K in keyof T遍历所有属性。
  • as IsOptional<T, K> extends true ? K : never 对于类型K,使用条件类型来判断属性K是否是可选的,如果是则保留K,否则将其排除(变为never)。

但是最后为什么还有一个any呢?因为这是一个遍历操作,我们要把结果放到一个对象中,对象的key是我们提取的可选属性,对象的值就是anyany只是用来来占位的,即使使用其他类型也是一样的,比如unknown或者void都可以。

循环遍历完成后,我们得到一个对象如下:

1
2
3
4
{
age?: any;
email?: any;
}

最后再使用使用keyof操作符获取这个对象的所有键,就得到了可选属性的联合类型:"age" | "email"

1
2
3
4
keyof {
age?: any;
email?: any;
} // "age" | "email"

因此,最终的OptionalKeys<User>将返回"age" | "email"

好了,今天就到这里了,感谢大家的支持!我们明天见。

其实这篇文章是分两次写的,昨天晚上写了一半困得不行,遂作罢,今天早上又起来继续写。日更不能停啊。