iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Modern Web

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

Day 11 Angular 表單與雙向綁定 – 關鍵字搜尋 + Reactive Forms 驗證 今日目標

  • 分享至 

  • xImage
  •  

今日目標

  • 了解 雙向綁定 [(ngModel)]:畫面改 → 變數改;變數改 → 畫面跟著改
  • 在 Skills 區塊加入「關鍵字搜尋」即時過濾
  • 使用 Reactive Forms 重做 Contact 表單
  • 顯示即時驗證錯誤訊息,送出前把關資料品質

基礎概念(先懂再做)

1) 什麼是雙向綁定 [(ngModel)]

  • [(ngModel)]="keyword" 等同於同時綁定 [ngModel]="keyword" + (ngModelChange)="keyword=$event"
  • 需要在 AppModule 匯入 FormsModule 才能用。

2) Template-driven vs Reactive Forms(怎麼選?)

  • Template-driven(ngModel:小型表單、快速、直覺;驗證邏輯分散在模板。
  • Reactive Forms(FormGroup/FormControl:中大型表單、驗證邏輯集中在 TS、可測試、可組合。

今天兩者都用:Skills 搜尋用 ngModel,Contact 表單用 Reactive Forms。


模組準備(很重要!)

開啟 src/app/app.module.ts,加入 FormsModuleReactiveFormsModule

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
// 其他元件 imports 省略

@NgModule({
  declarations: [
    AppComponent,
    // 你的 Header/Hero/About/Skills/Projects/Contact/Footer…
  ],
  imports: [
    BrowserModule,
    FormsModule,          // for [(ngModel)]
    ReactiveFormsModule,  // for Reactive Forms
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}


Part A:Skills 加上「關鍵字搜尋」([(ngModel)]

1) skills.component.ts(在 Day 10 的基礎上加 keyword 過濾)

import { Component } from '@angular/core';

type Category = 'all' | 'frontend' | 'backend' | 'tools';

interface Skill {
  name: string;
  category: Exclude<Category, 'all'>;
}

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

  skills: 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' }
  ];

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

  get filtered(): Skill[] {
    const kw = this.keyword.trim().toLowerCase();
    return this.skills.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; }
}

2) skills.component.html(新增搜尋輸入框 + 計數)

<section id="skills" class="container section" aria-labelledby="skills-title">
  <div class="section-header">
    <h2 id="skills-title">技能 Skillset</h2>

    <!-- 分類按鈕列(沿用 Day 10) -->
    <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" placeholder="例如:Angular、Docker…" />
    <small class="muted">結果:{{ filtered.length }} 項</small>
  </div>

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

✅ 現在輸入框任何變動都會即時反映到 keyword,清單自動過濾,不用自己手動更新 DOM。


Part B:Contact 使用 Reactive Forms(即時驗證)

1) contact.component.ts

import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-contact',
  templateUrl: './contact.component.html',
  styleUrls: ['./contact.component.scss']
})
export class ContactComponent {
  constructor(private fb: FormBuilder) {}

  form = this.fb.group({
    name:    ['', [Validators.required, Validators.minLength(2), Validators.maxLength(20)]],
    email:   ['', [Validators.required, Validators.email]],
    message: ['', [Validators.required, Validators.minLength(10)]],
  });

  // 便捷 getter(模板好讀)
  get name()    { return this.form.get('name'); }
  get email()   { return this.form.get('email'); }
  get message() { return this.form.get('message'); }

  submit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched(); // 讓錯誤一次顯示
      return;
    }
    // TODO: 在這裡串接 API 或寄信服務
    alert('已送出!感謝你的來信。');
    this.form.reset();
  }
}

2) contact.component.html

把原本的 <form> 改成 Reactive Forms 寫法:[formGroup]formControlName,加上即時錯誤提示。

<section id="contact" class="container section" aria-labelledby="contact-title" [formGroup]="form">
  <h2 id="contact-title">聯絡我</h2>

  <form (ngSubmit)="submit()" novalidate>

    <div class="field">
      <label for="name">姓名</label>
      <input id="name" type="text" formControlName="name" placeholder="王小明" />
      <small class="error" *ngIf="name?.touched && name?.invalid">
        <ng-container *ngIf="name?.errors?.['required']">請輸入姓名。</ng-container>
        <ng-container *ngIf="name?.errors?.['minlength']">至少 2 個字。</ng-container>
        <ng-container *ngIf="name?.errors?.['maxlength']">最多 20 個字。</ng-container>
      </small>
    </div>

    <div class="field">
      <label for="email">Email</label>
      <input id="email" type="email" formControlName="email" placeholder="name@example.com" />
      <small class="error" *ngIf="email?.touched && email?.invalid">
        <ng-container *ngIf="email?.errors?.['required']">請輸入 Email。</ng-container>
        <ng-container *ngIf="email?.errors?.['email']">Email 格式不正確。</ng-container>
      </small>
    </div>

    <div class="field">
      <label for="message">訊息</label>
      <textarea id="message" rows="4" formControlName="message" placeholder="想合作的內容…"></textarea>
      <small class="error" *ngIf="message?.touched && message?.invalid">
        <ng-container *ngIf="message?.errors?.['required']">請輸入訊息。</ng-container>
        <ng-container *ngIf="message?.errors?.['minlength']">至少 10 個字。</ng-container>
      </small>
    </div>

    <div class="actions">
      <button class="btn" type="submit">送出</button>
      <button class="btn btn-outline" type="button" (click)="form.reset()">清除</button>
    </div>
  </form>

  <aside class="contact-aside">
    <h3>其他聯絡方式</h3>
    <address>
      Email:<a href="mailto:hotdanton08@hotmail.com">hotdanton08@hotmail.com</a><br />
      GitHub:<a href="https://github.com/你的帳號">github.com/你的帳號</a><br />
      LinkedIn:<a href="https://linkedin.com/in/你的帳號">linkedin.com/in/你的帳號</a>
    </address>
  </aside>
</section>

✅ 只要輸入框被碰過(touched)且不符合驗證規則,就會即時顯示對應錯誤。

✅ 送出時若無效,會 markAllAsTouched(),一次提示所有欄位。


成果

  • Skills 區塊具備「分類 + 關鍵字」雙條件過濾,完全資料驅動。
  • Contact 表單改用 Reactive Forms,驗證規則集中在 TS,模板專心顯示狀態。
  • 全站互動朝 Angular 方式收斂(可測試、可擴充、可維護)。

小心踩雷(常見錯誤 → 正確做法)

  1. 沒匯入 FormsModule 就用 ngModel
    • ❌ 直接在模板用 [(ngModel)] 會報錯
    • ✅ 到 AppModule 加上 FormsModule
  2. 沒匯入 ReactiveFormsModule 就用 formGroup
    • [formGroup]formControlName 無效
    • ✅ 到 AppModule 加上 ReactiveFormsModule
  3. 驗證訊息永遠不顯示
    • 常見原因:忘了檢查 toucheddirty
    • 建議條件:ngIf="control?.touched && control?.invalid"
  4. 在模板做大量過濾計算
    • ngFor="let s of skills | filter: keyword : current" 自定義 pipe 若不純會拖慢變更檢測
    • ✅ 把過濾邏輯放 TS(getter 或方法),或用 純管線 (pure pipe)
  5. 忘了 reset 狀態
    • 送出後建議 form.reset(),否則錯誤訊息或 touched 狀態會殘留

下一步(Day 12 預告)

明天要把「外觀」與「資料」更解耦:

  • 把 Skills/Projects 的資料抽到 獨立檔案或 service(可重用)
  • 認識 Dependency Injection@Injectable()
  • 練習用 service 提供資料給多個元件(未來也能改成呼叫 API)

上一篇
Day 10 Angular 事件綁定 – 讓按鈕驅動 UI
下一篇
Day 12 抽離資料到 Service – 認識 Angular 的相依性注入(DI)
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言