【前端进阶】深入浅出浏览器事件循环【内附练习题】

8,254 阅读9分钟

引子:为什么会有事件循环

重点: javascript 从诞生之日起就是一门单线程的非阻塞的脚本语言

我们先来聊下 JavaScript 这两个特点:

  • 单线程: JavaScript 是单线程的,单线程是指 JavaScript 引擎中解析和执行 JavaScript 代码的线程只有一个(主线程),每次只能做一件事情。单线程存在是必然的,在浏览器中, 如果 javascript 是多线程的,那么当两个线程同时对 dom 进行一项操作,例如一个向其添加事件,而另一个删除了这个 dom,这个时候其实是矛盾的

  • 非阻塞: 当我们的 Javascript 代码运行一个异步任务的时候(像 Ajax 等),主线程会挂起这个任务,然后异步任务返回结果的时候再根据特定的结果去执行相应的回调函数

如何做到非阻塞呢?这就需要我们的主角——事件循环(Event Loop

浏览器中的事件循环

我们看一个很经典的图,这张图基本可以概括了事件循环(该图来自演讲—— 菲利普·罗伯茨:到底什么是Event Loop呢? | 欧洲 JSConf 2014)后面演示用的 Loupe 也是该演讲者写的((Loupe是一种可视化工具,可以帮助您了解JavaScript的调用堆栈/事件循环/回调队列如何相互影响))

javascript 代码执行的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针

执行栈(call stack: 当我们调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有作用域,上层作用域的指向,方法的参数,这个作用域中定义的变量以及这个作用域的this对象。 而当一系列方法被依次调用的时候,因为js是单线程的,同一时间只能执行一个方法,于是这些方法被排队在一个单独的地方。这个地方被称为执行栈

比如,如下是一段同步代码的执行

function a() {
    b();
    console.log('a');
}
function b() {
    console.log('b')
}
a();

我们通过 Loupe 演示下代码的执行过程:

  • 执行函数 a()先入栈
  • a()中先执行函数 b() 函数b() 入栈
  • 执行函数b(), console.log('b') 入栈
  • 输出 b, console.log('b')出栈
  • 函数b() 执行完成,出栈
  • console.log('a') 入栈,执行,输出 a, 出栈
  • 函数a 执行完成,出栈

同步代码的执行过程是相对比较简单的,但涉及到异步执行的话,又是怎样的呢?

事件队列(callback queue): js 引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js 会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列

被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码

Loupe 官方的一个例子:

$.on('button', 'click', function onClick() {
    setTimeout(function timer() {
        console.log('You clicked the button!');    
    }, 2000);
});

console.log("Hi!");

setTimeout(function timeout() {
    console.log("Click the button!");
}, 5000);

console.log("Welcome to loupe.");

我们分析一下这个执行的过程:

  • 首先是,注册了点击事件,异步执行,这个时候会将它放在 Web Api
  • console.log("Hi!") 入栈,直接执行,输出 Hi
  • 执行 setTimeout,异步执行,将其挂载起来
  • 执行 console.log("Welcome to loupe."), 输出 Welcome to loupe.
  • 5 秒钟后,setTimeout 执行回调,将回调放入到事件队列中,一旦主线程空闲,则取出运行
  • 我点击了按钮【这里我只操作了一次】,触发了点击事件,将点击事件的回调放入到事件队列中,一旦主线程空闲,则取出运行
  • 运行点击事件回调中的 setTimeout
  • 2 秒钟后,setTimeout 执行回调,将回调放入到事件队列中,一旦主线程空闲,则取出运行

再回头看看这张图,应该有种豁然开朗的感觉

以上的过程按照类似如下的方式实现,queue.waitForMessage() 会同步地等待消息到达(如果当前没有任何消息等待被处理),故我们称之为事件循环(Event Loop

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

微任务和宏任务

微任务——Micro-Task

常见的 micro-task: new Promise().then(callback)MutationObserve 等(asyncawait)实际上是 Promise 的语法糖

宏任务——Macro-Task

常见的 macro-tasksetTimeoutsetIntervalscript(整体代码)、 I/O 操作、UI 交互事件、postMessage

事件循环的执行顺序

异步任务的返回结果会被放到一个事件队列中,根据上面提到的异步事件的类型,这个事件实际上会被放到对应的宏任务和微任务队列中去

Eveent Loop 的循环过程如下:

  • 执行一个宏任务(一般一开始是整体代码(script)),如果没有可选的宏任务,则直接处理微任务
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 执行过程中如果遇到宏任务,就将它添加到宏任务的任务队列中
  • 执行一个宏任务完成之后,就需要检测微任务队列有没有需要执行的任务,有的话,全部执行,没有的话,进入下一步
  • 检查渲染,然后 GUI 线程接管渲染,进行浏览器渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务...(循环上面的步骤)

如下图所示:

执行顺序总结:执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环

为了更好的理解,我们来看一个例子

console.log('start')

setTimeout(function() {
  console.log('setTimeout')
}, 0)

Promise.resolve().then(function() {
  console.log('promise1')
}).then(function() {
  console.log('promise2')
})

console.log('end')

我们来分析一下:

  • 执行全局 script,输出 start
  • 执行 setTimeout 压入 macrotask 队列,promise.then 回调放入 microtask 队列,最后执行 console.log('end'),输出 end
  • 全局 script 属于宏任务,执行完成那接下来就是执行 microtask 队列的任务了,执行 promise 回调打印 promise1
  • promise 回调函数默认返回 undefinedpromise 状态变为 fullfill 触发接下来的 then 回调,继续压入 microtask 队列,event loop 会把当前的microtask 队列一直执行完,此时执行第二个 promise.then` 回调打印出promise2
  • 这时 microtask 队列已经为空,接下来主线程会去做一些 UI 渲染工作(不一定会做),然后开始下一轮 event loop,执行 setTimeout 的回调,打印出 setTimeout

故最后的结果如下:

start
end
promise1
promise2
setTimeout

练习题

增加这个环境在于,现在面试笔试都会出事件循环的题目,实际上的可能比上面的例子难,原因在于微任务和宏任务涉及的知识点不少,这就需要我们进一步巩固我们的基础知识,我相信能够认真对待以下题目的,都能够更好的掌握事件循环

我就暂不做分析,大家不懂的有疑问的可以在评论区一起交流

题目一

console.log('start');
setTimeout(() => {
    console.log('children2');
    Promise.resolve().then(() => {
        console.log('children3');
    })
}, 0);

new Promise(function(resolve, reject) {
    console.log('children4');
    setTimeout(function() {
        console.log('children5');
        resolve('children6')
    }, 0)
}).then((res) => {
    console.log('children7');
    setTimeout(() => {
        console.log(res);
    }, 0)
})
点击查看答案 start children4 children2 children3 children5 children7

题目2

const p = function() {
    return new Promise((resolve, reject) => {
        const p1 = new Promise((resolve, reject) => {
            setTimeout(() => {
                resolve(1)
            }, 0)
            resolve(2)
        })
        p1.then((res) => {
            console.log(res);
        })
        console.log(3);
        resolve(4);
    })
}


p().then((res) => {
    console.log(res);
})
console.log('end');
点击查看答案 3 end 2 4

题目3

async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2(){
    console.log('async2')
}
console.log('script start')
setTimeout(function(){
    console.log('setTimeout') 
},0)  
async1();
new Promise(function(resolve){
    console.log('promise1')
    resolve();
}).then(function(){
    console.log('promise2')
})
console.log('script end')
点击查看答案 script start async1 start async2 promise1 script end async1 end promise2 setTimeout

题目4

let resolvePromise = new Promise(resolve => {
  let resolvedPromise = Promise.resolve()
  resolve(resolvedPromise);
  // 提示:resolve(resolvedPromise) 等同于:
  // Promise.resolve().then(() => resolvedPromise.then(resolve));
})
resolvePromise.then(() => {
  console.log('resolvePromise resolved')
})
let resolvedPromiseThen = Promise.resolve().then(res => {
  console.log('promise1')
})
resolvedPromiseThen
  .then(() => {
    console.log('promise2')
  })
  .then(() => {
    console.log('promise3')
  })
点击查看答案 promise1 -> promise2 -> resolvePromise resolved -> promise3

题目5

console.log('script start');

setTimeout(() => {
  console.log('Gopal');
}, 1 * 2000);

Promise.resolve()
.then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});


async function foo() {
  await bar()
  console.log('async1 end')
}
foo()

async function errorFunc () {
  try {
    // Tips:参考:https://zh.javascript.info/promise-error-handling:隐式 try…catch
    // Promise.reject()方法返回一个带有拒绝原因的Promise对象
    // Promise.reject('error!!!') === new Error('error!!!')
    await Promise.reject('error!!!')
  } catch(e) {
    console.log(e)
  }
  console.log('async1');
  return Promise.resolve('async1 success')
}
errorFunc().then(res => console.log(res))

function bar() {
  console.log('async2 end') 
}

console.log('script end');
点击查看答案 script start async2 end script end promise1 async1 end error!!! async1 promise2 async1 success Gopal

题目6

new Promise((resolve, reject) => {
  console.log(1)
  resolve()
})
.then(() => {
  console.log(2)
  new Promise((resolve, reject) => {
      console.log(3)
      setTimeout(() => {
        reject();
      }, 3 * 1000);
      resolve()
  })
    .then(() => {
      console.log(4)
      new Promise((resolve, reject) => {
          console.log(5)
          resolve();
        })
        .then(() => {
          console.log(7)
        })
        .then(() => {
          console.log(9)
        })
    })
    .then(() => {
      console.log(8)
    })
})
.then(() => {
  console.log(6)
})
点击查看答案 1 2 3 4 5 6 7 8 9

题目7

console.log('1');

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

Promise.reject().then(() => {
  console.log('13');
}, () => {
  console.log('12');
})

new Promise((resolve) => {
  console.log('7');
  resolve();
}).then(() => {
  console.log('8')
})

setTimeout(() => {
  console.log('9');
  Promise.resolve().then(() => {
    console.log('10');
  })
  new Promise((resolve) => {
    console.log('11');
    resolve();
  }).then(() => {
    console.log('12')
  })
})
点击查看答案 1 7 12 8 2 4 9 11 3 5 10 12

总结

本文从 JS 的两个特点:单线程以及非阻塞介绍了事件循环的必要性,因为事件循环在浏览器和 Node.js 的表现是很大不一样的,本人只谈论到了浏览器中的事件循环,并介绍了微任务和宏任务,以及它们的执行流程,最后通过 7 道题目帮助大家巩固知识

大家喜欢的话,别忘了点赞关注~

往期优秀文章推荐

参考

详解JavaScript中的Event Loop(事件循环)机制

深入理解NodeJS事件循环机制

并发模型与事件循环

【前端体系】从一道面试题谈谈对EventLoop的理解

菲利普·罗伯茨:到底什么是Event Loop呢? | 欧洲 JSConf 2014

JavaScript中的Event Loop(事件循环)机制

JS事件循环机制(event loop)之宏任务/微任务

深入理解js事件循环机制(浏览器篇)

从面试题看 JS 事件循环与 macro micro 任务队列