0%

介绍

各位老铁上午好,昨天我们学习了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中运行,代码格式化工具可能会将代码格式化,导致结果不正确。

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

介绍

IIFE(Immediately Invoked Function Expression),中文名称:立即执行函数表达式,其实IIFE最早并不叫这个名字,而是叫做Self-Executing Anonymous Function,即自执行匿名函数。根据MDN的资料,IIFE这个说法最早由Ben Alman于2010年提出,下面我们一起来看看这个名字的来龙去脉。

2010年11月5日,Ben Alman写下来他的著名文章:Immediately-Invoked Function Expression (IIFE),标志着IIFE这个名字的诞生。

在文章中,Ben Alman称他是一个对待术语非常严谨的人,之前他多次看到Self-Executing Anonymous Function这个说法,觉得不是很恰当,于是他提出了Immediately-Invoked Function Expression这个说法。

IIFE到底是咋来的?

当我们定义一个函数或者一个函数表达式时,你得到的是一个名字,通过这个名字,你就可以调用这个函数。

下面这两段代码,第一个定义了一个普通函数,第二个定义了一个函数表达式,这两种形式,我们都可以通过标识符foo来调用它们。

1
2
3
4
5
6
7
8
9
// 普通函数
function foo() {
console.log('I am a function');
}

// 函数表达式
const foo = function() {
console.log('I am a function expression');
};

也就是说,当javascript解释器遇到全局function关键字,或者一个函数内部的function关键字时,会将其解释为一个函数声明。

然而函数声明是无法直接调用的,所以下面的写法会导致错误:

1
2
3
function foo() {
console.log('I am a function'); // Uncaught SyntaxError: Unexpected token ')'
}();

我们来分析一下,上面这段代码,javascript解释器会将其解释为一个函数声明,和一个分组操作符(()), 分组操作符是用来改变运算符优先级的,里面必须有表达式才行,所以javascript解释器会报错。

那我们就给它一个表达式:

1
2
3
function foo() {
console.log('I am a function'); // Uncaught SyntaxError: Unexpected token ')'
}(1);

这回代码不报错了,但是这段代码毫无意义,这个函数并没有执行,实际上这段代码与下面的代码等价:

1
2
3
4
5
function foo() {
console.log('I am a function');
}

(1);

它的返回值就是1,这不是我们想要的结果,我们需要函数定义后能立即被执行,那就需要我们告诉javascript解释器,这个函数是一个表达式,而不是一个声明,因为表达式可以立即执行,但是声明不能。

而在javascript中,生成表达式最简单的方式就是用()包裹起来,于是有了下面的代码

1
2
3
(function foo() {
console.log('I am a function');
});

这样函数声明就变成了一个函数表达式,但是这个表达是没有名字,我们没法调用它,我们先给它一个名字,然后通过名字调用它。

1
2
3
4
5
const bar = (function foo() {
console.log('I am a function');
});

bar(); // I am a function

这样完全没有问题,但是这里的bar实在有点多余,实际上bar

1
2
3
(function foo() {
console.log('I am a function');
});

是等价的,既然bar()可以调用函数,那么我们直接在函数表达式末尾加上(),也可以调用这个函数,于是就有了下面的代码,这就是IIFE的由来。

1
2
3
(function foo() {
console.log('I am a function');
})();

()写在外层的括号内也一样,这种方式颇得javascript专家Douglas Crockford的青睐。我本人更喜欢第一种。

1
2
3
(function() {
console.log('I am a function');
}());

IIFE的变种

有上面介绍可知,生成IIFE的精髓就是将函数声明变成函数表达式,而在javascript中,生成表达式可不止使用()包裹起来这一种方式,还有很多其他的方式可以实现。于是IIFE也就是产生了诸多变种。

这个变种利用赋值运算符=来实现,赋值运算符是将右侧表达式的值赋值给左侧变量的,所以它右侧的部分被解析成了函数表达式及其调用。

1
2
3
const i = function() {
console.log('I am an IIFE');
}();

下面的表中使用逻辑运算符来生成表达式。

1
2
3
true & (function() {
console.log('I am an IIFE');
}());

还有下面这些,都是利用一元运算符来生成函数表达式。

1
2
3
4
!function(){ /* code */ }();
~function(){ /* code */ }();
-function(){ /* code */ }();
+function(){ /* code */ }();

最后来一个不为人知的,void运算符会对其右侧的表达是求值然后返回undefined。(void expression - 先对expression求值,然后返回undefined)。

1
2
3
void function() {
console.log('I am an IIFE');
}();

还有使用new运算符来生成IIFE,这种方式比较少见,因为它会创建一个新的对象。

1
2
3
new function() {
console.log('I am an IIFE');
}();

这些方式都比较偏门了,不建议使用,只是用来帮助我们理解IIFE的。

为什么Self-Executing Anonymous Function这个名字不好?

Ben Alman认为这个名字有两个问题:

Self-Executing:这个名字暗示函数会调用自己,但是实际上函数是立即被执行的,而不是调用它自身。
比如下面的几段代码都会调用自己,但是这并不是IIFE的语义。

1
2
3
4
5
// 递归调用自身
function foo() { foo();

// 使用arguments.callee调用自身
const foo = function() { arguments.callee(); };

Anonymous:这个名字暗示函数是匿名的,但实际上函数可以有名字,也可以没有名字,比如下面的例子:

1
2
3
4
// 有名字的IIFE
(function foo() {
console.log('I am an IIFE');
})();

参考

https://web.archive.org/web/20171201033208/http://benalman.com/news/2010/11/immediately-invoked-function-expression/#iife

大家有时间可以去拜读Ben Alman的原文,大佬写的东西就是不一样,通俗易懂,是我辈楷模!

介绍

IIFE(Immediately Invoked Function Expression)中文名称:立即执行函数表达式,是一种JavaScript编程模式,它允许函数在定义后立即执行。由这个定义可以看出IIFE有两个特点:

  1. 它是函数表达式
  2. 它是定义后立即执行的

来看一个例子:

1
2
3
(function() {
console.log('I am an IIFE');
})();

将这段代码放在浏览器控制台中执行,会输出I am an IIFE。这就是一个简单的IIFE。

分析一下上面的代码,它分为两部分,每个部分由一组()包裹。
第一部分是一个函数(本例用的是匿名函数,也可以是具名函数):

1
2
3
(function() {
console.log('I am an IIFE');
})

第二部分是一个函数调用

1
();

这两部分结合在一起,就形成了一个IIFE。

快速掌握IIFE

IIFE的语法其实不复杂,但是刚开始要记住它并不容易,这里教大家一个小技巧,可以快速书写IIFE代码。

  1. 首先,写下两组括弧()()
  2. 然后在第一组括弧内写下函数定义 - 注意这里必须是匿名函数。
  3. 如果函数需要参数,那么在第二组括弧内传入参数。

一道练习题,写一个IIFE,计算1 + 2的和

我们使用上面的技巧来书写:

首先写下两组括弧

1
()()

然后在第一组括弧内写下函数定义

1
2
3
(function add(a, b) {
console.log(a + b);
})()

因为这里的add函数需要参数,所以我们要在第二组括弧内传入参数

1
2
3
(function add(a, b) {
console.log(a + b);
})(1, 2);

IIFE的变种

上面介绍的IIFE写法是最常见的形式,其实IIFE还有一些其他写法(至于为什么这些写法是可以的,我们下一篇再做介绍),现罗列如下:

这种写法将调用函数的括号放到了第一组括弧内部。

1
2
3
(function() {
console.log('I am an IIFE');
}());

以下几种写法都是使用一元运算符来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
!function() {
console.log("IIFE with !")
}();

~function() {
console.log("IIFE with ~")
}();

+function() {
console.log("IIFE with +")
}();

-function() {
console.log("IIFE with -")
}();

这些一元运算符的作用是将函数定义转换为一个表达式,从而使其可以被调用。如果不加这些一元运算符的话,就变成了下面的形式:

1
2
3
function() {
console.log("IIFE without unary operator")
}();

这会导致一个语法错误,因为JavaScript引擎会将其解析为一个函数声明,而不是函数表达式。函数声明需要一个函数名,而匿名函数没有名字,所以会报错:

1
Uncaught SyntaxError: Function statements require a function name

我们给它加上一个名字,变成下面这样

1
2
3
function foo() {
console.log("IIFE with function name")
}();

但是还是会报错:

1
Uncaught SyntaxError: Unexpected token ')'

javascript引擎会将上述代码解析为一个函数声明和一个分组操作符 - 即(),但是分组操作符内不能是空的,必须有一个表达式,我们在里面加上一个1

1
2
3
function foo() {
console.log("IIFE with function name")
}(1);

这样就不会报错了,但是这不是一个IIFE了。它的返回值就是分组表达式的值,也就是1。

为什么需要IIFE?

早期在JavaScript尚未支持模块化的时候,IIFE是实现模块化的一种简单方式,它可以创建一个独立的作用域,从而避免全局污染,下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const myModule = (function MyModule() {
const privateVariable = 'private variable';
const publicVariable = 'public variable';

function privateMethod() {
console.log(privateVariable);
}

function publicMethod() {
console.log(publicVariable);
}

// 将需要暴露给外界的变量和方法写在返回的对象中
return {
publicVariable,
publicMethod,
};
})();

console.log(myModule.publicVariable); // 访问公共变量
myModule.publicMethod(); // 访问公共方法

console.log(myModule.privateVariable); // undefined
myModule.privateMethod(); // TypeError: myModule.privateMethod is not a function, privateMethod is not accessible

分析一下上面的代码,这是一个典型的IIFE + 闭包实现模块化的例子。

MyModule是一个IIFE,它在内部定义了:

  • 私有变量privateVariable
  • 私有方法privateMethod

这些变量和方法无法从外部访问。

它还定义了

  • 一个公共变量publicVariable
  • 一个公共方法publicMethod

这些可以通过返回的对象访问。

IIFE的返回值赋值给了myModule变量,这样就可以通过myModule访问公共变量和方法。

注意:这个例子仅供参考,在ES6模块已经十分普及的今天,这种方式已经不推荐使用了。

介绍

今天在写前端性能监控的文章时,需要模拟一个长任务,所谓长任务就是指阻塞主线程超过50毫秒的任务。

这种任务当然直接交给AI做了,我给了AI一个提示:帮我模拟一个执行事件超过50毫秒的JavaScript任务。

于是AI给了以下几个代码片段:

使用Busy-Wait来模拟一个长任务

使用Busy-Wait来模拟一个长任务,这个实现没啥毛病,也是我最终采用的方案,就是阻塞主线程直到50毫秒过去:

1
2
3
4
5
6
function simulateLongTask() {
const start = performance.now();
while (performance.now() - start < 50) {
// 模拟一个长任务
}
}

使用setTimeout来模拟一个长任务

下面这个实现使用setTimeout来模拟一个长任务。

1
2
3
4
5
6
7
function simulate50msTask() {
const start = performance.now();
setTimeout(() => {
const end = performance.now();
console.log(`Task took ~${end - start}ms`);
}, 50);
}

但是这个长任务是有问题的,它不会阻塞主线程,因为setTimeout是异步的,所以它不会在50毫秒内阻塞主线程。

这可能是一个常见的误区吧,setTimeout中的延时参数并不意味着主线程会在这个时间内被阻塞,它只是告诉浏览器在指定的时间后执行回调函数。

结论

所以,如果要模拟一个长任务,不能使用setTimeout,最好的办法就是使用循环(while, for)等待的方式,直到耗尽给定的时间。