iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Modern Web

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

Day 16 Angular HttpClient 與 RxJS – 串接 API 資料

  • 分享至 

  • xImage
  •  

今日目標

  • 認識 Angular 的 HttpClient 模組
  • 了解 Observableasync pipe 的基礎用法
  • 把 Projects 從本地假資料改成「模擬 API 回傳」
  • 在畫面上顯示 Loading 狀態錯誤處理

基礎概念

HttpClient

Angular 內建的 HTTP 工具,支援:

  • http.get<T>(url):發 GET 請求,回傳 Observable
  • http.post<T>(url, body):發 POST 請求
  • 自帶 RxJS 的 Observable,可用 operator 處理非同步

👉 需要在 AppModule 匯入 HttpClientModule 才能用。


Observable 與 async pipe

  • Observable:一種「可以被訂閱」的資料流,HttpClient 回傳的就是 Observable。
  • async pipe:模板專用,可以自動訂閱並顯示資料,不用手動 subscribe()

範例:

projects$ = this.http.get<Project[]>('api/projects');

<li *ngFor="let p of projects$ | async">{{ p.title }}</li>


實作步驟

1) 在 AppModule 匯入 HttpClientModule

import { HttpClientModule } from '@angular/common/http';

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    // 其他模組
  ],
})
export class AppModule {}


2) 修改 ProjectsDataService(用 HttpClient 模擬 API)

projects-data.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Project } from '../models/project.model';
import { Observable, of } from 'rxjs';
import { catchError, delay } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class ProjectsDataService {
  private readonly apiUrl = 'assets/projects.json';
  // 這裡用本地 JSON 模擬,未來可換成真正 API

  constructor(private http: HttpClient) {}

  getAll$(): Observable<Project[]> {
    return this.http.get<Project[]>(this.apiUrl).pipe(
      delay(500), // 模擬網路延遲
      catchError(err => {
        console.error('載入失敗', err);
        return of([]); // 出錯回傳空陣列
      })
    );
  }
}


3) 建立模擬資料檔

src/assets/projects.json 建立檔案:

[
  {
    "title": "毛毛購物(寵物電商)",
    "tech": "Angular + Node.js|購物車、結帳、RWD",
    "desc": "主導前端架構,完成商品列表、購物流程與訂單頁。",
    "link": "#"
  },
  {
    "title": "LINE Bot 預約系統",
    "tech": "Cloud Functions + LINE API|時段預約",
    "desc": "整合 LINE 聊天介面與雲端排程,完成會員預約流程。",
    "link": "#"
  }
]


4) 修改 ProjectsComponent

改用 Observable + async pipe

projects.component.ts

import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { Project } from '../../models/project.model';
import { ProjectsDataService } from '../../services/projects-data.service';

@Component({
  selector: 'app-projects',
  templateUrl: './projects.component.html',
  styleUrls: ['./projects.component.scss']
})
export class ProjectsComponent {
  projects$!: Observable<Project[]>;
  isLoading = true;

  constructor(private projectsSvc: ProjectsDataService) {
    this.projects$ = this.projectsSvc.getAll$();
    // 使用 async pipe 時不用手動 subscribe
  }
}

projects.component.html

<section id="projects" class="container section">
  <h2>作品集 Projects</h2>

  <!-- Loading 狀態 -->
  <p *ngIf="isLoading">載入中...</p>

  <!-- 用 async pipe 訂閱並顯示 -->
  <div class="project-grid" *ngIf="projects$ | async as projects; else errorTpl">
    <article class="card" *ngFor="let project of projects; trackBy: trackByTitle">
      <h3>{{ project.title }}</h3>
      <p class="muted">{{ project.tech }}</p>
      <p>{{ project.desc | summary:50 }}</p>
      <a class="btn small" [href]="project.link" target="_blank">Live Demo</a>
    </article>
  </div>

  <!-- 錯誤處理 -->
  <ng-template #errorTpl>
    <p class="error">無法載入專案資料,請稍後再試。</p>
  </ng-template>
</section>


成果

  • Projects 區塊資料不再是硬寫死,而是由 JSON 模擬 API 提供。
  • 畫面有 Loading 狀態錯誤處理,更接近實際產品。
  • 學會用 HttpClient + Observable + async pipe,未來可直接替換成真 API。

小心踩雷

  1. 忘了匯入 HttpClientModule
    • NullInjectorError: No provider for HttpClient!
    • AppModule.imports 要加上 HttpClientModule
  2. 仍用 subscribe() 而非 async pipe
    • 手動 subscribe() 要自己管理 unsubscribe
    • async pipe Angular 自動處理訂閱/取消,更安全
  3. API 回傳錯誤沒處理
    • ❌ 直接 http.get(),出錯畫面會壞掉
    • ✅ 用 catchError 或在 template 加 ngIf

下一步(Day 17 預告)

明天我們會深入 Router 進階

  • 使用 Route Guard(守衛)保護路由(例如只有有 Token 才能看 Contact 表單)
  • 使用 Resolver 在進頁前預先載入資料
  • 練習更貼近「真實專案」的路由體驗

升級版資料格式(src/assets/projects.json

新增 id(數字)、slug(字串)、images、tags、createdAt、repo、demo、featured 等欄位,之後可依 id 或 slug 查詢。

[
  {
    "id": 1,
    "slug": "maomao-shop",
    "title": "毛毛購物(寵物電商)",
    "tech": "Angular + Node.js|購物車、結帳、RWD",
    "desc": "主導前端架構,完成商品列表、購物流程與訂單頁。",
    "tags": ["angular", "node", "rwd", "ecommerce"],
    "images": ["assets/projects/maomao-1.png", "assets/projects/maomao-2.png"],
    "demo": "#",
    "repo": "#",
    "createdAt": "2024-08-12",
    "featured": true},
  {
    "id": 2,
    "slug": "line-bot-reservation",
    "title": "LINE Bot 預約系統",
    "tech": "Cloud Functions + LINE API|時段預約",
    "desc": "整合 LINE 聊天介面與雲端排程,完成會員預約流程。",
    "tags": ["line", "gcp", "serverless"],
    "images": ["assets/projects/line-1.png", "assets/projects/line-2.png"],
    "demo": "#",
    "repo": "#",
    "createdAt": "2024-05-30",
    "featured": false}
]


型別介面(src/app/models/project.model.ts

export interface Project {
  id: number;
  slug: string;
  title: string;
  tech: string;
  desc: string;
  tags: string[];
  images: string[];
  demo: string;
  repo: string;
  createdAt: string;   // ISO 日期字串;顯示時可配合 date pipe
  featured: boolean;
}


服務調整(src/app/services/projects-data.service.ts

加入 getAll$()、getById$()、getBySlug$(),方便 Resolver 或詳情頁取用。保留錯誤處理與延遲模擬。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Project } from '../models/project.model';
import { Observable, of } from 'rxjs';
import { catchError, delay, map } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class ProjectsDataService {
  private readonly apiUrl = 'assets/projects.json';

  constructor(private http: HttpClient) {}

  getAll$(): Observable<Project[]> {
    return this.http.get<Project[]>(this.apiUrl).pipe(
      delay(300),
      catchError(err => {
        console.error('載入專案列表失敗', err);
        return of([]);
      })
    );
  }

  getById$(id: number): Observable<Project | undefined> {
    return this.getAll$().pipe(map(list => list.find(p => p.id === id)));
  }

  getBySlug$(slug: string): Observable<Project | undefined> {
    return this.getAll$().pipe(map(list => list.find(p => p.slug === slug)));
  }
}


清單頁(ProjectsComponent)小改

列表用 routerLink 帶 id 或 slug。建議用 slug,網址更漂亮:/projects/maomao-shop

<!-- projects.component.html -->
<section id="projects" class="container section">
  <h2>作品集 Projects</h2>

  <div class="project-grid" *ngIf="projects$ | async as projects">
    <article class="card" *ngFor="let project of projects; trackBy: trackByTitle">
      <h3>{{ project.title }}</h3>
      <p class="muted">{{ project.tech }}</p>
      <p>{{ project.desc | summary:60 }}</p>
      <a class="btn small" [routerLink]="['/projects', project.slug]">查看詳情</a>
    </article>
  </div>
</section>

如果先用 id 也行:[routerLink]="['/projects', project.id]"


詳情頁路由切換為 slug(或 id)

兩種方案擇一,明天 Day 17 我會示範用 Resolver 先載入單筆資料。

路由(slug 版)

const routes: Routes = [
  { path: '', redirectTo: 'projects', pathMatch: 'full' },
  // 列表
  { path: 'projects', loadChildren: () => import('./projects/projects.module').then(m => m.ProjectsModule) },
  // 兜底
  { path: '**', redirectTo: 'projects' }
];

projects-routing.module.ts(子模組內)

const routes: Routes = [
  { path: '', component: ProjectsComponent },
  { path: ':slug', component: ProjectDetailComponent } // 明天會加 resolver
];

詳情頁讀參數(暫時版,Day 17 會改用 Resolver 注入資料)

// project-detail.component.ts(暫時直接從參數載入)
import { ActivatedRoute } from '@angular/router';
import { switchMap } from 'rxjs/operators';

slug$ = this.route.paramMap.pipe(
  map(params => params.get('slug') ?? ''),
);

project$ = this.slug$.pipe(
  switchMap(slug => this.projectsSvc.getBySlug$(slug))
);

<!-- project-detail.component.html(暫時用 async 取值) -->
<div class="container section" *ngIf="project$ | async as project">
  <h2>{{ project.title }}</h2>
  <p class="muted">{{ project.tech }}</p>

  <div class="gallery" *ngIf="project.images?.length">
    <img *ngFor="let src of project.images" [src]="src" alt="專案截圖" width="400">
  </div>

  <p>{{ project.desc }}</p>

  <div class="actions">
    <a class="btn" [href]="project.demo" target="_blank">Live Demo</a>
    <a class="btn btn-outline" [href]="project.repo" target="_blank">GitHub</a>
  </div>

  <br />
  <a routerLink="/projects" class="btn btn-outline">返回列表</a>
</div>


明天(Day 17)我們要做什麼?

  • 新增 Resolver:在進入 /projects/:slug 前先把單筆 Project 載入好,失敗時導回 /projects 或顯示 Not Found。
  • 順手加上 Route Guard 範例(例如只有持有假 Token 才能進入某個編輯頁)。
  • 把今天的暫時寫法(component 內 switchMap)改成 透過路由設定注入資料,component 直接讀取 this.route.data.

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

尚未有邦友留言

立即登入留言