JavaScript系列 - JavaScript 的执行过程和作用域

241 阅读6分钟

本文会介绍 JavaScript 的大致执行过程:词法分析、语法分析、编译时和运行时。之后介绍了作用域的基础知识以及 JavaScript 的词法作用域。

1 Js 的执行过程:

JavaScript 引擎在运行这段脚本会经历 2 个阶段,词法/语法分析 和 执行阶段。在执行阶段又划分为 编译时运行时。这几个阶段的间隔时间会非常的短,可以理解为当引擎刚刚完成了词法分析,便会立刻开始编译阶段和运行阶段。而不像传统的 C,Java 等语言,编译阶段和运行阶段可以分开执行。

  • C:源代码 .c ----[编译为]----> .s ----[汇编为]----> .obj ----------> .exe 可执行文件。
  • Java:源代码 .java ----[编译为]----> .class

image.png

JavaScript 的执行,一共有 2 个阶段:

1.1 词法/语法分析阶段

主要是为了分析代码的语法是否正确,如果不正确会抛出语法错误(syntaxError)并停止执行代码。

  1. 词法分析(Tokenizing) 。将代码拆分成具有意义的最小代码块,也称之为词法单元(token) ,比如代码 var a = 2;,会拆分成: vara=2;
  2. 语法分析(Parsing) 。将词法单元流,按照语法结构转为 抽象语法树(Abstract Syntax Tree, AST) 。转换后的抽象语法树,具有了逐级嵌套的树状结构。

1.2 执行阶段

词法/语法分析阶段后,

  1. 引擎根据抽象语法树分析代码的 词法作用域,进而形成 运行环境。一个运行环境,就是一个词法作用域。

    引擎从 全局运行环境(全局词法作用域) 开始,进入每个运行环境(词法作用域)中。先编译,后运行该运行环境中的代码。运行完毕后,进入下一个运行环境。这样一直到所有运行环境执行完毕。

  2. 创建一个 调用栈 call stack

当进入某一个运行环境中,会进入:

1 编译阶段

根据运行环境的词法作用域,创建 执行上下文(execution context) ,并放入 调用栈(call stack) 中。 栈底是 全局执行上下文(global execution context) ,栈顶则是当前的执行上下文。

创建当前执行上下文,其基本结构如下:

  • variable environment 变量环境

    • outer 外部环境
  • lexical environment 词法环境

  • this value

2 运行阶段

根据创建好的 当前执行上下文,开始逐次运行 当前运行环境 中的全部代码。

  • 当代码全部执行完毕后,弹出当前执行上下文。

如果在代码执行中,遇到一个函数调用,会暂停当前执行上下文的执行,创建这个函数调用的执行上下文,把这个执行上下文压入 调用栈 中。此时会进入这个调用函数的编译和运行阶段,直到该函数内的全部代码执行完毕,弹出该执行上下文。

注意:不同的运行环境执行都会进入到 编译和执行两个阶段。而词法/语法分析阶段不区分运行环境,对全部代码进行解析。

更多关于调用栈、执行上下文内容的知识,参见 “执行上下文” 章节。

image.png

2 作用域 scope

作用域是一个区域,规定了代码中变量和函数的可访问范围。所以,作用域决定了变量和函数的可见性和生命周期。

image.png

2.1 作用域的两种工作模型:

动态作用域

动态作用域中,对作用域的定义发生在 运行阶段

动态作用域不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。作用域链是基于调用栈的,而不是根据代码结构的作用域嵌套。

词法作用域

词法作用域中,对作用域的定义发生在 词法分析阶段。 换句话说,每个变量都有一个对应它的词法作用域:无论函数在哪里被调用,无论如何调用,它的词法作用域都 只由函数被声明时所处的位置决定

  • 词法作用域只查找一级标识符,如果存在这样一个代码:foo.bar.baz.name 。Js 引擎会通过词法作用域查找到 foo,剩余的查找工作会通过对象的属性访问去查找。如果想了解如何通过对象属性查找,参加 “对象” 章节。
  • 根据代码的层层结构,形成了作用域链。

JavaScript 同绝大多数编程语言一样,使用静态的词法作用域。

3 词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

image.png

词法作用域的划分:

全局作用域

全局作用域中的对象在代码中的任何地方都能访问。随着代码的执行完毕,生命周期结束。全局作用域在一个完整的代码脚本中,有且只有一个。

函数作用域

一个函数内部,便是一个函数作用域。函数内部定义的变量会随着函数执行结束而销毁。

闭包

闭包是一个变量的集合,也是一个函数作用域。当一个函数执行完毕后,本应被销毁,但其内部定义的变量,依然被其他作用域引用(持有)。这时,这个函数就会形成一个闭包。为了节省开销,JS 引擎会销毁这个函数自身绝大多数的内容(这些内容只要不影响这些被引用变量的调用,都会被销毁)。而这些没有被销毁的、被引用的变量和支撑内容所组成的集合,就是闭包。

块级作用域

使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句、try / catch / finally,单独的一个 {} 都被看作是一个块级作用域。

块级作用域仅限制 letconst 变量的可访问范围;不限制 var 变量和 function 函数的可访问范围。

分析打印结果:

function bar() {
    console.log(myName)
}
function foo() {
    var myName = "Ninjee"
    bar()
}
var myName = "Moxy"foo()  // Moxy

原因:

根据作用域关系,barfoo 两个函数作用域都在全局作用域中。作用域的判定是函数声明的位置。可以看到,这两个函数的声明没有嵌套的关系,所以两个函数的作用域也没有嵌套关系。

代码中,barfoo 的内部调用,在函数调用上出现了嵌套关系,误导了作用域的判断。

需要记住:作用域链和执行上下文栈完全是两套逻辑;前者在词法分析阶段被确定,后者在编译时和运行时被确定。

更多关于作用域链、闭包的相关知识,参见 “作用域链和闭包”

引用

《你不知道的 JavaScript》

winter - 重学前端

李兵 - 浏览器工作原理与实战