0%

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)等待的方式,直到耗尽给定的时间。

介绍

今天我们来谈谈JavaScript中如何做性能监控,性能优化也是前端开发中的老话题了,做好性能优化的前提是要有性能监控,只有知道哪里有性能问题,才能有针对性的进行优化。
性能监控的方式多种多样,比如使用浏览器的开发者工具,使用第三方性能监控工具,或者自己编写代码来监控性能等。

今天我们主要介绍一下从代码层面如何做性能监控,JavaScript中提供了一个PerformanceObserver接口,可以用来监控性能相关的事件。

使用PerformanceObserver进行性能监控

首先我们需要定义一个PerformanceObserver实例。

1
2
3
4
5
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.log(`${entry.entryType}: ${entry.name} - ${entry.startTime} - ${entry.duration}`);
});
});

PerformanceObserver的构造函数,接受以下三个参数:

  1. entities - 这是一个PerformanceObserverEntryList对象,包含一系列可观察的性能事件。
  2. observer - 接收上述事件的观察者对象。
  3. options - 这是一个可选的配置对象,我们暂时用不到它,先忽略。

接下来,我们需要指定我们要监听的性能事件类型,比如longtaskmark等。

1
observer.observe({ entryTypes: ['longtask', 'mark'] });

可以使用PerformanceObserver中的静态函数PerformanceObserver.supportedEntryTypes来获取支持的性能事件类型。

1
console.log(PerformanceObserver.supportedEntryTypes);

在我的浏览器上,支持以下类型的性能事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
"element"
"event"
"first-input"
"largest-contentful-paint"
"layout-shift"
"long-animation-frame"
"longtask"
"mark"`
"measure"
"navigation"
"paint"
"resource"
"visibility-state"

监控longtask事件

longtask任务是指阻塞主线程超过50毫秒的任务,我们首先来模拟一个longtask任务。

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

接下来,我们在PerformanceObserver中监听longtask事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Create a PerformanceObserver to observe long tasks
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
console.log("Long task detected:", entry);
});
});

// Start observing long tasks
observer.observe({ type: "longtask", buffered: true });

// Start the long task.
simulateLongTask();

打开控制台,可以看到如下输出,可以看到entryTypelongtask,表示这是一个长任务。duration表示任务的持续时间为50毫秒,满足长任务的条件。如果将simulateLongTask中的时间改为小于50毫秒,则不会触发longtask事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Long task detected: PerformanceLongTaskTiming 
{
name: "self",
entryType: "longtask",
startTime: 10.299999997019768,
duration: 50,
attribution: [
{
name: "unknown",
entryType: "taskattribution",
startTime: 0,
duration: 0,
containerType: "window",
containerSrc: "",
containerId: "",
containerName: ""
}
]
}

参考

  1. https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver

介绍

今天在写代码的时候,用到了Ant Design的Select组件,也就是下拉选择框,和以前一样,习惯性的给Select组件加上了placeholder,但是运行程序时,发现Select组件中并未显示placeholder。我的代码如下,大家看出问题来了吗?

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

export default function SelectComponent() {
const options: { label: string; value: string }[] = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];

return (
<Form initialValues={{ select: '' }} layout="vertical">
<Form.Item label="Select an option" name="select">
<Select
options={options}
style={{ width: 200 }}
placeholder="Select an option"
allowClear
/>
</Form.Item>
</Form>
);
}

问题分析

没错,罪魁祸首就是这个initialValues={{ select: '' }},因为我在Form组件中设置了Select组件初始值为'',注意initialValues中小写的select对应Form.Item中组件的name,不要与大写的Select名字搞混了,这就导致该组件的值被设置为空字符串,从而无法显示placeholder

如果把这个initialValues去掉,或者将其设置为undefined,就可以正常显示placeholder了。

反过来思考一下,placeholder的作用是在用户尚未选择某个值的时候给用户一个提示,而空字符串从程序的角度来说,是一个合法的值,从而导致Ant Design的Select组件不会显示placeholder

但是undefined就不一样了,它表示没有值,这时Select组件就会显示placeholder。(还是基础不牢呀)。

源码分析

为了追本溯源,我们从源码的角度分析一下其中细节,首先去Ant Design的github页面将源码下载到本机。https://github.com/ant-design/ant-design

然后找到Select组件的源码,路径是src/components/select。在这个目录下,我们可以找到index.tsx文件。这就是Select组件的入口了。

于是尝试在文件中搜索placeholder,竟然没找到,这时我才意识到,Ant Design的Select组件并没有直接处理placeholder,而是通过rc-select这个库来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const InternalSelect = <OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType>() {
//...
return (
<RcSelect<any, any>
ref={ref as any}
virtual={virtual}
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
{...selectProps}
// ...
/>
);
};

// ...
const Select = React.forwardRef(InternalSelect);
export default Select;

从上面的代码可以看出,InternalSelect组件内容是通过RcSelect来实现的,而RcSelect是一个来自rc-select库的组件。遂又去github上找到这个库的源码 - https://github.com/react-component/select。

全库搜索placeholder,很快找到了相关代码,下面是placeholder对应的结点的定义,最终Select通过渲染这个结点来显示placeholder

1
2
3
4
5
6
7
8
9
10
11
12
13
const placeholderNode = React.useMemo<React.ReactNode>(() => {
if (item) {
return null;
}
return (
<span
className={`${prefixCls}-selection-placeholder`}
style={hasTextInput ? { visibility: 'hidden' } : undefined}
>
{placeholder}
</span>
);
}, [item, hasTextInput, placeholder, prefixCls]);

if(item) - 这句很快被排除,因为item是当前选中的值,而我们要显示placeholder的前提是没有选中任何值。

于是来到style这一行,可以看到如果hasTextInputtrue,则placeholder的样式会被设置为visibility: 'hidden' - 也就是隐藏。

1
style={ hasTextInput ? { visibility: 'hidden' } : undefined }

继续查找hasTextInput的定义,发现它是通过inputValue来判断的。

1
const hasTextInput = mode !== 'combobox' && !open && !showSearch ? false : !!inputValue;

继续查找inputValue的定义,最终发现如下代码,看到了吗?这里添加了空字符串,也就是说如果inputValue是空字符串,那么hasTextInput就会被设置为true,从而导致placeholder被隐藏。

1
let inputValue: string = searchValue || '';

打完收工,觉得有用就点个关注,你的关注是我输出的动力!我们明天再见!