JavaScript是如何工作的

作为一个记录,记录下对JS内部执行机制的总结

一:V8引擎

Google的V8引擎是最流行的一个JS运行环境,通过研究V8引擎来了解JS内部执行机制。

二:V8内部架构

1:JS进程环境


V8引擎

这个图代表了JS解释器运行的环境,其中包括了

  • 内存堆:堆负责给各种类型分配内存
  • 调用栈:栈负责调用时的压栈出栈操作

2:Web API
单独的JS运行环境只能处理一些纯逻辑的内容。如果想完成完整的工作,还需要一些外部接口的协助。这些被成为Web Api。常见的有负责DOM的Document,负责Ajax的XMLHttpRequest,以及负责外部定时的window.setTimeout等。


外部API

其中除了API外,还有两个最常用的两个机制

  • 事件循环:事件循环是一个while循环,负责所有同步/异步代码的调度运行,比如回调队列中事件的执行。
  • 回调队列:外部API根据交互,定时向回调队列中添加事件。这是一个FIFO队列,遵循先入先出的规则。

三:详细解析

JS解释器内部

1:内存堆
内存堆用于存放所有在JS运行过程中创建的全局变量,对象。内存堆中资源的分配和回收管理依赖于JS的GC机制,也就是垃圾回收机制。具体的可以参考这篇文章。GC使用的回收算法称为标记算法。标记清除算法有3个步骤:

  • 根:通常,根是代码中引用的全局变量。例如,在JavaScript中,可以充当根的全局变量是“window”对象。Node.js中的root对象为“global”。所有根的完整列表由垃圾回收器构建。

  • 算法然后检查所有根和他们的子节点并且标记他们是活跃的(意思着他们不是垃圾)。任何根不能达到的对象将被标记为垃圾。

  • 最后,垃圾回收器释放所有未标记为活动的内存块,并将该内存返回给操作系统。


    image.png

    在实际使用中,我们需要注意的是几种常见的导致内存泄漏的问题:

    • 全局变量
      JavaScript以一种有趣的方式处理未声明的变量:当引用未声明的变量时,在全局对象中创建一个新变量。 在浏览器中,全局对象将是window,这意味着

      function foo(arg) {
        bar = "some text";
      }
      

      等价于

      function foo(arg) {
        window.bar = "some text";
      }
      

      假设我们的目的只是引用foo函数中的一个变量,一个冗余的全局变量将被创建。但是,只要你不使用var来声明它,在上述情况下,这没有太大的问题。当然您可以想象一个更具破坏性的场景。
      你也可能用this不经意的创建一个全局变量:

      function foo() {
        this.var1 = "potential accidental global";
      }
      
      // Foo called on its own, this points to the global object (window)
      // rather than being undefined.
      foo();
      

      您可以在JavaScript文件的开始添加 ‘use strict’; 来避免这一问题,这将开启一个更加严格的解析JavaScript模式,防止意外创建全局变量。

      意外的全局变量当然是一个问题,然而,更多的时候,代码会受到显式全局变量的影响,而这些全局变量在垃圾回收器中是无法收集的。需要特别注意用于临时存储和处理大量信息的全局变量。如果您必须使用全局变量来存储数据,那么确保在使用完后将其分配为空值,或者重新分配。

    • 被遗忘的定时器和回调
      以经常使用的setInterval为例,大多数有观察者机制,使用接收callback的库,都会确保,当他们的实例变得不可到达的时候,也会将对callback的引用变得无法访问。不过,下面的代码并不少见:

      var serverData = loadData();
      setInterval(function() {
        var renderer = document.getElementById('renderer');
        if(renderer) {
          renderer.innerHTML = JSON.stringify(serverData);
        }
      }, 5000); //This will be executed every ~5 seconds.
      

      上面的代码片段展示了引用不再需要的节点或数据的定时器的后果。
      renderer对象可能会在某个时候被替换或删除,这会使得定时器里面回调函数变得多余。但此时处理回调依然无法回收,因此此时定时器还在活动,定时器需要先停止,才能解除对回调函数的引用。回调函数无法回收,自然它所引用的依赖也无法回收,其中加载数据的serverData也会被保留,而它可能会占用相当大的内存空间。

      因此当使用观察者模式的时候,一旦用完,需要确保做一个显式的调用来删除它们。

      幸运的是,大多数现代浏览器都会为你做这件事:即使你忘记删除监听器,当观察者对象变得无法访问时,也会自动收集观察者处理程序。过去的一些浏览器(旧的IE6)无法做到这一点。

      尽管如此,一旦对象变得过时就移除观察者,这是符合最佳实践的。看下面的例子:

      var element = document.getElementById('launch-button');
      var counter = 0;
      
      function onClick(event) {
       counter++;
       element.innerHtml = 'text ' + counter;
      }
      
      element.addEventListener('click', onClick);
      
      // Do stuff
      
      element.removeEventListener('click', onClick);
      element.parentNode.removeChild(element);
      
      // 现在,当element超出生命周期时,
      // element和onClick将会被回收,即使在无法处理好循环引用的旧浏览器中.
      

      在移除节点之前,你不再需要调用removeEventListener,因为现代浏览器有可以检测这些循环并处理好它们的垃圾回收器。

      如果您利用jQuery API(其他库和框架也一样),您也可以在节点失效之前删除侦听器。 即使应用程序在较旧的浏览器版本下运行,库也会确保没有内存泄漏。

    • 滥用闭包
      闭包是JS中一个非常有用的概念。关于闭包的作用域链以及语法环境的详细介绍,可以参考文章最后链接。闭包的作用域链(scope chain)机制一方面给JS提供了灵活的语法应用。但也在某一个方面扩大了函数对象的引用范围,导致了GC回收机制的失效,从而出现内存泄漏。以下面的代码为例:

      var theThing = null;
      var replaceThing = function () {
      var originalThing = theThing;
      var unused = function () {
        if (originalThing) // a reference to 'originalThing'
          console.log("hi");
        };
        theThing = {
          longStr: new Array(1000000).join('*'),
          someMethod: function () {
            console.log("message");
          }
        };
      };
      setInterval(replaceThing, 1000);
      

      上面的代码中有两个闭包函数someMethodsunused。每一次运行replaceThing函数,都会在内存中新开辟一大段空间,创建一个新的对象。theThing则指向该新建的对象。该对象的内部组成如下:

      // theThing object
      theThing = {
        longStr:   // point to a long Array in heap
        someMethod:  // point to a closures function which contain its lexical scope
      }
      

      同时根据闭包作用域链的原理,unused创建的闭包的语法环境中包含了originalThing对象。originalThing作为一个指针则指向了上一次调用replaceThing函数创建的theThing对象。从常识上理解,someMethod的语法环境不应该包含originalThing对象。这样当replaceThing函数执行完毕,unused被释放后,上一次创建的theThing对象(也就是originalThing对象)应该由于没有被引用,而被回收。但是当你在浏览器中执行上面的代码的时候,就会发现,内存在不断的累加。如果将运行一段时间后的theThing对象打印出现来的话,就会发现:

      image.png

      显而易见的,在someMethod的语法环境中存在了originalThing对象。之所以这样,是因为once a scope is created for closures that are in the same parent scope, that scope is shared.也就是说,当在一个函数内部存在多个闭包的时候,闭包的语法环境或者说scope是共享的。
      由于unused的语法环境存在对originalThing的引用,从而使得someMethod的语法环境中也存在了对originalThing对象的引用。这样就产生了一个引用链,每次调用replaceThing函数创建的新theThing对象中,包含的someMethod闭包总会引用上一次创建theThing对象。这样就会使得上一次调用生成的theThing对象都因为被新的对象引用而无法释放,从而导致内存泄漏的出现。
      为什么会将同一个scope内的闭包的语法环境共享呢?从变量可变的角度考虑下,就会理解了。假如同一个scope内的存在多个闭包函数,如果每个闭包都维护自己的语法环境。那么当一个变量,被多个闭包引用的时候。如果在某一个闭包中修改了该变量的值,那么就需要通知所有其他引用该变量的闭包对自己语法环境中的该变量进行同步更新。这样就需要存在复杂的观察监控机制,反而不如将所有闭包引用的变量放在一起,供大家一起使用来的简单。

    • DOM引用
      我们有某些时候,为了方便对DOM的操作,会新建一个变量或对象,将其指向DOM元素,比如下面的代码:

      var elements = {
        button: document.getElementById('button'),
        image: document.getElementById('image')
      };
      

      这样就建立了一条对#button#image的引用。这条引用是除了DOM树对它们两的引用外,新增的引用。这样即使后面,我们在DOM树中将这两个DOM元素删除,比如下面的代码:

      functiondocument.body.removeChild(document.getElementById('image'));
      

      可由于上面elements新增的引用,导致无法回收 #image元素。从而导致了内存泄漏。这在存在表格类的大量DOM引用的情况下的时候,需要额外注意。

    2:调用栈
    调用栈的概念以及栈内部存放的堆栈帧,可以看下JS中的执行上下文(Execution Context)和栈(stack)这篇文章。上图中的Call Stack可以视为文章中的stack,堆栈帧可以视为文章中的执行上下文。
    当JS代码出现错误的时候,抛出的错误,可以看出当前错误发生的时候,调用栈的内部情况,从而可以帮助我们定位发生错误的原因。比如下面的代码

    function foo() {
      throw new Error('SessionStack will help you resolve crashes :)');
    }
    function bar() {
      foo();
    }
    function start() {
      bar();
    }
    start();
    

    当运行的时候,会在控制台打印出下面的错误提示:


    控制台错误输出

    从图片中可以看到,当前的堆栈帧内部存在4个堆栈帧,从上到下依次为
    foo->bar->start->foo.js。其中的foo,bar,start都是函数执行上下文,foo.js为全局执行上下文。

解释器外部的Event Loop & CallbackQueue

在JS的主线程里面,所有代码的执行都是单线程,同步执行的,通过调用栈的机制实现。但我们在实际使用总还需要一些异步操作,比如定时执行,比如ajax,以及新增的Promise等。这些异步操作的执行,则是依赖Event Loop & CallbackQueue来实现的。
首先关于EventLoop 和 CallbackQueue有以下几点需要知道:

  • 在JS中,Event Loop有且只有一个,它控制调度了所有代码的执行。CallbackQueue则有不止一个,不同的任务队列对应不同类型的异步任务。
  • 异步任务分为macro-task(宏任务)micro-task(微任务),在最新标准中,它们被分别称为task与jobs。
  • 当满足执行条件时,macro-task和micro-task会被放入各自的队列中等待放入主线程执行,我们把这两个队列称为Task Queue(也叫Macrotask Queue)和Microtask Queue。
  • macro-task包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering,以及XHR
  • micro-task大概包括: process.nextTick(node.js), Promise, Object.observe(已废弃), MutationObserver(html5新特性)

在了解上面的内容后,我们继续看下JS究竟是按照什么样的顺序执行代码的。首先给出结论,JS按照下面的次序进行代码的执行。

  1. 执行完主执行线程中的任务。
  2. 取出Microtask Queue中任务执行直到清空
  3. 取出Macrotask Queue中一个任务执行。
  4. 取出Microtask Queue中任务执行直到清空
  5. 重复3和4。。。。

如果以伪代码进行表示的话,就是

while (true) {
  宏任务队列.shift()  // 推出一个宏任务,进行调用
  微任务队列全部任务()  // 执行当前所有的微任务
}

下面我们先看个例子:

console.log('start')
setTimeout( function () {
  console.log('setTimeout0')
  new Promise(function(resolve) {
    console.log('promise3')
    resolve()
  }).then(function() {
    console.log('promise4');
  })
}, 0)

setTimeout( function () {
  console.log('setTimeout1')
}, 0)

new Promise(function(resolve) {
  console.log('promise0')
  resolve()
}).then(function() {
  console.log('promise1');
}).catch(function() {
  console.log('promise2');
});

console.log('end')

运行的结果可以点击demo进行查看。下面逐条分析:

// 执行主线程 
// console.log('start')
=> start  // 首先先执行主线程里面的任务。因此第一条被打印的是start
// setTimeout( function () {
//   console.log('setTimeout0')
//   new Promise(function(resolve) {
//     console.log('promise3')
//     resolve()
//   }).then(function() {
//     console.log('promise4');
//   })
// }, 0)
== 碰到了setTimeout,将回调函数cb1交给timer,timer会在定时时间到的时候,将cb1添加到宏任务队列
// setTimeout( function () {
//   console.log('setTimeout1')
// }, 0)
== 碰到了setTimeout,将回调函数cb2交给timer,timer会在定时时间到的时候,将cb2添加到宏任务队列,因为两个定时器的定时时间相同,但cb2在cb1之后调用,因此,添加到宏定义队列的时候,先添加cb1,再添加cb2上面的
// new Promise(function(resolve) {
//   console.log('promise0')
=> promise0 // 执行新建Promise代码,打印promise0,这是第二条打印
//   resolve()
== 当Promise碰到resolve函数时,将其对应的then代码中的回调cb3,放到微任务队列中
// }).then(function() {
//   console.log('promise1');
// }).catch(function() {
//   console.log('promise2');
// });

// console.log('end')
=> end // 新建Promise执行完后,继续执行,打印end
== 至此,我们完成了第1阶段的主执行线程中的任务,然后执行第2阶段,查询微任务队列,发现了cb3函数。将其取出,然后进行执行。
// console.log('promise1');   // cb3的代码,执行后打印
=> promise1
== 所有的微任务已经执行完毕,进入第3阶段然后执行宏任务队列中的第一个任务,也就是cb1
//   console.log('setTimeout0')
=> setTimeout0 
//   new Promise(function(resolve) {
//     console.log('promise3')
=> promise3
//     resolve()  
== 当Promise碰到resolve函数时,将其对应的then代码中的回调cb4,放到微任务队列中
//   }).then(function() {
//     console.log('promise4');
//   })
== 执行完cb1后,执行第4阶段,重新查询微任务队列,发现了cb4,将其执行
//     console.log('promise4');  // cb4
=> promise4
== 微任务队列执行完毕后,重复第3阶段,查询宏任务队列,会发现,还有一个cb2没有执行,取出后执行
// console.log('setTimeout1') // cb2的代码,执行后打印
=> setTimeout1

######## 最后总结后的输出如下 ########
start 
promise0
end
promise1
setTimeout0 
promise3
promise4
setTimeout1

OK,我们已经理解了当一段代码中,存在同步代码,以及各种各样的异步代码的时候的Event Loop是如何调度代码执行的。那么如果存在多段代码的情况呢?比如说存在多个js文件,此时的代码是如何调度执行的呢?

在继续下面的内容前,需要声明一点:

在浏览器加载页面过程中,可以认为初始执行线程中没有代码,每一个script标签中的代码都是宏任务中的script(整体代码),是一个独立的task。即会执行完前面的script中创建的microtask再执行后面的script标签中的代码。

为了加深理解,我们以实际浏览器加载一个网页为例:

  1. 首先浏览器解析HTML文件,获取到其中包括的JS代码。这些代码由一个个的script标签包含的代码块组成(无论代码是在HTML文件中,还是在单独的JS文件中)。
  2. 首先所有的代码块都作为script(整体代码),成为一个个宏任务的task,按照顺序放到了宏任务队列中去。
  3. 开始从宏任务队列中的取出一个代码块,放入到主线程中,进行执行
  4. 主线程执行代码块过程中,当碰到异步操作的时候,根据异步操作的类型,放置到不同的任务队列中去。
  5. 当主线程执行代码完毕后,执行当前微任务队列中的所有任务。
  6. 微任务执行完毕后,继续从宏任务队列中取下一个任务
  7. 重复 3,4,5,6。。。

下面,拿一个实际例子进行分析,为了方便,我们不演示存在多个JS文件的情况,只是使用了多个script标签,分割成多个代码块。这样的情况和存在JS文件是一样的,可以自己实际操作体验下。可以点击查看demo(JSBin会将所有的script打包成一个进行处理,codepen则不会)的具体运行结果。其代码如下


  
  
  

下面具体分析流程:

1:首先,浏览器解析html代码,发现存在三个script代码块,将其划分为三个宏任务,依次添加到宏任务队列中去
2:执行主线程,此时为空。搜索微任务队列,发现也为空,则取宏任务队列的第一个任务。
3:第一个任务为第一个代码块,将其放入到主线程中进行执行
     // 首先 ,碰到了setTimeout函数,将cb1(console.log('timeout1');)放入到宏任务队列中去
     // 碰到了新建Promise代码,输出打印,然后,将then中的cb2(console.log('then1');)放入到微任务队列中去
     => promise1
     => promise2
    // 最后执行打印global1的代码
     => global1
4:执行完毕第一个代码块后,查询微任务队列,发现了cb2回调,将其取出执行
     => then1
5:执行完微任务队列后,重新查询宏任务队列,将第二个代码块取出,调度到主线程中执行
     // 首先 打印global2
     => global2
     // 碰到新建Promise代码,输出打印,然后,将then中的cb3(console.log('then2');)放入到微任务队列中去
     => promise3
     => promise4
     // 碰到了setTimeout命令,将回到cb4(console.log('timeout2');)放入到宏任务队列中去后,执行完毕
6:执行完毕第一个代码块后,查询微任务队列,发现了cb3回调,将其取出执行
     => then2
7:执行完微任务队列后,重新查询宏任务队列,将第三个代码块取出,调度到主线程中执行
     => global3
8:执行完第三个代码块后,查询微任务队列发现为空,则继续查询宏任务队列,取出执行第一个代码块时放入的cb1函数,执行
     => timeout1
9:执行完后,查询微任务队列,发现依然为空,则继续查询宏任务队列,发现执行第二个代码时放入的cb4函数,执行
     => timeout2
###### 最终输入 ######
promise1
promise2
global1
then1
global2
promise3
promise4
then2
global3
timeout1
timeout2

OK,如果确定已经搞明白后,可以尝试,将上面的代码块重新组织,比如讲第一个script和第二个script中的代码放到同一个script中,分析下输出会发生什么变化。然后执行下,验证下自己的结果是否正确。

在了解了各类异步操作的调用规律后,我们再看看实际使用中的会面临的几个问题,

  • UI渲染属于宏任务流程,它可以按照屏幕刷新频率需要刷新的时候,进行调用,添加到宏任务队列中去,或者用户修改UI,进行触发添加到宏任务队列中去。
  • 在代码中,不要创建很多的微任务。因为需要执行完所有的微任务后,才能执行下一个宏任务,微任务过多,会导致“卡死”宏任务,当宏任务为UI渲染的时候,就会导致页面无法刷新。
  • 当我们需要在下一次更新前异步执行某些操作的时候,比如vue的$nextTick方法。nextTick 方法使用了 microtask 和 (macro) task。低于 2.4 的版本中到处都用 microtask,但有些场景下因为 microtask 优先级太高执行的太早了,然而用 (macro) task 也有一些小问题。所以,默认还是使用 microtask,但提供一种方式在必要的情况下可以使用 (macro) task。

四:总结

在上面的描述中,我们将JS在它的运行环境是如何运行的进行了详细介绍。包括主线程的运行规则,其中涉及到执行上下文,闭包,scope-chain,语法环境等,以及主线程运行中内存的使用,如何避免出现内存泄漏。另外还详细归纳了浏览器如何通过Event Loop调度各类代码进入到主线程执行的规律,其中涉及到了宏任务队列和微任务队列,以及由此引发的实际使用中需要注意的事项。后面,会寻找其中的具体各项,进行展开,加强自己的记忆。

参考链接

https://blog.csdn.net/mogoweb/article/category/2654805
https://zhuanlan.zhihu.com/p/26229293
https://zhuanlan.zhihu.com/p/26238030
https://juejin.im/post/5b3847aee51d4558bd51a6dd
https://juejin.im/post/5aa5dcabf265da239c7afe1e
http://imweb.io/topic/5a27610da192c3b460fce29f
http://zhouweicsu.github.io/blog/2018/05/05/javascript-event-loop/
http://davidshariff.com/blog/javascript-scope-chain-and-closures/

你可能感兴趣的:(JavaScript是如何工作的)