打开网易新闻 查看精彩图片

2012年,Promises/A+规范制定期间,函数式编程派提了一个需求:把单子(monad)和范畴论塞进JavaScript。双方吵到不可开交,各自觉得对方不可理喻。规范作者赢了,单子派出局。但最近一个真实案例让我发现,当年被否掉的方案,在特定场景下反而是最优解。

当年吵的到底是什么

当年吵的到底是什么

函数式语言如Haskell,不写执行指令,只写值与值的关系方程。这套体系依赖两个核心抽象:函子(functor)和单子(monad)。

函子的本质是"可映射的容器"。它接收Functor和一个A -> B的函数,返回Functor。JavaScript里的数组和map()就是典型例子:

['category', 'theory'].map(word => word.length) // [8, 6]

单子比函子多一步:它接收Monad和一个A -> Monad的函数,返回Monad。关键是它会自动展平一层嵌套。数组的flatMap()就是单子的实现:

['a b', 'c d'].flatMap(s => s.split(' ')) // ['a','b','c','d']

如果用map(),得到的是[['a','b'], ['c','d']]——嵌套数组。单子派当年要求Promise实现类似的自动展平:当then()的回调返回另一个Promise时,外层应该直接拿到内层的值,而不是包两层。

规范作者为什么拒绝

他们的理由很实际:Promise的then()已经会展开一层了。这是JavaScript Promise跟Haskell单子的关键差异。

在Haskell里,IO (IO String)是合法类型——你可以有一个"包含IO操作的IO操作"。但JavaScript的Promise.resolve(Promise.resolve(42))直接得到Promise<42>,不会变成Promise>

规范作者认为,再加一层单子式的自动展平,会让Promise丢失"异步操作的异步操作"这种表达能力。换句话说,嵌套Promise在某些场景下是有意义的。

当时单子派的反驳是:那你倒是举个例子啊?规范作者没举出来。双方不欢而散。

10年后,那个例子出现了

10年后,那个例子出现了

我在写一个批量任务调度器时撞上了墙。需求很简单:并发执行N个异步任务,但每个任务本身可能动态创建子任务。子任务完成前,父任务不算完成。

用Promise.all直接套会丢失层级关系。用async/await写嵌套循环,代码像意大利面。最后我想要的结构是:

Task>——外层Promise代表父任务完成,内层Promise数组追踪子任务进度。

但JavaScript的自动展平让这变成不可能。Promise.resolve([p1, p2])不会给你Promise,而是直接Promise<[]>。子任务的完成状态被提前消化了。

这时候我才理解规范作者当年的担忧是多余的,而单子派的直觉是对的:有时候你真的需要保留那层嵌套

workaround很丑:手动包装一个带unwrap()方法的对象,或者把子任务Promise存到闭包变量里。无论哪种,都是在JavaScript的Promise机制上打补丁。

嵌套Promise的3个真实用例

嵌套Promise的3个真实用例

重新梳理后,这类场景其实不少见:

1. 延迟求值的异步工厂

配置加载器:loadConfig()返回Promise,但Config本身包含Promise的延迟字段。你想在真正访问secret时才触发网络请求,而不是配置加载时就全部拉取。

打开网易新闻 查看精彩图片

JavaScript的自动展平会强制你在配置加载阶段就解决所有Promise,或者手动拆成两个API。嵌套Promise本可以让调用者自己决定何时展开。

2. 可取消的异步任务

取消令牌通常设计成{ promise, cancel() }对象。如果任务本身返回Promise,理想类型是Promise<{ promise, cancel }>——外层代表"取消能力已就绪",内层代表"业务结果"。

自动展平迫使你把取消能力和业务逻辑混在一起,或者放弃Promise的链式语法。

3. 流式批处理的背压控制

处理大规模数据流时,你需要Promise[]>这样的结构:外层控制"一批请求已发出",内层数组让每个chunk独立解析,同时保留整批的完成边界。

现在的Promise.all+async函数组合,要么失去细粒度控制,要么代码复杂度爆炸。

如果重来一次

如果重来一次

规范作者当年的设计选择并非错误。自动展平降低了90%用户的心智负担,这是JavaScript的成功之道。

但缺失的是逃生舱——一个显式的Promise.nest()Promise.of()构造器,让需要嵌套语义的人能打破默认行为。Haskell用returnjoin区分"包装"和"展平",JavaScript全替用户做了决定。

TC39近年讨论过Promise.withResolvers(),解决的是类似方向但不同维度的问题。嵌套语义似乎没人再提。

我在Mastodon上重开这个话题时,当年参战的某位规范作者回复:「你说得对,那个例子我当时确实没想到。」

技术争论的胜负往往取决于谁先拿出具体场景,而非哪边更"正确"。10年后补上这个场景,算晚吗?