深入理解 JavaScript 闭包与作用域

在 JavaScript 开发中,闭包和作用域是两个绕不开的核心概念。它们不仅影响着代码的行为,还决定了变量的生命周期和访问权限。本文将从基础概念出发,通过实例代码和跨语言对比,带你彻底理解这两个概念。

什么是闭包

闭包(Closure)= 函数 + 函数能够访问的变量环境

这个定义听起来抽象,换个说法:闭包就是函数"记住了"它定义时的作用域,即使这个函数在作用域外被调用,它依然能访问到当时作用域里的变量。

看一个最基础的例子:

function outer() {
  let count = 0;
  function inner() {
    count++;
    console.log(count);
  }
  return inner;
}

const fn = outer();
fn(); // 输出 1
fn(); // 输出 2
fn(); // 输出 3

按照常规理解,outer 函数执行完毕后,它的局部变量 count 应该被销毁。但实际上,inner 函数"记住了"它定义时所在的环境,仍然能访问 count。这就是闭包的魔力:函数能"带走"它定义时的词法环境

闭包的实际应用场景

理解了闭包的定义,我们来看看它在实际开发中的三个典型用途。

封装私有变量

JavaScript 没有像 Java 那样的 private 关键字,闭包可以模拟私有变量:

function Counter() {
  let count = 0;
  return {
    inc: () => ++count,
    get: () => count
  };
}

const c = Counter();
console.log(c.inc()); // 1
console.log(c.inc()); // 2
console.log(c.get()); // 2

外部代码无法直接访问 count,只能通过暴露的方法操作,实现了数据的封装和保护。

保持状态

在事件回调、定时器等异步场景中,闭包可以帮助我们保留外部变量的值:

function setupTimer(message) {
  setTimeout(() => {
    console.log(message); // 闭包保持了对 message 的引用
  }, 1000);
}

setupTimer('Hello from closure');

函数工厂

闭包可以根据不同参数生成定制化的函数:

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(3));  // 8
console.log(add10(3)); // 13

每个生成的函数都"记住了"自己的 x 值,形成了独立的闭包环境。

var、let、const 的作用域差异

在深入闭包的"坑"之前,必须先理解 JavaScript 的三种变量声明方式。

三者的核心区别

关键字作用域类型是否可重复声明是否可修改提升行为
var函数作用域提升并初始化为 undefined
let块级作用域提升但存在暂时性死区
const块级作用域否(对象内容可变)提升但存在暂时性死区

块级作用域 vs 函数作用域

// var:函数作用域
function testVar() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10,x 在整个函数内可见
}

// let:块级作用域
function testLet() {
  if (true) {
    let y = 10;
  }
  console.log(y); // ReferenceError: y is not defined
}

块级作用域指的是由一对花括号 {} 包裹的区域,letconst 声明的变量只在该区域内有效。而 var 声明的变量会"穿透"块级作用域,提升到整个函数或全局作用域。

经典问题:循环中的闭包陷阱

现在我们把闭包和作用域结合起来,看一个前端开发中极其常见的"坑"。

问题演示:var 版本

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>var vs let 作用域演示</title>
  <style>
    body { 
      font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial; 
      padding: 24px; 
    }
    h2 { margin: 24px 0 12px; }
    .container { 
      display: flex; 
      gap: 8px; 
      flex-wrap: wrap; 
      margin-bottom: 24px; 
    }
    .box { 
      padding: 8px 12px; 
      border: 1px solid #ccc; 
      border-radius: 6px; 
      cursor: pointer; 
      user-select: none; 
    }
  </style>
</head>
<body>
  <h2>使用 var(点击任意按钮都会 alert 5)</h2>
  <div id="container-var" class="container"></div>

  <h2>使用 let(点击按钮 alert 自己的索引)</h2>
  <div id="container-let" class="container"></div>

  <script>
    // 演示一:var —— 计数器 i 是函数/全局作用域,循环结束后为 5
    (function demoVar(){
      var container = document.getElementById('container-var');
      for (var i = 0; i < 5; i++) {
        var btn = document.createElement('div');
        btn.className = 'box';
        btn.textContent = 'box ' + i;
        btn.onclick = function () {
          // 这里读取到的是同一个 i(循环结束后等于 5)
          alert('This is box #' + i);
        };
        container.appendChild(btn);
      }
    })();

    // 演示二:let —— 计数器 i 具有块级作用域,每次迭代都有自己的 i
    (function demoLet(){
      const container = document.getElementById('container-let');
      for (let i = 0; i < 5; i++) {
        const btn = document.createElement('div');
        btn.className = 'box';
        btn.textContent = 'box ' + i;
        btn.onclick = function () {
          alert('This is box #' + i);
        };
        container.appendChild(btn);
      }
    })();
  </script>
</body>
</html>

保存为 HTML 文件打开后,你会发现:

  • 第一组按钮(var):点击任何一个都弹出 “This is box #5”
  • 第二组按钮(let):点击按钮 0-4 分别弹出对应的索引号

问题根源分析

var 版本:所有闭包共享同一个变量
for (var i = 0; i < 5; i++) {
  var btn = document.createElement('div');
  btn.onclick = function () {
    alert('This is box #' + i);
  };
  container.appendChild(btn);
}

时间线拆解:

  1. var i 声明在函数作用域内,整个循环过程中只有一个 i 变量
  2. 循环创建了 5 个点击事件处理函数(5 个闭包)
  3. 这 5 个闭包都引用同一个 i 变量(不是值,是变量本身)
  4. 循环迅速结束,此时 i === 5
  5. 用户点击按钮时,所有闭包读取的都是那个已经变成 5 的 i

关键点:闭包捕获的是"变量的引用",而不是"变量的快照"。

直观图示:

[变量 i (最终值=5)]
    ↑   ↑   ↑   ↑   ↑
    |   |   |   |   |
闭包0 闭包1 闭包2 闭包3 闭包4
let 版本:每次迭代创建独立绑定
for (let i = 0; i < 5; i++) {
  const btn = document.createElement('div');
  btn.onclick = function () {
    alert('This is box #' + i);
  };
  container.appendChild(btn);
}

ECMAScript 规范规定:当 for 循环使用 letconst 声明计数器时,每次迭代都会创建一个新的词法环境,相当于为 i 拷贝一份新的绑定。

时间线拆解:

  1. 第 1 次迭代:创建 i₀ = 0,闭包 0 引用 i₀
  2. 第 2 次迭代:创建 i₁ = 1,闭包 1 引用 i₁
  3. 第 3 次迭代:创建 i₂ = 2,闭包 2 引用 i₂
  4. 第 4 次迭代:创建 i₃ = 3,闭包 3 引用 i₃
  5. 第 5 次迭代:创建 i₄ = 4,闭包 4 引用 i₄

直观图示:

[i₀=0]  [i₁=1]  [i₂=2]  [i₃=3]  [i₄=4]
  ↑       ↑       ↑       ↑       ↑
闭包0   闭包1   闭包2   闭包3   闭包4

ES5 环境的解决方案

如果必须使用 var(比如在老旧浏览器或遗留项目中),有三种常见的解决方案。

方案一:立即执行函数(IIFE)
for (var i = 0; i < 5; i++) {
  (function (j) {
    var btn = document.createElement('div');
    btn.onclick = function () { 
      alert('This is box #' + j); 
    };
    container.appendChild(btn);
  })(i);
}

通过 IIFE 将当次的 i 值作为参数 j 传入,每个闭包引用的是不同的 j 参数。

方案二:函数工厂
function makeHandler(n) {
  return function () { 
    alert('This is box #' + n); 
  };
}

for (var i = 0; i < 5; i++) {
  var btn = document.createElement('div');
  btn.onclick = makeHandler(i);
  container.appendChild(btn);
}

将闭包的创建封装到一个工厂函数中,每次调用都产生新的词法环境。

方案三:数据属性存储
for (var i = 0; i < 5; i++) {
  var btn = document.createElement('div');
  btn.dataset.index = i;
  btn.onclick = function () { 
    alert('This is box #' + this.dataset.index); 
  };
  container.appendChild(btn);
}

不依赖闭包,将值直接存储在 DOM 元素的 data-* 属性中。

setTimeout 中的同类问题

这个问题不仅出现在事件绑定中,在定时器场景同样常见:

// 问题代码
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:5 5 5 5 5

// 解决方案 1:使用 let
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2 3 4

// 解决方案 2:IIFE
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(() => console.log(j), 0);
  })(i);
}
// 输出:0 1 2 3 4

跨语言对比:Python 中的作用域

对于从 Python 转向 JavaScript 的开发者,理解两种语言在变量声明和作用域上的差异非常重要。

JavaScript vs Python:变量声明对比

JavaScriptPython 对应特点
let普通变量(x = 10可变,Python 没有块级作用域,只有函数/类/模块作用域
const大写变量名MAX_SIZE = 100Python 无真正常量机制,仅靠命名约定
var-Python 无对应,所有变量默认类似 let 但作用域更宽松

Python 如何表达"常量"

虽然 Python 没有内建的常量关键字,但有几种惯用方法:

命名约定(最常用)
MAX_SIZE = 100
API_KEY = "your_key_here"

开发者看到全大写变量名就知道这是常量,虽然技术上仍可修改。

类封装
class Config:
    PI = 3.14159
    MAX_RETRY = 3

# 使用
print(Config.PI)
# 虽然能改,但不符合约定
Config.PI = 123  # ❌ 不推荐
typing.Final 类型注解(Python 3.8+)
from typing import Final

MAX_SIZE: Final = 100
MAX_SIZE = 200  # IDE 会警告,但运行时不会报错

Final 提供了类型检查层面的约束,但不是运行时强制。

作用域的关键差异

JavaScript(let/const)

if (true) {
  let x = 10;
}
console.log(x); // ReferenceError: x is not defined

Python

if True:
    x = 10
print(x)  # 输出:10,变量在 if 块外仍可访问

Python 没有块级作用域,ifforwhile 等结构内定义的变量在结构外依然可见。只有函数、类和模块才会创建新的作用域。

Python 中的闭包

Python 同样支持闭包,但在修改外层变量时需要 nonlocal 关键字:

def outer():
    count = 0
    def inner():
        nonlocal count  # 必须显式声明
        count += 1
        print(count)
    return inner

fn = outer()
fn()  # 输出:1
fn()  # 输出:2

JavaScript 中不需要这样的声明,闭包可以直接修改外层变量。

闭包的潜在风险

闭包虽然强大,但使用不当会带来问题。

内存泄漏风险

function attachEvents() {
  const largeData = new Array(1000000).fill('data');
  
  document.getElementById('btn').onclick = function() {
    // 即使不使用 largeData,闭包也会保持对它的引用
    console.log('clicked');
  };
}

如果闭包长期存在(比如事件处理器未被移除),它引用的所有外层变量都无法被垃圾回收。

解决方案

function attachEvents() {
  const largeData = new Array(1000000).fill('data');
  
  // 处理完数据后立即释放引用
  processData(largeData);
  
  document.getElementById('btn').onclick = function() {
    console.log('clicked');
  };
}

意外的变量共享

const callbacks = [];
for (var i = 0; i < 3; i++) {
  callbacks.push(() => i);
}

console.log(callbacks.map(fn => fn())); // [3, 3, 3]

这就是前面讨论过的问题,使用 let 或其他解决方案即可避免。

核心要点总结

闭包的本质:函数能"带走"它定义时的词法环境,记忆外层变量。

作用域规则

  • var:一个变量,循环后值为最终值,所有闭包共享
  • let/const:每轮迭代创建独立绑定,各自保存当轮的值

实用建议

  • 优先使用 letconst,避免 var
  • 理解闭包捕获的是"引用"而非"值"
  • 注意长生命周期闭包可能导致的内存占用
  • 跨语言开发时注意作用域规则的差异

记忆口诀

  • 闭包 = 函数 + 环境
  • var = 共享变量,let = 独立绑定
  • 捕获引用,非快照
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐