我是 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,还是外面那个数组吗?
答案是"是"。而为什么是,就是这篇文章要讲的东西。
写完这篇,我把自己踩过的每一个坑都复盘了一遍,包括一个让我卡了很久、事后觉得很蠢但当时就是想不通的问题。
一、先给结论
教程里最常见的说法是"闭包就是函数可以访问外部变量"。这句话没错,但它只描述了现象 ,没解释机制------我当初就是被这句话糊弄过去的。
准确的定义是:
闭包 = 函数代码 + 它诞生时所处的那个变量环境
这两部分被打包成一个整体,可以到处传递。
"闭"(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 从源头堵死。
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",因为那行代码不属于它。
一句话总结:let count = 0 执行了 2 次(因为 makeCounter 被调了 2 次),++count 执行了 3 次。
c1 输出 2,不是因为 count 神奇地存活,而是因为重置它的那行代码根本没有被再次执行。
let 是不是原因?
不是。换成 var count = 0,结果还是 1、2、1。
let 和 var 的区别在作用域范围 和循环里的每轮绑定 ,跟"每次调用是否重新初始化"无关。任何局部变量声明都是"每次函数被调用时执行一遍",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 的时候,不是每次调方法的时候。
闭包一模一样:
- 外层函数
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 = ... 的作用只是给我一个句柄,让我能反复调用同一个闭包。
六、生命周期:局部变量为什么没有死
js
function makeCounter() {
let count = 0;
return () => ++count;
}
const c1 = makeCounter(); // makeCounter 早就返回了
c1(); // 但 count 还活着
按 Java 栈帧的直觉,makeCounter 返回时 count 就该销毁了。但只要闭包还活着,它引用的环境就不会被 GC 回收。
局部变量的生命周期,从"函数执行期"延长到了"闭包存活期"。
这也解释了 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 ✅
let 在 for 循环里有特殊规则:每次迭代创建一个新的绑定,三个闭包各捕获各的盒子。
有意思的是,Java 那条我当初嫌烦的限制,在这里体现了价值------Java 直接不让你写出上半段这种 bug 。for (int i = 0; ...) 的 i 不是 effectively final,编译器会拦住你;而 for (String s : list) 的 s 每轮是新变量,所以可以捕获。
实践结论:TS 里永远用 let / const,别用 var。
八、生活场景:一张储值卡
概念讲完了,我用一个类比把上面所有机制串起来------健身房储值卡。
| 生活场景 | 代码 | 机制 |
|---|---|---|
| 办卡 | makeCounter() |
外层函数调用 → 创建环境 |
| 店里的账户 | count |
被捕获的自由变量(盒子) |
| 卡 | c1 |
闭包 = 行为 + 绑定的环境 |
| 刷卡 | c1() |
内层函数调用 → 操作环境 |
| 办两张卡 | 调两次 makeCounter() |
每次创建独立环境 |
| 卡在账户就在 | --- | 闭包延长变量生命周期 |
| 借卡给朋友异地刷 | 闭包传给别的函数 | 脱离原作用域仍持有环境 |
| 一次性卡用完就扔 | counter() 返回数字 |
无闭包,环境立即回收 |
我那个困惑,在这里是常识
"调用两次
c1()为什么是 2?内部不是let count = 0吗?"
翻译成生活场景就是:"刷两次卡为什么扣了两次?办卡的时候不是清零了吗?"
因为办卡只办了一次 。我不会每次进健身房都重新办一张卡。let count = 0 就是"办卡时开户"这个动作,它属于办卡流程 ,不属于刷卡流程。
这么一说就完全不需要解释了。
其他细节也都对得上
两张卡互不干扰:同事去办卡,店家开的是另一个账户。你刷你的,他刷他的。
卡在,账户就在:只要卡还在钱包里,健身房就不会注销账户。卡(闭包)活着 → 账户(盒子)不会被回收。把卡扔了,账户才会被清理------这就是 GC。
捕获的是账户,不是余额数字 :如果卡上印着 "余额 100",那是快照,店里改了余额卡上也不会变。但实际是卡只记账户号,余额存在店里的系统里,在 A 门店刷、B 门店查,看到的是同一个数。
这就是"捕获盒子而不是值"。Java 之所以要求 effectively final,正是因为它选了"在卡上印数字"的方案------印上去就不能改了。
卡可以借给别人:你把卡借给朋友,他在另一个城市的分店刷------扣的还是你的账户。他不需要知道你的账户在哪、余额多少,只需要"有这张卡"。
最后这一条,正是我最开始被卡住的那段 Agent 代码在做的事。
另一个场景:贴好回邮地址的信封
对于回调式的闭包,我更喜欢这个类比:
我要出差,交给助理一沓已经写好我家地址、贴好邮票的空信封 。助理在外地办事,每收集到一份资料就塞进一个信封投进邮筒------东西自动寄到我家。
关键在于:助理完全不需要知道我家在哪。地址已经"封"在信封里了,他只管"塞进去、投出去"。
九、回到最开始卡住我的那段代码
现在再看开头那段 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); // ← 把闭包传出去(借卡 / 交信封)
}
三个要点:
emit不是一个"纯函数" ,它是"函数体 +{events, printEvent}这个环境"打包成的一个值;- 它被传进
TinyModel.complete后,在完全不同的调用栈里执行,但events.push推进去的还是runAgentLoop里那个数组------因为盒子被一起带过去了; 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 背景的朋友留一份自检清单,遇到闭包相关的困惑,按顺序问自己:
- 哪个是外层函数,哪个是内层函数? 内层那个(引用了自由变量的)才叫闭包。
- 外层函数被调用了几次? 这决定了有几个独立的盒子。
- 初始化那行代码属于谁? 它在外层函数体里,就只在外层被调用时执行。
- 内层函数被传到哪里去了? 不管传多远,它操作的还是出生时那个盒子。
- 还有谁引用着这个闭包? 只要有,被捕获的变量就不会被 GC。
最后一条心得:当我把闭包翻译成"构造器 + 私有字段 + 实例方法"之后,所有困惑都自动消失了。 语言语法可以完全不同,但状态封装这件事,Java 和 JS 想做的是同一件事。