一遇到作用域、变量提升、闭包的代码输出题就发懵 —— 不是记不住答案,而是没搞懂 “为什么”。比如 “变量提升到底提了啥?”“闭包为啥能留住变量?”“为啥函数里的变量找不到外层的?”。
其实这类题的核心就 3 个底层逻辑:JS 的 “编译 + 执行” 机制、词法作用域规则、闭包的形成条件。今天这篇文章,咱们不记零散答案,从根上把这些逻辑扒透,再结合经典面试题拆解思路,以后再遇到这类题,不管怎么变形,你都能轻松搞定。
JS 执行的底层:不是 “从上到下跑” 这么简单
要理解作用域和变量提升,必须先明白JS 代码的执行过程—— 它不是拿到代码就直接一行行执行,而是先经过 “编译阶段”,再进入 “执行阶段”。这是所有问题的根源,一定要吃透。
1. 编译阶段:JS 引擎在 “偷偷做准备”
编译阶段(也叫 “预解析”),JS 引擎会快速扫描代码,做 3 件关键的事:
- 词法分析:把代码拆成一个个 “词”,比如
var x = 1拆成var、x、=、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 个缺一不可)
- 函数嵌套:内部函数嵌套在外部函数里;
- 变量引用:内部函数引用了外部函数的变量(或参数);
- 外部访问:内部函数被 “带出” 外部函数(比如返回出去、赋值给全局变量)。
当这 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) 后续调用
a = fun(0):调用 fun (0, undefined),log (o)=undefined;返回的对象里的fun(m)引用 n=0(闭包保留 n=0);a.fun(1):调用内部 fun (1),实际是fun(1, 0)(n=0),log(o)=0;- 后续
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)
fun(0):log undefined,返回的 fun 引用 n=0;.fun(1):调用fun(1, 0),log 0;返回的新 fun 引用 n=1(此时闭包保留 n=1);.fun(2):调用fun(2, 1),log 1;返回的新 fun 引用 n=2;.fun(3):调用fun(3, 2),log 2;输出:undefined 0 1 2。
情况 3:c = fun (0).fun (1) 后续调用
fun(0):log undefined,返回的 fun 引用 n=0;.fun(1):调用fun(1, 0),log 0;返回的新 fun 引用 n=1(c 保留这个 n=1);c.fun(2):调用fun(2, 1),log 1;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)
}
})()
解题步骤:
-
编译阶段:自执行函数内有
var name,提升到函数作用域的变量环境,值为 undefined; -
执行阶段:
- 先判断
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())
解题步骤:
-
编译阶段:自执行函数内的
function g()提升到函数作用域,所以内部 g 是return true; -
执行阶段:
- 先调用
g():用内部提升的 g,返回 true; - 再判断
[] == ![]:![]:数组转布尔是 true,取反是 false;[] == false:数组转字符串是 '','' 转数字是 0;false 转数字是 0,所以等式成立;
- if 条件为 true,执行`f = function() { return false }`(f 是全局变量,被修改);
- 最后 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
解题步骤:
-
编译阶段:createFns 内的 var i 提升到函数作用域,值为 undefined;
-
执行阶段:
- 循环 3 次,每次 push 的函数引用的是 “同一个 i”(因为 var i 是函数作用域的,不是块级的);
- 循环结束后 i=3,createFns 返回 fns(闭包保留了对 i 的引用);
- 调用 fns0时,找 i 的值是 3,所以输出 3;如何改成输出 0、1、2? 用 let i(块级作用域),或用立即执行函数(IIFE)包裹,创建独立作用域。
最后:记住 3 个核心,搞定 80% 面试题
- 执行机制是根:先编译(登记变量 / 函数),再执行(按顺序跑,找变量靠作用域链);
- 变量提升看类型:var 提声明、function 提整体、let/const 有 TDZ;
- 闭包看 3 个条件:嵌套函数、引用外部变量、内部函数被带出。
其实作用域、变量提升、闭包本质是 “JS 为了管理变量而设计的规则”,理解了规则背后的逻辑,再遇到代码输出题,就不是 “猜答案”,而是 “推导结果”—— 这才是面试想考察的能力。
下次再遇到这类题,先在脑子里过一遍 “编译阶段做了什么?执行阶段按什么顺序跑?变量从哪找?”,答案自然就出来了。