iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 18

Day 18 Angular 狀態管理 – 用 Service + BehaviorSubject 打造小型 Store

  • 分享至 

  • xImage
  •  

今日目標

  • 認識「狀態管理」是什麼,為什麼需要它
  • 使用 RxJS BehaviorSubject 管理 UI 狀態(例如主題切換、技能分類、搜尋關鍵字)
  • 建立一個 UiStateService,集中管理狀態
  • 在多個元件間共用狀態(例如 Header 的主題按鈕 ↔ AppComponent 的 <body> 顏色)

基礎概念(白話版)

  • 狀態:任何會影響 UI 的資料(例:登入狀態、主題模式、篩選條件、目前選取的專案)。
  • 為什麼要集中管理?
    • 避免 props 一層層傳遞
    • 避免多個元件各自存一份,結果不一致
    • 更容易在 debug 時追蹤狀態改變

Angular 沒強迫你用特定解法,但常見三種:

  1. 小專案:Service + BehaviorSubject(輕量、好上手)
  2. 中專案:Component Store(@ngrx/component-store)
  3. 大專案:NgRx / NGXS / Akita

實作:UiStateService

1) 建立服務

ng g s services/ui-state

ui-state.service.ts

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UiStateService {
  // 預設值:亮色模式
  private themeSubject = new BehaviorSubject<'light' | 'dark'>('light');
  theme$ = this.themeSubject.asObservable();

  private skillCategorySubject = new BehaviorSubject<'all' | 'frontend' | 'backend' | 'tools'>('all');
  skillCategory$ = this.skillCategorySubject.asObservable();

  private keywordSubject = new BehaviorSubject<string>('');
  keyword$ = this.keywordSubject.asObservable();

  // Actions:提供更新方法
  setTheme(theme: 'light' | 'dark') {
    this.themeSubject.next(theme);
    localStorage.setItem('theme', theme); // 永續化
  }

  setSkillCategory(cat: 'all' | 'frontend' | 'backend' | 'tools') {
    this.skillCategorySubject.next(cat);
  }

  setKeyword(kw: string) {
    this.keywordSubject.next(kw);
  }

  // 初始化時可從 localStorage 把 theme 載回來
  init() {
    const saved = localStorage.getItem('theme') as 'light' | 'dark' | null;
    if (saved) this.themeSubject.next(saved);
  }
}


2) 在 AppComponent 訂閱主題

app.component.ts

import { Component, OnInit, Renderer2 } from '@angular/core';
import { UiStateService } from './services/ui-state.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent implements OnInit {
  constructor(private ui: UiStateService, private renderer: Renderer2) {}

  ngOnInit() {
    this.ui.init();
    this.ui.theme$.subscribe(theme => {
      this.renderer.setAttribute(document.documentElement, 'data-theme', theme);
    });
  }
}

👉 現在主題狀態集中管理,Header / 按鈕 / AppComponent 都能共用同一份。


3) 在 HeaderComponent 切換主題

header.component.ts

import { Component } from '@angular/core';
import { UiStateService } from '../../services/ui-state.service';

@Component({
  selector: 'app-header',
  templateUrl: './header.component.html'
})
export class HeaderComponent {
  theme$ = this.ui.theme$;

  constructor(private ui: UiStateService) {}

  toggleTheme() {
    this.theme$.subscribe(current => {
      const next = current === 'dark' ? 'light' : 'dark';
      this.ui.setTheme(next);
    }).unsubscribe(); // 單次取值
  }
}

header.component.html

<header class="site-header">
  <div class="container">
    <a class="brand" routerLink="/">Chiayu</a>
    <nav class="site-nav">
      <a routerLink="/projects">作品集</a>
      <a routerLink="/contact">聯絡</a>
    </nav>
    <button type="button" (click)="toggleTheme()">
      切換主題
    </button>
  </div>
</header>


4) SkillsComponent 使用 Store 狀態

skills.component.ts

import { Component, OnInit } from '@angular/core';
import { UiStateService } from '../../services/ui-state.service';
import { combineLatest, map } from 'rxjs';
import { Skill, SkillCategory } from '../../models/skill.model';
import { SkillsDataService } from '../../services/skills-data.service';

@Component({
  selector: 'app-skills',
  templateUrl: './skills.component.html'
})
export class SkillsComponent implements OnInit {
  skills: Skill[] = [];
  filtered$ = combineLatest([
    this.ui.skillCategory$,
    this.ui.keyword$
  ]).pipe(
    map(([cat, kw]) =>
      this.skills.filter(s => {
        const byCat = cat === 'all' || s.category === cat;
        const byKw = !kw || s.name.toLowerCase().includes(kw.toLowerCase());
        return byCat && byKw;
      })
    )
  );

  constructor(private ui: UiStateService, private svc: SkillsDataService) {}

  ngOnInit() {
    this.skills = this.svc.getAll();
  }

  setFilter(cat: SkillCategory | 'all') { this.ui.setSkillCategory(cat); }
  setKeyword(kw: string) { this.ui.setKeyword(kw); }
}

skills.component.html

<section class="container section">
  <h2>技能 Skillset</h2>

  <div class="filters">
    <button (click)="setFilter('all')">全部</button>
    <button (click)="setFilter('frontend')">前端</button>
    <button (click)="setFilter('backend')">後端</button>
    <button (click)="setFilter('tools')">工具</button>
  </div>

  <input type="text" placeholder="搜尋…" (input)="setKeyword($event.target.value)" />

  <ul>
    <li *ngFor="let s of filtered$ | async">{{ s.name }}</li>
  </ul>
</section>


成果

  • 主題切換:狀態集中在 Service,不論 Header 還是 AppComponent 都能共用
  • 技能篩選與搜尋:狀態統一在 Store,跨元件也能維持一致
  • 狀態被 BehaviorSubject 管理,初始值、最新值、訂閱者同步都搞定

小心踩雷

  1. 多次 subscribe() 沒取消
    • this.theme$.subscribe(...) 每次呼叫都留一個訂閱
    • ✅ 用 async pipe 或 take(1),避免 memory leak
  2. 狀態散落在多個元件
    • ❌ 各自用 @Input() 傳來傳去
    • ✅ 集中到 Store,元件只「取用」
  3. BehaviorSubject 和 ReplaySubject 混淆
    • BehaviorSubject 會保留最新值(適合狀態)
    • ReplaySubject 會保留多個值(適合事件歷史)

下一步(Day 19 預告)

我們要更深入 RxJS + 狀態管理:

  • BehaviorSubject 做「全域 loading 狀態」
  • 練習 RxJS operatorsswitchMapdebounceTimedistinctUntilChanged
  • 把「技能搜尋」改成更專業的 debounce 搜尋(避免每打一次字就重刷)

上一篇
Day 17 Router 進階 – 守衛 (Guards) 與 Resolver(資料先載好再進頁)
下一篇
Day 19 Angular RxJS 進階 – Loading 狀態與 Debounce 搜尋
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言