【js基础巩固计划】你真的理解变量提升吗

565 阅读13分钟

努力让学习成为一种习惯,自信来源于充分的准备。

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

这是js基础巩固系列文章的第一篇,旨在帮助自己巩固js相关知识,同时也希望能给大家带来些新的认识,如有疑问出入,欢迎评论区一起讨论交流

一道基本面试题引发的思考

说说看var、let、const这三者区别

这是一道前端入门级别的八股文面试题,相信大家都能够轻松解答

  1. var存在变量提升,let、const则不存在(我个人认为let、const也是存在变量提升的,具体后续会聊到)
  2. let、const支持块级作用域,存在暂时性死区的现象。且在同一作用域下,均不可重复声明。const在声明的时候需要初始化且对值的引用不可变
  3. 在全局作用域下,通过var声明的变量会自动挂载到全局对象上,而let、const不存在

上述回答存在几个关键概念:块级作用域变量提升暂时性死区

这几个概念相信大家也都不陌生,这里大家可以稍微花一首歌的时间思考下面几点:

  1. 为什么存在变量提升
  2. 为什么要引入letconst,它们是怎么支持块级作用域
  3. 暂时性死区触发的原因是什么,为什么会引入这么一个概念

知其然更要知其所以然,一个概念、方案的引入一定是为了解决某个问题

如果上述思考点你存在一些疑惑🤔,继续往下看,会解答你的疑惑

如果你了熟于心,不妨也看看,温故而知新🧐

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

显然,上面代码的输入预期与常识不符:

  1. 函数和变量在声明之前就可以使用操作了
  2. for循环代码块执行完成,里面声明的变量没有被销毁,在外部仍然可以被访问到

导致这种视觉差异的原因是var在设计之初的缺陷

接下来我们来具体看看下var在设计之初存在哪些缺陷以及是怎么解决的

变量提升

首先我们来看看mdn官网变量提升的定义

变量提升(Hoisting)被认为是,Javascript 中执行上下文(特别是创建和执行阶段)工作方式的一种认识,从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中

由上述概念我们可以得出一个关键信息:js代码在执行之前需要进行编译,变量和函数的声明在编译阶段会被放在内存中

编译

在这里需要引出一个重要的概念:执行上下文

简单来说执行上下文是js执行一段代码时的运行环境。当js执行一段可执行代码时,会创建对应的执行上下文

执行上下文有三种类型:

  1. 全局执行上下文
  2. 函数执行上下文
  3. eval 产生的执行上下文

每一个对应的执行上下文(EC)都包含三个属性(ES3版本):

  1. this
  2. 变量对象(VO
  3. 作用域链

javascript执行一段代码的细化图如下:

变量提升 (2).jpg

从上图可以看出:一段js代码编译后会生成执行上下文可执行代码两部分

执行上下文更多的细节在后续文章会详细深入介绍。本文不做其他拓展。这里我们重点关注执行上下文中的变量对象

变量对象:执行上下文相关的数据作用域,保存了编译阶段当前上下文中通过var关键字的变量声明与函数声明,其中变量会有一个默认值 undefined

这时候有些小伙伴们可能会提出:既然变量对象属于执行上下文的一部分,那么执行上下文的不同是否对应的变量对象也会有些区别。答案是对的!

在全局执行上下文中, 变量对象就是window

其数据结构可以用如下伪代码描述:

GOLABEL_EC = {
  this: window,
  VO: {
    myname: undefined,
    foo: function() {console.log(‘函数foo被执行’)}
  },
  [[scope]]: Local scope // 这里即是window 
}

在函数执行上下文中,将会用活动对象AO作为变量对象VOAO最初只有一个定义变量arguments(在全局执行上下文中却不存在)

本质上,VOAO是同一个东西。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
}

这里有几点需要额外注意下:

  1. 使用函数表达式的方式:var foo = funtion(){...},在 VO中foo为undefined,此时如果提前调用会报错
  2. 如果函数与变量声明的标识符一样,函数声明的优先级更高,不会被变量声明覆盖。但是会被变量赋值覆盖,如下:
function y(){}
var y = 1
console.log('y :>> ', y); // 1

好了,通过上面的介绍,我们明白了变量提升的本质:js执行一段代码需要先编译,编译的过程中会生成对应的执行上下文,变量和函数的声明会保存到其中的变量对象中,代码执行的时候会从当前执行上下文的变量对象逐层往上(词法作用域规则)寻找变量和函数,直到全局执行上下文

讲到这里结合之前的代码例子的输出,我们可以罗列出var的缺陷了

  1. 变量提升的影响,容易出现变量污染变量覆盖代码层面不好理解等问题
  2. 不支持块级作用域很容易在不知情的情况下声明全局变量本应销毁的变量没有被销毁

为了解决这个设计缺陷,es6引入支持块级作用域的关键字letconst

块级作用域

我们都知道,var是不支持块级作用域的, 引入 MDN官方的描述

var语句用于声明一个函数作用域全局作用域的变量,并且可以选择将其初始化为一个值

用 var 声明的变量的作用域是最靠近并包含 var 语句的以下花括号闭合语法结构的一个:

如果不是以上这些情况则是:

  • 当前模块,如果代码以模块模式运行
  • 全局作用域,如果代码以脚本模式运行

小伙伴们比较陌生的可能是类静态初始化块,这里直接引用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 varfunctionconst, and let declarations. var declarations in the block are not hoisted

重要的是,其他块级结构,包括块语句try...catchswitch 以及其中一个 for 语句的头部,对于 var 并不创建作用域,而在这样的块内部使用 var 声明的变量仍然可以在块外部被引用

//if块
if(1){}

//while块
while(1){}

//函数块
function foo(){}
 
//for循环块
for(let i = 0; i<100; i++){}

//单独一个块
{}

// try catch块
try {
}catch(error){}

接下来,我们来看看letconst是怎么支持块级作用域、以及它是怎么解决变量提升设计缺陷带来的问题的

let、const

前面我们提到了执行上下文变量对象等概念,明白了通过var声明的变量会作为变量对象的属性添加进去。并有个默认值undefined

ES5 规范又对 ES3 中执行上下文的部分概念做了调整,最主要的调整,就是去除了 ES3 中变量对象和活动对象,以 词法环境组件( LexicalEnvironment component变量环境组件( VariableEnvironment component 替代

在这里不展开介绍具体相关细节了,后面会单独写一篇关于执行上下文的文章。等不及的小伙伴可以自行搜索资料了解,阅读规范或者看文末的参考链接

现在,我们需要明白通过var声明的变量会存放到变量环境组件中,而通过letconst声明的变量、函数声明会存放到词法环境组件

举个例子:

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'
    }
  }
}

这里我们打一个断点观察下:

image.png

image.png

我们可以发现:

  1. 通过var声明的innerinner3并没有在块级作用域中,并且值为undefined
  2. 通过letconst声明的变量innerinner1innerinner2块级作用域中,并且值不可使用,直到执行到声明语句的时候
  3. 如果遇到代码块嵌套的情况,则会产生多个块级作用域,在词法环境中以的形式管理

我们可以简单的画一张图描述整个func函数执行上下文中变量的存放情况

词法环境.jpg 当一段代码块执行完成,对应代码块里的变量便会从栈顶弹出

咦,第二点不就是暂时性死区的原因嘛

我们来看看暂时性死区的官方描述

用 letconst 或 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 而言)
}

到这里,相信大家对文章开头的问题已经有了清晰的认识了:

  1. 为什么存在变量提升
  2. 为什么要引入let、const,它们是怎么支持块级作用域
  3. 暂时性死区触发的原因是什么,为什么会引入这么一个概念

我们可以做一个总结:

  1. 变量提升的原因是因为js执行代码前需要先进行编译,它是通过变量环境实现的。
  2. 引入支持块级作用域关键字letconst是为了避免var由于设计缺陷在变量提升的作用下,引发的一系列问题:不经意的情况定义全局变量污染变量覆盖变量等。让代码执行更加的规范以及符合我们的常识:代码块执行完了里面的变量应该被立刻销毁外部无法访问内部代码块的变量块级作用域是通过词法环境实现的,每一个块级作用域内的变量都会在当前执行上下文中的词法组件的形式保存下来。每当一个块级作用域代码执行完成。便会从栈顶弹出
  3. letconst也存在变量提升的情况, 但是对它们做了一道限制:没有默认值 undefined。直到代码执行到声明语句之后,才可以对其使用。代码块开头到声明语句前的这段区域为暂时性死区,在暂时性死区内使用变量,会有ReferenceError错误。解决了var在声明前就可以使用,给人带来困扰、难以理解的问题

所以引出的letconst暂时性死区等概念其实都是为了填坑- -||

发现一个疑惑🤔: 按照mdn官方描述

let 声明的变量不能被同一个作用域中的任何其他声明重复声明

在同一作用域中,const 声明不能被任何其他声明重新声明

var 不能与 letconstclass 或 import 在同一作用域中声明同名变量

但是我尝试下面代码会有报错,理论上不应该有的(一个在块级作用域、一个在全局作用域)

{
  let x = 1
  var x = 1
}

image.png

结语

最后给大家留一到思考题(下面代码执行结果是怎么样的)

{
    a = 1
    function a() {}
    a = 2
    console.log(a)
}
console.log(a)

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论。

参考文章

JavaScript深入之变量对象

面试官:说说执行上下文吧