JavaScript 中,varletconst 是声明变量 / 常量的三种方式,核心差异体现在 作用域提升规则重复声明可修改性 等方面;

核心概念铺垫

作用域

  1. 作用域:是指在程序中定义变量的区域,该位置决定了变量的生命周期 (通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期)

  2. 作用域有如下三种:

    1. 全局作用域:变量在任何地方都能访问,其生命周期伴随着页面的生命周期;
    2. 函数作用域:定义的变量或者函数只能在函数内部被访问;函数执行结束之后,函数内部定义的变量会被销毁;
    3. 块级作用域 (es6):变量仅在声明它的 「块」 ({} 包裹的区域,如 if/for/while、直接的 {}) 内部可访问;

变量提升

  1. 变量提升:JS 引擎在执行代码前,会将变量声明「提升」到其作用域顶部,但赋值操作保留在原位置;

  2. 变量提升所带来的问题:

    1. 变量容易在不被察觉的情况下被覆盖掉
      var myname = "张三"
      function showName(){
          console.log(myname); // undefined
          if(0){
              var myname = "李四"
          }
          console.log(myname); // undefined
      }
      showName();
      
    2. 本应销毁的变量没有被销毁
      function foo(){
          for (var i = 0; i < 7; i++) { }
          console.log(i); // 循环结束之后 i 的值并未被销毁,打印出来 7
      }
      foo()
      

逐一对比解析

var:ES5 声明方式(函数作用域 + 提升 + 可重复声明)

  1. varES5 及更早版本的变量声明方式,设计上存在诸多「反直觉」特性,ES6 引入 let/const 后,已不推荐优先使用;

  2. 核心特性

    特性 说明
    作用域 函数作用域 (忽略块级作用域,如 if/for 的 {} 无法限制 var 范围)
    变量提升 声明提升到作用域顶部,且默认赋值 undefined (「声明 + 初始化」都提升)
    重复声明 允许同一作用域内重复声明同一变量 (后声明覆盖前声明)
    全局声明 全局作用域中用 var 声明的变量,会挂载到 window 对象 (浏览器)
    可修改 / 可重新赋值 支持后续修改值、重新赋值

let:ES6 声明变量(块级作用域 + TDZ + 不可重复声明 + 提升但未初始化)

  1. letES6 为解决 var 的缺陷设计的变量声明方式,优先用于声明需要后续修改的变量;

  2. 核心特性:

    特性 说明
    作用域 块级作用域 ({} 内声明的 let 变量,外部无法访问)
    变量提升 声明提升,但未初始化 (「暂时性死区」TDZ:访问前不可用)
    重复声明 同一作用域内禁止重复声明 (报错:Identifier ‘x’ has already been declared)
    全局声明 全局作用域中用 let 声明的变量,不会挂载到 window 对象
    可修改 / 可重新赋值 支持后续修改值、重新赋值
  3. 关键概念:暂时性死区 (TDZ)let 声明的变量,从作用域开始到变量声明行之间的区域,称为「暂时性死区」,在此区域访问变量会直接报错 (而非 undefined)

    console.log(e); // 报错:ReferenceError: Cannot access 'e' before initialization
    let e = 60; // 声明行是 TDZ 的结束点
    

const:ES6 声明常量(块级作用域 + TDZ + 不可重复声明 + 不可重新赋值)

  1. const 用于声明「常量」,优先用于声明不需要修改的变量 (如配置项、固定值、引用类型的引用)

    1. ES5 中不能直接声明一个常量,如果想声明一个不可修改的变量需要借助 defineProperty 方法;
    2. ES6 为了弥补这方面的缺失,新增了 const 语句用于声明一个常量;
    function setConst(key, value) {
      // 在 window 对象上添加属性,也可以自己定义一个对象进行添加,可以实现局部作用域的效果
      Object.defineProperty(window, key, {
        get: function(){
          return value;
        },
        set: function(){
          console.error('Uncaught TypeError: Assignment to constant variable');
        },
      });
    }
    
    setConst('PI', 3.1415);
    console.log(PI)     // 3.1415
    PI = 3;             // Uncaught TypeError: Assignment to constant variable.
    
  2. 核心特性:

    特性 说明
    作用域 块级作用域 (同 let)
    变量提升 声明提升,但未初始化 (同 let,存在 TDZ)
    重复声明 同一作用域内禁止重复声明 (同 let)
    全局声明 全局作用域中用 const 声明的变量,不会挂载到 window 对象 (同 let)
    初始化要求 声明时必须立即赋值 (报错:Missing initializer in const declaration)
    可修改性 「引用不可改,内容可改」:
    - 基本类型 (数字 / 字符串 / 布尔):值不可改;
    - 引用类型 (对象 / 数组):引用 (地址) 不可改,但内部属性 / 元素可改
    可重新赋值 禁止重新赋值 (报错:TypeError: Assignment to constant variable)

三者核心差异总结

特性 var let const
作用域 函数作用域 块级作用域 块级作用域
变量提升 声明 + 初始化 (undefined) 声明提升,未初始化 (TDZ) 声明提升,未初始化 (TDZ)
重复声明 允许 禁止 禁止
全局声明挂载 window
声明时必须赋值
重新赋值 允许 允许 禁止
基本类型值修改 允许 允许 禁止
引用类型内容修改 允许 允许 允许

JavaScript 是如何支持块级作用域的

  1. 在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?

  2. 先看下面这段代码:

    function foo(){
        var a = 1
        let b = 2
        {
            let b = 3
            var c = 4
            let d = 5
            console.log(a)
            console.log(b)
        }
        console.log(b) 
        console.log(c)
        console.log(d)
    }   
    foo()
    
  3. 接下来一步步分析上面这段代码的执行流程:

    1. 第一步是编译并创建执行上下文,下面是 foo 执行上下文示意图,可以得出以下结论:
      • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了;
      • 通过 let 声明的变量,在编译阶段会被存放到词法环境 (Lexical Environment) 中;
      • 在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中;
    2. 第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:
      • 从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在;
      • 其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构;
      • 再接下来,当执行到作用域块中的 console.log(a) 这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找;这样一个变量查找过程就完成了,可以参考下图:
      • 当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:
    3. 通过上面的分析,想必已经理解了词法环境的结构和工作机制,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了;

面试题

看下面这样一段代码,能通过分析词法环境,得出来最终的打印结果吗

let myname= '李四'
{
  console.log(myname) 
  let myname= '张三'
}

// 解答
// VM6277:3 Uncaught ReferenceError: Cannot access 'myname' before initialization
// 进入块级作用域不会有编译过程,只不过通过let或者const声明的变量会在进入块级作用域的时被创建,但是在该变量没有赋值之前,引用该变量JavaScript引擎会抛出错误---这就是“暂时性死区”

下面的代码输出的结果是什么?

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000*i);
}

// var 声明的变量是全局作用域的,循环完后 i 的值是 3
// 3
// 3
// 3
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000 * i);
}
// 这里循环的变量 i 是 let 声明的,当前的 i 只在本轮循环有效,所以每一次循环的 i 其实都是一个新的变量
// for 循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域
// 0
// 1
// 2
for (var i = 0; i < 3; i++) {
  (function (i) {
    setTimeout(() => {
      console.log(i);
    }, 1000 * i);
  })(i)
}

嵌套作用域下的变量优先级

// 请说出输出结果及原因
var a = 100;
function fn() {
  console.log(a); 
  let a = 200;
}
fn();
Uncaught ReferenceError: Cannot access 'a' before initialization

函数作用域内存在 let a,因此变量提升规则按 let 处理(声明提升,未初始化)
console.log(a) 位于 let a 声明前,处于「暂时性死区」,直接报错(而非访问全局 a=100)
打赏作者
您的打赏是我前进的动力
微信
支付宝
评论

你好👏🏻,我是 ✍🏻   疯狂 codding 中...

粽子

这有关于前端开发的技术文档和你分享。

相信你可以在这里找到对你有用的知识和教程。

了解更多

目录

  1. 1. 核心概念铺垫
    1. 1.1. 作用域
    2. 1.2. 变量提升
  2. 2. 逐一对比解析
    1. 2.1. var:ES5 声明方式(函数作用域 + 提升 + 可重复声明)
    2. 2.2. let:ES6 声明变量(块级作用域 + TDZ + 不可重复声明 + 提升但未初始化)
    3. 2.3. const:ES6 声明常量(块级作用域 + TDZ + 不可重复声明 + 不可重新赋值)
    4. 2.4. 三者核心差异总结
  3. 3. JavaScript 是如何支持块级作用域的
  4. 4. 面试题
    1. 4.1. 看下面这样一段代码,能通过分析词法环境,得出来最终的打印结果吗
    2. 4.2. 下面的代码输出的结果是什么?
    3. 4.3. 嵌套作用域下的变量优先级