JavaScript 函数作用域详解------为什么函数外面访问不到里面的变量?
从一个报错说起
javascript
function outer() {
let functionVar = "我是函数作用域变量";
function inner() {
console.log(functionVar); // ✅ 可以访问
}
inner(); // 输出:我是函数作用域变量
console.log(functionVar); // ✅ 输出:我是函数作用域变量
}
outer();
console.log(functionVar); // ❌ ReferenceError: functionVar is not defined
最后一行代码报错了:ReferenceError: functionVar is not defined。
为什么? 明明 functionVar 已经定义了,为什么说它"未定义"?
答案就藏在 JavaScript 的作用域(Scope) 机制中。
一、什么是作用域?
作用域就是变量的"可见范围"。
你可以把作用域想象成一个房间:
在房间里面的人(代码),可以看到房间里的东西(变量)。
但房间外面的人,看不到房间里的东西。
┌─── 全局作用域(大街上)─────────────────────────┐
│ │
│ console.log(functionVar) ← ❌ 这里看不到! │
│ │
│ ┌─── outer() 函数作用域(房间)──────────────┐ │
│ │ │ │
│ │ let functionVar = "我是函数作用域变量" │ │
│ │ │ │
│ │ console.log(functionVar) ← ✅ 看得到 │ │
│ │ │ │
│ │ ┌─── inner() 函数作用域(房间里的柜子)─┐│ │
│ │ │ ││ │
│ │ │ console.log(functionVar) ← ✅ 看得到 ││ │
│ │ │ ││ │
│ │ └──────────────────────────────────────┘│ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
二、JavaScript 中的三种作用域
1. 全局作用域
在所有函数和代码块外面声明的变量,拥有全局作用域。
javascript
let globalVar = "我是全局变量";
function test() {
console.log(globalVar); // ✅ 函数内可以访问
}
test();
console.log(globalVar); // ✅ 函数外也可以访问
全局变量就像放在大街上的东西,谁都能看到。
2. 函数作用域
在函数内部声明的变量,只在该函数内部可见。
javascript
function myFunction() {
let localVar = "我是局部变量";
console.log(localVar); // ✅ 函数内可以访问
}
myFunction();
console.log(localVar); // ❌ ReferenceError!
函数内的变量就像放在房间里的东西,出了这个房间就看不见了。
3. 块级作用域(ES6 新增)
用 let 和 const 在 {} 代码块中声明的变量,只在该块内可见。
javascript
if (true) {
let blockVar = "我是块级变量";
console.log(blockVar); // ✅ 块内可以访问
}
console.log(blockVar); // ❌ ReferenceError!
三、回到那个报错:逐行分析
javascript
function outer() {
// 👇 functionVar 在 outer 函数内部声明
let functionVar = "我是函数作用域变量";
function inner() {
console.log(functionVar); // ✅ 第1处
}
inner();
console.log(functionVar); // ✅ 第2处
}
outer();
console.log(functionVar); // ❌ 第3处
| 位置 | 代码在哪里? | 能否访问 functionVar? |
原因 |
|---|---|---|---|
| 第1处 | inner() 函数内部 |
✅ 能 | inner 是 outer 的子函数,子函数可以访问父函数的变量 |
| 第2处 | outer() 函数内部 |
✅ 能 | functionVar 就是在 outer 里面声明的,当然能访问 |
| 第3处 | 全局(函数外部) | ❌ 不能 | functionVar 的作用域仅限于 outer 内部,外面根本不知道它的存在 |
四、核心规则:作用域链
JavaScript 查找变量时,遵循一条由内向外的链条:
当前作用域 → 父级作用域 → 爷爷级作用域 → ... → 全局作用域
只能由内向外找,不能由外向内找!
示例
javascript
let a = "全局的 a";
function level1() {
let b = "level1 的 b";
function level2() {
let c = "level2 的 c";
function level3() {
let d = "level3 的 d";
console.log(d); // ✅ 自己的
console.log(c); // ✅ 往外找一层,找到了
console.log(b); // ✅ 往外找两层,找到了
console.log(a); // ✅ 往外找三层,在全局找到了
}
level3();
}
level2();
}
level1();
level3 找变量的过程:
level3 自己 → level2 → level1 → 全局
d ✅ c ✅ b ✅ a ✅
反过来呢?
javascript
let a = "全局的 a";
function level1() {
let b = "level1 的 b";
function level2() {
let c = "level2 的 c";
}
level2();
console.log(c); // ❌ ReferenceError! level1 看不到 level2 里面的 c
}
level1();
console.log(b); // ❌ ReferenceError! 全局看不到 level1 里面的 b
📌 记住:儿子可以用爸爸的东西,爸爸不能翻儿子的口袋。
五、var、let、const 的作用域区别
这里有一个很容易踩的坑:
javascript
function test() {
if (true) {
var varVariable = "var 声明";
let letVariable = "let 声明";
const constVariable = "const 声明";
}
console.log(varVariable); // ✅ "var 声明" ------ 居然能访问!
console.log(letVariable); // ❌ ReferenceError
console.log(constVariable); // ❌ ReferenceError
}
| 关键字 | 作用域范围 | 说明 |
|---|---|---|
var |
函数作用域 | 只认函数边界,不认 {} 块边界 |
let |
块级作用域 | 认 {} 块边界 |
const |
块级作用域 | 认 {} 块边界 |
用图来理解:
function test() {
┌──────────────── 函数作用域 ──────────────────┐
│ │
│ if (true) { │
│ ┌──────────── 块级作用域 ────────────────┐ │
│ │ var varVariable = "var 声明"; ← 逃出去了│
│ │ let letVariable = "let 声明"; ← 被困住了│
│ │ const constVariable = "const"; ← 被困住了│
│ └────────────────────────────────────────┘ │
│ │
│ console.log(varVariable); ✅ var 逃出来了 │
│ console.log(letVariable); ❌ let 出不来 │
│ console.log(constVariable); ❌ const 出不来 │
└──────────────────────────────────────────────┘
这也是为什么现代 JavaScript 推荐使用
let和const,而不是var。var的作用域行为容易导致意想不到的 bug。
六、一个经典的坑:for 循环中的 var
javascript
// ❌ 用 var
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// 输出:3 3 3(不是 0 1 2!)
// ✅ 用 let
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// 输出:0 1 2 ✅
原因:
var i是函数作用域,整个循环共享同一个i,循环结束后i = 3let i是块级作用域,每次循环都会创建一个新的i
七、实际应用:利用函数作用域保护变量
既然函数外部访问不到函数内部的变量,我们可以利用这一点来保护数据:
模拟私有变量
javascript
function createCounter() {
let count = 0; // 外部无法直接访问和修改
return {
increment() {
count++;
console.log(`当前计数:${count}`);
},
decrement() {
count--;
console.log(`当前计数:${count}`);
},
getCount() {
return count;
}
};
}
const counter = createCounter();
counter.increment(); // 当前计数:1
counter.increment(); // 当前计数:2
counter.decrement(); // 当前计数:1
console.log(counter.count); // ❌ undefined,无法直接访问
console.log(counter.getCount()); // ✅ 1,只能通过方法访问
count被安全地藏在 函数内部,外部只能通过我们提供的方法来操作它------这就是闭包(Closure) 的基本思想。
避免全局污染
javascript
// ❌ 不好的写法:变量全暴露在全局
let userName = "张三";
let userAge = 25;
function greetUser() { /* ... */ }
// ✅ 好的写法:用函数包裹,避免污染全局
(function () {
let userName = "张三";
let userAge = 25;
function greetUser() { /* ... */ }
// 在这里执行你的代码...
greetUser();
})();
// 外面访问不到 userName、userAge、greetUser
这种写法叫做 IIFE(立即调用函数表达式),在模块化出现之前被广泛使用。
八、总结
| 要点 | 说明 |
|---|---|
| 函数内部的变量,外部无法访问 | 这就是报错的根本原因 |
| 作用域链是单向的 | 只能由内向外查找,不能由外向内 |
var 是函数作用域 |
会"穿透" if、for 等代码块 |
let / const 是块级作用域 |
更安全、更可控,推荐使用 |
| 利用作用域可以保护数据 | 闭包、IIFE 等模式 |
最后,用一张图记住一切:
🌍 全局作用域
│
│ 能看到:全局变量
│ 不能看到:任何函数内部的变量 ❌
│
└── 📦 函数 outer()
│
│ 能看到:全局变量 + outer 自己的变量
│ 不能看到:inner 内部的变量 ❌
│
└── 📦 函数 inner()
│
│ 能看到:全局变量 + outer 的变量 + inner 自己的变量 ✅
│ (由内向外,一路畅通)
核心一句话:变量在哪个作用域声明,就只在那个作用域及其子作用域中可见。
理解了作用域,你就理解了 JavaScript 变量管理的核心,也为后面学习闭包 、模块化打下了坚实的基础。🚀
为什么 var + setTimeout 输出 3 3 3?
先看代码
javascript
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// 1秒后输出:3 3 3
要理解这个问题,你需要搞清楚两件事:
第一件事:var 只有一个 i
var 是函数作用域 ,不是块级作用域。所以整个循环从头到尾,共享同一个变量 i。
javascript
// 你以为的样子(每轮循环有自己的 i):
// 第1轮:i = 0 ← 独立的
// 第2轮:i = 1 ← 独立的
// 第3轮:i = 2 ← 独立的
// 实际的样子(只有一个 i,不断被修改):
var i; // 👈 只有一个 i,被提升到外面
for (i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 👈 三个箭头函数都指向同一个 i
}, 1000);
}
可以这样想象:
┌──────────────────────┐
│ var i (只有一个!) │
└──────────┬───────────┘
│
┌───────┼───────┐
│ │ │
▼ ▼ ▼
箭头函数1 箭头函数2 箭头函数3
console console console
.log(i) .log(i) .log(i)
三个函数都盯着同一个 i !
第二件事:setTimeout 是"稍后执行"
setTimeout 里的函数不会立刻执行,而是等到指定时间后才执行。
所以真正的执行顺序是这样的:
时间线:
──────────────────────────────────────────────────►
【第1步】for 循环开始,飞速执行完毕(几乎瞬间)
i=0 → i=1 → i=2 → i=3(循环结束)
【第2步】1秒后,三个 setTimeout 的回调函数开始执行
此时去读 i,i 已经变成 3 了!
逐步模拟
🔄 循环第1轮:i = 0
→ 注册一个 setTimeout(1秒后执行 console.log(i))
→ 注意:这里只是"注册",不是"执行"
🔄 循环第2轮:i = 1
→ 又注册一个 setTimeout(1秒后执行 console.log(i))
🔄 循环第3轮:i = 2
→ 再注册一个 setTimeout(1秒后执行 console.log(i))
🔄 i++ 变成 3,3 < 3 不成立,循环结束
此时 i = 3 ✅
此时有 3 个 setTimeout 在等待中 ⏳
⏰ 1秒后...
箭头函数1 执行:console.log(i) → i 是 3 → 输出 3
箭头函数2 执行:console.log(i) → i 是 3 → 输出 3
箭头函数3 执行:console.log(i) → i 是 3 → 输出 3
一个生活中的比喻 🏠
想象这个场景:
墙上挂了一块白板 ,上面写着一个数字(就是
var i)。你叫了三个人(三个 setTimeout),跟他们说:
"1秒后,去看白板上的数字,然后大声念出来。"然后你飞快地擦掉白板上的数字:
- 先写 0,擦掉
- 再写 1,擦掉
- 再写 2,擦掉
- 最后写上 3
1秒后,三个人走到白板前,看到的都是 3。
用 let 为什么就正常了?
javascript
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// 输出:0 1 2 ✅
let 是块级作用域 ,每轮循环都会创建一个全新的 i:
第1轮循环 ┌─ 块作用域 ─┐
│ let i = 0 │ ← 独立的 i
│ setTimeout(回调 → 记住这个 i = 0)
└─────────────┘
第2轮循环 ┌─ 块作用域 ─┐
│ let i = 1 │ ← 独立的 i
│ setTimeout(回调 → 记住这个 i = 1)
└─────────────┘
第3轮循环 ┌─ 块作用域 ─┐
│ let i = 2 │ ← 独立的 i
│ setTimeout(回调 → 记住这个 i = 2)
└─────────────┘
用白板比喻来说
let相当于每轮循环都拍一张白板的照片 📸每个人手里拿着的是各自的照片,不是去看白板。
所以 1 秒后:
- 第 1 个人看照片:0
- 第 2 个人看照片:1
- 第 3 个人看照片:2
对比总结
var:一个变量,三个函数共享
┌─────────┐
│ i = 3 │ ← 循环结束后
└────┬────┘
│ ┌──── 读取 ────┐
├──→ 回调1: log(3) │
├──→ 回调2: log(3) │ → 输出 3 3 3
└──→ 回调3: log(3) │
└──────────────┘
let:三个变量,各自独立
┌─────────┐
│ i = 0 │──→ 回调1: log(0)
└─────────┘ → 输出 0 1 2
┌─────────┐
│ i = 1 │──→ 回调2: log(1)
└─────────┘
┌─────────┐
│ i = 2 │──→ 回调3: log(2)
└─────────┘
一句话记住
var只有一个变量,循环结束后才执行回调,读到的是最终值。
let每轮创建新变量,每个回调记住的是当轮的值。
后记
2026年4月16日于上海, 在opus 4.6辅助下完成。