-
在之前系列章节中,我们都是采用var来声明变量,但从上一章节开始,我们开始使用let和const来声明
-
可以明确的说,var声明变量已经是过去式了,从现在开始,往后的例子(个别除外)都会使用let和const来进行
-
但var的学习使用并非没有意义,没有var在前,我们很难理解let和const为什么会诞生
-
并且伴随着let和const的清晰划分变量,同时影响到了和声明变量有关的其他内容,比如块级作用域,比如暂时性死区,亦或者新的ECMA代码执行描述
-
-
在本章节中,我们都会学习到,而非只是简单使用
一、let/const基本使用
-
在ES5中我们声明变量都是使用的var(variable)关键字,从ES6开始新增了两个关键字可以声明变量:let、const
-
let、const在其他编程语言中都是有的,所以也并不是新鲜的关键字
-
但是let、const确确实实给JavaScript带来一些不一样的东西
-
-
从使用角度来说,只是在原有基础上换一个名字而已,使用的位置和方式是一样的
var name = 'xiaoyu'
let name = 'xiaoyu'
const name = 'xiaoyu'
-
let和const的出现,其实是顺延了ES6所想表达的思想,在这里主要表达为消除二义性、提高可读性,以及弥补ES5及之前的缺陷问题
-
从直观的角度来说,let和var是没有太大的区别的,都是用于声明一个变量
-
而const关键字是
constant单词
的缩写,表示常量、衡量
的意思,意味着保存的数据一旦被赋值,就不能被修改,但是如果赋值的是引用类型
,那么可以通过引用找到对应的对象,修改对象的内容
-
-
let、const与var最大的共同区别在于前两者不允许重复声明变量
-
例如我声明一个Names变量,第一次声明的同时,我进行赋值
['xiaoyu','coderwhy']
,那Names变量所表达的含义是所有具体名字
,但在后面我又声明了一遍Names,我赋值为2,这次的内容变为了数字,表达一共有几个名字
。同样的变量名导致了不同的含义 -
在开发的时候,未必不会出现这种情况,并且在越大型的合作项目中,越容易出现该问题,因为不清楚该变量名是否已经被同事所使用,会造成不便与排查难度提升,提早的发现有助于排除隐患
-
进行单纯的赋值操作,只能够说明该变量的作用性质已经决定了,我们改变内容并不会影响其使用的本质
-
这一点其实很重要,使用var可以重复声明变量其实是一个早期的缺陷,这并不能算作特性
-
每一个变量都有属于自己的作用,我们不会莫名其妙声明一个意义不明的变量出来。而声明变量和修改变量,是两个完全不同的操作,也代表了两种不同的思路走向
-
当进行变量声明(let)时,会确定该变量的作用性质。一旦在不注意的情况下声明相同变量,两个变量可能表达的含义很可能完全不同:
-
-
因此声明和赋值所具备的含义是可以进行拆分使用的,只是大多数情况我们会合并使用
-
但有些情况,也会提前进行声明占位,确定
变量/常量名
的性质,等到后续在进行使用
-
//声明
let name
//赋值
name = 'xiaoyu'
// 重复声明报错
let name
-
常量const有一个容易令人误会的地方,很多人往往会认为放在常量里的数据是不可改变的,但事实并非如此,const本质上并不阻止对内容的修改。想要理解 const 的底层实现的话,更需要关注对引用的保护机制而不是对数据本身的保护
-
在一开始的时候,我们学习过使用var声明时,在内存中的表达形式,常量在这方面是类似的
-
数据在JS中分简单数据和复杂数据,前者放在栈中,后者放在堆中
-
常量所做的事情是锁住栈内容,在这种情况下,如果是简单数据类型,本身数据就在栈中无法改动。如果是复杂数据类型,则存储在栈中的
内存地址 如0xb00
将无法改变,这个表达形式为引用无法改变,但const常量并不对堆内容进行封锁,导致位于堆位置的内容是可以改变的
-
-
因此在使用常量来存储复杂数据类型,例如:Object、Array、Function、Date、RegExp(正则表达式)、Map、Set的时候,需要谨慎使用
const person = {name:'xiaoyu'}
person.name = 'coderwhy'
console.log(person);//{ name: 'coderwhy' }
//其他复杂数据类型例如Set Map 后续会学习到
const set = new Set([1, 2, 3]);
set.add(4); // 可以:添加新元素
set.delete(1); // 可以:删除元素
console.log(set); // Set(3) { 2, 3, 4 }
1.1 作用域提升
-
新的声明方式在替代var的时候,同时也改变了其内在声明逻辑,这和语法糖是完全不同的
-
var在进行声明的时候,作用域会进行提升到全局,而赋值不会。导致var一旦声明就能够在所有地方进行使用,哪怕在声明之前使用也可以
-
在这种情况下,声明之前进行调用就会产生默认结果undefined,直到运行到赋值的位置,变量值才发生对应的改变
-
-
这种方式其实是不合理的,因为在变量不存在的情况(未声明),是直接报错
-
报错通常是:ReferenceError: 变量 is not defined,没有引用到对应的变量名而报错
-
而var声明变量之前,变量其实因为作用域提升因素,导致可访问,容易使人误会该变量已经可以使用,但使用的位置如果在赋值之前,就会因为调用JS引擎赋予变量的默认值undefined而报错
-
因此造成错误的原因会不明确,不清晰,从而造成误导、不必要的排查
-
console.log(person.foo());//undefined.foo() 调用失败 TypeError: Cannot read properties of undefined (reading 'foo')
var person = {
foo(){
console.log('coderwhy');
}
}
-
在优化后的let身上,如果在声明之前的位置进行使用,会报准确的错误(无法在初始化之前访问person)
console.log(person.foo());//ReferenceError: Cannot access 'person' before initialization
//let与const的效果在这里一样
let person = {
foo(){
console.log('coderwhy');
}
}
-
所以,从效果来看,let/const是没有作用域提升的,但这句话在社区是有争议的
-
没有作用域的提升,是否意味着person变量只有在代码执行阶段才会创建?
-
事实上并不是这样的,我们可以看一下 ECMA262 对 let 和 const 的描述
-
这些变量会被创建在包含他们的词法环境被实例化时,但是是不可以访问它们的,直到词法绑定被求值
-
图20-1 ECMA262 对 let 和 const 的描述
-
这要如何理解?我们需要抽离一下关键信息:
创建
与访问
-
let/const被划分为两个阶段,从该内容所暴露出来的信息,可以确定
创建阶段
是不变的 -
关键的改变在于
访问阶段
:词法绑定被求值之前(赋值过程之前),无法访问
-
-
这么看来,在执行上下文的词法环境创建出来的时候,变量事实上已经被创建了,只是这个变量是不能被访问的
-
那么变量已经有了,但是不能被访问,是不是一种作用域的提升呢?
-
-
事实上维基百科并没有对作用域提升有严格的概念解释,那么我们自己从字面量上理解
-
作用域提升:在声明变量的作用域中,如果这个变量可以在声明之前被访问,那么我们可以称之为作用域提升
-
在这里,它虽然被创建出来了,但是不能被访问,我认为不能称之为作用域提升
-
所以我的观点是let、const没有进行作用域提升,但是会在执行上下文创建阶段被创建出来
-
二、let/const与window关系
-
在使用var的时候,我们曾说过其中的一个全局变量弊端,var声明的变量会存储在window中,导致在全局能访问,也能被修改,这也是被称为全局变量的原因
-
这其实是一个
变量存储位置
的问题,内容存放的位置,取决了哪些地方可以访问到这些变量 -
我们知道,在全局通过var来声明一个变量,事实上会在window上添加一个属性,window就是var的变量存储位置,所处位置是导致全局可访问可修改的根源
-
而let/const解决了"全局变量"的问题,自然这两者是不会给window上添加任何属性的
-
var names = 'xiaoyu'
console.log(window.names);//xiaoyu
-
那么我们可能会想这个let/const声明的变量是保存在哪里呢?
-
这就需要先回顾一下最新的ECMA标准中对执行上下文的描述
-
图20-2 ECMA标准中对执行上下文的描述
-
在ES6之后的标准当中,对VO的称呼也有了一定的变化
-
在之前学习执行JavaScript代码执行过程中,我们学习了很多ECMA文档的术语:
-
执行上下文栈:Execution Context Stack,用于执行上下文的栈结构
-
执行上下文:Execution Context,代码在执行之前会先创建对应的执行上下文
-
变量对象:Variable Object,上下文关联的VO对象,用于记录函数和变量声明
-
全局对象:Global Object,全局执行上下文关联的VO对象
-
激活对象:Activation Object,函数执行上下文关联的VO对象
-
作用域链:scope chain,作用域链,用于关联指向上下文的变量查找
-
图20-3 ECMA标准中对变量环境的描述
-
但在新的ECMA代码执行描述中(ES5以及之上),对于代码的执行流程描述改成了另外的一些词汇:
-
基本思路是相同的,只是对于一些词汇的描述发生了改变
-
执行上下文栈和执行上下文也是相同的
-
-
所以在ES6的标准中,let/const所存储或者说所关联的位置叫做VE(变量环境),仔细了解
变量环境VE
的规则是必要的,这决定了所声明的变量会放到哪里-
window是浏览器实现的,在V8引擎中是没有window的,这也是我们在node环境下打印window会报错
-
如今实际的var声明是放在
variable_
中的,这是一个VariableMap类型,也是一种用C++实现的hashMap结构,且C++也是需要分配内存来实现存储的,所以内容依旧是存储在内存当中的 -
在目前的情况下,虽然使用var仍可以在window中找到,但这是为了兼容性所做出的牺牲取舍,正常情况下,这是不应该存在的
-
var在变量存储位置进行存放时,会同步在window进行操作,但也说明了在ES6中,window和variable_已经不是同一个对象了
-
图20-4 var声明存放处
图20-5 变量存储位置的兼容情况
2.1 词法环境(Lexical Environments)
-
词法环境是一种规范类型,用于在词法嵌套结构中定义关联的变量、函数等标识符
-
一个词法环境是由环境记录(Environment Record)和一个外部词法环境(outer Lexical Environment)组成
-
一个词法环境经常用于关联一个函数声明、代码块语句、try-catch语句,当它们的代码被执行时,词法环境被创建出来
-
-
在 JavaScript 中,执行上下文(Execution Context)是一个非常核心的概念,它描述了一个环境,其中 JavaScript 代码被解析和执行。从 ECMAScript 2015(ES6)起,执行上下文的处理确实有所更新,特别是引入了块级作用域变量(通过
let
和const
声明)的概念-
在 ECMAScript 的最新规范中,每个执行上下文包括两个环境:
-
**变量环境 (VE)**:主要用于存储由
var
声明的变量和函数声明。 -
**词法环境 (LE)**:用于存储由
let
和const
声明的变量,以及包含块级作用域的其他信息
-
-
这也是let/const与var主要的不同来源:两个环境都具备类似的结构,但它们的关键区别在于如何处理变量和作用域。
var
声明的变量因为没有块级作用域,所以它们被放在变量环境中。而let
和const
声明的变量具备块级作用域,它们被放在词法环境中,这允许 JS 引擎处理嵌套的块级作用域
表20-1 var、let、const对比总结
特性/声明类型 | var | let | const |
---|---|---|---|
存储位置 | 变量环境 | 词法环境的块级作用域 | 词法环境的块级作用域 |
作用域 | 函数作用域或全局作用域 | 块级作用域 | 块级作用域 |
提升行为 | 变量提升,初始化为 undefined | 无提升,暂时性死区直到声明处 | 无提升,暂时性死区直到声明并初始化 |
-
当 JavaScript 代码执行时:
-
解析器会先通过词法分析过程识别所有的变量和函数声明
-
对于
var
和函数声明,它们会被添加到变量环境中,并在函数执行之前完成初始化(函数完整地、变量为undefined
) -
对于
let
和const
,它们被添加到词法环境中,对应的作用域是它们所在的块级作用域。直到执行到它们的具体声明语句之前,它们都不可用
-
-
这种机制确保
let
和const
提供了比var
更严格和更清晰的作用域管理,减少因变量提前使用导致的潜在问题
2.2 块级作用域
-
在上述词法环境中,我们可以看到let/const的变化有很大程度来自存储的位置,也就是词法环境的块级作用域中
-
在ES5中,JavaScript只会形成两个作用域:全局作用域和函数作用域
-
所谓的块级作用域在这个阶段是形同虚设的,外层依旧可以进行访问,没法产生实际的意义
-
//块级作用域(ES5中不存在)
{
var foo = 'coderwhy'
}
console.log(foo);//可以访问到
-
我们想要说明的是
块级作用域
是和函数作用域
具备类似能力的,或者说后者就是前者的一种表达形式1、内层能够链式访问外层
2、外层无法访问内存
-
这是一个单项不可逆的过程,在ES6中,块级作用域对let/const/function/class声明的类型是有效的
{
let foo = 'coderwhy'
}
console.log(foo);//无法访问
-
但这不是绝对的,比如说我在块级作用域中书写一个函数,在外层能否访问
-
根据规范,块级作用域对function声明的类型是有效的,所以外层是无法访问内层函数的
-
但实际上可以访问,因为不同的浏览器有不同的实现方式,大部分浏览器为了兼容以前的代码,放开该层限制,让function不具备块级作用域
-
-
从而产生了一些奇特的效果,比如在node环境下不可以的操作,在浏览器中就能实现
{
function foo(){
console.log('xiaoyu');
}
}
foo()//浏览器可以访问 node环境不可访问
-
这种操作很容易产生二义性,让人无法正确预估产生结果,所以类似这种有可能令人误解效果的代码应该少进行
-
但我们知道函数声明是有多种方式的,我们可以配合let声明来实现具备块级作用域
-
{
let foo = function(){
console.log('xiaoyu');
}
}
foo()//不管是浏览器还是node环境,都无法访问
-
包括说和
{}
所结合的结构,比如if判断语句、switch循环语句、for循环语句,其内部都是具备块级作用域效果的-
我们在使用变量名的时候,很多情况下不可避免会遇到重复的情况,有一些地方会产生冲突,有些地方不会,由块级作用域明确的冲突界限
-
在不会冲突的地方,不应该让重复变量会产生关联,理解一个变量主要取决于两点:1.变量名本身 2.变量所处环境
-
-
在不同环境下,相同的变量名是不同的含义(例如:初见的笑容是喜悦,离别的笑容是不舍,笑容的释义除了本身的含义还有场景的因素)
-
let变量在解决变量名冲突的时候,是根据第二点的因素去决定的
-
以场景作为切割,会让目的清晰明确
-
if (true) {
let message = "Hello, World!";
console.log(message); // 输出:Hello, World!
}
// console.log(message); // ReferenceError: message is not defined
for (let i = 0; i < 3; i++) {
console.log(i); // 输出:0, 1, 2
}
// console.log(i); // ReferenceError: i is not defined
switch ('coderwhy') {
case 'coderwhy':
let xiaoyu = "Switch Case";
console.log(xiaoyu); // 输出:Switch Case
break;
default:
// 不可访问 'xiaoyu' 变量
break;
}
// console.log(xiaoyu); // ReferenceError: example is not defined
-
在应用场景清晰明确的情况下,会解决很多隐形的bug,例如for循环
-
在使用let的情况下,每一次循环,都是一次独立的块级调用,彼此之间是无法干涉的
-
同时也无法影响到外层的内容,因为遍历的情况非常普遍,遍历的索引通常都为i(item缩写),通俗易懂,也避免的每一次循环都需要重新想变量名的苦恼,但使用var就会影响到之后同索引变量名,产生矛盾冲突及bug
-
const names = ['coderwhy','xiaoyu','JS高级']
for (let i = 0; i < names.length; i++) {
console.log(names[i]);
}
//产生如下效果
// {
// let i = 0
// console.log(names[i])
// }
// {
// let i = 1
// console.log(names[i])
// }
// {
// let i = 2
// console.log(names[i])
// }
-
for循环中,一般采用let,而非const的原因主要是在这的索引一般是递增或者以某种规律进行改变
-
采用const,则无法正常的进行变化
-
但在其他的场景下,也有采用const进行声明的形式,例如
for of
遍历,该遍历的使用目标为可迭代对象
,这一点我们后面还会进行说明 -
从目前的角度来说,
for of
是直接遍历集合的元素而不是索引的方法,所以可以不使用let声明一个变量来记录索引,对于不想要改变的数据此时就可以使用const来进行声明,直接访问元素最直观的就是简化迭代 -
但这不是其最大的作用,最大的作用是ES6引入统一的迭代协议:可迭代协议和迭代器协议,这一点在之后会专门讲解
-
const names = ['coderwhy','xiaoyu','JS高级']
//ES6新增遍历数组(对象)
for(const item of names) {
console.log(item);
}
2.3 暂时性死区
暂时性死区(Temporal Dead Zone,TDZ)是 JavaScript 中与 let
和 const
声明相关的一个行为特征。这个概念主要涉及到变量的生命周期,在变量声明
和初始化
之间存在一个时间段,在这段时间里,变量虽然已经被声明,但还不能被访问或使用
-
通俗的说就是这段访问不了的区域叫做暂时性死区
-
在这块区域中的访问,会返回明确的错误指示:Cannot access 'bar' before initialization,也就是初始化成功之前无法访问
-
这说明我们正处于变量声明到初始化之间的一个阶段,而变量声明前调用会报错未定义、初始化之后则可以正常访问,在该周期中访问有三种不同的结果
-
图20-6 暂时性死区表现
//暂时性死区
function foo(){
console.log("小余");
console.log("测试");
// console.log(bar); 无法访问
let bar = "bar"
let good = "好的"
}
-
使用术语 “temporal” 是因为区域取决于执行顺序(时间),而不是编写代码的位置
-
因此根据变量生命周期,能够正确访问的位置在初始化之后,跟代码位置是无关的
-
虽然在foo函数中存在访问message变量位于初始化之前,但函数本身在调用之前,是可以当做不存在去看待的,对于函数中内容的判定,应该取决于调用位置,而非书写位置,所以当函数调用位置位于变量初始化之后,是不会报错的
-
//暂时性死区和定义的位置没有关系,和代码执行的顺序有关系
function foo(){
console.log(message);
}
let message = "Hello World"
foo()//Hello World,foo函数定义在message的上面,但是依旧可以打印出来内容不报错,因为我们是在let message的后面进行调用的
console.log(message);//Hello World
-
变量声明位于
当前作用域最前面
,初始化位于执行赋值的位置中-
因此foo函数中存在块级作用域,message已经找到了当前作用域内的变量了,哪怕该变量处于初始化阶段,也是存在的,所以变量不会继续往外层作用域去寻找(但如果把foo函数内的声明初始化message变量注释掉,则会继续向外找对应的变量,此时就不会报错)
-
因此调用foo函数才会产生报错的问题,所以在看待变量声明的同时,需要注意当前所处作用域
-
正因此测试,我们可以确定在初始化变量之前,确实存在
暂时性死区
,并确立了扩散的范围(当前作用域最上面)
-
//暂时性死区形成之后,在该区域内这个标识符不能访问
let message = "Hello World"
function foo(){
console.log(message);//能访问到吗?很显然不行,我们在很早之前作用域的时候也做过类似的操作,在控制台打印message,他会先在自己的当前作用域寻找,message其实在这里面已经形成,词法坏境实例化,只是不能访问,message是在内部存在的,这就阻止了控制台打印的message继续向外层作用域寻找(虽然外层作用域有,但是已经在内层就被拦截住了)
let message = "小余"
}
foo()//报错啊啦啦
console.log(message);
三、var-let-const开发中的选择
-
那么在开发中,我们到底应该选择使用哪一种方式来定义我们的变量呢?
-
对于var的使用:
-
我们需要明白一个事实,var所表现出来的特殊性:比如作用域提升、window全局对象、没有块级作用域等都是一些历史遗留问题,这并不是特性而是缺陷,是JS设计之初所遗留的问题
-
当然目前市场上也在利用这种缺陷出一系列的面试题,来考察大家对JavaScript语言本身以及底层的理解
-
但是在实际工作中,我们可以使用最新的规范来编写,也就是不再使用var来定义变量了
-
-
对于let、const:
-
对于let和const来说,是目前开发中推荐使用的
-
我们会优先推荐使用const,这样可以保证数据的安全性不会被随意的篡改,当我们要进行修改变量而无法修改的时候,对应的常量报错会提示我们,后续确立要修改也会很方便,但以let为常态时,我们有可能不知不觉中将不应该修改的内容进行改动
-
所以只有当我们明确知道一个变量后续会需要被重新赋值时,这个时候才使用let
-
这种在很多其他语言里面也都是一种约定俗成的规范,我们尽量也遵守这种规范
-