带着问题看这篇文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3)
});
});
new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data);
})
setTimeout(() => {
console.log(6);
})
console.log(7);

// 结果:1475236

JS Runtime 的几个概念

call stack 调用栈

heap 堆

一大块内存区域(通常是非结构化的),对象被分配在堆中

task queue 消息队列

JS运行时包含了一个消息队列,每个消息队列关联着一个用于处理这个消息的回调函数。(队列的特点是先进先出)

  1. 当调用栈为空时,event loop会消息队列中的下一个消息
  2. 被处理的消息被移出队列,
  3. 消息被作为参数调用与之关联的回调函数
  4. 同时为该函数调用向调用栈添加一个新的栈帧
  5. 调用栈再次为空时,event loop会重复1-4步骤

通常,task queue中的任务被称为:macrotask 宏任务.

以下几种异步API的回调属于宏任务

Single Thread 单线程

Non-blocking 非阻塞

不被抢占

每个消息被完整的执行后,其他消息才会被执行。

优点:当一个函数执行时,它不会被抢占,只有在它运行完毕后才会去运行其他代码,才能修改这个函数操作的数据。

缺点:当一个消息需要太长时间才能处理完,浏览器就无法处理用户交互,eg.滚动和点击,这也是性能较差的网页“卡顿现象”的原因。

因此良好的操作方式是:缩短单个消息处理时间,在可能的情况下尽量将一个消息裁剪成多个消息。以保证浏览器 60 frames per second 的流畅渲染,即每个消息处理时间 < 1000ms/60=16ms,

Event Loop 事件循环

event loop是一个执行模型,在不同的地方有不同的实现。浏览器和NodeJS基于不同的技术实现了各自的Event Loop。

浏览器EventLoop运行机制(不考虑microtask)

添加消息

setTimeout(fn,n)

debug 一个 demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// demo
function bar(){
debugger
console.log('bar')
foo()
}
function foo(){
debugger
console.log('foo')
setTimeout(function(){
debugger
console.log('setTimeout')
},1000)
}
(function all(){
debugger
console.log('anounymous')
bar()
})()

原理图

知识延伸:webWorker & 跨运行时通信

postMessage:

1
2
3
// eg. 当一个窗口可以获得另一个窗口的引用时,例如targetWindow = window.opener

otherWindow.postMessage(message, targetOrigin, [transfer]);

otherWindow:其他窗口的引用:

message:要发送到其他窗口的数据,会被结构化克隆算法序列化

targetOrigin:用来指定哪些窗口能接收到消息事件

transfer:一串和message 同时传递的 Transferable 对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

结构化克隆算法:

用于克隆复杂对象

不能克隆:Error、Symbol、Function对象、DOM节点

不能克隆:属性的描述符、RegExp对象的 lastIndex字段、原型链上的属性

Transferable对象:

一个抽象接口,代表可以在不同可执行上下文中传递的对象。(抽象:没有定义任何属性和方法)

不同执行上下文:例如主线程和webworker之间。

ArrayBuffer 、MessagePort 和 ImageBitmap 实现于此接口。

接收消息:

1
2
3
4
5
6
7
8
window.addEventListener("message", receiveMessage, false);

function receiveMessage(event)
{
// event.data:传递来的对象
// event.origin:消息发送方窗口的origin
// event.source:对消息发送窗口的引用
}

UI Rendering Task & 性能优化

浏览器渲染 - Rendering Task步骤

render blocking 渲染阻塞

具体来讲,如果js runtime 的 call stack 一直不能清空,例如event loop将一个耗时的回调放进了call stack,会导致浏览器主线程被占用,无法执行render相关的工作,用户交互的事件也被添加在消息队列等待调用栈清空得不到执行,因此无法响应用户的操作,造成阻塞渲染的“卡顿”现象。

60FPS

在event loop处理消息队列时,我们提倡要缩短单个消息处理时间,在可能的情况下尽量将一个消息裁剪成多个消息,rendering task 可以在消息之间执行,以保证保证UI Rendering调用的频率能达到 60 frames per second (UI Rendering Task执行次数通常是每秒60次,但在大多数遵循W3C建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。),即每次event loop处理消息执行回调所占用的时间 小于 16.67 毫秒。

demo1:

看下面这段代码,先 append 一个元素再设置display=none去隐藏这个元素,不必担心这个元素会闪现,因为这两行代码会在某一次event loop中执行,只有这两行代码执行完,并且清空了当前调用栈,才有可能执行下一次UI Render task

1
2
document.body.appendChild(el)
el.style.display='none'

demo2:

下面这段代码,重复的显示隐藏一个元素,看起来开销很大,但其实在RenderingTask期间,只会取最终结果来渲染,

1
2
3
4
5
6
7
8
9
10
11
button.addEventListener ('click,()=>{
box style. display='none';
box style. display ='block';
box style. display ='none';
box style. display ='block';
box style. display='none';
box style. display ='block';
box style. display ='none';
box style. display ='block';
box style. display ='none';
})

requestAnimationFrame

demo1:requestAnimationFrame优化动画的一个例子

1
2
3
4
5
6
7
8
9
10
11
12
// 使用RAF
function callback(){
moveBoxForwardOnePixel();
requestAnimationFrame(callback)
}
callback();

// 使用setTimeout
function callback(){
moveBoxForwardOnePixel();
setTimeout(callback,0)
}

效果:

demo2:用RAF控制动画执行顺序,需求是box元素的水平位置变化:1000→500

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
button addEventListener ('click,()=>{
box.style.transform = 'translateX(1000px)'
box.style.transition= 'transform 1s ease-in-out'
box.style.transform = 'translateX(500px)'
})

//由于上述代码会一起执行,
//因此渲染时,1000px会被忽略,浏览器会取500作为最终值,在下一帧渲染,
//因此上述代码的效果是:元素位移0->500

//换一种写法
button addEventListener ('click,()=>{
box.style.transform = 'translateX(1000px)'
box.style.transition= 'transform 1s ease-in-out'

requestAnimationFrame(()=>{
box.style.transform = 'translateX(500px)'
})
})
// 上述代码,1000的初始值是有效的,
//但是在下一次的rendering task期间,由于RAF先执行,因此500将1000覆盖
//最终渲染的效果还是元素位移:0->500

//如何令500在下下一次渲染再生效?嵌套调用RAF
button addEventListener ('click,()=>{
box.style.transform = 'translateX(1000px)'

requestAnimationFrame(()=>{
requestAnimationFrame(()=>{
box.style.transition= 'transform 1s ease-in-out'
box.style.transform = 'translateX(500px)'
})
})
})

可视化:event loop和rendering

理想的状态

setTimeout的浪费

间隔调用setTimeout的效果:导致浪费

以前的动画仓库的处理方式:setTimeout(animFrame, 1000/60)

但是这种处理方式不稳定,可能会不准确,因为

RAF的稳定有序状态

MicroTask 微任务

微任务,microtask,也叫jobs。

微任务 异步类型

一些异步任务执行完成后,其回调会依次进入microtask queue,等待后续被调用,这些异步任务包括:

⭐event loop运行机制(含microtask)

event loop中任务的执行顺序:

  1. 同步代码执行,直至调用栈清空
  2. microtask:调用栈清空后,优先执行所有的microtask,如果有新的microtask,继续执行新microtask,直至microtask queue清空
  3. task queue:执行task queue第一个任务,后续的task暂不处理
  4. 每当调用栈清空后,重复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
Promise.resolve().then(()=>{
console.log('microtask 1')
})
Promise.resolve().then(()=>{
console.log('microtask 2')
})
console.log('sync code')
setTimeout(()=>{
console.log('macro task 1')
Promise.resolve().then(()=>{
console.log('microtask 3')
})
},0)
setTimeout(()=>{
console.log('macro task 2')
},0)

//结果:
//sync code 同步代码优先执行
//microtask 1 同步代码执行完后,调用栈清空,优先执行 microtask
//microtask 2 同上
//macro task 1 调用栈清空,microtask queue清空,此时可以执行一个位于队首的macro task,执行期间新增一个microtask
//microtask 3 调用栈清空后,由于存在microtask,因此优先执行microtask
//macro task 2 最后执行macro task,清空task queue

流程图

demo1:调用栈未清空,不执行microtask

在控制台中执行一段代码,会当做同步代码来处理。listener1执行后,微任务队列+1,但是因为是同步执行的代码,所以会立即执行listener2,微任务队列+1,所以顺序是listener1,listener2,microtask1,microtask2

demo2:调用栈清空后,microtask 优先于 macro task执行

同步执行两个setTimeout,会将 listener1和listener2加入到task queue,同步代码执行就结束。先执行listener1,将microtask1加入微任务队列,listener1执行完后,调用栈清空,即使这时候task queue还有listener2,也会先执行所有微任务,将所有微任务清空后,再执行listener2,因此输出顺序是 listener1,microtask1,listener2,microtask2

demo3:同demo2

用户点击事件

由于点击事件会被添加到task queue,因此,这个 demo3 的结果和 demo2 结果相同

demo4:同demo1

js调用click()事件

由于是在代码中手动执行click,所以会同步执行两个listener,因此demo4和demo1结构相同。

demo5:micro 优先于 macro执行

demo6:综合实例

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
// 浏览器中执行
console.log(1);
setTimeout(() => {
console.log(2);// callback2,setTimeout属于宏任务
Promise.resolve().then(() => {
console.log(3)// callback3,Promise.then属于微任务
});
});
new Promise((resolve, reject) => {
console.log(4)// 这里的代码是同步执行的
resolve(5)
}).then((data) => {
console.log(data);// callback5,Promise.then属于微任务
})
setTimeout(() => {
console.log(6);// callback6,setTimeout属于宏任务
})
console.log(7);

// 结果:1475236

// 逻辑:
147是同步执行,同步代码执行完后的queue:
task queue:callback2,callback6
microtask:callback5
此时调用栈已清空,优先执行微任务callback5,调用栈清空
再执行callback2,调用栈清空
此时的queue:
task queue:callback6
microtask:callback3
优先执行微任务callback3,调用栈清空
最后执行callback6

demo7:综合实例

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
console.log('main start');

setTimeout(() => {
//cb1
console.log('1');
Promise.resolve().then(() => {
//cb2
console.log('2')
});
}, 0);

Promise.resolve().then(() => {
//cb3
console.log('3');
Promise.resolve().then(() => {
//cb4
console.log('4')
});
});

console.log('main end');

//结果:
// main start,main end,3412

main start 和 main end同步执行,同步代码执行完后,调用栈清空,此时的queue:
task queue:cb1
microtask queue:cb3
先执行微任务cb3,执行完后,调用栈清空,此时的queue:
task queue:cb1
microtask queue:cb4
先执行微任务cb4,执行完后,调用栈清空,此时的queue:
task queue:cb1
microtask queue:空
最后执行cb1,然后执行cb2

rendering task的执行顺序
在上面的event loop执行机制中,没有提到rendering task,是因为rendering task是由浏览器自行去决定何时运行的,与当前设备的屏幕刷新率等因素相关,确定的是:

macrotask、microtask、animation task的区别,可以看在下面的动图中横向对比:

参考资料