RxJS Best Practices

2025-06-07

RxJS 最佳实践

RxJS 最佳实践

RxJS 是 JavaScript 中最流行的响应式函数式编程框架
。这意味着很多人每天都在
项目中使用 RxJS。大多数开发者都知道常见的代码整洁之道,但是…… RxJS 的最佳实践
是什么呢?您是否了解函数式响应式编程的注意事项 ?您是否在代码中运用了这些最佳实践?

本教程将重点介绍我在日常编写
代码时使用的几个最佳实践,并附带一些实际示例。我们将涵盖以下几点:

  1. 避免在订阅函数中使用逻辑

  2. 使用主题强制完成

  3. 避免重复逻辑

  4. 避免嵌套——使用链接代替

  5. 共享以避免流重复

  6. 不要暴露主题

  7. 使用大理石图进行测试

不用多说,让我们开始吧:

避免在订阅函数中使用逻辑

这句话对某些人来说可能显而易见,但对于 RxJS 初学者来说,这是一个常见的陷阱。在你学会如何响应式思考之前,你可能会倾向于这样做:

pokemon$.subscribe((pokemon: Pokemon) => {
if (pokemon.type === "Water") {
return;
}
const pokemonStats = getStats(pokemon);
logStats(pokemonStats);
saveToPokedex(pokemonStats);
});

我们的pokemon$ Observable 会发射 Pokemon 对象,我们以一种非常非反应式的方式订阅它,以便访问这些对象并执行一些操作,比如如果 Pokemon 类型是 Water 则提前返回,调用getStats()函数,记录此函数返回的统计信息,并最终将数据保存到Pokedex中。我们所有的逻辑都在 subscribe 函数内部。

然而,这段代码看起来不就像我们在传统命令式编程范式中看到的那样吗?由于 RxJS 是一个函数式响应式编程库,我们必须告别传统的思维方式,开始采用响应式思维(流!纯函数!)。

那么,我们如何使我们的代码具有响应性?通过使用RxJS 提供的可管道操作符:

瞧,只需几个简单的修改,我们的代码就从命令式变成了响应式。看起来更简洁了,不是吗?

注意:我完全清楚部分逻辑(
saveToPokedex() 函数)仍然保留在订阅函数中。我发现将最后一部分逻辑保留在订阅函数中更方便我阅读代码。您可以自由选择是否将订阅函数保留为空 :)

我们使用的操作符非常简单:filtermap 的工作方式与它们共享名称的数组操作符完全相同,而tap用于执行副作用。

维基百科:如果一个操作、函数或表达式修改了其局部环境之外的某些状态变量值,则称其具有副作用。


使用主题强制完成

使用 Observable 时,内存泄漏确实存在风险。为什么?因为一旦我们订阅了一个 Observable,它就会无限期地发出值,直到满足以下两个条件之一:

  1. 我们手动取消订阅Observable。
  2. 完成了

看起来很简单,对吧?让我们看看如何取消订阅 Observable:

正如您在上面的示例中看到的,我们必须将pokemon$ Observable的订阅存储在一个变量中,然后在该存储的订阅上手动调用 unsubscribe。目前看来似乎不太难……

但是如果我们有更多需要订阅的 Observable 会发生什么情况呢?

如你所见,随着我们在代码中添加越来越多的 Observable,我们需要跟踪越来越多的 subscriptions,代码开始显得有些拥挤。难道没有更好的方法来告诉 Observable停止发射值吗?幸运的是,我们有,而且非常非常简单:

我们可以使用SubjecttakeUntil()操作符来强制 Observable完成。怎么做?以下是示例:

让我们理解一下上面发生了什么。我们创建了一个stop$ Subject,并用 takeUntil 操作符将三个 Observable 传入管道。此操作符的作用是让 Observable持续发出值,直到有通知型 Observable 发出值。这意味着,当 stop$ Subject 发出值时,我们的三个 Observable 就会停止发出值。

那么,我们如何让stop$ Observable 发出数据呢?通过调用它的 next()
函数,这正是我们在
stopObservables()函数中所做的。因此,每当我们调用
stopObservables()函数时,stop$ Observable 都会发出数据,并且所有 Observable都会自动完成。听起来很酷,不是吗?

再也不用存储订阅信息并调用取消订阅函数了,也不用再费劲地处理数组了?takeUntil 操作符真是太棒了!


避免重复逻辑

我们都知道,重复的代码是个坏兆头,应该尽量
避免。(如果你还不知道,我建议你先读一下
这篇文章
然后再回来。)你可能想知道哪些场景会导致 RxJS 逻辑重复。我们来看下面的例子:

如你所见,我们有一个 number$ Observable,它每秒都会发出一个值。我们订阅了这个 Observable 两次:一次是使用scan()记录分数,另一次是每十秒调用一次getPokemonByID()函数。这看起来很简单,但是……

注意到我们在两个 Observable 中重复了 takeUntil() 的逻辑吗?只要我们的代码允许,就应该避免这种情况。怎么做呢?只需将此逻辑附加到源 observable即可,如下所示:

更少的代码 && 没有重复 === 更简洁的代码。太棒了!


避免嵌套——使用链接代替

嵌套订阅应该不惜一切代价避免。它们会使我们的代码变得复杂、肮脏、难以测试,并且可能导致一些非常严重的错误。你可能会问,什么是嵌套订阅?它是指我们在一个 Observable 的 subscribe 块中订阅另一个 Observable。我们来看下面的代码:

看起来不太简洁,对吧?上面的代码令人困惑,很复杂,而且,如果我们需要调用更多返回 Observable 的函数,就必须不断添加订阅。这听起来有点像“订阅地狱”。那么,我们该如何避免嵌套订阅呢?

答案是使用高阶映射运算符。这些运算符包括:switchMapmergeMap等。

为了修复我们的示例,我们将使用switchMap运算符。为什么?因为switchMap会取消订阅前一个 Observable,并切换到内部 Observable(很容易记住,对吧?),在我们的例子中,这是完美的解决方案。但是,请注意,根据所需的行为,您可能需要使用其他高阶映射运算符。

看看我们的代码现在看起来多么可爱。


共享以避免流重复

你的 Angular 代码是否曾发出过重复的 HTTP 请求,并且想知道为什么?继续阅读,你就会发现这个普遍存在的 bug 背后的原因:

大多数 Observable 都是冷的。这意味着它们的生产者在我们订阅它们时被创建并激活。这听起来可能有点令人困惑,但其实很容易理解。对于冷的 Observable,每次我们订阅它们时,都会创建一个新的生产者。所以,如果我们订阅一个冷的 Observable 五次,就会创建五个生产者。

那么,生产者到底是什么?它本质上是Observable 值的来源(例如,DOM 事件、HTTP 请求、数组等等)。这对我们响应式程序员来说意味着什么呢?例如,如果我们两次订阅一个发出 HTTP 请求的 Observable,那么就会产生两个 HTTP 请求。

听起来很麻烦。

下面的例子(借用 Angular 的 HttpClient)会触发两个
不同的 HTTP 请求,因为pokemon$是一个冷 Observable,我们对它进行了两次订阅:

可以想象,这种行为可能会导致严重的 bug,那么,我们该如何避免呢?有没有一种方法可以多次订阅一个 Observable,而不会触发重复的逻辑,因为它的源会被反复创建?当然有,请允许我介绍一下:share() 操作符。

此运算符用于允许对一个 Observable 进行多次订阅而无需重新创建其源。换句话说,它将一个 Observable 从冷状态变为热状态。让我们看看它是如何使用的:

是的,这就是我们需要做的全部,我们的问题“神奇地解决了”。通过添加share()操作符,我们之前冷的pokemon$ Observable 现在表现得像热的一样,即使我们订阅了它两次,也只会发出一个 HTTP 请求。

需要注意的是:由于热的 Observable 不会复制源数据,如果我们订阅较晚,将无法访问之前发出的值。shareReplay ()操作符可以解决这个问题。


不要暴露主题

使用服务来包含我们在应用程序中复用的可观察对象是一种常见的做法。在这样的服务中包含主题对象也很常见。许多开发人员常犯的一个错误是将这些主题对象直接暴露给“外部世界”,例如:

不要这样做。暴露 Subject 意味着允许任何人向其中推送数据,更不用说这完全破坏了DataService类的封装性。我们不应该暴露 Subject 本身,而应该暴露 Subject 的数据。

你可能会想,这难道不是一回事吗?答案是否定的。如果我们暴露一个 Subject,我们就让它的所有方法都可用,包括next()函数,该函数用于使 Subject发出新值。另一方面,如果我们只暴露它的数据,我们就无法让 Subject 的方法可用,只能让它发出的值可用。

那么,我们如何才能公开 Subject 的数据而不公开其方法呢?答案是使用asObservable () 运算符,它将 Subject 转换为 Observable。由于 Observable没有 next() 函数,因此 Subject 的数据不会被篡改

上面的代码中有四件不同的事情:

  • 我们的pokemonLevelstop$主题现在都是私有的,因此无法从我们的DataService类外部访问

  • 我们现在有了一个pokemonLevel$ Observable,它是通过在pokemonLevel Subject上调用asObservable()操作符创建的。这样,我们可以从类外部访问pokemonLevel数据,同时保证 Subject 的安全,避免被篡改

  • 你可能已经注意到,对于stop$ Subject,我们没有创建 Observable。这是因为我们不需要从类外部访问 stop$ 的数据。

  • 我们现在有两个公共方法,分别是increaseLevel()stop()。后者很容易理解。它允许我们让私有的stop$ Subject从类外部发出数据,从而完成所有通过takeUntil(stop$)管道发送的 Observable 。

  • increaseLevel()充当过滤器,只允许我们将某些值传递给pokemonLevel()主题。

这样,任何任意数据都无法进入我们的主题,因为主题在类内部受到很好的保护。

注意:请记住,Observables 具有 complete() 和 error() 方法,它们仍然可以用来干扰 Subject。

大家记住,封装是关键。


使用大理石图进行测试

众所周知,编写测试与编写代码本身一样重要。但是,如果编写 RxJS 测试的想法对您来说有点令人望而生畏……别担心,从 RxJS 6+ 开始,RxJS 弹珠测试工具将使我们的工作变得非常轻松。您熟悉弹珠图吗?如果不熟悉,这里有一个例子:

即使你是 RxJS 新手,也应该或多或少能理解这些
图。它们无处不在,非常直观,能够让你轻松理解一些更复杂的 RxJS 操作符的工作原理。RxJS 测试工具允许我们使用这些弹珠图来编写简单、直观且可视化的测试。你只需从 rxjs/testing 模块导入TestScheduler,然后开始编写测试!

让我们通过测试我们的 number$ Observable 来看看它是怎么做的:

由于深入研究大理石测试不是本教程的目标,因此我将仅简要介绍上述代码中出现的关键概念,以便我们对正在发生的事情有一个基本的了解:

  • TestScheduler:用于虚拟化时间。它接收一个回调函数,可以通过辅助对象(在本例中是cold()expectObservable()辅助对象)调用。

  • Run():当回调返回时自动调用flush() 。

  • -:每个 - 代表10ms的时间。

  • Cold():创建一个冷 Observable,其订阅在测试开始时生效。在我们的例子中,我们创建了一个冷 Observable,它会每 10 毫秒发出一个值,并完成。

  • |:表示可观察对象的完成。

  • 因此,我们的expectedMarbleDiagram预计在 20ms 时发射。

  • expectedValues变量包含 Observable 发出的每个项目的预期值。在我们的例子中,a是唯一将被发出的值,等于 10。

  • ExpectObservable():安排一个断言,该断言将在testScheduler刷新时执行。在我们的例子中,断言期望 number$ Observable 类似于expectedMarbleDiagram,其值包含在expectedValues变量中。

您可以在官方 RxJS
文档
中找到有关助手等的更多信息

使用 RxJS 大理石测试工具的优点:

  • 避免了大量的样板代码。(Jasmine-marbles 用户会对此心存感激。)
  • 使用起来非常简单直观
  • 好玩!就算你不太喜欢写测试,我保证你也会喜欢弹珠测试。

因为我喜欢把所有代码示例都做成 Pokemon 主题,所以我会加入另一个规范,这次以 pokemon$ Observable 测试为特色:


结论

好了,各位!今天我们讨论了一些 RxJS 的最佳实践,这些实践我始终在代码中认真应用。如果您之前还不了解,希望这些实践对您有所帮助。

你还知道更多 RxJS 的最佳实践吗?如果有,请在下方评论区留言告诉我。这样我们就能共同努力,编写更好、更简洁的响应式代码!

如果你喜欢这篇文章,别忘了分享给你的朋友/同事,或者给我点个赞 :) 如果你有任何问题,欢迎在评论区留言,或者通过Twitter联系我。下期教程再见!

文章来源:https://dev.to/nyagarcia/rxjs-best-practices-bhb
PREV
使用 React 和 Django 构建完整的仓库管理系统。
NEXT
React、Vite 和 TypeScript:2 分钟内即可上手