变量提升到底提升了什么:为什么 var 是 undefined,let 却直接报错

本文是「JavaScript 运行现场」系列的第 2 篇。

下面使用几段可直接运行的简化代码说明问题,重点放在声明创建、初始化时机和函数声明的处理差异。

先看两段只差一个关键字的代码。

js 复制代码
console.log(a)
var a = 1

运行结果是:

text 复制代码
undefined

var 换成 let

js 复制代码
console.log(b)
let b = 1

运行结果变成:

text 复制代码
ReferenceError: Cannot access 'b' before initialization

两段代码都在声明之前读取变量,结果却完全不同。把它简单归结为"var 会提升,let 不会提升"并不准确,因为执行到第一行时,引擎已经知道当前作用域中存在 b;如果它完全不知道 b,报错信息应该更接近 b is not defined

这个差异要从代码正式执行前的准备过程看起。上一篇提到,JavaScript 执行全局代码或调用函数时,会先创建对应的执行上下文。变量声明会在这个阶段被登记,但不同声明的初始化时间并不相同。

var 在代码执行前已经被初始化为 undefined

先只看第一段:

js 复制代码
console.log(a)
var a = 1

为了观察顺序,可以把引擎的处理过程简化为两个阶段。

准备阶段发现了 var a,于是创建变量绑定,并给它一个初始值 undefined。代码随后从上到下执行,第一行读取到的就是这个初始值;执行到 var a = 1 时,右侧的 1 才被赋给 a

text 复制代码
准备阶段:创建 a,并初始化为 undefined

执行第一行:console.log(a)
输出 undefined

执行第二行:a = 1

因此,下面这种改写有助于理解运行结果:

js 复制代码
var a

console.log(a)
a = 1

这只是行为上的等价说明,JavaScript 引擎不会真的把源代码移动到文件顶部。所谓"变量提升",描述的是声明在代码执行前已经进入当前环境,而不是文本发生了重新排序。

把声明和赋值拆开后,另一个容易忽略的问题也会清楚一些:

js 复制代码
console.log(a)
var a = getValue()

在执行 console.log(a) 时,getValue() 还没有运行。准备阶段处理的是变量声明,函数调用和赋值表达式仍然要等执行走到这一行。

同一个作用域里重复声明 var 也不会创建多个独立变量:

js 复制代码
var count = 1
var count = 2

console.log(count)

这里只有一个 count 绑定,第二次赋值把值改成了 2。这类宽松行为在旧代码中很常见,也让重复命名更难在早期暴露。

letconst 已经存在,但暂时不能读取

再看第二段:

js 复制代码
console.log(b)
let b = 1

准备阶段同样会发现 b,并在当前块级作用域中创建对应绑定。区别在于,这个绑定此时还没有完成初始化。

从进入作用域开始,到执行 let b = 1 之前,这段范围通常称为暂时性死区,英文是 Temporal Dead Zone,常写作 TDZ。

js 复制代码
{
  // b 的暂时性死区开始

  console.log(b)

  let b = 1
  // 执行声明后,b 才可以读取
}

第一行读取 b 时,引擎查找到了这个绑定,所以它不会继续向外层作用域寻找同名变量。由于绑定还没有初始化,读取操作直接抛出 ReferenceError

下面这段代码更能看出变量已经进入当前作用域:

js 复制代码
const value = 'outer'

{
  console.log(value)//ReferenceError
  let value = 'inner'
}

如果块内的 value 完全没有创建,console.log 应该读取外层的 'outer'。实际执行时,块内声明会遮蔽外层变量,而读取又发生在初始化之前,所以代码报错。

const 在声明前的行为与 let 相同:

js 复制代码
console.log(token)
const token = 'abc'

它也处于暂时性死区。const 额外要求声明时必须完成赋值,后续不能再次给这个变量绑定新值。

js 复制代码
let page
page = 2

const size // SyntaxError

暂时性死区还有一个容易误判的边界。typeof 对完全未声明的变量通常不会报错:

js 复制代码
console.log(typeof missingName)
// "undefined"

但变量处于暂时性死区时,typeof 仍然会抛出异常:

js 复制代码
console.log(typeof status)
let status = 'ready'

这里的 status 已经属于当前作用域,只是尚未初始化。typeof 不能绕过这个状态。

varletconst 放在同一张图里,会更容易看清它们在准备阶段和执行阶段的差异。

函数声明为什么能在定义前调用

变量声明之外,函数也会出现类似的提前可用现象。

js 复制代码
printMessage()

function printMessage() {
  console.log('hello')
}

这段代码可以正常输出 hello。准备阶段处理函数声明时,当前作用域中的 printMessage 已经被初始化为对应的函数对象,所以第一行调用能够直接找到函数。

函数表达式的结果不同:

js 复制代码
run()

var run = function () {
  console.log('running')
}

这里会出现:

text 复制代码
TypeError: run is not a function

准备阶段处理的是 var run,因此 run 已经存在并且值为 undefined。执行第一行时,相当于调用:

js 复制代码
undefined()

变量能够被找到,所以报错类型不是 ReferenceError;它当前保存的值不能调用,于是得到 TypeError

如果改成 let

js 复制代码
run()

let run = function () {
  console.log('running')
}

第一行会在暂时性死区中读取 run,因此得到 ReferenceError。函数体写法相同,前面的声明方式改变后,准备阶段的处理也随之改变。

箭头函数同样属于函数表达式:

js 复制代码
load()

const load = () => {
  console.log('loaded')
}

loadconst 的初始化规则约束,在声明执行前无法调用。

这几段代码可以归成三种情况:

  • 函数声明在准备阶段创建并完成初始化;
  • var 声明的函数表达式先得到 undefined
  • letconst 声明的函数表达式在初始化前不能读取。

项目中把函数声明改写成箭头函数时,如果原代码依赖"定义前调用",行为就会发生变化。下面这种调整表面上只改了语法:

js 复制代码
init()

function init() {
  // 初始化逻辑
}

改成:

js 复制代码
init()

const init = () => {
  // 初始化逻辑
}

第二版在执行第一行时会报错。重构时需要一起调整调用位置,不能只确认函数体是否相同。

用一段代码验证三个阶段

可以把前面的差异放进同一个函数中运行:

js 复制代码
function inspectDeclarations() {
  console.log(varValue)

  try {
    console.log(letValue)
  } catch (error) {
    console.log(error.name)
  }

  declaredFunction()

  try {
    expressionFunction()
  } catch (error) {
    console.log(error.name)
  }

  var varValue = 1
  let letValue = 2

  function declaredFunction() {
    console.log('function declaration')
  }

  var expressionFunction = function () {
    console.log('function expression')
  }
}

inspectDeclarations()

输出顺序是:

text 复制代码
undefined
ReferenceError
function declaration
TypeError

varValue 在准备阶段已经初始化为 undefinedletValue 已经创建,但读取时仍处于暂时性死区;declaredFunction 已经保存了函数对象;expressionFunction 此时只有 var 提供的初始值 undefined

调试类似问题时,可以先确认当前标识符来自哪一种声明,再看读取发生在声明执行之前还是之后。只记"都会提升"或者"只有 var 提升",很难解释错误类型为什么不同。

这一篇没有继续展开全局 varwindow 的关系、ES Module 顶层作用域以及块级函数声明的兼容行为。这些规则会受到脚本类型、严格模式和运行环境影响,混在当前问题里会削弱主线。

下一篇会继续沿着变量查找过程,分析内部函数读取同名变量时为什么优先使用定义位置的作用域,以及词法作用域怎样形成作用域链。

相关推荐
stringwu1 小时前
用 LangGraph 落地飞书 Bug 自动修复 Agent 实践
前端
labixiong1 小时前
ES2026 落地了什么?两个真上线的特性 + 两个被毙的,一次讲清楚
javascript·ecmascript 6·ecmascript 8
Revolution611 小时前
本地明明正常,为什么 CI 又挂了:一次前端构建失败的排查过程
前端·前端工程化
太平洋月光1 小时前
stagewise如何结合cursor开发
前端·ai编程
前端Hardy1 小时前
JavaScript 终于又进化了!ES2026 正式发布,这些新特性你必须知道!
前端
光影少年1 小时前
react navite 页面跳转、传参、路由监听、导航栏自定义
前端·react native·react.js
IT知识分享1 小时前
WebP转JPG开发经验:从格式解码到本地批量转换的实践
javascript·python·图片转换
肖志-AI全栈1 小时前
JavaScript 快速排序:从 pivot、双指针到分治思想
开发语言·javascript·排序算法
黑土豆1 小时前
CSS 新特性:Container Queries 到底能解决什么痛点?
前端·css