JS 函数的执行时机 定时器 Event Loop

351 阅读13分钟

Event loop 相关的基本概念

  • 任务可以分成两种,一种是同步任务和异步任务
    • 同步任务都在主线程上执行,形成一个执行栈
    • 异步任务分为微任务和宏任务,微任务级别高于宏任务
      • 你可以理解成任务队列分为微任务队列与宏任务队列,主线程会一直优先调用微任务队列的任务
      • 执行顺序可以看最后那个例子更好理解:1.先执行执行栈中的同步代码->2.然后执行任务队列中的微任务代码直至空->3.然后执行任务队列中的宏任务代码->3的期间如果遇到微任务则跳到2执行任务队列中的微任务代码直至空
      • 另外不同类型的任务会分别进入到他们所属类型的任务队列,比如所有setTimeout()的callback都会进入到setTimeout任务队列,所有then()回调都会进入到then队列
        • 微任务:new promise(),new MutaionObserver()
        • 宏任务:setInterval(),setTimeout() XHR回调、事件回调(鼠标键盘事件)、setImmediate、indexedDB数据库操作等I/O
  • 定时器并不能保证,回调函数一定会在setTimeout()指定的时间执行,如果同步代码(或者说执行栈)中的代码耗时过长
  • 定时器只是将事件插入了"任务队列",必须等到同步代码(或者说执行栈)执行完,主线程才会去执行它指定的回调函数。
  • 定时器延迟的时间即使设置为0也不会马上执行,HTM5规范定最小延迟时间不能小于4ms,不同浏览器的实现不一样,比如,Chrome可以设置1ms,IE11/Edge是4ms
  • 定时器的callback会交给浏览器的定时器模块来管理,延迟时间到了就将fn加入任务队列中,等待主线程的调用

最经典的for setTimeout打印结果问题

解释为什么如下代码会打印 6 个 6

let i = 0
for(i = 0; i<6; i++){
  setTimeout(()=>{
    console.log(i)
  },0)
}
  • let 声明的i是处于全局作用域中的
  • 而setTimeout是延时执行函数,会随着循环的发生依次推迟0秒把timer放到调用栈中
  • for循环执行完毕后,调用栈依次瞬间推出timer 6次 即console.log(i)
  • 到那时,timer会由内向外去寻找变量i,未在timer内部找到i,但在全局作用域中找到了,i是6了(循环完成后的结果)因此是瞬间6个6。

写出所有方法让上面代码打印 0、1、2、3、4、5 的方法

Demo1

这里的let+block形成暂时性死区,把每一次的i=0,1,2,3,4,5都封锁在独立的空间内,因此是0、1、2、3、4、5

//demo1
for(let i = 0; i<6; i++){
  setTimeout(()=>{
    console.log(i)
  },0)
}

Demo1.5

// let + blcok 把全局i锁在block里面
// 其实原理和demo1一模一样
for(var i = 0; i<6; i++){
  let n=i;
  setTimeout(()=>{
    console.log(n)
  },0)
}
  • var声明的i会存在于全局
  • 第一遍循环,setTimeout拿到i为1,因此是将此刻的timer推迟1s存入调用栈并且让它等待上下文中所有语句全部执行完成后立即触发执行。
  • 第二遍循环,setTimeour拿到i为2,因此是将此刻的timer推迟2s存入调用栈并且让它等待上下文中所有语句全部执行完成后立即触发执行。
  • 第N遍循环执行完毕以后,调用栈开始依次推出当时未执行的timer函数们,分别隔一秒后触发执行。
  • 执行timer时由于变量n未在函数内部找到,因此找到block发现n是6,因此是5个6(打印频率1个/1秒) Demo2

利用函数作用域

// IIFE + var/let
for(var i = 0; i<6; i++){、
 (function(){
  var n=i; // var let都可以
  setTimeout(()=>{
    console.log(n)
  },0)
 })()
}
  • 每次执行IIFE时会形成一个独立的函数作用域
  • 把全局i锁在了函数作用域中形成局部变量n
  • 每次执行IIFE时setTimeout都推迟0秒把timer放入调用栈中
  • 等到全部语句执行完毕,调用栈开始依次推出当时未执行的timer函数们,分别间隔0秒依次触发执行。
  • 执行timer时由于变量n未在函数内部找到,因此找到函数作用域发现n是6,因此是5个6(打印频率瞬间依次打印) Demo3

利用bind

// bind i 把当时的全局i逐一锁在timer里面
for (var i = 0; i<6; i++) {
  setTimeout( function timer(i) {
    console.log(i);
  }.bind(null,i),1000 );
}
  • 当延时执行的时候,timer里的i是能够在自身函数作用域内找到局部i的也就是当时留下的 Demo4

利用setTimeout的第3个参数

for (var i = 0; i<6; i++) {
  setTimeout( function timer(i) {
    console.log(i);    
   }, i*1000,i );
}
  • 我认为原理和.bind(null,i)是一样的,也是保留一份当时的i Demo5

利用JS中基本类型的参数传递是按值传递的特征实现

var output = function (i) {
 setTimeout(function () {
 console.log(i);
 },i)
}
for (let i = 0; i <6; i++) { // var let 都可
 output(i);
}

Demo5.5

同上,换了一种写法

for(var i = 0; i<6; i++){、
 (function(i){ // 注意啊,这里的i不能省的 
  setTimeout(()=>{
    console.log(i)
  },0)
 })(i)
}

解释如下代码

for (var i = 1; i <= 5; i++) {
    (function() {
        setTimeout( function timer() {
            console.log(i);
        },i*1000 );
    })();
}
// 虽然用了IIFE但是只是立即执行了setTimeout,加了IIFE和没加一样
for (var i = 1; i <= 5; i++) {
    (function() { // 每次执行IIFE时会形成一个独立的函数作用域
        var j = i; // 把全局i锁在了函数作用域中形成局部变量j
        setTimeout( function timer() {
            console.log(j);
        },i*1000 );  // 每次执行IIFE时i分别为1,2,3,4,5,因此setTimeout依次推迟1,2,3,4,5秒把timer放入调用栈中
    })();
}
// 这个例子仅仅是把i*1000 ===> j*1000
for (var i = 1; i <= 5; i++) {
    (function() { // 每次执行IIFE时会形成一个独立的函数作用域
        var j = i; // 把全局i锁在了函数作用域中形成局部变量j
        setTimeout( function timer() {
            console.log(j);
        },j*1000 ); // 每次执行IIFE时j别为1,2,3,4,5,因此setTimeout依次推迟1,2,3,4,5秒把timer放入调用栈中
    })();
}

setTimeout工作机制

  • 浏览器和JS线程是两回事
  • setTimeout会接受一个callback,浏览器负责计时,时间到了以后将callback放入事件队列中,也就是扔给JS线程
  • 如果JS线程上碰巧没东西,则立马执行
  • 如果JS线程上有东西,则还需要等待,这也解释了为什么JS是单线程的

demo1

setTimeout(() => {
    console.log('计时器')
}, 3000);

console.time();
for (let index = 0; index < 10000000; index++) {
    index.toString = '这是1' + index;
    // console.log()
}
console.timeEnd();
// default: 5030.498779296875ms
// 计时器
  • 原理:
    • JS执行到setTimeout时,将其交给浏览器去计时(哪怕时间是0),
    • 然后去执行同步的for循环,因为例子中的for循环执行需要5s左右,
    • 在for循环执行3秒时,浏览器已经把之前的计时器计时完毕,然后推送到JS的事件队列里,当做下一个task任务执行,当前任务(for循环)不受影响
    • 当for循环结束后,JS线程空了,然后去事件队列中取微任务或者新的任务(setTimeout),然后执行 参考资料链接

demo2

setTimeout(() => {
    console.log('3秒计时器');
}, 3000);

console.time();
for(let index = 0; index < 10000000; index++) {
   index.toString = '这是' + index;
}
console.timeEnd();

setTimeout(() => {
    console.log('2秒计时器');
}, 2000);
  • 原理:
    • 当JS运行到第一个计时器时,将其交给浏览器去计时,然后开始执行同步操作(for循环),
    • 在for循环进行3秒后(完成需要5s),浏览器将第一个计时器计时完成,然后将其返回到事件队列中等待,
    • 等到for循环执行完,然后执行被存到事件队列中的第一个计时器。(执行第一个定时器函数)
    • 然后代码走到第二个计时器时,再交给浏览器去执行,现在js线程是空闲状态,等到浏览器计时结束后,浏览器将其推送到js线程中。

demo3

如果同步任务(for循环)执行完,第一个计时器还没计时结束,那结果是什么?

setTimeout(() => {
    console.log('10秒计时器');
}, 10000);
console.time();
for(let index = 0; index < 10000000; index++) {
   index.toString = '这是' + index;
}
console.timeEnd();
setTimeout(() => {
    console.log('2秒计时器');
}, 2000);
  • JS运行到第一个计时器时让浏览器先计时然后JS接着执行for
  • for执行完了,第一个计时器浏览器还没完计时完
  • 然后JS执行第二个计时器,让浏览器另外计时
  • 于是第二个计时器先数完后才终于数完第一个

demo4 这个比前面的都重要

function click() {
		// code block1...
	setTimeout(function() {
		// process ...
	}, 200);
		// code block2
}
  • 一个button的click事件绑定了此方法, 按下按钮后, 肯定先执行block1的内容, 然后运行到setTimeout的地方
  • 浏览器对JS线程说"200ms后我会插一段要执行的代码给你的队列中"
  • 浏览器开始计时后,JS开始执行block2代码
    • 如果block2的代码执行时间超过200ms, 在block2执行到200ms时,process代码被插入JS线程中,但是是在click函数之后
    • 如果block2的代码执行时间小于200ms,那么先跑完block2后(click函数也运行完了,JS线程中已经弹出click函数了)浏览器才把process插到JS线程中
    • 无论如何,process代码所在的这个匿名函数都是排在click函数后面执行的,
  • 再加上js以单线程方式执行, 所以应该不难理解无论如何都是code2执行完了即click执行完了才会最后执行process
  • 在线示例

setTimeout函数中的this

  • 如果回调函数是对象的方法,那么setTimeout使得方法内部的this关键字指向全局环境,而不是定义时所在的那个对象。
  • 因为当obj.y在1000毫秒后运行时,this所指向的已经不是obj了,而是全局环境。
var x = 1;
var obj = {
  x: 2,
  y: function () {
    console.log(this.x);  // window
  }
};
setTimeout(obj.y, 1000) // 1
  • 那如果我们就是想让y获取obj.x呢? .bind强制绑定this为原对象obj就可以啦 var x = 1;

var obj = { x: 2, y: function () { console.log(this.x); } };

setTimeout(obj.y.bind(obj), 1000) // .bind强制绑定this为obj

setInterval流程详解

  • setInterval是按设定的时间间隔无限循环下去,但时间间隔特定情况下并不严格

    • setInterval指定的是“开始执行”之间的间隔,并不考虑每次任务执行本身所消耗的时间
    • 比如,setInterval指定每 100ms 执行一次,但每次执行需要 5ms,那么第一次执行结束后95毫秒,第二次执行就会开始
    • 如果某次执行耗时特别长,比如需要150毫秒,那么它结束后,下一次执行才会立即开始
    • 也就是一说第一次间隔是1秒,第二次可能是1.5秒
  • setTimeout 为了确保两次执行之间有固定的间隔,可以不用setInterval,而是每次执行结束后,使用setTimeout指定下一次执行的具体时间。

  • 以下这张图来自掘金作者原地址

附上强有力的代码佐证

(function testSetInterval() {
    let i = 0;
    const start = Date.now();
    const timer = setInterval(() => {
        i++;
        i === 2 && clearInterval(timer); // 不重要,只是表明只循环两次就停止
        console.log(`第${i}次开始`, Date.now() - start);
        for(let i = 0; i < 900000000; i++) {}
        console.log(`第${i}次结束`, Date.now() - start);
    }, 100);
})();

  • 可以看到当回调函数中同步代码执行时间超出100ms时,你所设置的100ms间隔就不复存在了
  • 事实上,setInterval并不管上一次fn的执行结果,而是每隔100ms就将fn放入异步队列
  • 这与JS的执行原理有关

如何实现先延时3s再延时3s从而实现延时6s呢?

以下做法会到了3s同时跳出

(function () {
	let timer1 = setTimeout(function() {
		console.log("0");
	}, 3000);
	let timer2 = setTimeout(function() {
		console.log("1");
	}, 3000);
	
})();

//3s后输出1
//3s后输出0

可以用函数回调 或者你写成一整个嵌套也可以

function time1(callback) {
	let timer1 = setTimeout(function() {
		console.log("0");
		callback();
	}, 3000);
}
time1(function () {
	let timer2 = setTimeout(function() {
		console.log("1");
	}, 3000);
});
//0 3s后输出
//1 6s后输出

改进一下,引入递归

  • setTimeout模拟setInterval---惯用套路
let timerNo
function timeFn() {
	 timerNo= setTimeout(function() {
		console.log("1");
		timeFn();
	}, 3000);
}
timeFn();
// clearTimeout(timerNo)

定时器中的this

- 示例2345展示了当执行`obj.fn()`时如何把fn里的定时器中的this指向obj而不是window
- 预留一份变量
- 利用bind把this提前绑死
- arrow打通this
- 第三个参数传this
  let obj = {
    fn() {
      console.log(this) // obj

      // 示例1
      let timer1 = setTimeout(function() {
      // setTimeout第一个callback将会在全局作用域中执行,因此函数内的this将会指向这个全局对象
        console.log('我是timer1的this指向:', this)  // Window
      }, 1000)

      // 示例2 (预留一份变量)
      let _this = this
      let timer2 = setTimeout(function() {
        console.log('我是timer2的this指向:', _this) // obj
      }, 1000)

      // 示例3 (利用bind把this提前绑死)
      let timer3 = setTimeout(
        function() {
          console.log('我是timer3的this指向:', this) // obj
        }.bind(this),
        1000
      )

      // 示例4 (arrow打通this)
      let timer4 = setTimeout(() => {
        console.log('我是timer4的this指向:', this) // obj
      }, 1000)
      
      // 示例5 (定时器第三个参数)
      let timer5 = setTimeout(() => {
        console.log('我是timer5的this指向:', this) // obj
      }, 1000,this)
    }
  }
  obj.fn()

彻底理解setTimeout() ----- 最最重要的例子

彻底理解setTimeout()简书原帖

  • 这个例子讲清楚了:
  • .then 与 setTimeout同为异步任务但是 一个属于.then类型的微任务,一个属于setTimeout类型的宏任务
  • 同步代码(执行栈)中的代码被JS主线程执行完以后先去执行微任务
  • 一轮微任务执行完以后,再去执行宏任务,并且只要执行宏任务时有任何微任务队列不为空,则立马跳去执行微任务,直到微任务队列清空,继续返回执行宏任务,如此循环。
console.log('global')

setTimeout(function () {
   console.log('timeout1')
   new Promise(function (resolve) {
     console.log('timeout1_promise')
       resolve()
   }).then(function () {
     console.log('timeout1_then')
  })
},2000)

for (var i = 1;i <= 5;i ++) {
  setTimeout(function() {
    console.log(i)
  },i*1000)
  console.log(i)
}

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

setTimeout(function () {
  console.log('timeout2')
  new Promise(function (resolve) {
    console.log('timeout2_promise')
    resolve()
  }).then(function () {
    console.log('timeout2_then')
  })
}, 1000)

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

我们来一步一步分析以上代码:

1)、首先执行整体代码,“global”会被第一个打印出来。这是第一个输出.

2)、执行到第一个setTimeout时,发现它是宏任务,此时会新建一个setTimeout类型的宏任务队列并派发当前这个setTimeout的回调函数到刚建好的这个宏任务队列中去,并且轮到它执行时要延迟2秒后再执行。

3)、代码继续执行走到for循环,发现是循环5次setTimeout(),那就把这5个setTimeout中的回调函数依次派发到上面新建的setTimeout类型的宏任务队列中去,注意,这5个setTimeout的延迟分别是1到5秒。此时这个setTimeout类型的宏任务队列中应该有6个任务了。再执行for循环里的console.log(i),很简单,直接输出1,2,3,4,5,这是第二个输出。

4)、再执行到new Promise,Promise构造函数中的第一个参数在new的时候会直接执行,因此不会进入任何队列,所以第三个输出是"promise1",上面有说到Promise.then是微任务,那么这里会生成一个Promise.then类型的微任务队列,这里的then回调会被push进这个队列中。

5)、再继续走,执行到第二个setTimeout,发现是宏任务,派发它的回调到上面setTimeout类型的宏任务队列中去。

6)、再走到最后一个new Promise,很明显,这里会有第四个输出:"promise2",然后它的then中的回调也会被派发到上面的Promise.then类型的微任务队列中去。

7)、第一轮事件循环的宏任务执行完成(整体代码可以看做宏任务)。此时微任务队列中只有一个Promise.then类型微任务队列,它里面有两个任务。宏任务队列中也只有一个setTimeout类型的宏任务队列。

8)、下面执行第一轮事件循环的微任务,很明显,会分别打印出"then1",和"then2"。分别是第五和第六个输出。此时第一轮事件循环完成。

9)、开始第二轮事件循环:执行setTimeout类型队列(宏任务队列)中的所有任务。发现都有延时,但延时最短的是for循环中第一次循环push进来的那个setTimeout和上面第5个步骤中的第二个setTimeout,它们都只延时1s。它们会被同时执行,但前者先被push进来,所以先执行它!它的作用就是打印变量i,在当前作用域找变量i,木有!去它上层作用域(这里是全局作用域)找,找到了,但此时的i早已是6了。(为啥不是5,那你得去补补for循环的执行流程了~)所以这里第七个输出是延时1s后打印出6。

10)、紧接着执行第二个setTimeout,它会先后打印出"timeout2"和"timeout2_promise",这分别是第八和第九个输出。但这里发现了then,又把它push到上面已经被执行完的then队列中去。

11)、这里要注意,因为出现了微任务then队列,所以这里会执行该队列中的所有任务(此时只有一个任务),即打印出"timeout2_then"。这是第十个输出。

11)、继续回过头来执行宏任务队列,此时是执行延时为2s的第一个setTimeout和for循环中第二次循环的那个setTimeout,跟上面一样,前者是第一个被push进来的,所以它先执行。这里会延时1秒(原因下面会解释)分别输出“timeout1”和“timeout1_promise”,但发现了里面也有一个then,于是push到then微任务队列并立即执行,输出了"timeout1_then"。紧接着执行for中第二次循环的setTimeout,输出6。注意这三个几乎是同时被打印出来的。他们分别是第十一到十三个输出。

12)、再就很简单了,把省下的for循环中后面三次循环被push进来的setTimeout依次执行,于是每隔1s输出一个6,连续输出3次。

13)、第二轮事件循环结束,全部代码执行完毕。

所以上代码的执行结果为: