“深入探索JavaScript:从词法分析到词法作用域的欺骗”

445 阅读15分钟

引言

JavaScript之所以复杂,部分原因在于它是一种多范式的语言,它支持命令式、函数式以及面向对象的编程风格。这种灵活性让开发者可以用多种方式来解决问题,但也意味着要理解不同风格下的代码执行和交互方式。此外,JavaScript的异步编程模型,如事件循环和回调函数,对于习惯了同步编程的开发者来说可能会造成困惑。

理解JavaScript的底层机制对开发者来说至关重要,因为这影响着代码的性能、安全性和可维护性。掌握词法作用域、闭包和原型链等概念,有助于编写出更高效和可预测的代码。了解变量提升、执行上下文和事件循环等机制,可以帮助开发者避免常见的错误和陷阱。而且,随着ECMAScript标准的不断发展,新的语言特性和改进也都建立在这些核心概念之上。因此,深入理解这些基础知识是掌握JavaScript并发挥其最大潜力的关键。

1.词法分析

编译器读取和处理代码的过程通常可以分为三个阶段:词法分析、语法分析和代码生成。

在词法分析阶段,编译器首先将输入的源代码(通常是一个字符串)分解成一个个的标记(tokens)。这个过程可以看作是对代码进行单词级别的解析。每个标记都代表了代码中的一个最小有意义的片段,比如一个变量名、一个操作符、一个数字、一个字符串等。

编译器在词法分析阶段会使用一种叫做词法分析器(lexer)的工具。词法分析器会按照预定义的规则(比如正则表达式)来识别和生成标记。这些规则定义了哪些字符序列可以构成一个有效的标记,以及如何从源代码中提取这些标记。

例如:对于JavaScript代码var x = 10;,词法分析器会将其分解成以下几个标记:varx=10;。每个标记都有一个类型,比如var的类型是关键字,x的类型是标识符,10的类型是数字,等等。

词法分析是编译过程的第一步,它为后续的语法分析阶段提供了基础。只有当源代码被成功地分解成标记后,编译器才能进一步理解代码的结构和意义。

2.解析(语法分析)

在编译器将源代码分解成tokens之后,下一步是将这些tokens转换成一个抽象语法树(AST)。这个过程被称为语法分析(parsing),它的目的是构建出一个结构化的表示,从而能够反映出源代码的语法结构。

语法分析(Parsing)

在语法分析阶段,编译器使用一组预定义的语法规则来检查tokens的顺序和结构是否符合语言的语法。这些规则通常以文法的形式存在,例如上下文无关文法(Context-Free Grammar, CFG)。编译器会尝试将tokens序列与这些文法规则进行匹配,以构建AST。

抽象语法树(AST)

AST是一种树状的数据结构,它以层次化的方式表示了代码的语法结构。每个节点代表了代码中的一个构造,比如一个语句、表达式或者声明。AST中的节点对应着源代码中的各种语法元素,但它并不包含所有的细节,比如空白符、注释等,因为这些对于代码的语义来说通常是不重要的。

例如,对于JavaScript代码var x = 10;,对应的AST可能包含一个根节点代表整个程序,它有一个子节点表示变量声明(VariableDeclaration),这个节点又有两个子节点:一个代表变量名(Identifier,其值为x),另一个代表赋值表达式(AssignmentExpression),其中包含一个数值字面量(NumericLiteral,其值为10

代码执行准备

一旦AST被成功构建,编译器就能够使用它来进行进一步的处理,比如变量绑定、作用域检查、类型检查等。在这个阶段,编译器或解释器可能会对AST进行变换,以执行优化操作,比如常量折叠、死代码移除等,以提高代码执行的效率。

在JavaScript的情况下,由于它通常被解释执行(尽管现代JavaScript引擎也会进行即时编译),AST可以直接用于执行代码。解释器会遍历AST,执行对应的指令,比如分配内存给变量、执行运算表达式、调用函数等。

3. 有效标识符

在JavaScript中,变量和函数名被称为标识符(identifiers)。标识符的命名规则遵循一定的模式,以确保代码的清晰性和功能性。以下是JavaScript中标识符的基本命名规则:

  1. 首字符:标识符的第一个字符必须是一个字母(大写或小写)、下划线(_)或者美元符号($)。
  2. 其他字符:在首字符之后,标识符可以包含字母、数字(0-9)、下划线或美元符号。
  3. Unicode 字符:JavaScript的最新版本支持Unicode,这意味着你可以使用Unicode字符集中的字符作为标识符的一部分。
  4. 关键字保留:JavaScript有一系列保留的关键字,这些关键字不能用作标识符。例如,forifbreakfunction等。
  5. 大小写敏感:JavaScript中的标识符是大小写敏感的,这意味着Variablevariable会被视为两个不同的标识符。
  6. 不允许空格:标识符中不能包含空格。
  7. 避免数字开头:虽然标识符可以包含数字,但它们不能以数字开头。

示例

有效的标识符:

  • username
  • $myVariable
  • _id
  • count123
  • π (Unicode字符)

无效的标识符:

  • 123count (数字开头)
  • for (关键字)
  • my variable (包含空格)

命名约定

除了这些基础规则之外,还有一些非正式的命名约定,这些约定在JavaScript社区中广泛被接受和使用:

  • 驼峰命名法myVariableName,其中第一个单词小写,后续单词首字母大写。
  • 帕斯卡命名法MyClassName,用于类名,每个单词首字母大写。
  • 下划线命名法my_variable_name,所有单词小写,使用下划线连接。
  • 常量CONSTANT_VALUE,全部大写,使用下划线连接。

4.作用域

相信有基础的小伙伴们都不用过多解释了。

  • 全局域
  • 函数域
  • 块级作用域
  • 内层作用域可以访问外层作用域的,反之则不行

5.栈

栈是一种后进先出(Last In, First Out, LIFO)的数据结构,它在函数调用过程中扮演着至关重要的角色。在编程语言中,特别是在JavaScript这样的语言里,栈用于管理函数调用的执行流程,这种管理方式通常被称为调用栈(Call Stack)。

栈的工作原理

想象一下,栈就像是一摞盘子。你只能在顶部添加或移除盘子,最后放上去的盘子会是第一个被拿走的。在函数调用的上下文中,"盘子"代表着函数调用。

调用栈的作用

当一个函数被调用时,一个记录(称为栈帧)被添加到调用栈的顶部。这个栈帧包含了函数的参数、局部变量以及其他函数调用的信息。如果这个函数内部调用了另一个函数,那么新的栈帧就会被创建并放到栈的顶部,表示新的函数调用。

函数执行完毕后,其对应的栈帧会从调用栈中被移除,控制流程返回到上一个栈帧代表的函数。这个过程一直持续,直到调用栈变为空,意味着所有的函数调用都已经完成。

调用栈的重要性

  1. 错误追踪:当程序抛出异常时,调用栈可以被用来追踪错误发生的位置。大多数编程环境都会提供调用栈的快照,指示出错误发生的函数以及这个函数是如何被其他函数调用的。
  2. 理解程序执行流:通过调用栈,开发者可以理解程序的执行顺序,哪些函数被调用,以及它们是如何相互调用的。这对于调试和优化代码非常有帮助。
  3. 管理函数执行上下文:每个栈帧实际上代表了一个函数执行的上下文,包括它的变量和状态。这使得函数能够递归调用自身,每次调用都有一个清晰的状态隔离。

限制

尽管调用栈是一个非常强大的工具,但它也有其限制。最显著的限制是栈的大小。大多数环境对调用栈的大小有限制,如果递归调用太深或者函数调用链太长,可能会导致“栈溢出”错误。这就是为什么在设计递归函数时需要特别注意。

6.自执行函数

自执行函数表达式(Immediately Invoked Function Expression,简称IIFE)是一个在定义后立即执行的JavaScript函数。IIFE的主要目的是创建一个独立的作用域,这样在函数内部声明的变量和函数就不会污染到全局作用域。

IIFE的基本语法

IIFE通常写成这样的形式:javascript

(function() {
  // 在这里写代码,代码在定义后立即执行
})();

或者使用箭头函数的形式:javascript

(() => {
  // 在这里写代码,代码在定义后立即执行
})();

函数被包裹在一对圆括号中,这是因为在JavaScript中,函数声明不能立即被调用。将函数包裹在圆括号中,将其转换为函数表达式,这样它就可以被立即调用了。紧随其后的另一对圆括号是函数调用的操作,它导致函数被执行。

IIFE的用途

  1. 创建局部作用域:在ES5及之前的版本中,JavaScript没有块级作用域的概念,变量只能通过函数来创建新的作用域。IIFE提供了一种方式来隐藏变量,防止它们泄露到全局作用域。
  2. 避免命名冲突:IIFE允许开发者在函数内部使用任何命名,而不用担心与全局作用域或其他脚本中的命名冲突。
  3. 模块化代码:在模块化标准(如CommonJS、AMD、ES6模块)出现之前,IIFE是一种模拟模块化的手段,可以将代码组织成各自独立的单元。
  4. 立即执行初始化代码:IIFE非常适合用于代码初始化,可以立即执行一些设置或配置,而不必等到整个脚本加载完成。

7.声明提升

直接举个例子吧:

console.log(a); //undefined  为什么不报错?
var a = 1;
// 在编译器引擎眼里会执行成: 1. var a   2. log(a)  3. a = 1
// var 声明提升

8.块级作用域

在ES6之前,JavaScript中只有全局作用域和函数作用域,没有块级作用域。这意味着由var声明的变量要么是全局的,要么是整个函数体内部的,这有时会导致一些混乱和错误。例如,即使在for循环中使用var声明变量,这个变量也是整个函数作用域内部可见的。

ES6引入了letconst关键字,它们允许开发者在块级作用域中声明变量,这些变量的作用域限制在块内(即大括号{}内部)。这种行为改变带来了几个重要的好处:

1. 减少了变量提升的问题

使用var声明变量时,变量会被提升到函数或全局作用域的顶部,这意味着变量可以在声明之前被引用,这会导致难以追踪的错误。而letconst声明的变量不会被提升,如果在声明之前使用它们,会抛出一个引用错误(ReferenceError)。

2. 块级作用域

在使用letconst的情况下,变量的作用域被限制在它们被声明的块中,这使得控制变量的可见性和生命周期变得更加容易。例如,在一个循环中,每次迭代都会创建一个新的作用域。 javascript:

for (let i = 0; i < 10; i++) {
  // i 在这里是块级作用域
}
// i 在这里不可访问,如果这里使用vari将在循环外部可访问

3. 防止变量重复声明

在同一个作用域内,letconst不允许重复声明同一个变量,这有助于避免和减少由于重复声明变量造成的错误。

4. 提倡不可变性

const关键字允许声明常量,即一旦赋值后不可改变的变量。这有助于提倡函数式编程中的不可变性原则,可以减少副作用和状态的改变,使得程序更加可靠和易于维护。

5. 更好的循环变量作用域

在循环中使用let声明变量时,每次迭代都会创建该变量的一个新实例,这在闭包中尤其有用,因为每个闭包都可以访问到循环的正确迭代值。

javascript

for (let i = 0; i < 10; i++) {
  setTimeout(function() {
    console.log(i); // 每个函数都会打印出对应的i值
  }, 100 * i);
}

如果使用var,上面的代码会打印出10个10,因为var声明的变量是函数作用域的,所有的闭包都引用了同一个变量实例。

9.let和var的区别

letvar是JavaScript中用于声明变量的关键字,它们在作用域、变量提升以及如何被添加到全局对象中方面存在显著差异。了解这些差异对于编写可靠和高效的JavaScript代码非常重要。

作用域

  • var:声明的变量拥有函数作用域,如果在函数外部使用var声明变量,那么该变量是全局的。在函数内部使用var声明变量,那么该变量在整个函数内部都是可见的,包括函数的所有块(如if语句和循环语句)。
  • let:引入了块级作用域的概念,用let声明的变量仅在声明它的块(或子块)内部可见。

变量提升

  • var:使用var声明的变量会被提升到其作用域的顶部,这意味着变量可以在声明之前被引用,此时变量的值为undefined
  • letlet声明的变量也存在提升,但它们不会被初始化。在代码执行到变量声明之前,该变量是不可访问的,这个阶段被称为“暂时性死区”(Temporal Dead Zone, TDZ)。

暂时性死区(Temporal Dead Zone, TDZ)是指从块的开始到letconst声明的变量被初始化之间的区域。在这个区域内访问变量会导致一个引用错误,因为变量尚未被初始化,即使它已经存在于作用域中。这与使用var声明的变量不同,后者会在代码执行前被提升并初始化为undefined

下面是暂时性死区的一个例子:

javascript

function tdzExample() {
  // 此时访问variable会抛出ReferenceError,因为处于TDZ
  console.log(variable); // ReferenceError: variable is not defined

  let variable = "I am initialized";
  
  // 初始化后访问variable是安全的
  console.log(variable); // "I am initialized"
}

tdzExample();

在上面的代码中,尝试在声明之前打印variable变量的值,这会导致一个引用错误,因为variable在这时候处于暂时性死区。一旦执行到let variable = "I am initialized";这行代码,variable就离开了TDZ,可以安全地被访问和使用了。

全局对象属性

  • var:在全局作用域中使用var声明变量会创建一个新的全局变量,作为全局对象(在浏览器中是window对象,在Node.js中是global对象)的属性。
  • let:使用let在全局作用域中声明的变量不会被添加到全局对象的属性中。

10.欺骗词法作用域

  • eval() 将原本不属于这里的代码变成就像天生就定义在这里的代码一样
  • with() {} 用于修改一个对象中的属性值,但如果修改的属性在原对象中不存在,那该属性就会被泄露到全局

词法作用域是在代码编写时确定的,通常不可更改。然而,JavaScript提供了evalwith两个语句,可以在运行时修改或创建新的作用域,但它们的使用是非常不推荐的,因为:

  • 安全性:尤其是eval,它可以执行任意的代码字符串,这可能会导致安全漏洞。

  • 性能:使用这些特性可能会导致JavaScript引擎优化代码的能力降低。

  • 可读性和可维护性:这些特性的使用会使代码更难理解和维护。

evaleval函数可以执行一段字符串形式的JavaScript代码,在这段代码中可以引入新的变量或者修改现有的词法作用域。

javascript

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

withwith语句可以将代码的作用域设置到一个特定的对象中,但它会使得代码的执行变得不明确,因此不推荐使用。

javascript

var obj = {a: 1, b: 2};
with (obj) {
  console.log(a); // 输出 1
}

总的来说,虽然evalwith可以在运行时修改词法作用域,但由于安全、性能和可维护性的考虑,避免使用这些特性是一个更好的做法。在实际编程中,通过函数和块级作用域来管理和隔离作用域是更加推荐和安全的方式。

结语

本文主要深入讲解了:1. 词法分析2. 解析(语法分析) 3. 有效标识符4. 作用域5. 栈6. 自执行函数7. 声明提升8. 块级作用域9. let和var的区别10. 欺骗词法作用域。相信通过阅读,你一定能有很大的收获!