Angular 20实战:使用rxResource、Tailwind v4和daisyUI 5构建认证与数据模式

本文详细介绍了Angular 20中的新技术特性,包括rxResource响应式数据管理、Signal-based认证服务、函数式守卫、HTTP拦截器,以及如何结合Tailwind v4和daisyUI 5构建现代化的Web应用界面。

Angular 20:使用rxResource、Tailwind v4和daisyUI 5实现真实场景的认证与数据模式

引言

Angular 20引入了新的构建系统(@angular/build)、Tailwind v4零配置集成,以及用于响应式数据处理的精炼Resource API——使用Signals替代了之前的request/loader,改用params/stream。 本文探索了一个真实场景的支持认证的商店应用(Teslo | Shop),使用了以下技术:

  • 函数式守卫(CanMatchFn)
  • 基于Signals的AuthService与rxResource
  • Tailwind v4 + daisyUI 5样式
  • 使用@if/@for的现代Angular 20模板

1️⃣ 使用Signals + rxResource的认证服务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { HttpClient } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { environment } from 'src/environments/environment';
import { catchError, map, Observable, of, tap } from 'rxjs';
import { rxResource } from '@angular/core/rxjs-interop';

import { AuthResponse } from '@auth/interfaces/auth-response.interface';
import { User } from '@auth/interfaces/user.interface';

type AuthStatus = 'checking' | 'authenticated' | 'not-authenticated';
const baseUrl = environment.baseUrl;

@Injectable({ providedIn: 'root' })
export class AuthService {
  private _authStatus = signal<AuthStatus>('checking');
  private _user = signal<User | null>(null);
  private _token = signal<string | null>(localStorage.getItem('token'));

  private http = inject(HttpClient);

  checkStatusResource = rxResource({
    stream: () => this.checkStatus(),
  });

  authStatus = computed<AuthStatus>(() => {
    if (this._authStatus() === 'checking') return 'checking';

    if (this._user()) {
      return 'authenticated';
    }

    return 'not-authenticated';
  });

  user = computed(() => this._user());
  token = computed(this._token);
  isAdmin = computed(() => this._user()?.roles.includes('admin') ?? false);

  login(email: string, password: string): Observable<boolean> {
    return this.http
      .post<AuthResponse>(`${baseUrl}/auth/login`, {
        email: email,
        password: password,
      })
      .pipe(
        map((resp) => this.handleAuthSuccess(resp)),
        catchError((error: any) => this.handleAuthError(error))
      );
  }

  checkStatus(): Observable<boolean> {
    const token = localStorage.getItem('token');
    if (!token) {
      this.logout();
      return of(false);
    }

    return this.http
      .get<AuthResponse>(`${baseUrl}/auth/check-status`, {
        // headers: {
        //   Authorization: `Bearer ${token}`,
        // },
      })
      .pipe(
        map((resp) => this.handleAuthSuccess(resp)),
        catchError((error: any) => this.handleAuthError(error))
      );
  }

  logout() {
    this._user.set(null);
    this._token.set(null);
    this._authStatus.set('not-authenticated');

    localStorage.removeItem('token');
  }

  private handleAuthSuccess({ token, user }: AuthResponse) {
    this._user.set(user);
    this._authStatus.set('authenticated');
    this._token.set(token);

    localStorage.setItem('token', token);

    return true;
  }

  private handleAuthError(error: any) {
    this.logout();
    return of(false);
  }
}

2️⃣ Angular 20中的函数式守卫

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { inject } from '@angular/core';
import { CanMatchFn, Route, Router, UrlSegment } from '@angular/router';
import { AuthService } from '@auth/services/auth.service';
import { firstValueFrom } from 'rxjs';

export const IsAdminGuard: CanMatchFn = async (
  route: Route,
  segments: UrlSegment[]
) => {
  const authService = inject(AuthService);

  await firstValueFrom(authService.checkStatus());

  return authService.isAdmin();
};

Angular 20更倾向于使用基于函数的守卫——简洁、可树摇优化,并且与Signals完美配合。

3️⃣ 用于令牌的HTTP拦截器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '@auth/services/auth.service';

export function authInterceptor(
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
) {
  const token = inject(AuthService).token();

  const newReq = req.clone({
    headers: req.headers.append('Authorization', `Bearer ${token}`),
  });
  return next(newReq);
}

4️⃣ 登录页面(响应式表单 + Signals)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';

import { AuthService } from '@auth/services/auth.service';

@Component({
  selector: 'app-login-page',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './login-page.component.html',
})
export class LoginPageComponent {
  fb = inject(FormBuilder);
  hasError = signal(false);
  isPosting = signal(false);
  router = inject(Router);

  authService = inject(AuthService);

  loginForm = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
  });

  onSubmit() {
    if (this.loginForm.invalid) {
      this.hasError.set(true);
      setTimeout(() => {
        this.hasError.set(false);
      }, 2000);
      return;
    }

    const { email = '', password = '' } = this.loginForm.value;

    this.authService.login(email!, password!).subscribe((isAuthenticated) => {
      if (isAuthenticated) {
        this.router.navigateByUrl('/');
        return;
      }

      this.hasError.set(true);
      setTimeout(() => {
        this.hasError.set(false);
      }, 2000);
    });
  }
}

5️⃣ 基于认证驱动的导航栏UI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<div class="navbar bg-base-100">
  <div class="navbar-start">
    <a routerLink="/" class="btn btn-ghost text-xl font-montserrat">
      Teslo<span class="text-secondary">|Shop</span>
    </a>
  </div>
  <div class="navbar-end gap-4">
    @if (authService.authStatus() === 'authenticated') {
      <button class="btn btn-ghost">{{ authService.user()?.fullName }}</button>
      <button class="btn btn-sm btn-error" (click)="authService.logout()">Salir</button>
    }
    @else if (authService.authStatus() === 'not-authenticated') {
      <a routerLink="/auth/login" class="btn btn-secondary">Login</a>
    }
    @else {
      <a class="btn btn-ghost">...</a>
    }
  </div>
</div>

6️⃣ 管理布局(Tailwind v4 + daisyUI)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<div class="bg-slate-800 h-screen text-slate-300">
  <div class="flex">
    <aside class="bg-gray-900 w-64 h-screen p-6">
      <h1 class="text-2xl font-bold">Teslo<span class="text-blue-500">|Shop</span></h1>
      <p class="text-slate-500 text-sm">Welcome, {{ user()?.fullName }}</p>
      <a routerLink="/admin/products" class="block py-3 hover:bg-white/5">Products</a>
      <button class="btn btn-ghost text-accent w-full mt-6" (click)="authService.logout()">Log out</button>
    </aside>
    <main class="flex-1 p-5 overflow-y-auto"><router-outlet /></main>
  </div>
</div>

7️⃣ 迁移说明(v19 → v20)

旧版本 新版本 描述
request params 响应式获取输入
loader stream observable/promise工厂
ResourceStatus 'idle'
'loading'
'error'
'success'
简化的状态
*ngIf/*ngFor @if/@for 新的控制流
内置语法

💡 专家要点

  • rxResource<T, P>提供类型化结果、可取消请求和轻松重新加载
  • 提供defaultValue以避免未定义的守卫
  • HttpClient支持{ signal: abortSignal }
  • 函数式守卫减少样板代码
  • Tailwind v4 + daisyUI v5带来现代化的UI主题

📚 资源

  • Angular Signals & Resource API: rxResource
  • Tailwind v4文档
  • daisyUI v5文档

接下来:在Angular 20中使用rxResource.stream()和SSE进行流式数据传输 🚀

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计