CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / JHIPSTER / P-231 · JHipster 项目开发

JHipster 开发 16:表单与表格的工程化

管理系统的日常就是表单和表格。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}`;
  });
}

三个要点:

  1. 分页/排序全是 URL 信号——page()currentSort() 一变,resource 自动重新请求。想加筛选,就是加一个 filter 信号并拼进 URL,五分钟的事;
  2. 页码从 1 开始,后端 page 从 0 开始,转换在组件里完成;
  3. 分页元信息从第 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@NotNullValidators.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 / @MaxValidators.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。

大数据量表格:把过滤也服务端化

生成的列表只分页了读取,但筛选和搜索默认没有。数据量到几十万行时,方案是把这三件事全部推到服务端:

  1. 过滤:列表页加 filter 信号,后端用 Specification 动态查询(@ParameterObject 传 filter 字段,Repository 扩展 JpaSpecificationExecutor);
  2. 跨页全选:别用”全选 = 当前页勾选 + 请求所有 id”,用”按当前过滤条件选中”的语义,后端提供 POST /api/customers/bulk-delete 接过滤条件;
  3. 导出:同步导出到 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 端点;
  • 数据量大了把过滤/全选/导出全部服务端化,通用表格组件等重复出现三次再抽象。

系列导航

← 软件工程 016:精益企业:集成精益、敏捷、DevOps 等 目录 开发工具 016:开发百宝箱 New →
← 返回文章列表