你不知道的Javascript之作用域和闭包

453 阅读15分钟

这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战

Hello,大家好,我是知秋今天给大家的分享的是我最近在看的一本书《你不知道的Javascrip》里面的有一章的内容作用域和闭包的知识。我将围绕以下内容来分享。

  • 作用域的原理概念
  • 词法作用域
  • 函数的作用域
  • 作用域与变量提升的关系
  • 作用域和闭包的联系

作用域是什么

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。

作用域查找规则

如果查找的目的是对 变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。 赋值操作符会导致 LHS 查询。

= 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

举个例子👀

JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,像 var a = 2 这样的声 明会被分解成两个独立的步骤:

  1. 首先,var a 在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。
  2. 接下来,a = 2 会查询(LHS 查询)变量 a 并对其进行赋值。 LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所 需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层 楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。 不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛 出 ReferenceError 异常(严格模式下)。

编译原理

传统编译语言的流程

  1. 分词/词法分析

    将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元

  2. 解析/语法分析

    将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”

  3. 代码生成

    将 AST 转换为可执行代码的过程称被称为代码生成。

理解作用域

参与者

引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程

编译器:负责语法分析及代码生成等脏活累活

作用域: 负责收集并维护由所有声明的标识符(变量)组成的一系列查 询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

协同工作

变量赋值举例: 变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如 果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对 它赋值。

引擎如何查找: 编译器已经到了第二步生成了代码,引擎与作用域协作,询问作用域是否存在这个值

RHS查询: 谁是赋值操作的源头 (RHS)如:需要查找并取 得 a 的值

LHS查询: 赋值操作的目标是谁(LHS) 如:不关心当前的值是什么,只是想要为 = 2 这个赋值操作找到一个目标

作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用 域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。 也叫遍历作用域链规则

异常

引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量, 全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非 “严格模式”

ReferenceError 同作用域判别失败相关

  • RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常
  • 在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询 失败时类似的 ReferenceError 异常。

TypeError 则代表作用域判别成功了,但是对 结果的操作是非法或不合理的

  • RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作, 比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的 属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError

词法作用域

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。

编译的词法分析阶段 基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它 们进行查找。

JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..) 和 with。

  • 前者可以对一段包 含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在 运行时)。
  • 后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作 用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。 这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认 为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

词法阶段

  function foo(a) { var b = a * 2;

    function bar(c) { console.log( a, b, c ); }

    bar( b * 3 ); }

foo( 2 ); // 2, 4, 12
  1. 包含着整个全局作用域,其中只有一个标识符:foo。
  2. 包含着 foo 所创建的作用域,其中有三个标识符:a、bar 和 b
  3. 包含着 bar 所创建的作用域,其中只有一个标识符:c

查找

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的 标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应, 作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见 第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此 可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引 用来对其进行访问。 window.a

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处 的位置决定

欺骗词法

欺骗词法会导致性能下降,尽量不用

eval: eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书 写时就存在于程序中这个位置的代码。

with: with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象 本身

函数作用域和块级作用域

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会 在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。 但函数不是唯一的作用域单元。

块作用域指的是变量和函数不仅可以属于所处的作用域, 也可以属于某个代码块(通常指 { .. } 内部)。 从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。

在 ES6 中引入了 let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if (..) { let a = 2; } 会声明一个劫持了 if 的 { .. } 块的变量,并且将变量添加到这个块 中。

有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开 发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。

函数中的作用域

属于这个函数的全部变量都可以在整个函数的范围内使用及复 用(事实上在嵌套的作用域中也可以使用)

隐藏内部实现

可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域 来“隐藏”它们。

好处

隐藏原因

这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来 的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必 要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计

避免冲突

可以避免同名标识符之间的冲突, 两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致 变量的值被意外覆盖。

全局命名空间

第三方库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属 性,而不是将自己的标识符暴漏在顶级的词法作用域中。

模块管理

是从众多模块管理器中挑选一个来 使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器 的机制将库的标识符显式地导入到另外一个特定的作用域中。

函数作用域

问题:首先, 必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域(在这个 例子中是全局作用域)。其次,必须显式地通过函数名(foo())调用这个函数才能运行其 中的代码。

解决方法:立即执行函数和匿名函数

函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处

具名和匿名

匿名函数表达式,因为 function().. 没有名称标识符。函数表达式可以是匿名的, 而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。

匿名函数缺点

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
  3. 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明 - 解决方法:行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函 数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践

立即执行函数IIFE

(function foo(){ .. })()。第一个 ( ) 将函数变成表 达式,第二个 ( ) 执行了这个函数。改进的形式:(function(){ .. }())

提升

我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a 和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。 可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的 最顶端,这个过程被称为提升。

声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。 要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引 起很多危险的问题!

编译器又来了

var a; 和 a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在 原地等待执行阶段。

这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动” 到了最上面。这个过程就叫作提升

函数声明会被提升,但是函数表达式却不会被提升

函数优先

函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个 “重复”声明的代码中)是函数会首先被提升,然后才是变量。

  • 重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的
  • 一个普通块内部的函数声明通常会被提升到所在作用域的顶部,所以尽可能避免在块内部声明函数

作用域闭包

闭包就好像从 JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人 才能够到达那里。

但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的 词法环境中书写代码的。

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时 就产生了闭包。 如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循 环中。

但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。 模块有两个主要特征:

(1)为创建内部作用域而调用了一个包装函数;

(2)包装函数的返回 值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭 包。 现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用 的事!

定义:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用 域之外执行。

词法作用域的查找规则,而这些规则只是闭包的一部分。(但却 是非常重要的一部分!)

bar() 嵌套在 foo() 内部,,函数 bar() 具有一个涵盖 foo() 作用域的闭包 (事实上,涵盖了它能访问的所有作用域,比如全局作用域)

这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的 词法作用域

无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到 闭包。传递函数当然也可以是间接的

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用 域的引用,无论在何处执行这个函数都会使用闭包。

IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建 可以被封闭起来的闭包的工具。因此 IIFE 的确同闭包息息相关,即使本身并不会真的使用 闭包。

循环域闭包

原始

for (var i=1; i<=5; i++) { 
setTimeout( function timer() { console.log( i ); }, i*1000 ); } //66666

解决方案

for (var i=1; i<=5; i++) { 
 (function(j) { 
     setTimeout(
     function timer() { 
     console.log( j ); },
     j*1000 ); })( i ); } //12345
for (let i=1; i<=5; i++) { 
    setTimeout( function timer() { console.log( i ); }, i*1000 ); }//12345