从面试题看JS变量提升

0 阅读9分钟

变量提升

开篇例题

先看一段代码,思考它的输出是什么。

var a = 2;
function fn(){
  b();
  return ;
  var a = 1;
  function b(){
    console.log(a);
  }
}
fn();

如果你觉得输出是 1 或者 2,那结果可能会让你意外。这段代码实际输出的是 undefined

为什么?这就要从 JavaScript 的变量提升机制说起。

什么是变量提升

在 JavaScript 中,无论变量或函数在代码的哪个位置声明,它们都会被提升到当前作用域的顶部。这个行为被称为变量提升(Hoisting)。

console.log(a); // undefined
var a = 5;

fn(); // "Hello"
function fn() {
  console.log("Hello");
}

上面的代码中:

  • var a 的声明被提升,但赋值 a = 5 留在原地
  • function fn() 整个函数声明被提升

这就是为什么在声明之前访问 a 不会报错,而是得到 undefined;在声明之前调用 fn() 也能正常执行。

回到开头的例题。变量提升了,但是赋值留在原地,return 后面的代码不执行,所以 a 没有被赋值为 1,因此 console.log(a) 输出 undefined

官方文档的解释

变量提升(Hoisting)被认为是 JavaScript 中执行上下文(特别是创建和执行阶段)工作方式的一种认识。在 ECMAScript® 2015 Language Specification 之前的 JavaScript 文档中找不到变量提升(Hoisting)这个词。

从概念的字面意义上说,变量提升意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中。

—— MDN

通俗来说,变量提升是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量被提升后,会给变量设置默认值为 undefined

函数声明与变量提升的优先级

当函数声明和变量声明同时存在时,提升规则有明确的优先级。看下面这段代码:

function fn() {
  console.log(a);
  var a = 1;
  function a() {}
  console.log(a);
}
fn();

提升规则

在同一个作用域内:

  1. 函数声明整体提升,优先级更高
  2. 变量声明提升,但如果函数已经占用了标识符,变量声明不会覆盖

提升后的实际代码

function fn() {
  function a() {}  // 函数提升,a 是一个函数
  var a;           // 变量提升,但因为 a 已经有值了,所以忽略
  
  console.log(a);  // 输出 [Function: a]
  a = 1;           // 赋值,把 a 从函数改成 1
  console.log(a);  // 输出 1
}
fn();

这里需要理解一个关键点:声明与赋值在提升的时候是分离的。

这两行代码:

var a;
a = 1;

等价于:

var a = 1;

但在提升时,只有 var a 这个声明被提升,赋值 a = 1 留在原地。

函数声明 vs 函数表达式

函数声明和函数表达式在提升行为上有本质区别:

function fn() {
  console.log(a);
  var a = function() {}
  console.log(a);
}
fn();

提升后的代码

function fn() {
  var a;              // 提升,a = undefined
  console.log(a);     // 输出 undefined
  
  a = function() {}   // 赋值(留在原地)
  console.log(a);     // 输出 [Function: a]
}

输出:

undefined
[Function: a]

总结两者的区别:

  • 函数声明整体提升,调用时已经有值
  • 函数表达式如同变量,赋值之前是 undefined

为什么会有变量提升

从语言设计角度看,变量提升的存在有几个原因:

第一,解决函数声明的相互调用问题。 在同一个作用域内,函数 A 调用函数 B,函数 B 调用函数 A,如果不提升,无论谁写在前面,都会有一个找不到。提升解决了这个问题。

第二,提高性能。 编译阶段一次性处理所有声明,执行阶段就可以专注于运算和赋值,不需要边执行边查找声明。

第三,历史遗留。 早期的 JavaScript 设计比较仓促,变量提升是一个实现上的选择,后来发现造成了诸多不符合直觉的行为,但为了兼容性无法轻易修改。

变量提升和 JavaScript 的编译过程密切相关。JavaScript 和其他语言一样,都要经历编译和执行阶段。在这个短暂的编译阶段,JS 引擎会搜集所有的变量声明,并且提前让声明生效。而剩下的语句需要等到执行阶段、等到执行到具体的某一句时才会生效。这就是变量提升背后的机制。

要理解变量提升,首先需要理解作用域。作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,作用域分为两种:

  • 全局作用域:全局对象在代码中的任何地方都可以访问,其生命周期伴随着页面的生命周期
  • 函数作用域:在函数内部定义的变量或者函数,只能在函数内部被访问,函数执行结束之后,这些变量会被销毁

变量提升的好处:

  • 提高性能
  • 容错性更好

变量提升的坏处:

  • 变量容易被覆盖
  • 变量不容易被销毁(容易造成内存残留)

正是由于变量提升导致了很多与直觉不太相符的代码,这也是 JavaScript 的一个设计缺陷。

ES6 的 let 与 const(改变而非回避)

ES6 引入了 letconst 关键字来声明变量。但需要明确的是,它们并没有完全回避变量提升,而是改变了提升的行为规则。

结论对比

特性varlet / const
是否有提升有(但行为不同)
提升后初始值undefined未初始化(访问会报错)
声明前能否访问能(得到 undefined)不能(报错)

关键区别在于:letconst 虽然有提升,但会进入暂时性死区(Temporal Dead Zone,TDZ),在声明之前不能访问。

用代码证明 let 也有提升

let a = 1;
{
  console.log(a);  // 报错:Cannot access 'a' before initialization
  let a = 2;
}

如果 let a 没有提升,console.log(a) 应该输出外层的 1。但实际报错了,这说明内部的 let a 确实被提升了,只是不允许提前使用。

三种声明方式的对比

// var:提升,初始值 undefined
console.log(a);  // undefined
var a = 1;

// let:提升,但进入暂时性死区
console.log(b);  // 报错
let b = 1;

// const:提升,同样进入暂时性死区
console.log(c);  // 报错
const c = 1;

暂时性死区的边界情况

// 声明前访问 → 报错
console.log(x);
let x = 1;

// typeof 也不安全
typeof y;  // 报错,y 在后面用 let 声明
let y;

// 死区内的函数参数也不行
function fn(z = z) {}  // 报错

自我检测

下面两题用来检验你是否真正理解了变量提升和暂时性死区。

第一题

var a = 10;
function test() {
  console.log(a);
  console.log(b);
  var a = 20;
  var b = 30;
}
test();

请问输出是什么?

答案:undefinedundefined

原因:函数内部有 var avar b,所以不会去全局找。提升后 a 和 b 都是 undefined,赋值在 console.log 之后,所以输出 undefined。

第二题

let x = 10;
function foo() {
  console.log(x);
  let x = 20;
}
foo();

请问输出是什么?

答案:报错 Cannot access 'x' before initialization

原因:内部的 let x 有提升,但进入暂时性死区,声明前不能访问。

面试回答思路

如果面试官问到你关于变量提升的问题,可以参考以下回答框架。

问题1:什么是变量提升?

回答思路:

  1. 先给出定义:变量提升是 JavaScript 引擎在编译阶段将变量和函数的声明提升到作用域顶部的行为
  2. 说明 var 和函数的区别:var 只提升声明不提升赋值,初始值为 undefined;函数声明整体提升
  3. 可以举一个简单例子佐证

问题2:let 有变量提升吗?

回答思路:

  1. 明确回答:有提升,但行为不同
  2. 解释区别:var 提升后初始化为 undefined,可以在声明前访问;let/const 提升后不会初始化,进入暂时性死区,声明前访问会报错
  3. 用代码证明:用花括号内的同名 let 变量会屏蔽外层这个例子来说明

问题3:函数声明和函数表达式在提升上有什么区别?

回答思路:

  1. 函数声明整体提升,可以在声明前调用
  2. 函数表达式(如 var a = function() {})只提升变量声明,赋值留在原地,因此在赋值前调用会得到 undefined 或报错

问题4:变量提升有什么优缺点?

回答思路:

  • 优点:提高性能(编译阶段预先处理声明),容错性更好
  • 缺点:变量容易被意外覆盖,代码可读性降低,容易产生不符合直觉的行为

追问示例

面试官可能会继续追问以下变体:

如果把第一题中的 var a = 20 删掉会怎样?

var a = 10;
function test() {
  console.log(a);  // 10(去全局找)
  console.log(b);  // 报错(b 未声明)
  var b = 30;
}

如果把第二题中的内部 let x 删掉会怎样?

let x = 10;
function foo() {
  console.log(x);  // 10(去外层找)
}
foo();

如果把第二题中的 let 改成 var 会怎样?

let x = 10;
function foo() {
  console.log(x);  // undefined(var 提升)
  var x = 20;
}
foo();

看代码说输出(面试回答模板)

如果面试官给你一段代码,让你说输出并解释,可以参考下面的回答框架。

示例代码

var a = 10;
function fn() {
  console.log(a);
  var a = 20;
  console.log(a);
}
fn();
console.log(a);

标准回答

这段代码的输出是 undefined、20、10。

首先,在编译阶段,全局作用域中的 var a 被提升,初始值为 undefined,然后执行到 var a = 10 时被赋值为 10。

进入 fn 函数时,会创建新的函数作用域。在这个作用域内,var a 被提升到函数顶部,初始值为 undefined。所以第一个 console.log(a) 输出的是函数作用域内的 undefined,而不是全局的 10。

接着执行 var a = 20,把函数内的 a 赋值为 20,所以第二个 console.log(a) 输出 20。

函数执行结束后,最后的 console.log(a) 访问的是全局作用域中的 a,它的值仍然是 10,没有被函数内部的赋值影响,因为函数内部的 var a 是局部变量,和全局的 a 是两个独立的变量。

参考阅读

浅谈 JavaScript 变量提升 - 掘金