别再慌代码输出题!吃透作用域 / 变量提升 / 闭包底层,80% 面试题直接秒

123 阅读10分钟

一遇到作用域、变量提升、闭包的代码输出题就发懵 —— 不是记不住答案,而是没搞懂 “为什么”。比如 “变量提升到底提了啥?”“闭包为啥能留住变量?”“为啥函数里的变量找不到外层的?”。

其实这类题的核心就 3 个底层逻辑:JS 的 “编译 + 执行” 机制、词法作用域规则、闭包的形成条件。今天这篇文章,咱们不记零散答案,从根上把这些逻辑扒透,再结合经典面试题拆解思路,以后再遇到这类题,不管怎么变形,你都能轻松搞定。

JS 执行的底层:不是 “从上到下跑” 这么简单

要理解作用域和变量提升,必须先明白JS 代码的执行过程—— 它不是拿到代码就直接一行行执行,而是先经过 “编译阶段”,再进入 “执行阶段”。这是所有问题的根源,一定要吃透。

1. 编译阶段:JS 引擎在 “偷偷做准备”

编译阶段(也叫 “预解析”),JS 引擎会快速扫描代码,做 3 件关键的事:

  • 词法分析:把代码拆成一个个 “词”,比如var x = 1拆成varx=1
  • 语法分析:检查语法是否正确,生成 “抽象语法树(AST)”。
  • 生成执行上下文:这是最关键的一步!JS 会为每个函数(或全局)创建一个 “执行上下文”,里面包含变量环境(存储变量和函数声明)和词法环境(存储 let/const 声明)。

简单说:编译阶段就是 “提前登记” 变量和函数的位置,确定它们属于哪个作用域 —— 这就是 “变量提升” 的本质,不是 “把代码挪到上面”,而是 “提前在执行上下文里记下来”。

2. 执行阶段:按顺序跑代码,找变量靠 “作用域链”

执行阶段会按代码顺序逐行执行,遇到变量就去 “执行上下文” 里找:

  • 先找当前作用域的变量环境 / 词法环境;
  • 找不到就往上找外层作用域的执行上下文;
  • 一直找到全局作用域,还找不到就报错(ReferenceError)。

这个 “从内到外找变量” 的链条,就是作用域链

变量提升:别只记 “提升”,要懂 “区别”

面试里常考 “var、function、let/const 的提升差异”,这是基础中的基础,错了就直接丢分。

1. var 的提升:“声明提上去,赋值留原地”

var 的变量在编译阶段,会把 “声明” 登记到当前作用域的变量环境,但 “赋值” 会留在原来的位置。

经典例题

fn1() // 输出:fn1(function声明整个提升了)
fn2() // 报错:TypeError: fn2 is not a function(var只提升声明,此时fn2是undefined)

function fn1() { console.log('fn1') }
var fn2 = function() { console.log('fn2') }

fn2() // 输出:fn2(此时赋值已执行)

拆解

  • 编译阶段:fn1(函数声明)和fn2(var 声明)被登记到全局变量环境,fn1的值是整个函数体,fn2的值是undefined
  • 执行阶段:先跑fn1(),找到fn1的函数体,执行成功;再跑fn2(),此时fn2还是undefined,调用就报错;最后执行fn2 = 函数,赋值后再调用就正常。

2. function 的提升:“整个函数体都提上去”

函数声明(function fn() {})的提升比 var 更 “彻底”—— 编译阶段会把整个函数体登记到变量环境,所以执行阶段只要在作用域内,不管在哪调用都能找到。

但要注意:函数声明会覆盖同名的 var 声明(因为函数提升优先级更高):

console.log(fn) // 输出:function fn() { console.log(2) }
var fn = 1
function fn() { console.log(2) }

编译阶段,fn先被 var 声明为undefined,随后被 function 声明覆盖为函数体,所以先 log 出函数。

3. let/const 的 “不提升”:其实是 “暂时性死区”

很多人说 “let/const 没有变量提升”,这是错的!实际上 let/const 在编译阶段也会被登记到 “词法环境”,但有个关键限制:声明前不能用,这就是 “暂时性死区(TDZ)”。

经典例题

console.log(x) // 报错:ReferenceError(x在暂时性死区)
let x = 1

// 对比var
console.log(y) // 输出:undefined(var没有暂时性死区)
var y = 2

为什么要有 TDZ?  就是为了避免 var 的 “先使用后声明” 的坑,让代码更规范。

变量提升核心总结

声明方式提升情况暂时性死区赋值前使用结果
var声明提升,赋值不提升undefined
function整个函数体提升正常执行函数
let/const声明提升(词法环境)ReferenceError 报错

作用域

作用域的核心是词法作用域—— 变量的作用域在 “定义时” 就确定了,和 “执行时” 无关。

1. 词法作用域 vs 动态作用域

JS 只有词法作用域,比如:

var a = 3 // 全局a
function c() {
  console.log(a) // 找c定义时的外层作用域——全局,所以输出3
}
(function() {
  var a = 4 // 局部a,和c的作用域无关
  c() // 执行c,但c的作用域在定义时就定了,找全局a
})()

底层逻辑 : 不管 c 在哪里执行,它找 a 的规则只看 “c 定义时的外层作用域(全局)”,不是 “执行时的当前作用域(自执行函数)”。

如果是动态作用域(比如 bash 脚本),才会找 “执行时的外层作用域”,但 JS 没有!记住这一点,就能避开很多坑。

2. 块级作用域:let/const 的 “专属领地”

ES6 之前,JS 只有 “全局作用域” 和 “函数作用域”,没有块级作用域({}包裹的区域,比如 if、for)。var 会穿透块级作用域,而 let/const 会创建块级作用域。

经典例题

// var的情况:穿透for循环,输出3个3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0) 
}

// let的情况:每次循环创建块级作用域,输出0、1、2
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0)
}

拆解

  • var 的 i 是全局变量,循环结束后 i=3,setTimeout 是异步的,执行时 i 已经是 3,所以输出 3 个 3;
  • let 的 i 在每次循环时,都会创建一个新的块级作用域,每个 setTimeout 引用的是当前循环的 i,所以输出 0、1、2。

闭包:让变量 “活下来” 的秘密

闭包是作用域的延伸,也是面试难点。很多人以为 “闭包就是嵌套函数”,其实不对 —— 闭包的核心是 “变量被保留”,也就是自由变量

1. 闭包的形成条件(3 个缺一不可)

  1. 函数嵌套:内部函数嵌套在外部函数里;
  2. 变量引用:内部函数引用了外部函数的变量(或参数);
  3. 外部访问:内部函数被 “带出” 外部函数(比如返回出去、赋值给全局变量)。

当这 3 个条件满足时,外部函数执行完后,它的执行上下文不会被销毁(因为内部函数还在引用它的变量)—— 这就是变量被 “留住” 的原因。

2. 经典闭包题:链式调用

这道题是闭包的 “终极考验”,能讲透就说明闭包真懂了:

function fun(n, o) {
  console.log(o)
  return {
    fun: function(m) {
      return fun(m, n) // 内部函数引用外部fun的参数n,形成闭包
    }
  }
}
// 分别执行以下三行,输出什么?
var a = fun(0); a.fun(1); a.fun(2); a.fun(3); 
var b = fun(0).fun(1).fun(2).fun(3); 
var c = fun(0).fun(1); c.fun(2); c.fun(3); 

逐行拆解(关键看 n 的值被哪个闭包保留)

  • 首先,fun(n,o)的第一个参数 n 会被内部返回的fun(m)引用,形成闭包;o是形参,没传的话是 undefined。

情况 1:a = fun (0) 后续调用

  1. a = fun(0):调用 fun (0, undefined),log (o)=undefined;返回的对象里的fun(m)引用 n=0(闭包保留 n=0);
  2. a.fun(1):调用内部 fun (1),实际是fun(1, 0)(n=0),log(o)=0;
  3. 后续a.fun(2)a.fun(3):都是用 a 保留的 n=0,所以 log 都是 0;输出:undefined 0 0 0。

情况 2:b = fun (0).fun (1).fun (2).fun (3)

  1. fun(0):log undefined,返回的 fun 引用 n=0;
  2. .fun(1):调用fun(1, 0),log 0;返回的新 fun 引用 n=1(此时闭包保留 n=1);
  3. .fun(2):调用fun(2, 1),log 1;返回的新 fun 引用 n=2;
  4. .fun(3):调用fun(3, 2),log 2;输出:undefined 0 1 2。

情况 3:c = fun (0).fun (1) 后续调用

  1. fun(0):log undefined,返回的 fun 引用 n=0;
  2. .fun(1):调用fun(1, 0),log 0;返回的新 fun 引用 n=1(c 保留这个 n=1);
  3. c.fun(2):调用fun(2, 1),log 1;
  4. c.fun(3):调用fun(3, 1),log 1;输出:undefined 0 1 1。

3. 闭包的常见误区:“闭包会导致内存泄漏”

很多人以为闭包会内存泄漏,其实不对 ——合理的闭包不会泄漏。只有当闭包引用了不需要的变量(比如 DOM 元素),且闭包一直被全局引用时,才会导致内存无法释放。

比如:

// 不好的闭包:引用了dom,且闭包被全局保留
var btn = document.getElementById('btn')
function addClick() {
  var data = '很多数据'
  btn.onclick = function() {
    console.log(data) // 闭包引用data,btn是全局的,data无法释放
  }
}
addClick()

解决办法:不需要时手动解除引用(btn.onclick = null)。

例题: 按 “底层逻辑” 解题,不记答案

最后,把前面的知识点结合成几道经典题,教你用 “编译→执行→作用域链” 的思路解题,而不是记答案。

题 1:变量提升 + 作用域

var name = 'World'
(function() {
  if (typeof name === 'undefined') {
    var name = 'Jack'
    console.log('Goodbye ' + name)
  } else {
    console.log('Hello ' + name)
  }
})()

解题步骤

  1. 编译阶段:自执行函数内有var name,提升到函数作用域的变量环境,值为 undefined;

  2. 执行阶段:

    • 先判断typeof name:当前作用域的 name 是 undefined,所以进入 if;
    • 执行var name = 'Jack',赋值后 log:Goodbye Jack;输出:Goodbye Jack。

题 2:作用域链 + 隐式转换

f = function() { return true }
g = function() { return false }
(function() {
  if (g() && [] == ![]) {
    f = function() { return false }
    function g() { return true } // 函数提升,覆盖外部g
  }
})()
console.log(f())

解题步骤

  1. 编译阶段:自执行函数内的function g()提升到函数作用域,所以内部 g 是return true

  2. 执行阶段:

  • 先调用g():用内部提升的 g,返回 true;
  • 再判断[] == ![]
    • ![]:数组转布尔是 true,取反是 false;
    • [] == false:数组转字符串是 '','' 转数字是 0;false 转数字是 0,所以等式成立;
-   if 条件为 true,执行`f = function() { return false }`(f 是全局变量,被修改);
  1. 最后 log (f ()):输出 false;输出:false。

题 3:闭包 + 循环

function createFns() {
  var fns = []
  for (var i = 0; i < 3; i++) {
    fns.push(function() {
      console.log(i)
    })
  }
  return fns
}
var fns = createFns()
fns[0]() // 3
fns[1]() // 3
fns[2]() // 3

解题步骤

  1. 编译阶段:createFns 内的 var i 提升到函数作用域,值为 undefined;

  2. 执行阶段:

    • 循环 3 次,每次 push 的函数引用的是 “同一个 i”(因为 var i 是函数作用域的,不是块级的);
    • 循环结束后 i=3,createFns 返回 fns(闭包保留了对 i 的引用);
    • 调用 fns0时,找 i 的值是 3,所以输出 3;如何改成输出 0、1、2?  用 let i(块级作用域),或用立即执行函数(IIFE)包裹,创建独立作用域。

最后:记住 3 个核心,搞定 80% 面试题

  1. 执行机制是根:先编译(登记变量 / 函数),再执行(按顺序跑,找变量靠作用域链);
  2. 变量提升看类型:var 提声明、function 提整体、let/const 有 TDZ;
  3. 闭包看 3 个条件:嵌套函数、引用外部变量、内部函数被带出。

其实作用域、变量提升、闭包本质是 “JS 为了管理变量而设计的规则”,理解了规则背后的逻辑,再遇到代码输出题,就不是 “猜答案”,而是 “推导结果”—— 这才是面试想考察的能力。

下次再遇到这类题,先在脑子里过一遍 “编译阶段做了什么?执行阶段按什么顺序跑?变量从哪找?”,答案自然就出来了。