JavaScript – 单线程 与 执行机制 (event loop)
前言
因为在写 RxJS 系列,有一篇要介绍 Scheduler。它需要对 JS 执行机制有点了解,于是就有了这里篇。
参考
知乎 – 详解JavaScript中的Event Loop(事件循环)机制
掘金 – requestAnimationFram和setTimeout执行的先后
掘金 – requestAnimationFrame和requestIdleCallback是宏任务还是微任务
知乎 – 深入解析 EventLoop 和浏览器渲染、帧动画、空闲回调的关系 (必读)
游览器与 JavaScript 的线程
游览器是多线程的
但是呢,负责执行 JavaScript 的却只有一条 JS 线程。
UI 渲染则是 GUI 线程负责,虽然是分开两条线程,但是它们关系又很密切。
JS 阻塞渲染
document.querySelector('h1')!.textContent = 'Hello World'; for (let i = 0; i < 5_000_000_000; i++) {} // 耗时 6 秒
游览器渲染是有周期的,第一句代码虽然更新了 DOM,但游览器并不会马上去渲染 UI。
它会等到所有 JS 执行完毕才去渲染。所以下面的 for loop 执行了 6 秒,那么 6 秒后用户才会看见 h1 变成 'Hello World'。
这就是 JS 阻塞渲染。
JS 不阻塞渲染
h1 { animation: moving 1s ease infinite; } @keyframes moving { from { transform: translateX(0); } to { transform: translateX(100%); } }
JS
for (let i = 0; i < 5_000_000_000; i++) {} // 耗时 6 秒
在这 6 秒中,CSS animation 依然可以跑的顺顺,这是因为 JS 和 UI 是不同线程负责的,它们是可以同时工作的。
JavaScript 的执行机制
first JS code
游览器从 URL 下载到 HTML 后,开始解析。当遇到 <script> 标签后去获取 JS 代码 (inline or src),
然后依据 defer or async 决定什么时候执行。
JS code to 执行栈 (execution stack)
当要执行时,游览器会把 JS 代码放入 exec stack (想象它是一个 box),
exec stack 接获代码后就开始执行,我们先用简单的同步代码为例子
const value = ''; for (const number of [1, 2, 3, 4, 5]) { console.log(number); }
执行完以后,JS 线程休息,轮到 UI 线程去渲染。这样就算完成了一个周期 (执行 JS + 渲染 = 1 周期)
注意:执行完 JS 并不一定会渲染 UI,要看有没有修改 DOM 这些,不过我们先简单假设它们是一套的,这样比较方便解释。
执行异步代码
上面我们以同步代码为例,这里我们换成异步代码 (Ajax)。
执行 JS...遇到 Ajax...发送请求....这时就遇到一个等待的问题。
请求发送以后,需要等待 server response,这可能是一个漫长的过程。如果 JS 线程就傻傻的等,那么就有可能阻塞 UI 渲染 (为了不阻塞, JS 线程一定要尽快的执行完, 完成一个周期),
于是就有了异步这个概念,我们把要等待 response 才能继续执行的代码叫 callback,不需要等待 response 依然能继续执行的代码叫同步代码。
当 exec stack 遇到异步代码后,它会把 callback 存起来,然后继续执行后续的同步代码。这样 JS 线程就不需要傻傻等了。
执行完同步代码后,就渲染 UI 这样一个周期就结束了。callback to event queue
当 response 回来以后,游览器会找出刚才保存的 callback 代码。然后把它放进 event queue (想象它是另一个 box)。
然后等待 exec stack 完成当前的周期,再把 event queue 的代码放进 exec stack,然后周而复始。
其它异步代码
除了上面提到的 Ajax 以外,SetTimeout,Event Listenner,Promise 这些都是异步代码,都有 callback。
Microtask vs Macrotask
异步代码中还有细分 Microtask 和 Macrotask。
Promise.resolve,queueMicrotask,MutaionObserver 属于 Microtask
SetTimeout,Event Listenner, ResizeObserver, IntersectionObserver 属于 Macrotask
它们的执行时机不同。
exec stack 执行完 JS 代码后,会先看 event queue 有没有 Microtask 可以执行。如果有就立刻执行 (before UI render)。没有的话就完成这次周期。
然后去看 event queue 有没有 Macrotask 可以执行 (after UI render)。
注意:
大部分情况,次序是 sync > microtask > ui render > macrotask。
但是 ui render 不一定会发生。
另外 ui render 的时机是很智能的,有时候游览器为了优化体验,会先执行 macrotask 才 render ui。
下面会给详细例子说明,这里我们先理解大部分的情况就好。
Promise, SetTimeout, requestAnimationFrame 触发时机
严格来讲 requestAnimationFrame 不属于 Micro / Macrotask,它 depend on UI render,不 depend on event loop。
上面我们也说了,ui render 不一定会发生,触发时机也比较智(不)能(准)。
requestAnimationFrame(() => { console.log('3. async requestAnimationFrame'); }); window.setTimeout(() => { console.log('4. async macro setTimeout'); }); Promise.resolve().then(() => { console.log('2. async micro Promise'); }); console.log('1. sync console');
结果
Console 是同步代码,立刻执行。
Promise / queueMicrotask 是异步代码,callback 会进 event queue,然后它是 Microtask,exec stack 执行完后会立刻执行 Microtask。
requestAnimationFrame 是异步代码,callback 会进 event queue,它会在 Microtask 之后,ui render 之前触发,大约是 16ms 后执行 (不一定 16ms,游览器有它的节奏)。
SetTimeout 是异步代码 Macrotask,它会进入 event queue,会在 ui render 后触发。(也不一定是 ui render 之后,游览器为了优化体验有可能会提前执行,甚至比 requestAnimationFrame 还早执行)
为什么 SetTimeout 触发时机不精准?
timeout 的计数不是 JS 线程负责的,游览器有一条计数的线程,时间到的时候,callback 会被放入 event queue。
但是并不一定马上执行。如果 exec stack 正忙着,时间自然就被耽搁了,就不精准了。
所以,不管有多少线程帮忙分工,执行 JS 的始终只有一条 JS 线程,还是有许多局限的。
耗时的 CPU 操作导致阻塞
异步的解决思路是靠 callback,JS 线程不等待就不会阻塞。
但是如果我跑 for loop 100亿次呢?JS 线程忙不过来,还是会阻塞 UI 渲染。
所以就有了 Web Worker。Web Worker 和 SetTimeout 是一样的概念 (setTimeout 游览器会给予多一条线程来帮忙计数,于是 JS 主线程就 free 了)
当开启 Web Worker 后,游览器会创建一条线程去处理 JS 代码 (比如 for loop 100亿次),这时 JS 主线程就 free 了。
然后就等 callback 咯。
Microtask, Macrotask, requestAnimationFrame, ui render 触发时机详解
上面我们粗糙的讲了在大部分情况下 Microtask, Macrotask, requestAnimationFrame 的执行顺序,但在一些特殊情况下,它们的顺序是会不同的。
注:下面例子中代表 Microtask 的是 queueMicrotask,代表 Macrotask 的是 setTimeout。
All microtask must clean
所有 Microtask 都必须清空了才会去执行 requestAnimationFrame, ui render 或者 Microtask。
如果 queueMicrotask callback 中又调用了 queueMicrotask 产生了新的 microtask,
这个新的 microtask 依然会先被执行,总之就是要 clear 完 microtask 才会去下一 part。
requestAnimationFrame(() => console.log('我后执行')); // 后 queueMicrotask(() => queueMicrotask(() => console.log('我先执行'))); // 先
Macrotask 不一定在 ui render 之后
假设我们有一个 h1 element,它一开始是 display none,1 秒后要让它 fade in 呈现出来。
我们知道 display none 配 transition 并不会有 fade in 效果,还需要配上 opacity 才行。
CSS
h1 { opacity: 0; transition-property: opacity; transition-duration: 5s; } h1:not(.showing) { display: none; } h1.showed { opacity: unset; }
JS
window.setTimeout(() => { const h1 = document.querySelector('h1')!; h1.classList.add('showing'); // change display from 'none' to 'block' h1.classList.add('showed'); // change opacity from 0 to 1 }, 1000);
效果
没有 fade in 效果,而是直接呈现。
因为上面都是同步代码,跑完后才会一起渲染,showing 和 showed 不能一起渲染,要先 display block,等 ui render 好以后,再 opacity 1,必须渲染 2 次才行。
我们加上 setTimeout
window.setTimeout(() => { const h1 = document.querySelector('h1')!; h1.classList.add('showing'); // change display from 'none' to 'block' window.setTimeout(() => { h1.classList.add('showed'); // change opacity from 0 to 1 }); }, 1000);
效果
还是一样。原因是 setTimeout 并不是发生在 ui render 之后。
我们再加入 requestAnimationFrame 看看它和 setTimeout 谁先执行。(requestAnimationFrame 一定是在 ui render 之前执行的)
window.setTimeout(() => { const h1 = document.querySelector('h1')!; h1.classList.add('showing'); window.setTimeout(() => { console.log('timeout'); h1.classList.add('showed'); }); requestAnimationFrame(() => console.log('animation')); }, 1000);
结果是 timeout 比 requestAnimationFrame 还早执行
由此确定,Macrotask 是有可能比 requestAnimationFrame 和 ui render 更早执行的。
所以,要解决上面的问题,我们需要可以跑 2 次 requestAnimationFrame,第二次的 requestAnimationFrame 100% 会在 ui render 之后执行。
window.setTimeout(() => { const h1 = document.querySelector('h1')!; h1.classList.add('showing'); requestAnimationFrame(() => requestAnimationFrame(() => h1.classList.add('showed'))); }, 1000);
效果
或者 requestAnimationFrame + setTimeout 也可以。
requestAnimationFrame + setTimeout 会比 requestAnimationFrame x 2 更早触发,但我测试了好几次都是在 after ui render 所以应该也可以用。
黑科技
还有一招黑科技
window.setTimeout(() => { const h1 = document.querySelector('h1')!; h1.classList.add('showing'); h1.offsetHeight; // 加上这一句 h1.classList.add('showed'); }, 1000);
为什么可以?
因为 h1.offsetHeight 会导致游览器强制执行 ui render (reflow / repaint) 所以最终也是执行了 2 次 ui render。
另外,这种情况下的 ui render 不会触发导致 requestAnimationFrame 提前触发,它就是个额外执行的 ui render。
冷知识
补上一个冷知识 – 分开监听事件,触发时机会不同
Q&A
1. 游览器是单线程吗?不是,游览器有很多线程。比如 JS 线程,UI 线程,Timer 线程 等等等。
2. JS 会影响 UI 渲染吗?
有些时候会,虽然它们是不同的线程,但是游览器渲染是有周期的。JS 一旦运行,就必须等到它结束后才更新 DOM 做 UI 渲染 (独立的 CSS animation,hover effect 这些就不受影响,可以并行)
3. JS 是单线程吗?
是也不是,JS 只有一条主线程。主线程阻塞就会导致 UI 无法渲染。但是 JS 可以通过 Web Worker 开多几条子线程来帮忙处理耗时的 CPU 计算。
这样就可以减轻主线程的负担,它就不会阻塞了。另外,Web Worker 是不能直接访问 DOM 的,它只能透过主线程间接访问 DOM,从这一点就看得出来它俩的职责是不一样的了。
4. 同步和异步代码是什么?有什么区别?
同步就是一镜到底,没有中断的执行。
异步就是跑了个开头,然后等待 (可以等另一个线程,等 IO,等 Network,等谁不重要),等到时机到了,再运行后续的代码 (callback)。
5. 为什么需要异步?
因为 JS 主线程不能一直跑,一直跑 UI 就一直不渲染,用户就感觉死机了。所以要分段。
另一点是,在等 IO, Network 时,JS 线程本来就没事干。瞎等干什么呢,倒不如去做点别的。
6. Promise,queueMicrotask,SetTimeout,requestAnimationFrame,UI render 谁先触发?
通常是 sync > async Microtask > requestAnimationFrame > UI Render > async Macrotask
但也有可能是 sync > async Microtask > async Macrotask > requestAnimationFrame > UI render
两个变数:
-
event loop 不一定会触发 UI render
-
Macrotask 有可能会被提早到 requestAnimationFrame 之前执行。
另外,ResizeObserver 和 IntersectionObserver 是 after ui render 执行的,而 MutaionObserver 则和 Microtask 一样。
7. SetTimeout 和 requestAnimationFrame 谁先触发?
不好说,因为是游览器控制的。
如果你想看哪个快就先跑哪个,可以用 Promise.race
const timeoutPromise = new Promise<string>(resolve => setTimeout(() => resolve('timeout'))); const requestAnimationFramePromise = new Promise<string>(resolve => requestAnimationFrame(() => resolve('request'))) Promise.race([timeoutPromise, requestAnimationFramePromise]).then(winner => console.log('who first?', winner));
8. queueMicrotask 和 Promise 谁先触发?
它们是同级,你先调用哪个,哪个的 callback 就先跑。
9. queueMicrotask 里面又调用了 queueMicrotask 什么时候执行?
window.setTimeout(() => console.log('timeout')); // 第四 queueMicrotask(() => { console.log('micro1'); // 第一 queueMicrotask(() => { console.log('micro2'); // 第三 }); }); Promise.resolve().then(() => console.log('promise')); // 第二
不管执行过程中又产生了多少 async microtask,
async microtask 一定要被 clear 到完后,才会执行 ui render 或 macrotask。
10. 为什么 interval, timeout 时间不精准?
timer 是准的,只是到点的时候,游览器只把 callback 放入了 event queue。
而 event queue 必须等到 exec stack 完成当前周期 (执行 + 渲染) 后,才会把 callback 交给 exec stack。
如果这时碰巧 exec stack 在忙,那么自然就耽误了。
11. 什么时候需要用 Web Worker?
当你需要处理耗时的 CPU 操作时。
开启 Web Worker 主要的目的不是为了开更多线程分工,从而提高计算速度. (这是目的, 但不一定时主要目的),
更重要的原因是不要让 JS 主线程阻塞影响到 UI 渲染。