事件循环 -- JSConf分享

原文连接javascript

对于浏览器而言,有多个线程协同合做,以下图。具体细节能够参考一帧剖析java

anatomy-of-a-frame

对于常说的JS单线程引擎也就是指的 Main Theradnode

main-thread

注意以上主线程的每一块未必都会执行,须要看实际状况。 先把 Parse HTML -> Composite 的过程称为渲染管道流 Rendering pipelinegit

浏览器内部有一个不停的轮询机制,检查任务队列中是否有任务,有的话就取出交给 JS引擎 去执行。github

例如:web

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();
复制代码

过程:segmentfault

eventloop

任务队列 Tasks Queue

一些常见的 webapi 会产生一个 task 送入到任务队列中。api

  • script 标签
  • XHRaddEventListener 等事件回调
  • setTimeout 定时器

每一个 task 执行在一个轮询中,有本身的上下文环境互不影响。也就是为何,script 标签内的代码崩溃了,不影响接下来的 script 代码执行。promise

  • 轮询伪代码以下(原视频中使用pop,便于 JSer 的世界观改用 shift)
while(true) {
  task = taskQueue.shift();
  execute(task);
}
复制代码
  • 任务队列未必维护在一个队列里,例如 input eventsetTimeoutcallback 可能维护在不一样的队列中。 代码若是操做 DOM,主线程还会执行渲染管道流。伪代码修改以下:
while(true) {
+ queue = getNextQueue();
- task = taskQueue.shift();
+ task = queue.shift();
  execute(task);
  
+ if(isRepaintTime()) repaint();
}
复制代码
  • 举个例子
button.addEventListener('click', e => {
  while(true);
});
复制代码

点击 button 产生一个 task,当执行该任务时,一直占用主线程卡死,该任务没法退出,致使没法响应用户交互或渲染动态图等。浏览器

改换执行如下代码

function loop() {
  setTimeout(loop, 0);
}
loop();
复制代码

看似无限循环执行 loopsetTimeout 到时后产生一个 task。执行完 loop 即退出主线程。使得用户交互事件和渲染可以得以执行。

正由于如此,setTimeout 和其余 webapi 产生的 task 执行依赖任务队列中的顺序。 即便任务队列没有其余任务,也不能作到 0秒 运行,setTimeout 定时器到时间 cb 入任务队列,在轮询取出 task 给引擎执行,最少大约 4.7ms

requestAnimationFrame

  • 举个例子,不停移动一个盒子向前1像素
function callback() {
  moveBoxForwardOnePixel();
  requestAnimationFrame(callback);
}

callback()
复制代码

换成 setTimeout

function callback() {
  moveBoxForwardOnePixel();
- requestAnimationFrame(callback);
+ setTimeout(callback, 0);
}

callback()
复制代码

对比,能够发现 setTimeout 移动明显比 rAF 移动快不少(3.5倍左右)。 意味着 setTimeout 回调过于频繁,这并非一件好事。

渲染管道流不必定发生在每一个 setTimeout 产生的 task 之间,也可能发生在多个 setTimeout 回调以后。 由浏览器决定什么时候渲染而且尽量高效,只有值得更新才会渲染,若是没有就不会。

若是浏览器运行在后台,没有显示,浏览器就不会渲染,由于没有意义。大多数状况下页面会以固定频率刷新, 保证 60FPS 人眼就感受很流畅,也就是一帧大约 16ms。频率高,人眼看不见无心义,低于人眼能发现卡顿。

在主线程很空闲时,setTimeout 回调能每 4ms 左右执行一次,留 2ms 给渲染管道流,setTimeout 一帧内能执行大概 3.5次3.5ms * 4 + 2ms = 16ms

setTimeout

setTimeout 调用次数太多 3-4次,多于用户可以看到的,也多于浏览器可以显示的,大约3/4是浪费的。 不少老的动画库,用 setTimeout(animFrame, 1000 / 60)来优化。

setTimeout16

setTimeout 并非为动画而生,执行不稳定,会产生飘移或任务太重会推迟渲染管道流。

broken

requestAnimationFrame 正是用来解决这些问题的,使一切整洁有序,每一帧都按时发生。

happy

推荐使用 requestAnimationFrame 包裹动画工做提升性能。它解决这个 setTimeout 不肯定性与性能浪费的问题,由浏览器来保证在渲染管道流以前执行。

  • 一个困惑的问题:如下代码能实现先从 0px 移动到 1000px 处,再到 500px 处吗?
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';
  box.style.transform = 'translateX(500px)';
});
复制代码

结果:从 0px 移动到 500px 处。因为回调任务的代码块是同步执行的,浏览器不在意中间态。

  • 修改以下
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';
- box.style.transform = 'translateX(500px)';

+ requestAnimationFrame(() => {
+ box.style.transform = 'translateX(500px)';
+ });
});
复制代码

结果:依然从 0px 移动到 500px 处。

这是由于在 addEventListenertask 中同步代码修改成 1000px。 在渲染管道流中的计算样式执行以前,须要执行 rAF,最终的样式为 500px

  • 正确修改,在下一帧的渲染管道流执行以前修改 500px
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';

  requestAnimationFrame(() => {
- box.style.transform = 'translateX(500px)';
+ requestAnimationFrame(() => {
+ box.style.transform = 'translateX(500px)';
+ });
  });
});
复制代码
  • 很差的方式,但也能达到效果
button.addEventListener('click', () => {
  box.style.transform = 'translateX(1000px)';
  box.style.transition = 'transform 1s ease-in-out';
+ getComputedStyle(box).transform;
  box.style.transform = 'translateX(500px)';
});
复制代码

getComputedStyle 会致使强制重排,渲染管道流提早执行,多余操做损耗性能。

  • bad news

EdgeSafarirAF 不符合规范,错误的放在渲染管道流以后执行。

微任务 Microtasks

DOMNodeInserted 初衷被设计用来监听 DOM 的改变。

  • 例如如下代码,会触发多少次 DOMNodeInserted
document.body.addEventListener('DOMNodeInserted', () => {
  console.log('Stuff added to <body>!');
});

for(let i = 0; i < 100; i++) {
  const span = document.createElement('span');
  document.body.appendChild(span);
  span.textContent = 'hello';
}
复制代码

理想 for 循环完毕后,DOMNodeInserted 回调执行一次。 结果:执行了 200 次。添加 span 触发 100 次,设置 textContent 触发 100。 这就让使用 DOMNodeInserted 会产生极差的性能负担。 为了解决此等问题,建立了一个新的任务队列叫作微任务 Microtasks

常见微任务

  1. MutationObserver —— DOM变化事件的观察者。
  2. Promise
  3. process.nextTick (node 中)

微任务是在一次事件轮询中取出的 task 执行完毕,即 JavaScript 运行栈(stack)中已经没有可执行的内容了。 浏览器紧接着取出微任务队列中全部的 microtasks 来执行。

  • 若是用微任务建立一个像以前的 loop 会怎样?
function loop() {
  Promise.resolve().then(loop);
}

loop();
复制代码

你会发现,它跟以前的 while 同样卡死。

如今咱们有了3个不一样性质的队列

  1. task queue
  2. rAF queue
  3. microtask queue
  • task queue 前面已知,事件轮询中取出一个 task 执行,若是产生new task 入队列。task 执行完毕等待下一次轮询取出next task
  • microtask queue task 执行完毕后,执行队列中全部 microtask,若是产生new microtask,入队列,等待执行,直到队列清空。
while(true) {
  queue = getNextQueue();
  task = queue.shift();
  execute(task);
  
+ while(microtaskQueue.hasTasks()) {
+ doMicrotask();
+ }
	  
  if(isRepaintTime()) repaint();
}
复制代码
  • rAF queue 每一帧渲染管道流开始以前一次性执行完全部队列中的 rAF callback,若是产生new rAF 等待下一帧执行。
while(true) {
  queue = getNextQueue();
  task = queue.shift();
  execute(task);
  
  while(microtaskQueue.hasTasks()) {
      doMicrotask();
  }
  
- if(isRepaintTime()) repaint();
+ if(isRepaintTime()) {
+ animationTasks = animationQueue.copyTasks();
+ for(task in animationTasks) {
+ doAnimationTask(task);
+ }
+ 
+ repaint();
+ }
}
复制代码
  • 思考,检验一下本身是否理解了
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 1'));
  console.log('Listener 1');
});

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 2'));
  console.log('Listener 2');
});
复制代码

点击按钮会是怎么样的顺序呢?

来分析一下,以上代码块为一个 task 0

  1. task 0 执行完毕后,webapi 监听事件。
  2. 用户点击按钮,触发 click 事件,task queue 中入队 task 1task 2
  3. 轮询取出 task 1 执行,Microtask queue 入队 Microtask 1console 输出 Listener 1task 1 执行完毕。
  4. 执行全部的 microtask(目前只有 Microtask 1),取出执行,console 输出 Microtask 1
  5. 轮询取出 task 2 执行,Microtask queue 入队 Microtask 2console 输出 Listener 2task 2 执行完毕。
  6. 执行全部的 microtask,取出 Microtask 2 执行,console 输出 Microtask 2

答案:Listener 1 -> Microtask 1 -> Listener 2 -> Microtask 2

若是你答对了,那么恭喜你,超越了 87% 的答题者。

answer

  • 若是是代码触发呢?
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 1'));
  console.log('Listener 1');
});

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('Microtask 2'));
  console.log('Listener 2');
});

+ button.click();
复制代码

思路同样分析

  1. task 0 执行到 button.click() 等待事件回调执行完毕。
  2. 同步执行 Listener 1Microtask queue 入队 Microtask 1console 输出 Listener 1
  3. 同步执行 Listener 2Microtask queue 入队 Microtask 2console 输出 Listener 2
  4. click 函数 return,结束 task 0
  5. 执行全部的 microtask,取出 Microtask 1 执行,console 输出 Microtask 1
  6. 取出 Microtask 2 执行,console 输出 Microtask 2

答案:Listener 1 -> Listener 2 -> Microtask 1 -> Microtask 2

在作自动化测试时,须要当心,有时会产生和用户交互不同的结果。

  • 最后来点难度的的题

如下代码,用户点击,会阻止a连接跳转吗?

const nextClick = new Promise(resolve => {
  link.addEventListener('click', resolve, { once: true });
});
nextClick.then(event => {
  event.preventDefault();
  // handle event
});
复制代码

若是是代码点击呢?

link.click();
复制代码

暂不揭晓答案,欢迎评论区讨论。

node

  1. 没有脚本解析事件(如,解析 HTML 中的 script)
  2. 没有用户交互事件
  3. 没有 rAF callback
  4. 没有渲染管道(rendering pipeline)

node 不须要一直轮询有没有任务,清空全部队列就结束。

常见任务队列 task queue

  1. XHR requests、disk read or write queue(I/O)
  2. check queue (setImmediate)
  3. timer queue (setTimeout)

常见微任务 microtask queue

  1. process.nextTick
  2. Promise

process.nextTick 执行优先级高于 Promise

while(tasksAreWaiting()) {
  queue = getNextQueue();
  
  while(queue.hasTasks()) {
    task = queue.shift();
    execute(task);
    
    while(nextTickQueue.hasTasks()) {
      doNextTickTask();
    }
    
    while(promiseQueue.hasTasks()) {
      doPromiseTask();
    }
  }
}
复制代码

web worker

  • 没有 script tag
  • 没有用户交互
  • 不能操做 DOM

相似 node

参考

  1. Further Adventures of the Event Loop - Erin Zimmer@JSConf EU 2018
  2. In The Loop - Jake Archibald@JSconf 2018
  3. 动图-事件循环
相关文章
相关标签/搜索