使用 NgRx 的模块化多步骤表格,20 分钟内即可完成
使用 NgRx 的模块化多步骤表格,20 分钟内即可完成
使用 NgRx 的模块化多步骤表格,20 分钟内即可完成
这是 ReactiveForm 系列的第三部分。现在您已经了解了如何使用 ReactiveForm 以及如何使其易于访问,是时候进行实际操作了。我们将创建一个多步骤表单,并添加易于访问的验证功能。不仅如此,我们还将使用 NgRx 来保持各个步骤的同步。
问题
在 This Dot,我们不断发展壮大。招聘是我们流程中的关键环节,我们通过导师制赋能开发者。这固然很好,但也意味着我们会收到大量的申请。因此,我们需要创建一个多步骤的申请表,供有意加入 This Dot 的开发者填写。
因为我们是一家包容性公司,所以我们需要确保每个人都能使用表格。因此,无障碍设计在这里至关重要。我们将使用本系列第二部分讨论的技术来实现这一点。但这还不是全部。由于我们不知道谁会申请,所以我们需要确保所有申请都有效。
我们需要很多信息才能启动这个流程:个人信息、地址详情和工作经历。因此,如果我们把它做成一个单页表单,使用起来会非常困难,更糟糕的是,它会让人们感到非常无聊,以至于他们干脆放弃尝试。
既然你已经了解了设计背后的原理,那么我们就开始吧。
解决方案
我相信,当你充满动力时,工作效率会更高,而界面糟糕的应用程序会让人非常厌烦。一个应用程序可能存在一些漏洞,但如果它看起来很棒,它很可能会激励你去修复或改进它。(至少对于我这个视觉型的人来说是这样。)
既然我负责这个项目的开发,为了激励大家,我们先从优化多步骤表单的外观入手。等我们对表单的美观度满意后,再继续完善它的功能。
该应用程序将使用 Angular 构建。我们不会手动创建所有文件夹、文件和配置文件,而是依赖 Angular CLI。为此,请按照以下步骤操作:
- 打开你最喜欢的命令行工具
- 使用以下命令全局安装 Angular CLI
npm install -g @angular/cli - 前往您想要创建应用程序的位置,然后运行该命令
ng new embrace-power
此时,您已经生成了一个全新的应用程序。我喜欢做的一件事是创建一个名为 variables.scss 的文件,用来存储所有我想使用的变量。在这个例子中,我只有一个变量,$base-color: #444所以我把它保存在.scss 文件中src/assets/styles/。然后,在任何需要访问它的 scss 文件中,您都可以使用它@import '~src/assets/styles/variables.scss';。
如果你和我一样,你肯定会好奇为什么开头有个“ ~”符号。
这是告诉webpack使用基础源代码的。
将应用模板的内容替换为以下内容:
<!-- src/app/app.component.html -->
<main>
<router-outlet></router-outlet>
</main>
现在,设置应用程序的基本样式。
// src/styles.scss
body,
html {
margin: 0;
background: #333;
font-family: 'Roboto';
}
最后,在 `<head>` 标签中添加 Roboto 字体系列和 Material 图标。
<!-- /src/index.html -->
<link
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
外观像原生应用的网页应用很棒。所以我打算尝试让它拥有移动/桌面应用的外观和体验。
步骤标题
如果您要构建一个多步骤表单,就需要一种方法来实现步骤间的导航。有时,允许用户快速跳转到任何步骤会很有用。在这种情况下,我们需要一个包含指向每个步骤链接的头部组件。
由于我们的应用程序只会用到单个头部组件,我们可以认为它是应用程序的核心部分。首先,让我们使用 Angular CLI 在ng generate module core应用程序文件夹内创建核心模块。现在,您已经拥有了核心模块——接下来您需要头部组件。
Angular CLI 又一次拯救了我!
只需运行即可ng generate component core/header。这将在核心模块中创建一个新组件。
要使用这个新组件,您需要将其添加到核心模块声明的 exports 数组中。或者,您可以使用标志--export=true指示 CLI 将组件添加到 exports 数组中。
现在是时候编写实际的模板了。
<!-- src/app/core/header/header.component.html -->
<header>
<nav>
<ul>
<li><a href="#">Personal</a></li>
<li><a href="#">Address</a></li>
<li><a href="#">Experience</a></li>
</ul>
</nav>
</header>
如果您按照上述说明生成了组件,则头部组件已包含在声明中,导出也已包含在内CoreModule。遗憾的是,您必须先在应用程序组件中导入该组件,才能在应用程序组件中使用它CoreModule。AppModule您只需导入一次此模块。如果出于某种原因需要在其他地方导入它,则应该考虑采用其他SharedModule方法。
在 AppModule 中导入 CoreModule
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CoreModule } from './core/core.module';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, AppRoutingModule, CoreModule],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
现在,你可以在应用程序组件模板中这样使用它。
<app-header></app-header>
<main>
<router-outlet></router-outlet>
</main>
但这看起来很糟糕,对吧?让我们添加一些 CSS 代码来让它好一些。
// src/app/core/header/header.component.scss
@import '~src/assets/styles/variables.scss';
:host {
display: block;
}
header {
background-color: darken($base-color, 20);
min-height: 10vh;
nav {
height: 100%;
ul {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
height: 100%;
li {
display: flex;
margin: 0.5rem;
list-style-type: none;
justify-content: center;
& > * {
color: darken(white, 20);
padding: 0.5rem;
font-size: 1.5rem;
letter-spacing: 0.1rem;
line-height: 1.5;
}
a {
text-decoration: none;
&:visited {
color: darken(white, 30);
}
&.active,
&:active,
&:hover,
&:focus {
text-decoration: underline;
color: white;
}
&:hover,
&:focus {
outline: 1px white solid;
}
}
}
}
}
}
@media all and (min-width: 768px) {
header {
nav {
ul {
flex-direction: row;
justify-content: space-around;
}
}
}
}
如果你好奇我们为什么要使用媒体查询,那是因为根据我的经验,移动优先设计总是最佳选择。具体做法是:首先定义移动端样式,当视口宽度大于或等于 768px 时,我们只需稍微调整样式即可。我们利用了 CSS 的层叠特性。
步骤组件
每个步骤都会有所不同,但它们共享一些布局逻辑。它们都包含标题、“上一步”和“下一步”按钮。在继续之前,我们将创建一个新组件,用于封装每个步骤特定的逻辑。这样可以确保界面的一致性。
所有步骤都将拆分成模块。我们稍后会详细讨论这一点。现在,让我们专注于创建这个可重用组件。我喜欢将所有可重用组件存储在一个共享模块中。这样,我就可以导入这个共享模块,并根据需要使用这些可重用组件。
我们将再次使用 Angular CLI:
- 打开你最喜欢的命令行工具
- 将目录更改为项目所在位置。
- 运行该命令
ng generate module shared - 运行该命令
ng generate component shared/wizard-step --export=true
我们先来编写模板内容。
<!-- src/app/shared/wizard-step/wizard-step.component.html -->
<section>
<header>
<h1>{{ title }}</h1>
</header>
<div>
<button id="previous-button" (click)="goToPreviousStep()">
<i class="material-icons">navigate_before</i> <span>Previous</span>
</button>
<ng-content></ng-content>
<button id="next-button" (click)="goToNextStep()">
<span>Next</span> <i class="material-icons">navigate_next</i>
</button>
</div>
</section>
通过使用内容投影<ng-content>,我们可以使用这个新模块来共享步骤的所有标记逻辑。正如您所看到的,按钮中有一个 title 属性,以及两个通过事件绑定执行的方法。让我们看看它们在文件中是如何实现的.ts。
// src/app/shared/wizard-step/wizard-step.component.ts
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-wizard-step',
templateUrl: './wizard-step.component.html',
styleUrls: ['./wizard-step.component.scss']
})
export class WizardStepComponent implements OnInit {
@Input() title: string;
@Output() previousStepClicked = new EventEmitter();
@Output() nextStepClicked = new EventEmitter();
constructor() {}
ngOnInit() {}
goToPreviousStep() {
this.previousStepClicked.emit();
}
goToNextStep() {
this.nextStepClicked.emit();
}
}
还有款式,别忘了。
// src/app/shared/wizard-step/wizard-step.component.scss
@import '~src/assets/styles/variables.scss';
header {
background-color: darken($base-color, 15);
height: 10vh;
display: flex;
align-items: center;
justify-content: center;
h1 {
color: white;
margin: 0;
padding: 1rem;
text-align: center;
font-size: 2.8rem;
}
}
section {
height: 80vh;
div {
display: flex;
justify-content: space-around;
height: 100%;
button {
border: none;
background: darken($base-color, 10);
height: max-content;
align-self: center;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
padding: 0.5rem 0;
outline: 0.1rem darken(white, 30) solid;
cursor: pointer;
&#previous-button {
padding-right: 1rem;
}
&#next-button {
padding-left: 1rem;
}
span {
display: none;
}
&:focus,
&:hover {
outline: 0.2rem white solid;
background: darken($base-color, 20);
}
}
}
}
@media all and (min-width: 768px) {
section {
div {
button {
span {
display: block;
}
}
}
}
}
步骤内容
现在,是时候把所有东西整合起来了。首先,我们要创建一个专门用于个人信息填写步骤的新模块。这可以通过你已经知道的模块生成命令来实现ng generate module personal。但这还不够,对吧?现在,我们需要一个组件来存储实际的表单,这可以通过命令来实现ng generate component personal。
应用启动时将用户重定向到个人页面。
// src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
title = 'embrace-power';
constructor(private router: Router) {}
ngOnInit() {
this.router.navigate(['personal']);
}
}
导入 SharedModule 和 RouterModule,以设置默认路由。
// src/app/personal/personal.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { PersonalComponent } from './personal.component';
import { SharedModule } from '../shared/shared.module';
import { RouterModule } from '@angular/router';
@NgModule({
declarations: [PersonalComponent],
imports: [
CommonModule,
SharedModule,
RouterModule.forChild([{ path: '', component: PersonalComponent }]),
ReactiveFormsModule
]
})
export class PersonalModule {}
注意:请记住在新模块中导入 ReactiveFormsModule 和 SharedModule。
我们还有些事情要做。我们需要将新模块连接到路由结构中,以便能够正确导航。
// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{
path: 'personal',
loadChildren: () =>
import('./personal/personal.module').then(m => m.PersonalModule)
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
现在,更新标题中的链接,但首先需要RouterModule导入CoreModule。
// src/app/core/core.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HeaderComponent } from './header/header.component';
import { RouterModule } from '@angular/router';
@NgModule({
declarations: [HeaderComponent],
imports: [CommonModule, RouterModule.forChild([])],
exports: [HeaderComponent]
})
export class CoreModule {}
在 HeaderComponent 模板中,更新链接以使用routerLink, 和routerLinkActivefromRouterModule在标题中。
<header>
<nav>
<ul>
<li>
<a [routerLink]="['/personal']" routerLinkActive="active">Personal</a>
</li>
<li><a href="#">Address</a></li>
<li><a href="#">Experience</a></li>
</ul>
</nav>
</header>
现在该着手编写组件类声明了。
// src/app/personal/personal.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-personal',
templateUrl: './personal.component.html',
styleUrls: ['./personal.component.scss']
})
export class PersonalComponent implements OnInit {
title = 'Personal';
personalForm = this.fb.group(
{
firstName: [null, [Validators.required]],
lastName: [null, [Validators.required]],
age: [
null,
[Validators.required, Validators.min(18), Validators.max(120)]
],
about: [null, [Validators.required]]
},
{
updateOn: 'blur'
}
);
firstNameCtrl = this.personalForm.get('firstName');
lastNameCtrl = this.personalForm.get('lastName');
ageCtrl = this.personalForm.get('age');
aboutCtrl = this.personalForm.get('about');
submitted = false;
constructor(private fb: FormBuilder) {}
goToNextStep() {
this.submitted = true;
}
ngOnInit() {
// this method comes from OnInit interface
}
}
这里发生了什么?我们声明了将作为输入传递给 WizardStepComponent 的标题,以及用于处理 PersonalComponent 中表单数据的 ReactiveForm。如果您阅读过本系列之前的文章,您会注意到一些新内容:验证器和{ updateOn: 'blur' }配置对象的使用。
`updateOn` 选项的含义显而易见。它只是让响应式表单仅在用户离开输入框时才记录更改。验证器则稍微复杂一些。你可以使用一个验证器数组,这些验证器本质上是返回布尔值的函数。本示例中使用的所有验证器都是库内置的,但你也可以编写自己的验证器。
现在表单里有了验证器,每当发现错误,它都会被添加到表单的 error 属性中。这样,ngIf我们就可以使用指令来有条件地显示错误。还有一个非常巧妙的技巧:设置一个submitted默认值为 false 的属性。表单提交后,该属性的值会被更改,这样,只有在表单提交后才会显示错误。
这就是模板现在的样子:
<!-- src/app/personal/personal.component.html -->
<app-wizard-step [title]="title" (nextStepClicked)="goToNextStep()">
<form [formGroup]="personalForm" [attr.aria-label]="title">
<label>
<span>First name *</span>
<input
class="form-control"
type="text"
formControlName="firstName"
required
/>
</label>
<span
class="form-error"
*ngIf="submitted && firstNameCtrl?.errors?.required"
>
First name is required
</span>
<label>
<span>Last name *</span>
<input
class="form-control"
type="text"
formControlName="lastName"
required
/>
</label>
<span
class="form-error"
*ngIf="submitted && lastNameCtrl?.errors?.required"
>
Last name is required
</span>
<label>
<span>Age *</span>
<input
class="form-control"
type="number"
formControlName="age"
required
/>
</label>
<span class="form-error" *ngIf="submitted && ageCtrl?.errors?.required">
Age is required
</span>
<span class="form-error" *ngIf="submitted && ageCtrl?.errors?.min">
Age has to be greater or equal than 18
</span>
<span class="form-error" *ngIf="submitted && ageCtrl?.errors?.max">
Age has to be less or equal than 120
</span>
<label>
<span>About *</span>
<textarea
class="form-control"
rows="4"
formControlName="about"
required
></textarea>
</label>
<span class="form-error" *ngIf="submitted && aboutCtrl?.errors?.required">
About is required
</span>
</form>
</app-wizard-step>
别忘了样式设计。还记得我说过我讨厌用自己不喜欢的视觉效果吗?经过一些改进,我得到了以下样式。
// src/app/personal/personal.component.scss
@import '~src/assets/styles/variables.scss';
form {
width: 100%;
max-width: 700px;
padding: 2rem;
background: darken($base-color, 10);
overflow-y: auto;
}
label {
display: flex;
justify-content: space-around;
min-height: 2rem;
padding: 1rem;
flex-direction: column;
span {
color: white;
font-size: 1.2rem;
width: 100%;
}
&:not(:last-child) {
margin-bottom: 0.5rem;
}
&:hover,
&:focus-within {
outline: 1px white solid;
}
}
.form-control {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid white;
color: white;
font-size: 1.3rem;
padding-bottom: 0.3rem;
margin: 1rem 0;
&:focus {
outline: none;
}
}
.form-error {
display: block;
color: red;
margin: 0.5rem 0;
}
@media all and (min-width: 768px) {
label {
flex-direction: row;
span {
font-size: 1.5rem;
width: 30%;
}
}
.form-control {
width: 60%;
margin: 0;
}
}
你还会在这个样式表中发现一些经典的移动优先设计理念。欢迎查看。我把它作为可选作业。
其他步骤
第一步已经完成,我们可以轻松地将这套逻辑复用于其他步骤。我们可以添加任意数量的步骤。只需记住通过路由器连接,并将其作为步骤之一添加到请求头中即可。我相信您自己就能做到,所以我就跳过这一步。
如果您不想自己完成我们所做的一切,但又想直接进入状态管理部分,这里有一个可自定义的版本。
州
如果您已经创建了新的步骤,您可能想知道接下来该做什么?所有这些模块都是分离的,现在很难跟踪整个表单的状态。您可能还注意到,如果您在不同的状态之间切换,您输入的值就会丢失。这些对我们来说都不是问题,因为我们知道 NgRx 可以帮您解决这个问题。您现在需要做的是:
- 为表单中的步骤创建 reducer。
- 为每个步骤创建选择器。
- 所有步骤都会使用选择器来填充表单。
- 为每个步骤创建一组操作。
- 每次表单中的值发生更改时,都会将其更新到数据存储中。
首先,我们需要安装 NgRx Store,这可以通过npm install --save @ngrx/store在应用程序目录中运行命令轻松完成。
注意:我建议您通过执行以下命令安装 StoreDevtools 以进行测试。
npm install --save @ngrx/store-devtools
现在我们来创建 reducer(我将重点介绍个人步骤,但其他步骤的策略相同)。在 `<path>` 下
创建一个名为 `<folder>` 的文件夹,并将文件放入其中,内容如下:statesrc/app/corepersonal.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { PersonalPageActions } from '../../personal/actions';
import { Personal } from '../interfaces/personal.interface';
import { PersonalGroup } from '../models/personal.model';
export interface State {
data: Personal;
isValid: boolean;
}
const initialState = new PersonalGroup();
const personalReducer = createReducer(
initialState,
on(
PersonalPageActions.patch,
(state: State, action: ReturnType<typeof PersonalPageActions.patch>) => ({
...state,
data: { ...state.data, ...action.payload }
})
),
on(
PersonalPageActions.changeValidationStatus,
(
state: State,
{ isValid }: ReturnType<typeof PersonalPageActions.changeValidationStatus>
) => ({
...state,
isValid
})
)
);
export function reducer(state: State, action: PersonalPageActions.Union) {
return personalReducer(state, action);
}
export const selectPersonalGroupData = (state: State) => state.data;
export const selectPersonalGroupIsValid = (state: State) => state.isValid;
接口(src/app/core/interfaces/personal.interface.ts)和模型(src/app/core/models/personal.model.ts)如下所示:
export interface Personal {
firstName: string;
lastName: string;
age: number;
about: string;
}
import { Personal } from '../interfaces/personal.interface';
export class PersonalGroup {
data = {
firstName: '',
lastName: '',
age: 18,
about: ''
} as Personal;
isValid = false;
}
我将首先在缩减器中使用桶导入,这将有助于您稍后使用其他缩减器(src/app/core/state/index.ts)。
import { ActionReducerMap, createSelector, MetaReducer } from '@ngrx/store';
import * as fromPersonal from './personal.reducer';
import { PersonalGroup } from '../models/personal.model';
export interface State {
personal: PersonalGroup;
}
export const reducers: ActionReducerMap<State> = {
personal: fromPersonal.reducer
};
export const metaReducers: MetaReducer<State>[] = [];
export const selectPersonalGroup = (state: State) => state.personal;
export const selectPersonalGroupData = createSelector(
selectPersonalGroup,
fromPersonal.selectPersonalGroupData
);
export const selectPersonalGroupIsValid = createSelector(
selectPersonalGroup,
fromPersonal.selectPersonalGroupIsValid
);
此外,还有一些与操作相关的内容。我为每个页面创建了一个操作文件。这样,操作就与特定上下文相关,也便于日后思考。这些操作直接存储在可以分发它们的模块中。例如,“个人”操作存储在src/app/personal/actions/personal-page.actions.ts……
import { createAction, props } from '@ngrx/store';
import { Personal } from '../../core/interfaces/personal.interface';
export const patch = createAction(
'[Personal Page] Patch Value',
props<{ payload: Partial<Personal> }>()
);
export const changeValidationStatus = createAction(
'[Personal Page] Change Validation Status',
props<{ isValid: boolean }>()
);
export type Union = ReturnType<typeof patch | typeof changeValidationStatus>;
另外,别忘了为这些操作创建一个索引文件(src/app/personal/actions/index.ts):
import * as PersonalPageActions from './personal-page.actions';
export { PersonalPageActions };
现在唯一缺少的就是运用这些新特性。首先,我们将 reducer 添加到 AppModule 中,然后将所有内容连接到相应的组件中。
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { StoreModule } from '@ngrx/store';
import { reducers, metaReducers } from './core/state';
@NgModule({
imports: [
// ...
StoreModule.forRoot(reducers, { metaReducers }),
StoreDevtoolsModule.instrument({
maxAge: 25
})
// ...
]
// ...
})
export class AppModule {}
注意:我还注入了 StoreDevtools 以启用 Chrome Devtools 的 redux 小部件。
太好了。我们快完成了。现在只需要把步骤的组件连接到src/personal/personal.component.ts……
// 1) New imports
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as fromRoot from '../core/state';
import { PersonalPageActions } from './actions';
import { map, take, distinctUntilChanged } from 'rxjs/operators';
import { merge } from 'rxjs';
import { Personal } from '../core/interfaces/personal.interface';
// ...
export class PersonalComponent implements OnInit {
// ...
// 2) Inject the router and the store
constructor(
private router: Router,
private fb: FormBuilder,
private store: Store<fromRoot.State>
) {}
ngOnInit() {
// 3) Get the last state of the personal data and patch the form with it
this.store
.select(fromRoot.selectPersonalGroupData)
.pipe(take(1))
.subscribe((personal: Personal) =>
this.personalForm.patchValue(personal, { emitEvent: false })
);
// 4) For each field create an observable that maps the change as a key value
const firstName$ = this.firstNameCtrl.valueChanges.pipe(
map((firstName: string) => ({ firstName } as Partial<Personal>))
);
const lastName$ = this.lastNameCtrl.valueChanges.pipe(
map((lastName: string) => ({ lastName } as Partial<Personal>))
);
const age$ = this.ageCtrl.valueChanges.pipe(
map((age: number) => ({ age } as Partial<Personal>))
);
const about$ = this.aboutCtrl.valueChanges.pipe(
map((about: string) => ({ about } as Partial<Personal>))
);
// 5) For each change trigger an action to update the store
merge(firstName$, lastName$, age$, about$).subscribe(
(payload: Partial<Personal>) => {
this.store.dispatch(PersonalPageActions.patch({ payload }));
}
);
// 6) If the validaty status of the form changes dispatch an action to the store
this.personalForm.valueChanges
.pipe(
map(() => this.personalForm.valid),
distinctUntilChanged()
)
.subscribe((isValid: boolean) =>
this.store.dispatch(
PersonalPageActions.changeValidationStatus({ isValid })
)
);
}
// 7) Add method to go to next step through navigation if the form is valid
goToNextStep() {
if (this.personalForm.invalid) {
this.submitted = true;
return;
}
this.router.navigate(['address']);
}
}
我们在这里所做的只是从数据存储中获取最新状态,并将其更新到表单中。然后,我们创建一个流,每当输入内容发生变化时,该流都会发出一个操作。
如果对每个步骤重复此操作,您将得到一个完整的、带有验证功能的、易于访问的多步骤表单。如果您想直接跳到完整版本,这里有一个功能齐全的应用程序。
结论
ReactiveForms 功能非常强大。在前几部分中,我们讨论了一些核心概念,现在我们需要真正发挥它的威力。如果你运用了这里提到的所有概念,你几乎可以创建任何复杂的表单。如果你想了解测试方面的内容,这篇文章本身已经足够长了,所以我计划专门写一篇关于 ReactiveForms 测试的文章。
文章来源:https://dev.to/thisdotmedia/modular-multi-step-form-with-ngrx-in-less-than-20-minutes-3770