前面三十四篇把 v22 的每块能力单独讲透了,本篇把它们装配成一台完整的机器:一个任务看板应用(TaskBoard),覆盖列表加载、筛选统计、新建编辑、删除、拖拽排序的完整 CRUD 链路。全程只用本系列讲过的 API——路由输入绑定(24)、@Service() 数据层(19)、httpResource 声明式数据(23)、Signal Forms 表单(25)、新控制流(8)、CDK DragDrop(42 会展开细节,这里只用)。读完你就拥有一个可以照抄起手的 v22 项目骨架。
需求与架构
功能清单:
- 任务列表:加载
/api/tasks,按状态筛选(全部 / 进行中 / 已完成),显示统计 - 拖拽排序:列表项可拖动重排,新顺序持久化到后端
- 新建 / 编辑:路由到表单页,Signal Forms 校验,成功后返回列表
- 删除:列表项操作,乐观更新后同步
分层架构如下——组件只做视图与交互,状态与请求全部沉淀在 store:
graph TD
R[App 路由 - routes.ts] --> LST[TaskListComponent 列表页]
R --> FM[TaskFormComponent 表单页]
LST -->|cdkDropListDropped| DD[CDK DragDrop 排序]
LST -->|筛选 signal| ST[TaskStore @Service]
FM -->|form + validate| SF[Signal Forms]
SF -->|submit| ST
ST -->|httpResource 读取| API[/api/tasks/]
ST -->|HttpClient 写入| API
ST -->|signals + computed| LST
关键决策:
| 决策 | 选择 | 依据 |
|---|---|---|
| 数据读取 | httpResource | 声明式、自动取消、状态直通模板(第 23 篇) |
| 数据写入 | HttpClient + 手动 reload | 写操作是命令式的,Observable 形式便于序列化(第 23 篇) |
| 状态容器 | @Service() 单例 store | 跨页面共享列表状态(第 19 篇) |
| 表单 | Signal Forms | v22 稳定方案(第 25 篇) |
| 排序 | CDK DragDrop | 无障碍与移动端触控开箱即用(第 42 篇) |
| 组件通信 | 路由输入绑定 + store 事件 | 避免 service 当事件总线滥用 |
脚手架与领域模型
ng new task-board --style=scss --routing
cd task-board
ng add @angular/cdk
# 本地 mock 后端(可选)
npm install -D json-server
npx json-server --watch db.json --port 3000
领域模型,features/tasks/task.types.ts:
export interface Task {
readonly id: number;
title: string;
notes: string;
done: boolean;
order: number;
}
export type TaskFilter = 'all' | 'open' | 'done';
export interface ReorderPayload {
ids: number[];
}
proxy.conf.json 把 /api 转发到 mock 服务:
{
"/api": {
"target": "http://localhost:3000",
"secure": false
}
}
ng serve --proxy-config proxy.conf.json
路由:三个页面与输入绑定
app.routes.ts——列表、新建、编辑三态,编辑页通过 withComponentInputBinding 直接把路由参数变成组件 input(第 24 篇):
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
{
path: 'tasks',
loadComponent: () => import('./features/tasks/task-list/task-list.component')
.then((m) => m.TaskListComponent),
title: '任务看板',
},
{
path: 'tasks/new',
loadComponent: () => import('./features/tasks/task-form/task-form.component')
.then((m) => m.TaskFormComponent),
title: '新建任务',
},
{
path: 'tasks/:id',
loadComponent: () => import('./features/tasks/task-form/task-form.component')
.then((m) => m.TaskFormComponent),
title: '编辑任务',
},
{ path: '**', redirectTo: 'tasks' },
];
main.ts:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [provideRouter(routes, withComponentInputBinding())],
}).catch((err) => console.error(err));
根组件只留路由出口与标题(app.component.ts):
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `
<header class="app-header">
<h1>TaskBoard</h1>
</header>
<main class="app-main">
<router-outlet />
</main>
`,
})
export class AppComponent {}
数据层:TaskStore
features/tasks/task.service.ts——本应用唯一的状态所有者。读取走 httpResource(filter 变化自动重查),写入走 HttpClient,写完 reload() 同步:
import { Service, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { httpResource } from '@angular/common/http';
import { Task, TaskFilter } from './task.types';
@Service()
export class TaskStore {
private readonly http = inject(HttpClient);
// ---------- 状态 ----------
readonly filter = signal<TaskFilter>('all');
readonly reorderPending = signal(false);
// ---------- 读取:声明式资源 ----------
// filter 变化 → URL 变化 → 自动重新请求,过期请求自动取消
readonly tasksResource = httpResource<Task[]>(() => {
const filter = this.filter();
const query = filter === 'all' ? '' : `?done=${filter === 'done'}`;
return `/api/tasks${query}`;
});
// 服务端排序兜底:按 order 字段排
readonly tasks = computed(() =>
(this.tasksResource.value() ?? []).slice().sort((a, b) => a.order - b.order),
);
readonly stats = computed(() => {
const list = this.tasks();
return {
total: list.length,
open: list.filter((t) => !t.done).length,
done: list.filter((t) => t.done).length,
};
});
// ---------- 写入:命令式 ----------
create(draft: Omit<Task, 'id' | 'order'>): void {
this.http.post<Task>('/api/tasks', draft).subscribe({
complete: () => this.tasksResource.reload(),
});
}
update(id: number, patch: Partial<Task>): void {
this.http.patch<Task>(`/api/tasks/${id}`, patch).subscribe({
complete: () => this.tasksResource.reload(),
});
}
remove(id: number): void {
this.http.delete<void>(`/api/tasks/${id}`).subscribe({
complete: () => this.tasksResource.reload(),
});
}
toggleDone(task: Task): void {
// 乐观更新:本地先改,请求失败 reload 回滚
this.update(task.id, { done: !task.done });
}
reorder(previousIndex: number, currentIndex: number): void {
const ids = this.tasks().map((t) => t.id);
const [moved] = ids.splice(previousIndex, 1);
ids.splice(currentIndex, 0, moved);
this.reorderPending.set(true);
this.http.post('/api/tasks/reorder', { ids }).subscribe({
complete: () => {
this.reorderPending.set(false);
this.tasksResource.reload();
},
});
}
setFilter(filter: TaskFilter): void {
this.filter.set(filter);
}
}
注意读写的分工哲学:读是”状态的函数”(URL 由信号拼出),写是”事件的响应”(方法调用)。不要把写操作也塞进 resource 的 loader——那是把命令伪装成声明。
列表页:控制流 + 拖拽 + 筛选
features/tasks/task-list/task-list.component.ts:
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop';
import { TaskStore } from '../task.service';
import { Task, TaskFilter } from '../task.types';
const FILTERS: TaskFilter[] = ['all', 'open', 'done'];
@Component({
selector: 'app-task-list',
imports: [RouterLink, DragDropModule],
templateUrl: './task-list.component.html',
styleUrl: './task-list.component.scss',
})
export class TaskListComponent {
protected readonly store = inject(TaskStore);
protected readonly filters = FILTERS;
protected readonly filterLabel: Record<TaskFilter, string> = {
all: '全部',
open: '进行中',
done: '已完成',
};
onDrop(event: CdkDragDrop<never>): void {
if (event.previousIndex === event.currentIndex) {
return;
}
this.store.reorder(event.previousIndex, event.currentIndex);
}
}
task-list.component.html——@for 带 track、@if 分支加载态、@let 复用派生值、CDK 拖拽指令四件套齐活:
<section class="toolbar">
<nav class="filters" aria-label="筛选">
@for (f of filters; track f) {
<button
type="button"
class="filter"
[class.active]="store.filter() === f"
(click)="store.setFilter(f)"
>
{{ filterLabel[f] }}
</button>
}
</nav>
@let stats = store.stats();
<span class="stats">
共 {{ stats.total }} 项 · 进行中 {{ stats.open }} · 已完成 {{ stats.done }}
</span>
<a routerLink="/tasks/new" class="add">新建任务</a>
</section>
@if (store.tasksResource.isLoading() && !store.tasksResource.hasValue()) {
<p class="hint">加载中……</p>
} @else if (store.tasksResource.error(); as err) {
<p class="error">加载失败:{{ err.message }}</p>
} @else {
<ul
class="task-list"
cdkDropList
cdkDropListOrientation="vertical"
(cdkDropListDropped)="onDrop($event)"
>
@for (task of store.tasks(); track task.id) {
<li class="task-row" cdkDrag [class.done]="task.done">
<span class="drag-handle" cdkDragHandle aria-hidden="true">⋮⋮</span>
<label class="task-title">
<input
type="checkbox"
[checked]="task.done"
(change)="store.toggleDone(task)"
/>
<span>{{ task.title }}</span>
</label>
<span class="spacer"></span>
<a class="edit" [routerLink]="['/tasks', task.id]">编辑</a>
<button type="button" class="remove" (click)="store.remove(task.id)">
删除
</button>
</li>
} @empty {
<li class="empty">当前筛选下没有任务</li>
}
</ul>
}
@if (store.reorderPending()) {
<p class="hint">正在保存顺序……</p>
}
task-list.component.scss(节选):
.task-list {
list-style: none;
margin: 0;
padding: 0;
max-width: 640px;
.task-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 8px;
background: #fff;
&.cdk-drag-preview {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
&.cdk-drag-placeholder {
opacity: 0.3;
}
}
}
拖拽相关的类名(cdk-drag-preview、cdk-drag-placeholder)是 CDK 约定的动画钩子,第 42 篇会展开其排序原理与动画定制。
表单页:Signal Forms 双模式
新建与编辑共用一个组件,靠路由参数区分模式。features/tasks/task-form/task-form.component.ts:
import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { form, validate, required, minLength, maxLength } from '@angular/forms';
import { httpResource } from '@angular/common/http';
import { TaskStore } from '../task.service';
import { Task } from '../task.types';
@Component({
selector: 'app-task-form',
imports: [RouterLink],
templateUrl: './task-form.component.html',
styleUrl: './task-form.component.scss',
})
export class TaskFormComponent {
private readonly store = inject(TaskStore);
private readonly router = inject(Router);
// 路由参数经 withComponentInputBinding 注入;新建路由下为 null
readonly id = input<number | null>(null, { transform: (v) => (v === null ? null : Number(v)) });
readonly editing = computed(() => this.id() !== null);
// 编辑模式:按 id 加载(新建模式 request 返回 undefined → 资源 idle 不发请求)
private readonly taskResource = httpResource<Task>(() =>
this.id() === null ? undefined : `/api/tasks/${this.id()}`,
);
// ---------- 表单定义 ----------
protected readonly taskForm = form({
title: validate(required(), minLength(2), maxLength(80)),
notes: validate(maxLength(500)),
});
protected readonly submitting = signal(false);
constructor() {
// 编辑模式:资源就绪后回填表单字段
effect(() => {
const task = this.taskResource.value();
if (task !== undefined) {
this.taskForm.title.value.set(task.title);
this.taskForm.notes.value.set(task.notes ?? '');
}
});
}
protected submit(): void {
if (this.taskForm.invalid()) {
// 递归标记全部字段,错误一次性铺开(第 25 篇)
this.taskForm.markAsTouched();
return;
}
const value = this.taskForm.value();
const id = this.id();
this.submitting.set(true);
// store 的写方法不返回观察对象,组件只负责导航
if (id === null) {
this.store.create({ title: value.title, notes: value.notes, done: false });
} else {
this.store.update(id, { title: value.title, notes: value.notes });
}
this.router.navigate(['/tasks']);
}
protected cancel(): void {
this.router.navigate(['/tasks']);
}
}
task-form.component.html:
@if (editing()) {
<h2>编辑任务 #{{ id() }}</h2>
} @else {
<h2>新建任务</h2>
}
<form (ngSubmit)="submit()">
<label class="field">
<span class="label-text">标题</span>
<input
name="title"
[formField]="taskForm.title"
placeholder="要做的事"
autocomplete="off"
/>
@if (taskForm.title.touched() && taskForm.title.getError('required')) {
<span class="error">标题必填</span>
} @else if (taskForm.title.touched() && taskForm.title.getError('minLength'); as err) {
<span class="error">至少 {{ err.required }} 个字符,当前 {{ err.actual }}</span>
}
</label>
<label class="field">
<span class="label-text">备注</span>
<textarea name="notes" rows="4" [formField]="taskForm.notes"></textarea>
@if (taskForm.notes.touched() && taskForm.notes.getError('maxLength'); as err) {
<span class="error">备注不能超过 {{ err.required }} 个字符</span>
}
</label>
<div class="actions">
<button type="submit" [disabled]="submitting()">
{{ editing() ? '保存修改' : '创建任务' }}
</button>
<button type="button" class="secondary" (click)="cancel()">取消</button>
</div>
</form>
两个值得回味的细节:
- 双模式靠”信号统一”:
id是 input(路由参数注入),editing是 computed,资源 URL 函数读id(),新建模式下自动 idle——条件加载不需要任何 if/else 命令逻辑(第 23 篇) - 表单回填用 effect:资源值就绪是一次性事件,适合 effect;而”表单是否有效”是持续派生,交给字段的
invalid()信号。副作用与派生的分界线(第 21 篇)在这里落地
验证清单:走一遍全链路
npm start打开http://localhost:4200/tasks,列表从 mock 服务加载- 点筛选按钮:
filter信号变化 → URL 变化 → 自动重查,切快了中间请求被取消 - 拖动某行到新位置:
cdkDropListDropped→store.reorder→ POST ids → reload - 新建:空标题提交 →
markAsTouched()铺开错误;合法提交 → POST → 返回列表 - 编辑:路由参数 →
httpResource加载 → effect 回填 → PATCH → 返回 - 全程 Zoneless(v22 默认):任何一步的界面刷新都来自信号写入,没有一处
markForCheck
知识点回溯
| 本文用到的能力 | 出处 |
|---|---|
standalone 组件与 imports 内联 | 第 3、28 篇 |
| 路由懒加载 + 输入绑定 | 第 24 篇 |
@Service() 与 inject() | 第 18、19 篇 |
httpResource 条件加载与 reload | 第 23 篇 |
signal / computed / effect 分工 | 第 21 篇 |
@if / @for / @let 与 track | 第 8、9 篇 |
Signal Forms:form / validate / getError / markAsTouched | 第 25 篇 |
| Zoneless 下的刷新机制 | 第 20、21 篇 |
| CDK DragDrop 排序 | 第 42 篇(本篇仅用基础形态) |
| 组件/服务文件组织 | 第 31 篇 |
这 350 行代码覆盖了 Angular v22 应用开发约八成的日常形态。剩下的两成——UI 组件体系与浮层、无障碍、虚拟滚动——正是后续 Material 与 CDK 篇章(36-47 篇)的内容:把本文的 .task-row 换成 Material 组件、把删除按钮换成 Overlay 确认弹窗,就是下一阶段要做的事。