关于 Effect 的常见误解
澄清关于 Effect 在性能、复杂度和适用场景方面流传甚广的误解。
Effect 严重依赖生成器,而生成器很慢!
Effect 的内部实现并不是建立在生成器之上的,我们只是用生成器来提供一个与 async-await 高度相似的 API。在底层,async-await 使用的机制与生成器完全相同,两者的性能也相当。所以,如果你对 async-await 没有意见,那么对 Effect 的生成器也不会有意见。
生成器和可迭代对象真正慢到无法接受的地方,是转换数据集合;在这类场景中,请尽量使用普通数组。
Effect 会让你的代码慢 500 倍!
如果你拿下面两段代码作比较,Effect 确实会慢 500 倍:
const result = 1 + 1
以及:
import { Effect } from "effect"
const result = Effect.runSync(
Effect.zipWith(Effect.succeed(1), Effect.succeed(1), (a, b) => a + b),
)
原因在于,其中一个操作会被 JIT 编译器优化成一条直接的 CPU 指令,而另一个不会。
现实中你永远不会在这种场景下使用 Effect。Effect 是一个应用级库,用来驯服并发、错误处理以及更多难题!
你应该用 Effect 来协调自己编写的各个代码 thunk,而这些 thunk 可以按你认为性能最优的方式实现,同时仍然通过 Effect 来控制执行过程。
Effect 的性能开销巨大!
这取决于你说的「性能」指什么。很多时候,JS 中的性能瓶颈源于对并发的糟糕管理。
得益于结构化并发与可观测性,发现并优化这些问题变得容易得多。
有些前端应用以 120fps 运行,同时密集使用 Effect,所以 Effect 多半不会成为你的性能问题。
在内存方面,它占用的内存并不会比普通程序多多少。相比非 Effect 代码,它会多一些内存分配,但当非 Effect 代码做的是与 Effect 代码相同的事情时,通常这种差异就不存在了。
建议是:先用起来,并监控你的代码;只按需优化,而不是凭想象优化 —— 过早优化是软件设计中万恶之源。
打包体积巨大!
Effect 的最低成本大约是 25k 的 gzip 压缩代码,这部分包含了 Effect Runtime,并且已经涵盖普通应用代码场景下你几乎会用到的所有函数。
在此基础之上,Effect 对 tree-shaking 友好,所以你只会打包自己用到的部分。
此外,使用 Effect 时你自己的代码会变得更短、更紧凑,因此整体成本会随着使用而被摊薄。我们有些应用在大部分代码库中采用 Effect 之后,最终打包体积反而变小了。
Effect 根本不可能学会,函数和模块太多了!
确实,整个 Effect 生态相当庞大,有些模块包含上千个函数。但事实是,你不需要全部掌握才能开始高效工作:只要了解 10 到 20 个函数,你就可以放心地开始使用 Effect,然后再逐步探索其余部分 —— 就像你可以开始使用 TypeScript,而不必先了解每一个 NPM 包一样。
以下是一份适合入门的常用函数简表:
- Effect.succeed
- Effect.fail
- Effect.sync
- Effect.tryPromise
- Effect.gen
- Effect.runPromise
- Effect.catchTag
- Effect.catchAll
- Effect.acquireRelease
- Effect.acquireUseRelease
- Effect.provide
- Effect.provideService
- Effect.andThen
- Effect.map
- Effect.tap
以下是一份常用模块简表:
Effect 和 RxJS 一样,也有同样的问题
这是个敏感话题。先说清楚:RxJS 是一个了不起的项目,它帮助了数百万开发者写出可靠的软件,我们都应当感谢那些为这样一个出色项目做出贡献的开发者。
从项目定位来看,RxJS 的目标是让使用 Observable 变得简单,并为 JS 提供响应式扩展;而 Effect 想要的是让编写生产级 TypeScript 变得简单。尽管两者的交集并非空集,但它们的根本目标和策略截然不同。
有时人们会用负面的眼光看待 RxJS,但原因并不在 RxJS 本身,而在于把 RxJS 用在了它原本并未被设想使用的领域。
也就是说,「一切皆 stream」这一想法在理论上是成立的,但它会对开发者体验造成根本性的限制。首要问题在于:stream 是 multi-shot 的(可能发射多个元素,也可能一个都不发射),而可变的分隔延续(delimited continuation,即 JS 生成器)众所周知只适合表示 single-shot 的 effect(即只发射单个值)。
简而言之,这意味着用 stream 这类原语几乎不可能写出命令式风格的代码(想想 async/await)。(说「几乎」是因为你总可以选择在每个元素、每个步骤上重放生成器,但这往往效率低下,语义也反直觉,并且只有在整个函数体都没有副作用的前提下才成立。)这迫使开发者改用 pipe 这类声明式方法来表达全部代码。
Effect 有一个 Stream 模块(它是拉取式而非推送式的,以保持内存占用恒定),但基础的 Effect 类型是 single-shot 的,并且被优化成一个聪明而惰性的 Promise,从而支持命令式编程。所以使用 Effect 时,你并不被迫对所有东西都采用声明式风格,可以用一种类似 async-await 的模型来编程。
另一个重大区别是:RxJS 只关心类型显式的顺利路径(happy path),它不提供为错误和依赖标注类型的方式;而 Effect 把错误和依赖都视为需要显式标注类型的东西,并以完全类型安全的方式围绕它们提供控制流。
简而言之:如果你需要围绕 Observable 的响应式编程,就用 RxJS;如果你需要编写生产级 TypeScript,并且默认就带有原生遥测、错误处理、依赖注入等能力,就用 Effect。
Effect 应该做成一种语言,或者改用另一种语言
这两者都解决不了用 TypeScript 编写生产级软件的问题。
TypeScript 是一门出色的语言,适合编写全栈代码,它深深扎根于 JS 生态,工具的兼容性也很广;它是一门工业级语言,被许多大型公司采用。
像 Effect 这样的东西能够在这门语言内实现,而且这门语言支持生成器之类的特性,从而允许用 Effect 这样的自定义类型进行命令式编程 —— 这些事实让 TypeScript 成为一门独一无二的语言。
事实上,即使在 Scala 这样的函数式语言中,与 effect 系统的互操作也不如 TypeScript 中那样理想,以至于 effect 系统的作者们都曾表示,希望自己的语言能提供像 TypeScript 一样多的支持。