听风是风

学或不学,知识都在那里,只增不减。 语雀同步更新:https://www.yuque.com/echolun

导航

JS执行机制详解,定时器时间间隔的真正含义

# 壹 ❀ 引

通过结果倒推过程是我们常用的思考模式,我在上一篇学习promise笔记中,有少量关于promise执行顺序的例子,通过倒推,我成功让自己对于js执行机制的理解一塌糊涂,js事件机制,事件循环是面试常考的点,弄懂它们是贼有必要的。

回顾下我学习promise的心理历程:

let p = Promise.resolve(1);
p.then(resp => console.log(resp));
console.log(2);
//2
//1

哦,原来如此,同步代码会先执行,先输出2,所以then回调是异步。

let p1 = Promise.resolve(1);
p1.then(resp => console.log(resp));
let p2 = Promise.resolve(2);
p2.then(resp => console.log(resp));
//1
//2

哦!多个异步,先注册的回调先执行,原来如此。

setTimeout(() => console.log(2),0);
let p1 = Promise.resolve(1);
p1.then(resp => console.log(resp));
//1
//2

嗯????不是先注册的异步先执行?为啥这里先输出1,promise学习下来,成功让自己懵逼。

理解JS执行机制是很重要的,它会让你的代码调试更符合自己的预期,其次对于面试也非常有帮助。

介绍js执行机制的文章挺多了,这里只是做个个人思路的整理,那么开始。

 贰 ❀ JavaScript中的同步异步

JavaScript是一门单线程非阻塞语言,在同一时间只能专心做一件事,如果前面的事情没做,后面的事情就得耐心的等着,这就是所谓的同步。

你会想,为什么要同步?

JavaScript本身是一门浏览器脚本语言,更多负责用户的交互,dom操作之类;假设JS并非单线程,我让两个行为同时操作一个dom对象,那岂不是乱套了。想想我们排队取餐吃饭,如果不排队,往往容易引发争吵,编程也是现实行为的抽象。

也许你会说,不是有web worker吗,但web worker属于浏览器的解决方法,并非JavaScript;浏览器虽然可以开多个线程,但每个线程仍然是单线程,而且也不被允许操作dom,这依旧没改变JS是单线程语言的事实。

let funA = () => {
    let NUM = 10000;
    while (NUM) {
        NUM--;
    };
    console.log(1);
};

let funB = () => console.log(2);

funA(); //1
funB(); //2

在上述代码中,让10000进行自减如果让我们脑补这个过程是很费时的,但是对于强大的js引擎来说并不是事,也要不了太多时间;

可在开发中我们得处理大量的网络请求,我们知道请求可能存在延迟,服务器查询也得耗时,一次请求受诸多不确定因素影响,我们不可能让一次网络请求堵塞后面的程序。

也正因如此异步诞生了,对于不确定的网络请求,定时器之类,咱先备注一下有这些需要处理,就接着去忙同步的事情了,等手头上同步的处理完了,再来解决先前备注的异步事件。

想想我们排队取餐吃饭,前面的哥们大声说道,牛肉面不要面只要牛肉,多葱多蒜少辣不吃香菜半小时后来取,老板也不会等他半小时把面取了再做后面顾客的生意,那真要这样,店子早倒闭了。

 

那么说完同步异步,我们大概有了个抽象的概念,js会先执行同步,万一遇到异步,就先备注下有这个异步,等同步跑完了咱再来处理异步的后续操作,那么站在js角度这个过程是什么样的,我们接着说。

 叁 ❀ 执行栈与任务队列

我们都知道,当一个方法被调用时,JavaScript会生成一个属于此方法的执行环境,也叫执行上下文,这个上下文中存放着方法依赖的参数,变量以及作用域等等。

什么是执行栈呢?当调用一个方法A时,这个方法可能也会调用另一个方法B,B还可能调用方法C,而JS只能同时一件事,所以方法B、C没执行完之前,方法A也不能被释放,那总得找个地方把这些方法按顺序存一存吧,存放的地方就是执行栈

关于执行上下文,执行栈,具体可以阅读这篇文章 一篇文章看懂JS执行上下文  这里我们就只做简单阐述了。

执行栈是存放同步方法调用的地方,遵从先进后出的规则:

let A = () => {
    B()
    console.log(1);
};
let B = () => {
    C()
    console.log(2);
};
let C = () => {
    console.log(3);
};
A();//3 2 1

上述代码站在执行机制角度来看,是这样的,你应该也能理解递归处理不好陷入死循环后爆栈是个什么情况了:

凭直觉来想,异步任务不可能直接在执行栈中执行,不然绝对存在堵塞的问题,那先存放在哪呢?放在了任务队列中

那么到这里我们又有了一个模糊的概念,同步任务与异步任务存放的地方不同,有个问题,JavaScript怎么知道什么时候去执行异步任务呢?那就不得不说事件循环。

 肆 ❀ 事件循环 (Event Loop)

图片来源

当一个任务被执行,js会判断是否为同步任务,如果是同步,压入主线程立即执行;但如果是异步任务,移到异步处理模块(Task Table),当异步任务有了结果,就将异步任务的回调函数注入到任务队列中等待

当主线程的同步任务执行完毕执行栈为空,js引擎就会读取任务队列中的第一个任务加入到执行栈执行,当此任务完成,继续重复此类操作,这也就是事件循环了,任务队列满足先进先出的特性。

那么到这里,我们知道js引擎会利用事情循环机制来处理同步异步问题;那么问题又来了,还记得文章开头第三个例子吗,定时器和promise都是异步,为什么后面的promise反而比前面的定时器先执行,难道异步任务也有自己的先后顺序?这里就得引出宏任务与微任务了。

 伍 ❀ 宏任务与微任务

我们先对宏任务微任务做个大概分类:

macro-task(宏任务)script环境  setTimeout、setInterval、I/O、事件、postMessage、 MessageChannel、setImmediate (Node.js)

micro-task(微任务)Promise的then catch finally,process.nextTick,MutaionObserver

很多面孔没见过,没关系,好歹我们知道了定时器是宏任务,then catch finally是微任务。我把上面的例子搬下来:

setTimeout(() => console.log('我第一'), 1000);
let p1 = Promise.resolve('我第二');
p1.then(resp => console.log(resp));
//我第二
//我第一

明明是定时器先进的异步处理模块,结果promise.then还要早于定时器先执行,为什么呢?

这是因为,异步任务中又分为宏任务与微任务两种,当执行栈为空,JS引擎会优先处理微任务队列的任务,等到微任务队列处理完成,才会处理宏任务队列的任务。

setTimeout(() => console.log('我第一'), 2000);
let p1 = Promise.resolve('我第二');
p1.then(resp => console.log(resp));
setTimeout(() => console.log('我第三'), 1000);
let p2 = Promise.resolve('我第四');
p2.then(resp => console.log(resp));
//我第二
//我第四
//我第三
//我第一

上述代码中,不管你异步代码是怎么个顺序,我们可以明确的是微任务优先级总是高于宏任务。

但需要注意的是,script整体环境都是一个宏任务,所以微任务由宏任务执行过程中产生,除去同步代码执行完毕后,微任务执行优先级总是要优于剩余的异步宏任务。这里引用一张图:

 图片来源

上图中,宏任务运行过程中可能会产生微任务,若有微任务,执行所有微任务(前期是同步代码跑完了),微任务优先级始终高于宏任务(抛开同步代码)。

对于任务队列具有先进先出的特性,你肯定要喷我了,如果先进先出上述代码中等待2000ms的定时器比等待1000ms的定时器晚执行?那这里就得聊聊定时器时间的具体意义了。

 陆 ❀ 有趣的定时器

定期器分为一次性定时器setTimeout与周期性定时器setInterval,前者是等待N秒之后执行回调一次没了,后者是每隔N秒执行回调一次。

有这么一个定时器:

setTimeout(() => console.log('我第一'), 3000);

站在宏观思想上理解,这行代码的意思是这个定时器将在三秒后触发,但站在微观的角度上,3000ms并不代表执行时间,而是将回调函数加入任务队列的时间,这也是为何存在定时器执行与所设置等待时间不符的问题所在。

setTimeout(() => console.log('我第一'), 3000);
setTimeout(() => console.log('我第二'), 3000);

你猜这两个定时器怎么执行?先等三秒打印“我第一”,再等三秒打印“我第二”吗?其实不是,真正执行是是等待三秒后几乎无间隔的同时打印2个结果。

我们可以脑补下执行顺序,首先遇到第一个定时器,告诉异步处理模块,等待三秒后将回调加入任务队列,然后又调用了第二个定时器,同样是3秒后将回调加入任务队列。

等到执行栈为空,去任务队列拿任务,执行第一个console,这要不了多久,于是几乎无时差的又去任务队列拿第二个任务,这也导致了为什么2次输出几乎在同时进行。

两个定时器等待时间相同,但第一个定时器回调还是先进入任务队列,所以先触发,这也印证了任务队列先进先出的规则。

所以当我们使用周期定时器setInterval时,也会遇到执行间隔与所设时间不符的情况,比如前面有个贼复杂的操作,导致周期定时器按时间不停给任务队列加入回调,等到前面任务跑完,这时你会发现前面所积累的回调像憋久了一样一下全部一起执行了。

看到这大家应该对于JS执行机制有一定了解了,不妨阅读下博主 从一道看似简单的面试题重新理解JS执行机制与定时器  这篇文章,通过面试题来巩固下自己的理解程度。

如果大家对JS执行上下文有兴趣,欢迎阅读博主 一篇文章看懂JS执行上下文这篇文章,一定会有所收获。

那么本文到这里就结束了。

2020-12-17 更新 

在上文中我阐述了一个很重要的结论,微任务的执行优先级要高于宏任务,但需要注意的是,满足结论的前提是两种任务的等待时间至少相同,否则实际场景中确实会存在定时器(宏任务)比promise.then先执行的情况,这也导致了部分读者对于此结论理解上的混乱。

在上周,有博客园朋友在微信上问了我此问题,正巧今天前同事也对于这个结论较为混淆,所以我觉得还是有必要补充几个例子来拓展说明。

定时器时间的含义与then执行的前置条件 

一个一次性定时器是这样:

setTimeout(callback,time);

很多人会将上述代码理解为,等待time秒后就会立刻执行callback。但事实上time真正的含义是等待time秒后,将callback加入异步任务队列。当主线程同步代码都跑完执行栈此时为空,此时事件机制会跑去异步任务队列中看看有没有异步任务可以执行,一看有个callback,那就取出这个callback,然后去执行它。

setTimeout(() => console.log(1), 2000);
setTimeout(() => console.log(2), 1000);

上述代码中存在两个定时器S1与S2,S2在等待1秒后将callback加入了任务队列,之后又过了一秒将S1的callback加入任务队列,所以表现为等待1秒输出1,之后又等待1秒速出2,两者间隔为1S。

所以关于定时器唯一需要强调的是,callback加入任务队列不表示会立即执行,同步代码,promise这些没走,它就得等着,比如:

setTimeout(() => { console.log(1) });
new Promise((resolve) => {
    resolve();
}).then(() => console.log(2));

这个例子中,我们定时器没提供等待时间,几乎接近0S将callback加入任务队列,但是很遗憾,由于promise状态改变为同步执行,状态一改就表示then可以执行了(这一句有歧义,2021.7.20完善小节完善观点),微任务优先级高于宏任务,所以先输出2,在输出1,且两次输出几乎没有间隔。

new Promise就像new Array一样,它是一个同步执行过程,异步的是then,then执行的前置条件是promise的状态改变了(不再是pending),所以上述例子改变状态的正是resolve()这一句。

但实际开发中,改变状态可能也是异步的,今天前同事就问了我一个问题,他说promise不是比定时器先执行吗,我发起网络请求拿一个数据,怎么定时器比promise的then先执行了,我看了他的代码,首先他的定时器未提供等待时间,其次前面我们已经说了,什么时候promise状态变了定时器才能执行,网络请求去拿数据,请求不管成功还是失败,需要后台返回后前端才能根据返回情况去resolve(或者reject),所以请求不回来我们一直没改,定时器怎么可能傻傻的等你网络请求回来呢?

我们来模拟这个过程:

setTimeout(function (resolve) {
    console.log(1);
})
new Promise(function (resolve) {
    setTimeout(function () {
        resolve();
    })
}).then(function () {
    console.log(2);
})

这个例子中,promise中的resolve被一个定时器包裹了,且两个定时器都没设置等待时间。既然都没设置等待时间,那肯定先加入任务队列的先执行,所以先输出1,此时执行第二个定时器callback改变了promise状态,才触发了then输出了2。

一个例子

const P1 = () => (new Promise((resolve)=>{
    console.log('p1');
    resolve()
}));
const P2 = () => (new Promise((resolve)=>{
    console.log('p2');
    resolve()
}));
​
setTimeout(()=>{
    console.log('s1')
    P1().then(()=>{
        console.log(1);
    })
})
setTimeout(()=>{
    console.log('s2')
    P2().then(()=>{
        console.log(2);
    })
})

总结来说,就是在2个定时器中分别调用了2个promise,唯一需要考虑就是是先输出1还是先输出s2。因为全局就存在2个定时器,所以肯定先执行定时器S1,先输出s1之后调用了P1,自然紧接着输出p1,p1改变状态同步(resolve),因此接着跑then输出了1了,同等条件下微任务优先级比宏任务高,所以答案为s1 p1 1 s2 p2 2。假设P1的resolve被一个定时器包裹,你会发现就是先输出s2,最后输出1了。 

2021.7.20 更新修正

在上文中,我贴出了一个定时器与promise执行对比的例子,提到promise状态变了,就会立刻执行then,这句话就给人一种状态变化了then就会立马执行的错觉,这句话有歧义,我们来看个例子:

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

这段代码的执行结果是4 3 2 1,也感谢博客园用户傲娇的小韭菜的提问,才有这段观点的完善。

我们将外层Promise简称P1,内部Promise简称P2,P1的resolve是同步改变的,按照上文的说法,那不应该立马执行P1的then回调吗,如果是这样结果应该是4 3 1 2才对,状态先变先执行,这句话没错,但不够完善,我们来解释这个问题。

首先我们要清楚,Promise异步的是什么,异步的是then吗?其实并不是,真正异步的是then里面的callback,比如上述代码中这一句:

new Promise(resolve => {
    resolve(2)
}).then((t) => console.log(t));

异步的是(t) => console.log(t),而new Promise().then()这一句是同步执行的,它做了什么?它创建了一个Promise实例,然后调用了实例上的then方法,告诉Promise,我这里有个callback,你先帮我记录好,等同步跑完了,我的状态也改变了,你再帮我执行它。

所以对于P1这个外层Promise而言,它里面的三句代码(抛开P2的callback)都是同步执行的,因此P2的callback是先加入异步队列的,此时P1的then都还没执行,自然P1的callback都没加入队列,怎么能先输出1呢?因此执行过程其实是这样:

遇到P2的then,把P2的callback加入异步队列--->输出4--->把P1的callback加入任务队列--->输出3--->两个promise改变状态都是同步的,肯定是先加入的先执行(P1确实先改状态,但是P1 callback那时候都没加入队列,没的执行),最终输出4 3 2 1。

我们把问题升级,改成更符合我们日常网络请求的情况,来看代码:

new Promise(resolve => {
    setTimeout(() => { resolve(1) }, 0)
    new Promise(resolve => {
        setTimeout(() => { resolve(2) }, 0)
    }).then((t) => console.log(t));
    console.log(4);
}).then(t => console.log(t));
console.log(3);

此时的输出就是4 3 1 2了,为什么?此时P2的callback虽然先加入任务队列,但是因为resolve被定时器包裹了,改状态是个宏任务,优先级比then里面的callback执行还低,所以得等着,然后P1的then执行,也把callback加入了异步队列,两个定时器也有执行先后,肯定先加入异步队列的先执行(等待时间短的先执行),因此P1的状态先改,执行P1的callback,P2的状态后改,才执行P2的callback。

要这样想,虽然P2的callback先加入队列,假设这个请求要10S才响应,而P1 callback虽然加入的晚,但是1S就拿到请求改状态了,P1怎么可能等待P2 10S让它先执行后自己才执行呢,这不符合我们日常开发逻辑。

总结来说,Promise状态变了callback确实应该立刻执行,但前提是同步代码走完了,且你的callback有先注册进去,.then不是异步,异步的是里面的callback,.then也是在告诉Promise我有个callback需要你存起来,等时机到了,你再帮我执行,这个过程是个异步行为,补充完毕。

 柒 ❀ 参考

最后一次搞懂 Event Loop

这一次,彻底弄懂 JavaScript 执行机制

详解JavaScript中的Event Loop(事件循环)机制

posted on 2019-05-02 21:09  听风是风  阅读(6661)  评论(14编辑  收藏  举报