阅读前注意: 通常我们说宏任务(MacroTask), 这里的任务(Task)指的就是宏任务.
事件循环
算是众所周知的事情吧. js是一个单线程语言. 通过事件循环来实现异步编程.
对于js运行时来说, js运行时需要维护一组用来执行js代码的代理. 每个代理都由事件循环(Event Loop)驱动, 由一组执行上下文的集合, 执行上下文栈, 主线程, 一/多个任务队列和一个微队列以及一组可能创建用于执行worker的额外的线程集合构成.
除了主线程, 其它的组成部分对于代理是唯一的.
代理通常指window, worker, worklet.
任务队列实际上是一个set而不是queue, 事件循环处理模型会取出第一个可运行的任务, 而不是取出第一个任务.
一次事件循环
浏览器的事件循环会维护一些变量:
- 当前正在运行的任务(可以是空任务, 初始值为null)
- 微任务队列
- 微任务检查点(boolean, 初始值为false)
- last render opportunity time(最近一次渲染的时间, 初始值为0)
- last idle period start time(最近一个空闲期的开始时间, 初始值为0)
浏览器的一次事件循环大概是这样子:
- 执行一个任务
- 清空微任务队列
- 渲染检查点
- 检查相关变量(如页面是否可见, 离上次渲染时间是否接近等)
- 如果不需要渲染, 那么跳过
- 如果需要, 那么渲染
function updateRendering() {
if (!needRender() || !isVSyncTime()) {
return;
}
runRAF();
reCalculateStyles();
performLayout();
paint();
// ...
}- 空闲检查点
- 如果离下一帧还有时间, 或者判定处于空闲时间, 打卡记录last idle period start time
- 启动requestIdleCallback, callback里有个ddl, ddl.timeRemaining()就是剩余空闲时间
剩余时间约等于
last idle period start time+ 16.67ms - 当前时间, 也就是离下次渲染大概还有多少时间
requestIdleCallback((ddl) => {
while (ddl.timeRemaining()) {
// do something
}
})任务队列与微队列
在这些组成部分中, 主要关注任务队列和微队列以及主线程.
一个任务就是指计划由标准机制来执行的任何js代码. 如程序的初始化, 事件触发的回调等.
浏览器拿到一份js代码时, 会生成一个任务放进任务队列中, 之后取出并执行.
当一个任务执行完毕后, 就会检查微任务队列是否为空. 如果有微任务, 那么一个一个地取出微任务并执行, 直到微任务队列为空(即使中途微任务又产生了微任务到微任务队列).
微任务队列不是任务队列.
浏览器的任务队列
js运行时要求需要微任务队列和任务队列.
为了较大程度地复用任务队列, 浏览器内部维护了一些任务源.
- DOM任务源
- 用户交互任务源
- 网络任务源
- 导航与横移任务源
- 渲染任务源
- 延时任务源
不同的任务源会放进不同的任务队列中, 浏览器自己可以决定优先从哪个队列取出任务.
其中, 用户交互任务源优先级最高. 这些任务源主要是为了唤醒主线程.
当一些事件完成时(如网络请求完成了, 系统发起了vSync信号, 计时器到了等), 相应的任务源就会产生任务放进任务队列唤醒主线程, 然后执行事件循环.
主线程阻塞
由于一般我们写的代码根浏览器的渲染相关代码通常运行在同一个线程/事件循环中, 如果我们的代码阻塞了, 浏览器的渲染也会跟着阻塞.
如果确实会存在这种情况(如高强度计算等), 并且不是bug, 那么可以考虑使用web worker, 让主线程开一个新线程来运行脚本.
关于卡死
一般卡死指无法渲染或者无法交互, 这里有一些代码.
while (true) {
// 毫无意外地会卡死, 直到break
}
function p() {
// 无限递归, 但是不会栈溢出
// 同样会卡死, 浏览器会一直尝试清空微任务队列
queueMicrotask(() => {
p()
})
}
function s() {
// 无限递归, 但是不会栈溢出
// 不会卡死, 不会阻止交互和渲染
setTimeout(() => {
s()
}, 0)
}不过这些只能卡死主线程, 卡不死合成线程. transform相关的动画依旧会进行.