从 Java 到 JS:我终于把闭包想明白了

我是 Java 出身的后端开发,最近在学 Agent 开发,被迫开始读 TypeScript。

Groovy 写 Gradle 脚本的时候见过闭包,Kotlin 里也见过,但我一直是"照着抄能跑就行"的状态,从没真正理解它。直到我读一份 Agent 教学代码,被下面这十行卡住:

ts 复制代码
async function runAgentLoop(input: string) {
  const events: AgentEvent[] = [];
  const emit: Emit = (event) => {
    events.push(event);
    printEvent(event);
  };

  const model = new TinyModel();
  await model.complete(messages, emit);   // ← emit 被传进了另一个类
}

emit 被传进 TinyModel 之后,在完全不同的调用栈里执行。那它 push 进去的 events,还是外面那个数组吗?

答案是"是"。而为什么是,就是这篇文章要讲的东西。

写完这篇,我把自己踩过的每一个坑都复盘了一遍,包括一个让我卡了很久、事后觉得很蠢但当时就是想不通的问题。


一、先给结论

教程里最常见的说法是"闭包就是函数可以访问外部变量"。这句话没错,但它只描述了现象 ,没解释机制------我当初就是被这句话糊弄过去的。

准确的定义是:

闭包 = 函数代码 + 它诞生时所处的那个变量环境

这两部分被打包成一个整体,可以到处传递。

graph LR subgraph 闭包[&#34;闭包 Closure&#34;] direction TB A[&#34;函数代码<br/>events.push(event)&#34;] B[&#34;捕获的环境<br/>events → 某个数组&#34;] end A -.- B 闭包 --> C[&#34;可以作为值<br/>传递 / 返回 / 存储&#34;]

"闭"(closed over)指的是:函数体里那些既不是参数、也不是自己声明的局部变量 的变量------叫自由变量(free variable)------被"封闭"进了这个包裹,不会因为函数被传到别处而失效。

对我这种 Java 背景的人,最有用的一句类比是:

对象是"附带了行为的数据";闭包是"附带了数据的行为"。

两者是对偶的。想想 Java 8 之前我们怎么写回调------new Runnable() { ... } 匿名内部类,本质就是在手工制造闭包。一个只有单个方法的对象,和一个闭包,能力上完全等价。


二、我的第一个误解:捕获的是"值"还是"盒子"

这是最关键的一点,也是 Java 和其他语言差异的根源。

先看 JS:

js 复制代码
let count = 0;
const inc = () => { count++; };
inc();
inc();
console.log(count);   // 2  ← 外面的 count 真的被改了

如果闭包捕获的是"值的快照",count 应该还是 0。但它捕获的是变量本身(那个存储位置),所以内外看到的是同一个东西。

我习惯把它叫做盒子:闭包拿到的不是盒子里的数字,是盒子本身。

再看 Java:

java 复制代码
int count = 0;
Runnable inc = () -> { count++; };   // ❌ 编译错误

报错:local variables referenced from a lambda expression must be final or effectively final

为什么 Java 不行?

因为 JVM 上局部变量在栈帧里,方法一返回栈帧就没了。Java 选择的方案是:把捕获的变量按值复制一份,塞进 lambda 对象的合成字段。

既然是副本,允许你改就会产生"改了副本、外面看不见"的诡异语义。Java 干脆禁止------用 effectively final 从源头堵死。

graph TB subgraph Java[&#34;Java:按值复制&#34;] direction LR JV[&#34;栈上 count = 0&#34;] -->|&#34;创建 lambda 时<br/>复制一份&#34;| JC[&#34;lambda 的合成字段<br/>count = 0&#34;] JC -.->|&#34;改了也传不回去<br/>所以禁止修改&#34;| JV end subgraph JS[&#34;JS / Kotlin / Groovy:共享盒子&#34;] direction LR SV[&#34;堆上的盒子<br/>count = 0&#34;] SC[&#34;闭包&#34;] -->|&#34;持有引用&#34;| SV SO[&#34;外层作用域&#34;] -->|&#34;持有引用&#34;| SV end

Java 里怎么绕开? 手工造一个盒子就行:

java 复制代码
int[] count = {0};                     // 盒子在堆上
Runnable inc = () -> { count[0]++; };  // 引用 count 本身没变,改的是盒子内容 ✅

// 或者更体面:
AtomicInteger count = new AtomicInteger();
Runnable inc = () -> count.incrementAndGet();

注意这里 effectively final 的是引用 count,盒子里的内容随便改。

这个手工技巧,正是理解 Kotlin 的钥匙。


三、Kotlin / Groovy / JS:差别只在"谁来造盒子"

kotlin 复制代码
var count = 0
val inc = { count++ }    // ✅ Kotlin 允许
inc(); inc()
println(count)           // 2

Kotlin 凭什么能改?因为编译器帮你把盒子造了。这段代码编译后大致等价于:

java 复制代码
Ref.IntRef count = new Ref.IntRef();      // ← 就是那个 int[],只是自动生成的
count.element = 0;
Function0 inc = () -> { count.element++; };

Kotlin 只是把我在 Java 里手写的 int[]{0} 自动化了。语义上"捕获变量",实现上"捕获盒子的引用"。

Groovy 同理,闭包按引用捕获、可以直接修改:

groovy 复制代码
def count = 0
def inc = { count++ }
inc(); inc()
println count   // 2

JS 更彻底:变量本来就存在堆上的环境记录(Environment Record)里,不存在栈上,天然就是盒子,什么都不用做。

语言 能改捕获的变量 底层机制
Java 按值复制,强制 effectively final
Kotlin 编译器自动生成 Ref.XxxRef 包装盒
Groovy 按引用捕获
JS / TS 变量本就在堆上的环境记录里

能力上四者是一样的(Java 手动加个盒子就等价),差的只是语法糖的厚度。

我当初在 Groovy/Kotlin 里觉得"闭包很神奇",神奇的其实是编译器替我消除了样板代码。

⚠️ 一个容易混进来的东西:Groovy 闭包还有 delegate / owner / resolveStrategy 那一套,Gradle 的 dependencies { ... } DSL 就靠它实现。

那是 Groovy 在闭包之上额外加的动态派发机制,不是闭包的固有属性。 如果你的困惑来自 Gradle 脚本,混进来的多半是这部分------JS 和 Kotlin 的闭包没有它。


四、卡住我最久的问题

下面这段代码,我盯着看了很久:

js 复制代码
function makeCounter() {
  let count = 0;
  return () => ++count;
}

const c1 = makeCounter();
const c2 = makeCounter();

c1();   // 1
c1();   // 2   ← 我卡在这里
c2();   // 1

我的疑问是:count 明明是 let 定义在函数内部的局部变量,调用两次 c1(),不是应该每次都重新初始化成 0 吗?为什么是 2?

真相:let count = 0 到底什么时候执行

它在调用 makeCounter() 执行,不是 在调用 c1() 时执行。

js 复制代码
function makeCounter() {
  let count = 0;          // ← A 行
  return () => ++count;   // ← B 行
}

c1 拿到的是 B 行返回的那个箭头函数。这个函数的函数体只有 ++count 这一句,A 行根本不在它身体里。

所以 c1() 执行时只做一件事:++count。它压根不会"重新执行 let count = 0",因为那行代码不属于它。

sequenceDiagram participant M as 主程序 participant F as makeCounter participant B1 as 盒子① count participant B2 as 盒子② count M->>F: makeCounter() activate F F->>B1: 执行 A 行,新建盒子① = 0 F-->>M: 返回闭包 c1(持有盒子①) deactivate F M->>F: makeCounter() activate F F->>B2: 再次执行 A 行,新建盒子② = 0 F-->>M: 返回闭包 c2(持有盒子②) deactivate F M->>B1: c1() → ++ B1-->>M: 1 M->>B1: c1() → ++ B1-->>M: 2 M->>B2: c2() → ++ B2-->>M: 1

一句话总结:let count = 0 执行了 2 次(因为 makeCounter 被调了 2 次),++count 执行了 3 次。

c1 输出 2,不是因为 count 神奇地存活,而是因为重置它的那行代码根本没有被再次执行

let 是不是原因?

不是。换成 var count = 0,结果还是 1、2、1。

letvar 的区别在作用域范围循环里的每轮绑定 ,跟"每次调用是否重新初始化"无关。任何局部变量声明都是"每次函数被调用时执行一遍",Java 也一样。

换成 Java 我立刻就懂了

java 复制代码
class Counter {
    private int count = 0;                  // ← 相当于 A 行
    public int inc() { return ++count; }    // ← 相当于 B 行返回的闭包
}

Counter c1 = new Counter();   // ≈ makeCounter(),字段初始化跑一次
Counter c2 = new Counter();   // ≈ makeCounter(),又跑一次,独立实例

c1.inc();  // 1
c1.inc();  // 2   ← 我绝不会奇怪这里为什么是 2
c2.inc();  // 1

我不会问"c1.inc() 调两次为什么是 2"------因为我清楚字段初始化发生在 new 的时候,不是每次调方法的时候

闭包一模一样:

graph LR subgraph JS闭包 A1[&#34;makeCounter()&#34;] --> A2[&#34;c1&#34;] --> A3[&#34;c1()&#34;] end subgraph Java对象 B1[&#34;new Counter()&#34;] --> B2[&#34;实例 c1&#34;] --> B3[&#34;c1.inc()&#34;] end A1 -.等价.-> B1 A2 -.等价.-> B2 A3 -.等价.-> B3
  • 外层函数 makeCounter构造器
  • 被捕获的 count私有字段(不是方法内的局部变量!)
  • 内层函数 ≈ 实例方法
  • c1那个实例

我之所以困惑,是因为 count 在源码里"长得像"方法局部变量。但从闭包的角度,makeCounter 是构造器,count 是字段。

顺带一提,这个"字段"比 private 还严格------外界绝无可能访问,连反射都摸不到。

什么情况才会每次都归零

js 复制代码
function counter() {
  let count = 0;
  return ++count;    // 不返回函数,直接返回值
}
counter();   // 1
counter();   // 1   ← 这才是"每次重新初始化"

这里每次调 counter() 都重新执行 let count = 0,所以永远是 1。

差别就在于:makeCounter 把「初始化」和「使用」拆到了两次不同的调用里------初始化只在外层函数被调用时发生一次,使用可以在内层函数上发生无数次。

这就是闭包能当"状态容器"用的全部原理。


五、盒子的数量由什么决定

想通上面那点之后,我一度以为规律是"有没有用 const 变量接住返回值"。这个归因是错的。

真正的规则是:

调用一次外层函数 → 新建一个盒子 调用一次内层闭包 → 对它自己的那个盒子 ++

外层调用次数 盒子数 每个盒子被 ++ 次数 输出
counter(); counter(); 2 2 各 1 次 1, 1
const c1 = makeCounter(); c1(); c1(); 1 1 2 次 1, 2

const 在这张表里根本没出现------它不是变量。两个反例可以证明:

反例一:不用 const,照样新建盒子

js 复制代码
makeCounter()();   // 1
makeCounter()();   // 1  ← makeCounter 调了两次 = 两个盒子

反例二:两个变量名指向同一个闭包,盒子还是一个

js 复制代码
const c1 = makeCounter();
const alias = c1;      // 只是复制了函数引用,没有调用 makeCounter
c1();      // 1
alias();   // 2  ← 同一个盒子

如果"有 const 承接 = 一个盒子",这里两个 const 该有两个盒子才对。

可见盒子绑在函数对象 上,不绑在变量名上。const c1 = ... 的作用只是给我一个句柄,让我能反复调用同一个闭包

graph TB subgraph 反例二 V1[&#34;变量 c1&#34;] --> FN[&#34;同一个函数对象&#34;] V2[&#34;变量 alias&#34;] --> FN FN --> BOX[&#34;盒子① count&#34;] end

六、生命周期:局部变量为什么没有死

js 复制代码
function makeCounter() {
  let count = 0;
  return () => ++count;
}
const c1 = makeCounter();   // makeCounter 早就返回了
c1();  // 但 count 还活着

按 Java 栈帧的直觉,makeCounter 返回时 count 就该销毁了。但只要闭包还活着,它引用的环境就不会被 GC 回收。

局部变量的生命周期,从"函数执行期"延长到了"闭包存活期"。

graph TB subgraph 有闭包[&#34;makeCounter:环境活下来了&#34;] M1[&#34;makeCounter() 返回&#34;] --> M2[&#34;盒子① 仍被 c1 引用&#34;] M2 --> M3[&#34;不会被 GC ✅&#34;] end subgraph 无闭包[&#34;counter:环境立即回收&#34;] N1[&#34;counter() 返回数字 1&#34;] --> N2[&#34;盒子无人引用&#34;] N2 --> N3[&#34;立刻被 GC ❌&#34;] end

这也解释了 counter() 的根本问题:它返回的是数字,不是函数

返回之后 count 这个盒子没有任何东西引用它了,立刻变成垃圾。哪怕我写 const x = counter()x 也只是数字 1,连 x() 都调不了。

新建盒子的动作两者完全一样,差别在于新建出来的盒子有没有活下来。


七、经典陷阱:循环里捕获变量

这个坑几乎每种语言都踩过:

js 复制代码
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:3 3 3   ❌

var 是函数级作用域,整个循环只有一个 i 盒子 ,三个闭包共享它。等回调真正执行时循环早跑完了,i 已经是 3。

js 复制代码
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2   ✅

letfor 循环里有特殊规则:每次迭代创建一个新的绑定,三个闭包各捕获各的盒子。

graph TB subgraph VAR[&#34;var:共享一个盒子&#34;] VI[&#34;盒子 i(最终值 3)&#34;] VC1[&#34;闭包1&#34;] --> VI VC2[&#34;闭包2&#34;] --> VI VC3[&#34;闭包3&#34;] --> VI end subgraph LET[&#34;let:每轮一个新盒子&#34;] LC1[&#34;闭包1&#34;] --> LI1[&#34;盒子 i=0&#34;] LC2[&#34;闭包2&#34;] --> LI2[&#34;盒子 i=1&#34;] LC3[&#34;闭包3&#34;] --> LI3[&#34;盒子 i=2&#34;] end

有意思的是,Java 那条我当初嫌烦的限制,在这里体现了价值------Java 直接不让你写出上半段这种 bugfor (int i = 0; ...)i 不是 effectively final,编译器会拦住你;而 for (String s : list)s 每轮是新变量,所以可以捕获。

实践结论:TS 里永远用 let / const,别用 var


八、生活场景:一张储值卡

概念讲完了,我用一个类比把上面所有机制串起来------健身房储值卡

graph LR A[&#34;办卡<br/>makeCounter()&#34;] --> B[&#34;店里开一个账户<br/>盒子 count&#34;] A --> C[&#34;拿到一张卡<br/>闭包 c1&#34;] C -->|&#34;记着账户号&#34;| B C --> D[&#34;刷卡<br/>c1()&#34;] D -->|&#34;扣的是绑定的那个账户&#34;| B
生活场景 代码 机制
办卡 makeCounter() 外层函数调用 → 创建环境
店里的账户 count 被捕获的自由变量(盒子)
c1 闭包 = 行为 + 绑定的环境
刷卡 c1() 内层函数调用 → 操作环境
办两张卡 调两次 makeCounter() 每次创建独立环境
卡在账户就在 --- 闭包延长变量生命周期
借卡给朋友异地刷 闭包传给别的函数 脱离原作用域仍持有环境
一次性卡用完就扔 counter() 返回数字 无闭包,环境立即回收

我那个困惑,在这里是常识

"调用两次 c1() 为什么是 2?内部不是 let count = 0 吗?"

翻译成生活场景就是:"刷两次卡为什么扣了两次?办卡的时候不是清零了吗?"

因为办卡只办了一次 。我不会每次进健身房都重新办一张卡。let count = 0 就是"办卡时开户"这个动作,它属于办卡流程 ,不属于刷卡流程

这么一说就完全不需要解释了。

其他细节也都对得上

两张卡互不干扰:同事去办卡,店家开的是另一个账户。你刷你的,他刷他的。

卡在,账户就在:只要卡还在钱包里,健身房就不会注销账户。卡(闭包)活着 → 账户(盒子)不会被回收。把卡扔了,账户才会被清理------这就是 GC。

捕获的是账户,不是余额数字 :如果卡上印着 "余额 100",那是快照,店里改了余额卡上也不会变。但实际是卡只记账户号,余额存在店里的系统里,在 A 门店刷、B 门店查,看到的是同一个数。

这就是"捕获盒子而不是值"。Java 之所以要求 effectively final,正是因为它选了"在卡上印数字"的方案------印上去就不能改了。

卡可以借给别人:你把卡借给朋友,他在另一个城市的分店刷------扣的还是你的账户。他不需要知道你的账户在哪、余额多少,只需要"有这张卡"。

最后这一条,正是我最开始被卡住的那段 Agent 代码在做的事。

另一个场景:贴好回邮地址的信封

对于回调式的闭包,我更喜欢这个类比:

我要出差,交给助理一沓已经写好我家地址、贴好邮票的空信封 。助理在外地办事,每收集到一份资料就塞进一个信封投进邮筒------东西自动寄到我家

graph LR Y[&#34;我<br/>外层函数&#34;] -->|&#34;交出信封(闭包)&#34;| A[&#34;助理<br/>另一个函数/类&#34;] A -->|&#34;塞资料 + 投递&#34;| E[&#34;信封<br/>emit&#34;] E -->|&#34;地址已封装在里面&#34;| H[&#34;我家<br/>被捕获的 events&#34;]

关键在于:助理完全不需要知道我家在哪。地址已经"封"在信封里了,他只管"塞进去、投出去"。


九、回到最开始卡住我的那段代码

现在再看开头那段 Agent 代码,它对我已经是透明的了:

ts 复制代码
async function runAgentLoop(input: string): Promise<Message[]> {
  const events: AgentEvent[] = [];        // ← 自由变量(盒子 / 账户)
  const emit: Emit = (event) => {         // ← 内层函数 = 闭包(卡 / 信封)
    events.push(event);
    printEvent(event);
  };

  const model = new TinyModel();
  await model.complete(messages, emit);   // ← 把闭包传出去(借卡 / 交信封)
}
sequenceDiagram participant R as runAgentLoop participant E as events 数组 participant M as TinyModel R->>E: 创建空数组 R->>R: 创建闭包 emit(捕获 events) R->>M: complete(messages, emit) activate M Note over M: TinyModel 完全不知道<br/>events 的存在 M->>E: emit(message_start) M->>E: emit(message_update) M->>E: emit(message_update) M->>E: emit(message_end) deactivate M Note over R,E: 所有事件都落进了<br/>runAgentLoop 里的那个数组

三个要点:

  1. emit 不是一个"纯函数" ,它是"函数体 + {events, printEvent} 这个环境"打包成的一个值;
  2. 它被传进 TinyModel.complete 后,在完全不同的调用栈里执行,但 events.push 推进去的还是 runAgentLoop 里那个数组------因为盒子被一起带过去了
  3. TinyModel 完全不知道 events 存在,它只看见一个 (AgentEvent) => void

第 3 点是设计上的收益:控制反转TinyModel 只负责"我产出了一个增量,通知出去",至于这个通知是打印到终端、推给 WebSocket、写进日志,还是三件事一起做,全由 runAgentLoop 决定。

对比一下没有闭包会有多难受------上下文得一路透传:

ts 复制代码
// 假如没有闭包
await model.complete(messages, events, printEvent);   // 参数越传越多

而且有了闭包,TinyModel 可以被单独测试,传一个只收集不打印的 emit 就行:

ts 复制代码
const captured: AgentEvent[] = [];
await new TinyModel().complete(msgs, (e) => captured.push(e));
// 断言 captured 的第一个是 message_start、最后一个是 message_end

Agent 框架里这种模式极其普遍:事件回调、工具的权限校验函数、取消信号,本质都是"发一张卡出去,让别人替我操作我的东西"。


十、总结

一句话提炼:

闭包就是"随身携带的账户"------把行为和它需要的那份状态绑在一起交出去,接收方只管用,不用知道状态在哪。

给同为 Java 背景的朋友留一份自检清单,遇到闭包相关的困惑,按顺序问自己:

  1. 哪个是外层函数,哪个是内层函数? 内层那个(引用了自由变量的)才叫闭包。
  2. 外层函数被调用了几次? 这决定了有几个独立的盒子。
  3. 初始化那行代码属于谁? 它在外层函数体里,就只在外层被调用时执行。
  4. 内层函数被传到哪里去了? 不管传多远,它操作的还是出生时那个盒子。
  5. 还有谁引用着这个闭包? 只要有,被捕获的变量就不会被 GC。

最后一条心得:当我把闭包翻译成"构造器 + 私有字段 + 实例方法"之后,所有困惑都自动消失了。 语言语法可以完全不同,但状态封装这件事,Java 和 JS 想做的是同一件事。

相关推荐
常宇佳1 小时前
vue3 实战系列之无法识别vue组件
前端·javascript·vue.js
掘金者阿豪1 小时前
Codex 怎么突然变慢了?一个需求跑几十分钟,我才发现它的工作方式已经变了
前端·后端
Zadig1 小时前
Zadig 全面支持 CRD,至此所有 K8s 资源类型均可一键发布!
后端·devops
全栈项目管理程序猿2 小时前
ArcGIS JS 基础教程(12):视图截图 takeScreenshot
javascript
得物技术2 小时前
EP-Harness:从个人 AI Coding 到团队级 Agent 工作流|得物技术
后端·程序员·架构
用户125758524362 小时前
对象存储 URL 为什么别到处拼:后台附件预览要验这一层
后端·go·ai编程
小棉花的ai跨境之旅2 小时前
Codex 子 Agent 分工实战:Sol 当军师、Luna 当搬砖工,单任务成本压到 $0.61
javascript
步行cgn2 小时前
MyBatis 一对多关联映射详解
java·后端
阿弱2 小时前
pi 扩展机制:加载、执行与能力
后端·llm·agent