深入理解JS执行流程

4,381 阅读16分钟

前言

想要成为一名 JavaScript 开发者,那么你必须知道 JavaScript 程序内部的执行机制。执行上下文和执行栈、词法作用域、this、内存空间、变量对象等都是JavaScript中关键点,同时也是JavaScript难点。

一、内存空间

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

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

1、堆和栈

JavaScript中并没有严格意义上区分栈内存与堆内存。因此我们可以粗浅的理解为JavaScript的所有数据都保存在堆内存中。但是在某些场景,我们仍然需要基于堆栈数据结构的思路进行处理,比如JavaScript的执行上下文。

通过下图类比以下我们的栈:

堆存取数据的方式,则与书架与书非常相似,只要知道书的名字,我们就可以很方便的取出我们想要的书,而不用像从乒乓球盒子里取乒乓一样,非得将上面的所有乒乓球拿出来才能取到中间的某一个乒乓球。

2、变量对象与基础数据类型

js的执行上下文创建之后会生成一个变量对象,我们的基本数据类型基本都存储在了这里面。

严格意义上来说,变量对象也是存放于堆内存中,但是由于变量对象的特殊职能,我们在理解时仍然需要将其于堆内存区分开来。

我们的代码执行是在一个一个的执行上下文中进行的。我们声明的一些变量都存放在了相应的变量对象里~

基础数据类型都是一些简单的数据段,JavaScript中有5中基础数据类型,分别是Undefined、Null、Boolean、Number、String。基础数据类型都是按值访问,因为我们可以直接操作保存在变量中的实际的值。

3、引用数据类型

JS的引用数据类型,比如数组Array,它们值的大小是不固定的。引用数据类型的值是保存在堆内存中的对象。JavaScript不允许直接访问堆内存中的位置,因此我们不能直接操作对象的堆内存空间。在操作对象时,实际上是在操作对象的引用而不是实际的对象。因此,引用类型的值都是按引用访问的。这里的引用,我们可以粗浅地理解为保存在变量对象中的一个地址,该地址与堆内存的实际值相关联。

例如:

var a1 = 0;   // 变量对象
var a2 = 'this is string'; // 变量对象
var a3 = null; // 变量对象
var b = { m: 20 }; // 变量b存在于变量对象中,{m: 20} 作为对象存在于堆内存中
var c = [1, 2, 3]; // 变量c存在于变量对象中,[1, 2, 3] 作为对象存在于堆内存中

a1 a2 a3 b c 变量都被放入了变量对象,b c存储的是实际引用对象的堆内存地址,访问时属于引用访问。

4、案例分析

// demo01.js
var a = 20;
var b = a;
b = 30;
// 这时a的值是多少?

// demo02.js
var m = { a: 10, b: 20 }
var n = m;
n.a = 15;

5、内存管理

虽然js有自己的自动垃圾回收机制,我们可以不用太多的管理,js会在CPU空闲时刻定期去清理内存空间。但是我们了解js的内存管理,可以让我们更好的理解js的执行流程,帮组我们写出性能更好的代码。

var a = 20;  // 在内存中给数值变量分配空间
alert(a + 100);  // 使用内存
a = null; // 使用完毕之后,释放内存空间

上面就是一个简单的内存释放案例,js的垃圾回收机制其实就是去查找那些不再被引用或者不再被使用的内存空间,然后释放掉。最常用的就是标记清除的方式,js会从根部也就是全局开始对各变量进行标记,层层标记下去,知道所有变脸都被标记,这些标记表明了变量的使用状态,垃圾回收机制会根据这些标记去高效的清除那些不再被使用的变量的地址空间,以及那些互相引用,但是不能被根访问到的变量空间(函数内部)。

在局部作用域中,当函数执行完毕,局部变量也就没有存在的必要了,因此垃圾收集器很容易做出判断并回收。但是全局变量什么时候需要自动释放内存空间则很难判断,因此在我们的开发中,需要尽量避免使用全局变量,以确保性能问题。

二、变量对象

在js中我们肯定需要知道我们的变量和函数在哪里声明的,我们怎么找到并访问?所以我们还得靠执行上下文来帮忙,我们知道代码的执行是在一个一个的执行上下文中进行的,最外层的全局上下文、函数的函数执行上下文。

执行上下文的组成为:变量对象作用域链this。 执行上下文的生命周期可以分为两个阶段:创建阶段执行阶段

  • 创建阶段: 在这个阶段,执行上下文会创建变量对象、绑定this、创建作用域链
  • 执行阶段: 这个阶段,会根据this绑定、作用域链来完成对变量对象的赋值、使用以及函数的调用和执行其他代码~

变量对象(Variable Object)是一个与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明,先来看一段代码示例:

1、全局执行上下文的变量对象

全局而言,全局对象是window,全局上下文有一个特殊之处就是他的变量对象就是window。this指向也是window.我们在全局声明的变量和函数都会存储在window中。

// 以浏览器中为例,全局对象为window
// 全局上下文创建阶段
// VO 为变量对象(Variable Object)的缩写
windowEC = {
    VO: Window,
    scopeChain: {},
    this: Window
}

2、函数执行上下文的变量对象

变量对象存储了执行上下文中的变量和函数声明,但在函数上下文中,还多了一个arguments(函数参数列表), 一个伪数组对象。并且这里的VO是通过arguments来初始化的。

1、创建arguments对象。检查当前上下文中的参数,建立该对象下的属性与属性值。

2、检查当前上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果变量对象已经存在相同名称的属性,则完全替换这个属性(函数是第一公民)。

3、检查当前上下文中的变量声明(var 声明的变量),默认为 undefined;如果变量名称跟已经声明的形式参数或函数相同,为了防止同名的函数被修改为undefined,则会直接跳过变量声明,原属性值不会被修改。

上诉是VO变量对象的创建过程~~我们平时说的变量的提升、函数的提升其实就是在说这里,比如:

console.log(foo);
foo();//可以执行,此时foo是函数
var foo=10; // foo被重新赋值为10
foo();//foo已经被赋值为一个变量,无法执行foo为函数,会报错
console.log(foo);  // 10
function foo(){
  var a;
  console.log(a);
  a=12;
  console.log(a);
}
console.log(foo); // 10
// 创建变量对象如下:
VO = {
    arguments: {
        length: 0
    },
    foo: function(),
}

在看一个例子:

alert(a);//输出:function a(){ alert('我是函数') }
function a(){ alert('我是函数') }//
var a = '我是变量';
alert(a);   //输出:'我是变量'

有个细节必须注意:当遇到函数和变量同名且都会被提升的情况,函数声明优先级比较高,因此变量声明会被函数声明所覆盖,但是可以重新赋值。

当然还需要注意的是,函数未进入执行阶段之前,变量对象中的属性都不能访问!但是进入执行阶段之后,变量对象(VO)转变为了活动对象(AO),然后开始进行执行阶段的操作。

三、执行上下文

1、什么是执行上下文

执行上下文就是我们当前代码执行所处的环境的一个抽象概念,我们的代码执行是在一个一个的执行上下文中进行的。

为了方便管理我们的执行上下文,以及如何调配处理执行上下文的执行顺序,我们引入了执行上下文栈,用于方便我们存放执行上下文和调用执行上下文。

先来一个直观的例子:

var a = 1;
function foo() {
    var b = 2;
    function bar() {

        console.log(b)

    }
    bar()
    console.log(a);
}
foo()

1.执行这段代码,首先会创建全局上下文globleEC,并推入执行上下文栈中;

2.当调用foo()时便会创建foo的上下文fooEC,并推入执行上下文栈中;

3.当调用bar()时便会创建bar的上下文barEC,并推入执行上下文栈中;

4.当bar函数执行完,barEC便会从执行上下文栈中弹出;

5.当foo函数执行完,fooEC便会从执行上下文栈中弹出;

6.在浏览器窗口关闭后,全局上下文globleEC便会从执行上下文栈中弹出;

2、执行上下文的类型

  • 全局执行上下文: 1. 创建一个全局对象,在浏览器中这个全局对象就是 window 对象。2. 将 this 指针指向这个全局对象。一个程序中只能存在一个全局执行上下文。
  • 函数执行上下文:每次调用函数时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。一个程序中可以存在任意数量的函数执行上下文。
  • eval执行上下文:运行在 eval 函数中的代码也获得了自己的执行上下文,但由于 Javascript 开发人员不常用 eval 函数,所以在这里不再讨论。

3、执行上下文的声明周期

执行上下文的生命周期包括三个阶段:创建阶段→执行阶段→回收阶段,重点是创建阶段。

创建阶段其实就是创建变量对象、创建作用域涟、绑定this指向。

4、执行上下文栈

JavaScript 引擎创建了执行上下文栈来管理执行上下文。可以把执行上下文栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。

深入理解JavaScript执行上下文和执行栈

四、作用域链与闭包

1、作用域和作用域链

在详细讲解作用域链之前,我默认你已经大概明白了JavaScript中的下面这些重要概念。这些概念将会非常有帮助。

  • 基础数据类型与引用数据类型
  • 内存空间
  • 垃圾回收机制
  • 执行上下文
  • 变量对象与活动对象

2、什么是作用域

作用域决定我们执行代码中变量和函数的可访问性。只有两种情况(可以访问和不可访问)。我们可以理解他就是一种规则。

作用域可以分为全局作用域和函数作用域,ES6之后出现了块级作用域。作用域与执行上下文是完全不同的两个概念。我知道很多人会混淆他们,但是一定要仔细区分。

function outFun2() {
    var inVariable = "内层变量2";
}
outFun2();//要先执行这个函数,否则根本不知道里面是啥
console.log(inVariable); // Uncaught ReferenceError: inVariable is not defined

上例子报错,是因为外部根本没有这个变量的访问权限。

JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。

3、什么是作用域链

作用域链是在执行上下文的组成部分。我们可能有疑惑了,前面说到作用域实在js预处理编译阶段确定的一套规则,那这里的作用域链是什么东东?

作用域是一套规则,作用域链是对规则的具体实现。

我们知道函数在调用激活时,会开始创建对应的执行上下文,在执行上下文生成的过程中,变量对象,作用域链,以及this的值会分别被确定。

作用域链:其实就是当前环境和上一个执行环境的一个层层链式关系,通过这种关系我们可以在有访问权限的情况下,保证对变量有序的访问,以至于不会出现混乱~~

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。

泡泡1是全局作用域,有标识符foo;
泡泡2是作用域foo,有标识符a,bar,b;
泡泡3是作用域bar,仅有标识符c。

案例入手:

var a = 20;
function test() {
    var b = a + 10;
    function innerTest() {
        var c = 10;
        return b + c;
    }
    return innerTest();
}
test();
innerTestEC = {
    VO: {
        argument: {
            length: 0
        },
        c: undefined
    },
    scopeChain: [VO(innerTest), VO(test), VO(global)],
    this: {}
}

我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。第一项永远都是当前上下文的变量对象。

3、闭包

严格的来说闭包是指可以访问自由变量的函数。

自由变量: 指可以在函数中访问,但并不是函数参数也不是局部变量的变量,往往来自上层作用域

按照上面的定义,可以认为js中的左右函数都是闭包。

但是我们实际中所指的闭包加了一些限制条件:

即便创建他的执行上下文已经被销毁了,但是他仍然存在。
在代码中引用了自由变量

通常我们所见到的就是函数中包含函数的情况~~闭包和我们的作用域有着很大的关系。

通常情况下,当我们的函数执行上下文执行完毕之后,就会进入待销毁状态,等待被垃圾回收机制回收,但是闭包会阻碍它的执行,不会被销毁。

var fn = null;
function foo() {
    var a = 2;
    function innnerFoo() {
        console.log(a);
    }
    fn = innnerFoo; // 将 innnerFoo的引用,赋值给全局变量中的fn
}
function bar() {
    fn(); // 此处的保留的innerFoo的引用
}
foo();
bar(); // 2

在上面的例子中,foo()执行完毕之后,按照常理,其执行环境生命周期会结束,所占内存被垃圾收集器释放。但是通过fn = innerFoo,函数innerFoo的引用被保留了下来,复制给了全局变量fn。这个行为,导致了foo的变量对象,也被保留了下来。于是,函数fn在函数bar内部执行时,依然可以访问这个被保留下来的变量对象。所以此刻仍然能够访问到变量a的值。

3、闭包的应用场景

延迟函数setTimeout

function fn() {
    console.log('this is test.')
}
var timer =  setTimeout(fn, 1000);
console.log(timer);

执行上面的代码,变量timer的值,会立即输出出来,表示setTimeout这个函数本身已经执行完毕了。但是一秒钟之后,fn才会被执行。这是为什么?

这是因为fn被另一个地方引用了,所以fn的变量对象被保存了下来。下次还能调用。

模块封装

(function () {
    var a = 10;
    var b = 20;
    function add(num1, num2) {
        var num1 = !!num1 ? num1 : a;
        var num2 = !!num2 ? num2 : b;
        return num1 + num2;
    }
    window.add = add;
})();

add(10, 20);

通过一个自我执行函数来向window提供一个函数add。这样全局上下文的变量对象就会保留下add的执行环境(也就是它的变量对象)。

五、this的绑定

this的值是在执行阶段确立的,为什么呢?因为this绑定属于执行上下文的一部分,执行上下文是在执行阶段确立的。

情况一 默认绑定

// 情况1
function foo() {
  console.log(this.a) //1
}
var a = 1
foo()

情况二 隐性绑定

// 情况2
function fn(){
  console.log(this);
}
var obj={fn:fn};
obj.fn(); //this->obj

情况三 显性绑定

// 情况3
function add(c, d){
  return this.a + this.b + c + d;
}
var o = {a:1, b:3};
add.call(o, 5, 7); // 1 + 3 + 5 + 7 = 16
add.apply(o, [10, 20]); // 1 + 3 + 10 + 20 = 34
var obj = {
    a: 10
}

function foo () {
    console.log(this.a);
}

foo = foo.bind(obj);

foo();  // 10

显性绑定,js 给我们提供的函数 call 和 apply,它们的作用都是改变函数的this指向,第一个参数都是 设置this对象。

情况四 new绑定

// 情况4
function foo(){
    this.a = 10;
    console.log(this);
}
foo();                    // window对象
console.log(window.a);    // 10   默认绑定

var obj = new foo();      // foo{ a : 10 }  创建的新对象的默认名为函数名
                          // 然后等价于 foo { a : 10 };  var obj = foo;
console.log(obj.a);       // 10    new绑定

情况五 箭头函数绑定

function a() {
    return () => {
        return () => {
        	console.log(this)
        }
    }
}
console.log(a()()())

箭头函数其实是没有 this 的,这个函数中的 this 只取决于他外面的第一个不是箭头函数的函数的 this。在这个例子中,因为调用 a 符合前面代码中的第一个情况,所以 this 是 window。并且 this 一旦绑定了上下文,就不会被任何代码改变。