路由与HTTP:守卫、拦截器与响应式表单

前置:组件与服务
目标:掌握 routes 数组配置与懒加载,写出现代函数式守卫三件套,用 HttpInterceptor 实现 token 注入与统一错误处理,分清响应式表单与模板驱动表单。


1. 路由配置:routes 数组

1.1 基本装配

Angular 路由与 vue-router/react-router 高度同构:路径→组件映射表 + <router-outlet> 出口 + routerLink 链接。

// app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './pages/home.component';
import { UserListComponent } from './pages/user-list.component';
 
export const routes: Routes = [
  { path: '', component: HomeComponent, title: '首页' },
  { path: 'users', component: UserListComponent, title: '用户列表' },
  { path: '**', redirectTo: '' },        // 通配兜底(必须放最后)
];
 
// main.ts: bootstrapApplication(AppComponent, { providers: [provideRouter(routes)] });
<!-- 根组件模板 -->
<nav>
  <a routerLink="/" routerLinkActive="active">首页</a>
  <a routerLink="/users" routerLinkActive="active">用户</a>
</nav>
<router-outlet />   <!-- 匹配的组件渲染在此 -->

三大框架对照表:

概念AngularVue RouterReact Router
配置routes 数组createRouter({routes})createBrowserRouter
出口router-outletrouter-viewOutlet
链接routerLink + active 类router-linkNavLink
取参route.paramMap / withParamMappinguseRoute().paramsuseParams

1.2 动态段与读取

{ path: 'users/:id', component: UserDetailComponent },

现代推荐用 withComponentInputBinding() 把参数直接绑成组件 input:

provideRouter(routes, withComponentInputBinding())
 
@Component({ template: '<h2>用户 {{ id }}</h2>' })
export class UserDetailComponent {
  id = input.required<string>();     // 路由参数自动注入, 恒为 string
}
 
// 传统等价物(老项目常见): inject(ActivatedRoute).paramMap.subscribe(...)

注意 paramMap 是 Observable 且参数变化会再推送——同一组件在不同 id 间复用时自动响应,这与 React 里”id 进依赖数组”是同一个问题域。

1.3 子路由与懒加载

嵌套即 children,出口在父组件里:

{
  path: 'admin',
  component: AdminLayoutComponent,
  children: [
    { path: 'dashboard', component: DashboardComponent },
    { path: 'settings', component: SettingsComponent },
    { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
  ],
},
 
// 懒加载两式(构建产物按路由切块):
// 组件级: { path: 'lazy', loadComponent: () => import('./lazy.component').then(m => m.LazyComponent) }
// 子路由级: { path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES) }

对照 React 的 lazy+Suspense、Vue 的 () => import()——目的相同:首包瘦身。

2. 守卫三件套

Angular 守卫是导航期拦截(对比 React Router 渲染期包装组件),语义最接近后端过滤器。现代写法是普通函数返回 boolean 或 UrlTree。

2.1 canActivate:登录守卫

// guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
 
export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);      // 函数内注入, 无类构造器
  const router = inject(Router);
 
  if (authService.isLoggedIn()) return true;
  return router.createUrlTree(['/login'], {
    queryParams: { from: state.url },           // 记住来源, 登录后跳回
  });
};
 
// 应用到路由:
// { path: 'dashboard', component: ..., canActivate: [authGuard] }

2.2 canDeactivate:未保存提示

离开页面前询问”改动还没保存,确定离开?“:

// guards/unsaved.guard.ts
export interface CanComponentLeave {
  canLeave: () => boolean;
}
 
export const unsavedGuard: CanDeactivateFn<CanComponentLeave> = component =>
  component.canLeave?.() ?? true;
 
// 表单组件实现接口
export class EditProfileComponent implements CanComponentLeave {
  dirty = false;
  canLeave() {
    if (!this.dirty) return true;
    return confirm('有未保存的修改,确定离开?');
  }
}
 
// 路由注册: { path: 'edit-profile', component: ..., canDeactivate: [unsavedGuard] }

2.3 resolve:预取数据

导航完成之前把数据取好,组件激活时数据已就位(对应 Next.js loader 思想):

// guards/user.resolver.ts
export const userResolver: ResolveFn<User> = route => {
  const id = Number(route.paramMap.get('id'));
  return inject(UserService).getUser(id);
};
 
{ path: 'users/:id', component: UserDetailComponent, resolve: { user: userResolver } },
// 组件里直接 input.required<User>() 拿数据, 无 loading 态代码

三件对照表:

守卫时机典型场景其他框架对应
canActivate进入前未登录踢回登录Vue beforeEach / React ProtectedRoute
canDeactivate离开前未保存确认Vue beforeRouteLeave
resolve激活前预取数据React Router loader

3. HttpClient 与拦截器

3.1 拦截器:token 注入 + 统一错误

拦截器是 HttpClient 的请求管道中间件——与 axios interceptors、Spring HandlerInterceptor 三方同构:

// interceptors/auth.interceptor.ts(现代 functional 写法)
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
 
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
 
  // 1) 克隆请求并附加 token(HttpRequest 不可变, 必须 clone)
  const authReq = req.clone({
    setHeaders: { Authorization: `Bearer ${auth.token}` },
  });
 
  // 2) 放行到下一个拦截器, 并统一处理错误
  return next(authReq).pipe(
    catchError((err) => {
      if (err.status === 401) {
        auth.logout();                          // 清登录态
        return throwError(() => new Error('登录已过期'));
      }
      return throwError(() => err);
    }),
  );
};

注册(standalone 方式):provideHttpClient(withInterceptors([authInterceptor])) 加入 main.ts 的 providers 即可。

对照 axios 版本(React 实战 中写过):

关注点axiosAngular interceptor
加头request.use(config => …)req.clone({setHeaders})
错误钩response.use 第二参catchError 管道
不可变性config 直接改HttpRequest 必须 clone

错误提示的全局化:在 catchError 里调一个 NotificationService 弹 toast,所有组件的 HTTP 错误处理就归一了——这就是拦截器作为横切关注点的价值,AOP 思想再次现身。

3.2 类型化的 GET/POST

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
 
  getUsers(page = 0) {
    return this.http.get<User[]>('/api/users', { params: { page, size: 10 } });  // 查询串自动序列化
  }
 
  createUser(dto: { name: string; email: string }) {
    return this.http.post<User>('/api/users', dto);   // body 自动 JSON
  }
}

泛型标注让响应全程带类型;订阅时 next/error 分支各司其职。竞态防护方面,切换路由时配合 takeUntilDestroyed 或 switchMap 自动取消旧订阅(细节进阶自查)。

4. 响应式表单 vs 模板驱动表单

Angular 内置两套表单方案,选型先讲清楚:

维度响应式 ReactiveForms模板驱动 Template-driven
真相源组件里的 FormGroup 类模板里的 ngModel
校验定义TS 代码显式声明HTML 属性(required 等)
适用复杂表单/动态字段/单元测试简单两三个字段的表单
心智代码优先(像后端校验)模板优先(像原生)

复杂度超过”登录框”就用响应式——它是企业项目的默认答案,也是 Java 同学最有安全感的写法:校验规则集中在代码里可测试,正如 Bean Validation 注解背后的约束体系。

4.1 ReactiveFormsModule 快速上手

import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
 
@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <input formControlName="username" placeholder="用户名" />
      @if (form.controls.username.invalid && form.controls.username.touched) {
        <p class="err">用户名至少 3 个字符</p>
      }
      <input formControlName="email" type="email" placeholder="邮箱" />
      @if (form.controls.email.errors?.['email']) {
        <p class="err">邮箱格式不正确</p>
      }
      <button [disabled]="form.invalid">提交</button>
    </form>
  `,
})
export class SignupComponent {
  private fb = inject(FormBuilder);
 
  form = this.fb.nonNullable.group({
    username: ['', [Validators.required, Validators.minLength(3)]],
    email: ['', [Validators.required, Validators.email]],
    role: ['user'],
  });
 
  submit() {
    console.log(this.form.getRawValue());   // {username, email, role} 强类型
  }
}

要点:

  • FormBuilder.group 声明结构:字段名 → [初始值, 校验器数组];
  • 状态四件套:valid/invalid、touched(碰过没)、dirty(改过没)、errors(错误明细);
  • getRawValue() 返回强类型值对象——表单数据从此不是散落的 DOM 读值;
  • 动态增删字段用 FormArray;跨字段联动用 valueChanges 流(RxJS 再立功)。

对照 Spring MVC 后端:前端 Validators ≈ Bean Validation 注解,form.invalid 禁用按钮 ≈ 服务端 BindingResult 拒绝非法提交——只是把校验前移到了浏览器,两端契约仍需后端兜底。

5. 实战:登录 + 守卫 + CRUD 页面接 mock API

5.1 结构

src/app/
├── auth.service.ts          # 登录态(signal + localStorage)
├── auth.guard.ts            # 登录守卫
├── pages/login.component.ts
├── pages/todo-crud.component.ts
└── app.routes.ts

5.2 AuthService

import { Injectable, signal } from '@angular/core';
 
@Injectable({ providedIn: 'root' })
export class AuthService {
  readonly token = signal<string | null>(localStorage.getItem('token'));
 
  isLoggedIn() { return this.token() !== null; }
 
  async login(username: string, password: string): Promise<void> {
    // mock: 用户名非空即成功; 真实场景换成 http.post('/api/login')
    await new Promise(r => setTimeout(r, 300));
    if (!username.trim()) throw new Error('用户名不能为空');
    const t = btoa(`${username}:${Date.now()}`);
    localStorage.setItem('token', t);
    this.token.set(t);
  }
 
  logout(reason?: string) {
    localStorage.removeItem('token');
    this.token.set(null);
    if (reason) alert(reason);
  }
}

signal 存登录态:读它的模板自动随登出刷新(signals 简介 下章展开)。

5.3 登录页(响应式表单)

import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';
 
@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()" style="max-width:320px;margin:60px auto">
      <h2>登录</h2>
      <input formControlName="username" placeholder="用户名" />
      <input formControlName="password" type="password" placeholder="密码" />
      @if (error) { <p style="color:red">{{ error }}</p> }
      <button [disabled]="form.invalid || busy">{{ busy ? '登录中...' : '登录' }}</button>
    </form>
  `,
})
export class LoginComponent {
  private fb = inject(FormBuilder);
  private auth = inject(AuthService);
  private router = inject(Router);
 
  form = this.fb.nonNullable.group({
    username: ['', Validators.required],
    password: ['', Validators.required],
  });
  error = '';
  busy = false;
 
  async submit() {
    this.busy = true;
    try {
      const { username, password } = this.form.getRawValue();
      await this.auth.login(username, password);
      void this.router.navigateByUrl(history.state.from ?? '/');   // 跳回来源页或首页
    } catch (e) {
      this.error = e instanceof Error ? e.message : '登录失败';
    } finally {
      this.busy = false;
    }
  }
}

5.4 CRUD 页 + mock API

public/api/todos.json 提供初始数据;真实写入用内存模拟(重点看链路而非存储):

// pages/todo-crud.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
 
interface Todo { id: number; text: string; done: boolean }
 
@Component({
  selector: 'app-todo-crud',
  standalone: true,
  imports: [CommonModule, FormsModule],
  template: `
    <div style="max-width:480px;margin:30px auto">
      <input [(ngModel)]="draft" placeholder="新待办" />
      <button (click)="add()">添加</button>
      <ul>
        @for (t of todos; track t.id) {
          <li>
            <input type="checkbox" [checked]="t.done" (change)="toggle(t)" />
            {{ t.text }}
            <button (click)="remove(t.id)">删</button>
          </li>
        }
      </ul>
    </div>
  `,
})
export class TodoCrudComponent implements OnInit {
  private http = inject(HttpClient);
  todos: Todo[] = [];
  draft = '';
 
  ngOnInit() {
    this.http.get<Todo[]>('api/todos.json').subscribe(list => (this.todos = list));
  }
 
  add() {
    const text = this.draft.trim();
    if (!text) return;
    this.todos = [{ id: Date.now(), text, done: false }, ...this.todos];
    this.draft = '';
    // 真实后端: this.http.post('/api/todos', {text}).subscribe(...)
  }
 
  toggle(t: Todo) {
    t.done = !t.done;
    // 真实后端: this.http.patch(`/api/todos/${t.id}`, {done: t.done}).subscribe(...)
  }
 
  remove(id: number) {
    this.todos = this.todos.filter(t => t.id !== id);
    // 真实后端: this.http.delete(`/api/todos/${id}`).subscribe(...)
  }
}

5.5 路由与守卫装配

// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './auth.guard';
import { LoginComponent } from './pages/login.component';
import { TodoCrudComponent } from './pages/todo-crud.component';
 
export const routes: Routes = [
  { path: 'login', component: LoginComponent },
  {
    path: '',
    canActivate: [authGuard],                 // 整个受保护区
    children: [
      { path: '', component: TodoCrudComponent, title: '待办管理' },
    ],
  },
  { path: '**', redirectTo: '' },
];
// main.ts 全家桶注册
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
});

验收清单:

  • 未登录直接访问 / 被 authGuard 踢到 /login?from=/,登录成功跳回来源页;
  • 手动清掉 localStorage 再点页面内导航被拦(每次导航都过守卫);
  • CRUD 操作正常且 mock 数据来自 HTTP(Network 可见 json 请求);
  • 把任意接口地址改成 404 观察 error 分支表现。

自检清单:

  • routes 数组/routerLink/outlet 三要素与另外两家同构
  • loadComponent/loadChildren 懒加载两式;守卫三件时机背得出
  • 拦截器必须 clone 请求,catchError 收口错误
  • 复杂表单默认 ReactiveForms,getRawValue 强类型

小结

知识点一句话
路由配置routes 数组 + outlet + routerLink
懒加载loadComponent/loadChildren 切块
守卫canActivate/canDeactivate/resolve 导航期拦截
拦截器token 注入与错误收口的横切层
响应式表单FormBuilder 声明结构,校验在代码可测试
选型简单单字段模板驱动,其余一律 ReactiveForms

综合演练收官:Angular 实战