深挖js的执行上下文:为什么函数可以访问全局的变量?

653 阅读6分钟

在之前关于作用域的文章 [搞定一个js知识点——全局、函数和块级作用域-掘金]中,我们浅谈了一次js的执行过程、var定义变量存在声明提升和重复声明,以及函数作用域可以访问到全局作用域等相关问题,有些知识与本文有关联,有不了解的朋友可以先看看它。

今天呢,我就将带大家一起详细剖析js的执行机制到底是怎样的?为什么函数可以访问全局的变量?执行上下文又是什么呢?

184a-0a4a469a3e1bf466b41820ef519746f3.jpg

js的执行上下文

执行上下文的概念非常抽象,简而言之就是:这一份代码在用户设备中所占据的内存空间,就是代码是执行上下文对象。我也觉得还是抽象啊哈哈哈,其实我们可以忽略一下概念,直接记成在js代码运行过程中会创建执行上下文对象,执行上下文对象内部有各自的变量环境和词法环境,变量环境就是用来存放定义的变量、函数以及各类参数等。

执行上下文对象一共有三种:全局执行上下文,函数执行上下文和Eval函数执行上下文,Eval函数执行上下文在开发中比较少见,所以本文我们只谈另外两个常见的执行上下文对象。

  1. 全局执行上下文:除了函数体内的代码,其他代码都是运行在全局执行上下文的,即js运行一开始先创建一个全局执行上下文,有且只有一个全局执行上下文。
  2. 函数执行上下文:在函数被调用过程就会创建一个函数执行上下文,可以有无数个。

image.png

如图左边是一段简单的代码,右边是创建的全局执行上下文和add函数被调用时创建的函数执行上下文。

那函数的执行上下文对象创建在哪里呢?我们看一段代码的执行结果:

image.png

如图的代码,test函数内部也调用自己,这其实是递归。我们发现结果是告诉我们call stack溢出了,这就是我们常说的爆栈。

其实js中的call stack就是一个调用栈:V8引擎用来管理函数之间的调用关系的一种数据结构。

代码在运行过程中会往调用栈里面创建执行上下文对象,但是调用栈是有一定内存限制的,如果不断的往里面添加不移除,就会出现爆栈现象。如图,就是test函数不断在被调用,不断地创建test函数执行上下文对象,导致最后爆栈。如图:

image.png

所以在正常的可运行的代码中,在函数调用结束后,对应的函数执行上下文对象会被销毁,直到全局的代码执行结束后全局执行上下文对象也销毁,整个栈为空

js代码的编译过程

在js中,代码是从上往下执行的,而编译总是发生在执行的前一刻。所以整个js代码的执行流程分为三步:

  1. 读取代码
  2. 编译
  3. 执行
1. 声明提升

我们看一段代码:

showName() // 输出:函数showName被执行
console.log(myName); // 变量声明提升,输出:undefined

var myName = '美美'
// 函数声明整体提升
function showName(){
    console.log('函数showName被执行')
}

之前我们聊过var创建的变量存在声明提升——js在编译过程只找有效标识符声明变量,在执行过程进行赋值,所以第二行的输出结果会是undefined。那第一行代码中的函数定义在后面,此刻为什么可以调用呢?

其实是因为函数声明在编译过程会整体提升。上述代码我可以分为两步写成下面这种样子。

// js代码在运行过程会创建执行上下文
// 提升后
var myName = undefined
function showName(){
    console.log('函数showName被执行')
}

//执行
showName() 
console.log(myName); 
myName = '美美'
2. 形参和变量声明

我们再看一份代码:

var a = 1
function fn(a){
    var a = 2
    function a(){}
    var b = a
    console.log(a);
}
fn(3);

我们会思考a的结果会是什么?是 2 还是 3,还是一个空函数?你试试看~答案是2。

这就是js编译过程中重要的一个点:编译首先找形参和变量声明(全局中没有形参),然后统一形参和实参的值,再去找函数声明进行编译

所以上面代码的编译和执行过程描述如下:

  1. 全局执行上下文创建,编译开始,a = undefined,fn = func
  2. 全局代码执行,a = 1,fn(3)函数调用
  3. fn(3)函数调用触发函数创建执行上下文
  4. 函数上下文创建,编译开始,优先找形参和变量标识符: a = undefined
  5. var重复声明a,覆盖原来的:a = undefined;b = undefined
  6. 编译的过程会统一形参和实参的值:a = 3
  7. 找到函数声明,重复声明a,覆盖原来的:a = func
  8. 函数编译完成,函数内代码执行:a = 2, b = 2,打印a = 2
  9. 函数执行结束,函数执行上下文销毁
  10. 全局代码执行结束,全局执行上下文销毁

为了更好理解,我画了全过程,如图:

image.png

image.png

所以我们从上面这个例子可以总结出js编译的四个过程

  1. 创建上下文对象
  2. 找形参和变量声明,将形参和声明的变量名作为key,值为undefine
  3. 统一形参和实参的值(全局没有该步骤)
  4. 找函数声明,将函数名作为key,值为函数体

一个小细节:function d(){ } 这样的才是函数声明;var b = function(){ } 不是函数声明,是执行函数表达式赋值给b的操作。

函数可以访问全局的变量原理

在理解了什么是执行上下文,js的编译过程后,我们怎么向面试官解释:为什么函数可以访问全局的变量?

其实很简单,是调用栈中的执行上下文从上往下访问

我们举个例子:

var global = 100
function fn(){
    console.log(global);
}
fn()

我们分析这份代码后,就可以在回答中加上调用栈中执行上下文对象的创建与js的编译过程相结合:

  1. 首先,这份代码在调用栈中先创建全局执行上下文对象,编译过程得到变量global和函数体fn;
  2. 然后,在执行过程中,先给变量global赋值为100,然后调用函数fn,会触发在全局执行上下文上面创建函数执行上下文,编译过程没有变量声明;
  3. 最后函数在执行打印过程时,自身没有访问到变量global,就往函数执行上下文下面的全局执行上下文开始找,所以打印结果为100。

好了,今天的分享就到这里,喜欢的话点个赞喔~

OIP-C.jpg

我们下期再见