以小见大——从setTimeout引申JS的几大特性

1,740 阅读7分钟

前言

最近在复习JS基础,回新手村整理下笔记
回想当初看书的过程,有两位朋友曝光度极高
那就是setTimeoutsetInterval

他们的一些迷惑行为,初看实在让人摸不着头脑,但其实背后暴露出了JS的几大特性
如果能全盘理解,也就能基本掌握JS的一些原理了

先介绍一下

setInterval,是每隔一段时间执行一次函数,而setTimeout则是一段时间后进行,他们的用法基本一样,所以下面也就只讲解setTimeout

setTimeout(function (a,b) { console.log(a+b) }, 2000, 1, 2)

  • 第一个参数: 推迟执行的回调函数,也可以直接写函数名
  • 第二个参数: 推迟的毫秒数

如果不设置浏览器会自动配置时间,在IE,FireFox中,第一次配可能给个很大的数字,100ms上下,往后会缩小到最小时间间隔,Safari,chrome,opera则多为10ms上下。

  • 从第三个参数开始,是给回调函数传的参数
  • 返回值:定时器的id

可以通过clearTimeout(id)来清除这个定时器
或者也可以使用setInterval的清除方法clearInterval()
只不过从语义上来说不推荐

迷惑一:执行顺序

虽然setTimeout可以定时执行函数,但实际上它的执行时间不是精确的 这就要说到它的原理了
我们先看一个极端的例子,把第二个参数设置为0

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

虽然设置为0了,但也不是立即执行的
这个API是浏览器提供的,所以浏览器处理后会将setTimeout要执行的匿名函数添加到异步队列
需要等待到函数调用栈清空之后,即所有可执行代码执行完毕之后,才会开始执行执行这个异步队列,并且是先进先出
而setTimeout设定的延迟时间,并非相对于setTimeout执行这一刻,而是相对于其他代码执行完毕这一刻。

大致过程如上,理解了异步的过程大概也就明白执行顺序了
但是,其实这不够全面
出了新手村之后遇见了各式各样的新的朋友 比如promise

迷惑二:比promise的优先性差?

setTimeoutpromise都是异步的,按照队列先进先出的顺序来说
如果给setTimeout设置为0,同时放置在promise之前,那应该执行完同步代码之后就执行setTimeout的函数

setTimeout(function () {
    console.log('setTimeout1');
}, 0);
new Promise(function (resolve) {
    resolve();
}).then(function () {
    console.log('then1')
})
console.log('script end')

但实际上的输出结果是

// script end
// then1
// setTimeout1

.then()setTimeout优先执行了
那再有个async函数的话,执行顺序又是什么呢
如果不能坚定地回答,那说明我们之前的理解一定还差了点东西

Event Loop

完整的事件循环如上图

异步队列还分为Task(宏任务)队列MicroTask(微任务)队列
在最新标准中,它们被分别称为task与jobs。
MicroTask会优先于Task执行。

  • 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O(包括各种键鼠事件), UI rendering(不确定).
  • 微任务:process.nextTick, Promise, Object.observer,MutationObserver,callback

同时,Javascript引擎在执行Microtask队列的时候,如果期间又加入了新的Microtask,则该Microtask会加入到之前的Microtask队列的尾部,保证Microtask先于Task队列执行。

  1. 先在执行栈中执行整个script。
  2. 遇到微任务和宏任务,分别添加到微任务队列和宏任务队列中去。
  3. 当前宏任务执行完毕,立即执行微任务队列中的任务
  4. 当前微任务队列中的任务执行完毕,检查渲染,GUI线程接管渲染。
  5. 继续执行下一个宏任务从事件队列中取。

所以在我们写下setTimeout(fn,0)的时候他并不是在当时立即执行,是从下一个Event loop开始执行,即是等当前所有脚本执行完再运行,就是"尽可能早"。

引用自https://juejin.cn/post/6844903680353779720

如果想挑战一下,可以看一下这道题目

输出结果

async/await其实就是promise的语法糖
async function 声明将定义一个返回AsyncFunction对象的异步函数。
当调用一个 async 函数时,会返回一个 Promise 对象。
await之后的函数语句相当于被包裹在.then()里面,所以被推进了微任务队列

注意:
上面的测试结果是谷歌浏览器73版本之后输出的结果
在谷歌浏览器73版本以前,以及node中,promise的优先级都要大于这个await给出的回调函数,所以即便在任务队列中await的回调是先进入的,也要在promise.then()之后执行
也就是说async1 endthen3的顺序会颠倒

不过在73版本之后,为了避免await的执行需要至少3次tick,性能比较慢,所以 使用对PromiseResolve的调用来更改await的语义,以减少在公共awaitPromise情况下的转换次数。
如果传递给await的值已经是一个Promise,那么这种优化避免了再次创建Promise包装器,在这种情况下,我们从最少三个microtick到只有一个microtick
所以上图async1 end 会在then3前面

迷惑三:this指针

var x=1
function hhh () {
var x=2
setInterval(function() {
console.log(x)
console.log(this.x)},1000)}
// 2 1

this指针的指向是最令人头疼的,四种绑定非常反直觉
函数中的this指向的是执行上下文,而这个例子中匿名函数最终执行的环境就是浏览器,所以this.x就是window.x

关于执行上下文

当浏览器加载script的时候,默认直接进入Global Execution >Context(全局上下文),将全局上下文入栈。如果在代码中调用了函数,则会创建Function Execution Context(函数上下文)并压入调用栈内,变成当前的执行环境上下文。当执行完该函数,该函数的执行上下文便从调用栈弹出返回到上一个执行上下文。 可以看着这个图感受一下

但很多时候,我们看不清函数到底是在什么上下文执行的,所以ES6的箭头函数一定程度上解决了这个问题,this指向的是声明时的上下文

还有类似这样的例子

function User(login) {
this.login = login;
this.sayHi = function() {
console.log(this.login);
}
}
var user = new User('John');
setTimeout(user.sayHi, 1000);
// undefined

可以这样调用来解决问题

setTimeout(function() {
	user.sayHi();
}, 1000);

或者利用bind进行绑定
也可以用call或者apply方法,但是会导致函数立即执行,失去延时效果

setTimeout(user.sayHi.bind(user), 1000);

迷惑四:闭包

var x=1
function hhh () {
var x=2
setTimeout(function() {
    console.log(x)
1000)}
// 2

因为在调用setTimeout时发生了闭包
而匿名函数在执行时虽然已经不在hhh函数环境里了,但被定义的时候被告知:执行的时候你去调用hhh函数的x,已经绑定给你了
注意,在定义时只是进行绑定,并没有真正传参\

所以下面会发生下面这个老生常谈的问题

for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i)
}, i * 1000)
}
//输出5个5,且每隔一秒一次

上面这段话,我们可以把它翻译一下

var i = 5;
function timer() {
console.log(i);
}
setTimeout( timer, 1 * 1000 );
setTimeout( timer, 2 * 1000 );
setTimeout( timer, 3 * 1000 );
setTimeout( timer, 4 * 1000 );
setTimeout( timer, 5 * 1000 );

所以一秒一次,以及输出都是5
因为在定义匿名函数的时候,使用了i值来设置时间
但是参数只是进行了绑定,真正执行的时候才会取到那个值,而此时i已经变成了5

解决办法有三种:

  • 把var改成let
  • 用立即函数包裹匿名函数
  • 利用setTimeout的第三个参数当即传参

方法蛮多,大家应该都会用,就不赘述了
但其实原理都一样,就是不把匿名函数的参数绑定到公用的i值上去,而是每次循环时,将i值保存在一个闭包中,当匿名函数执行时,则访问对应闭包保存的i值即可

总结

setTimeout和setInterval其实并不推荐被大量使用
尤其是setInterval,可能会出现间隔被跳过的问题,这个可以参考这篇文章 www.cnblogs.com/xiaohuochai…

但通过对他们进行研究,可以以小见大地理解JS运行机制
原理部分如果有写的不对的,欢迎指正