进阶篇:事件循环、宏任务、微任务

JavaScript 的一大特点就是单线程设计,即一次只能完成一个任务(若有多个任务要执行,则必须按照任务队列中的顺序来执行),这一特点也造成了 JavaScript 的众多特性。这样设计可以使程序的开发更加简单,因为其不会涉及线程的通信、管理,比如在操作 DOM 的时候,如果是多线程,就需要组织各个线程的先后关系,但是单线程就不会涉及这些问题。这也使得 JavaScript 成为了一门入门比较简单的语言。

浏览器的进程和线程

进程(浏览器)

以 Chrome 浏览器为例,它有 5 个主要进程:

  • 浏览器进程(Browser Process): 顶层进程,负责浏览器各进程工作,Tab 外的工作由它负责。

    • UI 线程(UI Thread):负责浏览器按钮、地址栏;

    • 存储线程(storage Thread):负责文件访问;

  • 渲染器进程(Renderer Process): 浏览器内核,负责 Tab 内的所有工作;

    • 主线程(Main Thread):构建 Dom 树 -> 加载资源 -> Js 下载与执行 -> 样式计算 -> 构建布局树 -> 绘制 -> 创建层树。注意: Main 不是一个线程,而是多个线程的集合;

    • Worker 线程(Worker Thread):Web Worker、Share Worker 运行在这个线程,可能存在多个;

    • 合成器线程(Compositor Thread):将层合成帧,分成多个磁贴;

    • 栅格线程(Raster Thread):栅格化磁贴后交给GPU;

  • 网络进程(Network Process): 负责真正的接收和发送网络请求;

  • 插件控制进程(Plugin Process): 控制所有的插件;

  • GPU 进程(GPU Process):Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 CSS 的 3D 效果,只是随后网页、UI 界面都选择采取 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 也引入了 GPU 进程。

主线程(渲染器)

渲染器进程的主线程是一些线程的集合,主要用于整个网页的工作。它包括:

  • GUI 渲染线程:负责渲染工作,包括解析 HTML、CSS、构建 DOM 树和 RenderObject 树、布局和绘制等。

  • Js 引擎线程:负责解析 Javascript 脚本,运行代码,如 Chrome 的 V8 引擎。一个 Tab 页内中无论什么时候都只有一个 Js 线程在运行 Js。

  • 事件触发线程:主要用来控制事件循环,添加回调事件到队列中。当 Js 引擎执行代码块,如 setTimeOut 时(也可能是来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等),会被添加到事件线程中。 当对应的事件符合触发条件并被触发时,该线程会把事件添加到任务队列的队尾,等待 Js 引擎的处理。

  • 定时触发器线程:是指 setIntervalsetTimeout 执行的线程。由于 Js 引擎是单线程的,如果由 Js 来计时会影响计时准确性,因此额外使用一个线程来计时并触发定时。定时器到时间后就会把回调函数放到任务队列中,等待 Js 引擎处理。

  • 异步 Http 请求线程:XMLHttpRequest 连接后会新开一个线程。 当检测到状态变更时,如果设置有回调函数,则该函数放到 JavaScript 引擎的任务队列中等待处理。当然,实际做请求工作的还是上述提到的网程进程。

注意:GUI 渲染线程与 Js 引擎线程是互斥的,当 Js 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存等到 Js 引擎空闲时立即被执行,所以当 Js 执行的时间过长,页面的渲染会阻塞。

注意: setIntervalsetTimeout 的计时并不是很准确的,其误差在(10~20ms),其影响因素有:

  • 在 Chrome 底层中规定 setInterval 的最低时间为 4ms。

  • windows 等系统底层的时间并不是完全准确的,普通的时间 API 误差在 10~15ms(部分情况)。

  • 由于 JavaScript 引擎是单线程,即使回调完全准确的将任务加入执行队列,但是前面的任务的执行时间仍然会增加回调任务的误差。

JavaScript运行时

在执行 JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理。每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合、一个任务队列以及一个微任务队列构成。除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其它组成部分对该代理都是唯一的。

执行上下文

当一段 JavaScript 代码在运行的时候,它实际上是运行在执行上下文中。

每一个上下文在本质上都是一种作用域层级。每个代码段开始执行的时候都会创建一个新的上下文来运行它,并且在代码退出的时候销毁掉。

以下类型的代码会创建一个新的执行上下文:

  • 全局上下文是为运行代码主体而创建的执行上下文,也就是说它是为那些存在于 JavaScript 函数之外的任何代码而创建的。
  • 每个函数会在执行的时候创建自己的执行上下文。这个上下文就是通常说的 “本地上下文”。
  • 使用 eval() 函数也会创建一个新的执行上下文。

执行上下文栈

执行栈,也就是在其它编程语言中所说的“调用栈”,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。

  • JavaScript 执行在单线程上,所有的代码都是排队执行。
  • 一开始浏览器执行全局的代码时,首先创建全局的执行上下文,压入执行栈的顶部。
  • 每当进入一个函数的执行就会创建函数的执行上下文,并且把它压入执行栈的顶部。当前函数执行完成后,当前函数的执行上下文出栈,并等待垃圾回收。
  • 浏览器的 Js 执行引擎总是访问栈顶的执行上下文。
  • 全局上下文只有唯一的一个,它在浏览器关闭时出栈。

任务队列

Javascript 是单线程的语言,当有多个任务需要执行时,任务会排成一队,形成了队列,称为任务队列。任务队列遵循先进先出原则,也就是按照出现的先后顺序执行任务,先进入的任务先执行。

事件循环

事件循环是 Javascript 处理任务的一种执行机制,是一个在 JavaScript 引擎等待任务、执行任务、进入休眠状态等待更多任务状态之间转换的无限循环。

JavaScript 每个代理都是由事件循环驱动的。事件循环负责收集事件(包括用户事件以及其他非用户事件等)、对任务进行排队以便在合适的时候执行回调,然后它执行所有处于等待中的 JavaScript 任务(宏任务),然后是微任务,然后在开始下一次循环之前执行一些必要的渲染和绘制操作。

事件循环按任务队列中的顺序,一个接一个地处理它们。在当前迭代轮次中,只有那些当事件循环过程开始时已经处于任务队列中的任务会被执行。其余的任务不得不等待到下一次迭代。

一个宏任务执行开始到下一个宏任务执行开始,叫做一次事件循环。

宏任务

一个任务就是由执行诸如从头执行一段程序、执行一个事件回调或一个 setTimeout、setInterval 被触发之类的标准机制而被调度的任意 JavaScript 代码。这些都在任务队列上被调度。

HTML 规范中,没有宏任务的描述。宏任务的概念,应该是社区为了区别微任务,而创造出来的。

在以下时机,任务会被添加到任务队列:

  • 一段新程序或子程序被直接执行时(比如从一个控制台、或在一个 script 元素中运行代码);
  • 触发了一个事件,将其回调函数添加到任务队列时,如 UI 交互事件(click)、Http 请求、I/O、postMessage 等;
  • 执行到 setTimeout()、setInterval()、setImmediate(NodeJs)定时器时,其相应的回调函数会在到时间后,添加到任务队列时。

宏任务执行的间隙,如果有微任务,则浏览器先执行微任务,然后执行 DOM 渲染。

在一个宏任务的执行时,永远不会进行任何 DOM 渲染,只有完成后才进行。 如果一个宏任务执行时间过长,浏览器会抛出一个如 “页面未响应” 之类的警报,这种情况常发生在有大量复杂的计算或有死循环。

注意: 第一个宏任务队列中只有一个任务,即主线程上的 Js 代码;如果主线程上的 Js 代码有异步任务(如回调事件、定时器),会被加到宏任务队列;宏任务中可以创建微任务,但是在宏任务中创建的微任务不会影响当前宏任务的执行。

微任务

微任务可以理解是在当前任务(宏任务)执行结束后立即执行的任务。也就是说,在当前任务后,下一个任务之前、且在渲染之前执行。

微任务来源有以下形式:

  • 由 promise 创建的:promise 对象 resolve/reject 之后,调用了 then、catch、finally 方法,会立即将这些方法内部的函数作为微任务,添加到微任务队列中(注意:必须先 resolve、reject);
  • process.nextTick(NodeJs)、Vue.$nextTick(Vue);
  • async/await 函数也会创建微任务:await 之后的代码(不包括 await 所在行)都会作为微任务异步执行(await 当前行的代码会立即执行,它后面的代码作为微任务异步执行,相当于应用了 .then 方法);
  • Generator 函数;
  • DOM 中的 MutationObserver 触发后的回调函数;
  • queueMicrotask (func),它手动添加函数到微任务队列。

每个宏任务之后,Js 引擎会立即执行微任务队列中的所有任务,然后 DOM 渲染(如果有更新),然后再执行其他的宏任务。

注意: 微任务上创建的微任务,会加到微任务列表末尾,仍会阻碍后方将要执行的宏任务队列;由微任务创建的宏任务,会被加到宏任务队列末尾。

setTimeout、setInterval

setTimeout() 方法设置一个定时器,该定时器在定时器到期后执行一个函数或指定的一段代码。

setTimeout(function[, delay, arg1, arg2, ...])

setInterval() 方法重复调用一个函数或执行一个代码段,在每次调用之间具有固定的时间延迟。

setInterval(function[, delay, arg1, arg2, ...])

注意: setTimeout、setInterval 都会返回一个正整数,表示定时器的 ID,两者的是共用一个 ID 池。技术上,clearTimeout() 和 clearInterval() 清除定时器 ID 都是可以的。但是,为了避免混淆,不要混用取消定时函数。

注意: setTimeout、setInterval 方法都了接收一个回调函数和一个延迟时间作参数,还可以接收其他参数作为回调函数的传参(IE9 及更早的 IE 浏览器不支持)。

setTimeout、setInterval 都会创建新的宏任务,加到任务列表末尾。 连续设置 2 个相同时间的 setTimeout,两个并不会一起执行,而是创建两个宏任务,依次执行。

关于 this 问题

setTimeout()、setInterval 调用的代码运行在与所在函数完全分离的执行环境上。这会导致,这些代码中包含的 this 关键字在非严格模式会指向 window(或全局)对象,严格模式下为 undefined,这和所期望的 this 的值是不一样的。

最小延迟

在浏览器中,setTimeout()、setInterval() 的每调用一次定时器的最小间隔是4ms,这通常是由于函数嵌套导致(嵌套层级达到一定深度),或者是由于已经执行的 setInterval 的回调函数阻塞导致的。

在 Chrome 和 Firefox 中, 定时器的第 5 次调用被阻塞了;在Safari是在第 6 次;Edge是在第 3 次。(??)

一直以来,不同浏览器中出现这种最小延迟的情况有所不同 - 从其他地方调用了setInterval( ),或者在嵌套函数调用 setTimeout( ) 时(嵌套级别达到特定深度时),都会出现超时延迟。

超时延迟

除了"最小延时"之外,定时器仍然有可能因为当前页面(或者操作系统/浏览器本身)被其他任务占用导致延时。 需要被强调是, 直到调用 setTimeout() 的主线程执行完其他任务之后,回调函数和代码段才能被执行。

未被激活的tabs的定时最小延迟>=1000ms

为了优化后台 tab 的加载损耗(以及降低耗电量),在未被激活的 tab 中定时器的最小延时限制为 1 秒(1000ms)。(不同浏览器可能有差异)。

最大延时值

包括 IE、Chrome、Safari、Firefox 在内的浏览器,其内部以 32 位带符号整数存储延时。这就会导致如果一个延时大于 21 4748 3647 毫秒(大约 24.8 天)时就会溢出,其效果相当于延时设置为 0,导致定时器将会被立即加入到任务列表。

相关问题

任务队列和回调函数的区别?

  • Javascript 单线程任务分为同步任务、异步任务:同步任务会在调用栈中按照顺序等待主线程依次执行;异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列,等主线程空闲的时(调用栈被清空)执行。
  • 异步任务都能注册回调函数:如 setImmediate(NodeJs)、MessageChannel、setTimeout、setInterval、 process.nextTick 、new Promise().then、MutationObserver 等等。
  • 任务队列分为:宏任务队列、微任务队列。

渲染进程中的三个异步队列

  • 宏任务队列:每次事件循环只执行一个,事件循环间隙可以执行微任务和 DOM 渲染。

  • 微任务队列:每次事件循环会把微任务队列的微任务全部执行完,才会继续执行下一宏任务。如果微任务本身又新增微任务,也会一直执行下去。

  • Animation callbacks:requestAnimationFrame 也属于异步执行的方法,但它既不属于宏任务,也不属于微任务。它仍然属于宏任务范围内,是在微任务队列执行完毕后、DOM 渲染之前才执行。

    requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。

    requestAnimationFrame(() => {
        console.log(2)
    })
    setTimeout(() => {
        console.log(1)
    }, 0)
    new Promise(function(resolve){
        resolve()
    }).then(function(){
        console.log(4)
    })
    console.log(3)
    

    输出结果:3 4 1 2。

    每次事件循环执行 Animation callbacks 队列里的全部任务,连续调用两次 requestAnimationFrame,会在同一次事件循环执行;但是任务本身又新增 Animation callback 是放到下一个事件循环执行,不会阻塞。

Promise的运行机制

new Promise((resolve) => {
    console.log(1)
    resolve()
}).then(() => {
    console.log(2)
}).then(() => { 
    console.log(3)
}) 
console.log(4)

输出结果:1 4 2 3。

准确来说,new promise() 不属于微任务,.then( ) 才是会被归类到任务队列里的微任务。new promise() 里的代码会按宏任务顺序执行下来,不会被放到微任务队列里。

注意: .then 要等到 resolve() 之后加入微任务列表。??

注意: .then 中 return Promise.resolve,会产生类似两轮的微任务。

Promise.resolve().then(() => {
    return Promise.resolve(1)
})
    .then(res => {
    console.log(res)
})

Promise.resolve().then(() => {
    console.log(2)
})
    .then(() => {
    console.log(3)
})
    .then(() => {
    console.log(4)
})
    .then(() => {
    console.log(5)
})

输出结果:2 3 4 1 5。

await也是微任务?

async function async1() {
    console.log(1)
}

async function async2() {
    console.log(2)
    await async1()
    console.log(3)
}
// 等价于
async function async3() {
    console.log(2)
    async1().then(() => {
        console.log(3)
    })
}

当执行到 await 时,会先执行 await 右侧代码,然后把 await 后面的所有代码放到微任务队列!

请记住,await 后面的代码都属于的微任务!

示例题

Javascript 代码执行过程大致是:在文档中遇到 script 标签(多个 script 标签可当作合成一个 script 标签??),就加到宏任务队列中,然后执行该宏任务,当执行过程中遇到异步任务,就把加到宏任务队列中,要是遇到微任务就加到到微任务列表中,继续往下执行,直到该宏任务完成。

然后先去看微任务队列是否有任务,有则先执行完微任务队列的所有任务,然后进行 DOM 渲染。

再去宏任务例表中拿任务来执行,直到任务列表为空,但即使没有可执行任务,它也一直在监听任务列表,一旦其他线程加入了宏任务到任务列表,它又开始执行任务。

一个简单的示例题:

setTimeout(function(){
    console.log(1)
})
new Promise(function(resolve){
    console.log(2)
    resolve()
}).then(function(){
    console.log(3)
})
console.log(4)

输出结果:2 4 3 1。

一个复杂点的示例题:

new Promise((resolve) => { // p1
    console.log(1)
    resolve()
})
  .then(() => { // t1
    new Promise(resolve => {
        console.log(2)
        resolve()
    })
      .then(() => { // t11
          console.log(3)
      })
      .then(() => { // t12
          console.log(6)
      })
  })
  .then(() => { // t2
      console.log(4)
    new Promise((resolve) => {
        console.log(5)
        resolve()
    })
      .then(() => { //t21
          console.log(7)
        new Promise((resolve) => {
            console.log(8)
            resolve()
        })
        .then(() => {
            console.log(10)
          }) // t211
    })
      .then(() => { //t22
          console.log(11)
      })
  })
  .then(() => { //t3
      console.log(9)
  })

输出结果:1 2 3 4 5 6 7 8 9 10 11。

理解上述代码的执行时序,要注意两点:

  • Promise 函数体内的代码是同步执行的,then 函数体内的代码才是微任务。
  • 后一个 then 必需等先等前一个 then 的代码全部执行完(如果有异步代码,加入到相应的任务队列后,也算是执行完成)。

其步骤如下(个人理解):

  • 初始化时,执行 new Promise 函数内的代码,将 then(t1)加微任务队列([t1])。
  • 执行微任务 t1,将 t11 加入微任务队列([t11]),此时,t1 没有需要执行的代码,继续执行同级的下一个 then(t2),将 t2 加入微任务队列([t11], t2)。
  • 执行微任务 t11,将 t12 加入微任务队列([t2, t12])。
  • 执行微任务 t2,将 t21 加入微任务队列([t12, t21]),继续执行同级的下一个 then,将 t3 加入微任务队列([ t12, t21, t3]) 。
  • 执行微任务 t12,微任务队列([t21, t3])。
  • 执行微任务 t21,将 t211 加入微任务队列([t3, t211]),继续执行同级的下一个 then,将 t22 加入微任务队列 ([t3, t211, t22])。
  • 执行微任务 t3,微任务队列([t211, t22])。
  • 执行微任务 t211,微任务队列([t22])。
  • 执行微任务 t22,微任务队列为空,所有代码执行结束。

参考资料

JavaScript宏任务,微任务与Event-loop

MDN 深入:微任务与Javascript运行时环境

MDN 在 JavaScript 中通过 queueMicrotask() 使用微任务

requestAnimationFrame是一个宏任务么

事件循环、宏任务、微任务一网打尽(附超多经典面试题)

MDN window.setTimeout

© lizhao all right reserved,powered by Gitbook文件修订时间: 2022-05-29 00:44:50

results matching ""

    No results matching ""