全面理解JavaScript引擎背后的作用域机制

590 阅读17分钟

本文正在参加「金石计划」

JavaScript作用域是很重要的,因为它控制了你的代码里哪些变量和函数可以被访问。要搞懂这个东西,你需要知道JavaScript引擎是怎么工作的,以及编译器和解释器的作用。还得掌握词法分析、语法分析和代码生成等编译器的关键步骤。

一、从javascript引擎说起

想必大家第一时间想的是谷歌的v8引擎。实际上,JavaScript引擎不一定是V8引擎,那么还有什么?还有Mozilla的SpiderMonkey引擎、Microsoft的Chakra引擎、苹果的JavaScriptCore引擎等等。这些引擎就像不同品牌的汽车一样,各有千秋,都可以用来执行JavaScript代码。所以,不要小看这些引擎,它们也能为你的代码带来无限可能!

JavaScript引擎用于执行JavaScript代码的程序。它会解释和编译JavaScript代码,并将其转换为可执行代码,以便在浏览器或Node.js环境中运行。JavaScript引擎通常包括以下几个组件:

  • 解释器:用于解释和执行JavaScript代码。
  • 编译器:用于将JavaScript代码转换为可执行代码。
  • 内存管理器:用于管理JavaScript代码的内存使用情况。
  • 垃圾回收器:用于自动回收不再使用的内存。

JavaScript引擎的性能和效率对于Web应用程序的性能和用户体验至关重要。因此,现代的JavaScript引擎通常会使用各种技术,如即时编译(JIT),来提高代码的执行效率和性能。

二、引擎组件——编译器

对于javascript而言,大部分情况下编译发生在代码执行前的几微秒的时间内。

  • 编译器将代码转换为可执行代码。
  • 编译器的任务是将JavaScript代码转换为可执行代码,以便在宿主环境中运行。
  • 编译器会将代码进行语法分析、词法分析、代码优化等处理,并生成最终的可执行代码。
  • 编译器的作用是将代码转换为可执行代码,以便计算机能够理解和执行代码。
  • 在JavaScript中,编译器的工作是通过JavaScript引擎来完成的。
  • JavaScript引擎会在代码执行之前进行编译和优化,以提高代码的执行效率和性能。

image.png

1. 词法分析

词法分析是编译器中的一个重要步骤,它的作用是将源代码分解为词法单元,也就是 tokens,这些 tokens 是编译器中的基本单位。词法分析器会根据一定的规则,从源代码中提取出词法单元,并将它们组成一个个 token,然后将这些 token 传递给语法分析器进行下一步处理。

在 JavaScript 中,词法分析器会将源代码分解为单词、运算符、标点符号等词法单元。它会忽略空格和注释,并将代码转换为一个个 token。在 JavaScript 中,词法分析器是由 JavaScript 引擎内置的,它会在代码执行之前对代码进行词法分析。

在底层实现上,词法分析器通常是通过正则表达式和有限状态机来实现的。根据一定的规则,从源代码中匹配出词法单元,并将它们转换为相应的 token。

例如,在下面的代码中,词法分析器会将代码分解为多个词法单元,并将它们转换为相应的 token:

let a = 1;

其中,词法单元包括了 let、a、=、1 和 ;。词法分析器会将它们转换为下面的 token:

[
  { type: 'keyword', value: 'let' },
  { type: 'identifier', value: 'a' },
  { type: 'operator', value: '=' },
  { type: 'number', value: '1' },
  { type: 'punctuation', value: ';' }
]

每个 token 都包含了一个类型和一个值。类型用于表示该 token 的类型,值则用于表示该 token 的具体值。

2. 语法分析

语法分析也是编译器中的一个重要步骤,它的作用是将词法分析器生成的 token 转换为语法树(AST)。语法树是一种用于表示程序结构的树形结构,它由节点和边组成,每个节点表示一个语法单元,例如变量、函数、运算符等等。语法树的根节点表示程序的入口,而叶子节点表示程序的终止。通过对语法树的遍历,编译器可以将代码转换为可执行代码,并执行它。

在 JavaScript 中,语法分析器会将 token 转换为语法树,并检查语法是否合法。例如,在下面的代码中,语法分析器会将 token 转换为语法树,并检查语法是否合法:

let a = 1;
if (a === 1) {
  console.log('Hello, world!');
} else {
  console.log('Goodbye, world!');
}

语法分析器会将代码转换为下面的语法树:

Program
└── VariableDeclaration
    ├── Keyword(let)
    ├── Identifier(a)
    └── NumericLiteral(1)
└── IfStatement
    ├── BinaryExpression(===)
    │   ├── Identifier(a)
    │   └── NumericLiteral(1)
    ├── BlockStatement
    │   └── ExpressionStatement
    │       └── CallExpression
    │           ├── Identifier(console)
    │           └── StringLiteral(Hello, world!)
    └── BlockStatement
        └── ExpressionStatement
            └── CallExpression
                ├── Identifier(console)
                └── StringLiteral(Goodbye, world!)

语法分析器是 JavaScript 引擎内置的,会在代码执行之前对代码进行语法分析。语法分析器会将 token 转换为语法树,并检查语法是否合法。如果代码存在语法错误,语法分析器就会抛出一个 SyntaxError异常。

3. 代码生成

代码生成是编译器中的最后一个步骤,它的作用是将语法树转换为可执行代码。代码生成器通过遍历语法树,将每个节点转换为相应的计算机指令。

代码生成器的实现方式可以是静态的,也可以是动态的。静态代码生成器会在编译时将语法树转换为可执行代码,而动态代码生成器则会在代码执行时才将语法树转换为可执行代码。

在 JavaScript 中,代码生成器会将语法树转换为字节码或机器码。字节码是一种类似于汇编代码的中间代码,它可以在不同的平台上运行,并且可以通过解释器或即时编译器来执行。机器码则是一种特定于平台的二进制代码,它可以直接在计算机上执行。在 JavaScript 中,代码生成器通常会将语法树转换为字节码,并使用即时编译器将字节码转换为机器码。

三、引擎组件——解释器

在 JavaScript 中,解释器由 JavaScript 引擎内置。它会逐行读取 JavaScript 代码,并将其转换为可执行代码。解释器会在代码执行之前进行语法分析和语义分析,并执行一些代码优化操作,以提高代码的执行效率和性能。

与编译器不同,解释器通常不会将源代码转换为目标代码。相反,它会将源代码逐行解释为可执行代码,并将其发送到计算机的处理器上执行。这种方式可以使解释器更加灵活,因为它可以逐行执行代码,并在执行过程中进行动态优化。不过,由于解释器需要逐行解释代码,因此它的执行速度通常比编译器慢。

1. JS引擎调用解释器的时机

在 JavaScript 中,当代码中包含 eval() 函数、with 语句、setTimeout()setInterval() 函数等动态执行代码的语句时,JavaScript 引擎会调用解释器解释执行 JavaScript 代码。此外,当代码需要在运行时进行动态优化时,JavaScript 引擎也会调用解释器解释执行 JavaScript 代码。

当代码中包含动态类型转换、闭包、原型继承、动态作用域等特性时,JavaScript 引擎就需要调用解释器进行解释执行 JavaScript 代码。

这是因为这些特性都需要在运行时动态计算,而不是在编译时就能确定。例如,闭包需要在运行时捕获外部变量的值,原型继承需要在运行时查找原型链上的属性,动态作用域需要在运行时确定当前的作用域链。

2. 和编译器的区别

解释器和编译器是两种常见的程序转换方式,它们的主要区别在于代码执行的时机。

编译器是将源代码一次性转换为目标代码的程序。编译器会在源代码转换为目标代码之前执行语法分析,语义分析和代码优化等步骤,以提高程序的效率。由于编译器在运行之前将代码转换为目标代码,因此它只需要执行一次,然后可以重复执行目标代码。

解释器则是在运行时逐行解析和执行源代码的程序。解释器通常将源代码转换为中间代码(如字节码),然后逐行解释执行中间代码。解释器会在运行时执行语法分析和代码优化等步骤,以提高程序的效率。由于解释器在运行时逐行解释执行代码,因此它需要在每次运行时执行代码。

四、谈完引擎,再谈作用域

JavaScript 中的作用域是指变量和函数可以被访问的范围。JavaScript 采用的是词法作用域,也就是说,变量和函数的作用域是在代码编写时就确定的,而不是在代码运行时确定的。

JavaScript 中的作用域可以分为全局作用域局部作用域。全局作用域是指变量和函数在整个代码中都可以被访问,而局部作用域是指变量和函数只能在特定的代码块中被访问。

变量的作用域是由变量声明的位置决定的。如果变量在函数内部声明,则它的作用域就是该函数的局部作用域;如果变量在函数外部声明,则它的作用域就是全局作用域。

JavaScript 中的作用域链是指变量和函数在嵌套的函数和代码块中的作用域。当 JavaScript 引擎在查找变量或函数时,会先在当前作用域中查找,如果没有找到,就会继续向上查找,直到找到为止。

注意—划重点:

JavaScript 中没有动态作用域,它采用的是词法作用域。在词法作用域中,变量和函数的作用域在代码编写时就已经确定了,而不是在代码运行时确定。动态作用域是指变量和函数的作用域在运行时才能确定,这种作用域在 JavaScript 中是不存在的。

1. eval()改变词法作用域

在 JavaScript 中,eval() 函数可以将字符串解析为 JavaScript 代码并执行。由于 eval() 函数执行的是字符串中的代码,因此它可能会改变词法作用域。

具体来说,当 eval() 函数在函数内部执行时,它会将字符串中的代码解析为函数的局部作用域中的代码,从而改变函数的词法作用域。这可能导致代码的执行结果与预期不符。

在 JavaScript 中,应该避免使用eval() 函数,特别是在代码中包含用户输入的字符串时。这是因为 eval() 函数的执行可能会导致代码注入攻击等安全问题。

相反,可以使用 JSON.parse() 函数或其他安全的解析器来解析 JSON 格式的字符串,或者使用 Function() 函数来动态创建函数,从而实现类似于 eval() 函数的功能,但更加安全和可控。

eval() 函数改变词法作用域代码示例:

function foo() {
  var x = 1;
  eval('var x = 2;');
  console.log(x); // 输出 2
}

foo();

使用 eval() 函数会对代码的可读性和可维护性造成一定影响,并且还可能引发安全问题,因此应该尽量避免使用它。

2. with改变词法作用域

在 JavaScript 中,with 语句可以将一个对象作为上下文,然后在该对象的作用域中执行代码。这个特性虽然看起来很方便,但是它也可能导致词法作用域的改变,从而引发一些问题。

with 语句会将一个对象添加到作用域链的顶部,从而将该对象的属性添加到当前作用域中。这样一来,如果该对象中存在与当前作用域中的变量名称相同的属性,那么该属性就会覆盖当前作用域中的变量。这可能会导致代码的执行结果与预期不符。

with 语句改变词法作用域的代码示例:

var x = 1;
var obj = { x: 2 };

with (obj) {
  console.log(x); // 输出 2
  x = 3;
}

console.log(x); // 输出 3

使用 with 语句可能会对代码的可读性和可维护性造成一定影响,并且可能会引发一些意想不到的问题,因此应该尽量避免使用它。

3. 函数作用域和词法作用域的关系

函数作用域指的是变量在函数内部的可访问范围。在 JavaScript 中,每个函数都有自己的作用域,也就是说,在函数内部声明的变量只能在该函数内部访问,外部作用域无法访问这些变量。

词法作用域指的是变量在代码编写时就确定的可访问范围。也就是说,在 JavaScript 中,变量的作用域是在代码编写时就确定的,而不是在代码执行时才确定的。这意味着我们可以在代码的某个地方声明一个变量,然后在代码的其他地方访问这个变量。

函数作用域和词法作用域之间的关联在于,函数作用域是词法作用域的一种体现。具体来说,变量的作用域是由函数在定义时所处的作用域决定的。也就是说,在函数内部声明的变量的作用域是函数作用域,但这个函数作用域也是由词法作用域决定的。

举个例子,如果我们在全局作用域中定义一个函数,然后在函数内部声明一个变量,那么这个变量的作用域就是函数作用域,也就是说,它只能在函数内部访问。但是,这个函数作用域的范围是由词法作用域决定的,也就是说,在函数定义时,它所在的词法作用域就已经确定了。

// 全局作用域
function foo() {
  // 函数作用域
  var x = 1;
  console.log(x); // 输出 1
}

foo();
console.log(x); // 报错:x is not defined

4. 块级作用域和词法作用域的关系

块级作用域是词法作用域的一种扩展。在ES6之前,JavaScript中没有块级作用域,因此在代码块中声明的变量会污染其外部作用域。为了解决这个问题,ES6引入了letconst关键字,它们可以用于在代码块中创建块级作用域。块级作用域中声明的变量只在该块级作用域内部可见,不会污染其外部作用域。

在 JavaScript 中,try...catch 块并不是块级作用域。try 块中声明的变量在整个函数或全局作用域中都是可见的,因此 try 块中声明的变量可能会污染其外部作用域。

例如,在下面的代码中,变量 x 在 try 块中声明,在 catch 块中也可以访问它,而且在整个函数或全局作用域中也可以访问它。因此,变量 x 可能会与其它作用域中的变量发生冲突。

let x = 1;
try {
  let x = 2;
  throw new Error('something went wrong');
} catch (e) {
  console.log(x); // 输出: 2
}
console.log(x); // 输出: 1

如果将 let 替换为 var,则在 catch 块中访问变量 x 时会输出 1,因为变量 x 的作用域是整个函数或全局作用域。

var x = 1;
try {
  var x = 2;
  throw new Error('something went wrong');
} catch (e) {
  console.log(x); // 输出: 1
}
console.log(x); // 输出: 2

在JavaScript中,只有使用letconst关键字声明的变量才会创建块级作用域。

五、变量提升

变量提升的实现原理是在JavaScript引擎在执行代码之前,会先对变量和函数进行声明,并将它们添加到当前作用域的顶部。因此,在声明语句之前就可以使用变量或函数,这个过程被称为"提升"。

在底层实现上,JavaScript引擎会在词法环境中创建变量和函数的声明,并将它们存储在当前作用域的顶部。这个过程是在代码执行之前完成的,因此在声明语句之前就可以使用变量或函数。

变量提升的实现原理是在JavaScript引擎在执行代码之前,会先对变量和函数进行声明,并将它们添加到当前作用域的顶部。

变量提升的代码示例:

console.log(x); // 输出 undefined
var x = 1;

需要注意的是,虽然变量和函数会被提升到当前作用域的顶部,但它们的赋值操作不会被提升。因此,在声明语句之前对变量进行赋值操作是无效的。

console.log(x); // 输出 undefined
var x = 1;
console.log(x); // 输出 1

函数表达式不能进行变量提升是因为它们并不是在词法环境中进行声明的。变量提升是在代码执行之前进行的,JavaScript 引擎会在词法环境中创建变量和函数的声明,并将它们存储在当前作用域的顶部。但是,函数表达式并不是在词法环境中进行声明的,而是在代码执行过程中进行赋值的。因此,函数表达式不能进行变量提升。

函数表达式变量提升的代码示例:

foo(); // 报错:foo is not a function
var foo = function() {
  console.log('Hello world!');
}

由于变量提升的原因,JavaScript 引擎会先对变量 foo 进行声明,因此第一行代码会报错。如果我们改为使用函数声明,则不会报错:

foo(); // 输出:Hello world!
function foo() {
  console.log('Hello world!');
}

这是因为函数声明会被提升到当前作用域的顶部,因此在调用函数之前就已经被声明和定义了。

六、闻声色变的this

this 关键字是一个非常重要的概念,它在函数中使用频率非常高。不同的情况下,this 的值会有所不同,理解 this 的含义和使用方式非常必要。
简单来说,this 表示当前函数执行时的上下文对象。具体来说,它指向调用该函数的对象。在 JavaScript 中,this 的值是在函数被调用时确定的。因此,同一个函数在不同的上下文环境中可能会有不同的值。
我在之前有写过一篇文章【聊聊javascript中令人头大的this - 掘金 (juejin.cn)】,有更多的对JavaScript 中的 this 关键字进行阐述,各位掘友可以前往这篇文章进行更深层次的阅读。

七、执行上下文

在 JavaScript 中,每当函数被调用时,就会创建一个新的执行上下文。执行上下文是一个对象,它包含了该函数在执行期间的所有信息,包括函数的参数、局部变量、当前的 this 值以及函数执行过程中的其它信息。
我在之前有写过一篇文章【必知必会的JavaScript执行上下文 - 掘金 (juejin.cn)】,有更多的对JavaScript 中的执行上下文进行阐述,各位掘友可以前往这篇文章进行更深层次的阅读。

八、写在最后

作用域是 JavaScript 中非常重要的概念。理解作用域的概念可以帮助我们更好地编写代码,提高代码的可读性、可维护性和安全性。具体来说:

  • 可读性:更好地理解代码中各个变量的作用范围和生命周期,从而提高代码的可读性。
  • 可维护性:更好地管理和维护代码中各个变量的状态和值,从而提高代码的可维护性。
  • 安全性:更好地控制代码中变量的访问权限,从而提高代码的安全性。

作用域的概念对于每位前端开发人员来说是非常重要的。只有深入理解作用域的概念,才能编写出高质量、可读性强、可维护性高、安全性好的 JavaScript 代码。