欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  web前端

js执行机制实例详解

程序员文章站 2022-03-25 12:21:45
...

想要理解JavaScript的运行机制,需要分别深刻理解几个点:JavaScript的单线程机制、任务队列(同步任务和异步任务)、事件和回调函数、定时器、Event Loop(事件循环)。

JavaScript的单线程机制

JavaScript的一个语言特性(也是这门语言的核心)就是单线程。单线程简单地说就是同一时间只能做一件事,当有多个任务时,只能按照一个顺序一个完成了再执行下一个。

JavaScript的单线程与它的语言用途是有关的。作为一门浏览器脚本语言,JavaScript的主要用途是完成用户交互、操作DOM。这就决定了它只能是单线程,否则会导致复杂的同步问题。

设想JavaScript同时有两个线程,一个线程需要在某个DOM节点上添加内容,而另一个线程的操作是删除了这个节点,那么浏览器应该以谁为准呢?

所以为了避免复杂性,JavaScript从诞生起就是单线程。

为了提高CPU的利用率,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以这个标准并没有改变JavaScript单线程的本质。

任务队列

一个接一个地完成任务也就意味着待完成的任务是需要排队的,那么为什么会需要排队呢?

通常排队有以下两种原因:

  • 任务计算量过大,CPU处于忙碌状态;

  • 任务所需的东西未准备好所以无法继续执行,导致CPU闲置,等待输入输出设备(I/O设备)。

    比如有的任务你需要Ajax获取到数据才能往下执行


由此JavaScript的设计者也意识到,这时完全可以先运行后面已经就绪的任务来提高运行效率,也就是把等待中的任务先挂起放到一边,等得到需要的东西再执行。就好比接电话时对方离开了一下,这时正好有另一个来电,于是你便把当前通话挂起,等那个通话结束后,再连回之前的通话。 所以也就出现了同步和异步的概念,任务也被分成了两种,一种是同步任务(Synchronous),另一种是异步任务(Asynchronous)。

  • 同步任务:需要执行的任务在主线程上排队,一个接一个,前一个完成了再执行下一个

  • 异步任务:没有马上被执行但需要执行的任务,存放在“任务队列”(task queue)中,“任务队列”会通知主线程什么时候哪个异步任务可以执行,然后这个任务就会进入主线程并被执行。

    所有的同步执行都可以看作是没有异步任务的异步执行


具体来说,异步执行如下:

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

    也就是所有能被马上执行的任务都在主线程上排好了队,一个接一个的被执行。

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

    也就是说每个异步任务准备好了就会立一个唯一的flag,这个flag用来标识对应的异步任务。

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

    也就是主线程把之前的任务做完了之后,就会来看“任务队列”中的flag,来把对应的异步任务打包来执行。

  • (4)主线程不断重复以上三步。

    只要主线程空了,就会去读取“任务队列”。这个过程会被不断重复,这就是JavaScript的运行机制。

那怎么知道主线程执行栈为空啊?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。

下面用一

张导图来说明主线程和任务队列。

js执行机制实例详解

导图要表达的内容用文字来表述的话:

  • 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入Event Table并注册函数。

  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue。

  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。

  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

事件和回调函数

事件

“任务队列”是一个事件的队列(也可以理解成是消息的队列),IO设备完成一项任务,就会在“任务队列”中添加一个事件,表示相关的异步任务可以进入“执行栈”。接着主线程读取“任务队列”,查看里面有哪些事件。

“任务队列”中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入“任务队列”,等待主线程读取。

回调函数

所谓“回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

“任务队列”是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,“任务队列”上第一位的事件就自动进入主线程。但是,如果包含“定时器”,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

Event Loop

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

为了更好地理解Event Loop,下面参照Philip Roberts的演讲中的一张图。

js执行机制实例详解

上图中,主线程在运行时,产生了heap(堆)和stack(栈),栈中的代码调用各种外部API,并在“任务队列”中加入各种事件(click,load,done)。当栈中的代码执行完毕,主线程就会读取“任务队列”,并依次执行那些事件所对应的回调函数。

执行栈中的代码(同步任务),总是在读取“任务队列”(异步任务)之前执行。

let data = [];
$.ajax({    url:www.javascript.com,    data:data,    success:() => {        console.log('发送成功!');
    }
})console.log('代码执行结束');

上面是一段简易的ajax请求代码:

  • ajax进入Event Table,注册回调函数success

  • 执行console.log('代码执行结束')

  • ajax事件完成,回调函数success进入Event Queue。

  • 主线程从Event Queue读取回调函数success并执行。

定时器

除了放置异步任务的事件,“任务队列”还可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做定时器(timer)功能,也就是定时执行的代码。

SetTimeout()setInterval()可以用来注册在指定时间之后单次或重复调用的函数,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者会在指定毫秒数的间隔里重复调用:

setInterval(updateClock, 60000); //60秒调用一次updateClock()

因为它们都是客户端JavaScript中重要的全局函数,所以定义为Window对象的方法。

但作为通用函数,其实不会对窗口做什么事情。

Window对象的setTImeout()方法用来实现一个函数在指定的毫秒数之后运行。所以它接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。 setTimeout()setInterval()返回一个值,这个值可以传递给clearTimeout()用于取消这个函数的执行。

console.log(1);
setTimeout(function(){console.log(2);}, 1000);console.log(3);

上面代码的执行结果是1,3,2,因为setTimeout()将第二行推迟到1000毫秒之后执行。

如果将setTimeout()的第二个参数设为0,就表示当前代码执行完(执行栈清空)以后,立即执行(0毫秒间隔)指定的回调函数。

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

上面代码的执行结果总是2,1,因为只有在执行完第二行以后,系统才会执行“任务队列”中的回调函数。

总之,setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是尽可能早地执行。它在“任务队列”的尾部添加一个事件,因此要等到同步任务和“任务队列”现有的事件都处理完,才会得到执行。

HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。

需要注意的是,setTimeout()只是将事件插入了“任务队列”,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证回调函数一定会在setTimeout()指定的时间执行。

由于历史原因,setTimeout()setInterval()的第一个参数可以作为字符串传入。如果这么做,那这个字符串会在指定的超时时间或间隔之后进行求值(相当于执行eval())。

Node.js的Event Loop

Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。

Node.js的运行机制如下。

  • (1)V8引擎解析JavaScript脚本。

  • (2)解析后的代码,调用Node API。

  • (3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。

  • (4)V8引擎再将结果返回给用户。

除了setTimeout和setInterval这两个方法,Node.js还提供了另外两个与”任务队列”有关的方法:process.nextTick和setImmediate。它们可以帮助我们加深对”任务队列”的理解。

process.nextTick方法可以在当前”执行栈”的尾部—-下一次Event Loop(主线程读取”任务队列”)之前—-触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate方法则是在当前”任务队列”的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行,这与setTimeout(fn, 0)很像。请看下面的例子

process.nextTick(function A() {console.log(1);process.nextTick(function B(){console.log(2);});});
setTimeout(function timeout() {console.log('TIMEOUT FIRED');
}, 0)// 1// 2// TIMEOUT FIRED

上面代码中,由于process.nextTick方法指定的回调函数,总是在当前”执行栈”的尾部触发,所以不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这说明,如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前”执行栈”执行。

现在,再看setImmediate。

setImmediate(function A() {console.log(1);
setImmediate(function B(){console.log(2);});});
setTimeout(function timeout() {console.log('TIMEOUT FIRED');
}, 0);

上面代码中,setImmediate与setTimeout(fn,0)各自添加了一个回调函数A和timeout,都是在下一次Event Loop触发。那么,哪个回调函数先执行呢?答案是不确定。运行结果可能是1–TIMEOUT FIRED–2,也可能是TIMEOUT FIRED–1–2。

令人困惑的是,Node.js文档中称,setImmediate指定的回调函数,总是排在setTimeout前面。实际上,这种情况只发生在递归调用的时候。

setImmediate(function (){setImmediate(function A() {console.log(1);
setImmediate(function B(){console.log(2);});});
setTimeout(function timeout() {console.log('TIMEOUT FIRED');
}, 0);
}); 
// 1 // TIMEOUT FIRED // 2

上面代码中,setImmediate和setTimeout被封装在一个setImmediate里面,它的运行结果总是1–TIMEOUT FIRED–2,这时函数A一定在timeout前面触发。至于2排在TIMEOUT FIRED的后面(即函数B在timeout后面触发),是因为setImmediate总是将事件注册到下一轮Event Loop,所以函数A和timeout是在同一轮Loop执行,而函数B在下一轮Loop执行。

我们由此得到了process.nextTick和setImmediate的一个重要区别:多个process.nextTick语句总是在当前”执行栈”一次执行完,多个setImmediate可能则需要多次loop才能执行完。事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick,将会没完没了,主线程根本不会去读取”事件队列”!

process.nextTick(function foo() {process.nextTick(foo);
});

事实上,现在要是你写出递归的process.nextTick,Node.js会抛出一个警告,要求你改成setImmediate。

另外,由于process.nextTick指定的回调函数是在本次”事件循环”触发,而setImmediate指定的是在下次”事件循环”触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查”任务队列”)。

Promise

除了广义的同步任务和异步任务,任务还有更精细的定义:

  • macro-task(宏任务):包括整体代码script,setTimeout,setInterval

  • micro-task(微任务):Promise,process.nextTick

事件循环,宏任务,微任务的关系如图所示:

js执行机制实例详解

按照宏任务和微任务这种分类方式,JS的执行机制是

  • 执行一个宏任务,过程中如果遇到微任务,就将其放到微任务的【事件队列】里

  • 当前宏任务执行完成后,会查看微任务的【事件队列】,并将里面全部的微任务依次执行完

请看下面的例子:

setTimeout(function(){
     console.log('定时器开始啦')
 });

 new Promise(function(resolve){
     console.log('马上执行for循环啦');     for(var i = 0; i < 10000; i++){
         i == 99 && resolve();
     }
 }).then(function(){
     console.log('执行then函数啦')
 }); console.log('代码执行结束');
  • 首先执行script下的宏任务,遇到setTimeout,将其放到宏任务的【队列】里

  • 遇到 new Promise直接执行,打印”马上执行for循环啦”

  • 遇到then方法,是微任务,将其放到微任务的【队列里】

  • 打印 “代码执行结束”

  • 本轮宏任务执行完毕,查看本轮的微任务,发现有一个then方法里的函数, 打印”执行then函数啦”

  • 到此,本轮的event loop 全部完成。

  • 下一轮的循环里,先执行一个宏任务,发现宏任务的【队列】里有一个 setTimeout里的函数,执行打印”定时器开始啦”

所以最后的执行顺序是【马上执行for循环啦 — 代码执行结束 — 执行then函数啦 — 定时器开始啦】

我们来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制:

console.log('1');

setTimeout(function() {
    console.log('2');    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

第一轮事件循环流程分析如下:

  • 整体script作为第一个宏任务进入主线程,遇到console.log,输出1。

  • 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1

  • 遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。我们记为process1

  • 遇到Promisenew Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1

  • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2

宏任务Event Queue 微任务Event Queue
setTimeout1 process1
setTimeout2 then1

* 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。

  • 我们发现了process1then1两个微任务。

  • 执行process1,输出6。

  • 执行then1,输出8。

好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:

  • 首先输出2。接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2

宏任务Event Queue 微任务Event Queue
setTimeout2 process2

then2

* 第二轮事件循环宏任务结束,我们发现有process2then2两个微任务可以执行。
* 输出3。
* 输出5。
* 第二轮事件循环结束,第二轮输出2,4,3,5。
* 第三轮事件循环开始,此时只剩setTimeout2了,执行。
* 直接输出9。
* 将process.nextTick()分发到微任务Event Queue中。记为process3
* 直接执行new Promise,输出11。
* 将then分发到微任务Event Queue中,记为then3

宏任务Event Queue 微任务Event Queue

process3

then3

* 第三轮事件循环宏任务执行结束,执行两个微任务process3then3
* 输出10。
* 输出12。
* 第三轮事件循环结束,第三轮输出9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)

以上就是js执行机制实例详解的详细内容,更多请关注其它相关文章!