返回首页

JS 作用域与作用域链:为什么 fn() 看到的是"它出生那一刻"的世界

原型链讲"对象怎么找属性",执行上下文讲"代码怎么跑起来",这一篇讲"标识符怎么被查找"。把全局/函数/块级作用域、自由变量、词法(静态)作用域、闭包本质、作用域和执行上下文的区别一次性讲透。

继续 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 defined

2. 不声明直接赋值(隐式全局)

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 里能直接访问 abfoo;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 = 20fn 没关系 —— 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()  // 102

counter 内部那个匿名函数的作用域链都一样(都连到 counter 的局部作用域),但 counter(10)counter(100) 分别创建了两份独立的执行上下文 —— 每份有自己的 n(分别是 10 和 100)。所以 ab 互不干扰。

理解了这一点,React Hooks 的 state 隔离、Vue 3 的 setup 函数,这些"为什么每次组件实例的状态都是独立的",底层都是同一个原理。

现代实践

收尾几条工程建议:

  1. 不要再用 var。用 let / const —— 块作用域 + TDZ 让代码更安全,可读性也好
  2. 优先用 const,真的需要重新赋值再用 let。"先 const,实在不行换 let"是个不错的默认习惯
  3. 代码组织用 ESM 模块,每个 .js 文件天然就是一个隔离作用域,不用再写 IIFE 包裹
  4. 禁止用 with —— 它会运行时动态改变作用域链,V8 优化器会直接放弃整个函数(性能塌方)
  5. 避免 eval(也是动态作用域杀手) —— 实在要做模板求值用 Function constructor 或 AST 解析库
  6. 嵌套不要太深 —— 标识符查找需要遍历作用域链,5-6 层嵌套以上微优化时可以"在内层用局部变量缓存外层引用"(虽然 V8 大多数情况已经能内联)

一句话总结

作用域是 JS 引擎"查找标识符"的规则,作用域链是这些规则串成的一条静态链条。JS 用词法作用域 —— 链条在函数被定义的那一刻就钉死了,调用栈里发生什么都改不了它

把这一篇 + 前两篇连起来看,JS 的运行时模型就完整了:

概念 答的问题 关键词
原型链 一个对象的某个属性在哪儿? __proto__ 沿链上查
作用域链 一个标识符(变量名/函数名)在哪儿? 沿词法链外查
调用栈 当前代码跑到哪一层了? 函数调用 LIFO
执行上下文 这一层代码运行时的环境是什么? this / 局部变量 / 作用域链快照

这四个东西凑齐,JS 工程师在面试和日常 debug 中遇到的"诡异行为",几乎都能用其中一两个解释清楚。


下次见。

-EOF-