js事件循环(浏览器篇)|8月更文挑战

113 阅读6分钟

这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战

引子

javascript是单线程的语言,如果设计成多线程语言将会导致DOM操作冲突,假设存在两个线程同时操作一个 DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果。虽然可以使用锁来解决这个问题,但是会大大增加问题的复杂性。单线程就意味着在某一时刻,最多只有一块代码在运行,而实际上js却又是非阻塞的语言,那么js引擎是如何做到单线程又非阻塞的特点呢,答案就是事件循环(event loop)。

浏览器进程相关的线程

为了便于理解js的事件循环,先介绍下和浏览器相关的几个线程
1.GUI渲染线程 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
2.JS引擎线程 就是JS内核,负责处理Javascript脚本程序(例如V8引擎)
3.事件触发线程 当js执行碰到事件绑定和一些异步操作,会走事件触发线程将对应的事件添加到对应的线程中。 4.定时触发器线程 浏览器定时计数器并不是由JavaScript引擎计数的(因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确)
5.异步http请求线程 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由JavaScript引擎执行
以上5个线程只有js引擎线程属于js引擎,其他4个都属于浏览器,后三个线程负责把异步操作完成后的回调事件添加到任务队列中。为了便于描述,将后面三个线程统称为幕后线程.

事件循环

1.当一个js脚本执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么js会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境,这个过程反复进行,直到执行栈中的代码全部执行完毕。 2.如果在第一步过程遇到了异步操作(如ajax请求、settimeout)时,浏览器会把这些代码放到幕后线程中去执行,执行完毕后(例如:setTimeout到期,ajax请求得到响应),将回调函数放入任务队列中等待执行。 3.而当主线程执行完栈中的所有代码后,它就会检查任务队列是否有任务要执行,如果有任务要执行的话,那么就将该任务放到执行栈中执行。如果当前任务队列为空的话,它就会一直循环等待任务到来。因此,这叫做事件循环。

宏任务与微任务

任务队列如果是完全按照先到先处理, 未免显得过于简单粗放,必然满足不了现实的需求,所以,任务队列有两种类型的任务,一种是微任务, 一种是宏任务,微任务的优先级高于宏任务的优先级 。 常见的宏任务:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O 常见的微任务:
  • Promise(重点)
  • process.nextTick(nodejs)
  • Object.observe 事件循环执行流程如下:
  1. 先执行同步代码,宏任务进入宏任务队列,微任务进入微任务队列.
  2. 执行所有的微任务,如果在此过程生成了新的微任务, 继续执行新生成的微任务.
  3. 执行浏览器 UI 线程的渲染工作
  4. 检查是否有 Web Worker 任务,有则执行
  5. 执行队首的宏任务
  6. 回到第 2 步,继续依此循环,直到宏任务和微任务队列都为空

settimeout 为什么不准确

对于这样一段代码setTimeout("alert('对不起, 要你久候')", 3000 ), 初学者总是认为,3秒之后一定会执行alert, 其实setTimeout只是能够保证3秒后alert语句,会进入宏任务队列。此时前面还有多少宏任务,多少微任务都是未知的。所以对于这段代码,alert执行时机永远大于等于3秒。

关于 async/await 函数

async/await在底层转换成了promise和then回调函数。每次我们使用 await, 解释器都创建一个 promise 对象,然后把剩下的 async 函数中的操作放到 then 回调函数中。await 它会“阻塞”后面的代码,但是这个阻塞并不是真正的阻塞,这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成“阻塞”,它内部所有的“阻塞”都被封装在一个 Promise 对象中异步执行。当遇到await时,会阻塞函数内部处于它后面的代码,去执行该函数外部的同步代码,当外部同步代码执行完毕,再回到该函数内部执行剩余的代码, 并且当await执行完毕之后,会先处理微任务队列的代码

经典题目

       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' )

执行结果是:

image.png

事件循环过程

  • 首先执行同步代码打印出来script start, 将settimeout回调函数推入宏任务,执行async1(), 在内部首先执行console.log( 'async1 start' )
  • 接着执行async2(), 然后会打印console.log( 'async2' )
  • await关键字会阻塞后面的代码,去外部执行同步代码,同时将console.log( 'async1 end' )放入微任务队列
  • 进入 new Promise,打印console.log( 'promise1' ), 将then( function () { console.log( 'promise2' ) } )放入事件循环的微任务队列
  • 继续执行同步代码,打印console.log( 'script end' )
  • 此时微任务队列有两个函数, 先执行先放入的console.log( 'async1 end' ), 再执行console.log( 'promise2' )
  • 微任务队列清空后,执行宏任务队列,执行console.log( 'setTimeout' )

总结

javascript单线程的设计是降低了这门语言的复杂度,也是其能发展壮大的主要原因之一,在可预见的未来js都将还是单线程语言,js的非阻塞特性是通过事件循环实现的。所以事件循环是这门语言中非常重要且基础的概念。清楚的了解了事件循环的执行顺序和每一个阶段的特点,可以使我们对一段异步代码的执行顺序有一个清晰的认识,从而减少代码运行的不确定性。