• 搞懂JS执行机制


    前言

    JS执行机制的问题面试时经常会遇到,以前匆匆忙忙过了几篇文档就觉得自己掌握了,然后遇到相关笔试题时,还是懵懵懂懂的,于是下定决心已定要弄懂,于是产生了这篇文章

    我们都知道浏览器是多线程的,然而JS确是单线程的(可以理解为浏览器只给了一个线程来渲染)

    一.关于javascript

    大家都知道,javascript是一门单线程语言,虽然有H5的Web-Worker加持,但是创建出来的子线程完全受主线程控制,且不得操作 DOM ,所以还是无法改变JavaScript单线程的本质。所以一切 javascrip t版的“多线程”都是用单线程模拟出来的,一切 javascript 多线程都是纸老虎

    1.JS为什么是单线程的?

    最初设计JS是用来在浏览器验证表单操控DOM元素的是一门脚本语言,如果js是多线程的,那么两个线程同时对一个 DOM 元素进行了相互冲突的操作,那么浏览器的解析器是无法执行的。

    2.Js为什么需要异步?

    如果js中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。 对于用户而言,阻塞就意味着“卡死”,这样就导致用户体验很差。比如在进行ajax请求的时候,如果没有返回数据后面的代码就没办法执行

    3.js单线程又是如何实现异步的呢?

    js中的异步以及多线程都可以理解成为一种“假象”,就拿h5的WebWorker来说,子线程有诸多限制,不能控制DOM,不能修改全局对象等等,通常只用来做计算做数据处理。
    这些限制并没有违背我们之前的观点,所以说是“假象”。JS异步的执行机制其实就是事件循环(eventloop),理解了eventloop 机制,就理解了 js 异步的执行机制。

    4.JS的事件循环(eventloop)是怎么运作的?

    “事件循环”、“eventloop”、“运行机制” 这三个术语其实说的是同一个东西。
    “先执行同步操作异步操作排在事件队列里”这样的理解其实也没有任何问题,但如果深入的话会引出很多其他概念,比如event table和event queue, 我们来看运行过程:

    1. 首先判断JS是同步还是异步,同步就进入主线程运行,异步就进入event table.
    2. 异步任务在event table中注册事件,当满足触发条件后,(触发条件可能是延时也可能是ajax回调),被推入event queue
    3. 同步任务进入主线程后一直执行,直到主线程空闲时,才会去event queue中查看是否有可执行的异步任务,如果有就推入主线程中。
    4. js解析器会不断地重复检查主线程执行栈是否为空,然后重复第3步,就是Event Loop(事件循环)。

     

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


    二.宏任务与微任务:

    1.除了广义的同步任务和异步任务,我们对任务有更精细的定义:

    1. MacroTask(宏观Task): setTimeout, setInterval, , requestAnimationFrame(请求动画), I/O

    2. MicroTask(微观任务): process.nextTick, Promise, Object.observe, MutationObserver

    3. 先同步 再取出第一个宏任务执行 所有的相关微任务总会在下一个宏任务之前全部执行完毕 如果遇见 就 先微后宏

    注意: 

    宏任务中包含微任务,一定要将宏任务中的微任务执行完,再去执行下一个宏任务

     微任务中有宏任务,则将宏任务放入宏任务队列任务中

    2.事件循环机制

    不同类型的任务会进入对应的event queue, 比如setTime和setIntval会进入相同(宏任务)的event queue, 而Promise(主要是Promise.then)和process.nextTick会进入相同(微任务)的event queue.

    3.s执行顺序

    所以通常来说,我们页面中的js执行顺序是这样的:
    • 第一轮事件循环:
    1. 主线程执行js整段代码(宏任务),将ajax、setTimeout、promise等回调函数注册到Event Queue,并区分宏任务和微任务。
    2. 主线程提取并执行Event Queue 中的promise,process.nextTick等所有微任务,并注册微任务中的异步任务到Event Queue。
    • 第二轮事件循环:
    1. 主线程提取Event Queue 中的第一个宏任务(通常是setTimeout)。
    2. 主线程执行setTimeout宏任务,并注册setTimeout代码中的异步任务到Event Queue(如果有)。
    3. 执行Event Queue中的所有微任务,并注册微任务中的异步任务到Event Queue(如果有)。
    • 类似的循环:宏任务每执行完一个,就清空一次事件队列中的微任务

    3.分析

    (1)setTimeout

    setTimeout(() => {
      console.log('2秒到了')
    }, 2000)

    setTimeout是异步操作首先进入event table, 注册的事件就是它的回调,触发条件就是2秒之后,当满足条件回调被推入event queue,当主线程空闲时会去event queue里查看是否有可执行的任务。

     
    console.log(1) // 同步任务进入主线程
    setTimeout(fun(),0)   // 异步任务,被放入event table, 0秒之后被推入event queue里
    console.log(3) // 同步任务进入主线程

    1、3是同步任务马上会被执行,执行完成之后主线程空闲去event queue(事件队列)里查看是否有任务在等待执行,这就是为什么setTimeout的延迟事件是0毫秒却在最后执行的原因

    对于我们经常遇到setTimeout(fn,0)这样的代码,它的含义是,指定某个任务在主线最早的空闲时间执行,意思就是不用再等多少秒了, 只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。但是即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒。

    (2)setIntval

    以setIntval(fn,ms)为例,setIntval是循环执行的,setIntval会每隔指定的时间将注册的函数置入event queue,不是每过ms会执行一次fn,而是每过ms秒,会有fn进入event queue。需要注意一点的是,一旦setIntval的回调函数fn执行时间超过了延迟事件ms,那么就完成看不出来有时间间隔了。




    (3)Promise与事件循环

    Promise在初始化时,传入的函数是同步执行的,然后注册then回调。注册完之后,继续往下执行同步代码,在这之前,then的回调不会执行。同步代码块执行完毕后,才会在事件循环中检测是否有可用的promise回调,如果有,那么执行,如果没有,继续下一个事件循环。

    1. 宏任务,微任务都是队列, 一段代码执行时,会先执行宏任务中的同步代码。
    2. 进行第一轮事件循环的时候会把全部的js脚本当成一个宏任务来运行。
    3. 如果执行中遇到setTimeout之类的宏任务,那么就把这个setTimeout内部的函数推入[宏任务的队列]中,下一轮宏任务执行时调用。
    4. 如果执行中遇到promise.then()之类的微任务,就会推入到[当前宏任务的微任务队列]中, 在本轮宏任务的同步代码都执行完成后,依次执行所有的微任务。
    5. 第一轮事件循环中当执行完全部的同步脚步以及微任务队列中的事件,这一轮事件循环就结束了, 开始第二轮事件循环。
    6. 第二轮事件循环同理先执行同步脚本,遇到其他宏任务代码块继续追加到[宏任务的队列]中,遇到微任务,就会推入到[当前宏任务的微任务队列]中,在本轮宏任务的同步代码执行都完成后, 依次执行当前所有的微任务。
    7. 开始第三轮循环往复..

    下面用代码来深入理解上面的机制:
     1 setTimeout(function() {
     2     console.log('4')
     3 })
     4 
     5 new Promise(function(resolve) {
     6     console.log('1') // 同步任务
     7     resolve()
     8 }).then(function() {
     9     console.log('3')
    10 })
    11 
    12 console.log('2')
    1. 这段代码作为宏任务,进入主线程。
    2. 先遇到setTimeout,那么将其回调函数注册后分发到宏任务event queue.
    3. 接下来遇到Promise, new Promise立即执行,then函数分发到微任务event queue
    4. 遇到console.log(), 立即执行
    5. 整体代码script作为第一个宏任务执行结束, 查看当前有没有可执行的微任务,执行then的回调。(第一轮事件循环结束了,我们开始第二轮循环)
    6. 从宏任务的event queue开始,我们发现了宏任务event queue中setTimeout对应的回调函数,立即执行。执行结果: 1-2-3-4

     1 console.log('1')
     2 setTimeout(function() {
     3     console.log('2')
     4     process.nextTick(function() {
     5         console.log('3')
     6     })
     7     new Promise(function(resolve) {
     8         console.log('4')
     9         resolve()
    10     }).then(function() {
    11         console.log('5')
    12     })
    13 })
    14 
    15 process.nextTick(function() {
    16     console.log('6')
    17 })
    18 
    19 new Promise(function(resolve) {
    20     console.log('7')
    21     resolve()
    22 }).then(function() {
    23     console.log('8')
    24 })
    25 
    26 setTimeout(function() {
    27     console.log('9')
    28     process.nextTick(function() {
    29         console.log('10')
    30     })
    31     new Promise(function(resolve) {
    32         console.log('11')
    33         resolve()
    34     }).then(function() {
    35         console.log('12')
    36     })
    37 })

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

    1. 遇到setTimeout, 其回调函数被分发到宏任务event queue中。我们暂且记为setTimeout1
      遇到process.nextTick(),其回调函数被分发到微任务event queue中,我们记为process1
      遇到Promise, new Promise直接执行,输出7.then被分发到微任务event queue中,我们记为then1
    2. 又遇到setTimeout,其回调函数被分发到宏任务event queue中,我们记为setTimeout2.
    3. 现在开始执行微任务, 我们发现了process1和then1两个微任务,执行process1,输出6,执行then1,输出8, 第一轮事件循环正式结束, 这一轮的结果输出1,7,6,8.那么第二轮事件循环从setTimeout1宏任务开始
    4. 首先输出2, 接下来遇到了process.nextTick(),统一被分发到微任务event queue,记为process2(注意:宏任务中包含微任务,一定要将宏任务中的微任务执行完,再去执行下一个宏任务  
    5. new Promise立即执行,输出4,then也被分发到微任务event queue中,记为then2  
    6. 现在开始执行微任务,我们发现有process2和then2两个微任务可以执行输出3,5. 第二轮事件循环结束,第二轮输出2,4,3,5. 第三轮事件循环从setTimeout2宏任务开始
      10。 直接输出9,跟第二轮事件循环类似,输出9,11,10,12
    7. 完整输出是1,7,6,8,2,4,3,5,9,11,10,12(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)
    如果是setTimeout里面嵌套setTimeout, 那么嵌套的setTimeout的宏任务要在外面的宏任务排序的后面,往后排。看个例子
     1 new Promise(function (resolve) { 
     2     console.log('1')// 宏任务一
     3     resolve()
     4 }).then(function () {
     5     console.log('3') // 宏任务一的微任务
     6 })
     7 setTimeout(function () { // 宏任务二
     8     console.log('4')
     9     setTimeout(function () { // 宏任务五
    10         console.log('7')
    11         new Promise(function (resolve) {
    12             console.log('8')
    13             resolve()
    14         }).then(function () {
    15             console.log('10')
    16             setTimeout(function () {  // 宏任务七
    17                 console.log('12')
    18             })
    19         })
    20         console.log('9')
    21     })
    22 })
    23 setTimeout(function () { // 宏任务三
    24     console.log('5')
    25 })
    26 setTimeout(function () {  // 宏任务四
    27     console.log('6')
    28     setTimeout(function () { // 宏任务六
    29         console.log('11')
    30     })
    31 })
    32 console.log('2') // 宏任务一
    结果:1-2-3-4-5-6-7-8-9-10-11-12
    初步总结:宏任务是一个栈按先入先执行的原则,微任务也是一个栈也是先入先执行。但是每个宏任务都对应会有一个微任务栈,宏任务在执行过程中会先执行同步代码再执行微任务栈。
     

    (4)Promise

    来看一个案例

    1    console.log(1)
    2     new Promise(function(resolve,reject){
    3         console.log('2')
    4         resolve()
    5     }).then(function(){
    6       console.log(3)
    7     })
    8     console.log(4)  //1 2 4 3

    先看代码:一个打印,一个new promise,一个promise.then,一个打印

    因为new promise会立即执行,promise.then是异步操作且是微任务

    所以,先执行第一个打印,执行new Promise,将promise.then放入微任务队列,接着执行第二个打印,再执行微任务队列中的promise.then

    最后结果是:1 2 4 3

    (5)async/await

    async/await是什么

    我们创建了promise但不能同步等待它执行完成。 我们只能通过then传咦个回调函数这样很容易再次陷入promise的回调地狱。 实际上, async/await在底层转换成了promise和then回调函数,也就是说, 这是promise的语法糖。每次我们使用await, 解释器都创建咦个promise对象,然后把剩下的async函数中的操作放到then回调函数中。 async/await的实现,离不开promise. 从字面意思来理解, async是“异步”的简写,而await是async wait的简写可以认为是等待异步方法执行完成。

    async/await用来干什么

    用来优化promise的回调问题,被称为是异步的终极解决方案

    async/await内部做了什么

    async函数会返回一个Promise对象,如果在函数中return一个直接量(普通变量),async会把这个直接量通过Promise.resolve()封装成Promise对象。如果你返回了promise那就以你返回的promise为准。await是在等待,等待运行的结果也就是返回值。await后面通常是一个异步操作(promise),但是这不代表await后面只能跟异步才做,await后面实际是可以接普通函数调用或者直接量。
    async相当于 new Promise,await相当于then

    await的等待机制

    如果await后面跟的不是一个promise,那await后面表达式的运算结果就是它等到的东西,如果await后面跟的是一个promise对象,await它会'阻塞'后面的diamante,等着promise对象resolve, 然后得到resolve的值作为await表达式的运算结果。但是此"阻塞"非彼“阻塞”,这就是await必须用在async函数中的原因。 async函数调用不会造成"阻塞",它内部所有的“阻塞”都被封装在一个promise对象中异步执行(这里的阻塞理解成异步等待更合理)

    async/await在使用过程中有什么规定

    每个async方法都返回一个promise, await只能出现在async函数中

    async/await在什么场景使用

    单一的promise链并不能发现async/await的有事,但是如果需要处理由多个promise组成的then链的时候,优势就能体现出来了(Promise通过then链来解决多层回调的问题,现在又用async/awai来进一步优化它)

    async/await的使用方法暂时不说了,它的写法更优雅一些,要比promise的链接调用更多直观也易于维护

    我们来看在任务队列中async/await的运行机制,先给出大概方向再通过案例来证明:

    1. async定义的是一个promise函数和普通函数一样只要不调用就不会进入事件队列。
    2. async内部如果没有主动return promise, 那么async会把函数的返回值用promise包装
    3. await关键字必须出现在async函数中,await后面不是必须要跟一个异步操作,也可以是一个普通表达式
    4. 遇到await关键字,await右边的语句会被立即执行然后await下面的代码进入等待状态,等待await得到结果。
    5. await后面如果不是promise对象,await会阻塞后面的代码,先执行async外面的同步代码,同步代码执行完,再回到async内部,把这个非promise的东西,作为await表达式的结果。
    6. await后面如果是promise对象,await也会暂停async后面的代码,先执行async外面的同步代码,等着promise对象fulfilled,然后把resolve的参数作为await表达式的运算结果

    看一下下面的案例:

     从这两个案例可以得出:async/await在执行时,如果有return ,则会先跳出sync/await函数执行外部的主线程程序,最后才执行async/await函数的return

    (6)process.nextTick

    JS执行时会直接作为微任务放入Task Queque中,当主线程的任务执行完毕后,按微任务queue顺序执行

    三.实践

    1.先来一剂猛药,某面试题

     1 console.log('1');
     2 setTimeout(() => {
     3     console.log('9');
     4     this.$nextTick(() =>  {
     5         console.log('11');
     6     });
     7     new Promise(function(resolve) {
     8         console.log('10');
     9         resolve();
    10     }).then(function() {
    11         console.log('12')
    12     });
    13 },5000);
    14 this.$nextTick(() =>  {
    15     console.log('3');
    16 });
    17 new Promise(function(resolve) {
    18     console.log('2');
    19     resolve();
    20 }).then(function() {
    21     console.log('4');
    22 });
    23 setTimeout(() => {
    24     console.log('5');
    25     this.$nextTick(() =>  {
    26         console.log('7');
    27     });
    28     new Promise(function(resolve) {
    29         console.log('6');
    30         resolve();
    31     }).then(function() {
    32         console.log('8');
    33     });
    34 });

    先看答案:1-2-3-4-5-6-7-8-9-10-11-12

    分析:
    看到诸多异步延时任务先不要慌,一步一步来解读,代码中的this.$nextTick(callback)千万不要解读成上面的process.nextTick(callback),否则你会被坑惨的,process是nodeJs里面的,nodeJs执行机制和JavaScript的执行机制是不同的,nodeJs不会看你代码的层级关系哦,只关心你的事件的类型,按照这个顺序来执行代码,而我们的js是按照父级的事件,有着层级关系的执行。
    vueJs的主线程先执行,首先打印出1,第一个setTimeout push到macro task,nextTick放入micro task,Promise立即执行,then push进micro task,第二个setTimeout push到macro task,接着执行micro task,打印3 4,最后执行macro task,注意这里有个坑,macro task里面有两个timer,第一个5000ms之后执行,所以先执行第二个,所以最后的答案小学生都知道,打印顺序从1到12。

     2.

    3.

     1 console.log('1');
     2 // 记作 set1
     3 setTimeout(function () {
     4     console.log('2');
     5     // set4
     6     setTimeout(function() {
     7         console.log('3');
     8     });
     9     // pro2
    10     new Promise(function (resolve) {
    11         console.log('4');
    12         resolve();
    13     }).then(function () {
    14         console.log('5')
    15     })
    16 })
    17 
    18 // 记作 pro1
    19 new Promise(function (resolve) {
    20     console.log('6');
    21     resolve();
    22 }).then(function () {
    23     console.log('7');
    24     // set3
    25     setTimeout(function() {
    26         console.log('8');
    27     });
    28 })
    29 
    30 // 记作 set2
    31 setTimeout(function () {
    32     console.log('9');
    33     // 记作 pro3
    34     new Promise(function (resolve) {
    35         console.log('10');
    36         resolve();
    37     }).then(function () {
    38         console.log('11');
    39     })
    40 })

    分析:

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

    2.遇到第一个setTimeout-set1,其回调函数被分发到宏任务Event Queue中

    3.遇到new Promise 直接打印,输出6,Promise.then-pro1被分发到微任务Event Queue中

    4.遇到第二个setTimeout-set2,其回调函数被分发到宏任务Event Queue中

    5. 主线程的整段js代码(宏任务)执行完,开始清空所有微任务:主线程执行微任务pro1,输出7;遇到第三个setTimeout-set3,注册回调函数分发到宏任务Event Queue中。

      第一轮EvenLoop结束,开始第二轮

    6.主线程执行队列中第一个宏任务set1,输出2;代码中遇到了第四个setTimeout-set4,注册回调;又遇到了pro2,new promise()直接执行输出4,并注册回调;

    7. set1宏任务执行完毕,开始清空微任务,主线程执行微任务pro2,输出5。(宏任务中包含微任务,一定要将宏任务中的微任务执行完,再去执行下一个宏任务

    第二轮EvenLoop结束,开始第三轮

    8.主线程执行队列中第一个宏任务set2,输出9;代码中遇到了pro3,new promise()直接输出10,并注册回调;

    9.set2宏任务执行完毕,开始情况微任务,主线程执行微任务pro3,输出11。

     ...

    以此类似循环,最后输出结果为:1、6、7、2、4、5、9、10、11、8、3

    就讲到这儿吧,你会了吗?

    再来一个题检测一下吧:

     1 console.log('1');
     2     
     3     setTimeout(function () {
     4       console.log('2');
     5       new Promise(function (resolve) {
     6         console.log('3');
     7         resolve();
     8       }).then(function () {
     9         console.log('4')
    10       })
    11     },0)
    12   
    13     new Promise(function (resolve) {
    14       console.log('5');
    15       resolve();
    16     }).then(function () {
    17       console.log('6')
    18     })
    19 
    20     setTimeout(function () {
    21       console.log('7');
    22       new Promise(function (resolve) {
    23         console.log('8');
    24         resolve();
    25       }).then(function () {
    26         console.log('9')
    27       })
    28       console.log('10')
    29     },0)
    30    
    31     console.log('11') 

    答案: 1  5 11 6 2 3  4 7 8  10 9

    参考:https://juejin.im/post/6844903667301089288

              https://www.cnblogs.com/yaya-003/p/12875191.html

             https://www.jianshu.com/p/1368d375aa66

  • 相关阅读:
    3.22
    练习 3.16
    简单工厂模式
    Java-不可变字符串
    java中的缓冲流
    TCP协议下java通信
    nginx优化
    nginx反向代理
    shell-for循环
    shell-数组
  • 原文地址:https://www.cnblogs.com/yjiangling/p/13892672.html
Copyright © 2020-2023  润新知