面试常考:从底层原理深入理解var,const和let

91 阅读8分钟

引言

从ES5的var到ES6引入的letconst,这些变化不仅仅是语法上的改进,更是JavaScript向企业级开发语言迈进的重要标志。本文将深入探讨这三种声明方式的底层原理和区别。

一、JavaScript代码执行机制

1.1 代码执行过程

JavaScript代码的执行过程可以分为以下几个阶段:

  1. 代码加载阶段

    • 代码从硬盘读入内存
    • V8引擎(Chrome浏览器的心脏)负责解析和执行代码
  2. 编译阶段

    • 创建代码执行环境
    • 进行语法检测
    • 准备变量查找规则
    • 建立作用域链
  3. 执行阶段

    • 按照代码顺序执行
    • 进行变量赋值
    • 执行函数调用

1.2 作用域和作用域链

  • 作用域:变量查找的规则

    • 全局作用域
    • 函数作用域
    • 块级作用域
  • 作用域链:变量查找的路径

    • 从当前作用域开始查找
    • 逐级向上查找父作用域
    • 直到全局作用域
    • 如果都没找到则抛出ReferenceError

二、ES5时代的var

2.1 var的特点

var是ES5中唯一的变量声明方式,它具有以下特点:

  • 变量提升(Hoisting)

    变量提升是JavaScript中的一个重要概念,它指的是JavaScript引擎在执行代码之前,会将变量和函数的声明提升到当前作用域的最顶部。这个过程发生在代码编译阶段,而不是执行阶段。

    • 变量声明会被提升到当前作用域的最顶部

      // 实际代码
      console.log(a); // undefined
      var a = 1;
      
      // JavaScript引擎实际执行的代码
      var a; // 声明被提升
      console.log(a); // undefined
      a = 1; // 赋值保持在原位置
      
    • 只有声明会被提升,赋值不会被提升

      // 实际代码
      console.log(a); // undefined
      var a = 1;
      console.log(a); // 1
      
      // 等价于
      var a; // 声明提升
      console.log(a); // undefined
      a = 1; // 赋值不提升
      console.log(a); // 1
      
    • 函数声明会被整体提升

      // 实际代码
      showName(); // "函数执行了"
      console.log(myName); // undefined
      
      var myName = 'Trae'
      function showName() {
        console.log('函数执行了');
      }
      
    • 变量提升的优先级

      // 函数声明优先于变量声明
      console.log(foo); // [Function: foo]
      var foo = '变量声明';
      function foo() {
        console.log('函数声明');
      }
      
    • 变量提升的作用域

      function test() {
        console.log(a); // undefined
        var a = 1;
      }
      test();
      console.log(a); // ReferenceError: a is not defined
      // 变量提升只在当前作用域内有效
      
    • 变量提升的注意事项

      1. 使用var声明的变量会被提升
      2. 使用let和const声明的变量不会被提升,会形成暂时性死区(TDZ)
      3. 函数声明会被整体提升
      4. 函数表达式不会被提升
      // 函数表达式不会被提升
      sayHi(); // TypeError: sayHi is not a function
      var sayHi = function() {
        console.log("Hi");
      };
      
  • 函数作用域

    • 在函数内部声明的变量,外部无法访问
    • 没有块级作用域的概念
    function test() {
      var a = 1;
      if(true) {
        var b = 2;
      }
      console.log(b); // 2,if块中的变量在函数内可访问
    }
    
  • 可重复声明

    • 同一作用域内可以重复声明同名变量
    • 后面的声明会覆盖前面的声明
    var a = 1;
    var a = 2;
    console.log(a); // 2
    
  • 全局变量挂载到window对象

    • 在全局作用域使用var声明的变量会成为window对象的属性
    • 可能导致全局命名空间污染
    var globalVar = 1;
    console.log(window.globalVar); // 1
    

2.2 var的局限性

var的局限性主要体现在以下几个方面:

// 变量提升带来的问题
console.log(a); // undefined
var a = 1;

// 作用域问题
for(var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 输出3次3
    }, 0);
}

// 重复声明问题
var x = 1;
var x = 2; // 允许重复声明,容易造成代码维护困难

三、ES6的let和const

3.1 let的特性

let是ES6引入的新的变量声明方式,它解决了var的许多问题:

  • 块级作用域

    • 变量只在声明它的代码块内有效
    • 解决了for循环中的变量泄露问题
    {
      let blockVar = 1;
    }
    console.log(blockVar); // ReferenceError
    
    // for循环示例
    for(let i = 0; i < 3; i++) {
      setTimeout(() => {
        console.log(i); // 输出0,1,2
      }, 0);
    }
    
  • 暂时性死区(TDZ)

    • 在变量声明之前访问变量会抛出ReferenceError
    • 从代码块开始到变量声明之间的区域称为暂时性死区
    // ReferenceError: Cannot access 'a' before initialization
    console.log(a);
    let a = 1;
    
  • 不可重复声明

    • 同一作用域内不能重复声明同名变量
    • 有助于代码维护和调试
    let x = 1;
    let x = 2; // SyntaxError
    
  • 不会挂载到window对象

    • 全局作用域下声明的变量不会成为window对象的属性
    • 避免了全局命名空间污染
    let globalLet = 1;
    console.log(window.globalLet); // undefined
    

3.2 const的特性

const是ES6引入的常量声明方式,用于声明不可变的变量:

  • 声明时必须初始化

    • 必须在声明时赋值
    • 不能先声明后赋值
    const a; // SyntaxError
    const b = 1; // 正确
    
  • 简单数据类型:值不可改变

    • 数值、字符串、布尔值等基本类型
    • 尝试修改会抛出TypeError
    const num = 1;
    num = 2; // TypeError
    
  • 复杂数据类型:引用地址不可改变,但对象内容可以修改

    • 对象、数组等引用类型
    • 可以修改对象的属性或数组的元素
    const obj = { name: 'test' };
    obj.name = 'new name'; // 允许
    obj = {}; // TypeError
    

四、内存分配机制

4.1 内存栈(Stack)

内存栈是JavaScript中用于存储基本类型数据和引用地址的内存区域:

  • 连续的内存空间

    • 按照先进后出的原则管理内存
    • 内存分配和释放由系统自动管理
    • 适合存储固定大小的数据
  • 访问速度快

    • 直接访问内存地址
    • 不需要额外的内存管理开销
    • 适合频繁访问的数据
  • 存储简单数据类型和引用地址

    • 存储基本类型值(Number、String、Boolean等)
    • 存储引用类型的指针
    • 空间有限,不适合存储大量数据
  • 空间较小

    • 通常只有几MB到几十MB
    • 超出限制会导致栈溢出
    • 不适合存储大型数据结构

4.2 内存堆(Heap)

内存堆是JavaScript中用于存储复杂数据类型的内存区域:

  • 不连续的内存空间

    • 动态分配和释放
    • 可以存储任意大小的数据
    • 内存碎片化问题
  • 访问速度相对较慢

    • 需要通过引用地址访问
    • 需要垃圾回收机制
    • 内存分配和释放需要额外开销
  • 存储复杂数据类型

    • 对象、数组等引用类型
    • 函数、闭包等
    • 可以存储大量数据
  • 空间较大

    • 可以动态扩展
    • 受系统可用内存限制
    • 适合存储大型数据结构

五、变量赋值机制

5.1 简单数据类型

简单数据类型的赋值是值传递,在内存栈中进行:

const a = 1;
// 内存栈中存储值1
// 值不可改变
let b = a;
b = 2;
console.log(a); // 1,a的值不会改变

5.2 复杂数据类型

复杂数据类型的赋值是引用传递,涉及内存栈和内存堆:

const obj = { name: 'test' };
// 内存栈中存储引用地址
// 内存堆中存储对象内容
// 引用地址不可改变,但对象内容可以修改

const arr = [1, 2, 3];
arr.push(4); // 允许修改数组内容
arr = []; // TypeError,不能改变引用地址

5.3 ps:作用域链

image.png

《你不知道的JavaScript》(上卷)对作用域链的比喻

六、实际应用建议

  1. 优先使用const,除非确定变量需要重新赋值

    • 提高代码可维护性
    • 减少bug出现的可能性
    • 符合函数式编程思想
  2. 需要重新赋值的变量使用let

    • 循环计数器
    • 需要多次修改的变量
    • 临时变量
  3. 避免使用var,防止变量污染和提升带来的问题

    • 使用ESLint等工具强制使用let和const
    • 在旧代码中逐步替换var
    • 注意兼容性问题
  4. 在循环中使用let声明计数器

    • 避免闭包陷阱
    • 每次循环创建新的作用域
    • 解决异步问题
  5. 使用const声明对象时,注意对象内容仍然可以修改

    • 使用Object.freeze()冻结对象
    • 使用不可变数据结构
    • 注意深拷贝和浅拷贝的区别

七、面试常见要点总结

  1. 变量提升和暂时性死区的区别

    • var存在变量提升
    • let和const存在暂时性死区
    • 函数声明和变量声明的提升规则
  2. 作用域的区别(函数作用域 vs 块级作用域)

    • var只有函数作用域
    • let和const有块级作用域
    • 作用域链的形成过程
  3. 内存分配机制(栈和堆)

    • 栈和堆的特点
    • 内存管理方式
    • 垃圾回收机制
  4. 变量赋值机制(值传递 vs 引用传递)

    • 基本类型的值传递
    • 引用类型的引用传递
    • 深拷贝和浅拷贝
  5. const对简单类型和复杂类型的处理差异

    • 基本类型的不可变性
    • 引用类型的可变性
    • 如何实现真正的不可变对象