-
打个分吧:

Javascript & 异步机制

长文 + 动图 + 实例,试图解释清楚:Javascript Event Loop如何调度异步任务

10分钟阅读
-
-

带着问题看这篇文章

  • 我们写的各种回调什么时候执行?按照什么顺序执行?
  • setTimeout(cb,0)和Promise.resolve().then(cb)谁的回调先执行?
  • Javascript的单线程是如何实现异步并发的?
  • Event Loop到底是如何调度任务的?
  • 如何利用RAF优化性能?
  • 下面这段代码输出是什么?回答不对的朋友,看完这篇文章也许你的思路就会清晰~
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 调用栈

  • 定义:调用栈是浏览器的JavaScript解释器追踪函数执行流的一种机制,函数调用形成了一个由若干帧组成的栈。(栈的特点是后进先出)
  • 作用:通过调用栈,我们能够追踪:哪个函数正执行;执行的函数体中又调用了哪个函数;以及每一帧的上下文+作用域
  • 机制:
    • 每调用一个函数,就把该函数添加进调用栈并执行
    • 如果正在调用的函数还调用了其他函数,把新函数也添加到调用栈中,立即执行
    • 执行完毕后,解释器会将函数清除出栈,继续执行当前执行环境下剩余的代码
    • 当分配的调用栈被占满时,会引发“Stack Overflow堆栈溢出”错误

heap 堆

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

task queue 消息队列

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

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

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

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

  • setTimeout
  • MessageChannel
  • postMessage
  • setImmediate

Single Thread 单线程

  • 单线程 = 单调用栈 = one thing at a time,不能并发,一次只能做一件事
  • 为什么单线程能实现异步和并发?
  • 因为单线程指的是js runtime
  • 而浏览器和Node提供了API,使我们可以调用其他线程去做并发的异步任务,例如网络请求、DOM、setTimeout

Non-blocking 非阻塞

  • blocking:阻塞,是指浏览器在等待耗时长的代码(eg.网络请求,I/O)期间,不能处理任何其他事情,包括用户响应。
  • 解决阻塞的方法:异步任务
  • 异步任务怎么实现的?依赖的就是异步APIevent loop事件循环
  • JavaScript的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞,所以当一个应用正等待一个异步任务时,它仍然可以处理其它事情,比如用户输入。(由于历史原因有一些例外,如 alert 或者同步 XHR,但应该尽量避免使用它们,例外的例外也是存在的(但通常是实现导致的错误而非其它原因)。

不被抢占

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

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

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

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

Event Loop 事件循环

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

  • 浏览器的Event Loop模型是在html5的规范中明确定义的,具体的实现由浏览器厂商来做。
  • NodeJS的Event Loop是基于libuv实现的。可以参考Node的官方文档以及libuv的官方文档

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

  • 所有同步任务都在主线程上执行,形成一个call stack调用栈
  • 可以通过浏览器API调用 运行在其他线程的异步任务
  • 主线程之外,存在一个待处理消息的消息队列task queue。每一个消息都关联着一个用以处理这个消息的回调函数。
  • 当主线程调用栈中的所有同步任务执行完毕,系统就会读取task queue,取最先进的消息作为参数,将其关联的回调函数放入主线程调用栈中执行

添加消息

  • 浏览器中,如果一个事件有事件监听器,事件被触发后,一个消息就会被添加到消息队列中。
  • 除了事件,浏览器提供的其他API,例如setTimeout、xhr等异步任务,都会在任务结束后向消息队列添加消息

setTimeout(fn,n)

  • setTimeout 中的第二个参数n是指 消息被加入消息队列的最小延迟
  • 因此,不是保证回调在n毫秒内必须执行,而是保证回调在n毫秒之后被添加到消息队列,具体什么时候执行,取决于消息队列中待处理的消息 和 调用栈中已有的函数。
  • 零延迟setTimeout 0 的作用:将回调立即放入消息队列,而不是0s内立即执行

debug 一个 demo

// 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 & 跨运行时通信

  • 每个 WebWorker 、跨域的 **iframe 、**浏览器不同窗口都有各自的运行时,即都有各自的 call stack 、heap、queue。
  • 不同的运行时,可以通过 postMessage 方法来通信。

postMessage:

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

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

otherWindow:其他窗口的引用:

  • iframe的contentWindow
  • 执行window.open返回的窗口对象
  • 通过window.frames获取到的子frame窗口对象

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

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

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

结构化克隆算法:

用于克隆复杂对象

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

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

Transferable对象:

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

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

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

接收消息:

window.addEventListener("message", receiveMessage, false);

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

UI Rendering Task & 性能优化

浏览器渲染 - Rendering Task步骤

  • requestAnimationFrame API(在chrome,火狐,符合WEB标准)
  • style calculation 计算样式
  • layout 计算布局
  • paint 实际渲染像素数据
  • requestAnimationFrame API(在edge,safari)

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

document.body.appendChild(el);
el.style.display = "none";

demo2:

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

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

  • 简称RAF,是一个web api,要求浏览器在下一次重绘之前调用指定的回调函数,通常用于执行动画
  • 通过RAF,使浏览器可以在单次回流和重绘中优化处理并发动画,每次UI刷新之前执行RAF,使动画帧率更高
  • 当requestAnimationFrame() 运行在后台标签页或者隐藏的<iframe> 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命

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

// 使用RAF
function callback() {
  moveBoxForwardOnePixel();
  requestAnimationFrame(callback);
}
callback();

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

效果:

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

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,等待后续被调用,这些异步任务包括:

  • Promise.then
  • MutationObserver
  • process.nextTick (Node独有)
  • Object.observe

⭐event loop运行机制(含microtask)

event loop中任务的执行顺序:

  1. 同步代码执行,直至调用栈清空
  2. microtask:调用栈清空后,优先执行所有的microtask,如果有新的microtask,**继续执行新microtask,**直至microtask queue清空
  3. task queue:执行task queue第一个任务,后续的task暂不处理
  4. 每当调用栈清空后,重复2-3步骤

两个重点:

  • 微任务阻塞浏览器:如果执行微任务期间,不停的有新的微任务,会导致浏览器阻塞
  • 微任务的执行会因为JS堆栈的情况有所不同,要根据调用栈是否清空去判断微任务是否会执行。

一个直观的例子:

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:综合实例

// 浏览器中执行
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:综合实例

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是由浏览器自行去决定何时运行的,与当前设备的屏幕刷新率等因素相关,确定的是:

  • RAF 在 rendering task 初始期间执行
  • 如果定义了多个 RAF 回调,会被加入到 Animation queue中,在UI Rendering 期间,会清空 Animation queue,与 microtask 不同的是,如果清空 Animation queue 期间,有新的 animation task 被加入到 queue 中,此次 rendering task 执行期间,不会处理新的 animation task。

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

参考资料

上次更新:

评论区