0%

介绍

在昨天的文章中,我们介绍了如何使用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不是枚举定义的

这就导致了类型安全性的问题,枚举类型并不能限制状态值只能是1到5之间的数字。

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

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"

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

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

JavaScript中如何遍历对象?

今天来点稍微轻松的话题,如何在JavaScript中遍历对象,在平常的工作中,遍历对象是很常见的操作,javascript提供了多种方法来遍历对象的属性。这些方法各有特点,不同的场景需要使用不同的方法。

假设我们有一个Person对象,包含名字和年龄两个属性,下面看看有哪些方式可以遍历这个对象。

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

for…in

遍历对象最基本的方式就是使用for...in,这里需要注意区分和for...of的区别。

  • for...in - 遍历可枚举对象,比如Object
  • for...of - 遍历可迭代对象,比如Array

在以下代码中,key是每个属性的名字 - 对应nameage,而person[key]则是每个属性的值 - 对应Philip18

1
2
3
for (const key in person) {
console.log(key, person[key]);
}

输出如下:

1
2
name Philip
age 18

for...in是遍历对象最基本的方式,需要注意的是它不仅会遍历对象自身的属性,也会遍历原型链上的属性。假设我们在Object.prototype上添加一个属性,那么这个属性也会被遍历到。

1
2
3
4
Object.prototype.customProperty = 'Hello World';
for (const key in person) {
console.log(key, person[key]);
}

输出如下:

1
2
3
name Philip
age 18
customProperty Hello World

如果你只想遍历对象自身的属性,可以使用Object.hasOwnProperty方法来过滤掉原型链上的属性。

1
2
3
4
5
for (const key in person) {
if (person.hasOwnProperty(key)) {
console.log(key, person[key]);
}
}

使用for...of + Object.keys

Object.keys方法返回一个对象中所有可枚举属性的键名并放到一个数组里,配合for...of可以很方便地遍历对象的属性。

1
2
3
for (const key of Object.keys(person)) {
console.log(key, person[key]);
}

使用for...of + Object.values

如果你只关心对象的值,而不在key的话,那么可以使用Object.values方法,它返回一个包含对象所有可枚举属性值的数组。

1
2
3
for (const value of Object.values(person)) {
console.log(value);
}

输出如下:

1
2
Philip
18

for...of + Object.entries

下面的方法使用for...of进行遍历,我们都知道for..of是用来遍历可迭代对象的,所以Object.entries返回的一定是一个可迭代对象 - 这里是一个二维数组,然后[key, value]是一个解构操作,负责解构内层一维数组中的值并输出。

1
2
3
for (const [key, value] of Object.entries(person)) {
console.log(key, value);
}

Object.entries(person)输出如下,可以看作是一个键值对组成的二维数组。

1
[[name, 'Philip'], [age, 18]]

使用[key, value]进行解构后正好得到两组数据:

1
2
key = name, value = 'Philip' // 第一组数据
key = age, value = 18 // 第二组数据

Object.entries + forEach

一个更加函数式的写法是使用数组对象上的forEach方法。

1
2
3
Object.entries(person).forEach(([key, value]) => {
console.log(key, value);
});

forEach本质上和for...of并无区别,在使用链式操作时,这种方式可读性更好,比如和filter等方法串联调用时。

1
2
3
Object.entries(person)
.filter(([key, value]) => key !== 'age') // 过滤掉 age 属性
.forEach(([key, value]) => console.log(key));

还有一点要注意,那就是forEach是无法中断的,比如不能使用continue或者break来中断循环,但是for...infor...of则不受此限制。

Reflect.ownKeys

如果你的对象中有Symbol类型的属性,那么可以使用Reflect.ownKeys方法来获取所有属性的键名。

1
2
3
4
5
6
7
8
9
const person = {
name: 'Philip',
age: 18,
[Symbol('id')]: 123,
};

Reflect.ownKeys(person).forEach(key => {
console.log(key, person[key]);
});

输出如下:

1
2
3
name Philip
age 18
Symbol(id) 123

Reflect.ownKeys是遍历Symbol类型属性的唯一方法。

最后,如果你使用的是TypeScript,那么所有使用了索引操作的遍历方式(比如person[key])都需要添加索引签名,否则TypeScript会报错,具体原因可以看这篇:

好了最后用表格总结一下:

方法 包含继承属性 包含Symbol 包含不可枚举属性 TypeScript支持
for...in 需要索引签名
Object.keys()
Object.values()
Object.entries()
Reflect.ownKeys()

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

为什么我的对象无法添加新属性?

今天在网上看到一个问题,在typescript中定义了一个对象,却无法添加新属性,代码如下:

1
2
const obj = {};
obj.prop = 'value'; // Error: TS2339: Property prop does not exist on type {}

乍一看到这个问题,我有点疑惑,为什么要这样写呢?我以往的习惯是,需要使用什么类型,都会提前定义好,然后使用就行了,这个类型包括哪些属性,都是提前设置好的。但是这段代码里面的obj是一个空对象,什么属性都没有,感觉实际应用中这种情况很少。

但是作为一个典型的例子研究一下,还是不错的。这个错误产生的原因是作者对于typescript中的索引签名(index signature)不熟悉导致的。我们来分析一下原因。

索引签名

typescript在解析obj的时候,并不知道它里面会有哪些属性,属性的key是什么类型?属性的值又是什么类型?完全不知道,所以当用户试图添加一个新属性的时候,typescript就会报错。
解决的办法也很简单,我们需要提前告知typescript这些关于属性的信息。这个信息就是索引签名

1
2
3
4
5
6
interface looseObject {
[key: string]: any; // 添加索引签名:属性名是字符串类型,属性值是任意类型
}

const obj: looseObject = {};
obj.prop = 'value'; // OK

使用Record类型

这个例子更简单的办法是使用Record类型,Record类型在定义特定类型的对象时特别有用。Record<string, any>表示一个对象类型,其属性名是字符串类型,属性值可以是任意类型。

1
2
const obj: Record<string, any> = {};
obj.prop = 'value'; // OK

索引签名的使用场景

那么什么场景下需要使用索引签名呢?一般来说,当属性的个数不固定的时候,就需要使用索引签名了。

统计学生成绩

比如要统计学生成绩,每个科目对应一个分数,不同专业的学生科目是不一样的,这时候就可以使用索引签名来定义一个对象类型。

1
2
3
4
5
6
7
8
9
10
interface StudentScores {
[subject: string]: number; // 科目名是字符串类型,分数是数字类型
}

const scores: StudentScores = {
math: 90,
english: 85,
physics: 92,
};
scores.chemistry = 88; // 可以添加新的科目

CSS-In-JS的使用

在CSS-In-JS的场景中,通常需要动态添加样式属性,这时候索引签名也非常有用。下面的代码定义了一个主题(Theme),其中包括colorspacing属性,对于color属性,我们不可能列出所有可能的颜色名称,这时候可以使用索引签名就非常有用了。spacing也是同样的道理。

1
2
3
4
5
6
7
8
type Theme = {
colors: {
[colorName: string]: string;
};
spacing: {
[size: string]: number;
};
};

今天就到这里了,祝大家编程愉快,喜欢就点个关注,我要去打弹弓了,明天见!

介绍

结构化类型是typescript类型系统的一个重要特性,如果不了解这个特性,则经常会被typescript的行为搞得一头雾水,导致我们期待的行为与实际的行为不一致。今天我们就来看两个例子。

不了解结构化类型的同学,可以先看看这篇:TypeScript结构化类型初探

第一个例子

下面的代码定义了一个Person类型

1
2
3
4
interface Person {
name: string;
age: number;
}

然后又定义了一个函数打印这个类型的对象

1
2
3
function printPerson(person: Person) {
console.log(`Name: ${person.name}, Age: ${person.age}`);
}

按道理来说,要调用这个函数,必须传递一个Person类型的对象,但是你会发现,直接传一个对象进去也行。

1
printPerson({ name: "Alice", age: 30 });

这段代码没有报错,为什么呢?因为typescript的结构化类型系统认为,只要传入的对象包含了Person类型所需的所有属性,就可以被认为是Person类型。你甚至可以多加一些属性,比如:

1
printPerson({ name: "Alice", age: 30, location: "Wonderland" });

代码一样可以正常运行!

为什么?因为在typescript中,类型是基于结构的,而不是基于名称的。只要对象的结构符合要求,就可以被认为是该类型。如果一个类型A包含了类型B的所有属性,那么类型A就可以被认为是类型B。在使用类型B的地方,就可以使用类型A代替。

第二个例子

还是以上面的Person类型为例,假设我们要打印Person对象中的所有属性,有的同学可能不假思索的写下如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Person {
name: string;
age: number;
}

const person: Person = { name: "Alice", age: 30 };

function printProperties(person: Person) {
for (const property in person) {
console.log(`${property}: ${person[property]}`);
}
}

printProperties(person);

但是这段代码却报错了:

1
TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Person'. No index signature with a parameter of type 'string' was found on type 'Person'.

当我第一次看到这个错误时,我只想撞墙,我哪里用any了,这不是胡扯吗?但这不是对待错误的正确态度,这种错误如果不彻底解决,那么它就会一直困扰你,只有将它彻底击败,下次再遇到时才能得心应手!

仔细看一下这个报错,它大概描述了两件事情:

  1. string类型的值不能用来索引Person类型。
  2. Person类型没有定义索引签名。

其实这两件事本质上说的是一个问题,那就是在TypeScript中,只有在类型中显式定义了索引签名,才能使用string类型的值来索引该类型。那么我们就给Person类型添加一个索引签名:

方式一:为Person类型添加索引签名

1
2
3
4
5
interface Person {
name: string;
age: number;
[key: string]: any; // 索引签名
}

[key: string]: any; 这行代码的意思是,Person类型可以有任意数量的属性,属性名必须是字符串类型 ([key: string]),属性值可以是任意类型(any)。

现在我们再来运行printProperties函数,就不会报错了。

方式二:使用keyof关键字

坦白的说,为了一个遍历函数给Person类型添加一个索引签名有点过于冗余了,其实我们可以使用另一个方法来解决这个问题,那就是使用keyof关键字来获取Person类型的所有属性名。

1
2
3
4
5
function printProperties(person: Person) {
for (const property in person) {
console.log(`${property}: ${person[property as keyof typeof person]}`);
}
}

来看这一句代码property as keyof typeof person, 它的执行步骤是这样的:

  1. 先执行typeof person,得到Person类型。
  2. 再执行keyof Person,得到Person类型的所有属性名的联合类型 - name | age
  3. 最后使用as操作符将property转换为这个联合类型。

这样做的好处是,property的类型被限制为Person类型的属性名,在本例中就是nameage这两个属性,绝不会超出这个范围,这样就可以安全地索引person对象了。

眼力好的同学可能已经发现了,上面这个写法可以简化一下,property as keyof typeof person可以简化为property as keyof Person,因为person的类型就是Person,所以我们可以直接使用Person类型来代替。这样可以节省一个typeof操作符的使用。

方式三:使用Object.entries

当然,我们还可以使用Object.entries方法来遍历对象的属性,这样就不需要担心索引签名的问题了。

1
2
3
4
5
function printProperty(person: Person) {
Object.entries(person).forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
}

分析一下这段代码:

  1. Object.entries方法会返回一个二维数组,其中每个元素又是一个数组,这个数组包含了对象的属性名和属性值。以上面的person对象为例,Object.entries(person)会返回[['name', 'Alice'], ['age', 30]]
  2. 接下来的forEach方法会遍历这个数组,这里使用了一个数组解构操作符([key, value]),将每个属性的名字赋值给key,属性的值赋值给value,
  3. 最后使用console.log打印出来。

我比较喜欢方式三,简洁易懂,无需额外的操作。

今天就到这里了,觉得有用就点个关注吧,我们下次再见,我要去打弹弓了。

啥是鸭子类型?

作为一个前端程序员,想必大家都知道javascript是一个弱类型语言,如果需要类型的支持,那就需要借助typescript来实现,但是大家可曾听过这样一个说法?

javascript属于鸭子类型

当我第一次看到这个说法时,我不禁哈哈大笑,鸭子类型是啥?其实这不过是一个比喻而已,鸭子类型的意思是:

如果一个动物看起来像鸭子,游起泳来像鸭子,叫起来也像鸭子,那么它大概率就是鸭子。

结构化类型

TypeScript使用结构化类型(Structural Typing)来实现javascript中的鸭子类型,结构化类型描述的是两个类型之间的兼容性,我们看一个具体的例子,再下结论。

假设你正在开发一个3D图形应用程序,这个程序最基本的功能就是绘制图形,而绘制图形最基本的数据结构就是点,我们先定义一个2D点。

1
2
3
4
interface Point2D {
x: number;
y: number;
}

当然,要绘制3D图形,我们还需要一个3D点。

1
2
3
4
5
interface Point3D {
x: number;
y: number;
z: number;
}

现在我们可以下结论了,Point3DPoint2D是兼容的,因为Point3D包含了所有Point2D的属性。

所以结构化类型的定义如下:

如果一个类型B包含了另一个类型A的所有属性,那么这两个类型是兼容的,我们可以将类型B赋值给类型A。

需要注意的是,这种兼容性是单向的,Point3D可以赋值给Point2D,但反之不行,因为Point2D缺少了z属性。

其实这不难理解,假设我们要绘制一条2D线段,需要两个点来表示这条线段的起点和终点。

1
2
3
function drawLine(start: Point2D, end: Point2D) {
// 绘制线段的逻辑
}

那么如果我们传入的是Point3D类型的点,程序依然可以正常工作,因为Point3D包含了Point2D的所有属性。多出来的z属性直接忽略,并不影响结果。

1
2
3
const start: Point3D = { x: 0, y: 0, z: 0 };
const end: Point3D = { x: 1, y: 1, z: 1 };
drawLine(start, end); // 依然可以正常绘制线段

我们甚至不需要传递一个Point3D类型的点,任意一个包含xy属性的对象都可以作为参数传递给drawLine函数。

1
2
3
const start = { x: 0, y: 0 };
const end = { x: 1, y: 1 };
drawLine(start, end); // 依然可以正常绘制线段

这就是结构化类型的威力,也是JavaScript的灵活性所在。

名义类型

与结构化类型对应的是名义类型(Nominal Typing),比如JavaC#这种强类型语言,使用的都是名义类型,名义类型要求类型的名称必须匹配才能兼容。也就是说,只有当两个类型的名称完全相同或者存在继承关系时,它们才被认为是兼容的。

对于Java或者C#这样的强类型语言来说,上面drawLine的例子就不成立了,因为Point2DPoint3D是两个不同的类型,即使它们有相同的属性,也不能互相替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Point2D {
int x;
int y;
}

class Point3D {
int x;
int y;
int z;
}

void drawLine(Point2D start, Point2D end) {
// 绘制线段的逻辑
}

Point3D start = new Point3D(); // 定义起点
Point3D end = new Point3D(); // 定义终点
drawLine(start, end); // 编译错误,Point3D不是Point2D类型

基于这个原因,在强类型语言中如果要实现类型兼容性的话,只能通过继承来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Point2D {
int x;
int y;
}

class Point3D extends Point2D {
int z;
}

void drawLine(Point2D start, Point2D end) {
// 绘制线段的逻辑
}

Point3D start = new Point3D(); // 定义起点
Point3D end = new Point3D(); // 定义终点
drawLine(start, end); // 现在可以正常工作

上面的例子中,Point3D继承自Point2D,这就意味着Point3D是一个Point2D类型的对象,可以在需要Point2D的地方使用。

介绍

如何写一段javascript程序,输出自身的源代码?这个问题非常有意思,大家不妨先尝试一下,反正在尝试了半个小时之后,我果断放弃了。

这种能输出自身的程序在英文里被称为quine

准备知识

其实要实现这样一段程序,至少要掌握javascript中两个重要的知识点:

  1. IIFE
  2. toString()方法

关于IIFE,之前的文章已经做过详细的介绍,这里就不再赘述了,感兴趣的同学可以先去看一下。

JavaScript IIFE

关于toString()方法,相信大家也不陌生,但是你可知道对于一个函数来说,调用toString()方法意味着什么?

没错,调用一个函数的toString()方法会返回这个函数的源代码。

1
2
3
4
5
function foo() {
console.log('hello world')
}

console.log(foo.toString()) // 输出foo的源代码

第一个例子

有了上面的知识储备,我们可以动手写一下了,首先写一个函数foo,在函数体内调用console.log(foo.toString())输出函数的源代码。

1
2
3
function foo() {
console.log(foo.toString())
}

但是这只是函数定义,并未执行,所以需要在函数定义之后调用它。

1
2
3
4
function foo() {
console.log(foo.toString())
}
foo() // 调用函数,输出源代码

运行上面的代码,输出的结果如下所示,可以看到最后的调用语句foo()并没有打印出来。

1
2
3
function foo() {
console.log(foo.toString())
}

这不符合我们的要求,所以考虑去掉调用语句,将函数改为IIFE的形式

1
2
3
(function foo() {
console.log(foo.toString())
})()

运行上面的代码,输出的结果如下,最外层的()和末尾调用函数的()没有打印出来,这也不符合我们的要求。

1
2
3
function foo() {
console.log(foo.toString())
}

改造一下内部的console.log,将两组括弧也输出来。

1
2
3
(function foo() {
console.log('(' + foo.toString() + ')()')
})()

这回结果终于正确了,我们进一步改造,使用+拼接字符串时,js会自动将+另一侧的操作数转换为字符串,所以我们可以把toString()省略掉。

1
2
3
(function foo() {
console.log('(' + foo + ')()')
})()

再增加点神秘感,将foo换成$

1
2
3
(function $() {
console.log('(' + $ + ')()')
})()

运行结果如下:

1
2
3
(function $() {
console.log('(' + $ + ')()')
})()

这就是一个简单的javascript quine了。

最短的例子

在上面的例子中,为了便于观察结果,使用了console.log输出了源代码,实际上我们可以直接返回源代码,这样就可以省略掉console.log语句。

1
2
3
(function $() {
return '(' + $ + ')()'
})()

根据前面两篇文章学到的IIFE的知识,我们使用IIFE的箭头函数形式。下面这段代码定义一个箭头函数并将其赋值给变量$,然后立即执行这个函数。

1
2
3
($ = () => {
return '(' + $ + ')()'
})()

但是它的执行结果中没有包含$,我们加上$

1
2
3
($ = () => {
return '($ =' + $ + ')()'
})()

根据箭头函数的规则,如果返回值只有一行,那么可以省略掉大括号{}return关键字,所以我们可以进一步简化为

1
($ = () => '($ =' + $ + ')()')()

再将字符串拼接操作改为ES6的模板字符串形式:

1
($ = () => `($ = ${$})()`)()

最后去掉空格,得到如下代码。

1
($=()=>`($=${$})()`)()

这就是史上最短的javascript quine了。需要注意的是,上面的代码需要在浏览器的控制台中运行,如果在IDE中运行,代码格式化工具可能会将代码格式化,导致结果不正确。

你还能想到其他办法吗?欢迎在评论区分享你的想法。