共享状态 | Angular 中的渐进式响应式
应用程序的状态越多,就越有可能遇到不一致的状态,或者说没有响应的状态。例如:用户打开了一条消息,但未显示的消息计数却没有响应。
在 Angular 中,有很多方法可以实现响应式编程,从双向绑定(支持)到高级的 RxJS。有些团队决定采用单一的应用级策略,这意味着每个功能的策略都会像最高级的功能一样复杂。这会降低生产力和开发满意度。
有些团队倾向于不采用任何单一策略,而是允许每位开发人员独立开发每个功能,并根据问题的复杂性调整解决方案的复杂性。这种方法起初速度很快,但复杂性很少是静态的——不可能预测每个用户需求和每个需求变化。这一点很重要,因为在每个阶段都有多种处理更高复杂性的方法,其中一些是死胡同:它们可以被动地处理下一个复杂性级别,但它们的局限性限制了它们在该级别的应用。它们与能够处理更高复杂性级别的解决方案也存在显著差异,因此你必须先回溯,才能再次前进。
所以,我们既不希望过早出现复杂性,也不想陷入难以适应更高复杂性的尴尬境地。理想的策略是一开始就保持简单,但也要易于在任何阶段适应越来越高的复杂性。
那么,我们如何知道应该避免哪些语法呢?首先,我们需要充分理解响应式代码和命令式代码之间的区别。
渐进反应规则#1:
通过引入反应性而不是命令式代码来保持代码的声明性。
最小语法可以以多种可能的方式发展,既有反应式的,也有命令式的,所以我们需要认识到反应式和命令式代码之间的区别。
响应式代码完全是自定义的。没有任何其他东西告诉它如何改变。它通过声明清晰的数据依赖关系来管理自身的行为。
这是被动的:
a$ = new BehaviorSubject(0);
b$ = this.a$.pipe(delay(1000)); // Clear dependency on a$
这是必须的:
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);
}
部分定义b
已从 的声明中分离出来b
。你无法b
通过查看b
的声明或任何单个 来了解其行为setTimeout
。它变得分散了。这就是为什么响应式代码更容易理解的原因。
但想象一下,如果b
它从未改变,它就保持原样undefined
。那么它的初始声明就能完整地描述它的行为。所以它已经完全是声明式的了,就像现在这样。不需要 RxJS。
所有响应式代码都是声明式的,但并非所有声明式代码都是响应式的。声明式代码完全没有命令式的命令,这些命令控制着来自分散、脱离上下文的状态。由于我们试图避免命令式代码中容易出现的不一致状态,因此声明式代码才是我们真正追求的。只有当功能变得更加交互时,代码才必须同时具备声明式和响应式。
只要你不编写命令式代码,无论使用什么语法,你的代码都是声明式的。这意味着你可以从最基本的语法开始,然后等到以后需要更改时再修改其声明,而不是让其他地方的代码来指示它如何操作。
因此,请始终以声明性的方式编写,并在需要保持代码声明性时以反应性的方式编写。
如果您预计未来复杂性会更高,那么在更具反应性方面犯错也不会有什么坏处。
好的。我们准备开始研究第一层复杂性。
级别 0:静态内容
const b = 2
不是反应式的。以下也不是:
<h1>Hello World!</h1>
没关系。强制更改不会带来不一致 bug 的风险。所有静态内容都是声明式的。
级别 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>
然后有人会注意到颜色名称从未改变:
因此,您需要将第一行更改changeColor
为以下两行:
var previewEl = document.getElementById('color-preview');
previewEl.className = previewEl.innerText = newColor;
我们为什么会忽略这一点?写作时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>
// Component class
currentColor = 'aqua';
批判性思考者现在可能会注意到一个矛盾。我对我们的声明式模板感到兴奋,但currentColor = 'aqua'
它不是声明式的。currentColor
的更改是由散布在模板中的命令式命令决定的。但出于以下几个技术原因,这是我们能做的最好的事情:
- 我们只能定义一次模板,但它应该位于因果链的顶部和底部:
currentColor
依赖于按钮点击,但按钮又依赖于currentColor
。如果不使用循环引用,就无法声明这些关系。 - 如果我们想要
currentColor
对按钮点击做出反应,它就不能在组件之间共享,因为其他组件无法访问此按钮。
我们能做的最好的事情是:模板中的每个用户事件将最小的更改推送到 TypeScript 中的单个位置,然后其他所有内容都会对此做出反应。
句法死角
双向数据绑定通常不被鼓励,但在这种复杂程度下其实没问题。只要没有需要更新的派生状态,它就和其他任何东西一样具有声明性。它也不是语法上的死胡同,因为它很容易更改。
<input [(ngModel)]="currentColor" />
到
<input
[ngModel]="currentColor$ | async"
(ngModelChange)="currentColor$.next($event)"
/>
但需要注意的是模板逻辑。例如,如果我们使用currentCount
而不是currentColor
,我们最终可能会在模板内部进行一些简单的数学运算,如下所示:
current count is {{currentCount}}.
Next count: {{currentCount + 1}}.
这很好,因为它很容易迁移到其他地方,但在某种复杂程度下,要么无法用 Angular 的模板语言进行处理,要么我们想用类似 这样的更具表现力的方式{{nextCount}}
。在这种情况下,我们希望将其正式视为派生状态。这将是本系列下一篇文章的主题。