• 技术文章 >web前端 >js教程

    js执行机制实例详解

    小云云小云云2018-03-14 17:21:53原创1836

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

    JavaScript的单线程机制

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

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

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

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

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

    任务队列

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

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


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


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

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

    下面用一

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

    15fdd88994142347.webp.jpg

    大前端零基础入门到就业:进入学习

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

    事件和回调函数

    事件

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

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

    回调函数

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

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

    Event Loop

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

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

    Event Loop

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

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

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

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

    定时器

    除了放置异步任务的事件,“任务队列”还可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做定时器(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的运行机制如下。

    除了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

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

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

    按照宏任务和微任务这种分类方式,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('代码执行结束');

    所以最后的执行顺序是【马上执行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')
        })
    })

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

    宏任务Event Queue微任务Event Queue
    setTimeout1process1
    setTimeout2then1

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

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

    宏任务Event Queue微任务Event Queue
    setTimeout2process2

    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执行机制实例详解的详细内容,更多请关注php中文网其它相关文章!

    声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn核实处理。

    前端(VUE)零基础到就业课程:点击学习

    清晰的学习路线+老师随时辅导答疑

    自己动手写 PHP MVC 框架:点击学习

    快速了解MVC架构、了解框架底层运行原理

    专题推荐:javascript 详解 实例
    上一篇:鼠标响应式淘宝动画效果的实现 下一篇:自己动手写 PHP MVC 框架(40节精讲/巨细/新人进阶必看)

    相关文章推荐

    • ❤️‍🔥共22门课程,总价3725元,会员免费学• ❤️‍🔥接口自动化测试不想写代码?• 浅析Angular变更检测中的DOM更新机制• 一文带你深入了解Node中的Buffer类• Angular开发问题记录:组件拿不到@Input输入属性• 带你了解Nodejs中的非阻塞异步IO• 聊聊基于Node实现单点登录(SSO)的方法
    1/1

    PHP中文网