共享状态 | Angular 中的渐进式响应式

2025-06-10

共享状态 | Angular 中的渐进式响应式

应用程序的状态越多,就越有可能遇到不一致的状态,或者说没有响应的状态。例如:用户打开了一条消息,但未显示的消息计数却没有响应。

在 Angular 中,有很多方法可以实现响应式编程,从双向绑定(支持)到高级的 RxJS。有些团队决定采用单一的应用级策略,这意味着每个功能的策略都会像最高级的功能一样复杂。这会降低生产力和开发满意度。

有些团队倾向于不采用任何单一策略,而是允许每位开发人员独立开发每个功能,并根据问题的复杂性调整解决方案的复杂性。这种方法起初速度很快,但复杂性很少是静态的——不可能预测每个用户需求和每个需求变化。这一点很重要,因为在每个阶段都有多种处理更高复杂性的方法,其中一些是死胡同:它们可以被动地处理下一个复杂性级别,但它们的局限性限制了它们在该级别的应用。它们与能够处理更高复杂性级别的解决方案也存在显著差异,因此你必须先回溯,才能再次前进。

所以,我们既不希望过早出现复杂性,也不想陷入难以适应更高复杂性的尴尬境地。理想的策略是一开始就保持简单,但也要易于在任何阶段适应越来越高的复杂性。

那么,我们如何知道应该避免哪些语法呢?首先,我们需要充分理解响应式代码和命令式代码之间的区别。


分隔线

渐进反应规则#1:

通过引入反应性而不是命令式代码来保持代码的声明性。

最小语法可以以多种可能的方式发展,既有反应式的,也有命令式的,所以我们需要认识到反应式和命令式代码之间的区别。

响应式代码完全是自定义的。没有任何其他东西告诉它如何改变。它通过声明清晰的数据依赖关系来管理自身的行为。

这是被动的:



a$ = new BehaviorSubject(0);
b$ = this.a$.pipe(delay(1000)); // Clear dependency on a$


Enter fullscreen mode Exit fullscreen mode

这是必须的:



a = 0;
b: number | undefined; // No dependency here

constructor() {
  setTimeout(() => this.b = 0, 1000);
}

changeA(newA: number) {
  this.a = newA;
  setTimeout(() => this.b = newA, 1000);
}


Enter fullscreen mode Exit fullscreen mode

部分定义b已从 的声明中分离出来b。你无法b通过查看b的声明任何单个 来了解其行为setTimeout。它变得分散了。这就是为什么响应式代码更容易理解的原因。

但想象一下,如果b它从未改变,它就保持原样undefined。那么它的初始声明就能完整地描述它的行为。所以它已经完全是声明式的了,就像现在这样。不需要 RxJS。

所有响应式代码都是声明式的,但并非所有声明式代码都是响应式的。声明式代码完全没有命令式的命令,这些命令控制着来自分散、脱离上下文的状态。由于我们试图避免命令式代码中容易出现的不一致状态,因此声明式代码才是我们真正追求的。只有当功能变得更加交互时,代码才必须同时具备声明式响应式。

只要你不编写命令式代码,无论使用什么语法,你的代码都是声明式的。这意味着你可以从最基本的语法开始,然后等到以后需要更改时再修改其声明,而不是让其他地方的代码来指示它如何操作。

因此,请始终以声明性的方式编写,并在需要保持代码声明性时以反应性的方式编写。

如果您预计未来复杂性会更高,那么在更具反应性方面犯错也不会有什么坏处。

分隔线


好的。我们准备开始研究第一层复杂性。

级别 0:静态内容

const b = 2不是反应式的。以下也不是:



<h1>Hello World!</h1>


Enter fullscreen mode Exit fullscreen mode

没关系。强制更改不会带来不一致 bug 的风险。所有静态内容都是声明式的。

级别 1:共享状态

想象一个简单的颜色选择器如下:

颜色选择器 1

命令式陷阱

在 AngularJS 等框架出现之前,实现这一目标的常见方式是这样的:



<div id="color-preview" class="aqua">aqua</div>
<button
  id="aqua"
  class="active" 
  onClick="changeColor('aqua')"
>aqua</button>
<button
  id="orange"
  onClick="changeColor('orange')"
>orange</button>
<button
  id="purple"
  onClick="changeColor('purple')"
>purple</button>

<script>
var currentColor = "aqua";
function changeColor(newColor) {
  document.getElementById('color-preview').className = newColor;
  document.getElementById(currentColor).className = '';
  document.getElementById(newColor).className = 'active';
}
</script>


Enter fullscreen mode Exit fullscreen mode

然后有人会注意到颜色名称从未改变:

图片描述

因此,您需要将第一行更改changeColor为以下两行:



  var previewEl = document.getElementById('color-preview');
  previewEl.className =  previewEl.innerText = newColor;


Enter fullscreen mode Exit fullscreen mode

我们为什么会忽略这一点?写作时changeColor,我们并不一定考虑到模板的方方面面。

编辑:在编写此示例时,我故意忘记更新#color-preview的文本。但我无意中也忘记了更新currentColor = newColor。直到现在在StackBlitz中实现它时我才注意到这一点。

所以,基本上,命令式代码和被遗忘的 DOM 更新曾经是常态。DOM 不是响应式的。

响应式解决方案至第 1 级:共享状态

后来,Angular 和其他框架相继出现,现在我们可以声明式地实现类似的功能了。模板的每个部分都可以再次永久地声明它是什么,即使它不再是静态内容。区别在于,每个部分不再声明静态内容,而是声明与一个会变化的值的静态关系。

#color-preview的类和之前一样写着aqua。为什么?因为颜色一开始就是那样的。所以我们写[class]="currentColor",因为那才是它跨越时间的真实状态。内部文本也一样。所以我们{{currentColor}}为此而写。

button#aqua从类开始active。为什么?因为我们知道当当前颜色为时,按钮应该看起来是活动的aqua。所以我们写[class.active]="currentColor === 'aqua'"。按钮的作用是什么?嗯,它会将当前颜色更改为'aqua'。所以应该是(click)="currentColor = 'aqua'"

当我们一点一点地探究时,很容易就能明白一切事物的起源,并意识到它的当前状态始终与一个更高层次的共享状态相关联,即currentColor。我们可以编写完整的模板,并且确信没有遗漏任何内容:



<div
  id="color-preview"
  [class]="currentColor"
>{{currentColor}}</div>
<button 
  [class.active]="currentColor === 'aqua'"
  (click)="currentColor = 'aqua'"
>aqua</button>
<button 
  [class.active]="currentColor === 'orange'"
  (click)="currentColor = 'orange'"
>orange</button>
<button 
  [class.active]="currentColor === 'purple'"
  (click)="currentColor = 'purple'"
>purple</button>


Enter fullscreen mode Exit fullscreen mode


  // Component class
  currentColor = 'aqua';


Enter fullscreen mode Exit fullscreen mode

批判性思考者现在可能会注意到一个矛盾。我对我们的声明式模板感到兴奋,但currentColor = 'aqua'它不是声明式的。currentColor的更改是由散布在模板中的命令式命令决定的。但出于以下几个技术原因,这是我们能做的最好的事情:

  1. 我们只能定义一次模板,但它应该位于因果链的顶部和底部:currentColor依赖于按钮点击,但按钮又依赖于currentColor。如果不使用循环引用,就无法声明这些关系。
  2. 如果我们想要currentColor对按钮点击做出反应,它就不能在组件之间共享,因为其他组件无法访问此按钮。

我们能做的最好的事情是:模板中的每个用户事件将最小的更改推送到 TypeScript 中的单个位置,然后其他所有内容都会对此做出反应。

句法死角

双向数据绑定通常不被鼓励,但在这种复杂程度下其实没问题。只要没有需要更新的派生状态,它就和其他任何东西一样具有声明性。它也不是语法上的死胡同,因为它很容易更改。



<input [(ngModel)]="currentColor" />


Enter fullscreen mode Exit fullscreen mode



<input
  [ngModel]="currentColor$ | async"
  (ngModelChange)="currentColor$.next($event)"
/>


Enter fullscreen mode Exit fullscreen mode

但需要注意的是模板逻辑。例如,如果我们使用currentCount而不是currentColor,我们最终可能会在模板内部进行一些简单的数学运算,如下所示:



current count is {{currentCount}}.
Next count: {{currentCount + 1}}.


Enter fullscreen mode Exit fullscreen mode

这很好,因为它很容易迁移到其他地方,但在某种复杂程度下,要么无法用 Angular 的模板语言进行处理,要么我们想用类似 这样的更具表现力的方式{{nextCount}}。在这种情况下,我们希望将其正式视为派生状态。这将是本系列下一篇文章的主题。

鏂囩珷鏉ユ簮锛�https://dev.to/this-is-angular/progressive-reactivity-in-angular-1d40
PREV
Angular-eslint、ESLint 和 Nx 11 的终极迁移指南目录先决条件使用 angular-eslint 设置新的 Nx Angular 工作区使用 ESLint 迁移现有的 Nx 10 Angular 工作区使用 TSLint 迁移现有的 Nx 10 Angular 工作区结论
NEXT
不,我不想成为 Angular GDE 恐惧驱动的领导 难以接触 Angular 团队 骚扰和公开羞辱 是时候说出来