iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Modern Web

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

Day 12 抽離資料到 Service – 認識 Angular 的相依性注入(DI)

  • 分享至 

  • xImage
  •  

今日目標

  • 了解 Service 是什麼、為什麼需要它
  • 用 CLI 建立 Skills / Projects 兩個資料服務
  • 在元件中 注入(inject) 服務,改用服務提供的資料
  • 為未來「接 API」鋪路(今天先用同步資料,最後附「進階:Observable 寫法」)

基礎概念(白話版)

  • Component 專心做「畫面」與「互動」。
  • Service 專心做「資料」與「邏輯」(例如:讀取、過濾、快取、狀態)。
  • DI(相依性注入):你不用 new 服務();Angular 幫你建立並共享實例,只要在元件裡「要」就會給(注入)。

一句話:把資料與邏輯往 Service 收斂,Component 變更輕盈、好測試、好維護。


實作:建立資料模型與服務

先建立資料型別(介面),再做服務檔。這樣 TS 能幫你在編輯時就抓出型別錯誤。

1) 建立資料介面(models)

src/app/models/ 建兩個檔:

skill.model.ts

export type SkillCategory = 'frontend' | 'backend' | 'tools';

export interface Skill {
  name: string;
  category: SkillCategory;
}

project.model.ts

export interface Project {
  title: string;
  tech: string;   // 簡短技術堆疊說明
  desc: string;   // 專案描述
  link: string;   // Demo 或 Repo 連結
}

2) 用 CLI 建立服務(services)

在專案根目錄執行:

ng g s services/skills-data
ng g s services/projects-data

會產生:

src/app/services/
├─ skills-data.service.ts
└─ projects-data.service.ts

服務預設使用 providedIn: 'root',代表全域單例,不用手動加到 providers。

3) 實作服務:先用「同步資料」版本

skills-data.service.ts

import { Injectable } from '@angular/core';
import { Skill, SkillCategory } from '../models/skill.model';

@Injectable({ providedIn: 'root' })
export class SkillsDataService {
  private readonly data: Skill[] = [
    { name: 'HTML / CSS / SCSS', category: 'frontend' },
    { name: 'TypeScript',        category: 'frontend' },
    { name: 'Angular / React / Vue', category: 'frontend' },
    { name: 'Node.js / Express', category: 'backend' },
    { name: 'Git / GitHub / Docker', category: 'tools' },
    { name: 'Vite / Webpack',    category: 'tools' }
  ];

  // 取得全部
  getAll(): Skill[] {
    // 回傳拷貝,避免外部直接改到原資料
    return [...this.data];
    // 若日後要打 API,在這裡換成 HttpClient 呼叫即可
  }

  // 依類別取得(供未來需要時使用)
  getByCategory(cat: SkillCategory): Skill[] {
    return this.data.filter(s => s.category === cat);
  }
}

projects-data.service.ts

import { Injectable } from '@angular/core';
import { Project } from '../models/project.model';

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

  getAll(): Project[] {
    return [...this.data];
  }
}


在元件中使用服務(注入 & 替換原本資料)

Day 9、10 我們把資料直接寫在元件內。從今天開始,改成「向服務要資料」。功能與畫面不變,但結構更乾淨、可重用。

1) SkillsComponent(支援 Day 10/11 的分類與關鍵字過濾)

skills.component.ts(修改)

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

type Category = 'all' | SkillCategory;

@Component({
  selector: 'app-skills',
  templateUrl: './skills.component.html',
  styleUrls: ['./skills.component.scss']
})
export class SkillsComponent implements OnInit {
  current: Category = 'all';
  keyword = '';

  // 從服務取得的原始資料
  private all: Skill[] = [];
  // 經過分類/關鍵字過濾後,給模板渲染的資料
  view: Skill[] = [];

  constructor(private skillsSvc: SkillsDataService) {}

  ngOnInit(): void {
    this.all = this.skillsSvc.getAll();
    this.applyFilter();
  }

  setFilter(cat: Category) {
    this.current = cat;
    this.applyFilter();
  }

  // 關鍵字即時更新(若使用 (input) 事件)
  onKeywordChange() {
    this.applyFilter();
  }

  private applyFilter() {
    const kw = this.keyword.trim().toLowerCase();
    this.view = this.all.filter(s => {
      const byCat = this.current === 'all' || s.category === this.current;
      const byKw  = !kw || s.name.toLowerCase().includes(kw);
      return byCat && byKw;
    });
  }

  trackByName(_i: number, s: Skill) { return s.name; }
}

skills.component.html(幾乎不變,改接 view 與輸入框事件)

<section id="skills" class="container section" aria-labelledby="skills-title">
  <div class="section-header">
    <h2 id="skills-title">技能 Skillset</h2>
    <div role="tablist" aria-label="技能分類" class="filters">
      <button role="tab" class="chip" (click)="setFilter('all')"      [attr.aria-selected]="current==='all'">全部</button>
      <button role="tab" class="chip" (click)="setFilter('frontend')" [attr.aria-selected]="current==='frontend'">前端</button>
      <button role="tab" class="chip" (click)="setFilter('backend')"  [attr.aria-selected]="current==='backend'">後端</button>
      <button role="tab" class="chip" (click)="setFilter('tools')"    [attr.aria-selected]="current==='tools'">工具</button>
    </div>
  </div>

  <div class="field" style="margin:12px 0;">
    <label for="skill-search">關鍵字搜尋</label>
    <input id="skill-search" type="text" [(ngModel)]="keyword" (input)="onKeywordChange()" placeholder="例如:Angular、Docker…" />
    <small class="muted">結果:{{ view.length }} 項</small>
  </div>

  <ul class="skill-grid">
    <li *ngFor="let s of view; trackBy: trackByName">
      {{ s.name }}
    </li>
  </ul>
</section>

2) ProjectsComponent(用服務提供卡片資料)

projects.component.ts(修改)

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

@Component({
  selector: 'app-projects',
  templateUrl: './projects.component.html',
  styleUrls: ['./projects.component.scss']
})
export class ProjectsComponent implements OnInit {
  projects: Project[] = [];

  constructor(private projectsSvc: ProjectsDataService) {}

  ngOnInit(): void {
    this.projects = this.projectsSvc.getAll();
  }

  trackByTitle(_i: number, p: Project) { return p.title; }
}

projects.component.html(加上 trackBy)

<section id="projects" class="container section">
  <h2>作品集 Projects</h2>
  <div class="project-grid">
    <article class="card" *ngFor="let project of projects; trackBy: trackByTitle">
      <h3>{{ project.title }}</h3>
      <p class="muted">{{ project.tech }}</p>
      <p>{{ project.desc }}</p>
      <a class="btn small" [href]="project.link" target="_blank">Live Demo</a>
    </article>
  </div>
</section>


成果

  • Skills / Projects 的資料已經抽離到 Service
    • 元件只負責「顯示」與「互動(事件)」。
    • 服務負責「提供資料」,未來要接 API、做快取或錯誤處理都在這層改。
  • 專案結構更清晰:models/ 放型別、services/ 放資料來源、components/ 放 UI。

小心踩雷(常見誤用 → 正確作法)

  1. 在元件直接 new 服務
    • const svc = new SkillsDataService()
    • ✅ 讓 Angular 注入:建構子 constructor(private skillsSvc: SkillsDataService) {}
  2. 把資料硬寫回元件,失去抽象
    • ❌ 回到 skills = [...] 寫死元件
    • ✅ 從服務取得 getAll(),元件只負責過濾與呈現
  3. 修改服務內部陣列的參考
    • 服務回傳同一個陣列參考,元件若修改會汙染來源
    • ✅ 回傳拷貝(例如 return [...this.data]
  4. 為了拿資料在模板內做昂貴運算
    • ngFor="let s of skillsSvc.getAll() | filter:kw"
    • ✅ 在 TS 做一次,或之後用 Observable + async pipe

進階(選讀):改成 Observable + async pipe(為接 API 做準備)

真接 API(HttpClient.get<Skill[]>())會回傳 Observable。你可以先把服務介面長得像未來的樣子,改成回傳 Observable<Skill[]>,元件用 | async 取值。

服務(節錄)

import { of, Observable } from 'rxjs';
getAll$(): Observable<Skill[]> {
  return of(this.data); // 未來換成 this.http.get<Skill[]>(url)
}

元件(節錄)

skills$ = this.skillsSvc.getAll$();   // 在模板用 | async 取值

模板(節錄)

<li *ngFor="let s of (skills$ | async)">{{ s.name }}</li>


下一步(Day 13 預告)

開始導入 Routing(路由)頁面結構

  • 把「作品詳情」做成 /projects/:id 子頁
  • 練習 RouterLink / RouterOutlet、動態參數、導覽
  • 延伸:把 Projects 服務改為依 id 取單一專案

上一篇
Day 11 Angular 表單與雙向綁定 – 關鍵字搜尋 + Reactive Forms 驗證 今日目標
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言