函数调用栈

函数体

函数声明或函数表达式,都能够创建一个函数体。函数体也是数据,在代码解析阶段,被持久的存在于堆内存中。

执行上下文

执行函数会创建执行上下文,执行上下文会占用新的内存空间。执行上下文会按函数体内编辑好的代码逻辑,实时的记录函数体在执行过程中的所有状态与数据。

JS 的运行环境:

  • 全局环境:代码运行时首先会进入全局环境,生成全局上下文
  • 函数环境:当函数被调用时,进入函数环境执行代码,同时执行上下文被创建

当调用一个新函数时,新的执行上下文就会被创建。JS 引擎使用栈的方式来跟踪多个执行上下文的运行情况,我们称之为「函数调用栈」。正在执行的函数永远处于栈顶,栈顶上下文执行完毕就会出栈,被垃圾回收器回收,新的栈顶上下文继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var color = 'blue';

function changeColor() {
var anotherColor = 'red';

function swapColors() {
var tempColor = anotherColor;
anotherColor = color;
color = tempColor;

console.log(a);
const a = 1;
console.trace();
}

swapColors();
}

changeColor();
  1. 进入全局上下文,并一直处于栈底
  2. 遇到 changeColor(),创建 changeColor 上下文,新的上下文入栈并开始执行,此时全局上下文被挂起
  3. 遇到 swapColors(),创建 swapColors 上下文,新的上下文入栈并开始执行,changeColor 上下文被挂起
  4. swapColors 代码执行完毕,swapColors 上下文出栈,等待被回收
  5. changeColor 上下文激活,changeColor 代码执行完毕,changeColor 上下文出栈,等待被回收
  6. 全局上下文被激活,所有代码执行完毕,全局上下文出栈,函数调用栈被清空

console.trace()

我们可以使用 console 的 trace 方法打印出,当前函数调用栈中有哪些函数
pic.1708325971422

当我们代码报错时,浏览器的 console 面板也会把当前函数调用栈打印出来,这是我们定位 bug 的有效手段
pic.1708325977284

原型和原型链

原型

实例对象共享的属性和方法,可以抽离出来放在一个对象中,这个对象就是原型对象。

  1. 构造函数中,使用 prototype 指向原型对象
  2. 实例对象中,使用 proto 指向原型对象
  3. 原型对象中,使用 construct 指向构造函数
  4. 实例对象中,使用 construct 指向构造函数
1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
}
const p1 = new Person('Tom');
console.log('p1: ', p1);

console.log(Person.prototype === Object.getPrototypeOf(p1));
console.log(p1.__proto__ === Object.getPrototypeOf(p1));
console.log(Object.getPrototypeOf(p1).construct === Person());
console.log(p1.construct === Person());

instanceof: 用于检测构造函数的原型对象是否出现在某个实例对象的原型链上

1
console.log(p1 instanceof Person); // true

操作原型的方法:

  • Object.create: 根据指定的原型对象创建新对象。Object.create(proto, [propertiesObject])
  • Object.getPrototypeOf: 获取一个对象的原型。Object.getPrototypeOf(obj)
  • Object.setPrototypeOf: 设置一个对象的原型。Object.setPrototypeOf(obj, prototype)
  • Object.prototype.isPrototypeOf(): 返回一个布尔值,表示指定的对象是否在本对象的原型链中。
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
// 操作原型的方法
const parentObj = {
add: function () {
return this.a + this.b;
},
};

// Object.create: 接收一个现有对象为新对象的__proto__,第二参数为属性描述符对象
const newObj = Object.create(parentObj, {
a: {
value: 10,
},
b: {
value: 20,
},
});
console.log(newObj.add()); // 30

const childObj = {
a: 10,
b: 20,
};
// 设置一个指定对象的原型
Object.setPrototypeOf(childObj, parentObj);
console.log(childObj.add()); // 30

// 获取指定对象的原型
console.log(Object.getPrototypeOf(childObj)); // {add: f}

// 返回一个布尔值,用于检测一个对象的原型是否在另一个对象的原型链上
console.log(parentObj.isPrototypeOf(childObj)); // true

实例、原型对象、构造函数关系图:
pic.1708405528743

原型链

定义:
每一个对象都包含一个原型属性,用于关联另一个对象,关联后就能够使用那个对象的属性和方法;对象之前通过原型关联在一起,就好比一条锁链将一个个对象连接在一起,最终形成了一条原型链。

属性查找机制:
当查找对象属性时,如果实例对象自身不存在该属性,就从原型链往上一级查找,如果还没找到就再往上一级,直至最顶级的原型对象 Object.prototype,如果还没找到则返回 undefined

垃圾回收机制

垃圾:无任何引用的对象

回收:清理被垃圾占用的内存

区域:堆内存(栈内存有自己的回收机制)

发生时间:程序空闲时间

垃圾如何产生的

1
2
var a = 1;
a = 2;

pic.1708326437848

将变量 a 赋值为 2 的时候,会在内存中开辟一个新的空间,变量 a 指向这个新的地址,那么虚线部分的数据失去了连接,这时候它就成为了一个垃圾。如果不清理垃圾,等所以内存被占满时,内存就会溢出,所以有了垃圾回收机制。

垃圾回收机制是什么
垃圾回收机制是 V8 引擎实现的一套清理垃圾的方案,它会在程序空闲时,周期性的清理被标记为是垃圾的内存。

识别垃圾(标记方式)

识别垃圾有两种标记法,引用计数法和根搜索算法。

引用计数法

通过添加一个计数变量的方法,当对象被初始化赋值后,该变量计数为 1

1
var a = { n: 1 }; // 计数变量 = 1

当有一个地方引用它时,变量计数+1

1
2
var b = a; // 计数变量 +1 = 2
var c = a; // 计数变量 +1 = 3

引用失效时,变量计数-1

1
2
a = null; // 计数变量 -1 = 2
b = {}; // 计数变量 -1 = 1

当计数器为 0 时,则表示失去了所有引用,该对象成为垃圾。

关联场景:改实现原理和数组 length 的实现一样。

优点就是简单、高效。缺点就是无法解决循环引用的问题,这会导致内存泄漏。这个一个重大缺陷,所以 V8 没有采用这种标记法。

1
2
3
4
5
6
7
8
9
var p = {
n: 1,
next: {
n: 2,
next: p,
},
};

p = null;

![引用计数法的内存泄漏.png]pic.1708326472502
对象无法访问,计数也不为 0,无法被回收,导致内存泄漏。

循环引用的问题并不是没有办法解决,这需要开发者手动删除引用,即let a = null 这种方式。

根搜索算法(标记法)

V8 采用根搜索算法,根搜索算法会对堆内存进行遍历,找到GC Root(根对象)引用的其他对象,能访问到的都标记为活跃对象,其余则为非活跃对象。

如果不考虑循环引用,GC Roots Set(根集)会表现为一颗树状结构。考虑循环引用,则会呈现出图结构,所以不存在内存泄露问题。
![根搜索算法.png]pic.1708326483934

根对象 Root:

  • 所有正在运行的栈上的引用变量
  • 全局对象 Global、window(对于浏览器来说,Glocal 等于 window)
  • 所有内置对象

关于标记阶段指的注意的是,开始标记之前,需要先暂停应用线程(stop-the-world)

代际假说(The Generational Hypothesis)

代际假说是垃圾回收中的一个重要术语,他主要有两个特点:

  • 大部分对象在内存中的存活周期都很短暂。例如执行上下文
  • 不死的对象,存活周期比较漫长。例如函数声明,全局对象

V8 回收器 Orinoco

V8 回收器名叫 Orinoco。垃圾回收器在进行标记或回收行为时,会暂停 JS 主线程的执行。

任何垃圾回收器的基本任务:

  1. 识别死/活对象
  2. 回收/重用死对象占用的内存
  3. 压缩碎片内存(可选)

Orinoco利用代际假说这一假设,大多数对象都会在新生代中死亡,只有少数对象能在新生代中存活下来,然后移动到老生代。

所以,GC 复制算法得以在 V8 中被使用。

V8 回收算法(GC 复制算法)

分代回收
Orinoco中,存在两个不同的 GC。Minor GC: 用于回收新生代的垃圾,Major: 用于回收老生代的垃圾。

对于存活周期漫长的对象,那他需要的空间肯定也比较大,对应的算法也就不同。

新生代(Minor GC)

Scavenge(深度优先 存在递归问题)

Scavenge 是典型的牺牲空间换时间的复制算法。

to-space中,存在$free指针,用于指向当前对应空间可分配内存的起始地址。当我们从复制完一个对象后,$free会移动到新的起始位置。

具体步骤为以下 4 步:

  1. 从 Root 引用开始查询,通过根搜索算法标记活动对象和非活动对象
  2. 复制from-space的活动对象到to-space
  3. 清空from-space
  4. from-spaceto-space角色互换,以便下次回收

第二步还有个细节,将某个活动对象复制到to-space后,会在from-space中将该对象标记为已复制,而不是马上清空该对象占用的空间。这是因为该对象可能被其他对象引用。

还有一点,在from-space中找到某个活动对象(B)后,如果该活动对象下还有别的活动对象(A),就会将 A 复制到to-space。这是一个递归过程。

递归的问题:

  1. 持续占用栈内存,而且可能出现爆栈的情况
  2. 执行一个函数就会创建一个执行上下文,也会持续占用内存

递归带来的负担不可忽视,V8 并没有完全采用Scavenge算法,之后引入了cheney算法,用迭代来解决该问题。

cheney(广度优先 迭代)

cheney算法引入了新的指针$scan,该指针用于标记to-space中,还没有被向下搜索过子对象的起始位置。

  1. 从 Root 引用开始查询,通过根搜索算法标记活动对象和非活动对象
  2. 依次将活动对象复制到to-space中(不再进行递归查找)
  3. $scan指针不变,$free指针向右移动
  4. Root 节点中没有找到别的引用后,我们从to-space(可以看做队列)头部开始搜索。to-space头部的对象成为了新的 Root 节点,$scan指针向右移动(出队)如果发现新节点存在引用,则复制进入队列。
  5. $scan指针依次向右移动,发现新对象就复制入队,直到$scan指针与$free指针重合,表示所有活动对象查询完毕。
  6. 剩下的就是清理空间然后互换。

知识体系关联:与 Promise 的任务队列方式相似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const PromiseJobs = [];

// 加入 job1
PromiseJobs.push(job1);
// 加入 job2
PromiseJobs.push(job2);

PromiseJobs = [job1, job2];

let job;
// 将先进入队列的 job 移除队列,并执行
// PromiseJobs 是处于动态变化的,所以只能用 while 来处理这种动态循环的场景。不能用 for 循环
while ((job = PromiseJobs.shift())) {
job();
}

cheney 算法采用的是广度优先遍历,这就是迭代,这样把堆用做队列的方式,消除了 scavenge算法的递归风险。代价就是访问速度上,与scavenge 相比可能会稍微慢一点。

知识体系关联:队列优先级的算法,在 React Fiber 架构中也有用到。

老生代(Major GC)

scavenge算法为什么不合适老生代,因为

  • scavenge是复制算法,反复复制存活率高的对象没有意义
  • scavenge 是以空间换时间的算法,老生代内存空间很大,所以空间资源非常浪费

所以老生代使用了mark-sweep(标记清理)mark-Compact(标记清理整理)

Mark-Sweep

Mark-Sweep算法分为两个阶段,标记和清理。标记依旧是通过根搜索算法。清理阶段与scavenge算法不同,scavenge 算法是复制后再清理,而Mark-Sweep是标记后直接清理。

Mark-Compact

Mark-Compact是为了解决Mark-Sweep算法带来的内存缝隙而提出的解决方案。

compact 主要做两件事:

  1. 把活动对象移动到该去的位置
  2. 修改引用,让他们指向新的地址

![Mark-Compact.png]pic.1708326499231

全停顿(Stop-The-World)

JS 代码的运行需要用到 JS 引擎,垃圾回收也要用到 JS 引擎,如果这两者同时运行怎么办?答案是垃圾回收优于代码执行。代码会暂停执行,等待垃圾回收完毕再执行。这个过程称为全停顿
但这样的代价就是页面明细卡顿,因此Orinoco还继续做了优化。

Orinoco 进一步优化

增量标记+惰性清理

2011 年,V8 从早期的stop-the-world 切换到 Incremental增量标记 + Lazy Sweeping惰性清理的模式。

垃圾回收任务被拆分为多个小任务,然后在主线程空隙中执行这个小任务。

增量标记是针对标记阶段的优化。只有当垃圾达到一定数量是,增量标记就会开启:标记一点,JS代码运行一段

惰性清理是针对清除阶段的优化。假如当前可用内存足以让代码快速执行,那就延迟清理,或清理部分垃圾。

结合浏览器的requestIdleCallback,增量标记与惰性清理的出现,使主线程的最大停顿时间减少了 80%。页面更流畅了。

但是也带来了问题,标记和代码执行的穿插,可能会造成对象引用改变、标记错误的现象。这就需要写屏障技术来记录这些引用关系的变化。

2018 年,V8 同时引入了并行与并发,让垃圾回收的时间进一步缩短。

并行(Parallel)

并行指的是主线程和辅助线程同时执行大致数量相等的清理任务。依旧采用stop-the-world的方式,但是将清理任务交给多个线程来执行。这是实现起来最简单的方案。
![多线程并行减少主线程等待时间.png]pic.1708326507586

并发(Concurrent)

并发指的是在不暂停 JS 代码执行的同时,辅助线程在后台执行垃圾回收工作。除了需要写屏障技术,还可能会存在辅助线程与 JS 主线程同时读取或修改同一对象的问题,这就能能处理了。这是三种技术中实现起来最难的。
![并发执行(不暂停js的执行).png]pic.1708326513601

知识体系关联:React 18 的并发(Concurrent)

V8 当前的垃圾回收机制

在新生代中,使用并行机制。在将活动对象从from-space赋值到to-space时,启用多个辅助线程并行整理。由于多个线程可能会竞争同一个对象,因此第一个线程对该对象操作之后,都必须维护这个对象的转发地址,以便其他线程能够快速判断该对象是否已被复制。

新生代:并行方式+cheney 算法

在老生代中,如果堆中的内存大小超过某个阈值,会启用并发标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用,JS 代码执行的之后,并发标记也在后台的辅助进程中进行,当堆中的某个对象被修改的时候,写入屏障技术会在辅助线程在进行并发标记时进行追踪。

并发标记完成后,辅助线程会进行内存整理,不影响 JS 代码的执行。

从 V8 到实践

了解 V8 垃圾回收的内部机制,可以帮助我们考虑内存使用。例如,从垃圾回收角度来看,存活周期漫长的对象维护成本会偏高。因为

  • 对于无效函数的声明就应该更严谨
  • 使用 shaking 技术
  • 减少闭包对象的大小(Redux 的 Provider 注入,Provider 只放少量必要的数据)

参考:

封装的理解

背景

最近在学习 canvas 的使用,并写了个 demo,就是做一个 K 线图,支持提示、拖拽、缩放,也发布为 npm 包:echarts-for-abc
效果如图所示
pic.1708325742371
写代码过程中,封装是必不可少的一部分,所以想以这个为例子来讲一下封装。

目的

封装的目的,是为了减少代码量。

定义

万物皆对象,对象具有属性和行为(方法),对象公共属性和行为的提取就是封装。

理念

封装的前提,必定跟场景相关联的,也就是说,先有场景,再有封装。封装前,先把场景下的属性具象化出来,再考虑提取他们的共性。如果跳过这一步直接思考他们的共性,这很容易出现思考偏差。

举个例子:一个学校里面,有老师、学生、校长等各角色,不同角色有自己的特点和行为,比如学生有学号,老师有职位等级、类型,他们也有共同的特性,比如拥有性别、年龄等特点,用类(class)来表示各个角色。

将学生和老师用代码抽象化表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student {
constructor(studentNumber, sex, age) {
this.studentNumber = studentNumber; // 学号
this.sex = sex;
this.age = age;
}
}

class Teacher {
constructor(rank, sex) {
this.rank = rank; // 等级
this.sex = sex;
this.age = age;
}
}

发现学生和老师都有性别和年龄,那就可以提取出来,所以可以很容易用Person封装起来,

1
2
3
4
5
6
class Person {
constructor(sex, age) {
this.sex = sex;
this.age = age;
}
}

然后就可以使用extends让子类(学生)继承父类(Person)的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Student extends Person {
constructor(studentNumber, sex, age) {
super(sex, age);
// super() 表示执行父类构造函数,相当于Person.prototype.constructor.call(this), 用于继承父类Person的属性
// 在这里,相当于this.sex = undefined; this.age = undefined
// 传入sex,age 相当于 this.sex = sex; this.age = age;

this.studentNumber = studentNumber;
}
}

const student1 = new Student('330311221', '男', 15);
// student1 = { age: 15, sex: "男", studentNumber: "330311221" }

class Teacher extends Person {
constructor(rank, sex, age) {
super(sex, age);
this.rank = rank;
}
}

const teacher1 = new Teacher('高级教师', '女', 35);
// teacher1 = { age: 35, sex: "女", rank: "高级教师" }

封装颗粒度

封装颗粒度表示函数的拆分程度,是否越细越好呢,其实不是。
在项目中,如果组件拆的过于细,可能会导致父组件的参数向子组件一层层传递时,会出现遗漏,传错等问题。如果拆的太粗,会导致难以复用、难以维护等问题,那么怎样的颗粒度大小才算合适呢?封装的颗粒度大小,取决于不同场景下的偏向性考虑。

考虑偏向性

封装偏向性可以分为两种,偏应用还是偏底层。

偏应用:这个封装只适用你这个特定的场景
偏底层:具有独立性,颗粒度更小,可以脱离特定场景,就像工具方法,如 Lodash 工具库

工具方法:是一个纯函数,传入参数,返回结果。也就是说不要在工具方法内部获取外部变量,要作为参数传入。

考虑哪种偏向性,取决于场景,所以进入“写一个 K 线图 demo”的场景,来聊一聊封装。

举例说明

可以看到图中的蜡烛出现了 10 次,那么如何封装渲染蜡烛的这个函数呢?是封装单个蜡烛合适还是一串蜡烛合适?

如果作为单个蜡烛的出现,我们只需要知道蜡烛横坐标、蜡烛宽度、颜色以及四个点的纵坐标就能绘制出蜡烛,代码如下:

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
/**
* 绘制蜡烛
* @param {number} abscissa 蜡烛横坐标
* @param {number} topPointY 最高点纵坐标
* @param {number} bottomPointY 最低点纵坐标
* @param {number} secondPointY 第二个点纵坐标
* @param {number} thirdPointY 第三个点纵坐标
* @param {number} candleW 蜡烛宽度
* @param {string} candleColor 蜡烛颜色
*/
function renderCandle(
abscissa,
topPointY,
bottomPointY,
secondPointY,
thirdPointY,
candleW,
candleColor,
) {
const halfCandleW = candleW / 2;

// 绘制蜡烛上影线
ctx.beginPath();
ctx.moveTo(abscissa, topPointY);
ctx.lineTo(abscissa, secondPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛下影线
ctx.beginPath();
ctx.moveTo(abscissa, bottomPointY);
ctx.lineTo(abscissa, thirdPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛实体(中间矩形部分)
ctx.beginPath();
ctx.moveTo(abscissa - halfCandleW, secondPointY);
ctx.rect(
abscissa - halfCandleW,
secondPointY,
candleW,
thirdPointY - secondPointY,
);
ctx.fillStyle = candleColor;
ctx.fill();
}

renderCandle(50, 88, 50, 44, 33, 20, 'red');

pic.1708325756628
这个函数的封装偏向性就属于偏底层封装,它具有独立性,颗粒度更小,复用率更高,就像在 Echarts 图标库里,有多个应用到单个蜡烛的地方,就可以使用这个工具函数,你只需要传入相关参数即可。

但在我这个练习 demo 中,单个蜡烛并没有独立出现的场景,我考虑偏应用封装,而且在渲染蜡烛之前我需要处理数据源、判断涨跌以及蜡烛颜色的情况,所以将这些处理逻辑和绘制蜡烛封装为一个函数,不管在阅读还是使用上都更为方便,我的代码为:

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
/**
* 绘制一串蜡烛
* @param {array} data 数据源
* @param {number} candleW 蜡烛宽度
*/
function renderCandles(data, candleW) {
// 将数据源转换为绘制蜡烛所需要的纵坐标集合
const dataYAxisPoint = tranPriceToOrdinate(data);
const halfCandleW = candleW / 2;

for (let i = 0, candleLength = dataYAxisPoint.length; i < candleLength; i++) {
const { heightPrice, lowPrice, openingPrice, closingPice } =
dataYAxisPoint[i];
let abscissa = xAxisTickPointX(i),
topPointY = heightPrice,
bottomPointY = lowPrice,
secondPointY,
thirdPointY,
candleColor;

if (closingPice < openingPrice) {
// 涨
candleColor = 'red';
secondPointY = closingPice;
thirdPointY = openingPrice;
} else {
candleColor = 'green';
secondPointY = openingPrice;
thirdPointY = closingPice;
}

// 绘制蜡烛上影线
ctx.beginPath();
ctx.moveTo(abscissa, topPointY);
ctx.lineTo(abscissa, secondPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛下影线
ctx.beginPath();
ctx.moveTo(abscissa, bottomPointY);
ctx.lineTo(abscissa, thirdPointY);
ctx.closePath();
ctx.stroke();

// 绘制蜡烛实体(中间矩形部分)
ctx.beginPath();
ctx.moveTo(abscissa - halfCandleW, secondPointY);
ctx.rect(
abscissa - halfCandleW,
secondPointY,
candleW,
thirdPointY - secondPointY,
);
ctx.fillStyle = candleColor;
ctx.fill();
}
}

总结

  • 封装的理念为:把场景下的属性和方法具象化,提取共性
  • 封装的颗粒度大小,取决于不同场景下的偏向性考虑

异步解决方案

同步与异步

同步是指发起一个请求时,如果未得到请求结果,代码逻辑将会等待,直到结果出来才会继续执行之后的代码。

异步是指当发起一个请求时,不会等待请求结果,直接继续执行后面的代码。请求结果的处理逻辑,会添加一个监听,等到反馈结果出来后,在回调函数中处理对应的逻辑。

使用 Promise 模拟一个发起请求的函数,该函数在 1s 之后返回数值 30。

1
2
3
4
5
6
7
8
9
function fn() {
return new Promise(function (resolve, reject) {
setTimeout(function () {
resolve(30);
}, 1000);
});
}

fn().then((res) => console.log(res)); // 输出 30

在该函数的基础上,我们可以使用 async/await 来模拟同步的效果。

1
2
3
4
5
6
7
8
9
10
var foo = async function () {
var res = await fn();
console.log(res);
console.log('next code');
};
foo();

// 输出结果
// 30
// next code

而异步效果则会有不同的输出

1
2
3
4
5
6
7
8
9
10
var foo = function () {
fn().then((res) => {
console.log(res);
});
console.log('next code');
};

// 输出结果
// next code
// 30

ajax

ajax 是网页与服务端进行数据交互的一种技术。
我们可以通过服务端提供的接口,利用 ajax 向服务端请求需要的数据

整个过程的简单实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var url = 'http: www.demo.com/user/info';

var result;

var XHR = new XMLHttpRequest();
XHR.open('GET', url, true);
XHR.send();

XHR.onreadystatechange = function () {
if (XHR.readyState == 4 && XHR.state == 200) {
result = XHP.reponse;
console.log(result);
}
};

这看起来没什么麻烦的,但是这时候,如果我们还需要做另一个 ajax 请求,这个请求的参数是从上一个 ajax 请求中获取的,那么我们就不得不如下这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var url = "http: www.demo.com/user/info"

var result;

var XHR = new XMLHttpRequest()
XHR.open('GET', url, true)
XHR.send()

XHR.onreadystatechange = function () {
if (XHR.readyState == 4 && XHR.state == 200) {
result = XHP.reponse
console.log(result)

// 伪代码2
var url2 = 'http:xxx.yyy.com/zzz?ddd=' + result.someParams;
var XHR2 = new XMLHttpRequest();
XHR2.open('GET', url, true);
XHR2.send();
XHR2.onreadystatechange = function() {
...
}
}
}

当出现第三个(甚至更多)仍然依赖上一个请求的时候,代码就变成了一场灾难。

我们需要不停的嵌套回调函数。这样的灾难,我们称之为 回调地狱。

**Promise **可以帮助我们解决这个问题。

Promise

我们知道,如果要确保代码在谁之后执行,可以利用函数调用栈,将想要执行的代码放入回调函数中。

1
2
3
4
5
6
7
8
9
10
11
function want() {
console.log('这是你想要执行的代码');
}

function fn(want) {
console.log('这里表示执行了一大堆其他代码');

// 其他代码执行完后,最后执行回调函数
want && want();
}
fn(want);

或者可以利用任务队列

1
2
3
4
5
6
7
8
9
10
11
function want() {
console.log('这是你想要执行的代码');
}

function fn(want) {
// 根据事件循环的机制,我们就不用非得将代码放在最后面了,由你自由选择
want && setTimeout(want, 0);

console.log('这里表示执行了一大堆其他代码');
}
fn(want);

与 setTimeout 类似, Promise 也可以认为是一种任务分发器,它将任务分发到 PromiseJobs 执行队列中。通常的用法是,我们发起一个请求,然后等待并处理请求结果。

简单用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var tag = true;
var p = new Promise(function (resolve, reject) {
if (tag) {
resolve();
} else {
reject();
}
});

p.then(function (result) {
console.log(result);
}).catch(function (err) {
console.log(err);
});

定义

Promise 是一个对象,保存着未来某一时刻才会执行的事件。

我们常使用 Promise 来解决反馈结果需要等待的场景。

例如

  • 前端向服务端发送一个接口请求。请求结果不会马上返回,而是需要等待一段时间。
  • 加载图片,需要等待一段时间
  • 弹窗中,等待用户点击确认或者取消

基础知识

创建 Promise 实例

1
const p = new Promise();

Promise 函数中的第一个参数为一个回调函数,我们可以称之为 executor 。通常情况下,在这个函数中,我们将会执行发起请求操作,并修改结果的状态值。

1
2
3
4
5
6
7
8
const p = new Promise((resolve, reject) => {
if (true) {
resolve();
}
if (false) {
reject();
}
});

状态

状态有三种

  1. pending:等待结果状态
  2. fulfilled:已出结果,结果符合预期完成状态
  3. rejected:已出结果,结果未符合预期完成状态

promise 表达的就是从发起请求开始,从没有结果 padding 到有结果 fulfilled/rejected 的一个过程。

在 executor 函数中,我们可以分别使用 resolve 与 reject 将状态修改为对应的 fulfilled 与 rejected.

resolve/reject 是 executor 函数的两个参数。他们能够将请求结果的具体数据传递出去。

  1. Promise 实例拥有 then 方法,用来处理请求结果变为 fulfilled 状态时的逻辑。then的第一个参数也是一个回调函数,该函数的参数则是 resolve 传递出来的数据。第二个参数用来处理 rejected 状态时的逻辑。
  2. Promise 实例拥有 catch方法,用来处理请求结果变为 rejected 时的逻辑。catch的第一个参数也是一个回调函数,该函数的参数则是 reject 传递出来的数据。

基本使用

写个例子感受一下 Promise 的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fn(num) {
return new Promise(function (resolve, reject) {
// 模拟一个请求,2s 后得到结果
setTimeout(function () {
if (typeof num == 'number') {
resolve(num);
} else {
var err = num + ' is not a number';
reject(err);
}
}, 2000);
});
}

fn('abc')
.then(function (resp) {
console.log(resp);
})
.catch(function (err) {
console.log(err);
});

// 注意观察该语句的执行顺序
console.log('next code');

then方法可以接收两个参数,第一个参数用来接收 fulfilled 状态的逻辑,第二个参数用来处理 rejected 状态的逻辑。

1
2
3
4
5
6
7
8
fn('abc').then(
function (resp) {
console.log(resp);
},
function (err) {
console.log(err);
},
);

因此 catch 方法其实与下面的写法等价。

1
2
3
fn('abc').then(null, function (err) {
console.log(er);
});

then方法返回的仍然是一个 Promise 实例对象,因此 then 方法可以链式调用,通过在内部 return 的方式,能够将数据持续往后传递。

封装 ajax

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
var url = 'http://www.demo.com/user/info';

// 封装一个get请求的方法
function getJSON(url) {
return new Promise(function (resolve, reject) {
// 利用ajax发送一个请求
var XHR = new XMLHttpRequest();
XHR.open('GET', url, true);
XHR.send();

// 等待结果
XHR.onreadystatechange = function () {
if (XHR.readyState == 4) {
if (XHR.status == 200) {
try {
var response = JSON.parse(XHR.responseText);
// 得到正确的结果修改状态并将数据传递出去
resolve(response);
} catch (e) {
reject(e);
}
} else {
// 得到错误结果并抛出错误
reject(new Error(XHR.statusText));
}
}
};
});
}

// 封装好之后,使用就很简单了
getJSON(url).then(function (resp) {
console.log(resp);
// 之后就是处理数据的具体逻辑
});

Promise.all

当有一个 ajax 请求,它的参数需要另外 2 个甚至更多请求都有了结果之后才能确定,那么这个时候,就需要 Promise.all 来帮助我们应该这个场景。

1
var p = Promise.all([p1, p2, p3]);

Promise.all 接收一个由 Promise 对象组成的数组作为参数,当 Promise 对象状态都变成 fulfilled 的时候,才会去调用 then 方法。

如果其中一个 Promise 对象状态变成 rejected,那么 p 的状态就会变成 rejected,第一个被 reject 的实例的返回值会传递给回调函数。

如果作为参数的 Promise 实例,自己定义了 catch 方法,那么它一旦被 rejected,并不会触发 Promise.all() 的 catch() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const p5 = new Promise(function (resolve) {
resolve('hello');
});

const p6 = new Promise(function (resolve, reject) {
throw new Error('报错了');
}).catch((e) => e);

Promise.all([p5, p6])
.then((res) => console.log(res))
.catch((e) => console.log(e));

// 输出结果
// ["hello", Error: 报错了]

Promise.race

与 Promise.all 相似的是,Promise.race 也是接收一个 Promise 对象组成的数组作为参数,不同的是,只要当数组中的一个 Promise 状态变为 fulfilled 或者 rejected 时,就可以调用 .then 方法了。

1
var p = Promise.race([p1, p2, p3]);

Promise.race 可以理解为 Promise 实例赛跑,哪个实例有了状态就返回哪个,通常用于处理规定时间内请求超时的情况。

如果 5 秒之内请求无法返回结果,变量 p 的状态就会变为 rejected,从而触发 catch 方法指定的回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Promise.race([
new Promise((resolve, reject) => {
// 模拟请求,10秒后返回数据
setTimeout(resolve, 10000, 'my name is a');
}),
new Promise(function (resolve, reject) {
// 处理请求超时
setTimeout(reject, 5000, new Error('超时了'));
}),
])
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log('err: ', err);
alert(err);
});

封装

如何封装与使用息息相关。

加载图片

封装一个加载图片的函数,Promise 的使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function imageLoad(url) {
const img = new Image();
img.src = url;

return new Promise(function (resolve, reject) {
img.onload = function () {
resolve('图片加载成功');
};

img.onerror = function () {
reject('图片加载失败');
};
});
}

然后我们就可以使用 imageLoad 来执行图片加载完成之后的逻辑。

1
2
3
4
5
6
7
imageLoad('xxx.png')
.then((res) => {
alert(res);
})
.catch((err) => {
alert(err);
});

封装的核心关键是: Promise 的最终目的是,为了执行 then 中的回调函数,我们称它为 then_cb

所以在封装的时候,我们就应该思考如何在 Promise 内部,让 then_cb执行。

简易版 MyPromise

显而易见,Promise 包含原型方法 then,构造函数需要传递回调函数 executor,该回调函数包含两个参数,resolve 与 reject 。

根据这些特点,我们得出:

1
2
3
4
5
6
7
8
9
10
11
12
class MyPromise {
construcotr (executor) {
executor(this._resolve.bind(this), this._reject.bind(this)
}

_resolve (value) {}

_reject () {}

then (then_cb) {}
}

目的是为了调用 then_cb ,通过封装加载图片可以发现,调用 resolve 时,then_cb 才会执行,所以可以得出结论, then_cb 的执行需要被 resolve 触发

我们可以通过保存 then_cb 引用的方式来解决。所以代码就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyPromise {
constructor(executor) {
this.thenCallback = null;

executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
this.thenCallback(value);
}

_reject(value) {}

then(then_cb) {
this.thenCallback = then_cb;
}
}

同理,再解决 catch 回调函数的执行问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyPromise {
constructor(executor) {
this.thenCallback = null;
this.rejectCallback = null;
executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
this.thenCallback(value);
}

_reject(value) {
this.rejectCallback(value);
}

then(then_cb, onRejected) {
this.thenCallback = then_cb;
this.rejectCallback = onRejected;
}

catch(onRejected) {
this.then(null, onRejected);
}
}

如果不追求别的特性,我们的 Promise 对象就已经封装好了,并且可以使用了。

1
2
3
4
5
6
7
8
9
10
11
12
const p = new MyPromise((resolve, reject) => {
setTimeout(() => {
// resolve('123')
reject('some err');
}, 1000);
});

// p.then(res => {
// console.log(res);
// })

p.catch((err) => [console.log('err', err)]);

再模拟将 then_cb 放入队列中执行,简单调整如下:

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
class MyPromise {
constructor(executor) {
this.thenCallback = null;
this.rejectCallback = null;
executor(this._resolve.bind(this), this._reject.bind(this));
}

_resolve(value) {
// this.thenCallback(value)
setTimeout(() => {
this.thenCallback(value);
}, 0);
}

_reject(value) {
// this.rejectCallback(value)
setTimeout(() => {
this.rejectCallback(value);
}, 0);
}

then(then_cb, onRejected) {
this.thenCallback = then_cb;
this.rejectCallback = onRejected;
}

catch(onRejected) {
this.then(null, onRejected);
}
}

加入队列机制后,就可以在 executor 中直接执行 resolve ,否则会报错 this.thenCallback is not a function

1
2
3
4
5
6
7
const p = new MyPromise((resolve, reject) => {
resolve('123');
});

p.then((res) => {
console.log(res);
});

Promise.all

Promise.all 返回的是一个数组,我们要将参数 array 内的每个 Promise 的执行结果放在一个数组 result 里,并且 result 数组成员的顺序要与传入时的 array 成员顺序保持一一对应。

实现 Promise.all 的重点在于对 all 参数内 Promise 实例全部执行完毕时机的判断。

因为 Promise 是异步的,我们不能保证 Promise 实例完成的时机与数组顺序一样。也就是说我们不能使用数组的 length 属性来表达 Promise 全都执行完毕。

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
const delay = (data) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(data);
}, Math.random() * 1000);
});
};

Promise._all = (array) => {
return new Promise((resolve, reject) => {
let count = 0;
const result = [];
for (let i = 0, len = array.length; i < len; i++) {
array[i].then((data) => {
result[i] = data;
count++;
// 因为 array[i] 的执行是异步的,所以这种判断是错误的
// 如果 i===2 的 promise 先执行完毕,result[2] 导致 result.length === 3
if (result.length === array.length) {
resolve(result);
}
}, reject);
}
});
};

const p1 = delay(1);
const p2 = delay(2);
const p3 = delay(3);
Promise._all([delay(2), delay(1), delay(3)]).then(
(res) => {
console.log('res: ', res); // [2, 1, 3]
},
(err) => {
console.log('err: ', err);
},
);

上面的代码,如果最后一个 Promise 先执行完毕,赋值时 result[2] = data,那么 result.length等于 3, 满足 result.length === array.length的判断条件,就会提前执行 resolve 。

所以,我们可以参考垃圾回收机制的引用计数法,在内部添加一个计数变量 count 来判断是否所有 Promise 都已执行完毕。当 count 等于传入的 Promise 数组长度时,表示所有 Promise 都有了结果,然后我们再执行 resolve 将结果传递出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Promise._all = (array) => {
return new Promise((resolve, reject) => {
let count = 0;
const result = [];
for (let i = 0, len = array.length; i < len; i++) {
array[i].then((data) => {
result[i] = data;
count++;
// 因为array[i]的执行是异步的,所以这种判断是错误的
// 如果i===2的promise先执行完毕,result[2]导致result.length === 3
// if (result.length === array.length) {
// resolve(result)
// }
if (count === array.length) {
resolve(result);
}
}, reject);
}
});
};

async/await

异步问题除了使用 Promise 来解决之外,还可以使用 ES7 中新增的语法 async/await 来搞定。

在函数声明前面,加上关键字 async,这就是 async 的具体使用了。

1
2
3
4
5
6
7
8
async function fn() {
return 30;
}

// 或者
const fn = async () => {
return 30;
};

然后我们查看一下 fn 的运行结果

1
2
3
4
5
6
7
8
console.log(fn());

// result
Promise = {
__proto__: Promise,
[[PromiseStatus]]: 'resolved',
[[PromiseValue]]: 30,
};

发现 fn 函数运行返回的是一个标准的 Promise 对象。也就是说 async 其实就是 Promise 的一个语法糖,目的是为了让写法更加简单。于是,我们可以使用 Promise 的相关语法来处理后续的逻辑

1
2
3
fn().then((res) => {
console.log(res); // 30
});

await 含义为等待,意思就是需要等待 await 后面函数运行完了,并且有了返回结果,才能继续执行下面的代码。这正是同步的效果。

需要注意的是,await 关键字只能在 async 函数中使用。并且 await 后面的函数运行后必须返回一个 Promise 对象才能实现同步的效果。

当我们使用一个变量去接收 await 的返回值时,该返回值为 Promise 中 resolve 传递出来的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function fn() {
return new Promise((resolve, reject) => {
setTimeout(resolve, 1000, 30);
});
}

const foo = async () => {
const t = await fn();
console.log('t', t);
console.log('next code');
};

foo();

// 输出结果
// t: 30
// next code

从例子中我们可以看出,在 async 函数中,遇到 await 时,就会等待 await 后面的函数运行完毕,而不会直接执行 next code。

如果我们直接使用 then 方法,就不得不把后续的逻辑写在 then 方法中。

1
2
3
4
5
6
7
const foo = () => {
return fn().then((t) => {
console.log('t: ', t);
console.log('next doce');
});
};
foo();

很明显,如果使用 async/await 的话,代码会更简洁,逻辑也更清晰。

错误处理

  • 在 Promise 中,我们使用 .catch 方法来捕获错误
  • 而使用 async 时,我们使用 try/catch 来捕获错误
    • 如果有多个 await ,只会捕获到第一个错误
      :::info
      tyr/catch 除了可以捕获使用 async 时 Promise 抛出的错误,还可以捕获 throw 关键字抛出的错误。
      :::

在 Promise 中,我们使用 .catch 方法来捕获错误,而不是使用 .then 的第二个参数。因为:

  1. 更接近同步的语法(try/catch)
  2. then 中的错误也会被 catch 捕获

使用 then 的第二个参数,并不能捕获到第一个参数内部抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const p3 = new Promise(function (resolve, reject) {
setTimeout(() => {
resolve(123);
}, 1000);
});
p3.then(
(result) => {
console.log(result);
throw new Error('is err');
},
(error) => {
console.log('error', error);
},
);

// 输出结果
// 123
// Uncaught (in promise) Error: is err

使用 catch ,则可以捕获到上一个 then 内部抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const p3 = new Promise(function (resolve, reject) {
setTimeout(() => {
resolve(123);
}, 1000);
});
p3.then((result) => {
console.log(result);
throw new Error('is err');
}).catch((error) => {
console.log('error', error);
});

// 输出结果
// 123
// error Error: is err

而使用 async 时,我们使用 try/catch 来捕获错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fn() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error');
}, 1000);
});
}

const foo = async () => {
try {
await fn();
} catch (e) {
console.log('e: ', e);
}
};
foo();

如果有多个 await ,只会捕获到第一个错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fn1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error fn1');
}, 1000);
});
}
function fn2() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('some error fn2');
}, 1000);
});
}

const foo = async () => {
try {
await fn1();
await fn2();
} catch (e) {
console.log('e: ', e); // some error fn1
}
};
foo();

Generator 函数

Generator 函数可以认为是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。

1
2
3
4
5
6
7
8
function* gen(x) {
var y = yield x + 2;
return y;
}

var g = gen(1);
g.next(); // { value: 3, done: false }
g.next(); // { value: undefined, done: true }

Generator 函数名前要加*号,调用 Generator 函数,会返回一个指针对象(即遍历器)。调用指针对象的 next 方法,会移动内布指针,指向第一个遇到的 yield 语句。

也就是说,next 的作用是分段执行 Generator 函数。每次调用 next 方法,都会返回一个对象,表示当前阶段的信息。

value 属性是 yield 语句后面表达式的值,done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。

错误处理

使用 try catch 捕获函数体外抛出的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14

function* gen(x){
try {
var y = yield x + 2;
} catch (e){
console.log(e);
}
return y;
}

var g = gen(1);
g.next();
g.throw'出错了');
// 出错了

async

从 Promise 对象,再到 Generator,异步解决方案每次都有所改进,很多人认为 async 函数是异步操作的终极解决方案。
async 函数就是 Generator 函数的语法糖。
async 函数就是将 Generator 函数的*号替换成 async,将 yield 替换成 await。

1
2
3
4
5
6
7
8
9
var gen = function* () {
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
};

var asyncReadFile = async function () {
var f1 = await readFile('/etc/fstab');
var f2 = await readFile('/etc/shells');
};

async 函数对 Generator 函数的改进,体现在一下三点:

  1. 内置执行器。Generator 函数的执行必须依靠执行器。
  2. 更好的语义。async 和 await,比起星号和 yield,语义更清楚。
  3. 更广的适用性。await 命令后面可以跟 Promise 对象和原始类型的值(数值、字符串、布尔值),Generator 函数只能是 thunk 函数或 Promise 对象。

封装的理解

构造函数用于初始化实例对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const p1 = {
name: '张三',
age: 20,
run: function () {
console.log('调用此方法张三就开始奔跑');
},
};

const p2 = {
name: '李四',
age: 22,
run: function () {
console.log('调用此方法李四就开始奔跑');
},
};

const p3 = {
name: '王五',
age: 21,
run: function () {
console.log('调用此方法王五就开始奔跑');
},
};

这种场景很常见,每个对象都有nameage属性和run方法,我们可以用一个函数封装一下。

封装

封装的目的是为了减少重复代码的编写。

提取共性,将不同点作为参数传入。可以如下封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function CreatePerson(name, age) {
this.name = name;
this.age = age;
this.run = function () {
console.log(`调用此方法${this.name}就会开始奔跑`);
};
}

const p1 = new CreatePerson('张三', 20);
const p2 = new CreatePerson('李四', 30);
const p3 = new CreatePerson('王五', 40);
p1.run();
p2.run();
p3.run();

这种方式有个问题,就是每次使用 CreatePerson 函数创建对象时,run 方法被反复创建了多次。同样的内容,占据了多份内存空间。

原型对象
属于所有实例共享的属性和方法,抽离出来放在原型对象中,而每个实例特有的属性和方法,都会留在构造函数中
例如,每一个实例的名字,名字是每个实例特有的,不可能被共享。

将 run 方法挂载到原型对象上,这样就可以实现一次创建,被多个实例对象共同使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function CreatePerson(name, age) {
this.name = name;
this.age = age;
// this.run = function () {
// console.log(`调用此方法${this.name}就会开始奔跑`)
// }
}

CreatePerson.prototype.run = function () {
console.log(`调用此方法${this.name}就会开始奔跑`);
};

const p1 = new CreatePerson('张三', 20);
const p2 = new CreatePerson('李四', 30);
const p3 = new CreatePerson('王五', 40);
p1.run();
p2.run();
p3.run();

new 关键字都干了什么?

  1. 创建一个新对象,该对象为最终返回的实例
  2. 将新对象的原型对象指向构造函数的原型对象
  3. 将构造函数内部的 this 指向这个新对象,即为实例
  4. 如果构造函数明确返回了对象或函数,那么这个返回值取代第一部的新对象

实现一个 new

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
function myNew(Fn, ...args) {
// 1.创建一个新对象,该对象为最终返回的实例
let res = {};

// 2.将新对象的原型执行构造函数的原型
if (Fn.prototype !== null) {
// res.__proto__ = Fn.prototype
Object.setPrototypeOf(res, Fn.prototype);
}

// 3.将构造函数的 this 指向新对象
ret = Fn.apply(res, args);

// 4.如果函数返回了对象或者函数,那么这个返回值取代第一部创建的新对象
if ((typeof ret === 'Object' || typeof ret === 'function') && ret !== null) {
return ret;
}

return res;
}

const p1 = myNew(CreatePerson, '张三', 20);
const p2 = myNew(CreatePerson, '李四', 30);
const p3 = myNew(CreatePerson, '王五', 40);
p1.run();
p2.run();
p3.run();

小结:构造函数的返回值为基本类型,其返回值是实例化后的对象。构造函数的返回值为引用类型,其返回值即为 new 之后的返回值。

构造函数和原型对象的访问优先级
如果在构造函数和原型对象中,同时声明了属性/方法,那么会优先访问构造函数中的属性/方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function CreatePerson(name, age) {
this.name = name;
this.age = age;
this.run = function () {
console.log(`构造函数中的:调用此方法${this.name}就会开始奔跑`);
};
}

CreatePerson.prototype.run = function () {
console.log(`调用此方法${this.name}就会开始奔跑`);
};

const p1 = new CreatePerson('张三', 20);
const p2 = new CreatePerson('李四', 30);
const p3 = new CreatePerson('王五', 40);
p1.run(); // 构造函数中的:调用此方法张三就会开始奔跑
p2.run(); // 构造函数中的:调用此方法李四就会开始奔跑
p3.run();

柯里化

定义:柯里化是这么一个函数,他接收一个函数 A 为参数,运行后能够返回一个新函数,这个新函数能够处理函数 A 的剩余参数。

配合例子理解这个定义

假如有个一接收三个参数的函数 A

1
2
3
function A(a, b, c) {
// do something
}

同时还要一个封装好的柯里化通用函数 createCurry 。他接收 A 为参数,能够将 A 转化为柯里化函数,返回结果就是这个转化之后的函数。

1
var _A = createCurry(A);

那么 _A 作为 createCurry 运行的返回函数,他能够处理 A 的剩余参数。因此下面的执行结果是等价的。

1
2
3
4
5
_A(1, 2, 3);
_A(1, 2)(3);
_A(1)(2, 3);
_A(1)(2)(3);
A(1, 2, 3);

_A 能够处理 A 的所有剩余参数。因为柯里化也可以被称为部分求值。

在简单的场景下,可以不借用柯里化通用式得到柯里化函数,比如一个简单的加法函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function add(a, b, c) {
return a + b + c;
}

function _add(a) {
return function (b) {
return function (c) {
return a + b + c;
};
};
}

add(1, 2, 3);
_add(1)(2)(3);

柯里化的实现思路:
柯里化函数的运行过程其实是一个参数的收集过程,每次将传入的参数收集起来,在最里层处理。

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
// arity 用于标记剩余参数的个数
// args 用于收集参数
function createCurry(func, arity, args) {
// 第一次执行,剩余参数为函数的参数各位
var arity = arity || func.length;

// 第一次执行,args 为空
var args = args || [];

// 定义一个函数
var wrapper = function () {
// 将wrapper中的参数收集到args中
var _args = [].slice.call(arguments);
[].push.apply(args, _args);

// 如果收集到的参数小于最初的func.length,则递归调用,继续收集参数
if (_args.length < arity) {
arity -= _args.length;
return createCurry(func, arity, args);
}

// 参数收集完毕,则执行func
return func.apply(func, args);
};

// 返回这个函数
return wrapper;
}

柯里化确实把简单的问题复杂化了,但复杂化的同时,我们在使用函数时拥有了更多的自由度。也就是说,柯里化的核心,就是对于函数参数的自由处理。

举一个非常常见的例子

验证一串数字是否是正确的手机号,按照普通的思路,可能如下封装

1
2
3
function checkPhone(phoneNumber) {
return /^1[34578]\d{9}&/.test(phoneNumber);
}

如果想要验证一个邮箱,可能如下封装

1
2
3
function checkEmail(email) {
return /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/.test(email);
}

除此之外,可能还有更多的封装逻辑,因此在实践中,为了统一逻辑,我们会封装一个更为通用的函数

1
2
3
function check(reg, targetString) {
return reg.test(targetString);
}

但是这样封装之后,在使用时又会稍微麻烦一点,因为总是输入一串正则,这样就导致使用效率低下,并且容易出错

1
2
check(/^1[34578]\d{9}$/, '14900000088');
check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@163.com');

那么这个时候,我们可以借助柯里化,在 check 的基础上,再做一层封装,以简化使用

1
2
3
4
var _check = createCurry(check);

var checkPhone = _check(/^1[34578]\d{9}$/);
var checkEmail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);

最后在使用的时候就会变得更加直观与简洁了

1
2
checkPhone('183888888');
checkEmail('xxxxx@test.com');

通过这个例子可以发现,柯里化能够应付更加复杂的逻辑封装。

虽然柯里化确实在一定程度上将问题复杂化了,也让代码更加不容易理解。但是柯里化在面对复杂情况下的灵活性却让我们不得不爱。

模块化的理解

pic.1708326768564

什么是模块化

模块化,就是把一个大的问题,化解为小的问题在这种思维下提炼出来的工程化解决方案。

核心思想

核心思想就是隔离。要有自己的内部属性,内部方法,以及自己来决定哪些属性与方法能够被其他模块访问。

模块的设计目的是什么?

有了模块,我们可以更方便的使用别人的代码,想要什么功能,就加载什么模块。

什么是 js 模块?

在 es6 之前,JavaScript 没有官方规范,主要采用 CommonJS 和 AMD 这两种规范。

无模块化

函数 -> 命名空间 -> 立即执行函数

CommonJS 规范

nodeJS 采用的就是 CommonJS 规范,运行在 nodeJS 中的规范。

CommonJS 的加载方式称为运行时加载,以下代码的实质是加载整个fs对象,再从fs对象上读取这三个方法。

1
2
3
4
5
6
7
8
// CommonJS模块
let { stat, exists, readfile } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

require()

在 node 源码中有一个 module.js 文件,这个文件实现了 node 的整个模块加载系统。

1
2
3
4
5
6
7
// module.js

function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
//...

我们在 nodeJS 中使用的 require()方法调用的就是 module.require 方法。具体可参考 Node.js 模块加载机制 Require()

注意:node 中使用的**_require()_**和 RequireJS 是两个东西。

使用

主要有 module、exports、require。

导出:module

module.exports 由 Module 对象创建。如果我们想导出一个对象

1
2
3
4
5
// a-common.js(导出)
module.exports = { foo: 'bar' };

或者;
module.exports.foo = 'bar';

exports 是 module.exports 的一个引用,所以我们可以使用

1
2
// ✅
exports.foo = 'bar';

错误使用,这相当于重新定义了 exports

1
2
// ❎
exports = {};

导入:require

1
2
3
4
var o = require('./a-common.js');

使用;
console.log(o.foo);

优缺点

优点:
解决了依赖、全局变量污染的问题

缺点:
因为 nodeJS 是运行在服务端,服务端所有的模块是放在本地的,模块的加载速度就是硬盘的读取速度,是同步的。但是浏览器的模块是放在服务器端,如果一个模块过大,浏览器会处于一种假死状态。

因此,浏览器端的模块不能采用”同步加载”,只能采用”异步加载”。所以 AMD 规范诞生了。

AMD 规范

AMD 是”Asynchronous Module Definition”的缩写,意思是”异步模块定义“。模块的加载不影响后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等模块加载完毕,回调函数才会执行。
define 和 require 就是 require.js 在全局注入的函数。

AMD 规范主要由 require.jscurl.js 这两个 JavaScript 库实现。
在网页中嵌入 require.js,就可以进行模块化编程了。

1
<script data-main="scripts/main" src="scripts/require.js"></script>

定义模块

使用 define 方法

1
2
3
4
5
define('A', ['require', 'exports'], function (require, exports) {
exports.fun = function () {
return require('B').fun;
};
});

导入模块

AMD 也是采用 require()语句加载模块,require([module], callback)

1
2
3
require(['math'], function (math) {
math.add(2, 3);
});

注意,不要与 NodeJS 中的 require()混淆。CommonJS 中的 require 是 NodeJS 源代码 module.js 文件中定义的方法,AMD 中的 require()是由 RequireJs 实现的。

CMD

CMD,全称 Common Module Definition,它整合了 CommonJS 和 AMD 规范的特点。
主要区别为一下两点:

  1. CMD 可以同步加载模块,也可以异步加载,而 AMD 需要异步加载模块
  2. CMD 遵循依赖就近原则,而 AMD 遵循依赖前置原则。也就是说 CMD 在使用模块前,引入模块即可,而 AMD 需要把所有的依赖提前放在依赖数组中。

UMD

umd,全称 Universal Module Definition,它允许在环境中同时使用 AMD 规范和 CommonJS 规范。

ES Module

module 可完全取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的解决方案。
ES Module 的设计思想是静态化,也就是说在编译时就确定了模块的依赖关系,而 CommonJS 和 AMD 是在运行时确定,这是 EM 模块和其他模块最显著的差别;第二个差别是 ES6 模块输出的是值的引用,而 CommonJS 输出的是值的拷贝。

ES6 模块的加载方式称为“编译时加载”,在编译时就完成了加载,效率要比 CommonJS 和 AMD 的“运行时加载”效率高。

1
2
// ES6模块
import { stat, exists, readFile } from 'fs';

以上代码只会从fs中加载这 3 个方法,其他方法不会加载。

引入模块

1
2
3
// test.js

console.log('my name is test.js');
1
2
3
4
// index.js
import test from './test';

console.log('test: ', test);
  • import 表示引入一个模块
  • test 可理解为要引入模块的名字
  • from 表示从哪里引入
  • ‘./test’ 为 ‘./test.js’ 的简写,表示将要引入的模块的路径

引入这个模块的同时,'./test'里的代码也执行了。因为 test.js 没有对外暴露接口,所以 test 为空对象。
pic.1708326785878

对外提供接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('my name is test.js');

const name = 1;
const obj = { a: 1 };
const arr = [1, 2, 3];
const fn = () => {
console.log('fn');
};

export default {
name,
obj,
arr,
fn,
};

使用 export default 对外暴露一个对象。当使用import test from './test'时,test 就是这个暴露的对象
pic.1708326805238

我们还可以仅通过 export 暴露几个方法和属性,在 test 中添加以下代码,看看 index.js 中的 test 会发生什么变化

1
2
3
4
5
6
// test.js

export const age = 20;
export const bar = () => {
console.log('bar');
};

结果发现 test 并没有什么变化,因为它仅仅等于export defaule所暴露的对象

如果想要获取 test.js 对外暴露的所有接口,可通过如下模式获取

1
import * as test from './test';

*号表示所有,是比较常用的通配符,as 表示别名。* as test 表示将 test.js 对外暴露的所有接口组成的对象,命名为 test。
pic.1708326814698

1
2
3
4
5
6
7
import test, { age, bar } from './test';

// 等于以下写法

import test from './test';
import * as allTest from './test';
const { age, bar } = allTest;

这种写法非常常见,test 仍表示为export default 所暴露的对象,{ age, bar } 表示用解构的语法从返回的整个对象中取对应的接口。结果显而易见
pic.1708326822951

在 react,这种使用方法很常见,我们能根据导入语法,就知道 react 内部是如何封装接口的

1
2
3
4
5
6
import { lazy } from 'react'

// 对应的导出
expoet default {
lazy: () => {}
}

还有另一种写法:

1
2
3
4
5
6
export { default as RightMain } from './right-main';

// import RightMain from './right-main';
// export {
// RightMain
// }

参考:
https://developer.aliyun.com/article/113478

深浅拷贝

Object.assign

从一个或多个源对象分配到目标对象,返回目标对象

1
2
3
4
5
6
7
8
9
10
11
12
let obj1 = { a: { b: 1 }, sym: Symbol(1) };
Object.defineProperty(obj1, 'innumerable', {
value: '不可枚举属性',
enumerable: false,
});
let obj2 = {};
Object.assign(obj2, obj1);

obj1.a.b = 2;

console.log({ obj1 }); // { a: { b: 2 }, sym: Symbol(1), innumerable: "不可枚举属性" }
console.log({ obj2 }); // { a: { b: 2 }, sym: Symbol(1) }
  • 不会拷贝不可枚举属性
  • 可以拷贝 symbol 类型的属性
  • 他不会拷贝对象的继承属性

… 扩展运算符

Object.assign拥有同样的缺陷,但是拷贝基本类型的值的时候会更加方便

concat

用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组

slice

浅拷贝数组,仅仅针对数组类型。
类数组可以使用 Object.prototype.slice.call(arr),表示从 Array 中获取 slice 方法,绑定到 arr 中,并执行 slice。

测试:
array1cloneArray1第一项的 name 属性都变为’wangwu’

1
2
3
4
5
6
7
8
9
10
11
const array1 = [
{ id: 1, name: 'zhangsan' },
{ id: 2, name: 'lisi' },
];
// const cloneArray1 = [...array1]
// const cloneArray1 = Object.assign(array1)
// const cloneArray1 = array1.concat([])
const cloneArray1 = array1.slice();
cloneArray1[0].name = 'wangwu';
console.log('array1', array1);
console.log('cloneArray1', cloneArray1);

浅拷贝的实现

1
2
3
4
5
6
7
8
9
10
11
function shallowClone(obj) {
if (typeof obj !== 'object' || target === null) return;
const newObj = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
// 忽略原型上继承过来的属性
newObj[key] = obj[key];
}
}
return newObj;
}

深拷贝的实现

乞丐版:JSON.stringify

将对象序列化为 JSON 字符串,对对象里面的内容转为字符串,再用 JSON.parse 生成新对象。
注意点:

  • 如果对象”值”中有函数、undefined、symbol 这几种类型,经过序列化之后这个键值对会消失
  • 拷贝 Date 引用各类型会变成字符串
  • 拷贝 RegExp 引用类型会变成空对象
  • 无法拷贝不可枚举属性
  • 无法拷贝对象的原型链
  • 无法拷贝对象的循环应用,即对象成环(obj[key] = obj)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Obj() {
this.func = function () {
alert(1);
};
this.obj = { a: 1 };
this.arr = [1, 2, 3];
this.und = undefined;
this.reg = /123/;
this.date = new Date(0);
this.NaN = NaN;
this.infinity = Infinity;
this.sym = Symbol(1);
this[Symbol(2)] = 'sym2';
}

let obj1 = new Obj();

Object.defineProperty(obj1, 'innumerable', {
enumerable: false,
value: 'innumerable',
});
console.log('obj1', obj1);
let obj22 = JSON.parse(JSON.stringify(obj1));
console.log('obj22', obj22);

pic.1708326973863

基础版

注意点:

  • 不能复制不可枚举和 Symbol 类型的属性
  • 对于 Date、RegExp 这样的引用类型不能正确的拷贝
  • 没解决循环引用问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function deepClone(target) {
if (typeof target === 'object' && target !== null) {
const newObj = Array.isArray(target) ? [] : {};
for (const key in target) {
if (typeof target[key] === 'object') {
newObj[key] = deepClone(target[key]);
} else {
newObj[key] = target[key];
}
}
return newObj;
}
}

const obj3 = deepClone(obj1);
console.log('obj3', obj3);

pic.1708326963610

改进版

  • 对于不可枚举和 Symbol 类型,可以 Reflect.ownKeys(),Reflect.ownKeys === Object.getOwnPropertyNames(obj).concat(Object.getOwnPropertySymbols(Obj))
  • 对于 Date 和 RegExp 类型,直接返回一个实例对象
  • Object.getOwnPropertyDescriptors 可以获取所有自身属性的描述符,以及对应的特性,顺便结合 Object 的 create 方法创建一个新对象,并集成原对象的原型链
  • 利用 WeakMap 类型作为 hash 表,因为 WeakMap 是弱引用类型,可以有效防止内存泄漏,如果存在循环引用,就直接返回 WeakMap 存储的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const isComplexDataType = (obj) =>
(typeof obj === 'object' || typeof obj === 'function') && obj !== null;

function deepCloneImprove(obj, hash = new WeakMap()) {
if (obj.constructor === Date) return new Date(obj); // 日期对象直接返回一个新的日期对象
if (obj.constructor === RegExp) return new RegExp(obj); //正则对象直接返回一个新的正则对象
//如果循环引用了就用 weakMap 来解决
if (hash.has(obj)) return hash.get(obj);
let allDesc = Object.getOwnPropertyDescriptors(obj);
//遍历传入参数所有键的特性
let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc);
//继承原型链
hash.set(obj, cloneObj);

for (let key of Reflect.ownKeys(obj)) {
cloneObj[key] =
isComplexDataType(obj[key]) && typeof obj[key] !== 'function'
? deepCloneImprove(obj[key], hash)
: obj[key];
}
return cloneObj;
}

验证代码

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
let obj4 = {
num: 0,
str: '',
boolean: true,
unf: undefined,
nul: null,
obj: { name: '我是一个对象', id: 1 },
arr: [0, 1, 2],
func: function () {
console.log('我是一个函数');
},
date: new Date(0),
reg: new RegExp('/我是一个正则/ig'),
[Symbol('1')]: 1,
};
Object.defineProperty(obj4, 'innumerable', {
enumerable: false,
value: '不可枚举属性',
});
obj4 = Object.create(obj4, Object.getOwnPropertyDescriptors(obj4));
obj4.loop = obj4; // 设置loop成循环引用的属性
let cloneObj = deepCloneImprove(obj4);
cloneObj.arr.push(4);
console.log('obj4', obj4);
console.log('cloneObj', cloneObj);

pic.1708326948006

闭包

本质: 局部数据共享

没有任何一个技术方案可以做到局部数据共享。那么如何局部共享呢?
可以创建一个函数作用域,在这个作用域内共享数据:

1
2
3
4
5
6
7
8
9
10
11
function p () {
var a = 10

funtion foo () {
console.log(a)
}

funtion bar () {
console.log(a)
}
}

p 函数的局部变量 a,被 foo 和 bar 这两个函数共享

定义: 闭包是一个特殊的对象。它由两部分组成,执行上下文 A 以及在 A 中创建的函数 B。当 B 执行时,如果访问了 A 中的变量,那么闭包就产生了。

在 chrome 中,执行上下文 A 的函数名代指闭包

词法作用域:在我们编写代码时,语法规范就已经确定了词法作用域的作用范围。

词法作用域是为了明确告诉我们,当前的上下文环境中,能够访问哪些变量参与程序的运行。除了自身上下文,还可以从函数体的[[Scopes]]属性访问其他作用域的声明。

闭包形成的原因:

闭包是基于词法作用域的规则产生,让函数内部可以访问函数外部的声明。闭包在代码解析时就能确定。

闭包对象什么时候被回收

闭包对象[Closure(A)]的引用存在于函数体 B 的内存中。如果 B 函数体被回收,闭包对象同样也被回收。

看个例子:

1
2
3
4
5
6
7
8
var fn = function () {
var a = 1;
(function f() {
console.log(++a); // 这时候闭包产生了,
})();
console.log(a); //运行到这里是,f的执行上下文被销毁,闭包也消失了。此时fn中的a变成了2
};
fn(); // 等fn执行完,fn的执行上下文也被销毁

pic.1708325490489
内部函数f执行完毕,其执行上下文被回收,闭包对象也随之被回收。
pic.1708325496558

保存闭包对象不被回收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function foo() {
var a = 20;

function bar() {
a = a + 1;
var b = 10;
console.log(a + b);
}

return bar;
}

foo()(); // 31
foo()(); // 31

foo 调用完毕,其执行上下文被回收,bar 作为 foo 的一部分,自然也被回收,那么保存在 foo.[[Scipes]] 上的闭包对象自然也被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo() {
var a = 20;

function bar() {
a = a + 1;
var b = 10;
console.log(a + b);
}

return bar;
}

// foo()()
// foo()()

var bar = foo();
bar(); // 31
bar(); // 32

那么如何保存这个闭包对象不被回收呢?我们知道垃圾回收机制会通过根搜索算法回收非活跃的对象,那只需要在当前执行上下文中,保存内部函数的引用,闭包对象就不会被回收。

当 foo 执行完毕时,foo 依然会被回收,但是由于执行了var bar = foo(),内部函数 bar 有了新的方式保存引用,所以即使 foo 执行完毕,bar 也不会被回收,而是在内存中持续存在,那么闭包对象 Closure[foo]也就不会被回收。

闭包的应用

常用面试题

表现为 setTimeout 第二个参数为 1-5,定时器在循环结束后隔秒输出 6

1
2
3
4
5
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

用自执行函数改造-隔秒一次输出 1-5
自执行函数与 timer 形成闭包,所以 i 被保存了下来

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
(function (i) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
})(i);
}

pic.1708325509698
可以看到,循环执行完毕后,还能访问到 i,此时 i 的值为保存在闭包对象的值。

进一步改造,缩小约束范围,只约束 timer,瞬间输出 1-5

1
2
3
4
5
6
7
8
for (var i = 1; i <= 5; i++) {
setTimeout(
(function timer(i) {
console.log(i);
})(i),
i * 1000,
);
}

单例模式与闭包

单例模式,就是只有一个实例的对象
对象字面量的方法,就是一个单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
var per = {
name: 'Jake',
age: 20,
getName: function () {
return this.name;
},
getAge: function () {
return this.age;
},
};

per.name = 'Tom';
console.log(per.getName());

这样的单例存在一个问题,属性可以被外部修改。
我们希望拥有自己的私有方法/属性,所以可以利用自执行函数的作用域进行隔离。

1
2
3
4
5
6
7
8
9
10
11
12
13
var per = (function () {
var name = 'Jake';
var age = 20;
return {
getName: function () {
return name;
},

getAge: function () {
return age;
},
};
})();

这样内部修改就不会被修改,我们可以控制对外提供的属性和方法
但是还有一个小问题,这种方式一开始就被初始化了。

改造一下,仅在调用时初始化

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
var per = (function () {
var instance = null; // 保存实例
var name = 'Jake';
var age = 20;

function initial() {
return {
getName: function () {
return name;
},
getAge: function () {
return age;
},
};
}

return {
getInstance: function () {
if (!instance) {
instance = initial();
}
return instance;
},
};
})();

var per1 = per.getInstance();
var per2 = per.getInstance();

console.log(per1 === per2); // true

模块化与闭包

为了减少全局变量的使用,可用模块化的思维解决需要使用全局变量的问题

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
78
79
80
81
82
83
84
85
// 使用单例模式,创建一个用于管理全局状态的模块
var module_status = (function module_status() {
var status = {
number: 0,
color: null,
};

var get = function (prop) {
return status[prop]; // 访问闭包对象 Closure(module_status) 的属性
};

var set = function (prop, value) {
status[prop] = value; // 设置闭包对象 Closure(module_status) 的属性
};

return {
get: get,
set: set,
};
})();

// 负责改变颜色的模块
var module_color = (function module_color() {
// 假装用这种方式引入模块 类似于 import state from 'module_status';
var state = module_status;
var colors = ['orange', '#ccc', 'pink'];

function render() {
var color = colors[state.get('number') % 3]; // 访问闭包对象 Closure(module_status) 的 number 属性
document.body.style.backgroundColor = color;
}

return {
render: render,
};
})();

// 负责显示当前number的模块
var module_context = (function module_context() {
var state = module_status;

function render() {
console.log('this Number is '); // 小提示(与主逻辑无关): render[[scopes]]]作用域信息在解析这个函数时就已经确定了。执行到这里时 Closure(module_context) 就已经产生了
document.body.innerHTML = 'this Number is ' + state.get('number'); // 执行闭包对象 Closure(module_status) 的 get 方法,
}

return {
render: render,
};
})();

// 主模块
var module_main = (function module_main() {
var state = module_status; // 保存 module_status 模块返回的字面量对象的引用
var color = module_color;
var context = module_context;

var newNumber = state.get('number') + 1; // 访问闭包对象 Closure(module_status) 的 number 属性
// state.get() 执行完毕,正常情况下闭包对象 Closure(module_status) 随内部函数的执行完毕而被回收。
// 但是这里将闭包对象的引用地址保存在当前执行上下文中,所以闭包对象 Closure(module_status) 并没有被销毁
state.set('number', newNumber); // 改变闭包对象 Closure(module_status) 的number属性,number == 1

context.render(); // 43行访问的闭包对象于77行改变的闭包对象是同一个对象
color.render();
})();

// 注意,module_status 的引用一直存在全局对象中没被释放,所以执行 module_status 时,还能访问到闭包对象Closure(module_status),以为输出1
console.log('-------', module_status.get('number'));

// 主模块
// var module_main = (function () {
// var state = module_status;
// var color = module_color;
// var context = module_context;

// setInterval(function () { // 这里使用定时器实现切换固定颜色的功能
// var newNumber = state.get('number') + 1;
// state.set('number', newNumber);

// color.render();
// context.render();

// console.log(1);
// }, 1000);
// })();

其他应用场景

使用其他模块暴露的接口

utils/index.js

1
export const a = 1;
1
2
3
4
5
6
7
import { a } from '../../../utils';

const Closure = () => {
console.log(a);
return <div>{a}</div>;
};
export default Closure;

在函数组件 Closure 中访问了 utils 模块中的变量 a,那么闭包对象就产生了。
pic.1708325527266

父子组件通信

pic.1708325535782
<Child />组件中执行foo时,闭包对象Closure(MyClosure)产生了。

hooks 缓存上次结果

本质是将上次的结果(闭包对象)缓存在函数体内
pic.1708325542236
另外还有以下关于闭包的使用

  • 高阶函数: 记忆函数
  • 状态管理:redux、mbox
  • 函数封装
  • BEM
  • vue scoped
  • css in js:借 js 的作用域,生成 css 作用域
  • css modules

闭包的优缺点

优点:

  1. 可以重复使用闭包对象内的变量,并且不会造成变量污染
  2. 可以用来定义私有属性和私有方法(单例模式与闭包)

缺点:

  1. 可能会导致堆内存消耗过大

闭包对象的回收

  • 如果是全局变量保存了内部函数的引用,如果没有手动释放这个对象,闭包对象会一直存在堆内存中
  • 如果是函数内的一个局部变量保存了内部函数的引用,函数出栈后如果没有引用了,闭包对象等待下一次垃圾回收器回收
Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×