由节流函数引发出对event-loop的思考,顺便刷刷爆款题

2,986 阅读12分钟

引子

当我在看节流函数的时候,碰到了setTimtout,于是从js运行机制挖到了event-loop。那么咱们就先从这个简单的节流函数看起。

// 节流:如果短时间内大量触发同一事件,那么在函数执行一次之后,该函数在指定的时间期限内不再工作,直至过了这段时间才重新生效。
function throttle (fn, delay) {
    let sign = true;
    return function () {    // 闭包,保存变量的值,防止每次执行次函数,值都被重置
        if (sign) {
            sign = false;
            setTimeout (() => {
                fn();
                sign = true;
            }, delay);
        } else {
            return false;
        }
    }
}
window.onscroll = throttle(foo, 1000);

那么这个节流函数是怎么实现的节流呢?

让我们来看一下它的执行步骤(假设我们一直不停的在滚动):

  1. 当我们打开页面,代码执行到window.onscroll = throttle(foo, 1000)就会直接执行 throttle函数,定义了一个变量 sign 为 true,然后碰到了 return 跳出 throttle函数,并返回另一个匿名函数。
  2. 然后我们滚动页面,那么就会触发 onscroll 事件,执行 throttle函数。而此时我们的 throttle函数,实际就是执行 return 的那个匿名函数。因为闭包的缘故,保存了 sign的值(感觉还要填个闭包的坑...),此时的sign 是 true。就执行 if判断,把sign 改为 false。然后碰到了定时器,我们现在不用管定时器的回调函数的内容。
  3. 我们还一直在滚动,那么又触发了 onscroll事件,于是继续进行 if else 判断。此时 sign 已经是false了,什么都没有发生。
  4. 继续,我们一直不停的在滚动,还是触发了 onscroll事件,因为 sign 还是false,所以还是什么都没有发生。
  5. 一直重复步骤4,直到1s以后的那个 onscroll事件执行完成后,我们的setTimeout被执行了,首先执行了我们的需要被执行的fn()函数,然后把 sign置为 true。又开始跟前面一样,执行 if判断了。

那么为什么在执行了 if判断的过程中,碰到了setTimeout,我们的sign并没有被改为true,从而一直的执行 if判断呢?那么就需要聊一聊js的运行机制了。终于要进正题了,真不容易...

js运行机制

先看一下阮一峰大佬的

(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。

(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。

(4)主线程不断重复上面的第三步。

我自己归类就是js中有:

  • 同步任务和异步任务

  • 宏任务(macrotask)和微任务(microtask)

  • 主线程(同步任务) - 所有同步任务都在主线程上执行,形成一个执行栈。

  • 任务队列(异步任务):当异步任务有了结果,就在任务队列中放一个事件。

  • JS运行机制:当"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列"

其中宏任务包括:script(主代码), setTimeout, setInterval, setImmediate, I/O, UI rendering

微任务包括:process.nextTick(Nodejs), Promises, Object.observe, MutationObserver

这里我们注意到,宏任务里有 script,也就是我们的正常执行的主代码。

事件循环 event-loop

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。此机制具体如下:主线程会不断从任务队列中按顺序取任务执行,每执行完一个任务都会检查microtask队列是否为空(执行完一个任务的具体标志是函数执行栈为空),如果不为空则会一次性执行完所有microtask。然后再进入下一个循环去任务队列中取下一个任务执行。

我又给总结了一下笼统的过程:script(宏任务) - 清空微任务队列 - 执行一个宏任务 - 清空微任务队列 - 执行一个宏任务, 如此往复。

  • 先执行script里的同步代码(此时是宏任务)。碰到异步任务,放到任务队列。
  • 查找任务队列有没有微任务,有就把此时的微任务全部按顺序执行 (这就是为什么promise会比setTimeout先执行,因为先执行的宏任务是同步代码,setTimeout被放进任务队列了,setTimeout又是宏任务,在它之前先得执行微任务(就比如promise))。
  • 执行一个宏任务(先进到队列中的那个宏任务),再把这次宏任务里的宏任务和微任务放到任务队列。
  • ...一直重复2、3步骤

要做到心中有队列,有先进先出的概念

借用前端小姐姐的一张图来解释:

event-loop2

现在再看开头的节流函数,就明白为什么碰到了setTimeout,我们的sign并没有被改为true了把。

那我们继续,看一下最近看到的爆款题。

开始闯关

第一关

看这段代码

console.log('script start');

setTimeout(() => {
    console.log('setTimeout1');
}, 0);

new Promise((resolve) => {
    resolve('Promise1');
}).then((data) => {
    console.log(data);
});

new Promise((resolve) => {
    resolve('Promise2');
}).then((data) => {
    console.log(data);
});

console.log('script end');

对照这上面的执行过程不难得出结论,script start -> script end -> Promise1 -> Promise2 -> setTimeout1

就算 setTimeout 不延时执行,它也会在 Promise之后执行,谁让js就是先执行同步代码,然后去找微任务再去找宏任务了呢。

懂了这里,那我们继续咯。

第二关

setTimeout(() => {
    console.log('setTimeout1');

    setTimeout(() => {
        console.log('setTimeout3');
    }, 0);

    Promise.resolve().then(data=>{
        console.log('setTimeout 里的 Promise');
    });
}, 0);

setTimeout(() => {
    console.log('setTimeout2');
}, 0);

Promise.resolve().then(() => {
    console.log('Promise1');
});

根据前面的流程

  1. 执行script,看到了第一个 setTimeout 放入任务队列,看到了第二个 setTimeout 放到任务队列。看到了Promise.then() 放到任务队列,并没有同步代码。
  2. 检查微任务,发现了 Promise.then() 打印Promise1
  3. 检查发现没有别的微任务了,检查宏任务,此时有两个宏任务(两个setTimeout),但是规则告诉我们,只执行一个宏任务,因为队列是先进先出的原则,执行先进入队列的那个 setTimeout,打印 setTimeout1。又发现了 一个 setTimeout,放进任务队列。看见了 Promise.then() ,打印setTimeout 里的 Promise
  4. 检查宏任务,发现了宏任务,执行先进的那个,所以打印setTimeout2
  5. 检查微任务,没有。
  6. 检查宏任务,打印setTimeout3

搞清楚了这个,那我们再继续玩儿玩儿?

第三关

console.log('script start');

setTimeout(() => {
    console.log('setTimeout1');
}, 0);

new Promise((resolve) => {
    console.log('Promise3');
    resolve();
}).then(() => {
    console.log('Promise1');
});

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

console.log('script end');

再来看看这个代码的执行结果呢。

script start -> Promise3 -> script end -> Promise1 -> Promise2 -> setTimeout1

有些朋友可能会说,不是说好了 Promise 是微任务,要在主代码执行以后才执行嘛,你个 Promise3 咋叛变了。

其实 Promise3 没有叛变,之前说的 Promise微任务是.then()执行的代码。而在new Promise的回调函数里的代码是同步任务。

第四关

我们继续看关于promise的

setTimeout(()=>{
    console.log(1) 
},0);

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

console.log(5);

这个输出 2 -> 5 -> 3 -> 4 -> 1。你想对了嘛?

这个要从Promise的实现来说,Promise的executor是一个同步函数,即非异步,立即执行的一个函数,因此他应该是和当前的任务一起执行的。而Promise的链式调用then,每次都会在内部生成一个新的Promise,然后执行then,在执行的过程中不断向微任务(microtask)推入新的函数,因此直至微任务(microtask)的队列清空后才会执行下一波的macrotask。

第五关

promise继续进化

new Promise((resolve,reject)=>{
    console.log("promise1")
    resolve()
}).then(()=>{
    console.log("then11")
    new Promise((resolve,reject)=>{
        console.log("promise2")
        resolve()
    }).then(()=>{
        console.log("then21")
    }).then(()=>{
        console.log("then23")
    })
}).then(()=>{
    console.log("then12")
})

直接上解释吧。

遇到这种嵌套式的Promise不要慌,首先要心中有一个队列,能够将这些函数放到相对应的队列之中。

Ready GO

第一轮

  • current task: promise1是当之无愧的立即执行的一个函数,参考上一章节的executor,立即执行输出[promise1]
  • micro task queue: [promise1的第一个then]

第二轮

  • current task: then1执行中,立即输出了then11以及新promise2的promise2
  • micro task queue: [新promise2的then函数,以及promise1的第二个then函数]

第三轮

  • current task: 新promise2的then函数输出then21和promise1的第二个then函数输出then12
  • micro task queue: [新promise2的第二then函数]

第四轮

  • current task: 新promise2的第二then函数输出then23
  • micro task queue: []

END

可能有人会对第二轮的队列表示疑问,为什么是 ”新promise2的then函数“ 先进了队列,然后才是 ”promise1的第二个then函数“ 进入队列?”新promise2的第二then函数“ 为什么有没有在这一轮中进入到队列中来呢?

看不懂没关系,我们来调试一下代码:

在打印完 promise2 以后,19行先执行到了 })这里,然后到了then这里。

再下一步,到了 promise1的第二个})这里了。并没有执行20行的console.log。

由此看出:promise2的第一个then进入任务队列中了。并没有被执行.then()。

继续执行,打印 then21

由此得出:promise1的第二个then放入异步队列中,并没有被执行。程序执行到这里,宏任务算是执行完了。检查微任务,此时队列中放着 [ '新promise2的then函数', 'promise1的第二个then函数'] ,也就是第二轮所写的队列。

这一步,到了promise2的二个then前面的})

往下执行到了这里,又碰到了异步,放入队列中去。

此时队列: [ 'promise1的第二个then函数' ,'promise2的第二个then函数' ]

打印 promise1 的 then12

先进先出,所以先执行了 'promise1的第二个then函数' 。

此时队列: [ 'promise2的第二个then函数' ]

最后才输出了 then23


第六关 async/await

截至到上一关,我本以为我已经完全掌握了event-loop。后来我看到了 async/await , async await是generatorPromise 的语法糖这个大家应该都知道,但是打印之后跟我预期的不太一样,顿时有点儿蒙圈,后来一分析,原来如此。

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

这段代码也算是网红代码了,我已经不下三个地方见过了...

先仔细想一想应该输出什么,然后打印一下看看。(chrome 73版本打印结果)

script start
async1 start
async2
promise1
script end
async1 end
promise2
settimeout

直接从async开始看起吧。

当程序执行到了async1();的时候

  • 首先输出async1 start

  • 执行到await async2();,会从右向左执行,先执行async2(),打印async2,看见await,会阻塞代码去执行同步任务。

async/await仅仅影响的是函数内的执行,而不会影响到函数体外的执行顺序。也就是说async1()并不会阻塞后续程序的执行,await async2()相当于一个Promise,console.log("async1 end");相当于前方Promise的then之后执行的函数。

如此一来,就可以得出上面的结果了。

但是,你也许打印出来会是下面这样的结果:

clipboard.png

这个就跟V8有关系了(在chrome 71版本中,我打印出的是图片中的结果)。至于async/await和promise到底谁会先执行,这里偷个懒,大家看 小美娜娜:Eventloop不可怕,可怕的是遇上Promise里的版本4有非常详细的解读。

第七关: Node: process和setImmediate (node11以后的版本)

先看第一个代码,思考一下答案

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");
});
async1()
new Promise(function (resolve) {
    console.log("promise1");
    resolve();
}).then(function () {
    console.log("promise2");
});
setImmediate(()=>{
    console.log("setImmediate")
})
process.nextTick(()=>{
    console.log("process")
})
console.log('script end'); 

再看下面的代码,思考一下答案,会是不一样的吗?

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')
}, 1000)
async1()
new Promise(function (resolve) {
  console.log('promise1')
  resolve()
}).then(function () {
  console.log('promise2')
})
setImmediate(() => {
  console.log('setImmediate')
})
process.nextTick(() => {
  console.log('process')
})
console.log('script end')

先看答案: 第一个

script start
async1 start
async2
promise1
script end
process
async1 end
promise2
setTimeout
setImmediate

第二个

script start
async1 start
async2
promise1
script end
process
async1 end
promise2
setImmediate
setTimeout

阿勒?setTimeout和setImmediate顺序居然不一样了。这是为啥呢

7.1 setImmediate

因为 setImmediate 是在I/O回调只有立即执行。

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后。 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调 如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了

个人测试了一下,setTimeout(fn, 2)的时候,也是先执行setTimeout,如果设置为3ms或者以上的时候,会先执行setImmediate。

但当二者在异步i/o callback内部调用时,总是先执行setImmediate,再执行setTimeout

const fs = require('fs')
fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0)
    setImmediate(() => {
        console.log('immediate')
    })
})
// immediate
// timeout

7.2 process.nextTick

这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

参考文章:

安歌:浅谈js防抖和节流

阮一峰:JavaScript 运行机制详解:再谈Event Loop

前端小姐姐:彻底搞懂浏览器Event-loop

小美娜娜:Eventloop不可怕,可怕的是遇上Promise

隆金岑:js事件循环机制(浏览器端Event Loop) 以及async/await的理解

浪里行舟: 浏览器与Node的事件循环(Event Loop)有何区别?