CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-208 · Angular 高级教程

Angular 22+ 教程 35:学以致用——综合实战

前面三十四篇把 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 Formsv22 稳定方案(第 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——@fortrack@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-previewcdk-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 篇)在这里落地

验证清单:走一遍全链路

  1. npm start 打开 http://localhost:4200/tasks,列表从 mock 服务加载
  2. 点筛选按钮:filter 信号变化 → URL 变化 → 自动重查,切快了中间请求被取消
  3. 拖动某行到新位置:cdkDropListDroppedstore.reorder → POST ids → reload
  4. 新建:空标题提交 → markAsTouched() 铺开错误;合法提交 → POST → 返回列表
  5. 编辑:路由参数 → httpResource 加载 → effect 回填 → PATCH → 返回
  6. 全程 Zoneless(v22 默认):任何一步的界面刷新都来自信号写入,没有一处 markForCheck

知识点回溯

本文用到的能力出处
standalone 组件与 imports 内联第 3、28 篇
路由懒加载 + 输入绑定第 24 篇
@Service()inject()第 18、19 篇
httpResource 条件加载与 reload第 23 篇
signal / computed / effect 分工第 21 篇
@if / @for / @lettrack第 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 确认弹窗,就是下一阶段要做的事。

系列导航

← 算法 035:字符串匹配:Overview 目录 算法 036:字符串匹配:模式预处理:朴素算法(Naive)(暴力破解) →
← 返回文章列表