最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 事件循环(Event lop)

    正文概述 掘金(闻朝)   2021-01-02   402

    这篇文章要讲的是在 JavaScript 中一个很重要的知识点: 事件循环。结合一篇国外的开发者写的特别优秀的文章来整理总结。

    图解事件循环


    本部分内容翻译自原文,作者 Lydia Hallie。

    事件循环(Event lop)
    事件循环总体进程



    JavaScript 是单线程的。所谓单线程意味着一次只能运行一个任务(注释1)。通常情况下这没什么大不了的,但是想象一下如果你正在运行一个任务而且这个任务需要耗时 30 秒,在这 30s 时间结束之前我们没有办法做任何事情,因为 JavaScript 默认运行在浏览器的主线程,所以整个 UI 被阻塞了。

    幸运的是,浏览器为我们提供了一些 JavaScript 引擎本身没有提供的特性: Web API。这包括 DOM API、setTimeout、HTTP 请求等等。这可以帮助我们创建一些异步非阻塞行为。

    当我们调用一个函数时,它被添加到调用堆栈中(压栈)。调用堆栈是 JS 引擎的一部分,这不是浏览器特有的。它是一个堆栈,意味着它是第一个进来的,最后一个出来的(想想一堆煎饼)。当一个函数返回一个值,它被从栈中弹出(出栈)。

    事件循环(Event lop)
    函数调用,进栈出栈

    respond 函数返回一个 setTimeout 函数。setTimeout 是由 Web API 提供的: 它允许我们在不阻塞主线程的情况下延迟任务。我们传递给 setTimeout 函数的回调箭头函数 ()=> { return 'Hey' } 被添加到 Web API 中。与此同时,setTimeout 函数和 respond 函数从堆栈中弹出,它们都返回了它们的值!

    事件循环(Event lop)
    Web Api 接收回调函数

    在 Web API 中,一个计时器模块(timer)的运行时间与传递给它的第二个参数时间一样长,即 1000ms。当运行时间到达后,回调不会立即添加到调用堆栈中,而是传递给称为队列的东西(排队问询)。

    事件循环(Event lop)
    回调进入待执行队列,排队

    这可能是一个令人困惑的部分: 它并不意味着在 1000ms 之后将回调函数直接添加到调用栈,然后返回一个值!它只是在 1000ms 后添加到队列中。因为它是一个队列,函数必须排队,等待轮到它!

    事件循环(Event lop)
    排队结束,进栈执行

    回调被添加到调用堆栈中,被调用,并返回一个值,然后从堆栈中弹出。

    事件循环(Event lop)
    最终过程

    来看一个具体例子

    const foo = () => console.log("First");
    const bar = () => setTimeout(() => console.log("Second"), 500);
    const baz = () => console.log("Third");
    
    bar();
    foo();
    baz();
    


    让我们快速查看一下在浏览器中运行这段代码时发生了什么

    事件循环(Event lop)
    运行案例示意

    整个流程的执行结果如下

    1. 我们调用函数 bar,bar 返回一个 setTimeout 函数。
    2. 我们传递给 setTimeout 的回调被添加到 Web API 中,setTimeout 函数和 bar 被弹出 调用栈(callstack)。
    3. 计时器运行,同时 foo 被调用并打印 “First” 。foo 返回(undefined,没有 return),baz 被调用。
    4. baz() 打印 ”Third”。事件循环在 baz 返回后看到调用栈为空,然后将回调添加到调用堆栈中。
    5. 回调里打印 ”Second”。


    注释1:单线程的特性,与它的用途有关,作为浏览器脚本语言,JavaScript 的主要用途是与用户互动以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

    实践


    通过以上这部分,可以初步理解事件循环的基本原理。下面是一些自己的实践(监测代码的事件循环可以在此平台上实时查看,一个很好的平台)。

    事件循环中的任务(Task)

    1. 一个简单的总结是:每一行 JS 代码都是一个微任务,所有的微任务构成了一个宏任务;执行在 JS 引擎里的就是微任务,执行在 JS 引擎之外的就是宏任务,循环宏任务的工作就是事件循环
      拿浏览器举例:setTimeout、setInterval 这种其实不是 JS 语法本身的 API,是 JS 的宿主“浏览器”提供的 API, 所以是宏任务。而 Promise 是 JS 本身自带的 API,这种就是微任务。所以也可以说宿主提供的方法是宏任务,JS 引擎中运行的是微任务,JS 调用 WEB API 的代码也是微任务。

    2. 一个宏任务中只存在一个微任务队列,根据入队时间决定各微任务执行顺序,并且所有的同步代码会合并到一个微任务中。

    3. 假设任务列表里面有很多宏任务,然后每个宏任务里面有一个微任务列表,执行下一个宏任务之前一定会把上一个宏任务内部的微任务执行完。

    4. Promise 的作用是在队列循环中插队一个要立即执行的事件,生成一个异步微任务。也就是说,在没有 Promise 的时代其实没有微任务一说,因为所有同步代码都会被合并到一起作为一个微任务然后顺次执行,没有异步微任务。

    5. 需要注意的是** Promise 后的 then 部分只跟调用时期有关**,也就是说什么时候调用 resolve,then 部分的代码就属于哪一个任务。

    6. JS 只能活在宿主环境里,例如浏览器和 node。微任务控制权在 JS 引擎,宏任务控制权在宿主,这样 JS 就方便跨语言传递信息。

    7. 产生宏任务的场景主要包含:script、setTimeout、setInterval、I/O、UI 交互事件(鼠标,键盘,语音设备等等)、setImmediate(Node.js 环境)。产生微任务场景主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)


    在实际应用场景中,script 标签不是 JS 本身的功能,通过 script 标签引入的一个 JS 资源中的所有内容会被归到一个宏任务中,如果我们的一个宏任务中代码最后没有返回值,执行完这个宏任务后会自动返回一个 undefined,所以我们可以把一个 undefined 作为一个宏任务的结束标记(这个不重要)(所以在浏览器中常常说页面的渲染是从上往下的,加载到资源后就开始调用, JS 的文件引入最好放在 body 标签最后)。

    几个例子


    下面通过几个具体的列子来看看执行顺序。

    所有代码同步

    function func1() {
      console.log("执行到我了:**" + 1 + "**"); // @1
    }
    
    function func2() {
      console.log("执行到我了:**" + 2 + "**"); // @2
    }
    
    func1();
    func2();
    console.log("执行到我了:**" + 3 + "**"); // @3
    


      打印结果是:@1-->@2-->@3

    加入 setTimeout

    function func1() {
      console.log("执行到我了:**" + 1 + "**"); // @1
      setTimeout(() => console.log("执行到我了:**" + 1.1 + "**"), 500); // @2
      setTimeout(() => console.log("执行到我了:**" + 1.2 + "**"), 499); // @3
    }
    
    function func2() {
      console.log("执行到我了:**" + 2 + "**"); // @4
      setTimeout(() => console.log("执行到我了:**" + 2.1 + "**"), 0); // @5
    }
    
    func1();
    func2();
    console.log("执行到我了:**" + 3 + "**"); // @6
    setTimeout(() => console.log("执行到我了:**" + 3.1 + "**"), 500); // @7
    


    打印结果是:@1-->@4-->@6-->@5-->@3-->@2-->@7。

    从这里可以看到 1,4,6 属于同步代码会先后执行,2,3,5,7 各自生成一个宏任务在之后执行。由于 5 的延迟时间为 0,最早到达所以最先执行,2 和 3 在同一个函数里,2 虽然比 3 先调用,但是 3 时间较短先被执行,2 和 7 延迟时间相同,2 先被调用先执行,7 最后执行。

    加入 Promise

    function func1() {
      console.log("执行到我了:**" + 1 + "**"); // @1
      setTimeout(() => console.log("执行到我了:**" + 1.1 + "**"), 500); //@2
    }
    
    function func2() {
      console.log("执行到我了:**" + 2 + "**"); // @3
      setTimeout(() => console.log("执行到我了:**" + 2.1 + "**"), 0); // @4
    }
    
    func1();
    console.log("执行到我了:**" + 3 + "**"); // @5
    new Promise((resolve, reject) => {
      console.log("执行到我了:**" + 3.1 + "**"); // @6
      resolve();
    }).then(() => {
      let before = now = new Date().valueOf();
      while ((now - before) / 1000 < 10) {
        now = new Date().valueOf();
      }
      console.log("执行到我了:**" + 3.2 + "**"); // @7
    });
    func2();
    


    打印结果是:@1-->@5-->@6-->@3-->@7-->@4-->@2。

    这个例子比较可以说明问题:func1、func2 和 new Promise 都属于同步代码,所以 1,5,6,3 可以理解为一个微任务,7 是 Promise 生成的异步微任务,是另外一个微任务,4 和 2 生成两个宏任务。在执行 7 之前,阻塞了线程 10s, 4 和 2 一直被阻塞到 7 执行后,说明下一个宏任务的执行必须要前面的宏任务执行完毕。

    加入 async,await

    async function func1() {
      console.log("执行到我了:**1**"); // @1
      await func2();
      setTimeout(() => console.log("执行到我了:**7**"), 500); //@2
      console.log("执行到我了:**4**"); // @3
    }
    
    async function func2() {
      console.log("执行到我了:**2**"); // @4
      setTimeout(() => console.log("执行到我了:**6**"), 500); // @5
    }
    
    func1();
    new Promise((resolve, reject) => {
      console.log("执行到我了:**3**"); // @6
      resolve();
    }).then(() => {
      console.log("执行到我了:**5**"); // @7
    });
    


    打印结果是:@1-->@4-->@6-->@3-->@7-->@5-->@2。

    在这个例子中,func1,await,func2,new Promise 属于同步代码,所以 1,4,6 是一个微任务,await 后面的代码和 Promise 的 then 中的回调函数是两个不同的异步代码块,生成两个异步微任务,其中由于不同的浏览器下优先级不一致,微任务的执行顺序不一样,chrome 浏览器下是以调用顺序为先,先调用的先执行,而在 safari 下 await 的优先级更高,会优先执行(只看到现象,待验证)。3 和 7 打印之后,第一个宏任务结束,由于 5 先被调用所以5先执行,2之后执行。

    一道题目

    
    /**
     * 并发量控制的异步执行器
     * 实现 ParallelExecutor 类
     * constructor 中的 parallel 参数可以指定能够并发执行的 promise 数量
     * 可以在任何时候往里面push promiseMaker 但是同时只允许 parallel 个正在执行
     * 依次执行,直到所有队列中的promise被执行完毕
     */
    
    /**
     * 请实现ParallelExecutor
     */
    'use strict';
    
    class ParallelExecutor {
      constructor(parallel) {}
      push(promiseMaker) {}
    }
    
    /**
     * Test case
     */
    const exec1 = new ParallelExecutor(2);
    const t = new Date().getTime();
    
    const push = (name, pushTimeout, execTime) => {
      setTimeout(() => {
        exec1.push(() => {
          return new Promise((resolve) => {
            setTimeout(resolve, execTime);
          }).then(() => {
            console.log(`promise ${name} complete, cost: ${new Date().getTime() - t}`);
          });
        });
      }, pushTimeout);
    };
    
    push('p1', 0, 1000); // 第0ms push 一个开始执行 1000 ms之后的打印 promise
    push('p2', 0, 500); // 第0ms push 一个开始执行 500 ms之后的打印 promise
    push('p3', 0, 200); // 第0ms push 一个开始执行 200 ms之后的打印 promise
    push('p4', 0, 500); // 第0ms push 一个开始执行 500 ms之后的打印 promise
    push('p5', 1000, 500); // 第1000ms push 一个开始执行 500 ms之后的打印 promise
    
    /**
     * 结果
     * 大约 500ms 后,打印:promise p2 complete
     * 大约 700ms 后,打印:promise p3 complete
     * 大约 1000ms 后,打印:promise p1 complete
     * 大约 1200ms 后,打印:promise p4 complete
     * 大约 1500ms 后,打印:promise p5 complete
     */
    


    这是一道来自阿里的面试题,需要实现 ParallelExecutor 类,题目的核心实现其实并不难,关键是看懂 push 函数内部到底做了什么事情。我在做这道题的时候因为没有仔细看题目,没有注意到 exec1.push 内部关键位置是一个 setTimeout 调用,导致花了较多的时间,我的答案如下:

    class ParallelExecutor {
      constructor(parallel) {
        this.inRunning = 0;
        this.maxPromise = parallel;
        // 创建一个待执行队列,初始时队列为空
        this.waitQueue = [];
      }
    
      push(promiseMaker) {
        if(this.inRunning <= this.maxPromise - 1){
          this.inRunning ++;
          this.running(promiseMaker)
        }else {
          this.waitQueue.push(promiseMaker)
        }
      }
    
      running(promiseMaker){
        // !!!这里是导致出错的地方
        // new Promise((resolve)=>{
        //   promiseMaker();
        //   resolve();
        // })
        promiseMaker().then(()=> {
          if(this.waitQueue.length > 0){
            let next = this.waitQueue.shift();
            this.running(next);
          }else {
            this.inRunning --;
          }
        })
      }
    }
    

    上面标出了出错的地方,最开始的想法是 promiseMaker 函数内部做了什么事情可以不关心,反正是同步代码(没细看,看错了)执行就完事了,然后导致 running 函数内部的 then 处在了当前的宏任务里被先执行掉了。

    5次 push 的调用,生成了5个宏任务,宏任务中的 then 执行时下一个 exec1.push 并不会发生,这样也就导致了 this.waitQueue 永远为空值。

    这里需要再次强调: Promise 后的 then 部分只跟调用时期有关,也就是说什么时候调用 resolve,then 部分的代码就属于哪一个任务

    理解事件循环的意义


    事件循环的设计目的是解决 JS 运行阻塞主线程问题。

    计算机在运行过程中,如果排队是因为计算量大,CPU 忙不过来可以理解,但是如果是网络请求就不合适,因为你不知道什么时候会结束。因此理解事件循环以后,你可以很好的设计代码,例如同步变异步。

    如当某一个列表的数据量特别大时,可以通过异步任务加载的方式将一次运行的任务放到多次中去执行,在多次执行的间隙,就有可能有用户的交互行为穿插进来,从而产生响应,不至于因为一些长时间的操作导致无法对用户的操作行为作出正确的响应,导致形成卡顿的感觉。

    参考文章:

    1. JavaScript Visualized: Event Loop
    2. 【JS】深入理解事件循环,这一篇就够了!(必看)
    3. 深入理解javascript中的事件循环event-loop
    4. 两分钟视频搞懂EventLop

    起源地下载网 » 事件循环(Event lop)

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元