块级作用域绑定(let、const、临时死区及变量的生命周期)

本文深入探讨了ES6中let和const声明的工作原理,包括块级作用域、暂时性死区(TDZ)的概念,以及它们与var声明的区别。文章还介绍了变量的生命周期,强调了let和const声明不会被提升,以及在TDZ中使用变量的注意事项。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

文章目录

  1. let 声明
  2. let 是如何工作的?
  3. 暂时性死区( TDZ )
  4. const 声明
  5. 变量生命周期
  6. var 变量的生命周期
  7. 函数声明生命周期
  8. 受临时死区(TDZ) 影响的声明
  9. TDZ 中的 typeof 行为
  10. 总结
  11. 番外

let 声明

let 声明是 ES6 中很常见的特性,它的工作方式类似于var声明,但是它有不同的作用域规则。在确定作用域方面,JS 有着一个复杂的规则集,这让许多程序员在第一次尝试弄清楚变量在 JS 中是如何工作的时候感到抓狂。

提升是将变量或函数定义移动到作用域头部的过程,通常是 var 声明的变量和函数声明function fun() {...}

ES6 新增了let命令,用来声明变量。它的用法类似于var,但是所声明的变量,只在let命令所在的代码块内有效。

  function weAreGodd (name) {
    if (name === '前端小智') {
      var good = true
    }
    return good
  }

  weAreGodd('前端小智')
  // <- true
  weAreGodd('ES6 深入浅出')
  // <- undefined

咱们知道,var 命令会发生“变量提升”现象,即会被提升到当前作用域的顶部,值为undefined。上面函数声明等价于以下代码:

  function weAreGodd (name) {
    var good
    if (name === '前端小智') {
      good = true
    }
    return good
  }

不管咱们是否喜欢它,这显然比使用块范围的变量更令人困惑。块范围作用于括号级别,而不是函数级别。

如果咱们想要更深的作用域层级,则不必声明新函数,块作用域就可以帮咱们实现。 咱们可以任意创建新的{}块。

  {{{{{var deepLevel = '多级作用域'}}}}}
  console.log(deepLevel)
  // <- '多级作用域'

但是,使用 var,多级作用域之外访问变量,而不会出现错误。有时下面这些情况下得到错误是非常有用的:

  • 访问内部变量破坏封装的完整性
  • 内部变量根本不属于外部作用域
  • 块与块之间,也希望使用相同的变量名
  • 其中一个父元素已经有了一个需要的变量名,但是它仍然适合在内部块中使用

let 是如何工作的?

letvar的替代方案,它遵循块作用域规则,而不是默认的函数作用域规则。这意味着使用简单的{}块就可以得到的作用域,
而不需要使用函数声明。

let outer = '这里是最外层'
{
  let inner = '这里是中间层'
  {
    let innermost = '这里是内层'
  }
  // 在这里访问 innermost 会报错
}
// 在这里访问 inner 会报错
// 在这里访问 innermost 会报错

如上所示,当咱们在块外部访问块里面的变量时,就会报错。

暂时性死区( TDZ )

简而言之:如果具有以下代码,则会抛出错误:


there = '尽量避免让这种情况出现'
// <- ReferenceError: Cannot access 'there' before initialization
let there = '危险'

如果试图在执行let there语句之前以任何方式访问there,会抛出错误。但是咱们可以在函数先引用 there
只要在执行let there语句之后,再调用函数就可以:

function readThere () {
  return there
}
let there = '危险'
console.log(readThere())
// <- '危险'

但如果在执行let there语句之前,调用函数就会报错:

function readThere () {
  return there
}
console.log(readThere())
// ReferenceError: Identifier 'there' has already been declared
let there = '危险'

注意,当在初始声明时没有分配值时,这些示例的语义不会改变。下面的代码仍然报错,因为它仍然试图在离开TDZ之前访问there:

function readThere () {
  return there
}
console.log(readThere())
// ReferenceError: Identifier 'there' has already been declared
let there

如果在 let 声明之后再访问 there 变量就可以了,因为已经不受 TDZ 的约束。

function readThere () {
  return there
}
let there
console.log(readThere())
// <- undefined

唯一棘手的部分是要记住(当涉及到TDZ时)函数的工作方式有点像黑盒,直到它们第一次被执行,所以放在函数里面的
there 离开 TDZ 才能调用函数。

TDZ 的全部意义在于使它更容易捕获在用户代码中声明变量之前访问变量导致意外行为的错误。这种情况在ES5中经常发生,这是由于提升和糟糕的编码约定造成的。

const 声明

constlet非常相似:

  • const的作用域与let命令相同:只在声明所在的块级作用域内有效。

  • const 命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。

  • const声明的常量,也与let一样不可重复声明。

还有几个主要的不同之处:

  • const 声明一个只读的常量。一旦声明,常量的值就不能改变。

  • const 声明的变量不得改变值,这意味着,const 一旦声明变量,就必须立即初始化,不能留到以后赋值。

接着咱们来看看一些例子。 首先,下面示例说明了constlet 一样遵循块作用域规则。

const name = '前端小智'
{
  const aliname = '王大冶'  
  console.log(aliname)
  // <- '王大冶'
}
console.log(name)
// <- '前端小智'

声明const后,将无法更改为其分配的引用的地址或字面量的值。

const family = { people: ['张三', '李四', '王五', '赵六'] }
family = {}
// <- "family" is read-only

const 实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。
可以Object.freeze 来冻结对象,使对象里面值不可变。

const family = { people: ['张三', '李四', '王五', '赵六'] }
family.people.push('前端小智')
console.log(family)
// <- { people: ['张三', '李四', '王五', '赵六', '前端小智'] }

变量生命周期

当引擎处理变量时,它们的生命周期由以下阶段组成:

  1. **声明阶段(Declaration phase)**是在作用域中注册一个变量。

  2. **初始化阶段(Initialization phase)**是分配内存并为作用域中的变量创建绑定。 在此步骤中,变量使用undefined 自动初始化。

  3. **赋值阶段(Assignment phase)**是为初始化的变量赋值。

在这里插入图片描述

注意,在变量生命周期方面,声明阶段与一般意义上的变量声明是不同的。简单来说,JS 引擎将变量声明分为三个阶段:声明阶段初始化阶段赋值阶段

var 变量的生命周期

熟悉生命周期阶段之后,来看看 JavaScript 引擎如何处理var变量。

在这里插入图片描述

如上图所示,假设 JS 引擎遇到一个函数作用域,其中包含var variable语句。 在执行任何语句之前,该变量会在作用域的开头进入声明阶段初始化阶段(步骤1)。 var 变量语句在函数作用域中的位置不影响声明和初始化阶段。

在声明阶段和初始化阶段之后,但在赋值阶段之前,变量的值是undefined 的且可以访问使用。

在赋值阶段variable = 'value',变量被赋值(步骤2)。

** 严格地说,提升是指在函数作用域的开始处声明并初始化一个变量,声明和初始化阶段基本是同时进行的,没有间隙。**

有点抽象,来看看事例:下面的代码创建了一个包含var语句的函数作用域:

function multiplyBySix(number) {
  console.log(six) // => undefined
  var six
  six = 6
  console.log(six) // => 6
  return number * six
}
multiplyBySix(4) // => 24

当 JS 开始执行multiplyBySix(4)并进入函数作用域时,变量 six 在第一个语句之前就进入声明和初始化阶段。因此,当调用 console.log(six) 时,值为 undefined,通过语句six = 6,赋值之后,console.log(six) 的值为 6

函数声明生命周期

在函数声明语句function funName() {...}的情况下,它比变量声明生命周期更简单。

在这里插入图片描述

声明、初始化和赋值阶段同时发生函数作用域的开头(只有一步)。可以在作用域的任何位置调用funName(),而不依赖于声明语句的位置(甚至可以在末尾调用)。

下面示例演示了函数提升:

function sumArray(array) {
  return array.reduce(sum);
  function sum(a, b) {
    return a + b;
  }
}
sumArray([5, 10, 8]); // => 23

当 JavaScript 执行sumArray([5,10,8])时,就进入了sumArray函数作用域。在这个作用域内,在执行任何语句之

前,sum 已经通过了三个阶段:声明、初始化和赋值。所以 array.reduce(sum)可以在 sum(a, b){…}声明之前使用sum

let 变量的生命周期

let 变量的处理方式与var不同,主要区别在于声明和初始化阶段是分开的。

在这里插入图片描述

现在来看看一个另个场景,当 JS 进入一个包含let variable语句的块作用域时。变量立即通过声明阶段,在作用域中注册其名称(步骤1)。

接着 JS 引荐继续逐行解析块语句。

如果在声明阶段(与变量声明是不同)尝试访问 variable,JS 将抛出 ReferenceError: variable is not defined。这是因为变量状态未初始化,variable位于 临时死区 TDZ

当执行到语句let variable时,variable进入初始化阶段(步骤2),退出临时死区

接着,当执行赋值语句variable = 'value'时,进入赋值阶段(步骤3)。

如果 JS 遇到let variable = 'value',就会同时进入初始化和赋值阶段。

来看看事例:

let condition = true;
if (condition) {
  // console.log(number); // => Throws ReferenceError
  let number;
  console.log(number); // => undefined
  number = 5;
  console.log(number); // => 5
}

当 JS 进入 if (condition) {...} 块作用域,number 通过声明阶段。

由于 number 位于未初始化状态,并且位于暂时死区中,因此访问该变量将引发ReferenceError: number is not defined

接着,执行到 let number 进入初始化状态。现在可以访问变量,但是它的值是undefined

同理执行赋值语句number = 5 则进入赋值阶段。

constclass 类型与let具有相同的生命周期,只是分配只能发生一次。

提升在 let 生命周期中无效的原因

如上所述,提升是变量在作用域顶部 耦合声明和初始化阶段。然而,let生命周期分离声明和初始化阶段。解耦消除了let的提升期限,这两个阶段之间的间隙产生了临时死区,在这里变量不能被访问。

受临时死区(TDZ) 影响的声明

咱们知道 let 和 const 受临时死区的影响,除了这俩兄弟,还有几个也受 临时死区 影响,如下:

class 的声明

// 无法工作
const myNissan = new Car('red'); // throws `ReferenceError`

class Car {
  constructor(color) {
    this.color = color;
  }
}

构造函数内部的 super()

如果在构造函数中调用 super()之前扩展父类,则此绑定位于 TDZ 中。

class MuscleCar extends Car {
  constructor(color, power) {
    this.power = power;
    super(color);
  }
}

// Does not work!
const myCar = new MuscleCar('blue', '300HP'); // `ReferenceError`

在构造 constructor() 中,在调用super()之前不能使用 this

TDZ 建议调用父构造函数来初始化实例。这样做之后,实例就准备好了,就可以在子构造函数中进行调整。

class MuscleCar extends Car {
  constructor(color, power) {
    super(color);
    this.power = power;
  }
}

// Works!
const myCar = new MuscleCar('blue', '300HP');
myCar.power; // => '300HP'

默认函数参数

默认参数存在于一个中间作用域中,与全局作用域和函数作用域分离。默认参数也遵循 TDZ 限制。

const a = 2;
function square(a = a) {
  return a * a;
}
square(); // throws `ReferenceError`

在声明表达式 a = a之前,在表达式的右侧使用参数 a,这会报 a 的引用错误。

确保在声明和初始化之后使用默认参数。 咱们可以使用一个特殊的变量 init,该变量在使用前已初始化:

const init = 2;
function square(a = init) {
  return a * a;
}
square(); // => 4

TDZ 中的 typeof 行为

typeof 操作符用于确定是否在当前作用域内定义了变量。

例如,未定义变量 notDefined,对该变量应用 typeof 操作符不会引发错误:

typeof notDefined; // => 'undefined'

因为变量没有定义,所以 typeof notDefined 的值为 undefined

但是 typeof 操作符在与临时死区中的变量一起使用时具有不同的行为。如下所示,会抛出一个错误:


typeof variable; // throws `ReferenceError`

let variable;

总结

使用var声明变量很容易出错。在此基础上,ES6 引入了let。它使用一种改进的算法来声明变量,并附加了块作用域。

由于声明和初始化阶段是解耦的,提升对于let变量(包括constclass)无效。在初始化之前,变量处于暂时死区,不能访问。

为了保持变量声明的流畅性,建议使用以下技巧:

  • 声明、初始化然后在使用变量
  • 尽量避免使用 var 声明变量

番外

如何理解 let x = x 报错之后,再次 let x 依然会报错?

在这里插入图片描述

这个问题说明:如果 let x 的初始化过程失败了,那么:

  1. x 变量就将永远处于创建状态。

  2. 无法再次对 x 进行初始化(初始化只有一次机会,而那次机会咱失败了)。

  3. 由于 x 无法被初始化,所以 x 永远处在暂时死区

  4. 有人会觉得 JS 坑,怎么能出现这种情况;其实问题不大,因为此时代码已经报错了,后面的代码想执行也没机会。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@大迁世界

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值