js执行原理

136 阅读10分钟

前言

本章介绍详解JavaScript 程序内部的执行机制,包括内存空间、执行栈、上下文、变量对象、作用域、作用域链等JavaScript中关键点

内存空间

因为JavaScript具有自动垃圾回收机制,所以对于前端开发来说,内存空间并不是一个经常被提及的概念,很容易被大家忽视。

在很长一段时间里认为内存空间的概念在JS的学习中并不是那么重要。可是后我当我回过头来重新整理JS基础时,发现由于对它们的模糊认知,导致了很多东西我都理解得并不明白。比如最基本的引用数据类型和引用传递到底是怎么回事儿?比如浅复制与深复制有什么不同?还有闭包,原型等等。

1、认识内存管理

不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。 不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:

  • 分配申请你需要的内存(申请);
  • 使用分配的内存(存放一些东西,比如对象等);
  • 不需要使用时,对其进行释放;

不同的编程语言对于第一步和第三步会有不同的实现:

  • 手动管理内存:比如C、C++,包括早期的OC,都是需要手动来管理内存的申请和释放的(malloc和free函 数);
  • 自动管理内存:比如Java、JavaScript、Python、Swift、Dart等,它们有自动帮助我们管理内存;

2、基本类型和引用类型的值

ECMAScript 变量可能包含两种不同数据类型的值:基本类型值和引用类型值。基本类型值指的是简单的数据段,而引用类型值指那些可能由多个值构成的对象。

基本类型:Undefined、Null、Boolean、Number、String和Symbol。

引用类型:Object

在把一个值赋给变量时,JavaScript引擎必须确定这个值是基本类型还是引用类型。

  • 基本数据类型是按值访问的,因为可以操作保存在变量中的实际值。
  • 引用类型的值是保存在内存中的对象,JavaScript不允许直接访问内存中的位置,也就说不能直接操作对象所在的内存空间。变量中存储的是内存地址,在操作对象时,实际上是在操作对象的引用而不是实际的对象。为此,引用类型的值是按引用访问的。

3、栈和堆

JavaScript会在定义变量时为我们分配内存,但是内存分配方式是一样的吗?

  • JS对于基本数据类型内存的分配会在执行时, 直接在栈空间进行分配;
  • JS对于复杂数据类型内存的分配会在堆内存 中开辟一块空间,并且将这块空间的指针返回变量引用;

image.png

4、JS的垃圾回收

因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。

在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如free函数:

  • 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
  • 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露

所以大部分现代的编程语言都是有自己的垃圾回收机制:

  • 垃圾回收的英文是Garbage Collection,简称GC;
  • 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
  • 而我们的语言运行环境,比如Java的运行环境JVM,JavaScript的运行环境js引擎都会有内存 垃圾回收器
  • 垃圾回收器我们也会简称为GC,所以在很多地方你看到GC其实指的是垃圾回收器;

但是这里又出现了另外一个很关键的问题:GC怎么知道哪些对象是不再使用的呢?

  • 这里就要用到GC的算法了

5、常见的GC算法

引用计数

当一个对象有一个引用指向它时,那么这个对象的引用就+1,当一个对象的引用为0时,这个对象就可以被销 毁掉。 这个算法有一个很大的弊端就是会产生循环引用;

image.png

标记清除

这个算法是设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对 于哪些没有引用到的对象,就认为是不可用的对象;这个算法可以很好的解决循环引用的问题;

image.png

JS引擎比较广泛的采用的就是标记清除算法,当然类似于V8引擎为了进行更好的优化,它在算法的实现细节上也会结合 一些其他的算法

JavaScript的执行过程

假如我们有下面一段代码,它在JavaScript中是如何被执行的呢?

var name = 'why'
function foo() {
    var name = 'foo'
    console.log(name)
}

var num1 = 20
var num2 = 30
var result = num1 + num2

console.log(result)
foo()

首先我们要知道js代码要想运行得有一个运行环境,我们有两个选择 ①放到浏览器中运行 ②node环境运行 其本质都是通过v8引擎来运行(或者使用其他js引擎来允许)

js代码在执行之前会经历一系列步骤(预解析、解析、生成ast、标记热点代码..)具体细节查看 浏览器原理和v8执行过程,后才会是执行阶段

1、初始化全局对象 (Global Object(GO))

  • 全局代码被解析阶段,v8引擎会为我们创建一个全局对象 Global Object(GO)
    • 全局对象里面会包含Date、Array、String、Number、setTimeout、setInterval等等
    • 该对象 所有的作用域(scope)都可以访问
    • 其中还有一个window属性指向自己(node环境除外)
    • 解析阶段 ,会将全局定义的变量、函数(var、function,let、const除外)等加入到GlobalObject中,但是并不会赋值;这个过程也称之为变量的作用域提升(变量名提升)
    • 这里我们要说明一下,函数非常的特殊,js引擎在解析阶段遇到函数的声明的话,不但会将其加入到GlobalObject,而且会开辟一块内存空间用来存储这个函数(其空间中包含[[scope]]: parent vo 和函数执行体)
// 伪代码
globalObject = {
    String: '类',
    Date: '类',
    setTimeount: '函数',
    window: globalObject,
    name: undefined,
    num1: undefined,
    num2: undefined,
    result: undefined,
    foo: 内存地址
    ...
}

2、执行上下文栈 Execution Context Stack,简称ECS(调用栈)

  • 为了执行代码,v8引擎内部会有一个执行上线文栈(Execution Context Stack )也称调用栈,它是用来执行代码。
  • 那么现在它要执行谁呢?首先执行的是全局的代码块:
    • 为了全局代码可以被执行,这时会创建一个 全局执行上下文 (Global Execution Context, 简称GEC),全局代码被执行时才会创建
    • 全局执行上线文中维护着两部分 VO (Varibale Object: 变量对象, 一个与执行上下文相关的特殊对象,存储了在上下文中定义的变量和函数声明) 和 代码执行(对变量赋值、执行其他的函数)
    • 全局执行上下文中VO指向的是GO
    • 接下来 全局执行上线文(GEC)会被放入到调用栈(ECS)中执行,简称进栈执行

3、 全局执行上下文被放入到调用栈中

image.png

4、全局执行上下文开始执行代码

image.png

var num1 = 20 之前打印 console.log(num1) 会显示 undefined 是因为 变量的作用域提升

5、遇到函数调用如何执行?

  • 先解析函数体内部代码转换成AST(这个过程中会创建一个 Activation Object(AO),活动对象:AO包含形参、arguments、声明的变量)
  • 接下来创建 函数执行上下文(Functional Execution Context, 简称FEC)
    • FEC中包含三部分内容:
      • ① VO(函数中VO指向的是AO)
      • ② 作用域链(由VO(在函数中就是AO对象)和父级VO组成,查找时会一层层查找;)
      • ③ this绑定的值

image.png

FEC被放入到ECS中

image.png

FEC开始执行代码

image.png

执行完毕出栈销毁

作用域

作用域就是一套规则,用于确定在何处以及如何查找变量的规则, 也就是说作用域规定了如何查找变量,确定了当前执行代码对变量的访问权限。

JavaScript采用词法作用域(lexical scoping),也就是静态作用域函数的作用域在函数定义的时候就决定了

作用域链

当查找变量的时候,会先从`当前上下文`的变量对象中查找,如果没有找到,就会`从父级执行上下文`中查找,一直找到`全局对象`。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

变量环境和记录

其实我们上面的讲解都是基于早期ECMA(ES5以前)的版本规范:

Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function code, parameters are added as properties of the variable object.

译文:每一个执行上下文会被关联到一个变量对象(variable object,VO),在源代码中的变量和函数声明会被作为属性添加到VO中。对函数来说,参数也会被添加到VO中。

在最新的ECMA的版本规范中,对于一些词汇进行了修改:

image.png 通过上面的变化我们可以知道,在最新的ECMA标准中,我们前面的变量对象VO已经有另外一个称呼了变量环境 VE

JS中函数是一等公民

  • 在JavaScript中,函数是非常重要的,并且是一等公民:
    • 那么就意味着函数的使用是非常灵活的;
    • 函数可以作为另外一个函数的参数,也可以作为另外一个函数的返回值来使用;
  • 自己编写高阶函数
  • 使用内置的高阶函数

JS中闭包的定义

  • 一个普通的函数function,如果它可以访问外层作用于的自由变量,那么这个函数就是一个闭包;
    • 从广义的角度来说:JavaScript中的函数都是闭包;(这句话是有争议的,在全局中定义一个函数函数体中什么都没有,那么它是不是闭包呢?从可以访问来讲(它是可以访问全局作用域变量),它是闭包;从有访问来讲(函数体为空),它就不是一个闭包。)
    • 从狭义的角度来说:JavaScript中一个函数,如果访问了外层作用域的变量,那么它是一个闭包;

闭包的内存泄露

那么我们为什么经常会说闭包是有内存泄露的呢?

  • 因为内部函数访问了外层作用域的变量,使其变量对象不能被销毁,频繁使用闭包会造成内存泄漏问题。 那么,怎么解决这个问题呢?
  • 手动销毁 fn=null
  • 在GC的下一次检测中,它们就会被销毁掉; 我们来研究一个问题:AO对象不会被销毁时,是否里面的所有属性都不会被释放?
  • 下面这段代码中name属于闭包的父作用域里面的变量;
  • 我们知道形成闭包之后count一定不会被销毁掉,那么name是否会被销毁掉呢?
  • 这里我打上了断点,我们可以在浏览器上看看结果;

image.png

我们得到结论js引擎会做一些优化,把没有使用到的变量销毁掉