你对LazyPromise作为Effect的轻量级替代方案有何看法?
摘要
LazyPromise 是一个轻量级、延迟加载和可取消的 JavaScript Promise 库,支持同步发射、类型化错误和依赖注入,定位为原生 Promise 和像 Effect 这样的框架的替代方案。
查看缓存全文
缓存时间: 2026/08/15 23:48
lazy-promise/lazy-promise 来源:https://github.com/lazy-promise/lazy-promise
LazyPromise
LazyPromise 类似原生 Promise,但具有以下特点:
- 惰性执行且可取消
- 同步发出事件(而非微任务)
- 支持类型错误和依赖注入
安装
npm install @lazy-promise/core
设计动机
如果你从 Observable 开始
Observable 在概念上简洁优美,且具备出色的取消机制。LazyPromise 保留了这些优点,但将 Observable 限定为单次执行——可称之为 RxJava 中 Single 的 JavaScript 近亲。单次执行的 Observable 能很好地与 Signals(https://github.com/lazy-promise/lazy-promise/tree/main/packages/alien-signals)配合使用,且不易出现菱形问题(https://stackblitz.com/edit/rxjs-diamond-problem-s8cy9zzb?devToolsHeight=50&file=index.ts)及同步重入场景下的异常行为(https://stackblitz.com/edit/rxjs-sync-reentry-vxjr9fhr?devToolsHeight=50&file=index.ts)。
如果你从原生 Promise 开始
初看原生 Promise 似乎不需要单次执行的 Observable,但存在两个问题——一主一次。首先,AbortController API 的取消机制并不理想,问题核心不在于 API 细节,而在于 Promise 的即时执行特性。其次,与 Observable 相同,LazyPromise 认为微任务不应被强制执行。原生 Promise 保证 promise.then(foo); bar(); 中 foo 会在 bar 之后运行,但这种“Zalgo”保证是有代价的:例如当两个异步函数各自等待若干已解决的 Promise 时,最终执行顺序取决于哪个包含更多 await 操作。
尽管如此,原生 Promise 的 API 设计相当优雅。LazyPromise API 不仅与其相似,除非文档特别说明,否则遵循其所有细节设计。这为文档编写和学习带来了显著便利。
如果你从 Effect 开始
LazyPromise 与 Effect 类似,支持生成器语法、类型错误和依赖注入,但两者在库与框架的光谱上处于截然不同的位置。
使用方法
创建 LazyPromise 的方式类似原生 Promise,但使用 sink 对象替代 resolve, reject 参数,并可选择返回清理函数:
const lazyPromise = new LazyPromise((sink) => {
const timeoutId = setTimeout(() => {
if (...) {
sink.resolve(42);
} else {
sink.reject(new Error("oops"));
}
}, 1000);
return () => {
clearTimeout(timeoutId);
};
});
LazyPromise 在订阅前不会执行任何操作:
const subscription = lazyPromise.subscribe({
resolve: (value) => ...,
reject: (error) => ...,
});
取消订阅需调用:
// 此方法具有幂等性
subscription.dispose();
原生 Promise 是即时执行且单次执行的,而 LazyPromise 表现得像 Observable——每次有人订阅时都会运行构造函数回调。可理解为 new LazyPromise(foo) 仅是为 foo 添加了一个确保以下不变量的包装层:
- 如果发出事件,仅发生一次
- 取消订阅后不再发出事件
- 清理函数最多执行一次,且仅在没有发出事件时执行
- 不支持高阶 LazyPromise(即 resolve 结果为 LazyPromise 的 Promise)
若用 Promise 类型的值调用原生 Promise 的 resolve 方法,结果仍为 Promise 而非 Promise<Promise>。LazyPromise 同样会被展平。
除去表面差异,LazyPromise API 与原生 Promise 镜像对应:
| Promise API | LazyPromise 等效方法 |
|---|---|
promise.then(foo) | lazyPromise.map(foo) |
promise.catch(foo) | lazyPromise.catch(foo) |
promise.finally(foo) | lazyPromise.finally(foo) |
Promise.resolve(valueOrPromise) | box(valueOrLazyPromise) |
Promise.reject(error) | rejecting(error) |
new Promise(() => {}) | never |
Promise.all(...) | all(...) |
Promise.any(...) | any(...) |
Promise.race(...) | race(...) |
Awaited | Unbox |
取消 LazyPromise 会自动取消通过上述操作符派生的所有上游 LazyPromise。
存在 fromEager 函数可将异步函数转换为 LazyPromise,以及 toEager 方法可将 LazyPromise 转换为 Promise。两者均支持 AbortController API。
pipe 方法支持自定义操作符链式调用:lazyPromise.pipe(foo) 等效于 foo(lazyPromise)。
生成器语法
该语法是 LazyPromise 对 async/await 的等效实现。它能利用 JavaScript 流程控制语句,并与链式操作符相同,支持自动取消。只需用生成器函数替代 async 函数,用 yield* 替代 await:
// 类型推断为 LazyPromise<number>
const lazyPromise = fromGen(function* () {
while (true) {
// 类型推断为 number | undefined
const value = yield* new LazyPromise(...);
if (value !== undefined) {
return value;
}
}
});
原生 Promise 中,await promise 在 promise 以 error 拒绝时,等效于执行 throw error。当 yield* lazyPromise 且 lazyPromise 拒绝时,行为完全相同。
若在 try 或 catch 块内对 lazy promise 使用 yield*,且整个流程在等待该 promise 时被取消,则不会执行 finally 块。类似地,.finally 方法仅在 promise 解决(resolve 或 reject)时运行回调,在解决前取消订阅则不会执行。
类型错误
LazyPromise 对类型错误的支持反映了 JavaScript 的现实:无法对抛出的错误进行类型检查,必须通过返回值表示类型错误。我们不在 resolve 和 reject 之外增加额外通道,而是通过 resolve 通道传递类型错误,并用 ErrorBox 类包装以区别于其他值。
new ErrorBox(error) 仅将 error 存储在其 .error 属性中。
存在 catchBoxed 操作符作为 catch 的错误箱对应版本,以及辅助类型 UnboxError 用于提取 ErrorBox 内容。
某些 API 会特殊处理 ErrorBox 实例:
- 默认情况下,对可能 resolve 出错误箱的 LazyPromise 调用
.subscribe或.toEager会导致类型检查错误。这确保了当服务端点添加新错误时,客户端所有未处理该错误的位置都能被捕获。这些方法都接受可选的泛型参数WhitelistedError用于忽略部分或全部错误检查。 map、all和race操作符会像传递 reject 那样传递错误箱,例如:
declare const promiseA: LazyPromise<number, string>;
// 类型推断为 LazyPromise<string, string | number>
const promiseB = promiseA.map(
(// 类型推断为 number
value,
) => String(value),
);
当 lazyPromise 以 error 拒绝时,yield* lazyPromise 等效于执行 throw error。当 lazyPromise 以 ErrorBox 实例 boxedError 解决时,yield* lazyPromise 等效于执行 return boxedError。两种情况都会中断生成器函数执行,区别在于无法 catch 错误箱:必须使用 catchBoxed 操作符。
若执行继续,说明 lazyPromise 以非错误箱的值解决了:
declare const promiseA: LazyPromise<number, string>;
// 类型推断为 LazyPromise<string>
const promiseB = fromGen(function* () {
// 类型推断为 number
const value = yield* promiseA;
return String(value);
});
在客户端使用 LazyPromise 而在服务器端使用 async/await 是常见场景。此时服务器端点仍可通过从异步函数返回错误箱来产生类型错误。
类型错误是可选的——只要不使用 ErrorBox 类,可以完全忽略此概念。唯一的例外是 any 操作符,因为没有类型错误时其功能本身就不完善。
当传递给原生 Promise.any 的某个 promise 因缺陷拒绝时,若其他输入 promise 解决,则该缺陷会被掩盖。LazyPromise 版本的 any 在错误箱处理上与 Promise.any 类似,但只要任一输入拒绝就会拒绝。
依赖注入
new LazyPromise(foo) 实质上是 foo 的包装。依赖注入放宽了对 LazyPromise 可包装函数类型的限制:除第一个参数 { resolve, reject } 外,还允许名为“依赖”的第二个参数,类型不受限制:
const lazyPromise = new LazyPromise(
(
sink,
dep, // 类型为 `MyDep`
) => ...,
);
lazyPromise.subscribe(
consumer,
dep, // 必须满足 `MyDep`
);
依赖会在操作符或生成器语法中通过类型系统向上传递。例如若 promiseA 依赖 A,promiseB 依赖 B,则 all([promiseA, promiseB]) 将具有依赖 A & B——即 all 需要能同时传递给两者的依赖。这对测试很有帮助:可收集异步逻辑所需的所有依赖,然后用生产实现或模拟对象满足它们。
dep 参数不仅在 LazyPromise 构造函数回调中可用,也适用于所有延迟执行的回调,包括传给 map、catch、catchBoxed、finally 和 fromGen 的函数,例如 lazyPromise.map((value, dep: MyDep) => ...)。必须显式指定 dep 的类型。
可在订阅时满足依赖,也可更早使用 LazyPromise 的 inject 方法。该方法的回调应返回依赖,但与其他延迟回调相同,可选择接受依赖作为参数,实现依赖的层层传递:
declare const upstreamLazyPromise: LazyPromise<unknown, DownstreamDep>;
// 类型推断为 LazyPromise<unknown, UpstreamDep>
const downstreamLazyPromise = upstreamLazyPromise.inject(
(dep: DownstreamDep) => ...,
);
当跨多个模块使用依赖时,将其定义为带符号键的对象通常更方便。可用单个对象满足多个此类依赖,无需担心命名冲突:
export const randomSymbol = Symbol("random");
export interface RandomDep {
[randomSymbol]: () => number;
}
存在辅助类型 InferDep,类似 Unbox 但针对依赖类型参数。
与类型错误相同,依赖注入是可选功能。可省略 LazyPromise 的第二个类型参数,此时默认为 unknown,表示无依赖。
实用工具
库提供对浏览器和 Node 延迟 API 的封装:inTimeout、inMicrotask、inAnimationFrame、inIdleCallback、inImmediate、inNextTick、inMessageChannel、inScheduled。每个函数返回一个 LazyPromise,通常以 undefined 作为值,在 setTimeout、queueMicrotask 等对应环境中触发。
由于这些是对原生 API 的简单封装,并未大幅增加 API 复杂度,却省去了异步库中常见的额外构造。例如在生成器函数中休眠 1 秒只需 yield* inTimeout(1000)。
库还提供 log 函数,可包装 LazyPromise 而不改变其行为,并将所有事件输出到控制台:lazyPromise.pipe(log("your label"))。
基于类的 API
为获得最佳性能(例如开发库时),可用对象替代函数以避免创建和垃圾回收开销。可将具有 .produce 方法的对象(Producer)而非回调传递给 LazyPromise 构造函数,返回具有 .dispose 方法的对象(Job)而非清理函数。
常见问题
为什么方法名是 map 而不是 then?
JavaScript 对 then 有内置行为,因此不可用。至于 map 与 flatMap 的区别,得益于不存在高阶 LazyPromise,若 map 的回调返回 LazyPromise,它无法返回 LazyPromise<LazyPromise> 而必须展平结果,故无需区分。同理,只需使用 box 而无需区分 box 与 normalize。
为什么不像 Promise.resolve 和 Promise.reject 那样对称?
原生 Promise 实际上也不对称。给 Promise.resolve 传入 Promise 会将其展平,而给 Promise.reject 传入 Promise 则会直接抛出。
为什么支持点符号而不仅限于管道(如 RxJS)? 与 RxJS 不同,存在一组小而明确的操作符,可与语言特性等同视之,且比其他操作符更重要。
为什么在 lazy promise 被取消时不执行 finally?
此问题涉及生成器函数中的 finally 块和 .finally 方法。原因有三:
- JavaScript 生成器函数的工作方式:仅当
try/catch中不包含yield时,才保证finally块执行。 - 使用
finally清理资源违背“单一职责原则”,因为 LazyPromise 构造函数已返回清理逻辑。 - 支持
lazyPromise.finally(() => anotherLazyPromise)模式,等效于原生 Promise 的:
try {
return await promise;
} finally {
// 等待 `anotherPromise`,然后传递 `promise` 的结果
await anotherPromise;
}
例如可用于使 lazy promise 像原生 Promise 那样在微任务中触发:lazyPromise.finally(inMicrotask)。
为什么 LazyPromise 不提供共享/缓存结果的功能?
虽然可通过 RxJS 等用户库操作符实现,但不应将其内置于基础类型中,因为实现方式取决于状态管理方案。若使用 Signals,可扩展现有的 computed/createMemo(https://github.com/lazy-promise/lazy-promise/tree/main/packages/alien-signals#step-2-memos)。
为什么不用单独的通道传递类型错误?
尽管 LazyPromise<"value" | ErrorBox<"error">> 比 LazyPromise<"value", "error"> 稍难阅读,但额外通道和类型参数会在与原生 Promise 及生成器语法结合使用时引入不必要的复杂性。你将无法在原生 async 函数中通过返回 ErrorBox 产生类型错误,且生成器中的 try/catch/finally 语法会出现非直观行为。
相似文章
异步编程的承诺与现实
深入剖析异步编程模型的演进——从回调到 Promise——揭示每一轮迭代如何解决先前的资源与性能问题,同时带来新的易用性挑战。
proposal-async-context: 用于 JavaScript 的异步上下文
关于 JavaScript 中 Async Context API 的提案,用于在异步代码(如 promise 延续或 async 回调)中隐式传播值。
@cnakazawa: fate 1.0:首个完整的异步 React 元框架 1.0 新特性:* 基于 SSE 的零配置实时视图 * Drizzle 支持 * "Na…
fate 1.0 是一个全新的完整异步 React 元框架,具备基于 SSE 的零配置实时视图、Drizzle 支持、原生 HTTP、Void Router、Vite 插件以及客户端垃圾回收等功能。它旨在通过规范化缓存和组合视图来简化数据获取。
Show HN: Pure Effect – 无需数据库,在笔记本上复现生产环境bug
Pure Effect 是一个零依赖的 JavaScript/TypeScript 效应库,通过将副作用表示为纯数据来分离业务逻辑与 I/O,无需数据库即可复现生产环境 bug。
Prism:一种带类型效应的非纯函数式语言
Prism 是一种新型函数式语言,它结合了代数效应与类型系统,允许在没有单子的情况下使用可变状态及其他效应,同时从外部保持纯函数性。其目标是让效应成为类型系统的一等公民,从而实现优化和安全使用。