我改变主意了。Angular 需要一个响应式原语
Angular 开发者们已经等待了 7 年,希望能与 RxJS 更好地集成,但这似乎并没有发生。相反,Angular 团队想要的是自己的响应式原语,甚至无法处理异步响应式。
一开始这让我很生气,但经过大量的思考和与其他开发人员的交流后,我现在相信,与更好的 RxJS 支持相比,反应式原语可以提供更好的整体开发人员体验,即使对于最忠实的 RxJS 粉丝来说也是如此。
- 更好的承诺?
- Angular 社区的分裂
- 拒绝 NgRx 还是超越 NgRx
- Angular 的响应性不够
- 你让他们反对我!
- 瑞安·卡尼亚托
- 哦是的,RxJS 实际上很糟糕
- 选择器
- 主机托管
- SolidJS 信号语法很棒
- 我想要什么
- RxJS 兼容性仍然有必要
- 总结
更好的承诺?
大多数 Angular 开发人员甚至不知道 RxJS,直到 Angular 要求他们学习一些核心 Angular API。
起初,我们大多数人认为可观察对象只是 Promise 的升级版,因为它们不仅能做 Promise 能做的所有事,还能随时间返回多个值。所以我们以为我们只需要像 一样获取 HTTP 数据http.get(...).subscribe(data => this.data = data)
,甚至可以用同样的方式处理 WebSocket 数据,但这已经是 RxJS 的全部优势了。我认为 Angular 团队也是这样看待可观察对象的。
但事实证明,表示长生命周期数据源的能力远不止比 Promise 略有改进。可观察对象支持函数式响应式编程 (FRP),即以声明式方式定义异步代码。这意义重大。FRP 可以完全消除竞争条件和不一致的状态,改善代码组织,减少页面加载时间,简化状态管理,并使命名更容易。我的上一篇文章对此进行了解释。
Angular 社区的分裂
但是,FRP 需要采用一种不同的思维方式才能充分利用这些优势。这被称为“反应式思考”。对于大多数 Angular 开发者来说,探索这种思维方式的机会来自于他们第一次接触 NgRx/Store。
Rob Wormald 于 2016 年创建了 NgRx,它结合了 Redux 和 RxJS,作为 Angular 的首选状态管理库。这似乎是理所当然的,因为 Redux 在 React 社区中非常流行,而且 Redux store 本身就自带subscribe
方法,就像可观察对象一样。它基本上已经是一个等待用 RxJS 实现的可观察对象了。
NgRx 听起来很酷很强大,用起来应该很容易。等等,怎么获取当前状态?不能直接这样吗
this.store.currentState
?
没有。
更糟糕的是,他们根本没有计划添加这个功能。他们甚至都没有计划添加这个功能。
这原来是某种 RxJS 原则的产物。进一步探索之后,我们遇到了一些奇怪的建议,比如“不要取消订阅”和“不要订阅”。你能想象有人告诉你不要使用.then
with 和 promise 吗?但在 RxJS 中,我们应该使用withLatestFrom
和takeUntil
Angular 的async
管道。而且,如果我们使用async
管道,据说我们的应用程序性能会更高。
对于 Angular 开发人员来说,这是一个岔路:我是否需要学习一些东西,或者 RxJS 是否很愚蠢,因为我无法完成我想到的第一件事?
拒绝 NgRx 还是超越 NgRx
NgRx 在 Angular 应用程序中引入更多 RxJS 后不久,一些替代方案开始涌现,吸引了那些不想学习如何进行反应式思考的开发人员,例如NGXS和Akita。
随着其他开发者纷纷采用 RxJS 并大行其道,Angular 社区内部的分裂也愈演愈烈。我记得曾经听过一个播客,NgRx 的创始人兼 Angular 团队成员 Rob Wormald 曾提出,Angular 的未来将是无区域的,RxJS 流将从事件源一直延伸到模板。
这对我来说非常令人兴奋。我想象着最终我们可以通过 RxJS 流将精确的更新直接输入到 DOM 中,完全不需要任何变更检测。这将极大地提升 Angular 的性能,它的性能已经开始与 React 相媲美了。我希望这种惊人的性能提升能够吸引越来越多的 Angular 开发者学习如何进行响应式思考,从而将响应式的其他优势带给越来越多的 Angular 代码库:不再有竞争条件或不一致的状态,改进的代码组织,减少页面加载时间,简化状态管理,以及更优的函数命名。
但这个未来似乎还很遥远,因为在编写响应式代码时,我不得不不断与 NgRx 和 Angular 作斗争。
例如,如果您必须连续点击 3 个服务来聚合页面数据,则可以使用 RxJS 来执行此操作:
data1$ = this.http.get(...);
data2$ = this.data1$.pipe(switchMap(data1 => this.http.get(...));
data3$ = this.data2$.pipe(switchMap(data2 => this.http.get(...));
这样做的好处是每个数据源都是声明式的,因此它独立存在,与如何定义其他状态或特性无关。这非常灵活,并且具备我已经提到过两次的 FRP 的所有优点。
但是,如果要将这些数据放入 NgRx/Store 中,推荐的解决方案响应性不够强,因此无法充分发挥 FRP 的优势。我最终想出了一个办法,将 NgRx API(dispatch
& select
)包装在 RxJS 中,这样我就可以像使用纯 RxJS 一样构建代码。以下是它与 NgRx/Effects 的推荐模式的比较:
我使用这种 RxJS-first 方法重写了大量使用效果的功能,减少了 25% 以上的代码,减少了 90% 以上的页面加载 + 渲染时间。
支持响应式的 Angular 开发者们花了比反对响应式的开发者们更长时间才摆脱 NgRx 的影响。不过,现在我们有了一些 RxJS 优先的库,比如RxAngular和StateAdapt,它们都是状态管理库,能够最大限度地提升 Angular 的响应式。
因此,尽管 NgRx 和 Angular 都向开发者介绍了 RxJS,但它们的设计方式都使得完全响应式变得困难。我提到了 NgRx 的一个问题,但 Angular 的问题远不止这些。
Angular 的响应性不够
显然,RxJS 在 Angular 的设计中只是事后才想到的。它被用来表示事件流,例如组件输出。但它也用于路由参数,这些参数的状态会随时间变化。然而,组件输入(它们的状态也会随时间变化)并没有被表示为可观察对象。RxJS 在 Angular 中的使用体验非常不一致。
当我听到一些 Angular 开发者抱怨在 Angular 中使用 RxJS 有多麻烦时,我真的很理解他们的感受。但他们应该怪 Angular,而不是 RxJS。有些功能在 RxJS + Angular 中需要四行代码,但在RxJS + Svelte中只需要一行代码。Angular 是唯一一个大多数组件库都要求强制打开对话框的主流框架,这意味着async
无法使用管道,而是需要手动管理订阅。而这在其他框架中要容易得多。
我可以讲得更详细,我之前也讲过 ,但主要思想是:即使在我创建了所有包装组件和实用程序之后,我仍然无法修复 Angular 的核心 API。
你让他们反对我!
我等待 Angular 改进与 RxJS 的集成已经很久了。这个问题是 Angular 史上点赞最多的问题之一,它创建于2015 年 12 月!最初希望它能得到解决是在 2019 年 Ivy 编译器最终完成的时候,但又过了两年相对沉寂之后,这个问题终于被彻底解决了!原因何在?Angular 团队表示:“将输入转换为 Observable 会使 Angular 和 RxJS 的耦合度更高,而我们不想进一步耦合 RxJS。”
什么???
在这个问题被关闭之前,最后一条评论来自 Minko Gechev,他首先重申了社区对 RxJS 的分歧:“正如我们过去讨论过的,在 Angular 中 RxJS 的使用量(多或少)问题上,社区内部存在很大分歧。” 在评论的最后,他补充道:“当我们优先考虑该项目时,我们会分享我们关于更符合人体工程学的 RxJS API 的计划。” 对于一个已经等待了 5 年、期盼更好的 Angular API 的人来说,这非常令人沮丧,因为他根本没有给出任何时间表。不仅如此,他给出的主要原因是许多 Angular 开发者不喜欢 RxJS,而我坚信,造成这种情况的主要原因是 Angular 与 RxJS 的集成令人不快。
然后我看到 Angular 团队的一名成员正在探索一种完全不同的响应式原语。如果 Angular 开发者不喜欢响应式,他们为什么会选择另一种响应式原语而不是 RxJS?如果他们不喜欢 Angular 与 RxJS 的集成,那么使用另一种响应式原语又有什么用呢?这一切对我来说都毫无意义。
瑞安·卡尼亚托
另一件让我困扰的事情是 Ryan Carniato 坚持认为 RxJS 并非细粒度响应式。我知道 RxJS 可以用来以细粒度的方式更新 DOM,而 Ryan 也知道这一点,因为他之前用 RxJS 构建了 SolidJS 的一个版本。几周以来,我一直在他的 YouTube 视频评论区纠缠他,他终于给了我一个解释,让我豁然开朗。
Ryan 告诉我,他之所以称 RxJS 为“粗粒度”响应式,是因为虽然技术上可以用它进行细粒度的更新,但人们combineLatest
更倾向于使用流。是否可以说 RxJS 不够细粒度,是因为人们通常不会以细粒度的方式使用它?我不这么认为,但 Ryan 的主要观点确实很有意思。他让我在脑海中建立了一种从未有过的联系。
哦是的,RxJS 实际上很糟糕
我第一次使用 NgRx 时,想把所有东西都用 RxJS 来实现,所以我非常依赖 RxJSmap
操作符来处理派生状态。但现在,几乎所有 NgRx 项目中的派生状态都完全用选择器实现了。因此,现代 NgRx 会像这样组合来自多个 Reducer 的状态:
const selectItems = createSelector(state => state.items);
const selectFilters = createSelector(state => state.filters);
const selectFilteredItems = createSelector(
selectItems,
selectFilters,
(items, filters) => items.filter(filters),
);
最初我是这样做的:
items$ = this.store.select(state => state.items);
filters$ = this.store.select(state => state.filters);
filteredItems$ = combineLatest(
this.items$,
this.filters$,
([items, filters]) => items.filter(filters),
);
我对这种 RxJS 方法非常满意,直到我的团队负责人在过滤函数中添加了一个控制台日志,发现它运行了 28 次,便问我为什么。我告诉他:“我不知道,我发誓 FPR 确实能让应用程序性能更高。”
我的第一个想法是使用distinctUntilChanged
。这减少了一些重播。
然后我意识到,每一个链接起来的可观察对象filteredItems$
都会导致过滤函数再次运行。解决方案是什么?shareReplay()
除非实际上是publishReplay(), refCount()
因为一些奇怪的 RxJS 问题。这也减少了一些重复运行的情况。
然后我意识到,每当 和items
同时filters
更改时,combineLatest
就会运行两次,每个输入可观察变量运行一次。这效率低下,而且在某些状态下也会导致错误,因为处理中间情况(一个输入有更新值,而另一个没有)非常麻烦。
我真的很想让 RxJS 来解决这个问题。当时我只是个初级开发者,但我花了好几个小时想办法用 RxJS 解决这个问题,最后得出结论:必须要用到调度器,但我不知道具体该如何实现。
那时,我开始质疑自己的目标。想想我之前经历的所有工作,感觉仍然很尴尬。我们真的要创建自定义操作符来让这些语法更简洁吗?为什么 RxJS 不能让派生状态的处理更简单?直接使用选择器真的很糟糕吗?
和我同时还有很多其他 Angular 开发人员也在为这个问题苦苦挣扎,我们都得出结论,选择器是处理 NgRx 中派生状态的方法。
选择器
每次看到新的状态管理库出现,我都会检查它们是否使用选择器,或者是否要求你使用distinctUntilChanged
、publishReplay
、和。大多数库都需要,或者你不得不忍受低效的代码。RxAngular 是个例外,它不需要refCount
,因为它提供了,这很棒。combineLatest
debounceTime
debounceTime
coalesceWith
我一直都不喜欢选择器的语法,但它们对我来说似乎很有必要。所以,当我设计自己的状态管理库StateAdapt时,我想找到一种更好的选择器语法。这就是我的想法:
// NgRx:
const selectItems = createSelector(state => state.items);
const selectFilters = createSelector(state => state.filters);
const selectFilteredItems = createSelector(
selectItems,
selectFilters,
(items, filters) => items.filter(filters),
);
// StateAdapt:
const adapter = buildAdapter<State>()({})({
items: s => s.state.items,
filters: s => s.state.filters,
})({
filteredItems: s => s.items.filter(s.filters),
})();
我使用了一个代理对象s
(代表state
和selectors
),它监视您正在访问的选择器并创建比更有效的记忆createSelector
。
我对这种方法非常满意。我以为 Angular 中除了 RxJS 用于事件和异步逻辑,以及 Selector 用于同步和派生状态之外,就不需要其他东西了。
但事实证明选择器也并不完美。
主机托管
选择器有 3 种类型。
状态选择器
这些是简单地从状态对象中进行选择的选择器:
const selectItems = createSelector(state => state.items);
使用管理其所选择状态的减速器来导出这些内容是有意义的。
派生选择器
这些选择器会将来自多个 Reducer/Store 的状态组合起来,并计算出派生状态。这些派生状态不属于顶级状态,并且这些选择器可以包含大量的业务逻辑。
UI 选择器
它们从前两个选择器中获取派生状态并改变数据的形状以方便在模板中使用。
例如,假设我有一些状态描述了一个位置为 的矩形。如果需要用 CSS{ x: 50, y: 200 }
将其渲染为,那么我们可以有如下的 UI 选择器:div
style="left: 50px; top: 200px"
itemWithStyle: s => ({ left: `${s.x}px`, top: `${s.y}px` }),
<div [ngStyle]="store.itemWithStyle$ | async"></div>
这样组件就可以保持精简,您可以通过直接导入状态适配器并向其传递值,将此选择器作为纯函数进行测试。这真的很不错。还有什么地方比将纯函数放在选择器文件中更好呢?在选择器文件中,您可以将其作为纯函数进行测试,而不是作为组件的一部分。而且,作为状态适配器的一部分,它默认是可复用的,而如果您将逻辑放在组件模板中,则必须将其提取出来才能复用。
但是,在尝试将 UI 选择器放入状态适配器一段时间后,我发现,相比将其放在适配器中带来的一点好处,在两个单独的文件中管理同一个关注点实在太麻烦了。感觉就像我用无聊的 UI 关注点污染了派生的选择器文件,而且这些 UI 关注点放在模板中更方便。想象一下,如果模板需要将其实现为 SVG,那么能够在模板中编辑这些逻辑而不是在单独的文件中编辑,岂不是更方便?此外,在什么情况下你会想要重用这些 UI 逻辑?可能是当你也想重用组件的时候,对吧?
但是,如何在组件中定义高效的派生状态呢?在StateAdapt中,这其实并不难,但也并非特别容易,而且使用 NgRx 风格的语法肯定不太方便。
组件输入和钻石问题
RxJS 和选择器的另一个问题是如何处理组件输入。
长期以来,我只是希望将组件输入作为可观察对象。但事实证明,这样做存在一个小问题:如果每个组件输入同时获得一个新值,则每个输入可观察对象都会连续触发。因此,如果您需要在组件内部组合输入值,则combineLatest
每个输入可观察对象都会触发一次。选择器对此无能为力,因为组件输入不属于全局存储。
这个问题实际上有一个名字:钻石问题。
但是你知道什么语法真的很棒,并且完美地解决了菱形问题吗?一个不是 RxJS 的响应式原语:Signals。
SolidJS 信号语法很棒
这是一个简单的 SolidJS 组件:
const CountingComponent = () => {
const [count, setCount] = createSignal(0);
const doubleCount = createMemo(() => count() * 2);
return (
<div onClick={() => setCount(count() + 1)}>
Double count value is {doubleCount()}
</div>
);
};
Angular 和 RxJS 也有同样的情况:
@Component({
selector: 'app-counter',
template: `
<div (click)="count$.next(count$.value + 1)">
Double count value is {{doubleCount$ | async}}
</div>
`,
})
export class CounterComponent {
count$ = new BehaviorSubject(0);
doubleCount$ = this.count$.pipe(
map(count => count * 2),
distinctUntilChanged(),
publishReplay(),
refCount(),
);
}
SolidJS 语法更好,原因有很多:
1. 是否衍生?
在 Angular 中,你需要知道你将拥有派生状态,这样才能BehaviorSubject
立即使用。使用 SolidJS,所有状态的变化都将是信号,它们最终是否用于派生其他状态并不重要。因此,在首次创建信号后,你无需用不同的语法重写它。
2. 重新计算
默认情况下,如果 SolidJS 的信号来源状态没有改变,则不会重新计算信号。无需distinctUntilChanged
。
3.共享
无论从一个信号派生出多少个信号,它都不会为每个信号重新计算,这与 RxJS 不同,它会map
为每个派生的可观察对象运行一个函数,除非你使用publishReplay(), refCount()
。
4. 合并
组合 SolidJS 信号时,您无需像使用 NgRx 选择器或 那样预先定义依赖项数组combineLatest
。您可以像 一样定义它c = createMemo(() => a() + b())
。
RxJS 为每个输入运行一次的问题combineLatest
与信号无关。SolidJS 信号会等待每个依赖项运行完毕后再运行。SolidJS 信号优雅地解决了菱形问题。
好的,现在让我们将 SolidJS 与一些使用选择器的示例进行比较。以下是 SolidJS 的代码:
const CountingComponent = () => {
const [count, setCount] = createSignal(0);
const doubleCount = createMemo(() => count() * 2);
return (
<div onClick={() => setCount(count() + 1)}>
Double count value is {doubleCount()}
</div>
);
};
让我们尝试使用 StateAdapt 的 Angular:
@Component({
selector: 'app-counter',
template: `
<div (click)="count.increment()">
Double count value is {{doubleCount$ | async}}
</div>
`,
})
export class CounterComponent {
count = adapt(['count', 0], {
increment: state => state + 1,
selectors: {
double: state => state * 2,
},
});
}
这其实还不错!但代码量比实际需要的多一点,正如您将在下面看到的。
带有 NgRx 的 Angular 太长了,但你可以想象在组件文件中定义选择器:
@Component({
selector: 'app-counter',
template: `
<div (click)="increment()">
Double count value is {{doubleCount$ | async}}
</div>
`,
})
export class CounterComponent {
selectDoubleCount = createSelector(
selectCount,
count => count * 2,
);
doubleCount$ = this.store.select(this.selectDoubleCount);
constructor(private store: Store) {}
//...
}
基本上,选择器可以有效地完成工作,但我从未见过用信号所需的尽可能少的代码来使用它们的方法。
如果 Angular 中有一个默认高效、语法最少的反应原语,那不是很好吗?
我想要什么
这个怎么样:
@Component({
selector: 'app-counter',
template: `
<div (click)="count.set(count.get() + 1)">
Double count value is {{doubleCount.get()}}
</div>
`,
})
export class CounterComponent {
count = signal(0);
doubleCount = memo(() => this.count.get() * 2);
}
SolidJS 的元组语法不可用,因为我们在这里分配类属性,但这也很好。
当然,应该有一种方法可以将 Angular 输入指定为信号:
@InputSignal: count!: Signal<number>;
RxJS 兼容性仍然有必要
信号非常适合同步派生状态,但 RxJS 对于异步响应式仍然是必需的。如果你有丰富的 RxJS 使用经验,你应该会明白其中的原因,但我在本文中会更详细地解释。
因此,如果 Angular 团队能够找到一种方法,让信号到可观察对象的转换变得极其方便,那就太酷了:
export class CounterComponent {
count = signal(0);
doubleCount = memo(() => this.count.get() * 2);
delayedCount$ = this.count.pipe(delay(1000));
}
这pipe
会将信号转换为可观察对象,并将参数传递给新可观察对象的pipe
方法。
由于我们正在访问可观察对象,pipe()
因此可以将该逻辑隐藏在延迟导入后面,这将允许 RxJS 代码在需要之前不被加载。
或者 Angular 可以复制 SolidJS 的做法:
const CountingComponent = () => {
const [count, setCount] = createSignal(0);
// signal to observable (`from` imported from 'rxjs'):
const count$ = from(observable(count));
// observable to signal (`from` imported from 'solid-js'):
const countAgain = from(count$);
};
总结
我有一些坚定的观点,但我似乎也经常改变主意。但我并不是喜欢每个月都从一种 JavaScript 潮流跳到另一种。我花了很多时间,经历了很多痛苦才形成了我的观点,而我学到的一切都在不断地丰富这些观点。
一开始我以为 RxJS 只是对承诺的轻微改进。
然后我了解了响应式思考的好处,并想在任何地方使用 RxJS。
后来我了解到,使用记忆化选择器比使用 RxJS 更能同步状态。但对于异步逻辑,RxJS 已经非常出色了,而且我希望有比 Angular 和 NgRx 提供的更好的 API。
后来,我接触到了 Ryan Carniato 版本的细粒度同步响应式,以及他关于 Marko 6 中 colocation 的流,最后还看到了大家对Angular 旧问题的评论,该问题描述了组件输入的菱形问题。所有这些想法一直在我脑海中盘旋,直到我和 Ryan 聊了聊 RxJS 的问题。直到那时,我才恍然大悟,意识到除了 RxJS 或选择器之外,响应式原语也扮演着重要的角色。
所以,
- RxJS 对于异步反应性是必需的。
- 选择器对于使用状态适配器重用状态管理模式是必需的。
- 对于简单的、本地的、反应状态同步来说,简单的反应原语是必需的。
在改进StateAdapt 的过程中,我需要弄清楚这个新的响应式原语如何融入状态管理。我可能会先探索如何让它更轻松地与 SolidJS 配合使用。
我们每个人都有不同的理解,我相信 Web 开发社区最终会找到优美的声明性语法,以及有效地培训开发人员以正确的心态利用反应式编程的方法,我们的行业将变得越来越高效,工作起来也越来越愉快。
感谢阅读!
我很想听听您对我在这篇文章中分享的任何内容的个人体验。
封面照片由 Casia Charlie 拍摄:https://www.pexels.com/photo/sea-dawn-landscape-nature-2433467/
文章来源:https://dev.to/this-is-angular/i-changed-my-mind-angular-needs-a-reactive-primitive-n2g