JavaScript 单线程 & 定时器

有一天dao面试官问我..JS的同步和异步的问题,随便蒙了个答案。从此这个梗一直留在我心里,这不,开始度娘同步&异步的文章,结果发现它涉及了JS单线程、再来个定时器…虽然距离上次使用JS动画已经是N个月前了,现在一直使用CSS3动画,简单高效易操作谁不喜欢呢?不过还是得知道它内部原理。以下是我的个人见解,如果有什么不对之处,希望大大们指出。当然有些专业术语也有来自其它大牛的文章。

JavaScript单线程

同一个时间内只能做一件事情。为什么JavaScript不能像Java语言一样多线程呢,这样可以同时处理多个事情不是更好吗?

首先它是浏览器端的脚本语言,JavaScript就是为了与用户进行互动,以及操作DOM,这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JS中同时有两个线程,一个在某个DOM元素节点上添加内容,一个是删除了这个元素节点,这时浏览器应该听谁的呢?

为了避免复杂性,从一诞生,JS就是单线程。


同步 & 异步

由于它是单线程的,因此每次等到执行完前一个任务,才会去执行接下来的任务。如果遇到一个非常耗时的任务,这会导致下一个任务需要等待很长一段时间才会执行。

为了解决这样的问题,JS中所有任务可以分成同步任务和异步任务。

同步任务: 任务放入”执行栈”中,必须等待一个任务执行完毕,该任务出栈,才可以执行接下来的任务。(注:按文档定义的顺序被推入栈中)

异步任务:不进入执行栈,而是进入”任务队列”的任务,只有等到执行栈内部的同步任务全部执行完毕,某个异步任务才开始执行(通过”回调函数”的方式执行)。(注:定时器第二个参数如果不一样不是按照文档顺序,而是按照指定的时间推入队列中)

setTimeout(function(){
    console.log("2");
},1000);
setTimeout(function(){
    console.log("3");
},0);
setTimeout(function(){
    console.log("1");
},0);
//结果:3->1->2


任务队列

定义:任务队列就是一个事件的队列(也可以理解为消息的队列)。

“任务队列”中的事件,除了IO设备(ajax获取服务器数据)的事件以外,还包括一些用户产生的事件(mousehover、click、scroll、keyup等)和定时器等。只要在事件中指定了回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。而主线程读取任务队列中的异步任务,主要就是读取回调函数。

当”执行栈”中所有同步任务执行(排队执行)完毕之后,就会读取”任务队列”中的异步任务,将异步任务推入”执行栈”中执行。任务队列是一个先进先出的数据结构,即排在前面的事件,优先被主线程读取。如果存在定时器,时间越短的越先进入执行栈。

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  3. 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

Event loop(事件循环)

主线程从”任务队列”中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

在异步任务中,又可以分为两种,macrotask(宏任务)和micro(微任务)。在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完。

两个类别的具体分类如下:

macrotask: script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering
microtask: process.nextTick, Promise对象

这里写图片描述

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种webAPIs,它们在任务队列中加入各种事件(click,load,keyup等)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。


定时器

一、setTimeout

第一个参数(必填):可以是函数或字符串(不推荐,比如不能执行取消定时器的操作)。

第二个参数(可选),这里指定的时间,比如100ms,并不是说在100ms之后就一定会执行,而是说100ms之后可能会执行(没有同步任务以及它之前都没有异步任务的情况下,可以最快执行)。是指相对于所有同步任务总执行时间,延迟多少ms执行的时间。

在默认情况下,第二个参数默认值为0。但是0毫秒实际上也是达不到的。根据HTML 5标准,setTimeout推迟执行的时间,最少是4毫秒。如果小于这个值,会被自动增加到4ms。

//请允许我用辣鸡代码实现同步执行约10s
var timer = 50000;
for(var i=0;i<timer;i++){
    document.getElementById("box").innerHTML += "a";
}

//是指用户在所有同步任务执行之后,只有触发该事件,这个事件才会加入"任务队列"中
document.getElementById("box1").onclick = function(){
    alert("1");
};

//在约10s+6s才执行回调函数
setTimeout(function(){
    console.log("timer2");
},6000);

以前的错解(如果觉得会混乱,可以不用看哈):我以为在6s的时候,setTimeout()就会加入”任务队列”中,由于同步总执行时间10s大于定时器的6s,就会导致在执行完同步任务约10s之后,定时器立马执行。

正解:结果经过测试,发现在约10s+6s之后,定时器才会被加入”任务队列”中,此时没有同步任务以及它的前面没有异步任务,就会触发该回调函数。(不确定是在10s之后将定时器放入任务队列,还是10+6s,欢迎大大们解答我的疑惑,不过在此处不影响代码的分析)

再来看看第二段代码:

//约5s左右执行
setTimeout(function(){
    console.log("timer1");
},5000);

//约10s左右执行
setTimeout(function(){
    console.log("timer2");
},10000);

错解:这里并不是说5s之后执行timer1,等到约5s+10s才开始执行timer2。

正解:而是约5s开始执行timer1,等到约10s开始执行timer2。
所有的定时器延迟时间都是相对于同步总执行而言的。

第三个参数(可选):是指传递给回调函数的参数。

setTimeout(function(a,b){
    console.log(a,b);  //123,456
},0,123,456);

总结

  1. 不论所有同步任务总执行时间之和是多少的前提下,等到”执行栈”中所有同步任务执行完毕;遇到定时器,才会将该代码放入任务队列中,再根据它第二个参数(延迟执行的时间),定义为多少ms,就代表多少ms后才执行。
  2. 所有的定时器延迟时间都是相对于同步总执行时间而言的(嵌套的定时器不考虑在内)

补充:JS中没有sleep()的痛苦你知道吗??吓得爸爸都用DOM操作和重绘、重排了有没有???后来看到有大大推荐这么干,感天动地泣鬼神,终于找到了另外一种解决方法。

//同步任务执行约10秒
var date = new Date();
while((new Date().getTime() - date.getTime()) <= 10000){};


二、setInterval

setInterval,也称为间歇调用定时器,是指允许设置间歇时间来调用定时器代码在特定的时刻执行。也就是说,setInterval会在每隔指定的时间就执行一次代码。

第一个参数可以是一个函数,也可以是一个字符串。

第二个参数是每次执行之前需要等待的毫秒数,如果主线程上的同步任务未执行完毕,且任务队列上还存在其他异步任务(包括时间更短的定时器),这时候就要等待以上同步任务和异步任务执行完毕之后,并且在延迟指定的时间后,任务才会开始执行。

第三个参数以后是指传入函数的一些参数。其中,只有第一个参数是必须的,其他都是可选的。在默认情况下,第二个参数默认值为0。但是0毫秒实际上也是达不到的。根据HTML 5标准,setTimeout推迟执行的时间,最少是4毫秒。如果小于这个值,会被自动增加到4ms。

调用完setInterval之后,该方法会返回一个定时器ID,主要用于取消超时调用。

下面来看两段代码:

setInterval(function(){
    console.log("setInterval");
    setTimeout(function(){
        console.log("setTimeout")
    },5000);
},1000);

这里1s->2s->—>5s都会执行setTimeout()并且console.log(),不论内部代码是否执行完毕,等到第6s的时候,不仅会执行第6s的setTimeout()和console.log(),还会执行第1s中的setTimeout。

由此可知,setInterval()会导致前一次的回调函数还没有执行完,就开始执行下一次代码

setTimeout(function timer(){
    console.log("setTimeout");
    setTimeout(timer,5000);
},1000);

这里第1s开始console.log()->第6s又一次console.log(),接下来会每隔5s才console.log()。它会等到内部代码执行完毕才会重新调用自身(setTimeout)


三、setTimeout模拟setInterval效果

由于setInterval间歇调用定时器存在一些问题,所以一般会使用setTimeout代替setInterval,至少我本人在开发中是不会使用setInterval的..替换代码如下。

setTimeout(function timer() {
    //需要执行的代码
    //setTimeout会等到定时器代码执行完毕之后才会重新调用自身(递归),要注意的是要给匿名函数添加一个函数名,以便调用自身。
    setTimeout(timer, 1000);
}, 1000)

这样做的好处是,在前一个定时器执行完毕之前,不会向任务队列中插入新的定时器代码,因此确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续执行。这个模式主要用于重复定时器。再看看一些实例。

let num = 0;
let max = 10;

setTimeout(function timer() {
    num++;
    console.log(num);
    if (num === max) {return}
    setTimeout(timer, 500)
}, 500);
//或者是
setTimeout(function timer() {
    num++;
    console.log(num);
    if (num < max) {setTimeout(timer, 500)}
}, 500);

综上,由于setInterval间歇调用定时器会因为在定时器代码未执行完毕时又向任务队列中添加定时器代码,导致某些间隔被跳过等问题,所以应使用setTimeout代替setInterval。

补充:在实际项目的开发中,我们是不建议使用setTimeout的。第一是因为它打乱了模块的生命周期;第二是一旦出现了问题,它是很难调试的,也就是说后期难以维护;第三是定时执行的时间是不精确的,运算顺序混乱;

我的灵感来自好友周大大JavaScript定时器

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值