管理系统的日常就是表单和表格。JHipster 生成的 CRUD 页面覆盖了标准场景,但真实业务里的动态字段、联动校验、文件上传、十万行数据表格都在生成代码的能力之外。这一篇讲我们团队在生成代码之上搭的表单与表格工程化体系:先读懂生成的,再决定复杂需求往哪放。
生成的表单长什么样
每个实体的 update/ 组件配一个 *-form.service.ts,负责 FormGroup 的构建与回填:
@Injectable({ providedIn: 'root' })
export class CustomerFormService {
private readonly fb = inject(NonNullableFormBuilder);
createForm(): CustomerFormGroup {
return this.fb.group({
id: [],
name: [null, [Validators.required, Validators.minLength(2), Validators.maxLength(100)]],
email: [null, [Validators.required, Validators.pattern('^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$')]],
phone: [],
vipLevel: [null, [Validators.min(0), Validators.max(9)]],
birthDate: [],
address: [],
});
}
getFormValue(form: CustomerFormGroup): Customer {
return { ...form.getRawValue() };
}
resetForm(form: CustomerFormGroup, customer: Partial<Customer> | null): void {
form.reset(customer ?? undefined);
}
}
分工清晰:FormService 管”形状与校验”,组件管”状态与提交”。这份契约的最大价值是测试性——FormService 可以脱离组件与 DOM 单测(Vitest 直接实例化调方法),生成的骨架天生适合第 17 篇要讲的测试金字塔。
注意 getRawValue():它连 disabled 字段的值也取出来。实现”某些字段只读但随表单提交”时,用 rawValue 而不是 form.value。
生成的表格:默认就是服务端分页
生成的 list 组件(Angular 21 信号风格)核心状态:
export class CustomerComponent implements OnInit {
currentSort = signal<SortState>({ predicate: 'id', order: 'asc' });
page = signal(1);
totalItems = signal(0);
itemsPerPage = 20;
customersResource = httpResource<RestResponse<Customer[]>>(() => {
const params = new URLSearchParams({
page: String(this.page() - 1),
size: String(this.itemsPerPage),
sort: `${this.currentSort().predicate},${this.currentSort().order}`,
});
return `${SERVER_API_URL}api/customers?${params}`;
});
}
三个要点:
- 分页/排序全是 URL 信号——
page()或currentSort()一变,resource 自动重新请求。想加筛选,就是加一个filter信号并拼进 URL,五分钟的事; - 页码从 1 开始,后端 page 从 0 开始,转换在组件里完成;
- 分页元信息从第 11 篇讲的 Page 模型响应体里读
totalElements,页脚的”共 N 条”用共享的 item-count 组件渲染。
生成的表格默认就是服务端分页(后端 Pageable),没有”先全量再前端分页”的坑。要警惕的反而是自己写代码时手痒把整表拉下来。
前后端校验一致性
JDL 里声明的校验规则会同时生成两处:后端 Bean Validation 注解 + 前端 validators。以 JDL 片段为例:
entity Customer {
name String required minLength(2) maxLength(100)
email String required pattern(/^[^@\s]+@[^@\s]+\.[^@\s]+$/)
vipLevel Integer min(0) max(9)
}
生成结果对照:
| JDL 声明 | 后端注解 | 前端 validator |
|---|---|---|
| required | @NotNull | Validators.required |
| minLength(2) | @Size(min = 2) | Validators.minLength(2) |
| maxLength(100) | @Size(max = 100) | Validators.maxLength(100) |
| pattern(…) | @Pattern(regexp = ...) | Validators.pattern(...) |
| min(0) / max(9) | @Min / @Max | Validators.min / Validators.max |
一致性维护的铁律:改校验改 JDL,再生成两端。手工单独改一端的下场就是”前端过了后端 400”或反过来,用户看到的是两种互相矛盾的报错。
前端的角色定位也要想明白:前端校验是体验(即时反馈、错误高亮),后端校验才是正确性。后端 400 响应里的字段级错误(MethodArgumentNotValidException 翻译后的错误体)同样要回填到表单——生成组件已经处理了这条链路,手写表单时别弄丢它。
复杂表单:动态、联动与文件上传
三种典型复杂场景与我们的落点选择:
动态字段(表单结构运行时可变)用 FormArray,封装在独立的 FormService 扩展方法里:
createInvoiceForm(): FormGroup {
return this.fb.group({
customer: [null, Validators.required],
lines: this.fb.array([this.createLine()]), // 动态行
});
}
createLine(): FormGroup {
return this.fb.group({
productId: [null, Validators.required],
qty: [1, [Validators.required, Validators.min(1)]],
});
}
联动校验(B 字段规则依赖 A 字段值)有两档:轻量联动用 toSignal(form.controls.type.valueChanges) 驱动 computed,进而 setValidators / updateValueAndValidity;跨页面的复杂联动(向导式表单)拆成每步独立的 FormGroup + 一个汇总 service,不要用巨型 FormGroup 硬扛——调试一个 200 行的表单定义是精神污染。
文件上传不走 JSON 表单体,独立资源 + multipart:
uploadAvatar(customerId: number, file: File): Observable<HttpResponse<string>> {
const formData = new FormData();
formData.append('file', file);
return this.http.post(`${SERVER_API_URL}api/customers/${customerId}/avatar`, formData, {
observe: 'response',
responseType: 'text',
});
}
后端用专门的 FileUploadResource(第 11 篇的扩展类模式),流式落盘/对象存储,大小与类型校验在服务端二次执行——Content-Type 与扩展名都是客户端可伪造的。大文件(>10MB)升级为分片或直传对象存储,别让 Spring 的 multipart 在堆里攒出 OOM。
大数据量表格:把过滤也服务端化
生成的列表只分页了读取,但筛选和搜索默认没有。数据量到几十万行时,方案是把这三件事全部推到服务端:
- 过滤:列表页加
filter信号,后端用 Specification 动态查询(@ParameterObject传 filter 字段,Repository 扩展JpaSpecificationExecutor); - 跨页全选:别用”全选 = 当前页勾选 + 请求所有 id”,用”按当前过滤条件选中”的语义,后端提供
POST /api/customers/bulk-delete接过滤条件; - 导出:同步导出到 10 万行就会超时,走异步任务 + 下载链接(配合第 12 篇的消息化)。
JPA 分页深翻页(page=5000)的性能悬崖用 keyset 分页解,或干脆限制最大页数(产品层面”请用搜索缩小范围”)。这些决策都该在表格组件之外的后端层完成,前端只是老实传参。
通用表格组件抽象
第五个项目时我们沉淀了内部 UI 库的 DataTableComponent,把生成列表的共性收敛成一个配置驱动的组件:
export interface ColumnConfig<T> {
field: keyof T & string;
header: string; // i18n key
type?: 'text' | 'date' | 'enum' | 'currency';
sortable?: boolean;
render?: (row: T) => string; // 复杂单元格格式化
}
@Component({
selector: 'app-data-table',
template: `
<table>
<thead>
<tr>
@for (col of columns(); track col.field) {
<th (click)="toggleSort(col.field)">
{{ col.header | translate }}
@if (sort().predicate === col.field) { {{ sort().order === 'asc' ? '▲' : '▼' }} }
</th>
}
</tr>
</thead>
<tbody>
@for (row of data(); track row.id) {
<tr (click)="rowClick.emit(row)">
@for (col of columns(); track col.field) {
<td>{{ format(row, col) }}</td>
}
</tr>
}
</tbody>
</table>
<app-pagination [page]="page()" [total]="total()" (pageChange)="pageChange.emit($event)" />
`,
})
export class DataTableComponent<T extends { id: number }> {
columns = input.required<ColumnConfig<T>[]>();
data = input.required<T[]>();
page = input(1);
total = input(0);
sort = model<SortState>({ predicate: 'id', order: 'asc' });
rowClick = output<T>();
}
业务页退化成三件事:列配置、数据 resource、行点击处理。新实体的列表页从半天变成半小时,且排序/分页交互全站一致。抽象的时机提醒:第二个项目就开始抽通用表格通常过度设计,等三处以上重复模式出现再收敛——我们是在第四、五个项目时抽的,配置接口一次就没再改过。
小结
- 生成表单 = FormService 管形状校验 + 组件管状态提交,可脱离 DOM 单测;
- 生成的表格默认服务端分页,加筛选就是加信号拼 URL,警惕自己写成全量前端分页;
- 校验一致性靠 JDL 单源生成两端,前端校验是体验、后端校验是正确性;
- 复杂表单分档处理:FormArray 动态、信号联动、上传独立 multipart 端点;
- 数据量大了把过滤/全选/导出全部服务端化,通用表格组件等重复出现三次再抽象。
系列导航
- 上一篇:改造 UI——不动生成代码的优雅方式
- 下一篇:测试全景——单元到端到端
- 相关阅读:Angular 端架构——生成的代码怎么读 · REST 层设计 · 性能调优