JavaScript全局执行上下文与暂时性死区深度解析

本文深入探讨JavaScript全局执行上下文的两阶段工作机制,详细解析变量提升、暂时性死区的原理,以及不同声明方式(var、let、const)和函数类型的提升行为差异。

JavaScript全局执行上下文与暂时性死区如何工作?

你是否曾想过JavaScript在幕后如何运行你的代码,全局执行上下文实际上是如何工作的?var、let和const的提升机制有何不同?

考虑以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
console.log('My age is', age)
console.log('My name is', name)
console.log('My country is', country)

var age = 24
let name = 'Shejan'
const country = 'Bangladesh'
sayHi()
function sayHi() {
  console.log('Hi!')
}

你认为这段代码的输出会是什么?

第一行可能会打印undefined,对吗?但是第二行带有name的代码呢?这将抛出"ReferenceError: Cannot access name before initialization"错误。为什么?因为let变量会被提升,但在暂时性死区(TDZ)中保持未初始化状态,直到到达它们的声明行。

带有country的第三行永远不会执行,因为代码在第2行由于ReferenceError而停止。但如果第2行不存在,第3行将因相同原因抛出相同的错误——const也停留在TDZ中。

sayHi()函数调用呢?如果我们能到达它,它会完美工作并打印"Hi!",因为函数声明会完全提升,包括它们的完整函数体。

主要问题:为什么以及如何发生所有这些?让我们深入探讨并找到这些问题的答案。

我们将涵盖的内容:

  • 全局执行上下文如何工作?
  • 内存创建阶段
  • 理解流程图
  • 提升到底是什么?
  • 只有var会被提升吗?
  • 暂时性死区(TDZ)——它到底是什么?
  • 函数提升——最有趣的部分!

全局执行上下文如何工作?

当我们运行任何JavaScript代码时,发生的第一件事就是创建全局执行上下文(GEC)。这是JavaScript执行背后的基本概念!这个全局执行上下文有两个重要阶段:

  • 内存创建阶段(内存阶段)
  • 代码执行阶段(线程阶段)

让我们看看每个阶段发生了什么,一个一个来。

内存创建阶段

这是准备时间。在此阶段,JavaScript引擎扫描整个代码一次(不执行它)并为所有变量和函数分配内存。

但这里变得有趣:

  • 变量(var、let、const)在内存中被分配空间
  • var被赋值为undefined
  • let和const被放入内存但保持未初始化状态
  • 函数(函数声明)与它们的完整代码体一起存储在内存中

那么在我们的示例代码中,内存阶段会发生什么?

1
2
3
4
age: undefined
name: <uninitialized>
country: <uninitialized>
sayHi: function() { console.log("Hi!"); }

如你所见,甚至在一行代码执行之前,所有内容都已经在内存中了!这个在内存创建阶段将变量和函数提升到内存中的整个过程被称为提升——这使得JavaScript执行看起来"神奇"。

代码执行阶段

现在真正的行动开始了!JavaScript引擎开始逐行执行代码。

第1行: console.log("My age is", age);

  • 在内存中查找age
  • 找到undefined
  • 输出:My age is undefined

第2行: console.log("My name is", name);

  • 在内存中查找name
  • 发现它存在于内存中但尚未初始化(它在暂时性死区或TDZ中——我们稍后将详细探讨这个概念)
  • 输出:ReferenceError: Cannot access name before initialization

代码执行就在这里停止了!剩余的行不会被执行。

但如果第2行和第3行不存在会发生什么?

第4行: var age = 24;

  • 内存中age的值从undefined更新为24

第5行: let name = "Shejan";

  • name现在用值"Shejan"初始化
  • 从这一点开始,可以访问name

第6行: const country = "Bangladesh";

  • country用值"Bangladesh"初始化

第7-9行: 函数调用

  • sayHi()函数在内存阶段已经加载了它的完整函数体
  • 当调用sayHi()时,JavaScript引擎专门为这个函数创建一个新的执行上下文
  • 这个新上下文被称为函数执行上下文(FEC)——它作为全局执行上下文的子级工作

这个函数执行上下文也有两个阶段,就像全局执行上下文一样:

内存创建阶段:

  • 函数内部的所有变量、参数和嵌套函数都在内存中分配
  • 函数参数被赋值
  • 创建函数作用域并与外部词法环境(函数定义的地方)建立引用链接——这个链接被称为作用域链。作用域链是JavaScript解析变量名的方式。它就像一系列连接的作用域。当JavaScript在函数内部查找变量时,它首先检查函数自己的作用域。如果在那里找不到变量,它会沿着链向上检查父作用域(函数定义的地方),然后是祖父作用域,依此类推,直到到达全局作用域。这个链确保函数可以从它们的外部环境访问变量。

代码执行(线程)阶段:

  • 现在函数体被逐行执行
  • console.log("Hi!"); 执行并打印"Hi!"

函数执行完成后:

  • 该函数执行上下文从调用栈中弹出
  • 控制权返回到全局执行上下文

注意:当所有代码执行完成时,全局执行上下文也从调用栈中弹出。

上面的流程图图示说明了JavaScript全局执行上下文工作流程,显示了两阶段。在内存创建阶段,变量和函数被分配,在代码执行阶段,代码逐行运行。它还显示了当函数被调用时如何创建函数执行上下文。

理解流程图

上面的图表可视化了JavaScript代码执行从开始到结束的完整旅程。

流程开始于JavaScript执行启动并立即创建全局执行上下文(GEC)。这个上下文然后分成两个不同的阶段,在图中显示为菱形。

左侧 - 内存创建阶段: 你可以看到三个平行分支显示如何处理不同类型的声明:

  • var变量被分配值undefined
  • let和const变量被分配但保持未初始化(在暂时性死区中)
  • 函数声明完全提升,包括它们的完整函数体

右侧 - 代码执行阶段: JavaScript现在逐行执行代码。在执行期间:

  • 它从内存访问变量值
  • 如果你尝试在初始化前访问let或const,你会得到ReferenceError(暂时性死区)
  • 如果你在赋值前访问var,你会得到undefined
  • 当函数被调用时,创建一个新的函数执行上下文(FEC)

函数执行上下文(FEC)(显示在右侧分支中)作为GEC的子级工作,并有其自己的内存和执行阶段。在函数完全执行后,FEC从调用栈中弹出,控制权返回到GEC。

最后,当所有代码执行完成时,GEC本身从调用栈中弹出,程序结束。

这个视觉表示帮助你理解JavaScript不只是读取和运行你的代码——它首先准备一切(内存阶段),然后系统地执行它(执行阶段)。

提升到底是什么?

提升是JavaScript在代码执行开始前将变量和函数声明移动到内存的默认行为。

这样想——看起来好像所有声明自动移动到代码的最顶部。虽然代码没有物理移动,但内存分配首先发生。

只有var会被提升吗?

这让许多人感到惊讶,但答案是否定的。不仅仅是var会被提升!这是许多开发人员中的一个巨大误解。

事实是——let、const和函数——所有东西都会被提升!但它们的行为完全不同。让我们深入了解细节。

var会发生什么?

1
2
3
console.log(name) // undefined
var name = 'Rahim'
console.log(name) // "Rahim"

用var声明的变量:

  • 被提升
  • 用undefined初始化
  • 存在于全局作用域或函数作用域中
  • 可以在声明前访问(不会抛出错误)

let会发生什么?

1
2
3
console.log(name) // ReferenceError: Cannot access 'name' before initialization
let name = 'Rahim'
console.log(name) // "Rahim"

这是魔法吗?不!实际上,let确实被提升了,但它卡在一个称为暂时性死区的特殊状态中!

空间在内存中被分配,但它没有被初始化。所以如果你尝试在声明前访问它,JavaScript会说——“嘿,变量存在,但你还不能使用它!”

const会发生什么?

1
2
3
console.log(age) // ReferenceError: Cannot access 'age' before initialization
const age = 24
console.log(age) // 24

const的行为与let完全相同:

  • 被提升
  • 停留在TDZ中直到声明
  • 存在于块作用域中
  • 此外,一旦赋值,就不能重新赋值

暂时性死区(TDZ)——它到底是什么?

暂时性死区是变量存在于内存中(由于提升)但尚未被初始化的时间段或区域。在此期间,变量基本上是"死的"——意味着你不能访问它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ← TDZ开始于x和y
console.log(x) // ReferenceError - 仍在TDZ中
console.log(y) // ReferenceError - 仍在TDZ中

// TDZ继续...
let x = 10 // ← x的TDZ在此行结束
const y = 20 // ← y的TDZ在此行结束

console.log(x) // 10 - 现在可以访问
console.log(y) // 20 - 现在可以访问

TDZ的整个概念是迫使我们编写更好的代码。在声明变量之前使用变量是不好的做法,而TDZ阻止我们这样做。

函数提升——最有趣的部分!

函数的提升甚至更有趣和强大:

1
2
3
4
5
greet() // "Hello World!" - 完美!它能工作!

function greet() {
  console.log('Hello World!')
}

这怎么可能?因为函数声明被完全提升了!这意味着不仅仅是名称,整个函数体都被提升到内存中。这就是为什么它甚至可以在声明之前被调用。

但是等等!不是所有函数都这样工作。

函数表达式:

1
2
3
4
5
greet() // TypeError: greet is not a function

var greet = function () {
  console.log('Hello World!')
}

这里发生了什么?greet作为变量被提升并接收值undefined。它没有被作为函数提升!所以当你尝试调用它时,你会得到一个错误。换句话说,它作为变量被提升(赋值为undefined),但函数体没有加载到内存中。

箭头函数:

1
2
3
4
5
sayHello() // ReferenceError (如果使用let/const)

const sayHello = () => {
  console.log('Hello!')
}

箭头函数的行为就像函数表达式。它们遵循变量规则。

让我们用一个完整的例子来澄清一切:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
console.log(a) // undefined (var提升)
console.log(b) // ReferenceError (TDZ)
console.log(c) // ReferenceError (TDZ)
multiply(2, 3) // 6 (函数提升)
add(2, 3) // TypeError (函数表达式)

var a = 10
let b = 20
const c = 30

function multiply(x, y) {
  return x * y
}

var add = function (x, y) {
  return x + y
}

内存阶段会发生什么:

1
2
3
4
5
a: undefined
b: <uninitialized>
c: <uninitialized>
multiply: function(x, y) { return x * y; }
add: undefined

这个快照表示在任何代码执行之前的内存状态。以下是每行的含义:

  • a: undefined - 由于a是用var声明的,它被提升并立即赋值为undefined。这就是为什么当你在其声明行之前尝试访问a时,你会得到undefined而不是错误。

  • b: <uninitialized> - 变量b是用let声明的,所以它被提升并为它分配了内存,但它保持未初始化状态。它在暂时性死区(TDZ)中。尝试在声明行之前访问它将抛出ReferenceError。

  • c: <uninitialized> - 类似地,c是用const声明的,并遵循与let相同的行为。它被提升但保持在TDZ中未初始化,直到到达声明行。

  • multiply: function(x, y) { return x * y; } - 这是一个函数声明,所以它完全提升,包括其完整函数体。整个函数存储在内存中,并准备好被调用,甚至在JavaScript引擎在代码中到达其声明之前。这就是为什么multiply(2, 3)完美工作并返回6。

  • add: undefined - 这是关键区别!即使add最终将存储一个函数,它是使用var add = function() {...}(一个函数表达式)声明的。在内存阶段,只有变量add被提升并用undefined初始化。实际的函数体直到执行阶段到达第11行才被赋值。这就是为什么在赋值之前调用add(2, 3)会抛出TypeError: add is not a function——你基本上是在尝试执行undefined()。

结论

理解JavaScript的执行机制对于成为熟练的开发人员至关重要。让我们回顾一下我们探索的基本概念:

全局执行上下文(GEC)是JavaScript执行的基础。每次运行JavaScript代码时,首先创建GEC。它在两个关键阶段工作:

  • 内存创建阶段:JavaScript通过扫描代码并为变量和函数分配内存来准备
  • 代码执行阶段:JavaScript逐行运行你的代码

提升是普遍的——不仅仅限于var。以下是如何提升不同的声明:

  • var变量被提升并用undefined初始化
  • let和const被提升但保持在TDZ中未初始化
  • 函数声明完全提升,包括它们的整个函数体
  • 函数表达式和箭头函数遵循变量提升规则

暂时性死区(TDZ)是JavaScript的内置安全机制。它从作用域开始存在,直到到达变量声明行。TDZ阻止我们在let和const变量声明之前访问它们,鼓励更好的编码实践,并帮助我们避免错误。

函数提升行为各不相同:

  • 函数声明可以在它们出现在代码之前被调用
  • 函数表达式表现得像变量,不能在赋值之前被调用
  • 箭头函数遵循与函数表达式相同的规则

为什么这很重要?理解这些概念帮助你:

  • 在运行代码之前预测代码的行为方式
  • 避免常见错误,如ReferenceError和TypeError
  • 编写更清晰、更可维护的代码
  • 在问题出现时更快地调试问题
  • 就是否使用var、let或const做出明智的决定

关键要点:JavaScript不只是执行你的代码——它先准备,然后执行。内存阶段搭建舞台,执行阶段进行表演。掌握这个两阶段过程,你将牢固理解JavaScript在幕后的工作方式。

现在你具备了编写更好JavaScript代码并确切理解幕后发生情况的知识。继续练习这些概念,它们将成为你的第二天性!

快乐编码!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计