从JS执行过程彻底讲清楚闭包和作用域链
前言
今天和大家一起 来 弄清楚一段 JavaScript 代码,它是如何执行的呢? 进而彻底讲明白闭包和作用于链的含义。
JavaScript 是一门高级语言,需要转化成机器指令,才能在电脑的 CPU 中运行。使 JavaScript 代码转换成机器指令,是通过 JavaScript 引擎来完成的。
JavaScript 引擎在把 JavaScript 代码转换成机器指令过程中,先对 JavaScript 代码进行解析(词法分析,语法分析),生成 AST 树,然后在通过一些列操作转换成机器指令,从而在 CPU 中运行。今天带大家详细讲解一下相关概念,并通过一个具体的案例加深大家对相关概念的理解。
JavaScript 执行过程
JavaScript 是一门高级语言,JavaScript 引擎会先把 JavaScript 代码转换成机器指令,先对 JavaScript 代码进行解析(词法分析,语法分析),生成 AST 树,然后转换成机器指令,进而会才能 CPU 中运行。
如下图所示:
JS 执行过程,我们会遇到一些名词,这里在前面先做个解释
名词 | 解释 |
---|---|
ECS (Execution Context Stack) 执行上下文栈/调用栈 | 以栈的形式调用创建的执行上下文。JavaScript 引擎内部实现了一个执行上文栈,目的就是为了执行代码。只要有代码执行,一定是在执行上下文栈中执行的。 |
GEC GEC(Global Execution Context)全局执行上下文 | 在执行全局代码前创建。 代码想要执行一定经过调用栈(上个关键词),也就意味着代码是以函数的形式被调用。但是全局代码(比如:定义变量、定义函数等)并不是函数形式,我们并不能主动调用代码,而被动的需要浏览器去调用代码。起到该作用的就是全局执行上下文,先解析全局代码然后执行。 |
FEC (Functional Execution Context)函数执行上下文 | 在执行函数前创建。如果遇到函数的主动调用,就会生成一个函数执行上下文,入栈到函数调用栈中;当函数调动完成之后,就会执行出栈操作 |
VO (Variable Object)变量对象 | 早期 ECMA 规范中的变量环境,对应 Object。该对象保存了当前执行上下文中的变量和函数地址(也就是当前作用域)。 |
VE (Variable Environment 变量环境 | 最新 ECMA 规范中的变量环境,对应环境记录。 在最新 ECMA 的版本规范中:每一个执行上下文会关联到一个变量环境(Variable Environment,简称 VE),在执行代码中变量和函数的声明会作为环境记录(Environment Record)添加到变量环境中。对于函数来说,参数也会被作为环境记录添加到变量环境中。 简单来讲:1. 也就是相比于早期的版本规范,对于变量环境,已经去除了 VO 这个概念,提出了一个新的概念 VE;2. 没有规定 VE 必须为 Object,不同的 JS 引擎可以使用不同的类型,作为一条环境记录添加进去即可;3. 虽然新版本规范将变量环境改成了 VE,但是 JavaScript 的执行过程还是不变的,只是关联的变量环境不同,将 VE 看成 VO 即可; |
GO (Global Object)全局对象 | 全局对象,解析全局代码时创建,GEC 中关联的 VO 就是 GO |
AO (Activation Object)函数对象 | 函数对象,解析函数体代码时创建,FEC 中关联的 VO 就是 AO |
名词太多不容易理解,这里不用去记,下面用到的时候重新从这里查找即可。
⚠️⚠️❗️❗️ 下面的小章节是按照特定顺序讲解的,讲解了代码生成执行过程。
解析阶段(编译器伪代码)
- 创建一个全局对象 GO/window(全局作用域)
- 词法分析。词法分析就是将我们写的代码块分解成词法单元。
- 检查语法是否有错误。语法分析是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。 并检查你的代码有没有什么低级的语法错误,如果有,引擎会停止执行并抛出异常。
- 给全局对象 GO 赋值(GO/VO 中不止包括变量自身,还包含其他的上下文等)
如果遇到了函数,编译阶段是不会去解析他,仅仅是在堆内存中创建了 FO 对象(会记录他的 parent scope 和 当前代码块),在 GO 中定义的函数变量会指向此变量。
生成全局对象的伪代码是什么? (变量提升考点)
- 从上到下查找,遇到 var 声明,先去全局作用域查找是否有同名变量,如有忽略当前声明,没有则添加声明变量为 GO 对象的属性,值为 undefined,并为变量分配内存。
- 遇到 function,如有同名变量,则将值替换为 function 函数,没有则添加到 GO,并分配内存并赋值。
- ES6 中的 class 声明也存在提升,不过它和 let、const 一样,被约束和限制了,其规定,如果再声明位置之前引用,则是不合法的,会抛出一个异常。
创建全局对象有什么用?
- 所有的作用域(scope)都可以访问该全局对象;
- 对象里面会包含一些全局的方法和类,像 Math、Date、String、Array、setTimeout 等等;
- 其中有一个 window 属性是指向该全局对象自身的;
- 该对象中会收集我们上面全局定义的变量,并设置成 undefined;
- 全局对象是非常重要的,我们平时之所以能够使用这些全局方法和类,都是在这个全局对象中获取的;
什么是变量提升?
面试经常问是因为工作中经常因为他出现 BUG。
什么是变量提升:通常 JS 引擎会在正式执行之前先进行一次预编译,在这个过程中,首先将变量声明及函数声明提升至当前作用域的顶端,然后进行接下来的处理。
- 函数提升只针对具名函数,而对于赋值的匿名函数(表达式函数),并不会存在函数提升。
- 【提升优先级问题】函数提升优先级高于变量提升,且不会被同名变量声明覆盖,但是会被变量赋值后覆盖。而且存在同名函数与同名变量时,优先执行函数。
console.log(a); //f a()
console.log(a()); //1
var a=1;
function a(){
console.log(1);
}
console.log(a); //1
a=3
console.log(a()) //a not a function
🤔 思考:(一道腾讯面试题)
var a=2;
function a() {
console.log(3);
}
console.log(typeof a);
为什么会进行变量提升?
- 【比较信服的一种说法】正是由于第一批
JavaScript
虚拟机编译器上代码的设计失误,导致变量在声明之前就被赋予了undefined
的初始值,而又由于这个失误产生的影响(无论好坏)过于广泛,因此在现在的 JavaScript 编译器中仍保留了变量提升的“特性”。 - 【提升性能,这是预编译的好处,与变量提升没有没有关系】解析和预编译过程中的声明提升可以提高性能,让函数可以在执行时预先为变量分配栈空间
- 【但也带了很多弊端】声明提升还可以提高 JS 代码的容错性,使一些不规范的代码也可以正常执行
现在讲完了变量赋值过程,接下来我们了解一下,全局执行上下文和函数执行上下文。
什么是 全局执行上下文 和 函数执行上下文?
全局执行上下文和函数执行上下文,大致也分为两个阶段:编译阶段和执行阶段。
解析过程中,获得了三个重要的信息(上下文包含的重要信息)【上下文对象中包含的信息有哪些?】:
VO(Variable Object)对象
:该对象保存了当前执行上下文中的变量和函数地址(也就是当前作用域)。- 作用域链:
VO(当前作用域)
+ParentScope(父级作用域)
【在函数部分重要讲解】 this
的指向: 视情况而定。
什么是作用域?
JavaScript 中的作用域说的是变量的可访问性和可见性。也就是说整个程序中哪些部分可以访问这个变量,或者说这个变量都在哪些地方可见。
作用域两个重要作用是 安全 和 命名压力(可以在不同的作用域下面定义相同的变量名)。
Javascript 中有三种作用域:
- 全局作用域
- 函数作用域
- 块级作用域
作用域链:当在 Javascript 中使用一个变量的时候,首先 Javascript 引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。
记住两句话:
- 父级作用域在编译阶段就已经确定了。
- 查找变量就是按照作用域链查找(找到近停止)[]
也可以这么理解:作用域链是 AO 对象上的一个变量[scopeChain] 里面的变量是 当前的 VO+parentVO,当某个变量不存在时会顺着 parentVO 向上查找,直到找到为止。
什么是词法作用域?
词法作用域(也叫静态作用域)从字面意义上看是说作用域在词法化阶段(通常是编译阶段)确定而非执行阶段确定的。 JavaScript 的作用域是词法作用域。
例如:
let number = 42;
function printNumber() {
console.log(number);
}
function log() {
let number = 54;
printNumber();
}
// Prints 42
log();
上面代码可以看出无论printNumber()
在哪里调用console.log(number)
都会打印42
。
动态作用域不同,console.log(number)
这行代码打印什么取决于函数printNumber()
在哪里调用。
如果是动态作用域,上面console.log(number)
这行代码就会打印54
。
使用词法作用域,我们可以仅仅看源代码就可以确定一个变量的作用范围,但如果是动态作用域,代码执行之前我们没法确定变量的作用范围。
什么是执行上下文?
简单的来说,执行上下文是一种对 Javascript
代码执行环境的一种抽象概念,也就是说只要有 Javascript
代码运行,那么它就一定是运行在执行上下文中。
Javascript 一共有三种执行上下文:
- 全局执行上下文。
这是一个默认的或者说基础的执行上下文,所有不在函数中的代码都会在全局执行上下文中执行。它会做两件事:创建一个全局的window
对象(浏览器环境下),并将this
的值设置为该全局对象,另外一个程序中只能有一个全局上下文。 - 函数执行上下文。
每次调用函数时,都会为该函数创建一个执行上下文,每一个函数都有自己的一个执行上下文,但注意是该执行上下文是在函数被调用的时候才会被创建。函数执行上下文会有很多个,每当一个执行上下文被创建的时候,都会按照他们定义的顺序去执行相关代码(这会在后面会说到)。 Eval
函数执行上下文。
在eval
函数中执行的代码也会有自己的执行上下文,但由于eval
函数不会被经常用到,这里就不做讨论了。(译者注:eval
函数容易导致恶意攻击,并且运行代码的速度比相应的替代方法慢,因为不推荐使用)。
执行上下文栈(调用栈)?
了解了什么是全局对象后,下面就来聊聊代码具体执行的地方。JS 引擎为了执行代码,引擎内部会有一个执行上下文栈(Execution Context Stack,简称 ECS),它是用来执行代码的调用栈。
ECS 如何执行?先执行谁呢?
- 无疑是先执行我们的全局代码块;
- 在执行前全局代码会构建一个全局执行上下文(Global Execution Context,简称 GEC);
- 一开始 GEC 就会被放入到 ECS 中执行;
GEC 主要包含三个内容(和 FEC 基本一样): VO,作用域链,this 的指向。
调用栈(ECS)、全局执行上下文、函数执行上下文(FEC)三者大致的关系如下:
函数执行上下文
在执行全局代码遇到函数如何执行呢?
- 在执行的过程中遇到函数,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称 FEC),并且加入到执行上下文栈(ECS)中。
- 函数执行上下文(FEC)包含三部分内容:
- AO:在解析函数时,会创建一个 Activation Objec(AO);
- 作用域链:由函数 VO 和父级 VO 组成,查找是一层层往外层查找;
- this 指向:this 绑定的值,在函数执行时确定;
- 其实全局执行上下文(GEC)也有自己的作用域链和 this 指向,只是它对应的作用域链就是自己本身,而 this 指向为 window。
变量环境和记录(VO 和 VE)
上文中提到了很多次 VO,那么 VO 到底是什么呢?下面从 ECMA 新旧版本规范中来谈谈 VO。
在早期 ECMA 的版本规范中:每一个执行上下文会被关联到一个变量环境(Variable Object,简称 VO),在源代码中的变量和函数声明会被作为属性添加到 VO 中。对应函数来说,参数也会被添加到 VO 中。
- 也就是上面所创建的 GO 或者 AO 都会被关联到变量环境(VO)上,可以通过 VO 查找到需要的属性;
- 规定了 VO 为 Object 类型,上文所提到的 GO 和 AO 都是 Object 类型;
在最新 ECMA 的版本规范中:每一个执行上下文会关联到一个变量环境(Variable Environment,简称 VE),在执行代码中变量和函数的声明会作为环境记录(Environment Record)添加到变量环境中。对于函数来说,参数也会被作为环境记录添加到变量环境中。 - 也就是相比于早期的版本规范,对于变量环境,已经去除了 VO 这个概念,提出了一个新的概念 VE;
- 没有规定 VE 必须为 Object,不同的 JS 引擎可以使用不同的类型,作为一条环境记录添加进去即可;
- 虽然新版本规范将变量环境改成了 VE,但是 JavaScript 的执行过程还是不变的,只是关联的变量环境不同,将 VE 看成 VO 即可;
什么是闭包?
MDN 上解释:闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。
也可以简单:函数 + 函数定义时的词法环境。
具体实例来理解整个执行过程
var name = 'curry'
console.log(message)
var message = 'I am new-coder.cn'
function foo() {
var name = 'foo'
console.log(name)
}
var num1 = 1
var num2 = 2
var result = num1 + num2
foo()
如下图:图中描述了上面这段代码在执行过程中所生成的变量。
图中三个步骤的详细描述:
- 初始化全局对象。
- 这里需要注意的是函数存放的是地址,会指向函数对象,与普通变量有所不同;
- 从上往下解析 JS 代码,当解析到 foo 函数时,因为 foo 不是普通变量,并不会赋为 undefined,JS 引擎会在堆内存中开辟一块空间存放 foo 函数,在全局对象中引用其地址;
- 这个开辟的函数存储空间最主要存放了该函数的父级作用域和函数的执行体代码块;
-
构建一个全局执行上下文(GEC),代码执行前将 VO 的内存地址指向 GlobalObject(GO)。
-
将全局执行上下文(GEC)放入执行上下文栈(ECS)中。
-
从上往下开始执行全局代码,依次对 GO 对象中的全局变量进行赋值。
- 当执行
var name = 'curry'
时,就从VO
(对应的就是GO
)中找到name
属性赋值为curry;
- 接下来执行
console.log(message)
,就从VO
中找到message
,注意此时的message
还为undefined
,因为message
真正赋值在下一行代码,所以就直接打印undefined
(也就是我们经常说的变量作用域提升); - 后面就依次进行赋值,执行到
var result = num1 + num2
,也是从VO
中找到num1
和num2
两个属性的值进行相加,然后赋值给result
,result
最终就为50
; - 最后执行到
foo()
,也就是需要去执行foo
函数了,这里的操作是比较特殊的,涉及到函数执行上下文,下面来详细了解;
- 遇到函数是怎么执行的
继续来看上面的代码执行,当执行到foo()
时:
- 先找到
foo
函数的存储地址,然后解析foo
函数,生成函数的AO
; - 根据
AO
生成函数执行上下文(FEC)
,并将其放入执行上下文栈(ECS
)中; - 开始执行
foo
函数内代码,依次找到AO
中的属性并赋值,当执行console.log(name)
时,就会去foo
的VO
(对应的就是foo
函数的AO
)中找到name
属性值并打印;
- 如此下去函数执行完成后会进行出栈,直到栈为空。代码出栈,如果出栈中发现当前上下文中的一些变量仍然被引用(形成了闭包),那就会将此出栈的上下文移动到堆中。