所有JavaScript代码都需要在某种环境中托管和运行。在大多数情况下,这种环境是一个web浏览器。
对于在web浏览器中执行的任何一段JavaScript代码,很多过程都发生在后台。在本文中,我们将看一看JavaScript代码在web浏览器中运行的幕后发生了什么。
在开始之前,你需要先熟悉一些概念,因为我们将在本文中经常用到它们:
- 解析器:
语法解析器
是一个逐行读取代码的程序。它理解代码如何符合编程语言定义的语法,以及代码应该做什么。 - JavaScript引擎: JavaScript引擎是一个简单的计算机程序,它接收JavaScript源代码并将其编译成CPU可以理解的二进制指令(机器码)。JavaScript引擎通常是由web浏览器供应商开发的,每个主要浏览器都有一个。例如谷歌chrome的V8引擎,Firefox的SpiderMonkey和Internet Explorer的Chakra。
- 函数声明:函数被赋予一个名称:
function doSomething() { //"doSomething" 是这个函数的名字 //statements; }
- **函数表达式:**匿名函数,即没有函数名的函数,如:
function () {statements}
。它们通常在语句中使用,比如将函数赋值给变量:
let someValue = function(){ //statements }
现在,我们已经知道了这些概念,让我们开始吧。
JavaScript代码是如何执行的
浏览器并不能理解我们在应用程序中编写的高级JavaScript代码。它需要转换成一种浏览器和计算机都能理解的格式——机器代码。
当通过HTML读取时,如果浏览器遇到要通过<script>
标签或包含类似onClick
的JS代码的属性运行的JS代码,它会将其发送给它的JS引擎。
然后,浏览器的JS引擎创建一个特殊的环境来处理这段JS代码的转换和执行。这个环境称为执行上下文。
执行上下文包含当前正在运行的代码,以及帮助其执行的所有内容。
在执行上下文运行时,解析器解析存储在内存中的特定的代码、变量和函数,生成可执行的字节码,并执行代码。
JavaScript中有两种执行上下文:
- 全局执行上下文(GEC)
- 函数执行上下文(FEC)
接下来,让我们详细地看看这两种上下文。
全局执行上下文(GEC)
当JavaScript引擎接收到一个脚本文件时,它首先创建一个默认的执行上下文,即全局执行上下文(GEC)。
GEC
是基本的/默认的执行上下文,所有不在函数内的JavaScript代码都在这里执行。
注:对于每个
JavaScript
文件,只能有一个GEC。
函数执行上下文(FEC)
无论何时调用函数,JavaScript引擎都会在GEC中创建一种不同类型的执行上下文,称为函数执行上下文(function Execution Context, FEC),以计算和执行该函数中的代码。
由于每个函数调用都有自己的FEC,所以在脚本的运行时可以有多个FEC。
执行上下文是如何被创建的?
前面我们知道了什么是执行上下文,现在让我们看看执行上下文是如何被创建的。
执行上下文(GEC或FEC)的创建分为两个阶段:
- 创建阶段
- 执行阶段
创建阶段
在创建阶段,执行上下文首先与执行上下文对象(ECO)相关联。执行上下文对象存储了许多重要数据,执行上下文中的代码在运行时使用这些数据。
创建阶段又可以分为3个阶段,在这3个阶段中定义和设置执行上下文对象的属性。这些阶段是:
- 创建变量对象(VO)
- 创建作用域链
- 为变量赋值
创建阶段:创建变量对象(VO)
变量对象(VO)是一个在执行上下文中创建的类对象容器。它存储了在执行上下文中定义的变量和函数声明。
在GEC中,对于每个使用var
关键字声明的变量,都会在VO中添加一个指向该变量的属性,并将其设置为“undefined”
。
此外,对于每个函数声明,都会在VO
中添加一个属性,指向该函数,并将该属性存储在内存中。这意味着所有函数声明都将被存储在VO中,甚至在代码开始运行之前就可以访问。
不同的是,FEC并没有建立VO
。相反,它生成一个类似数组的对象,称为“参数”对象,其中包括提供给函数的所有参数。
在代码执行之前,在内存中存储变量和函数声明的过程称为hoving
。由于这是一个重要的概念,我们将在进入下一阶段之前简要地讨论它。
Hoving - 提升
函数和变量声明在JavaScript中会被提升,这意味着它们被存储在当前执行上下文的VO的内存中,甚至在代码开始执行之前就可以在执行上下文中使用。
函数提升
在大多数情况下,当构建一个应用程序时,开发人员可以选择在脚本的顶部定义函数,然后在代码中调用它们,就像这样:
但是,由于提升的原因,这段代码仍然可以工作。我们可以先调用函数,然后在脚本中定义它们。
在上面的代码中,getAge
函数声明将存储在VO
的内存中,这样就可以在定义它之前使用它。
变量提升
用var
关键字初始化的变量作为属性存储在当前执行上下文的VO内存中,初始值为undefined
。这意味着,与函数不同,试图在变量定义之前访问它的值将导致未定义。
提升的规则
提升只适用于函数声明,而不适用于表达式。下面是代码执行将中断的函数表达式示例。
getAge(1990); var getAge = function (yearOfBirth) { console.log(new Date().getFullYear - yearOfBirth) };
代码执行中断,因为使用函数表达式,getAge
将作为变量而不是函数被提升。变量提升时,其值设置为未定义。这就是我们得到错误的原因:
另外,变量提升不适用于用let
或const
关键字初始化的变量。试图在声明之前访问一个变量,然后使用let
和const
关键字声明它,将导致ReferenceError
。
在这种情况下,它们将被提升,但不会被赋值为undefined
。js console.log(名称);let name = "Victor";
将抛出错误:
创建阶段:创建作用域链
在创建了变量对象(VO)之后,就开始创建作用域链,作为执行上下文创建阶段的下一个阶段。
JavaScript中的作用域是一种机制,它决定代码库的其他部分如何访问一段代码。作用域回答了以下问题:
- 从哪里可以访问一段代码?
- 从哪里不能访问它?
- 谁可以访问它,谁不能访问它?
每个函数执行上下文创建它的作用域:在这个环境中,它定义的变量和函数可以通过一个称为Scoping
的进程访问。
这意味着代码库中某些东西的位置,也就是一段代码所在的位置。
当一个函数在另一个函数中定义时,内部函数可以访问外部函数及其父函数中定义的代码。这种行为称为词法作用域。
但是,外部函数不能访问内部函数中的代码。
这种作用域的概念在JavaScript中引发了一种称为闭包
的关联现象,它能让变量或函数私有化。
让我们看一些例子来更好地理解:
- 右边是全局作用域。它是加载
a.js
脚本时创建的默认作用域,代码中的所有函数都可以访问它。 - 红框是
first()
函数的作用域,它定义了变量b = 'Hello!
和second()函数
。 - 绿色的是
second()
函数的作用域。有一个console.log
语句,用于打印变量a、b和c
。
现在变量a
和b
没有在second()
函数中定义,只有c
。然而,由于词法作用域,它可以访问它所在函数的作用域及其父函数的作用域。
在运行代码时,JS引擎不会在second()
函数的作用域中找到变量b
。因此,它查找父函数的作用域,从first()
函数开始。在那里,它找到变量b = 'Hello'
。它返回到second()
函数,然后解出变量b
。
对于a
变量也是同样的过程。JS引擎查找所有父函数的作用域,一直到GEC
的作用域,在second()
函数中解析它的值。
JavaScript引擎在定义函数的执行上下文中遍历作用域,以解析其中调用的变量和函数,这种做法称为作用域链。
只有当JS引擎无法解析范围链中的变量时,它才会停止执行并抛出错误。
然而,这并不能逆转。也就是说,全局作用域永远不能访问内部函数的变量,除非它们从函数返回。
打个比方:作用域链就像
隐私玻璃
。你可以看到外面,但是外面的人看不到你。
这就是为什么上图中的红色箭头指向上方因为这是作用域链的唯一方向。
创建阶段:设置“this”关键字的值
在执行上下文的创建阶段确定作用域之后,下一个也是最后一个阶段是设置this
关键字的值。
this
关键字指的是执行上下文所属的作用域。
一旦创建了作用域链,'this'
的值就会由JS引擎初始化。
全局上下文中的 this
在GEC(在任何函数和对象之外)中,this
指向全局对象—即窗口对象window。
因此,函数声明和用var
关键字初始化的变量被赋值为全局对象。
就像这样:
var occupation = "Frontend Developer"; function addOne(x) { console.log(x + 1) }
等同于:
window.occupation = "Frontend Developer"; window.addOne = (x) => { console.log(x + 1) };
GEC中的函数和变量作为方法和属性附加到window
对象。这就是下面的代码片段将返回true
的原因。
函数中的 this
对于FEC,它不创建this对象。相反,它获得对定义它的环境的访问权。
这里是window
对象,函数在GEC中定义:
var msg = "I will rule the world!"; function printMsg() { console.log(this.msg); } printMsg(); // "I will rule the world!"
在对象中,this
关键字并不指向GEC
,而是指向对象本身。
考虑下面的代码示例:
var msg = "I will rule the world!"; const Victor = { msg: "Victor will rule the world!", printMsg() { console.log(this.msg) }, }; Victor.printMsg(); // "Victor will rule the world!"
代码将“Victor will rule The world!”
输出到控制台,而不是“I will rule The world!”
,因为在本例中,函数可以访问的this
关键字的值是定义函数的对象的值,而不是全局对象。
通过设置this
关键字的值,就定义了执行上下文对象的所有属性。在创建阶段结束之前,现在JS引擎进入执行阶段。
执行阶段
最后,在执行上下文的创建阶段之后就是执行阶段。这是实际代码开始执行的阶段。
在此之前,VO
所包含的变量值都是未定义的。如果代码在此时运行,它必然会返回错误,因为我们不能处理未定义的值。
在这个阶段,JavaScript引擎**再次读取当前执行上下文中的代码,然后用这些变量的实际值更新VO
。**然后,代码被解析器解析,被传递到可执行字节码,最后被执行。
JS 执行栈
执行堆栈,也称为调用堆栈,跟踪脚本生命周期中创建的所有执行上下文。
JavaScript是一种单线程语言,这意味着它一次只能执行一个任务。因此,当其他操作、函数和事件发生时,将为每个事件创建一个执行上下文。由于JavaScript的单线程特性,一个堆积的执行上下文堆栈被创建,称为执行堆栈。
当脚本在浏览器中加载时,全局上下文被创建为默认上下文,JS引擎在其中开始执行代码,并被放置在执行堆栈的底部。
然后JS引擎在代码中搜索函数调用。对于每个函数调用,都会为该函数创建一个新的FEC,并置于当前正在执行的执行上下文之上。
位于执行堆栈顶部的执行上下文成为活动的执行上下文,并且总是首先由JS引擎执行。
一旦活动的执行上下文中的所有代码执行完毕,JS引擎就会弹出执行堆栈中的特定函数的执行上下文,移动到它下面的下一个函数,以此类推。
为了理解执行堆栈的工作过程,考虑下面的代码示例:
var name = "Victor"; function first() { var a = "Hi!"; second(); console.log(`${a} ${name}`); } function second() { var b = "Hey!"; third(); console.log(`${b} ${name}`); } function third() { var c = "Hello!"; console.log(`${c} ${name}`); } first();
- 首先,将脚本加载到JS引擎中。
- 在此之后,JS引擎创建
GEC
并将其放在执行堆栈的底部。 name
变量定义在任何函数之外,所以它在GEC中,并存储在它的VO
中。
同样的过程发生在first()
、second()
和third()
函数中。
不要对为什么它们的功能仍然在GEC中感到困惑。记住,GEC只适用于不在任何函数内的JavaScript代码(变量和函数)。因为它们没有在任何函数中定义,所以函数声明在GEC中。现在明白了吗😃?
当JS引擎遇到first()
函数调用时,会为它创建一个新的FEC。这个新上下文被置于当前上下文的顶部,形成了所谓的执行堆栈。
在first()
函数调用的期间,它的执行上下文成为活动上下文,JavaScript代码在这里第一次执行。
在第一个函数中,变量a = 'Hi!'
存储在它的FEC中,而不是GEC中。
接下来,在第一个函数中调用第二个函数。
由于JavaScript的单线程特性,first()
函数的执行将会暂停。它必须等待second()
函数执行完成。
JS引擎再次为second()
函数设置一个新的FEC,并将其放在堆栈的顶部,使其成为活动上下文。
second()
函数成为活动上下文,变量b = 'Hey!';
在其FEC中获取存储,在second()
函数中调用third()
函数。它的FEC被创建并放在执行堆栈的顶部。
在third()
函数中,变量c = 'Hello!'
被存储在它的FEC中,并且消息Hello!Victor
输出到控制台。
因此,函数已经完成了它的所有任务。它的FEC从栈顶移除,调用third()
函数的second()
函数的FEC返回活动上下文。
回到second()
函数,消息Hey!Victor
输出到控制台。函数完成它的任务,返回,它的执行上下文从调用堆栈弹出。
当first()
函数被完全执行时,first()
函数的执行栈从堆栈中弹出。因此,控制返回到代码的GEC。
最后,当整个代码的执行完成时,JS引擎将GEC从当前堆栈中移除。
如果看到这,你有些晕,可以再回顾一下。💪
JavaScript中的全局执行上下文与函数执行上下文
既然你已经看到这里了,现在让我们用一个表格总结一下GEC和FEC之间的要点:
全局执行上下文 | 函数执行上下文 |
创建一个全局变量对象,用于存储函数和变量声明。 | 不创建全局变量对象。相反,它创建一个参数对象,存储传递给函数的所有参数。 |
创建“this”对象,将全局作用域中的所有变量和函数作为方法和属性存储。 | 不创建“this”对象,但是可以访问定义它的环境的对象。通常是window 对象。 |
不能访问定义在其中的函数上下文的代码 | 由于作用域,可以访问它定义的上下文中的代码(变量和函数)以及它的父级 |
为全局定义的变量和函数设置内存空间 | 仅为函数内定义的变量和函数设置内存空间。 |
总结
JavaScript的执行上下文是正确理解许多其他基本概念的基础。
执行上下文(GEC和FEC)和调用堆栈是在底层由JS引擎执行的进程,让我们的代码运行。
希望现在你看完本文可以更好地理解了函数/代码的执行顺序,以及JavaScript引擎如何处理它们。
作为一个开发人员,对这些概念有一个很好的理解可以帮助你:
- 充分了解这门语言的来龙去脉。
- 掌握好一门语言的底层/核心概念。
- 编写干净的、可维护的、结构良好的代码,在生产中引入更少的bug。
所有这些都将使你成为一个更好的开发人员!
希望这篇文章对你有所帮助。感谢你的阅读,祝你编程愉快!
如果你发现这篇文章很有用,请不要忘记点赞👍🏻和关注
哦!
我每天都会发这样的帖子,
所以请关注我,了解更多信息。❤️