《你不知道的JavaScript》 上卷:深入作用域与闭包

210 阅读11分钟

image.png 以下是根据《你不知道的 JavaScript:上卷》中 第一部分扩展的文章,其中融入了个人的思考和见解。

前言:JavaScript 素有"易学"的美誉,但这往往是一种误导。虽然 JavaScript 的语法可能相对简单,但真正掌握其底层机制,尤其是作用域和闭包,对于编写健壮高效的代码至关重要。

Ⅰ. 作用域:变量的家

作用域决定了变量在程序中的“居住地”以及如何访问它们。它不是一个抽象的概念,而是 JavaScript 引擎在编译阶段就确定的规则。

1.1 编译过程的三个阶段:  和许多人的直觉相反,JavaScript 并非纯粹的解释型语言,它在执行前会经历编译过程:词法分析(Lexing)、语法分析(Parsing)和代码生成(Code Generation)。词法分析将代码分解成一个个有意义的代码块(token),语法分析将这些代码块构建成抽象语法树(AST),最后代码生成阶段将AST转换为可执行的机器码。作用域的建立就发生在编译阶段的词法分析和语法分析中。

1.2 LHS 和 RHS 查询:寻找变量的两种方式:  在代码执行阶段,引擎会根据变量的使用情况进行两种类型的查找:LHS(左值)和 RHS(右值)查询。当我们给变量赋值时(例如,a = 2;),引擎会进行 LHS 查询,试图找到变量 a 的容器(在作用域链中),以便将值 2 赋值给它。当我们读取变量的值时(例如,console.log(a)),引擎会进行 RHS 查询,直接查找变量 a 的值。LHS 和 RHS 查询的失败会导致不同的结果:RHS 查询失败会抛出 ReferenceError 参考错误 异常(变量未定义);LHS 查询失败,在非严格模式下会隐式创建全局变量,在严格模式下则抛出 ReferenceError 参考错误。

这里展示了 RHS 查询读取变量值,而 LHS 查询进行赋值。需要注意的是,在非严格模式下,LHS 查询失败时(未声明变量)会隐式创建全局变量。
LHSRHS 查询

let myVar;

// RHS 查询 (读取值)
console.log(myVar); // 输出: undefined

// LHS 查询 (赋值)
myVar = 10;
console.log(myVar); // 输出: 10

// LHS 查询失败的例子(非严格模式下)
foo = 20; // 创建全局变量 foo
console.log(foo); // 输出: 20

1.3 作用域嵌套:作用域链:  JavaScript 支持嵌套作用域,例如函数内部的函数。内层函数可以访问外层函数中的变量,形成一个作用域链。当引擎查找变量时,它会从内层作用域开始,逐层向外查找,直到找到该变量或到达全局作用域。

image.png

Ⅱ.欺骗词法作用域

 eval  和 with 与 这两个机制,它们如何改变作用域行为以及为什么应该尽量避免使用它们。

2.1 词法作用域:代码编写时的约定

词法作用域是指在编写代码时就决定了作用域,变量和函数的作用域由它们在代码中出现的位置决定。 编译器在编译阶段就可以根据词法作用域确定变量和函数的查找顺序。这种静态确定性使得 JavaScript 引擎可以进行一些性能优化。

2.2 欺骗词法作用域:运行时的变数

虽然词法作用域是 JavaScript 的默认行为,但 eval() 和 with()  这两个机制允许我们动态地改变作用域行为,即在运行时修改或创建作用域。 这会破坏词法作用域的静态确定性.

eval(): 代码的动态执行
eval() 函数接受一个字符串作为参数,并将该字符串解析并执行为 JavaScript 代码。
这意味着,eval() 可以动态地生成和执行代码,改变程序的执行流程。 
如果在 eval() 中声明了变量或函数,它们会影响 eval() 所在的作用域。

let x = 10;
eval("let x = 20; console.log(x);"); // 输出: 20
console.log(x); // 输出: 10 (全局变量 x 未受影响,因为 let 创建了块作用域)

eval("x = 30; console.log(x);"); // 输出: 30
console.log(x); // 输出: 30 (全局变量 x 被修改)

(function() {
  let y = 40;
  eval("let y = 50; console.log(y);"); // 输出: 50
  console.log(y); // 输出: 50 (函数内部变量 y 被修改)
})();

如果 eval() 中使用了 let 或 const,则新声明的变量仅在 eval() 的作用域内有效,不会影响外部作用域。
with(): 简洁但危险的语法
with 语句允许在特定的对象上下文中执行代码。在 with 块内,如果引用了某个变量,
JavaScript 引擎会首先在指定对象中查找该变量,如果找不到则在外部作用域中查找。

let obj = { a: 1, b: 2 };

with (obj) {
  console.log(a); // 输出: 1
  console.log(b); // 输出: 2
  c = 3;           // 创建全局变量 c
}

console.log(c);    // 输出: 3

with 的问题在于,它可能会意外地创建全局变量(如上例中的 c),这可能会导致难以追踪的 bug。

2.3 为什么应该避免使用 eval() 和 with

首先运行时 eval() 和 with() 会导致 JavaScript 引擎难以在编译阶段进行优化,因为引擎无法在编译时预知 eval() 会执行哪些代码以及 with() 语句会使用哪个对象。 这会导致性能下降。

其次是安全风险 eval() 会将传入的字符串作为代码执行。如果这个字符串来自用户输入或不受信任的来源,那么恶意代码可能会被执行,造成安全漏洞。攻击者可以利用 eval() 执行任意代码,例如窃取 cookie、修改网页内容等。

Ⅲ. 函数作用域和块作用域

3.1 函数作用域:  传统上,JavaScript 采用函数作用域,即变量只能在其被声明的函数内部访问。这使得函数内部的实现细节得以隐藏,从而避免命名冲突。

3.2 匿名和具名函数:  函数可以是匿名的(没有名称),也可以是具名的。具名函数在调试和代码可读性方面略优于匿名函数,尤其是在递归或需要函数自引用的情况下。

3.3 立即执行函数表达式 (IIFE):  IIFE 是一种常用的模式,用于创建私有作用域,避免全局命名污染。IIFE 通过立即执行函数表达式来实现:(function(){ ... })();。

3.4 块作用域 (ES6之前):  ES6 之前,JavaScript 没有真正的块作用域。虽然 with 语句和 try...catch 语句的 catch 块会创建块级作用域,但它们的使用方式比较特殊且不推荐频繁使用。

3.5 块作用域 (ES6及以后,let 和 const):  ES6 引入了 let 和 const 关键字,允许在块级作用域中声明变量。let 声明的变量只在块内有效,避免了变量污染全局范围的问题。const 声明的变量是常量,值一旦赋值就不能改变。

3.6 块作用域的替代方案:  在 ES6 之前,可以使用 IIFE 模拟块作用域,或者利用 try...catch 中 catch 块的特性来实现局部作用域。

3.7 动态作用域:  本书也对比介绍了动态作用域,它根据函数的调用栈来确定作用域,与 JavaScript 的词法作用域形成对比。

例子展示了函数作用域和块作用域 (ES6 引入的 let 和 const) 的区别。块作用域内的变量在其块之外是不可访问的。
// 函数作用域
function outerFunction() {
  let outerVar = "Hello";

  function innerFunction() {
    console.log(outerVar); // 可以访问外层变量
  }

  innerFunction();
}

outerFunction(); // 输出: Hello


// 块作用域 (ES6+)
{
  let blockVar = "World";
  console.log(blockVar); // 输出: World
}
// console.log(blockVar); // 报错: blockVar is not defined

// 全局作用域
let globalVar = "JavaScript";
console.log(globalVar); // 输出: JavaScript
IIFE 创建一个立即执行的匿名函数,其内部变量在外部作用域是不可访问的,避免了命名冲突。
(function() {
  let iifeVar = "IIFE scope";
  console.log(iifeVar); // 输出: IIFE scope
})();

// console.log(iifeVar); // 报错: iifeVar is not defined
let 声明的变量仅在块内有效,const 声明的变量是常量。
function myFunction() {
  if (true) {
    let blockScopeVar = "Block scope";
    console.log(blockScopeVar); // 输出: Block scope
  }
  // console.log(blockScopeVar); // 报错: blockScopeVar is not defined
  const constantVar = 30; // 常量声明
  // constantVar = 40; // 报错: Assignment to constant variable.
}
myFunction();

Ⅳ.提升:编译器的小把戏

JavaScript 引擎在执行代码之前会进行编译,而“提升”就是编译器的一个重要步骤。所有变量和函数的声明都会被“提升”到作用域的顶部

4.1 先有鸡还是先有蛋:  这正是提升带来的看似违反直觉的现象。变量声明的提升只发生在声明(蛋)本身,而赋值(鸡)操作则保留在原位置。这意味着,如果在声明之前使用变量,得到的结果可能是 undefined (声明提升了,但赋值没提升)。

4.2 函数优先:  如果在同一作用域内既有函数声明又有变量声明,函数声明会优先提升。

函数声明会被提升到作用域顶部,因此可以在声明之前调用。但是函数表达式不会提升。
console.log(myFunc()); // 输出: 10

function myFunc() {
  return 10;
}

// 函数表达式不会提升
console.log(myExpr()); // 报错: myExpr is not a function

let myExpr = function() {
  return 20;
};

Ⅴ.闭包:函数的记忆

闭包是函数作用域和词法作用域结合的产物。当一个内层函数能够访问其外层函数的变量时,即使外层函数已经执行完毕,这个内层函数仍然保留着对这些变量的访问能力,这就形成了闭包。

5.1 循环和闭包:  在使用闭包时最常见的问题之一是循环中闭包的使用。如果不仔细处理,所有闭包可能会共享同一个循环变量的最终值,而不是每个闭包对应一个循环变量的快照。IIFE 或 let 声明通常用来解决这个问题。

function outerFunction() {
  let outerVar = "This is a closure";

  return function innerFunction() {
    console.log(outerVar); // 访问外层函数的变量
  };
}

let myClosure = outerFunction();
myClosure(); // 输出: This is a closure

Ⅵ.模块机制:代码的组织

模块是利用闭包来组织代码的一种常见模式,它可以将代码的内部实现隐藏起来,避免命名冲突。

6.1 现代模块机制:  文章中详细讨论了基于函数的模块模式,以及如何使用 IIFE 来实现模块的私有作用域。(都是代码介绍)

6.2 未来的模块机制 (ES6 模块):  ES6 提供了对模块的原生支持,提供 import 和 export 关键字,使得模块之间的依赖关系更加清晰,避免了基于函数的模块模式的复杂性。

如何使用 IIFE 创建一个模块,隐藏内部变量 privateVar,只暴露 publicMethod。
// 模块化(简化)
const myModule = (function() {
  let privateVar = "Private";

  return {
    publicMethod: function() {
      console.log(privateVar); // 可以访问私有变量
    }
  };
})();

myModule.publicMethod(); // 输出: Private
// console.log(myModule.privateVar); // 报错: privateVar is not defined

Ⅶ. this 词法

this 关键字是 JavaScript 中最容易混淆的概念之一。它的值取决于函数的调用方式。

6.1 this 的绑定规则:  在文章中详细解释了 this 绑定的四条规则:默认绑定隐式绑定显式绑定和 new 绑定,并讨论了它们的优先级和各种例外情况。 this 的绑定发生在运行时,而不是编译时。(我会在下一篇文章中详细说到this)

6.2 箭头函数和 this 词法:  ES6 中的箭头函数 (=>) 有特殊的 this 词法行为,它会从词法作用域中继承 this 的值,而不是使用四条标准的绑定规则。

this 的值取决于函数的调用方式。在 setTimeout 中,如果我们不使用 .bind() 来显式绑定 this,this 会指向全局对象(浏览器环境下通常是 window),因此输出 undefined,因为全局对象没有 name 属性。
const myObject = {
  name: "My Object",
  myMethod: function() {
    console.log(this.name);
  }
};

myObject.myMethod(); // 输出: My Object

setTimeout(myObject.myMethod, 1000); //输出: undefined (默认绑定)

setTimeout(myObject.myMethod.bind(myObject), 1000); // 输出: My Object (显式绑定)

总结

《你不知道的JavaScript》 上卷 - 作用域和闭包
  • 作用域 定义了变量的可见性和生命周期,决定了代码如何访问变量。包含词法作用域和动态作用域(JavaScript 使用词法作用域)。LHS 和 RHS 查询分别用于赋值和取值。
  • 函数作用域:变量在其声明的函数内部可见。包含函数声明和函数表达式(匿名函数和具名函数)。IIFE 用于创建私有作用域。
  • 块作用域 (ES6+):let 和 const 关键字创建块级作用域,变量仅在其块内可见。
  • 提升:变量和函数声明会被提升到作用域顶部,但赋值操作不会。函数声明优先于变量声明提升。
  • 闭包:内层函数访问其外层函数作用域变量的能力,即使外层函数已执行完毕。经常用于创建私有状态和模块。
  • 欺骗词法作用域:eval() 和 with 可以动态修改作用域,但会影响性能和可维护性,应尽量避免使用。
  • 模块:使用闭包来组织代码,ES6 模块提供了标准的模块化机制,提升了代码的可维护性和可读性。