<script>
function fn1() {
console.log('fn1')
}
fn2() // ReferenceError: fn2 is not defined
</script>
<script>
function fn2() {
console.log('fn2')
}
fn1() // "fn1"
</script>
上述这个例子,也可以证明 <script> 是按块编译执行的。
2. 执行阶段
编译阶段完成之后,接着就到代码执行阶段。这个阶段主要涉及到执行上下文的内容。下午再详说...
三、JavaScript 作用域
1. 作用域是什么?
在编写 JavaScript 程序时,例如一个简单的声明语句:
var a = 1
我们可能会问这些问题:
JS 引擎如何声明一个标识符名称为 a 的变量?
变量 a 将会被存储在哪里?
假设对变量 a 进行赋值操作,JS 引擎又是怎样根据标识符 a 找到对应变量的?
疑问先保留着,再看个例子:
var a = 1
function foo() {
var a = 'local'
consol.log(a)
}
foo()
如果函数 foo 内部有一个同名变量 a,那么调用 foo() 时,语句 console.log(a) 中的标识符 a 是指哪一个?
本文主要是深入 JavaScript 执行过程,覆盖了执行上下文、变量对象、作用域链、This、闭包等内容。
但文中可能参杂了很多其他内容,看起来没有那么地清晰。内容主要整合了:《JavaScript 高级程序设计(第四版)》、冴羽大佬的 JavaScript 专题系列、Veda 专题、以及本人此前写过的一些文章。
其实我建议你们以下专题会更好:
一、JavaScript
就个人而言,自从喜欢上写文章之后,我经常会在一些概念性上的东西纠结,本着认真负责任的态度,理应如此。举个例子,JavaScript 是什么类型的语言?
说实话,虽然作为一个 JSer,我至今也没弄明白 JavaScript 是什么语言。我不知道你们会不会有这些纠结点,反正我会,我想要一个官方且权威的解释,并将它作为我此后的观点。假设将来某一天有人问我:JavaScript 是什么?我可以只字不差地跟他说道。
JavaScript 与 ECMAScript 的历史关系
我们知道,JavaScript 语言的创造者是 Brendan Eich,也被称为 JavaScript 之父。1995 年任职于 Netscape 公司的 Brendan Eich 接到一项任务:负责开发一个在浏览器上运行的编程语言。没错,那就是“后来”的 JavaScript 语言。
其实,起初 JavaScript 语言的名称是 “LiveScript”。由于当时 Java 语言如日中天,为了蹭热度而更名为 JavaScript。原本 JavaScript 这个项目就是 Netscape 公司和 Sun 公司合作开发,用这个名字也无可厚非。
在 1996 年 Netscape 公司决定将 JavaScript 提交给行业标准组织 ECMA,希望这种语言能够成为国际标准。次年 6 月发布第一版,即 ECMA-262 号标准。其中 ECMA-262 是一个编号,对应的标准名称为 ECMAScript,换句话说 ECMA-262 和 ECMAScript 是同一个东西的两种不同表达方式。就好比如身份证号码和姓名的关系,只是 EMCA 组织的标准名称不会重名罢了。
请注意,ECMAScript 是一种规范和标准,而不是一门编程语言。只是我们习惯将符合 ECMAScript 标准的语言统称为 “JavaScript” 罢了。严谨地的说法应该是:JavaScript 是 ECMAScript 的一种实现。
扯了那么多,那 JavaScript 究竟是什么类型的语言?
由于 JavaScript 编程语言并没有严格意义上的官网,因此并没有一个明确的说法。相对来说,由 Mozilla 基金会维护的 MDN Web Docs 相对权威一些。
因此,我将会理解为:JavaScript 是一门编译型的语言。当你对 JavaScript 有基础的认知之后,自然就想着深入地了解这门语言,于是第一站来到了 JavaScript 引擎。
JavaScript 是怎样解析代码的呢?
由于蹩脚的英语水平,没办法直接去看类似 V8 引擎等官网,有点吃力。于是全网搜索各类文章,于是又出现了很多名词/术语,比如:解析器、编译器、预编译、词法分析等等等...
由于各类文章参差不齐,而且从英文翻译成中文,可能语义上也会有偏差。加之每个人对 JavaScript 相关术语的理解或认知水平又不一样,有时候真的很难去考究谁对谁错,唯有更相信一些大佬的文章。从这个心路历程来看,学好英文真的很重要。
举个例子,我看到很多文章描述 JS 引擎去执行代码的过程,有的说是解析器、有的又称为编译器。当初看到这些真的好烦,究竟谁对谁错呢?谁更严谨呢?由于我又是强迫症,又特想搞清楚。
于是我不停地找答案......终于找到了这篇文章:我们应该如何去了解 JavaScript 引擎的工作原理,并截取文中一段话:
因此,我也会开始认同:不需要过分去强调 JavaScript 解析引擎到底是什么,了解它究竟做了什么事情就可以了。由于前面把 JavaScript 更倾向于定义为编译型语言,因此,下文将涉及的相关内容称为“编译”、“编译器”、“编译过程”等。当然用“解析”也不能说它错。
二、JavaScript 编译与执行
举个例子,再简单不过了。
通常情况下,我们习惯将
var a = 1;
看作一个声明,而实际上 JS 引擎并不这么认为。它将var a
和a = 1
当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。在 JavaScript 中,编译阶段和执行阶段包括以下过程:
1. 编译阶段
简单地说,编译阶段就是将源代码转换成可执行代码。大致包括以下过程:
词法分析:将源代码(字符串形式)拆分成有意义的代码块,这些代码块被称为词法单元(token)。例如
var a = 1;
被分解成:var
、a
、=
、1
、;
。语法分析:将词法单元流(数组形式)转换成一个表示程序结构的树,称为“抽象语法树”(Abstract Syntax Tree,AST)。上述示例经过转换之后的抽象语法树如下:
代码生成:将 AST 转换为可执行代码(一组机器指令)的过程。简单来说,就是创建一个叫作
a
的变量(包括分配内存),并将值1
存储在a
中。首先,在词法分析阶段将源代码
var a = 1;
转换为tokens
如下:接着语法分析阶段,将
tokens
转换为 AST(Esprima Parser),结构如下:假设在语法分析阶段,若 tokens 无法构成合法的语句时,将会抛出语法错误(SyntaxError),例如:
然后,如果前面的过程都没有问题,将会代码生成阶段,来生成可执行代码。
上述三个过程是传统编译语言在执行源代码之前经历的三个步骤,统称为“编译”。而 JS 引擎要复杂得多,例如在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化。对此,就本文讨论范围有个大致的认知就行。如有兴趣另行搜索 V8 性能优化的策略。
说那么多,大家更关心的可能是这句话:包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
另外,JavaScript 是按块(
<script>
)编译的,其中语法分析是对当前块进行通篇的语法检查,若有误则抛出语法错误(SyntaxError),因此也不会执行当前块的任何代码了。接着再对下一个<script>
进行编译执行。最终的打印结果是
"script2"
。但编译器对第一个<script>
块语法分析阶段就抛出错误:SyntaxError: Unexpected token 'else'
。接着执行下一个块,于是打印出"script2"
。上述这个例子,也可以证明
<script>
是按块编译执行的。2. 执行阶段
编译阶段完成之后,接着就到代码执行阶段。这个阶段主要涉及到执行上下文的内容。下午再详说...
三、JavaScript 作用域
1. 作用域是什么?
在编写 JavaScript 程序时,例如一个简单的声明语句:
我们可能会问这些问题:
a
的变量?a
将会被存储在哪里?a
进行赋值操作,JS 引擎又是怎样根据标识符a
找到对应变量的?疑问先保留着,再看个例子:
如果函数
foo
内部有一个同名变量a
,那么调用foo()
时,语句console.log(a)
中的标识符a
是指哪一个?上述示例非常简单,但往往在写项目的时候,我们会定义很多变量和函数,以及嵌套使用等场景。那么这一连串的复杂问题,JS 引擎是怎么去处理它们的呢?它肯定需要约定好一套规则,开发者在编写代码的时候,唯有老老实实地按照这套规则去编写程序,才会得到预期结果。
这套规则被称为“作用域”,它约定了如何存储变量以及查找变量。然后各家 JS 引擎将会按照这套规则去实现,所以同一份代码在各浏览器下都可以得到相同的效果。
2. 了解作用域
我们将“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。
作用域的概念,不是 JavaScript 语言特有的,每一门编程语言都存在的,只是规则不一样而已。作用域有两种工作模型:一是“词法作用域”,二是“动态作用域”。前者也称为“静态作用域”,被大多数编程语言(包括 JavaScript)所采用。词法作用域是在编写代码或者说定义时确定的,而动态作用域是在运行时确定的。
词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
this
也是关注函数如何调用的,所以说它像动态作用域。举个例子:
因此,无论函数
foo
在哪里被调用,都会打印出1
。执行foo()
时,首先在函数内部查找是否存在(局部)变量a
,如无,再往上一层作用域查找,结果找到了且值为1
,因此会打印出1
。假设一层一层往上查找,直至全局作用域也找不到变量时,就会抛出ReferenceType
错误。四、执行上下文栈
我们书写的(源)代码会被编译器进行处理,并生成“可执行代码”,接着去执行它们。整个 JavaScript 程序的编译及执行过程,都由 JS 引擎来协调处理。
前面提到,函数的作用域是由其声明的位置决定的。那么我们一个项目中编写的函数会非常多,那么 JS 引擎如何管理它们呢?
我们知道,栈的特点是先进后出、后进先出,而且栈只能在栈顶进行插入(push)、删除(pop)操作。
在 JavaScript 中,代码执行及执行上下文栈的变化过程,大致如下:
举个例子 🌰:
然后代码执行过程,执行上下文栈变化如下:
相信以上过程大家理解起来都应该很轻松。让大家困惑的可能是这些 ECStack 栈有什么用?不急,一步一步来...
三、执行上下文
前面提到,代码执行的时候会在执行上下文栈中插入执行上下文,那么这个执行上下文具体做了什么工作呢?
执行上下文,分为全局上下文和函数上下文两类。
我们可以将每个执行上下文,简单地理解为一个 JavaScript 对象,该对象有一系列的属性(称为上下文状态),主要有三个属性:
1. 变量对象
在变量对象上,存储了当前上下文中声明的变量或函数。全局上下文与函数上下文的变量对象,稍有不同。
全局上下文
全局上下文的变量对象,就是宿主环境的顶层对象 globalThis。比如,浏览器中为
window
对象,Node.js 中是global
对象。需要注意的是,一是全局上下文的变量对象不含
arguments
属性。二是通过var
、function
关键字声明的变量或函数,都会成为globalThis
对象的属性,而let
或const
则不会。由于
let
或const
全局下的特性,我对“全局的变量对象等于顶层对象”这句话有所保留。但我们只要知道,无论通过var
、let
还是const
声明的变量,都包含在其变量对象中即可。函数上下文
在函数上下文中,我们使用活动对象(Activation Object,AO)来表示变量对象。其实变量对象是无法通过代码来访问的,当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,也只有活动对象上的各种属性才能被访问。可以理解为 AO 就是 VO 的一个别名。
活动对象是在进入函数上下文的时候才会被创建的,它通过函数的
arguments
属性进行初始化,arguments
属性值是Arguments
对象。执行过程
执行上下文分成两个阶段进行处理:
当进入执行上下文时,这时还没执行代码。内部会创建一个活动对象,它包括:
举个例子:
进入
foo()
的执行上下文后,这时 AO 如下:“进入执行上下文”的过程完成之后,接着开始按顺序执行代码,并执行对应的操作。当函数
foo()
执行完之后,这时 AO 如下:请注意,在非严格模式下,
arguments
会追踪变量的变化,而严格模式下则不会。例如:变量对象小结
Arguments
对象。2. 作用域链
未完待续...