继续 JS 内功系列。前两篇:
- JS 原型与原型链 讲了对象怎么找属性
- JS 执行上下文与调用栈 讲了代码怎么跑起来
这一篇换个视角,讲标识符(变量名 / 函数名)是怎么被查找到的 —— 作用域(Scope)和作用域链(Scope Chain)。
看完你应该能闭眼回答:
var/let/const的作用域区别本质是什么?- "词法作用域(Lexical Scope)" / "静态作用域" 是什么意思?
- 一个函数可以有多个作用域吗?
- 闭包到底是个什么东西?
一句话定义
作用域 = JS 引擎"查找一个标识符(变量名 / 函数名)指向哪里"的规则。
作用域链 = 当一个作用域里查不到时,向外层作用域逐级查找的那条链。
注意"标识符"这个词 —— 作用域管的不是对象上的属性(那是原型链的活),而是 let x / const y / function f 这种名字在哪个区域可见。
三种作用域
| 类型 | 范围 | 谁创建 |
|---|---|---|
| 全局作用域 | 程序最外层 | JS 启动时一份 |
| 函数作用域 | 函数体内部 | 函数声明 / 表达式 / 箭头函数 |
| 块级作用域 | { ... } 包起来的代码块 |
ES6+ 新加,仅对 let / const / class 生效 |
ES5 及之前 只有全局和函数作用域。ES6 引入了块级作用域,这是过去 10 年 JS 最重要的改进之一,直接干掉了一大批
var的歧义代码。
全局作用域:那些"看起来在全局"的坑
下面这些都进了全局作用域,容易踩雷:
1. 最外层的 var / function
var outerVar = '我在全局'
function outerFn() {
var inner = '我在函数里'
}
console.log(outerVar) // '我在全局'
console.log(inner) // ReferenceError: inner is not defined2. 不声明直接赋值(隐式全局)
function bad() {
variable = '我没用 var/let 声明,但被赋值了'
var local = '我有 var'
}
bad()
console.log(variable) // '我没用 var/let 声明,但被赋值了' ← 进了全局
console.log(local) // ReferenceError这是 JS 历史最坑的"特性"之一。严格模式('use strict')下会直接抛 ReferenceError,ESM 模块默认就是严格模式,所以现代代码基本碰不到这个坑了。
3. window 对象的内置属性
window.name / window.location / window.top 这些默认就在全局作用域。所以你写 var name = '张三' 实际是 window.name = '张三',而 window.name 是浏览器内置的字符串属性,会被强制转成字符串。
var name = 123
console.log(typeof name) // 'string' ← 不是 'number',因为 window.name 强制转字符串了全局污染:为什么 jQuery 要把代码包在 (function(){...})() 里
如果几个开发者各自写 var data = {...} 而都不放进函数,后定义的会覆盖前定义的:
var data = { a: 100 }
var data = { x: true }这就是为啥 jQuery / Zepto 这些老库,源码全包在一个立即执行函数表达式(IIFE) 里:
;(function (window) {
var $ = function () { ... }
window.jQuery = window.$ = $
})(window)里面定义的所有变量都在这个 IIFE 的函数作用域里,不会泄漏到全局,只通过显式挂到 window 上的方式对外暴露 1-2 个入口。
不过这个套路在 ESM 模块时代基本不需要了 —— 每个 .js/.mjs 模块文件本身就是一个独立作用域,顶层声明的 let/const/function 不会污染全局,只通过 export 显式对外暴露。
函数作用域:经典的"隔离地盘"
函数体内部 var / let / const / function 定义的标识符,外部都看不见:
function doSomething() {
var stuName = 'zhangsan'
function innerSay() {
console.log(stuName)
}
innerSay()
}
console.log(stuName) // ReferenceError
innerSay() // ReferenceError作用域是分层的:内层能看到外层,外层看不到内层。
用嵌套的盒子可视化:
┌──── 全局作用域 ─────────────────────────────┐
│ │
│ foo ← 在这层声明 │
│ │
│ ┌──── foo() 的作用域 ────────────────────┐ │
│ │ │ │
│ │ a, bar, b ← 在这层声明 │ │
│ │ │ │
│ │ ┌──── bar() 的作用域 ──────────────┐ │ │
│ │ │ │ │ │
│ │ │ c ← 在这层声明 │ │ │
│ │ │ │ │ │
│ │ │ 能看到外面所有的: │ │ │
│ │ │ a / bar / b(foo 的) │ │ │
│ │ │ foo(全局的) │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────┘bar 里能直接访问 a、b、foo;foo 里能看到 foo(自己),但 看不到 c(它在 bar 的盒子里)。
var 没有块级作用域
if / for / while / switch / try-catch 这些块的大括号 { ... },对 var 不会创建新作用域:
if (true) {
var name = 'Hammad'
}
console.log(name) // 'Hammad' ← 跑出来了!如果你以为 var name 只在 if 里有效,会被这种行为坑得一脸懵。这正是 ES6 引入 let / const 块级作用域的核心动机。
块级作用域:ES6 给我们松绑的礼物
let / const / class 严格遵守 { } 的边界:
function getValue(condition) {
if (condition) {
let value = 'blue'
return value
} else {
return null
}
}value 只在 if 块内可见,出了块就消失。
块级作用域的几个关键点:
1. 不会跨块"提升"出去
function f() {
if (true) {
let x = 1
}
console.log(x) // ReferenceError(变量 x 不在这一层)
}var 这种"假装在块里,实际在函数里"的歧义彻底没了。
2. 同一块里禁止重复声明
let count = 30
let count = 40 // SyntaxError: Identifier 'count' has already been declared但不同块里同名 let 互不干扰:
let count = 30
if (true) {
let count = 40 // OK,新块,新 binding
}3. for 循环 + let 的神奇之处
这是块级作用域最实用的应用之一。先看老 var 的经典坑:
<button>测试1</button>
<button>测试2</button>
<button>测试3</button>const btns = document.getElementsByTagName('button')
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = function () {
console.log('第' + (i + 1) + '个')
}
}点哪个按钮都弹"第 4 个"。因为 var i 是函数作用域(或者这里是全局),循环结束后 i = 3(最终值),三个 onclick 回调共享同一个 i。
换 let 就好了:
for (let i = 0; i < btns.length; i++) {
btns[i].onclick = function () {
console.log('第' + (i + 1) + '个')
}
}为什么这次对?因为 for (let ...) 在规范里有个特殊处理:每次循环都会创建一个新的块级作用域,i 是这一轮独有的。三次循环 → 三个独立的 i binding(分别是 0/1/2),回调函数闭包捕获的是各自轮次的那一份。
ES6 之前要实现这个效果,只能再套一层 IIFE 强行制造作用域:
for (var i = 0; i < btns.length; i++) {
;(function (i) {
btns[i].onclick = function () {
console.log('第' + (i + 1) + '个')
}
})(i)
}历史已经过去,新代码直接用 let 就行。
自由变量与作用域链
自由变量(Free Variable):在当前作用域里没有声明、但被引用的变量。
var a = 100
function fn() {
var b = 200
console.log(a) // a 在 fn 里没有声明,但被引用了 ← 自由变量
console.log(b) // b 在 fn 里有声明 ← 不是自由变量
}
fn()自由变量怎么求值?沿"作用域链"向外层逐级查找,直到找到或者到全局还没找到为止。
var a = 100
function f1() {
var b = 200
function f2() {
var c = 300
console.log(a) // 100 ← 自由变量,在 f1 里没找到,继续找到全局
console.log(b) // 200 ← 自由变量,在 f1 里找到
console.log(c) // 300 ← 本作用域有,不需要查链
}
f2()
}
f1()f2 的作用域链大致是:
f2 作用域(c) → f1 作用域(b) → 全局作用域(a) → null查找时从左往右(从内到外)。
词法作用域:JS 最关键的设计选择
到这里有个最最最重要的问题:
"向外层"是指什么?是定义的位置,还是调用的位置?
这是 JS 面试的杀手题。先看下面这段代码:
var x = 10
function fn() {
console.log(x)
}
function show(f) {
var x = 20
f()
}
show(fn)输出是 10,不是 20。
为什么?直觉上 f() 是在 show 里被调用的,show 里有个 x = 20,应该输出 20 才对啊?
因为 JS 用的是词法作用域(Lexical Scope),也叫静态作用域(Static Scope)。
词法作用域 = 作用域由源代码的写法(词法位置) 决定,而不是由运行时的调用栈决定。
fn 是在全局作用域被定义的,所以它的作用域链是:
fn 作用域 → 全局作用域(x=10)不管你在哪儿调它,这条链都一样。show 里那个 x = 20 和 fn 没关系 —— fn 的链上根本没有 show 这一环。
如果 JS 用的是动态作用域(Dynamic Scope,沿调用栈往上找),那才会输出 20。Bash 脚本 / 早期 Lisp 是动态作用域。绝大多数现代语言(JS / Python / Java / C++ / Rust / Go)都用词法作用域,因为可预测、能静态分析、性能更好。
再来一个加深印象:
const food = 'rice'
const eat = function () {
console.log(`eat ${food}`)
}
;(function () {
const food = 'noodle'
eat() // eat rice
})()eat 在全局被定义,它"看到的" food 永远是全局那一份('rice')。IIFE 里的 food = 'noodle' 和 eat 半毛钱关系都没有。
把 eat 挪进 IIFE 里再看:
const food = 'rice'
;(function () {
const food = 'noodle'
const eat = function () {
console.log(`eat ${food}`)
}
eat() // eat noodle
})()这次 eat 是在 IIFE 里定义的,它的作用域链:
eat 作用域 → IIFE 作用域(food='noodle') → 全局作用域(food='rice')查 food 在 IIFE 这层就找到了,输出 'noodle'。
一句话记住:"作用域链在函数被创建的那一刻就钉死了,不会因为它在哪儿被调用而改变。"
闭包就是"作用域链没断"
理解了词法作用域,闭包就一点都不神奇了:
function outer() {
const big = '我是 outer 的局部变量'
return function inner() {
return big
}
}
const fn = outer()
console.log(fn()) // '我是 outer 的局部变量'outer() 执行完之后,按理说它的执行上下文应该出栈、局部变量 big 应该 GC。但 fn(也就是 inner)被返回出去了,它的作用域链上还连着 outer 的作用域(词法作用域决定的,定义时就连好了)。
引擎一看:inner 这个函数还活着,而它的作用域链上引用着 outer 的环境记录 —— 那这个环境记录我不能销毁。于是 big 就一直挂在内存里。
这就是闭包的本质:
闭包 = 函数 + 它定义时所处的词法作用域链
不是什么"奇技淫巧",就是词法作用域 + 引擎不敢 GC 一个还被引用着的环境而已。
这也是上一篇 执行上下文 里说"闭包阻止上下文销毁"的来由。
作用域 vs 执行上下文
这两个概念最容易混。一张表对比:
| 维度 | 作用域(Scope) | 执行上下文(Execution Context) |
|---|---|---|
| 何时确定 | 定义时(代码写完那一刻) | 调用时(运行到调用那一刻) |
| 数量 | 一个函数 → 一份(静态) | 一个函数调用 N 次 → N 份(每次新建) |
| 关心什么 | 哪些名字能被看到 | 当前 this 是谁 / 局部变量当前值是多少 / 调用是谁 |
| 变不变 | 永远不变 | 每次调用都不一样 |
| 类比 | 房子的结构图(哪面墙是哪面墙) | 房子里现在住的人(每次入住都不同) |
关键认知:
一个函数只有一份作用域链(定义时就钉死),但可以有 N 份执行上下文(每次调用一份)。
function counter(start) {
let n = start
return function () {
return ++n
}
}
const a = counter(10)
const b = counter(100)
a() // 11
a() // 12
b() // 101
b() // 102counter 内部那个匿名函数的作用域链都一样(都连到 counter 的局部作用域),但 counter(10) 和 counter(100) 分别创建了两份独立的执行上下文 —— 每份有自己的 n(分别是 10 和 100)。所以 a 和 b 互不干扰。
理解了这一点,React Hooks 的 state 隔离、Vue 3 的 setup 函数,这些"为什么每次组件实例的状态都是独立的",底层都是同一个原理。
现代实践
收尾几条工程建议:
- 不要再用
var。用let/const—— 块作用域 + TDZ 让代码更安全,可读性也好 - 优先用
const,真的需要重新赋值再用let。"先 const,实在不行换 let"是个不错的默认习惯 - 代码组织用 ESM 模块,每个
.js文件天然就是一个隔离作用域,不用再写 IIFE 包裹 - 禁止用
with—— 它会运行时动态改变作用域链,V8 优化器会直接放弃整个函数(性能塌方) - 避免
eval(也是动态作用域杀手) —— 实在要做模板求值用Function constructor或 AST 解析库 - 嵌套不要太深 —— 标识符查找需要遍历作用域链,5-6 层嵌套以上微优化时可以"在内层用局部变量缓存外层引用"(虽然 V8 大多数情况已经能内联)
一句话总结
作用域是 JS 引擎"查找标识符"的规则,作用域链是这些规则串成的一条静态链条。JS 用词法作用域 —— 链条在函数被定义的那一刻就钉死了,调用栈里发生什么都改不了它。
把这一篇 + 前两篇连起来看,JS 的运行时模型就完整了:
| 概念 | 答的问题 | 关键词 |
|---|---|---|
| 原型链 | 一个对象的某个属性在哪儿? | __proto__ 沿链上查 |
| 作用域链 | 一个标识符(变量名/函数名)在哪儿? | 沿词法链外查 |
| 调用栈 | 当前代码跑到哪一层了? | 函数调用 LIFO |
| 执行上下文 | 这一层代码运行时的环境是什么? | this / 局部变量 / 作用域链快照 |
这四个东西凑齐,JS 工程师在面试和日常 debug 中遇到的"诡异行为",几乎都能用其中一两个解释清楚。
下次见。
-EOF-