JavaScript词法环境和作用域

211 阅读10分钟

scope.png

var fn = 1
function fn() {}
fn()

在编译阶段,变量声明会被提升,而 function 整个声明语句都会被提升。由此可见,在JavaScript中,函数是“头等公民”。

提升(Hoisting)是JavaScript(JS)将变量和函数的声明部分“移至”函数体的上部,并将其提升至函数体的顶部。

在执行阶段,function 声明的函数标识符 fn 被当作变量赋值为 1,于是 fn() 报错 TypeError: fn is not a function

当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数。例如,在这门语言中,函数可以被当作参数传递给其他函数,可以作为另一个函数的返回值,还可以被赋值给一个变量。

如果把上面代码中的 var 换成 let,错误则会变成 Uncaught SyntaxError: Identifier 'fn' has already been declaredlet 的词法环境要求同级作用域不能重复声明。

在电脑程序设计中,作用域(scope,或译作有效范围)是名字(name)与实体(entity)的绑定(binding)保持有效的那部分计算机程序。不同的编程语言可能有不同的作用域和名字解析。而同一语言内也可能存在多种作用域,随实体的类型变化而不同。作用域类别影响变量的绑定方式,根据语言使用静态作用域还是动态作用域变量的取值可能会有不同的结果。

  • 包含标识符的宣告或定义;
  • 包含语句和/或表达式,定义或部分关于可执行的算法;
  • 嵌套嵌套或被嵌套嵌套。 命名空间是一种作用域,使用作用域的封装性质去逻辑上组群起关相的众识别子于单一识别子之下。因此,作用域可以影响这些内容的名字解析。

作用域

自 ES2015 更新以来,JavaScript 具有以下几种类型的作用域:

  • 全局作用域(Global scope):以脚本模式运行的所有代码的默认作用域。
  • 函数作用域(Function scope):用函数创建的作用域。
  • 块作用域(Block scope):用一对大括号(块)创建的作用域。
  • 模块作用域(Module scope):以模块模式运行的代码的作用域。

从执行上下文的角度来看,作用域是什么?全局作用域是全局执行上下文,函数作用域与函数执行上下文相关。ES2015 中引入的块作用域与其两个兄弟作用域不同。

全局作用域
var ani = 'cat'

if (true) {
	var ani = 'dog'
	console.log('if ani:', ani)  // if ani: dog
}

console.log('Global ani:', ani)  // Global ani: dog

在这种情况下,我们只有一个全局执行上下文和一个全局变量环境。第二个 ani 赋值会覆盖前一个。执行结束时,只有一个 ani 变量保存值dog。从作用域的角度来看,我们可以说 ani 变量处于全局作用域中。

块作用域

在 JavaScript 中,代码块是使用花括号 {} 定义,所在作用域即块作用域。需要注意的是,块作用域仅适用于 let 和 const 。使用 var 声明的任何内容都将在全局范围内。

var ani = 'cat'

if (true) {
	let ani = 'dog'
	console.log('if ani:', ani)  // if ani: dog
}

console.log('Global ani:', ani)  // Global ani: cat

控制台输出两个不同的值。第一个 ani 变量保存值 cat,而 if 块中的变量保存值 dog

函数作用域

函数内声明的任何变量都在函数作用域内。它适用于 var 、 let 和 const 。

var ani = 'cat'

function test () {
	var ani = 'dog'
	console.log('function ani:', ani)  // function ani: dog
}

test ()
console.log('Global ani:', ani)  // Global ani: cat

控制台输出两个不同的值。第一个 ani 变量保存值 cat,而 if 块中的变量保存值 dog

模块作用域

随着 ES6 的引入,模块成为现实。模块内定义的任何变量都在模块范围内,不能在其他地方使用。

// gravity.js
export const g = 9.8

// main.js
import './gravity'

词法环境

让我们完成这个两步过程并揭开其工作原理的神秘面纱。在编译步骤中,未定义的 ani 变量被添加到全局执行上下文中。此时,JavaScript 引擎决定跳过第二个 ani ,原因有两个:

  • 它是使用 let 创建的变量
  • 它位于块级作用域内

接下来,执行步骤开始。第一个 ani 变量被赋予值 cat。当读取 if 块代码时,会发生嵌套编译步骤。创建第二个未定义的 ani 变量。这个 ani 变量被添加到词法环境中,而不是在变量环境中创建它。

很快,词法环境中的 ani 就被赋值为 dog。现在我们有两个同名的变量在两个环境中管理。这就是 JavaScript 引擎处理 let 的方式,并且仍然向后兼容 var

作用域堆栈
var color = 'global red'
let size = 'global big'

{
	let size = 'block big'
	var style = 'global dashed'
	let line = 'block dot'

	console.log(color)
	console.log(size)
}

console.log(size)
console.log(style)
console.log(line)

为了更好地理解 letvar 之间的区别,让我们将它们组合在一起,形成一个有趣的示例。在编译步骤中,未定义的 colorstyle 变量在变量环境中初始化。 style 初始化被提升。同时,在词法环境中创建了一个 size 变量。执行开始, color 被分配了一个值 global red,而 size 则被分配了一个值 global big

是时候处理块中的变量了。当在块作用域中看到 letconst 变量时,JavaScript 会为它们创建一个单独的区域。词法环境为其变量维护一个类似堆栈的结构,因此具有相同名称的变量不会相互冲突。这里,未定义的 sizeline 变量位于独立作用域中。很快,两者都被分配了相应的值。执行完最后一次赋值后,所有变量都准备好了。

当记录第一个变量时,JavaScript 引擎首先尝试在词法环境中从上到下查找 color 。然后它检查全局变量环境并找到 color ,打印 global red。当搜索 size 时,JavaScript 引擎遵循相同的步骤,找到它,并打印 block big。此时,块中已经没有更多的可执行代码了。块作用域被删除。

脚本继续执行。它找到全局变量环境中的 sizestyle ,并相应地打印出 global bigglobal dashed。当脚本搜索 line 时,该变量在任何地方都不存在,因为 line 存在的范围已被删除。它会抛出错误 ReferenceError: line is not defined

变量创建

一个变量从编译到执行,要经历三个步骤:

  • Creation 创建
  • Initialization 初始化
  • Assignment 赋值
let num = 1
{
	console.log(num)
	let num = 2
}

控制台会记录什么?是 1 吗?或者,是 2 吗?令人惊讶的是,错误显示ReferenceError: Cannot access 'num' before initialization。该错误与提升有关:

  • 对于 let 变量,其创建会被提升,但初始化和赋值不会。
  • 对于 var 变量,它的创建和初始化会被提升,但赋值不会。
  • 对于 function ,它的创建、初始化和赋值是同时提升的。

人们将变量初始化之前的代码区域命名为临时死区。

  • 如果创建之前尝试访问变量,会看到错误 ReferenceError: ** is not defined
  • 如果在初始化之前访问变量,会看到错误 ReferenceError: Cannot access '**' before initialization
  • 如果赋值之前记录变量,会看到值 undefined

块作用域中的 letconst 变量是在执行步骤而不是编译时创建的。这些变量存储在词法环境中。

变量查找
var num = 1

function isOdd () {
	console.log(num)
}

function isEven () {
	var num = 2
	isOdd()
}

isEven()

当执行 isOdd 函数时,产生三个堆栈执行上下文:

  • 全局执行上下文
  • isEven 函数执行上下文
  • isOdd 函数执行上下文

接下来,控制台开始查找 num 变量。直观上,通过调用堆栈中从上到下的流程来分析链式查找。控制台会输出 2 ,因为它在 isEven 函数执行上下文中找到 num 变量。相比之下,控制台实际上记录在全局执行上下文中分配的 1。为什么呢?

我们的链查找错过了执行上下文中的一个关键组件,即外部引用环境。外部定义了 JavaScript 引擎如何执行链查找,也称为作用域链。如果我们查看 isOdd 执行上下文,它的外部指向全局执行上下文。

在这种情况下,JavaScript 引擎在 isOdd 执行上下文中找不到 num 变量后,会立即在全局执行上下文中查找该变量。为什么 isOdd 执行上下文的外部指向全局而不是 isEvenisOdd 函数是在 isEven 内部调用的,作用域链不应该跟随调用堆栈吗?

与直觉相反,JavaScript 的作用域链是由词法作用域定义的,并且不受调用堆栈的影响。为了进一步回答这个问题,我们需要揭开 JavaScript 如何设计其词法作用域的神秘面纱。

作用域链

JavaScript 引擎有一个规则:词法作用域由函数所在的位置定义。

从词法范围的角度看,isOddisEven 函数在全局范围内声明。因此,它们的词法作用域是全局作用域。当 JavaScript 引擎编译脚本时,两个函数执行竞争中的外部都指向全局执行上下文。

let p = 10

function priceA () {
	let p = 20

	(function priceB() {
		let p = 30

		(function priceC() {
			console.log(p)
		})()
	})()
}

priceA()

在这种情况下:

  • 函数 priceA 定义在全局范围内;
  • 函数 priceB 定义在 priceA 范围内;
  • 函数 priceC 定义在 priceB 范围内。

基于词法作用域,我们可以在每个执行上下文中推理出外部:

  • priceC 执行上下文中,outer指向 priceB 执行上下文;
  • priceB 执行上下文中,outer指向 priceA 执行上下文;
  • priceA 执行上下文中,outer指向全局执行上下文。

执行结束时,控制台会记录 30

这就是作用域链在 JavaScript 执行上下文中的工作原理。

闭包

function p () {
	var a = 1
	let b = 2
	let c = 3
	var u = {
		getP: function () {
			console.log(a)
			return b
		},
		setP: function (v) {
			b = v
		}
	}
	return u
}

var p1 = p()
p1.setP(10)
console.log(p1.getP())

u 返回并分配给 p 变量之前,p1 在全局的EC创建,abcup 的EC创建,接着ab被赋值。返回 u 后,p 函数执行结束,并且其执行上下文被删除。同时,变量和词法环境消失,并且支持销毁其中的变量。此时,JavaScript 的词法作用域规则开始发挥作用——内部函数始终可以访问其外部函数中的变量。

这里,内部函数是 getPsetP ,外部函数是 p getP 函数使用两个变量 ab ,而 setP 函数使用 b 。按照规则, ab 变量保存在单独的区域中。它是一个独占区域,只能由 getPsetP 函数访问,也称为闭包。

同时, c 变量被销毁,因为没有方法保存对其的引用。接下来,继续执行并调用 setP 函数。 JavaScript 引擎查看作用域链并在闭包中找到 b 变量。 b 的值设置为 10

在最后一行中,调用了 getP 。按照相同的链查找,JavaScript 引擎在闭包中找到 ab 变量,并相应地输出 110。执行结束。