文章目录
- let 声明
- let 是如何工作的?
- 暂时性死区( TDZ )
- const 声明
- 变量生命周期
- var 变量的生命周期
- 函数声明生命周期
- 受临时死区(TDZ) 影响的声明
- TDZ 中的 typeof 行为
- 总结
- 番外
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 是如何工作的?
let
是var
的替代方案,它遵循块作用域规则,而不是默认的函数作用域规则。这意味着使用简单的{}
块就可以得到的作用域,
而不需要使用函数声明。
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 声明
const
与let
非常相似:
-
const
的作用域与let命令相同:只在声明所在的块级作用域内有效。 -
const
命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。 -
const
声明的常量,也与let
一样不可重复声明。
还有几个主要的不同之处:
-
const
声明一个只读的常量。一旦声明,常量的值就不能改变。 -
const
声明的变量不得改变值,这意味着,const
一旦声明变量,就必须立即初始化,不能留到以后赋值。
接着咱们来看看一些例子。 首先,下面示例说明了const
和 let
一样遵循块作用域规则。
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: ['张三', '李四', '王五', '赵六', '前端小智'] }
变量生命周期
当引擎处理变量时,它们的生命周期由以下阶段组成:
-
**声明阶段(Declaration phase)**是在作用域中注册一个变量。
-
**初始化阶段(Initialization phase)**是分配内存并为作用域中的变量创建绑定。 在此步骤中,变量使用
undefined
自动初始化。 -
**赋值阶段(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
则进入赋值阶段。
const
和class
类型与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
变量(包括const
和class
)无效。在初始化之前,变量处于暂时死区,不能访问。
为了保持变量声明的流畅性,建议使用以下技巧:
- 声明、初始化然后在使用变量
- 尽量避免使用
var
声明变量
番外
如何理解 let x = x
报错之后,再次 let x
依然会报错?
这个问题说明:如果 let x
的初始化过程失败了,那么:
-
x
变量就将永远处于创建状态。 -
无法再次对
x
进行初始化(初始化只有一次机会,而那次机会咱失败了)。 -
由于
x
无法被初始化,所以x
永远处在暂时死区 -
有人会觉得 JS 坑,怎么能出现这种情况;其实问题不大,因为此时代码已经报错了,后面的代码想执行也没机会。