z轴的学习规则

html 元素沿着相对其用户的一条虚构的 z 轴排开,层叠上下文就是对这些 html 元素的三维构想。
pic.1708406510231

背景

在 z 轴上,存在层叠上下文,他们有着遮挡关系的先后顺序,我们可以称之为层叠顺序,学习层叠上下文主要就是学习层叠顺序。在学习前,我们先来了解背景,我们试想一下,页面上为什么需要有 z 轴的存在?

举个例子:
常见的“顶部固定不动,内容区域向上滚动”的效果,我们给顶部设置position: fixed,内容区域设置overflow: auto。假设我们的内容在第一层,那顶部就在第一层之上,它不随第一层的滚动而滚动,因为 z 轴上的每一层都是相互独立的,这样轻而易举的达到了我们想要的效果。

如果不存在 z 轴,他们处在同一层,有可能实现这种效果吗?答案是有。内容向上滚动的同时,滚了多少距离,我们的顶部就向下滚动多少距离,这也可以实现相同的效果。但是这个过程的计算过程巨复杂,它使不变的元素(顶部)也进行了重新的计算,那么 GUI 渲染线程会重新计算 render tree,然后重新绘制页面元素。这将导致我们的页面性能非常差。

再举个常见的例子,比如视差、canvas,也是同个道理,都是为了更少的计算,让不变的保持不变。而这背后的原理,就是为了迎合浏览器的规则,减少回流。

所以答案就是:z 轴的存在,其实是网页性能优化的一种手段。使不变的元素保持不变,减少回流。

层叠上下文

我们可以把它们看做成一个个独立的空间,它们在 z 轴上存在顺序上的层叠关系。
层叠上下文存在以下特性

  • 层叠上下文可以包含在其他层叠上下文当中,形成一个嵌套层级
  • 每个层叠上下文完全独立于其他兄弟元素

层叠等级

层叠等级,也可以叫层叠级别/层叠水平,在同一个层级上下文中,等级越高,越靠近用户。

  • 普通元素的层叠等级由所处层叠上下文决定
  • 在同一个层叠上下文中,等级越高,越靠近用户
  • 不在同一个上下文中的层叠等级比较是没有意义的

用代码验证一下第三点:

1
2
3
4
5
<div class="yellow">
1
<div class="blue">999</div>
</div>
<div class="red">2</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.yellow {
background: yellow;
height: 100px;
position: relative;
z-index: 1;
}
.blue {
background: blue;
height: 100px;
position: absolute;
z-index: 999;
}
.red {
background: red;
width: 50px;
height: 50px;
position: relative;
left: 20px;
top: -20px;
z-index: 2;
}

pic.1708406521813

.yellow的层叠等级为 1,.red的层叠等级为 2,.blue为 999,.yellow.red在同一个根层叠上下文下。
但是由图可见,.blue.red下面,这是因为.yellow的层叠等级比.red小,也证明了不在同一个上下文中的比较是没有意义的。

创建层叠上下文

  • html为根层叠上下文
  • position属性为absoluterelative并设置了z-index属性为具体数值
  • position值为fixedsticky的元素
  • css3 的一些属性(文章后面介绍)

层叠顺序

层叠规则,表示元素在发生层叠时,有着特定的层叠顺序。层叠上下文和层叠等级是概念,而层叠顺序是规则。
层叠上下文的 background/border < z-index 负值 < block 元素 < float 元素 < inline-block/inline < z-index: 0 / z-index: auto < z-index: 正值
层叠顺序图.png

DOM 顺序和层叠顺序

从图中可以看出,层叠低的在层级高的下面,就像往容器里面放东西一样。它其实迎合栈的层叠规则,先进后出。所以浏览器会优先渲染层叠等级低的元素,再渲染比它高的。

所以结合实际的应用,在编码时,我们应该自然而然的把层叠等级低的写在最前面,让渲染更流畅。

1
2
3
<div style="z-index: -100">div1</div>
<div style="z-index: 0">div2</div>
<div style="z-index: 1">div3</div>

如果层级高的在下面会怎样?看个例子
我们先往容器(html)里放了一个 div1,在放 div2,div3,假设 3 个 div 都是层叠元素,div1 层叠等级最高,并且在最下面

1
2
3
<div style="z-index: 1">div1</div>
<div style="z-index: 0">div2</div>
<div style="z-index: -100">div3</div>

以上代码,假设先渲染等级高的(div1),那渲染等级低的元素时,是不是还要把它上面的元素先拿出来。

万能公式

  • 同一个层叠上下文,谁的层叠等级大,谁再上面。
  • 不在同一个层叠上下文,找到它们的父级为兄弟节点,再比较。
  • 当层叠等级和层叠顺序相同时,后来居上。

下面我们来验证一下层叠顺序和万能公式是否准确。
例子 1:z-index: 负值 < block 元素:

1
2
3
4
5
6
<div class="box">
<div class="parent">
parent
<div class="child">child</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.box {
}
.parent {
width: 200px;
height: 100px;
background: #168bf5;
}
.child {
width: 100px;
height: 200px;
background: #32d19c;
position: relative;
z-index: -1;
}

pic.1708406544047

.parent 是个普通的块元素,.child 是带负值的 z-index 层叠上下文元素,.parent 在.child 上面,所以 z-index: 负值 < block 元素成立。

例子 2: 层叠上下文的 background/border < z-index 负值
继续上个例子,我们给.parent 添加 position: relative 和 z-index: 0,其余代码不变

1
2
3
4
5
6
<div class="box">
<div class="parent">
parent
<div class="child">child</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.box {
}
.parent {
width: 200px;
height: 100px;
background: #168bf5;
position: relative;
z-index: 0;
}
.child {
width: 100px;
height: 200px;
background: #32d19c;
position: relative;
z-index: -1;
}

pic.1708406555021

这时候.parent 变成了带 background 的层叠上下文元素,.child 依然是带负值的 z-index 层叠上下文元素。网上的很多文章拿这个例子来验证 background/border < z-index 负值,其实是不正确的。.child 在.parent 上,是因为.child 嵌套在.parent 里面,所以.child 在上面。也就是说他们不在用一个层叠上下文之下,所以他们的比较是没有意义的。

关于 background/border < z-index 负值,我还没想到如何验证。

例子 3:block 元素 < float 元素 < inline-block/inline

1
2
3
4
<div class="box">
<img src="../../images/js1.jpg" alt="" />
<span>我是重要的内容,我的等级很高的哦</span>
</div>
1
2
3
4
5
6
7
8
9
10
11
.box {
background: red;
height: 300px;
}
img {
float: left;
}
span {
position: relative;
left: -30px;
}

pic.1708406566018

.box 是块元素,img 是浮动元素,span 是 inline 元素,inline 在最上面,img 在中间,所以block 元素 < float 元素 < inline-block/inline成立。

例子 4:同一个层叠上下文,谁的层叠等级大

1
2
3
4
5
6
7
8
9
10
<div class="box">
<div class="box1">
<!-- 盲僧 -->
<img src="../../images/ms.jpg" alt="" />
</div>
<div class="box2">
<!-- 剑圣 -->
<img src="../../images/js1.jpg" alt="" />
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.box1 {
position: relative;
z-index: auto;
}
.box1 img {
position: absolute;
z-index: 2;
}
.box2 {
position: relative;
z-index: auto;
}
.box2 img {
position: relative;
z-index: 1;
}

pic.1708406574167

.box1 和 .box2 的 z-index 值为 auto,此时不是层叠上下文元素,两张图片都属于在根层叠上下文下,因为盲僧在剑圣上面,所以同一个层叠上下文,谁的层叠等级大成立

例子 5:层叠等级和层叠顺序相同时,后来居上
在例子 4 的基础上,我们把.box1 和.box2 的 z-index: auto 改为 z-index: 0

1
2
3
4
5
6
7
8
9
10
<div class="box">
<div class="box1">
<!-- 盲僧 -->
<img src="../../images/ms.jpg" alt="" />
</div>
<div class="box2">
<!-- 剑圣 -->
<img src="../../images/js1.jpg" alt="" />
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.box1 {
position: relative;
z-index: 0;
}
.box1 img {
position: absolute;
z-index: 2;
}
.box2 {
position: relative;
z-index: 0;
}
.box2 img {
position: relative;
z-index: 1;
}

pic.1708406589649

此时.box1 和.box2 都变成了层叠等级为 0 的层叠上下文元素,并且在同个层叠上下文下。剑圣在盲僧上面,所以层叠等级和层叠顺序相同时,后来居上成立。

css3 中的属性对层叠上下文的影响

介绍几个比较常用的,如下:

  1. 父元素的 display 属性值为 flex | inline-flex,子元素的 z-index 属性值不为 auto,子元素为层叠上下文元素
  2. 元素的 opacity 值不为 1
  3. 元素的 filter 属性值不是 none
  4. 元素的 transform 属性值不是 none

例子 6:父元素 display: flex | inline-flex 与层叠上下文

1
2
3
4
5
6
<div class="box">
<div class="parent">
parent
<div class="child">child</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.box {
}
.parent {
width: 200px;
height: 100px;
background: #168bf5;
/* 虽然设置了z-index,但是没有设置position,z-index无效,.parent还是普通元素,没有产生层叠上下文 */
z-index: 1;
}
.child {
width: 100px;
height: 200px;
background: #32d19c;
position: relative;
z-index: -1;
}

pic.1708406603779
继续例子 6,我们把.parent 变成层叠上下文元素,
例子 7:层叠上下文的 background/border < z-index 负值

1
2
3
4
5
6
<div class="box">
<div class="parent">
parent
<div class="child">child</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.box {
display: flex;
}
.parent {
width: 200px;
height: 100px;
background: #168bf5;
/* .box设置了display: flex; 这时候.parent变成了层叠上下文 */
z-index: 1;
}
.child {
width: 100px;
height: 200px;
background: #32d19c;
position: relative;
z-index: -1;
}

pic.1708406621323

.child 跑到了上面,所以层叠上下文的 background/border < z-index 负值成立。

例子 8:元素的 opacity 值不为 1

1
2
3
4
5
<div class="box">
<div>
<img src="../../images/js1.jpg" alt="" />
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
.box {
}
.box div {
width: 300px;
background: #168bf5;
}
div img {
width: 200px;
height: 200px;
position: relative;
z-index: -1;
}

pic.1708406632213
因为 z-index: 负值 < 块元素,所以图片在蓝色背景下。然后我们给 div 添加 opacity: 0.5,其余不变

1
2
3
4
5
<div class="box">
<div>
<img src="../../images/js1.jpg" alt="" />
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
.box {
}
.box div {
width: 300px;
background: #168bf5;
opacity: 0.5;
}
div img {
width: 200px;
height: 200px;
position: relative;
z-index: -1;
}

pic.1708406639748

添加 opacity 后,div 变成了层叠上下文元素,图片跑到了上面,所以层叠上下文的 background/border < z-index 负值成立。

例子 9:元素的 filter 属性值不是 none
在例子 8 上,我们给 div 添加 filter: blur(5px),其余不变

1
2
3
4
5
<div class="box">
<div>
<img src="../../images/js1.jpg" alt="" />
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
.box {
}
.box div {
width: 300px;
background: #168bf5;
filter: blur(5px);
}
div img {
width: 200px;
height: 200px;
position: relative;
z-index: -1;
}

pic.1708406650744

同理,结论成立。

例子 10:元素的 transform 属性值不是 none
在例子 8 上,我们给 div 添加 transform: rotate(45deg),其余不变

1
2
3
4
5
<div class="box">
<div>
<img src="../../images/js1.jpg" alt="" />
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
.box {
}
.box div {
width: 300px;
background: #168bf5;
transform: rotate(45deg);
}
div img {
width: 200px;
height: 200px;
position: relative;
z-index: -1;
}

pic.1708406659567

同理,结论成立。

开发调试工具
可以用过 layers 来查看页面的层叠布局,检查遮挡关系
More tools -> Layers
pic.1708406666079

总结

  1. z 轴的存在,其实是网页性能优化的一种手段。使不变的元素保持不变,减少回流。
  2. 因为浏览器优先渲染层叠等级低的元素,所以编码时,我们应该自然而然的把层叠等级低的元素写在前面,让渲染更为流畅。
  3. 查看层叠等级时,先查看是否在同个层叠上下文下,如果是,层叠等级越高越在上面,层叠等级和层叠顺序相同时,后来居上。如果不是,那么判断所处层叠上下文的等级。
  4. 避免滥用 z-index。现在我们知道 inline > float,那么我们只需要把元素变成行内元素即可覆盖在浮动元素之上。过渡使用定位元素会使页面变复杂。
  5. 定位或者 css3 属性 display: flex | inline-flex 下,z-index: auto 所在的元素是普通元素,z-index: 0 是层叠元素。

事件循环机制

同步于异步

同步:按函数调用栈执行

异步:分发器分发一个任务,被通知后执行

定义

负责 JS 执行环境的代码执行顺序的问题。如果没有异步事件,函数调用栈几乎可以解决所有执行顺序问题。而事件循环机制,就是异步事件代码执行顺序的解决方案。

那么,浏览器中有哪些异步事件,以及它们各自有哪些特点呢?

线程

许多异步事件,都是由线程负责处理。
pic.1708327092141
JS 是单线程的,但是 JS 的执行环境是由多个线程协同工作的。不同的线程,对应着不同的异步事件。

五个线程

GUI 线程

负责 HTML 的解析与渲染。DOM 结构的修改是同步的,但是 DOM 的渲染过程是异步的

JS 引擎线程

负责 JS 代码的运行。
每一个网页,只会启动一个 JS 线程来配合完成页面的交互。

定时器线程

专门负责 setTimeout/setInterval 的逻辑。
回调函数中的逻辑并不会马上执行,即使将时间设置为 0,这也是异步的。

I/O 时间触发线程

当我们鼠标点击与滑动、键盘的输入等都会触发一些事件,而这些事件的触发逻辑的处理,就是依靠事件触发线程来帮助浏览器完成。

该线程也会把事件的逻辑放入队列中,等待 JS 引擎的处理。

http 线程

使用无状态短链接的 http 请求,在应用层基于 http 协议的基础之上,达到与服务端进行通信的目的。

该线程的触发逻辑,不是在 JS 引擎线程中,这个过程是异步的。

小结:除了 JS 引擎线程,其他四个线程分别处理与之对应的异步任务。比如定时器线程,由任务分发器 setTimeout /setInterval 分发异步任务进入定时器执行队列。

与 UI render 紧密相关的 raf/ric

requestAnimationFrame 简称「raf」,它是动画的重要实现手段。他跟前面介绍的异步方式大有不同,跟 UI render 紧密相关。

结合浏览器的渲染机制共同理解。
常规的显示器的刷新率为 60 Hz,也就是说,1 秒钟把页面刷新了 60 次,是最合理的频率。

这也是浏览器在进行 UI render 的合理频率。因此,每一次的渲染时间,控制在 1000 / 60 ms 以内。对浏览器来说才不会负荷工作。

requestAnimationFrame 是完成符合浏览器刷新频率的回调方式。

1
2
3
4
5
6
如果单次执行时间大于 16.67ms,也就是刷新率低于 60 Hz,会表现出卡顿;

卡顿的理解:
网络直播时,如果网络不好,会丢包导致卡帧。也就是说,下一秒的包还没发给你,自动刷新,刷新的还是上一次的老包,所以表现为卡顿,看起来就像刷新率低一样。

会用掉帧策略(放弃某一帧的任务)来解决。而不使用任务堆积。
1
如果高于 60hz,浏览器会超负荷工作。raf 的回调函数为 1000 / 60 执行一次,刚刚好符合浏览器刷新频率

我们通常喜欢将一次 UI render 描述为 一帧 「frame」。requsetAnimationFrame 只会在每一帧开始渲染之前执行。
pic.1708327251065

requestIdleCallback 简称「ric」,图中的idle,通常将优先级不高的任务放在 ric 中执行。

它的执行频率也跟 UI render 的频率一样。但是它会在每一帧的最后执行。

Promise

在浏览器中,线程对应的事件,并不能覆盖所有的异步事件类型。Promise 就是一个特例。

Promise 是 JS 的内部逻辑。并非由浏览器额外的线程来处理。因此,Promise 的异步逻辑与线程对应的异步逻辑是不一样的。

在 JS 引擎的处理逻辑中,Promise 有自己的事件队列,并且该队列在所有 JS 代码执行完成后执行。

1
2
3
const p = new Promise((resolve, reject) => {});

p.then(f, r);

job

then 中的回调函数,就是一个 job。此处为 f 与 r。catch 同理。

PromiseJobs 执行队列

用于存储 Promise 异步逻辑。该队列在所有 JS 代码执行完之后执行。也可以说在 call stach 清空之后执行。

状态

一个 promise 有三种状态

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

当一个 promise 实例在创建时,处于 pending 状态。
当 resolve 函数调用时, padding -> fulfilled
当 reject 函数调动是, padding -> rejected

当 promise 有了状态,不再是 padding ,那么我们称该 promise 的状态被固定: settled

何时进入 PromiseJobs

调用 p.then(f, r)时,会将 f 放入 [[PromiseFulfillReactions]]队列尾部,将 r放入 [[PromiseRejectReactions]]队列尾部。

这两个队列,是临时中间队列(准确的说,应该叫临时存储地方,出队时是无序的)。该队列中的 job 只会移入到 PromiseJobs 队列中而不会有自己的执行过程。 PromiseJobs 才有执行 job 的逻辑。

resolve/reject函数调用时,promise 产生了结果。此时,根据不同的结果,p.then(f, r)将不同的 job 「f / r」加入到 PromiseJobs 队列中。

如果在创建时,就已经 settled ,那么 job 会直接进入 PromiseJobs 队列中。

1
2
3
const p = new Promise((resolve, reject) => {
resolve(); // 直接敲定状态
});

链式调用时,后续的 then 如何将 job 加入到 PromiseJobs 队列,需要根据上一个 then 的返回结果来决定。

  • 当不确定返回结果时,且 then 已调用,对应的 job 进入临时队列中。
  • 确定了返回结果之后,才会将 job 移入到 PromiseJobs 队列中

PromiseJobs 队列如何执行

先进入的先执行。但是有一点要注意,当 job 执行时,可能会产生新的 job 进入到该队列。因此 PromiseJobs 在执行过程中会动态变化

PromiseJobs 的执行规则

  • 当所有 JS 代码执行完毕,PromiseJobs 队列开始出队执行
  • PromiseJobs 处于动态变化中,只有当 PromiseJobs 队列为空时,才会结束执行
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();
}

通过两例子感受一下
例子一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const p1 = new Promise((resolve) => {
resolve();
})
.then(function f1() {
console.log(1);
const p2 = new Promise((resolve) => {
resolve();
})
.then(function f3() {
console.log(2);
})
.then(function f4() {
console.log(4);
});
})
.then(function f2() {
console.log(3);
});
console.log(0);

// 输出: 0 1 2 3 4

Promise 在创建时,直接调用了 resolve , Promise 有了结果,因此, f1 马上被加入 PromiseJobs 队列。f2 要等待 f1 的结果,所以只能被加入临时队列。

1
2
PromiseJobs = [f1];
PromiseFulfillReactions = [f2];

输出 0, JS 所有代码执行结束, 开始执行 PromiseJobs 列队中的逻辑

** f1 出队执行**, 输出 1, 遇到一个新的 Promise 对象, 且直接调用了 resolve , f3 进入 PromiseJobs 队列,f4 需要等到 f3 的执行结果, 所以进入临时队列

1
2
PromiseJobs = [f3];
PromiseFulfillReactions = [f2, f4];

f1 执行结束, 可以得知 f1 返回了 undefined, 等价于 resolve(undefined). f1 有了结果, 所以 f2 进入 PromiseJobs 队列.

1
2
PromiseJobs = [f3, f2];
PromiseFulfillReactions = [f4];

又开始执行 PromiseJobs 中的任务。

f3 出队执行, 输出 2
f3 执行完毕, 返回 undefined, 因此 f4 进入 PromiseJobs 队列

1
2
PromiseJobs = [f2, f4];
PromiseFulfillReactions = [];

开始执行 PromiseJobs 中的任务

依次执行 f2 f4, 没有产生新的 job, PromiseJobs 变为空, 当前循环结束。

例子二:

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
new Promise((resolve) => {
resolve();
})
.then(function f1() {
new Promise((resolve) => {
resolve();
})
.then(function f3() {
console.log(1);
})
.then(function f4() {
console.log(2);
})
.then(function f5() {
console.log(3.1);
});
})
.then(function f2() {
console.log(1.1);
new Promise((resolve) => {
resolve();
})
.then(function f6() {
new Promise((resolve) => {
resolve();
})
.then(function f7() {
console.log(4);
})
.then(function f8() {
console.log(6);
});
})
.then(function f9() {
console.log(5);
});
})
.then(function f10() {
console.log(3);
});
console.log(0);

// 输出: 0 1 1.1 2 3 3.1 4 5 6

Promise 在创建时, 直接调用了 resolve, 所以 f1 进入 PromiseJobs 队列. PromiseJobs = [f1]
f2 需要等待 f1 的执行结果才能进入 PromiseJobs 队列中, f10 需要等待 f2, 所以 f2 f10 都进入临时队列. PromiseFulfillReactions = [f2, f10]
输出 0, js 所有代码执行结束. 开始执行 PromiseJobs 队列中的逻辑

f1 出队并执行, f1 执行过程中, 遇到了新的 Promise, 并直接调用了 resolve, 所以 f3 的状态直接被固定, 进入 PromiseJobs 队列. PromiseJobs = [f3]
f4 和 f5 需要等待 f3 的结果才能入 PromiseJobs 队列. 所以只能进入临时队列. PromiseFulfillReactions = [f2, f10, f4, f5]
f1 逻辑代码执行完毕, 相当于 resolve(undefined) , f2 进入 PromiseJobs 队列.  PromiseJobs = [f3, f2], PromiseFulfillReactions = [f10, f4, f5]. 开始执行 PromiseJobs 队列中的逻辑

f3 出队并执行, 输出 1, 逻辑代码执行完毕, f3 返回 undefined, 因此 f4 进入 PromiseJobs 队列.  PromiseJobs = [f2, f4], PromiseFulfillReactions = [f10, f5]. 开始执行 PromiseJobs 队列中的逻辑

f2 出队并执行, 输出 1.1, 遇到新的 Promise, 新的 Promise 直接调用了 resolve, f6 直接进入 PromiseJobs 队列.  PromiseJobs = [f4, f6], f9 进入临时队列, PromiseFulfillReactions = [f10, f5, f9]
f2 代码执行完毕, 返回 undefined, f10 进入 PromiseJobs , PromiseJobs = [f4, f6, f10], PromiseFulfillReactions = [f5, f9]. 开始执行 PromiseJobs 队列中的逻辑

f4 出队并执行, 输入 2, 返回 undefined, f5 进入 PromiseJobs, PromiseJobs = [f6, f10, f5], PromiseFulfillReactions = [f9]. 开始执行 PromiseJobs 队列中的逻辑

f6 出队并执行, 遇到了新的 Promise, 新的 Promise 直接调用了 resolve, f7 进入 PromiseJobs 队列, PromiseJobs = [f10, f5, f7], f8 进入临时队列.  PromiseFulfillReactions = [f9, f8].
f6 执行完毕, 返回 undefined, f9 进入 PromiseJobs . PromiseJobs = [f10, f5, f7, f9].  PromiseFulfillReactions = [f8]. 开始执行 PromiseJobs 队列中的逻辑

f10 出队并执行, 输出 3, PromiseJobs = [f5, f7, f9], PromiseFulfillReactions = [f8].  开始执行 PromiseJobs 队列中的逻辑

f5 出队并执行, 输出 3.1,  PromiseJobs = [f7, f9], PromiseFulfillReactions = [f8].  开始执行 PromiseJobs 队列中的逻辑

f7 出队并执行, 输入 4, 返回了 undefined, f8 进入 PromiseJobs 队列.  PromiseJobs = [f9, f8], PromiseFulfillReactions = [].  开始执行 PromiseJobs 队列中的逻辑

f9 出队并执行, 输出 5, PromiseJobs = [f8], PromiseFulfillReactions = [], 开始执行 PromiseJobs 队列中的逻辑

f8 出队并执行, 输出 6, PromiseJobs = [], PromiseFulfillReactions = []. 循环结束

内循环的实现

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 queue = [];

// 定义一个事件分发器
function rafx(cb) {
queue.push(cb);
}

rafx(() => {
console.log(0.1);
});

rafx(() => {
console.log(0.2);

rafx(() => {
console.log(0.21);
});
});

rafx(() => {
console.log(0.3);
});

rafx(() => {
console.log(0.4);
});

rafx(() => {
console.log(0.5);
});

let cb;
while ((cb = queue.shift())) {
cb();
}

// 输出: 0.1 0.2 0.3 0.4 0.5 0.21

queue 队列为空时,才结束执行。

事件循环到底是怎么回事

我们已经知道,单纯依靠 call stask 不能完全覆盖所有代码的执行逻辑,call stask 的代码执行顺序永远都是同步的逻辑。对于许多线程引发的异步逻辑,需要依靠队列机制。
pic.1708327269186
每一个异步行为,都有对应的执行队列。

执行队列

会在一轮循环中,直接执行的队列。如 PromiseJobs 队列。

临时队列

不会马上执行,处于等待状态的队列。在 promise 中,有两个临时队列, PromiseFulfilledReactions 与 PromiseRejectedReactions 。在满足条件后,才会将该队列中的任务,移入到执行队列中。有的临时队列又被称为事件表 Event Table,或注册表。

关于临时队列的理解
通常,在代码中,setTimeout,事件 I/O,http 请求,都会通过回调的方式编写代码的执行逻辑。
例如

1
2
3
4
function foo() {
// 点击之后执行的逻辑
}
d.onclick = foo;

foo 就是回调函数。函调函数里的逻辑不会马上执行,而是要等到条件满足之后才会执行。

执行队列有哪些

  • scriptJobs:指的是 script 标签,更顶层的供任务,S 代码执行的起点
  • rafs:requestAnimationFrame 对应的队列
  • UI render:渲染 UI 的任务队列
  • ric:requestIdleCallback 对应的队列
  • event queue:I/O 事件列队
  • timer queue:定时器队列,由 setTimeout/setInterval 分发
  • http queue:http 队列
  • PromiseJobs:Promise 队列,由 p.then 分发

宏任务队列与微任务队列

每个队列都有各自鲜明的特点,如果非要区分这些异步队列是宏任务队列还是微任务队列,那么,除了 PromiseJobs ,其它的都可以理解为宏任务队列。

一轮循环的起点

同一时间,不会存在两个任务同时执行的情况。
UI 渲染也是一个任务,也不会与其它任务同时执行。所以我们常说,UI 渲染与 JS 代码时互斥的关系。

  • 我们知道,PromiseJobs 是内循环,所以永远不可能作为一轮循环的起点
  • UI render 的渲染,由 GUI 线程执行。所以 UI render 队列也不会是一轮循环的起点。

UI render 队列,是纯粹的渲染任务队列。既然要渲染任务,那必须有分发任务的指令才知道如何渲染。发起一个 http 请求也算是一个指令。

除了 PromiseJobs 和 UI render,其他所有任务队列中的任务「称之为 task」,都有可能是一轮循环的起点。

一轮循环完毕的标志

当次循环所有的执行队列都清空之后,一轮循环完毕。一轮循环完毕的标志是最后一个 task 中的内循环 PromiseJobs 队列清空

事件循环的顺序

从 script 开始第一次循环

  1. 所有能作为起点的队列中的任务,都是进入主线程执行,借助函数调用栈依次执行,等调用栈清空,并且 PromiseJobs 为空,当次任务结束。PromiseJobs 是 task 的内循环
  2. 当次任务执行过程中,可能会产生新的任务,这些任务会放入临时队列或者下一次循环(比如定时器任务)的执行队列,当次循环的执行队列是执行一个少一个,直到清空为止。
  3. 任务分发时,多半都是进入临时队列,满足条件后,进入执行队列。
  4. 下一轮循环从执行队列中的第一个任务开始执行,直到当次执行队列清空为止
  5. 多个执行队列之间存在先后关系。raf -> ui render -> [event,http,timer] -> ric
  6. raf | ui render | ric 三个队列中的 task 不会每次循环都执行,他们的执行频率要和刷新率保持一致。因此多次循环中,他们都不会执行「执行队列为空」

任务进入执行队列的时机

以 setTimeout 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
document.onclick = () => {
console.log('s');
setTimeout(() => {
console.log(0);
}, 0);

setTimeout(() => {
console.log(1);
}, 1000);

setTimeout(() => {
console.log(2);
}, 2000);

for (var i = 0; i < 5000000000; i++);
console.log('e');
};

// 点击之后的输出结果
// 先立刻输出 s
// 10s 之后,依次快速输出
// e 0 1 2

分析一下
setTimeout 执行时,三个 task 进入了临时队列。

但是 for 循环的执行时间非常长,超过了 3 秒,因此,在 for 循环执行的过程当中,定时器线程发现 timer 临时队列中的任务满足了条件,就之间放入到了 timer 执行队列。

等 for 循环结束时,就依次快速输出 e 1 2 3。

再说 setInterval

1
2
3
setInterval(function f1() {
func();
}, 100);

当 f1 的执行之间,会将 f1 放入临时队列。然后每隔 100 ms,会将 f1 这个任务重复的放入执行队列中。

这时候会有个问题,如果任务 f1 的执行时间超过 100 ms, 那么一轮循环里,执行队列里必然会多出一个 f1 任务,这就会让本次循环的时间拉长,后面队列的任务就会等待更多时间。

所以在 chrome 中,为了弱化这种情况的影响,timer 队列往往放在最后执行「仅比 ric 早」。

也正是这个原因,我们应该避免使用 setInterval, 不合理的使用可能会造成页面的严重卡顿。

所以我们常常使用 setTimeout 的递归调用方式来取代 setInterval .

1
2
3
4
5
6
7
function fn() {
console.log(2);
// 这句代码一定要放在最后
setTimeout(fn, 100);
}

fn();

这种递归的方式,每次在 fn 执行完之后,才会 push 一个任务到临时队列中。然后临时队列满足 100 ms 的时间之后推入执行队列。也就是说,执行队列中的任务始终只会有一个,即使 fn 的执行时间超过了 100 ms,那它的影响也仅此而已,不会像 setInterval 那样累加。

举个例子

最有,再通过一个例子分析下事件循环的执行顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setTimeout(function s1() {
console.log(5);
}, 1);

setTimeout(function s2() {
console.log(6);
}, 0);

new Promise(function (resolve) {
console.log(1);
for (var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log(2);
}).then(function p1() {
console.log(4);
});

console.log(3);

// 输出 1 2 3 4 5 6

事件循环从 script 开始, 在主线程, 此时只有 scriptJobs 队列中有任务

遇到第一个 setTimeout 函数, setTimeout 进入函数调用栈, setTimeout 分发一个 task s1 进入临时队列, 但是因为 1ms 时间太短, 因此 s1 直接进入 timer 执行队列, timer = [s1], setTimeout 出栈

我们可能决定不立刻执行一个函数,而是在某时间之后执行,一般我们成为“调度执行”
因为调度的执行时间太短了,只有 1ms , 因此会立刻进入到执行队列。如果改成 2ms ,就可能不会这么快,就会先进入临时队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
setTimeout(function s1() {
console.log(5);
}, 1);

setTimeout(function s2() {
console.log(6);
}, 0);
// 输出: 5 6

setTimeout(function s1() {
console.log(5);
}, 2);

setTimeout(function s2() {
console.log(6);
}, 0);
// 输出: 6 5
又遇到 setTimeout, setTimeout 进入函数调用栈执行, setTimeout 分发一个 task s2, 因为延迟时间为 0, s2 直接进入 timer 执行队列队列, timer = [s1, s2] setTimeout 出栈

接下来遇到 Promise 的创建, Promise 构造函数进入函数调用栈, Promise 的第一个参数我们称为 executor, executor 会在 Promise 内部直接执行, 所以 executor 进栈,
又遇到 log 函数, log 进栈,输出 1, log 出栈. 在 for 循环时 遇到了 resolve 函数, resolve 进栈执行, Promise 状态被固定. log 进栈, 输出 2, log 出栈, resolve 出栈, executor 出栈, Promise 出栈

Promise 执行完, 接着执行 then 方法, 因此 then 进栈. then 方法时 Promise 的异步分发器. 本来 p1 应该直接进入 Promise 的临时队列, 但是 resolve 已经直接执行过了, 状态被固定, 因此已经知道了该如何执行, 所以 p1 直接进入 PromiseJobs 执行队列.
p1 进入执行队列后, then 执行完毕, 出栈.

然后遇到 log, log 进栈, 输出 3, log 出栈.

到这里, 主线程中的代码执行完毕, 调用栈也被清空了, 因此接下来就要执行 PromiseJobs 内循环. 发现 PromiseJos 队列中有任务, 开始执行 PromiseJobs 队列中的逻辑

p1 出队, 进入调用栈, p1 开始执行, log 进栈, 输出 4, log 出栈. p1 逻辑执行完毕, 出栈. 至此, PromiseJobs 执行队列已经清空, 内循环执行完毕. 当轮产生的 setTimeout 的任务, 应该放在下一轮执行. 至此第一轮循环结束

开始第二轮循环, 发现 timer 执行队列中有任务, s1 出队并进入函数调用栈, log 进栈, 输出 5, log 出栈, s1 出栈, timer = [s2]

s2 出队并进入函数调用栈, log 进栈, 输出 6, log 出栈, s2 出栈. s2 执行完之后, timer 队列也被清空了, 检查发现 PromiseJobs 队列中也没有任务, 所以可执行代码执行完毕, 循环结束

模拟一个没有临时队列的外循环事件分发器

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
// 用数组模拟一个队列
var tasks = [];

// 模拟一个事件分发器
var addFn1 = function (task) {
tasks.push(task);
};

// 执行所有的任务
var flush = function () {
tasks.map(function (task) {
task();
});
};

// 最后利用setTimeout/或者其他你认为合适的方式丢入事件循环中
setTimeout(function () {
flush();
});

// 分发任务
addFn1(function add() {
console.log('my name is add fn');
});
addFn1(function foo() {
console.log('my name is foo fn');
});

也可以不利用事件循环,而是手动在适当的时机去执行对应的某一个方法

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
// 用数组模拟一个队列
var tasks = [];

// 模拟一个事件分发器
var addFn1 = function (task) {
tasks.push(task);
};

// 去执行对应的某一个方法
var dispatch = function (name) {
tasks.map(function (item) {
if (item.name === name) {
item.handle();
}
});
};

// task 只需要多保存一个 name 即可。格式如下
demoTask = {
name: 'demo',
handle: function () {
console.log('my name is demoTack');
},
};

addFn1(demoTask);
dispatch('demo');

于是,一个订阅-通知的设计模式就这样实现了。

思考

为什么要有一轮两轮
相当于给一个浏览器一个缓存的时间。告诉浏览器下一轮该执行什么,而不是打他个措手不及。如果没有异步任务,那其实一轮就够了。
再回到之前的例子

1
2
3
4
function foo() {
// 点击之后执行的逻辑
}
d.onclick = foo;

从 script 标签开始第一轮循环,给 I/O 事件临时队列分发一个 task foo,主线程代码执行完毕,第一次循环结束。

当满足 onclick 条件时,foo 移入 I/O 事件队列, 并开始出队执行。

代码规范

eslint

通过配置.eslintrc.*指定团队代码规范。在编码过程中,对于不规范的代码能够进行提示,帮助开发者更正代码。同时,提供 auto-fix 能力。
auto-fix 能够修复的问题比较有限,无法修复较大变动的规范问题,如:单行不能超过 80 个字符。

prettier

prettier 是什么?

prettier 能根据.eslintrc.*的约定,自动格式化代码。具有比 eslint auto-fix 更加强大的代码修复能力。

配置 prettier 的几种方式:

可以使用 vscode settings.json、prettier 配置文件或.editorconfig 文件。VSCode settings.json 是用来作为备用的,通常只用于非项目文件,不能用于 prettier CLI。建议你总是在项目中包含一个 prettier 配置文件,这将确保无论你以何种方式运行 prettier(通过 prettier-vscode 插件 or prettier CLI [prettier –single-quote ]),都将应用同样的格式化选项。
推荐使用 prettier 配置文件来设置格式化选项。如果你想将 prettier 的格式化选项应用于整个项目,只需在项目根目录下添加一个 prettier 配置文件即可。

配置 prettier

所有的 prettier 配置项都可以直接在 settings.json 中配置,这些配置的默认值始终是 prettier 2.0 的默认值。

一般我们需要npm install prettier进行安装,再进行使用,这里先不介绍如何使用,主要介绍下在 vs code 中的使用。

在 vs code 中使用 prettier

  • 在 vs code 应用商店安装 prettier 插件

pic.1708325653485

  • 配置插件.prettierrc
1
2
3
4
5
6
7
8
9
10
module.exports = {
printWidth: 80, //一行的字符数,如果超过会进行换行,默认为80
tabWidth: 2, //一个tab代表几个空格数,默认为2
useTabs: false, //是否使用tab进行缩进,默认为false,表示用空格进行缩减
singleQuote: false, //字符串是否使用单引号,默认为false,使用双引号
semi: true, //行位是否使用分号,默认为true
trailingComma: 'none', //是否使用尾逗号,有三个可选值"<none|es5|all>"
bracketSpacing: true, //对象大括号直接是否有空格,默认为true,效果:{ foo: bar }
parser: 'babylon', //代码的解析引擎,默认为babylon,与babel相同。
};

eslint 与 prettier

区别:

eslint 主要解决代码质量问题。代码风格问题并没有完全做完。而 Prettier 认为代码风格更重要,所以格式问题由 Prettier 处理。
但是 Prettier 的处理结果可能 eslint 不会完全满意,但绝对不丑,而且给予了部分配置项,可以通过.prettierrc文件修改。

冲突:

因为 eslint 和 prettier 一起使用会有冲突。可以通过eslint-config-prettier 这个插件解决。

eslint-config-prettier:用 prettier 的规则,覆盖掉 eslint:recommended 的部分规则。因此不会有冲突。
eslint-plugin-prettier:将 prettier 的能力集成到 eslint。按照 prettier 的规则检查代码规范性,并进行修复。

.editorconfig

开发者使用不同编辑器或系统时,将.editorconfig文件保存到项目根目录下,即可让项目下所有文件都统一编辑器代码风格。
这个文件会覆盖编辑器的默认配置。
https://www.cnblogs.com/lwming/p/15270816.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# http://editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true

[*.md]
max_line_length = 0
trim_trailing_whitespace = false

vscode settings.json

settings.json 文件是 VS Code 众多“配置文件”中的一个,用来控制诸多工作项的配置。
按快捷键【Ctrl】+【,】,打开 VS Code 的设置界面,
pic.1708325666467
可以看到设置内容有“用户” “工作区”两个分支,它们其实就分别对应了两个不同位置的 settings.json 文件,前者位于与用户相关联的特定文件夹中,后者就会位于你当前打开的文件夹的.vscode 子目录下。这两个 settings.json 文件其实都只会包含你修改过的设置项,而软件的全局默认设置则记录在软件安装目录中的某个位置,用户是不必理会的。
pic.1708325675886
这些配置文件具有工作区>用户>全局默认 的优先级,也就是前面的配置覆盖后面的,这样做的好处是你可以为不同工作区、不同用户做不同配置,而不会互相干扰。而当你不小心在某个工作区做了错误的配置导致软件不能正常工作时,只需删除相应的设置项,或者 settings.json 文件,即可恢复到用户配置或默认配置。

1
2
3
4
5
6
7
8
9
// 工作区分支 .vscode/setting.json

{
"prettier.printWidth": 100,
"editor.formatOnSave": true,
// 一个制表符等于的空格数。
"editor.tabSize": 2,
"editor.fontSize": 13
}

以“prettier”开头的配置项可以放在.prettierrc 文件中

总结

对于 vscode 配置,setting.json 文件只会包含你「手动添加」过的设置项,在「设置页面中勾选」和「手动添加」设置项的效果是一致的。为了保持多项目使用同一份配置,建议将需要的配置『手动添加』到工作区分支的 setting.json 文件中,后续只需拷贝 setting.json 文件即可。

参考
https://its401.com/article/yexudengzhidao/113249805
prettier: https://juejin.cn/post/6914549928131821576

Q&A

修改了.gitignore 文件但无效?
如果在开发的过程中添加或者修改了.gitignore 文件,那么它可能不会生效,因为一些需要忽略的文件已经加入了 git 的追踪列表中,可以通过清除 git 缓存来使新的.gitignore 生效。方法如下:
:::info
第一步:cd 到项目目录
第二步:git rm -r –cached .
第三步:git add .
第四步:git commit -m ‘update .gitignore’
:::

作用域和作用域链

作用域

作用域是 规定变量与函数可访问范围的一套规则

最常见的两种作用域,分别是全局作用域和函数作用域。

词法作用域

与词法作用域对立的是动态作用域,所有也可以成为静态作用域。因为我们前端领域没有动态作用域的概率,所以可以统称为作用域。

全局作用域

全局作用域中声明的代码,可以在任何地方被访问。以下三种情形属于全局作用域

  1. 在全局对象(window)下的属性与方法
  2. 在最外层声明的变量与方法
  3. 在非严格模式中,函数作用域中未申明直接赋值的属性和方法

我们应该尽量少将变量或方法定义为全局,因为

  1. 我们可能无意间修改了全局变量的值,但其他场景不知道
  2. 容易造成命名冲突
  3. 在应用程序的执行过程中,全局变量的内存无法被释放

知识体系关联:

css 没有作用域的概念,那么也就意味着,每一个组件的样式都有可能影响别的组件布局,因为我们常常会通过一些方式让当前组件具备方为约束,以达到作用域的效果

1
2
3
4
.sidebar .container {
}
.sidebar .content {
}

在 vue 中,也专门提供了 css 作用域的语法,以达到类似效果

1
2
3
4
5
<style scoped>
.example{
color: red;
}
</style>

会被编译为

1
2
3
4
5
6
7
8
9
<style>
.example[data-v-f3f3eg9] {
color: red;
}
</style>

<template>
<div class="example" data-v-f3f3eg9>hi</div>
</template>

有一个细节需要关注一下,在全局声明的变量还存在一些差异。
使用 var 声明的变量,会被挂载到 window 对象下
而使用 const/let 的变量,会被挂载到 script 对象下

函数作用域

函数表达式或函数声明让花括号具备作用域,我们称之为函数作用域。函数作用域中的变量与方法,只能在下层子作用域中被访问,不能被其他不相关的作用域访问。

1
2
3
4
5
6
7
var arr = [1, 2, 3, 4, 5];

for (var i = 0; i < arr.length; i++) {
console.log('do something by ', i);
}

console.log(i); // i == 5

这种方式并不会有作用域的限制,i 挂载在全局对象下,循环结束后,i 还能被访问。
pic.1708405619882

模拟块级作用域

在没有块级作用域之前,我们可以使用自执行函数模拟块级作用域

1
2
3
4
5
6
7
(function () {
for (var i = 0; i < arr.length; i++) {
console.log('do something by ', i);
}
})();

console.log(i); // i is not defined

块级作用域

es6 引入了块级作用域。使用letconst声明的变量,能被任何花括号约束

let

1
2
3
4
{
let a = 10;
}
console.log(a); // Uncaught ReferenceError: a is not defined
1
2
3
for (let i = 1; i <= 5; i++) {
console.log(i); // 1 2 3 4 5
}

不能重复声明

1
2
let a = 10;
let a = 20; // Uncaught SyntaxError: Identifier 'a' has already been declared

不能在声明前访问,因为存在暂时性死区

1
2
console.log(a); // Uncaught ReferenceError: Cannot access 'a' before initialization
let a = 10;

const

const 与 let 差不多,唯一不同的是,const 声明的变量的值不能被修改。

如果是基础数据类型,则值不能被修改。

1
2
const a = 1;
a = 2; // Uncaught TypeError: Assignment to constant variable.

如果是引用数据类型,则地址不能被修改。

1
2
3
4
const a = { n: 1 };
// a = {} // 这样不行
a.n = 2; // 这样可以修改
console.log(a);

let 和 const 的使用原则是,优先使用 const,不能用 const 再用 let。

作用域链

在一个函数内,能够访问哪些变量与函数,都要有一个具体的方式体现出来。因此有了作用域链。

每一个函数都有一个 [[scopes]] 属性,它是由一系列父作用域对象组成的数组。

当一个函数要寻找变量的值是从哪来的,就首先会在当前执行上下文中查找,如果没有找到,就回去作用域链中查找。作用域链存在于函数对象的一个属性 [[scopes]],该属性在代码预解析阶段就已经确定了。

完整作用域链的组成部分

  • Global 全局对象
  • Script 对象:全局环境下 let/const 声明的变量对象(变量和函数)
  • Closure 对象:闭包对象,嵌套函数生成
  • Local 对象:可称为活动对象。只有这个对象是在函数执行过程中才会确定,其它在函数声明是就确定了。local 对象会记录当前函数上下文的变量与函数声明,还会额外记录 this

内存与数据结构

把一个页面看做一个完整的独立应用。

那么,哪些个体参与了应用,个体以什么形式存在的,个体存放在哪里,个体在内存中如何存放?

个体 -> 数据类型 -> 内存 -> 数据结构

个体

个体可以理解为角色,这些角色会参与应用运行
有变量、函数、对象

1
2
3
4
5
6
// 申明变量
var a = 10;
// 申明函数
function add() {}
// 申明对象
class M {}

a、add、M 表示个体的名字。我们可以通过名字,访问到具体的值。

数据类型

7 种基础数据类型和一种引用类型

基础数据类型

基础数据类型:Boolean、Null、Undefined、Number、String、BigInt、Symbol

重点理解 基础数据类型的值,是不可变的

1
2
3
let a = 1;
let b = a;
b++;

将 a 赋予 b,这时候 a 和 b 是等价的。随后 b++,b 的值被改变了,a 的值却没有改变。这意味着 a b 的等价,并不表示他们是同一个值。也就是说,a 赋予 b 的时候,重新给 b 分配了一块内存空间。因此我们说,基础数据类型,是按值访问的。用图表示
pic.1708326860642
基础数据类型的比较,是值在比较

1
2
3
const a = 1;
const b = 1;
a === b; // true

当 a 和 b 在比较的时候,本质上是值在比较。

还有一点要注意,当我们使用字符串时,竟然可以使用方法,这是因为访问字符串时,实际上依然是在访问一个对象。使用字符串调用方法时,经历了以下 3 步骤

1
2
3
4
5
6
7
8
// 首先使用包装对象创建对象
var _str = new String('hello world');

// 然后使用包装对象的实例去访问方法
_str.charAt(0);

// 最后销毁该对象
_str = null;

引用数据类型

与基础数据类型不同,引用类型的值是可以被改变的

1
2
3
4
5
6
7
8
9
const book = {
title: 'JavaScript 核心进阶',
author: '这波能反杀',
date: '2020.08.02',
};
const b2 = book;
b2.author = '反杀';
console.log(book); // {title: "JavaScript 核心进阶", author: "反杀", date: "2020.08.02"}
console.log(b2); // {title: "JavaScript 核心进阶", author: "反杀", date: "2020.08.02"}

我们发现,修改变量 b2 的值,变量 book 也被修改了。这是因为执行 b2=book 时,b2 拷贝的是 book 的地址,所以他们指向的是同样的引用类型值。我们可称之为浅拷贝。
pic.1708326879449

所以说,引用数据类型,是按引用访问的。这里的引用,指的就是内存中的地址。

地址:我们常说的地址,其实指的是内存中的首地址,因为一个引用对象占多少个内存格子是不确定的。内存管理器知道这个引用的大小,那么就能找到首尾地址。

引用类型的比较,本质上是他们的内存地址在比较

1
2
3
4
5
6
7
8
9
10
11
const a = {
name: '张三',
age: 20,
};
const b = a;
b.name = '李四';
a === b; // true

const c = {};
const d = {};
c === d; // false

由于 c 和 d 是两个分开创建的对象,所以他们在内存中的地址是不一样的,所以结果也不同。直接比较引用地址,我们可以称之为浅比较。

还有一种场景,只比较一层数据结构,也被认为是浅比较,这种比较成本比较低,浅拷贝同理。

数据类型判断

判断是否为数组

  1. Array.isArray(arr)
  2. arr instanceof Array
  3. Object.prototype.toString.call(arr) === ‘[object, Array]’

判断是否为对象

  1. isObject
1
2
3
4
function isObject(value) {
const type = typeof value;
return value != null && (type === 'object' || type === 'function');
}
  1. Object.prototype.toString.call(obj) === ‘[object Object]’
  2. obj instanceof Object 数组也成立

内存

运行一段 app/进程,操作系统会分配一段连续的内存空间,在内存中运行。

内存用于储存程序运行时的信息。CPU 通过寄存器直接访问,访问速度非常快。
硬盘可以将大量电影、图片等信息永久存储在硬盘。CPU 不能直接访问硬盘的数据,要通过硬盘控制器来访问。

我们可以将一些数据做持久化储存,也就是常说的本地缓存,其实就是将数据存储在硬盘。在浏览器中,提供了 localStorage 对象来帮助我们实现本地缓存。

内存和硬盘的区别:

对象 容量 访问速度 CPU 能否直接访问 存储时效
内存 程序运行时
硬盘 不能 持久性

JS 中,内存分为栈内存和堆内存。栈内存与堆内存本身没有区别,只因为存取方式的差异,而有了不同。

使用栈内存时,从地址高位开始分配内存空间。使用堆内存时,从地址低位开始分配。

:::info
内存中的地址本应该用 16 进制表示,为了便于理解,此处用 10 进制表示。
:::
pic.1708326895926

内存空间

Bit-比特
Byte-字节 计算机数据类型的最基本单位。1 Byte = 8 Bits
KB-千字节
MB-兆字节
GB-吉字节
TB-太字节

1 GM = 1024 MB
1 TB = 1024 GB

英文字母 占一个字节空间
中文汉字 占两个字节空间
英文标点符号 占一个字节空间
中文标点符号 占两个字节

指针、引用、对象

指针:也叫做首地址。指针占 4 个字节
0xFF
[1][2][3][4][][][][]

引用:指针的别名。在我们前端里,可以认为指针和引用是同个东西。

举个例子:
基础数据类型:

1
2
var a = 10;
a = 20;

0xFF(十六进制。我们这里用十进制代替,更直观)
[10][20][][][][][][]

改为 20 后,引用变量 a 的指针发生改变。10 失去了引用,等待被回收。所以我们说基础数据类型的值是不可变的。
0xFF
[10][20][][][]

引用数据类型:

1
var b = 首地址 -> { m: 10 }

首地址也是个基础数据类型。当我们改变引用类型时,首地址不变。

基础数据结构

基础数据结构主要介绍一下栈、堆、队列

说到栈,其实有两种场景,栈数据结构和栈内存空间。

第一种场景,栈数据结构:表达的是对数据的一种存取方式。

把栈数据结构的存取方式,通过乒乓球盒子来分析
pic.1708326910241
乒乓球依次入栈,5 号球位于最顶层,他一定是最后放进去的。1 号球位于最底层,你要想拿 1 号球,必须将上面所有乒乓球取出来之后才能取出。但 1 号球是最先放入盒子的。

这种存取方式与栈数据结构如出一辙。特点可以总结为先进后出,后进先出(LIFO Last in, First Out)。

javaScript 中,数组提供了两个栈方法来应对这种存取方式。

push:向数组末尾添加元素「进栈方法」
pop:删除数组末尾元素「出栈方法」

第二种场景,栈内存空间。刚刚提到,内存空间,因为操作方式不同才有了区别,而栈内存空间的操作方式,正是使用了栈数据结构的思维。

栈内存空间,用于记录函数的执行记录,管理函数的执行顺序。我们称他为函数调用栈「call stack」

应用场景:有效的括号、两个栈实现队列

堆数据结构:是树结构中的一种。常见的是二叉堆。

二叉堆分为两种类型:最大堆和最小堆

最大堆:堆顶元素是整个堆中的最大值。任何父节点的键值都大于任何一个子节点
最下堆:堆顶元素是整个堆中的最小值。任何父节点的键值都小于任何一个子节点

以最小堆为例:

  • 插入节点时,只能插入二叉堆的最后一个位置。比较节点时,如果插入节点比父节点大,则需要上浮,持续比较,直到比父节点小为止
  • 删除节点时,我们通常只会删除堆顶的元素。删除后结构出现了混乱,需要将最后一个节点补充到堆顶。补充后树结构不符合最小堆的特性,因此需要与子元素进行比较,找到最小的子元素与其交换位置,这个行为称之为下沉。持续比较,知道符合最小堆的规则

应用场景:
数组的 sort 方法,也是采用类似堆的排序逻辑;前中后序遍历;

1
2
3
4
5
6
7
8
9
function sort(a, b) {
// 最小堆(升序)
return a < b;
}

function sort(a, b) {
// 最大堆(降序)
return a > b;
}

栈和堆的区别

  • 分配方式不同:栈有操作系统分配;堆由开发人员分配
  • 存放内容不同:栈用于存放函数的参数值、局部变量、函数返回值等;堆的存放内容由开发人员填充
  • 释放方式不同:栈内存的数据,随生命周期函数的调用结束而结束。堆内存由开发人员释放,容易产生内存泄漏。
  • 空间大小不同:每个进程拥有的栈大小远远小于堆大小。理论上,栈只有几兆大小,堆可以是虚拟内存的大小。

队列

先进先出的思维。在浏览器中,所有的异步事件都是放在任务队列中。

优先级队列是一个重要的概念,决定了哪个任务优先执行。

运用到实践中,有如下常用操作:

  1. 从队列最后入队
  2. 从队列头部出队
  3. 从队列任意位置离队(有其他事情)
  4. 从队列任意位置插队(特殊权利)
  5. 清空队列

应用场景:先到先处理

  • 医院挂号
  • promiseJobs

链表

链表是一种递归的数据结构,由多个节点组成,节点之前使用引用相互关联,组成一根链条。

链表的特征:

  • 在内存中,链表是松散不连续的结构,通过引用确定节点之间的联系,不像数组那样是排列在一起的连续内存地址
  • 链表没有序列,如果引用是单向的,只能通过上一个节点,找到下一个节点
  • 节点之间的引用可以是单向的「单向链表」,也可以是双向的「双向链表」,还可以是首尾连接的「循环链表」

和数组的区别:

  • 在内存空间里,链表是松散的,不连续的。数组是紧密的,连续的
  • 在性能角度考虑,访问某个成员,数组远远优于链表,而新增/删除元素,链表远远优于数组

应用场景:

  1. 节点元素的相互引用,nextSibling、previousSibling
  2. 原型链中,由__proto__进行关联的单向链表
  3. react 源码中的Fiber节点中的nextEffect

内存空间管理

内存溢出:多指栈溢出

内存泄露:指某段内存空间无法被管理

  1. 没有引用,无法被访问
  2. 失去引用时,没被标记,无法被垃圾回收机制回收

思考

基础数据类型存在栈内存,引用数据类型存在堆内存,这句话对不对?
不对。栈内存和堆内存本身并没有区别,只是存取方式的差异而有了不同。基础数据类型可以在栈里,也可以在堆里,函数执行时的参数、变量都在栈里。引用类型的地址在栈里,引用类型的数据在堆里。

变量会存在内存中吗,如果存在,以什么方式存在
不存在。内存中只存在 16 进制的地址编码和具体的数值

m 的值是否被改变?为什么?

1
2
3
4
5
6
7
8
const m = {
a: 1,
b: 2,
};
function foo(arg) {
arg.a = 20;
}
foo(m);

m 被改变了。函数传入一个引用数据类型时,会干扰外部的值,参数变量的地址入栈内存,引用类型对象入堆内存。

为什么使用不可变数据类型:
因为浅比较无法比较出引用数据类型之间的差异

引用类型为什么有地址这个概念?
因为一个内存空间无法记录整个对象的值,所以用首地址(地址)来记录

函数写法

函数声明

  • 函数名不可省略,省略了就变成非法语法
  • 函数声明会存在函数提升
1
function fn() {}

函数表达式

不存在函数提升,在使用前就得定义函数,阅读体验更好

1
const fn = function f() {};

匿名函数

匿名函数在回调参数中常见,可以省略函数名,优点是书写起来容易

1
setTimeout(function () {}, 1000);

匿名函数表达式

1
2
3
4
5
// 写法二:匿名函数表达式
const fn = function () {};

// 写法二:箭头函数写法的匿名函数表达式
const fn = () => {};

立即执行函数表达式(IIFE)

Immediately Invoked Function Expression,函数名不是必须的,完全可以省略。
第一个括号把函数变成了表达式,第二个括号执行了这个函数

1
(function IIFE() {})();

函数调用栈

函数体

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

执行上下文

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

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

博客搭建

hexo + hexo-theme-oranges主题 + Github Pages + 自定义域名

Hexo 环境搭建

  1. 安装 hexo 并初始化,仓库名为 weilun0510.github.io
  2. 更换主题:hexo-theme-oranges
  3. 修改_config.yml
  4. 添加项目说明
  5. 部署:安装 hexo-deployer-git

gitalk 评论

  1. 新建一个仓库 blog-comments
  2. 新建开放认证应用 https://www.cnblogs.com/qisi007/p/13731562.html
  3. 博客配置

注意:配置后,需要手动新建一个 issues 才能使用

效果图:

hexo 源码备份

hexo 发布hexo d后,github 仓库上的 hexo 源代码会消失,只保留构建后的代码,如图所示:

万一 username.github.io本地仓库的代码丢失,后面就无法进行写作。
所以新建一个仓库,用于专门写作,如 blog。后面 username.github.io 项目不动,在 blog 上写作即可。

Pages 自定义域名

  1. 买个域名,如华为云
  2. 域名解析 -> 添加记录集

华为云提供了一种 CNAME 记录的设置方式,这样可以更好地适应服务器 IP 地址的变化。通过使用 CNAME 记录,你的域名将指向 GitHub Pages 的域名,而不是直接指向 IP 地址。这样,GitHub 在 IP 地址变更时会更新其域名的 DNS 记录,而你无需手动调整。

每次部署后自定义域名失效?

图片存储(Github Pages + PicGo )

github 做图床(免费,上限 100G) + picGo 上传图片

缺点:想使用 Github Pages ,仓库不能设置为 private,必须为 Public

实现:

  1. 设置图床
    1. 新建一个仓库 blog-images
    2. 添加 index.html 文件,内容随意
  2. 设置 picGo。我用的 vscode,注意:repo 值得格式为 username/仓库名
    1. 配置参考: https://zhuanlan.zhihu.com/p/138012354
1
2
3
4
5
6
7
8
{
"picgo.picBed.current": "github",
"picgo.picBed.github.branch": "master",
"picgo.picBed.github.token": "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",
"picgo.picBed.github.repo": "weilun0510/blog-images",
"picgo.customUploadName": "pic.${Date.now()}${extName}",
"picgo.picBed.github.path": "2024-02-07/"
}

写作时,picgo.picBed.github.path根据写作日期进行更改,如2424-02-27/,这样图片就会保存在这个目录下。这样做的好处是图片保存路径与写作日期一致,到时想查找这张图片会很方便。

使用:
Uploading an image from clipboard

1
2
3
4
5
Windows/Unix:
ctrl + alt + u

OsX:
Cmd + Opt + u

上传成功后的链接格式: https://raw.githubusercontent.com/weilun0510/blog-images/master/2024-02-07/pic.1707273493092.png

上传失败参考:https://blog.csdn.net/TalesOV/article/details/104450037

参考:

原型和原型链

原型

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

  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 只放少量必要的数据)

参考:

Your browser is out-of-date!

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

×