努力让学习成为一种习惯,自信来源于充分的准备。
如果你觉得该文章对你有帮助,欢迎大家点赞、关注和分享。
前言
这是js基础巩固系列文章的第一篇,旨在帮助自己巩固js相关知识,同时也希望能给大家带来些新的认识,如有疑问出入,欢迎评论区一起讨论交流
一道基本面试题引发的思考
说说看var、let、const这三者区别
这是一道前端入门级别的八股文面试题,相信大家都能够轻松解答
var
存在变量提升,let、const
则不存在(我个人认为let、const
也是存在变量提升的,具体后续会聊到)let、const
支持块级作用域,存在暂时性死区的现象。且在同一作用域
下,均不可重复声明。const
在声明的时候需要初始化且对值的引用
不可变- 在全局作用域下,通过
var
声明的变量会自动挂载到全局对象上,而let、const
不存在
上述回答存在几个关键概念:块级作用域、 变量提升 、暂时性死区
这几个概念相信大家也都不陌生,这里大家可以稍微花一首歌的时间思考下面几点:
- 为什么存在变量提升
- 为什么要引入
let
、const
,它们是怎么支持块级作用域的 - 暂时性死区触发的原因是什么,为什么会引入这么一个概念
知其然更要知其所以然,一个概念、方案的引入一定是为了解决某个问题
如果上述思考点你存在一些疑惑🤔,继续往下看,会解答你的疑惑
如果你了熟于心,不妨也看看,温故而知新🧐
js语言最初设计遗留的缺陷
Javascript的设计,其实只用了十天(Js的作者也算是天赋拉满,10天设计出一门语言)。而且,设计师是为了向公司交差,本人并不愿意这样设计。可能本人也完全没有想到javascript会发展到如今的规模。由于太过仓促,埋了不少雷💥(也可能是权衡之后的取舍)
这里直接给出相关链接:javascript诞生记、javascript的十个设计缺陷
感兴趣的小伙伴可以阅读,这里就不展开讨论了
我们重点讨论下 var
的设计缺陷
var的设计缺陷
相信前端小伙伴们都曾经对下面的代码感到困惑
foo() // 函数foo被被执行
console.log(myname) // undefined
var myname = 'lxy'
function foo() {
console.log('函数foo被执行');
}
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i); // 输出5个5
}, 0);
}
console.log(i); // 5
显然,上面代码的输入预期与常识不符:
- 函数和变量在声明之前就可以使用操作了
- for循环代码块执行完成,里面声明的变量没有被销毁,在外部仍然可以被访问到
导致这种视觉差异
的原因是var在设计之初的缺陷
接下来我们来具体看看下var在设计之初存在哪些缺陷
以及是怎么解决的
变量提升
首先我们来看看mdn官网对变量提升的定义
变量提升(Hoisting)被认为是,Javascript 中执行上下文(特别是创建和执行阶段)工作方式的一种认识,从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中
由上述概念我们可以得出一个关键信息:js代码在执行之前需要进行编译,变量和函数的声明在编译阶段会被放在内存中
编译
在这里需要引出一个重要的概念:执行上下文
简单来说执行上下文是js执行一段代码时的运行环境。当js执行一段可执行代码时,会创建对应的执行上下文
执行上下文有三种类型:
- 全局执行上下文
- 函数执行上下文
- eval 产生的执行上下文
每一个对应的执行上下文(EC
)都包含三个属性(ES3版本
):
- this
- 变量对象(
VO
) - 作用域链
javascript执行一段代码的细化图如下:
从上图可以看出:一段js代码编译后会生成执行上下文和可执行代码两部分
执行上下文更多的细节在后续文章会详细深入介绍。本文不做其他拓展。这里我们重点关注执行上下文中的变量对象
变量对象:执行上下文相关的数据作用域,保存了编译阶段当前上下文中通过var关键字的变量声明与函数声明,其中变量会有一个默认值 undefined
这时候有些小伙伴们可能会提出:既然变量对象属于执行上下文的一部分,那么执行上下文的不同是否对应的变量对象也会有些区别。答案是对的!
在全局执行上下文中, 变量对象就是
window
其数据结构可以用如下伪代码描述:
GOLABEL_EC = {
this: window,
VO: {
myname: undefined,
foo: function() {console.log(‘函数foo被执行’)}
},
[[scope]]: Local scope // 这里即是window
}
在函数执行上下文中,将会用活动对象
AO
作为变量对象VO
,AO
最初只有一个定义变量arguments
(在全局执行上下文中却不存在
)本质上,
VO
与AO
是同一个东西。VO
无法通 过 js访问。在进入一个执行上下文的时候(此时真正的js代码还没有执行
),VO
会被激活成AO
。激活后,对应的属性才能够被访问
其数据结构可以用如下伪代码描述:
FUNC_EC = {
this: Local Scope[this],
arguments: Arguments,
VO: {
myname: undefined,
foo: function() {console.log(‘函数foo被执行’)}
},
[[scope]]: Local scope + Closure scope + parent scope
}
这里有几点需要额外注意下:
- 使用函数表达式的方式:var foo = funtion(){...},在 VO中foo为undefined,此时如果提前调用会报错
- 如果函数与变量声明的标识符一样,函数声明的优先级更高,不会被变量声明覆盖。但是会被变量赋值覆盖,如下:
function y(){}
var y = 1
console.log('y :>> ', y); // 1
好了,通过上面的介绍,我们明白了变量提升的本质:js执行一段代码需要先编译,编译的过程中会生成对应的执行上下文,变量和函数的声明会保存到其中的变量对象中,代码执行的时候会从当前执行上下文的变量对象逐层往上(词法作用域规则)寻找变量和函数,直到全局执行上下文
讲到这里结合之前的代码例子的输出,我们可以罗列出var
的缺陷了
- 受变量提升的影响,容易出现
变量污染
、变量覆盖
、代码层面不好理解
等问题 - 不支持
块级作用域
,很容易在不知情的情况下声明全局变量
,本应销毁的变量没有被销毁
为了解决这个设计缺陷,es6引入支持块级作用域
的关键字let
、const
块级作用域
我们都知道,var
是不支持块级作用域的,
引入 MDN官方的描述
var
语句用于声明一个函数作用域或全局作用域的变量,并且可以选择将其初始化为一个值
用 var
声明的变量的作用域是最靠近并包含 var
语句的以下花括号闭合语法结构的一个:
- 函数主体
- 类静态初始化块 (en-US)
如果不是以上这些情况则是:
- 当前模块,如果代码以模块模式运行
- 全局作用域,如果代码以脚本模式运行
小伙伴们比较陌生的可能是类静态初始化块
,这里直接引用mdn的例子:
var y = "Outer y";
class A {
static field = "Inner y";
static {
var y = this.field;
}
}
// var defined in static block is not hoisted
console.log(y); // 'Outer y'
The scope of the variables declared inside the static block is local to the block. This includes
var
,function
,const
, andlet
declarations.var
declarations in the block are not hoisted
重要的是,其他块级结构,包括块语句、try...catch
、switch
以及其中一个 for
语句的头部,对于 var
并不创建作用域,而在这样的块内部使用 var
声明的变量仍然可以在块外部被引用
//if块
if(1){}
//while块
while(1){}
//函数块
function foo(){}
//for循环块
for(let i = 0; i<100; i++){}
//单独一个块
{}
// try catch块
try {
}catch(error){}
接下来,我们来看看let
、const
是怎么支持块级作用域、以及它是怎么解决变量提升
设计缺陷带来的问题的
let、const
前面我们提到了执行上下文、变量对象等概念,明白了通过var
声明的变量会作为变量对象的属性添加进去。并有个默认值undefined
ES5
规范又对 ES3
中执行上下文的部分概念做了调整,最主要的调整,就是去除了 ES3
中变量对象和活动对象,以 词法环境组件( LexicalEnvironment component
) 和 变量环境组件( VariableEnvironment component
) 替代
在这里不展开介绍具体相关细节了,后面会单独写一篇关于执行上下文的文章。等不及的小伙伴可以自行搜索资料了解,阅读规范或者看文末的参考链接
现在,我们需要明白通过var
声明的变量会存放到变量环境组件中,而通过let
、const
声明的变量、函数
声明会存放到词法环境组件中
举个例子:
func()
function func() {
var outer1 = 'outer1'
let outer2 = 'outer2'
{
let inner1 = 'inner1'
const inner2 = 'inner2'
var inner3 = 'inner3'
{
let innerinner1 = 'innerinner1'
const innerinner2 = 'innerinner2'
var innerinner3 = 'innerinner3'
}
}
}
这里我们打一个断点观察下:
我们可以发现:
- 通过
var
声明的innerinner3
并没有在块级作用域中,并且值为undefined
- 通过
let
、const
声明的变量innerinner1
、innerinner2
在块级作用域中,并且值不可使用
,直到执行到声明语句的时候 - 如果遇到
代码块嵌套
的情况,则会产生多个块级作用域,在词法环境中以栈
的形式管理
我们可以简单的画一张图描述整个func函数执行上下文
中变量的存放情况
当一段代码块执行完成,对应代码块里的变量便会从栈顶弹出
咦,第二点不就是暂时性死区的原因嘛
我们来看看暂时性死区的官方描述
用
let
、const
或class
声明的变量可以称其从代码块的开始一直到代码执行到变量声明的位置并被初始化前,都处于一个“暂时性死区”(Temporal dead zone,TDZ)中。当变量处于暂时性死区之中时,其尚未被初始化,并且任何访问其的尝试都将导致抛出
ReferenceError
。当代码执行到变量被声明的位置时,变量会被初始化为一个值。如果变量声明中未指定初始值,则变量将被初始化为undefined
。这与
var
声明的变量不同,如果在声明位置前访问var
声明的变量会返回undefined
。以下代码演示了在声明位置前访问let
和var
声明的变量的不同结果。
{
// 暂时性死区始于作用域开头
console.log(bar); // "undefined"
console.log(foo); // ReferenceError: Cannot access 'foo' before initialization
var bar = 1;
let foo = 2; // 暂时性死区结束(对 foo 而言)
}
到这里,相信大家对文章开头的问题已经有了清晰的认识了:
- 为什么存在变量提升
- 为什么要引入let、const,它们是怎么支持块级作用域的
- 暂时性死区触发的原因是什么,为什么会引入这么一个概念
我们可以做一个总结:
- 变量提升的原因是因为js执行代码前需要先进行
编译
,它是通过变量环境实现的。 - 引入支持块级作用域关键字
let
、const
是为了避免var
由于设计缺陷在变量提升
的作用下,引发的一系列问题:不经意的情况定义全局变量
,污染变量
、覆盖变量
等。让代码执行更加的规范以及符合我们的常识:代码块执行完了里面的变量应该被立刻销毁
。外部无法访问内部代码块的变量
,块级作用域是通过词法环境实现的,每一个块级作用域
内的变量都会在当前执行上下文
中的词法组件
以栈
的形式保存下来。每当一个块级作用域
代码执行完成。便会从栈顶
弹出 let
、const
也存在变量提升
的情况, 但是对它们做了一道限制:没有默认值 undefined。直到代码执行到声明语句之后,才可以对其使用。代码块开头到声明语句前的这段区域为暂时性死区
,在暂时性死区
内使用变量,会有ReferenceError
错误。解决了var
在声明前就可以使用,给人带来困扰、难以理解的问题
所以引出的let
、const
、暂时性死区
等概念其实都是为了填坑- -||
发现一个疑惑🤔: 按照mdn官方描述
let
声明的变量不能被同一个作用域中的任何其他声明重复声明在同一作用域中,
const
声明不能被任何其他声明重新声明
但是我尝试下面代码会有报错,理论上不应该有的(一个在块级作用域、一个在全局作用域)
{
let x = 1
var x = 1
}
结语
最后给大家留一到思考题(下面代码执行结果是怎么样的)
{
a = 1
function a() {}
a = 2
console.log(a)
}
console.log(a)
到这里,就是本篇文章的全部内容了
如果你觉得该文章对你有帮助,欢迎大家点赞、关注和分享。
如果你有疑问或者出入,评论区告诉我,我们一起讨论。