阅读 79

JS运行机制

学习JS的时候,为了更深入的理解JS的运行机制,需要理解各种概念,比如JS引擎是单线程的、同步/异步任务、JS事件循环机制等等。为了清晰的理解这些概念和它们之间的联系,需要静下心来好好梳理一番,本文则作为学习笔记产出。

先热个身

在chrome浏览器控制台里执行一段JS代码

console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
    console.log('Promise');
});

console.log('script end');
复制代码

打印结果是

script start
script end
Promise
setTimeout
复制代码

从打印结果顺序可以看出,代码的执行顺序并不是按照代码的编写顺序从上到下依次被执行的,如果我们希望通过调整代码编写顺序使得输出的顺序是

script start
setTimeout
Promise
script end
复制代码

我们应该这样写

console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
  Promise.resolve().then(function() {
    console.log('Promise');
    console.log('script end');
  });
}, 0);
复制代码

为什么第一段代码片段会是这样的顺序呢,为什么第二段代码片段要这样写呢?这就需要探究JS引擎的运行机制了。

JS引擎单线程

JS引擎是单线程的可以理解为:在一个浏览器渲染进程中,无论什么时候,只有一个JS线程在执行JS程序。也就是说所有的JS代码都是在一个线程中被执行的。
至于为什么要设计成单线程呢,这个可能是单线程容易实现一些吧,毕竟现在看来采用的 事件循环机制 是能能应付解决真实场景的一系列问题的。而多线程模式涉及到线程切换、线程通信等,相对来说实现比较复杂。

JS的一些概念

JavaScript执行本身是单线程的,把一段JS程序看做是要执行多个任务,那么线程中可能涉及到其中有的任务是非常耗时的,常见的任务有

  • 等待用户输入
  • 从数据库或者文件系统中读取数据
  • 网络请求数据
  • 设置时间间隔延时执行任务
  • 设置固定时间间隔重复执行任务

   遇到这类耗时任务时,如果同步处理,JS引擎线程需要在任务执行过程中一直等待,直到任务执行结果返回后再继续执行下一个任务,这样一来,JS引擎会运行很长时间。    然而,这里要知晓一件事情:JS引擎线程和GUI渲染线程是互斥的,当JS引擎线程执行时,GUI渲染线程会被挂起。所以JS脚本运行会阻塞UI渲染。这是因为JavaScript是可操纵更改DOM的,如果在修改DOM元素属性同时渲染界面,即JS线程和UI渲染线程同时运行,那么渲染线程前后获得的元素数据就可能不一致了。
   因此,如果JS运行时间过长,就会造成页面的渲染不连贯。这样网页性能太差了,显然是不行的,所以不能让JS运行时间过长,遇到耗时任务,那该如何处理呢?
   我们希望JS引擎在处理这类任务的时候是能够异步处理,即不需要一直等待任务完成才继续下一个任务,JS引擎可以在耗时任务执行过程中先继续执行下一个任务,等耗时任务执行完毕有了结果数据后再回头处理结果数据。这样一来,JS引擎运行时间自然就缩短了。
   事实上,JS是有语法支持异步处理耗时任务的,然后JS引擎也能够正如我们所期望的那样工作,具体是通过JS事件循环机制协调处理JS脚本中一系列任务和UI渲染,避免浏览器失去响应。

这里引起了一系列疑问:

  • 同步任务是什么?
  • 异步任务是什么?
  • 哪些操作是同步任务?
  • 哪些操作是异步任务?
  • JS事件循环机制具体是怎么样的?
  • ...
  • ...
    带着这些疑问寻找答案。

同步和异步

把一段程序看做是要执行多个任务, 这里有四个概念来定义任务和任务执行过程:同步任务、异步任务、同步执行和异步执行。 同步任务和异步任务描述的是任务执行本身的特征。

  • 同步任务:任务执行后可以得到预期结果。
  • 异步任务:任务执行后得不到预期结果,而是需要在将来通过一定的手段得到。比如网络请求,定时任务等。所以异步任务通常会有一个或者多个回调函数,任务执行后,在将来拿到结果数据后再执行回调函数对结果数据进行处理。 通俗解释就是:如果任务是异步的,发出调用之后,马上返回,但是不会马上返回预期结果。调用者不必主动等待,当被调用者得到结果之后会通过回调函数主动通知调用者。

举例说明: A想知道C的电话,需要打电话让B查询,如果B要查询10分钟

  • 同步任务是,A请求后需要一直等待,10分钟后拿到结果后挂掉电话。
  • 异步任务是,A请求后挂掉电话,10分钟后B打电话告诉A。

同步执行和异步执行描述的是任务的结果顺序和执行顺序是否一致,其具体含义是

  • 同步执行:在要执行的多个任务中,不管任务是否是异步任务,后一个任务始终需要等待前一个任务结束后再执行。所以任务是先执行先返回结果,即任务的结果顺序和执行顺序是一致的,是同步的。
  • 异步执行:在要执行的多个任务中,有异步任务的情况下,后一个任务不需要等待异步任务结束就可以执行。这可能出现后一个任务的运行结果比前一个任务的运行结果要先返回。所以任务的结果顺序和执行顺序可能是不一致的,是异步的。

举例说明:
A想知道C的电话,需要打电话让B查询,如果B要查询10分钟 A想知道D的电话,需要打电话让E查询,如果E要查询5分钟

  • 同步执行是,A请求后C需要一直等待,10分钟后拿到结果后挂掉电话。再打给E,一直等待8分钟后拿到结果挂掉电话。
  • 异步执行是,A请求后C后挂掉电话,再去请求E后挂掉电话,5分钟后E打电话告诉A。再过5分钟后B打电话告诉A。虽然A后去查询D电话,但是先拿到其电话电话。

异步编程

JavaScript执行本身是单线程的,但是线程里可能涉及到的耗时任务进行异步处理来提高性能。
程序一部分任务现在运行,而另外一部分将来运行,如何处理这之间的时间间隙以及程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。异步编程的本质是(相对同步)控制程序块的执行顺序。通过异步编程我们可以

  • 同步执行同步任务
  • 异步执行同步任务
  • 同步执行异步任务
  • 异步执行异步任务

关于JS异步编程的具体实现打算后续另起一篇学习笔记来总结,这里先放一篇别人的文章JS异步编程

为异步而生的JS语法

在JS语法定义中,有一些特定的语法来标识异步任务。另外,异步任务又分为宏任务(task)和微任务(microtask),这样区分等于是给异步任务的回调函数加了执行优先级,microtask优先级更高,会在当前的同步任务执行完成之后立即执行

  • task有:setTimeOut、setInterval、http请求等
  • microTask有两种:Promise和Process.nextTick。 setTimeOut、Promise经常用,需要掌握

JS事件循环(Event Loop)

为了协调事件,用户交互,脚本,ui渲染和网络处理行为。防止主线程的阻塞,event loop解决方案应运而生。 渲染进程是多线程的

  • 当遇到setTimeOut、setInterval时就放到定时器线程中,
  • 当遇到网络请求时,就放到网络线程中,
  • 当遇到事件如点击等时,就放到事件线程中,

JS事件循环机制通过事件触发线程来执行异步任务的回调函数的。整体流程是:

  1. 本轮循环开始
  2. 主线程会判断是同步还是异步任务
  3. 如果是同步任务,则立即执行,同步任务都在主线程上执行,形成一个执行栈
  4. 如果是异步任务,则将该任务交给其他线程进行处理。
  5. 异步任务执行完成之后会判断它是task还是microtask,再分别将其回调事件到宏任务队列中和微任务队列中
  6. JS引擎将当前同步任务执行完成之后会清空当前的microtask队列
  7. 本轮循环结束
  8. UI渲染线程开始工作,执行UI render,所以微任务会优先于UI渲染线程,这就意味着我们使用微任务更新的DOM能更快的被渲染出来。
  9. render完毕之后,读取当前宏任务队列进入下一轮循环
  10. 每一次处理宏任务之前,都会去判断微任务队列是否为空,不为空则优先清空微任务队列,然后再处理宏任务。

了解了JS运行机制后,就能理解下面这段代码的执行结果了

console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
    console.log('Promise1');
    Promise.resolve().then(function() {
        console.log('Promise2');
        
        setTimeout(function() {
          console.log('Promise2 setTimeout');
        }, 0);
    });
});

console.log('script end');
复制代码

结果是

script start
script end
Promise1
Promise2
setTimeout
Promise2 setTimeout
复制代码

执行结果验证了执行顺序。