刨根问底事件循环,一条公式让你快速判断

152 阅读11分钟

先讲讲异步

时间间隙

程序的一部分现在运行,而另一部分将来运行,处于现在和将来的这段时间称之为时间间隙。 比如输入登录信息到登入成功,比如for循环计数到循环结束,这些现在正在发生的事件和将来要发生的事件之间的间隙就叫做时间间隙。

异步

在写代码的过程中我们经常会遇到这样的情况:程序中将来要执行的部分并不一定在现在执行的程序执行完之后立即执行。换句话说就是,我们不希望等待现在执行的部分执行完再去执行将来要执行的部分。 如下:ajax请求不是同步完成的,所以我们想要拿到ajax返回的data,则需要等待ajax执行完毕,我们可以通过传入一个回调函数实现让程序等待的过程。

ajax('https:xxxx.xxx.x', function callback(){
    console.log(data)
})

但这样会出现锁定UI(按钮、菜单、滚动条等),并阻塞所有用户交互。这是无法容忍的。

分块的程序

js程序写在.js文件中,整个程序几乎是由多个块组成的,而最常见的块单位是函数。

/***现在***/
// 程序块1
function now(){
  return 21
}
// 程序块2
function later(){
  sum = sum * 2
  console.log("I'll find you later", sum)
}

var sum = now()
setTimeout(later, 1000);

// 可以解读为
// 现在
function now(){
    return 21
}
function later(){...}
var count = now()
setTimeout(later, 1000);

// 将来
count = count * 2
console.log("I'll find you later", count)

现在这部分的程序块会在程序运行之后立即执行,但是settimeout设置了一个定时事件在将来执行,所以later函数会在将来(1000后)某个时间段后执行。任何时候只要把一段代码包装成一个函数,并指定他在响应某个事件(定时器、鼠标事件、ajax等)时执行,就相当于在代码中创建了一个将来执行的程序块,也就是引入了异步机制。

小细节

console.*簇并不是javascript的一部分,而是由宿主环境添加到javascipt中的。因此,某些环境下,congosle.log并不会把传入的内容立即输出,其主要原因是,在许多非Javascript程序中,I/O是非常低速的阻塞部分,浏览器在后台异步处理控制台I/O能够有效的提高性能。

var obj = {
    index: 1
}
console.log(obj)

obj.index++

多数情况下能输出{index:1},与预期一致,但是部分浏览器可能会认为需要将控制台I/O延迟到后台,这个时候可能obj.index++已经执行了,因此会输出{index: 2}。具体什么时候会控制台I/O会延迟,甚至是否能被观察到都是游移不定的,如果遇到这种少见的情况,可以优先选择断点调试,或者把对象序列化到一个对6中,以强制执行一次快照,比如通过JSON.stringify

单线程语言

js是一门单线程语言,也就是说他的代码块具有原子性,比如foo()一旦开始执行,那他所有代码都会在下一个函数执行之前执行完成,或者相反。它相对于多线程语言来说更加具有确定性,但是这种确定性也是相对的。依照前面铺垫的知识可以知道js代码块的执行顺序是可以遵循宿主环境的调度机制的,而不是一定按照表达式顺序来执行的,这也就带来了不确定性。

函数执行顺序的不确定性称之为竞态条件。foo和bar的执行顺序是互相竞争的,如果无法可靠的预测foo和bar的最终结果,所以才是竞态条件。

事件循环

javascript引擎并不是单独执行的,它运行在宿主环境中。javascript本身是没有事件概念的,只是一个按需执行的任意代码片段,“事件”调度是由宿主环境进行的。宿主环境提供了一种机制来处理程序中多个块的执行,且执行每个块时执行javascript引擎,这种机制被称为事件循环

// eventloop事件队列
// 特点:先进先出
const eventloop = []
let event=null

while(true){
    if(eventloop.length>0){
    // 拿到队列中的第一个事件(先进先出)
        event = eventloop.shift()
        try{
            event()
        }catch(err){
            reportError(err)
        }
    }
}

每个循环称为一个任务tick,如果队列中仍有等待的事件,则每次都会从事件队列里拿出下一个事件并执行,这些时间就是你的回调函数。

settimeout并没有把回调函数挂在事件队列里中,他所做的是设定一个定时器,当定时器到时候才把你的回调函数放到事件循环中,在未来某个时刻的tick会取出这个函数执行。

小细节

为什么settimeout的精度不高? 没有抢占的方式支持settimeout中的回调一定会排在首位,如果先前已经有20个其他的任务在排队了,那settimeout里的事件就只能等待,它得排在其他事件的后面。settimeout只能保证你的回调不会再某个事件之前执行,但是不能保证它一定会在那个事件点执行,它可能在那之后执行,具体是根据事件队列的状态而定的。

事件任务分类

  • 我们把事件任务分为三大类:主线程任务=>宏任务=>微任务
    1. 常见宏任务:正常的异步任务都是宏任务,最常见的就是定时器(setInterval, setImmediate, setTimeout)、I/O任务
    2. 常见微任务:微任务出现比较晚,queueMicrotask、Promise.then和async/await

事件循环公式

  1. 查找主线程上的代码
  2. 遇到微任务,将微任务放到微任务队列里
  3. 遇到宏任务,将宏任务放到宏任务队列里
// 从普通函数开始讲起
function fun1(){
    console.log('普通函数1')
}

console.log('script start')

fun1()

script start ->普通函数1

js执行到第一行代码,读取到函数声明fun1,23行读取函数fun1的内容,确定不包含微任务or宏任务代码,第5行执行语句输出script start,第七行执行命名函数fun1的内容,输出普通函数1 像这样既不属于微任务也不属于宏任务的就是基本的主线程任务,可以直接执行输出。

function fun1(){
    console.log('普通函数1')
}

console.log('script start')

setTimeout(() => {
    console.log('宏任务2')
}, 0);
setTimeout(()=>{
    console.log('宏任务1')
})
new Promise((resolve, reject)=>{
    console.log('promise 主体')
    resolve()
    console.log('promise 主体内resolve 后')
}).then(()=>{
    console.log('promise 第一个then')
}).then(()=>{
    console.log('promise 第二个then')
})
fun1()

image.png

我们还是从上往下一步步读取script代码

  1. 遇到普通函数,记录函数内容
    遇到js语句,执行js语句,输出script start
  2. 遇到第一个宏任务,放到宏任务队列里 【宏任务2 宏任务1】
  3. 遇到promise,promise主体里的内容属于主线程任务,执行里面的js语句输出promise 主体 primise主体内resolve后(可见resolve或者reject并不会阻碍或者干扰js执行顺序)遇到一个promise.then放到微任务队列里【promise 第一个then promise 第二个宏任务】
  4. 遇到fun1函数执行,执行fun1输出普通函数1
  5. 至此,主线程解析完毕,执行微任务队列里的内容,微任务队列先进先出,依次执行输出promise 第一个then promise 第二个宏任务
  6. 微任务队列空,执行宏任务队列里的内容,依旧是先进先出原则,依次输出宏任务2 宏任务1

是不是很简单,是不是很easy!

任务嵌套

上面是简单的理想情况,好判断,下面上点难度。

setTimeout(() => {
   console.log("宏任务0")
}, 1000);

new Promise((resolve)=>{
    console.log('promise 主体程序块1')
    resolve()
    new Promise(resolve=>{
        console.log('promise2 主体程序块')
        resolve()
        setTimeout(() => {
            console.log('宏任务1')
        }, 0);
    }).then(()=>{
        console.log('promise2 then 程序块1')
    }).then(()=>{
        console.log("promise2 then 程序块2")
        setTimeout(()=>{
            console.log('宏任务2')
        }, 0)
    })
    setTimeout(() => {
        console.log('宏任务')
    }, 0);
}).then(()=>{
    console.log("promise then 程序块")
})

  1. 第一步,遇到settimeout,但是有延时1000,先不设置,等待执行
  2. 遇到promise,主线程代码块输出内容 promise 主程序块1
  3. 在promise主程序块内遇到promise2,执行promise2主程序块代码,输出promise2 主程序块
  4. 遇到settimeout,并且没有定时设置,建立宏任务队列,将宏任务1放入宏任务队列【宏任务1】
  5. 遇到.then 建立微任务队列,放入微任务队列【promsie2 then程序块1】,第二个then后面的内容,需要执行了第一个then里的代码块后才会放入微任务队列里
  6. 遇到宏任务,放入宏任务队列【宏任务1,宏任务2】
  7. promise.then 放入微任务队列微任务队列检索完毕【promise2 then程序块1, promise then 程序块】
  8. 最后在第1000之后将回调函数代码块输出内容放入到宏任务队列宏任务队列检索完毕【宏任务1,宏任务2,宏任务】
    依次读取微任务队列里的tick函数,输出promise2 then程序块1后将第二个then里的代码块内容给放到微任务队列里【promise then 程序块,promise2 then 程序块2】,继续依次执行微任务队列tick,微任务队列为空后开始执行宏任务队列,输出顺序如下。

image.png

特殊点 settimeout immadiate

settiemout和immadiate的执行顺序同上settimeout为什么会不准的解释。

特殊点 async/await

async function async1 () {
    await new Promise((resolve, reject) => {
     resolve()
      })
    console.log('A')
   }

async1()

new Promise((resolve) => {
console.log('B')
resolve()
}).then(() => {
console.log('C')
}).then(() => {
console.log('D')
})

遇到await的函数可以改写为以下

async function async1 () {
    await new Promise((resolve, reject) => {
     resolve()
    }).then(()=>{
        // 改写部分
        console.log('A')
    })

   }

async1()

new Promise((resolve) => {
console.log('B')
resolve()
}).then(() => {
console.log('C')
}).then(() => {
console.log('D')
})
   

按照上面讲的逐步分析可以得到的输出结果是B->A->C->D

提问

await后面跟随的内容,都可以改写为promise么?

async function async1 () {
    await 11
    console.log('A')
}

async1()

new Promise((resolve) => {
console.log('B')
resolve()
}).then(() => {
console.log('C')
}).then(() => {
console.log('D')
})

输出B->A->C->D

async function async1 () {
    await new Promise(resolve=>{
        resolve()
    }).then(()=>{
        
    })
    console.log('A')
}

async1()

new Promise((resolve) => {
console.log('B')
resolve()
}).then(() => {
console.log('C')
}).then(() => {
console.log('D')
})

image.png

输出顺序变了!法失效了?no no!上面的例子其实可以改写成下面这样

async function async1 () {
    await new Promise(resolve=>{
        resolve()
    }).then(()=>{

    }).then(()=>{
        console.log('A')
    })
}

async1()

new Promise((resolve) => {
console.log('B')
resolve()
}).then(() => {
console.log('C')
}).then(() => {
console.log('D')
})

改写后的函数输出跟原有函数输出顺序一致,也就是说await后面部分永远是在原有基础上新增一个then,这样理解对么?

async function async1 () {
    await new Promise(resolve=>{
        resolve()
    }).then(()=>{

    }).then(()=>{

    })
    console.log('A')
}

async1()

new Promise((resolve) => {
console.log('B')
resolve()
}).then(() => {
console.log('C')
}).then(() => {
console.log('D')
})

会输出什么呢?

image.png

A又向后移了一位,看起来猜想是对的,我们再细化一点。

async function async1 () {
    await new Promise(resolve=>{
        resolve()
    }).then(()=>{
        console.log(1)
    }).then(()=>{
        console.log(2)
    })
    console.log('A')
}

async1()

new Promise((resolve) => {
console.log('B')
resolve()
}).then(() => {
console.log('C')
}).then(() => {
console.log('D')
})

  1. 执行函数块async1,遇到await,执行promise主体程序块,遇到then,放入微任务队列【1】
  2. 遇到promise,执行promise主体程序块内容,输出B,遇到then,放入微任务队列【1,c】
  3. 执行tick,依次取出微任务队列里的回调函数并执行输出1,遇到then,放入微任务队列【c,2】
  4. 执行tick,依次取出微任务队列里的回调函数并执行输出c,遇到then,放入微任务队列【2, D】
  5. 执行tick,依次取出微任务队列里的回调函数并执行输出2,遇到then,放入微任务队列【D, A】
  6. 依次执行微任务队列里的回调函数输出。

好像不是很难,对吧。

async function test () {
     console.log(1);
     await {
       then (cb) {
         cb();
        },
      };
     console.log(2);
   }
  
   test();
   console.log(3);
  
   Promise.resolve()
      .then(() => console.log(4))
      .then(() => console.log(5))
      .then(() => console.log(6))
      .then(() => console.log(7));

这里await后面接的是一个回调,回到函数会等前面执行之后再执行,相当于一个.then,可以改写为

async function test () {
     console.log(1);
     await {
       then (cb) {
        console.log('cc')
         cb();
        },
      };
     console.log(2);
   }
  
   test();
   console.log(3);
  
   Promise.resolve()
      .then(() => console.log(4))
      .then(() => console.log(5))
      .then(() => console.log(6))
      .then(() => console.log(7)

效果是一样的。

node版本影响

尚明说过,js本身是没有时间概念的,事件执行时间的调度是由宿主环境来完成的,那么不同的宿主环境或者不同版本的宿主环境的事件调度机制可能会有所不同。

    setTimeout(()=>{
        console.log('timer1')
        Promise.resolve().then(function() {
            console.log('promise1')
        })
    }, 0)
    setTimeout(()=>{
        console.log('timer2')
        Promise.resolve().then(function() {
            console.log('promise2')
        })
    }, 0)

  • 如果是 node11 版本一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,这就跟浏览器端运行一致,最后的结果为timer1=>promise1=>timer2=>promise2

  • 如果是 node10 及其之前版本要看第一个定时器执行完,第二个定时器是否在完成队列中.

    • 如果是第二个定时器还未在完成队列中,最后的结果为timer1=>promise1=>timer2=>promise2
    • 如果是第二个定时器已经在完成队列中,则最后的结果为timer1=>timer2=>promise1=>promise2
    setImmediate(() => console.log('immediate1'));
    setImmediate(() => {
        console.log('immediate2')
        Promise.resolve().then(() => console.log('promise resolve'))
    });
    setImmediate(() => console.log('immediate3'));
    setImmediate(() => console.log('immediate4')); 
  • 如果是 node11 前的版本,会输出immediate1=>immediate2=>immediate3=>immediate4=>promise resolve

  • 如果是 node11 后的版本,会输出immediate1=>immediate2=>promise resolve=>immediate3=>immediate4