📘 Booklab Workshop

Day 3

Angular พื้นฐาน — สร้างหน้าเว็บชิ้นแรก

เวลา: 120 นาที จบคาบนี้จะได้: หน้าเว็บที่แสดงการ์ดหนังสือเป็นรายการสวยงาม (ยังใช้ข้อมูลจำลอง ยังไม่ต่อ API)


ช่วงที่ 1 — รู้จัก Angular (45 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • ✅ NestJS (หลังบ้าน) (ทำไปแล้ว)
  • ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)

ต่อไป: ใช้ signal แสดงรายการหนังสือแบบไดนามิก

1.1 ติดตั้ง Angular CLI

powershell
npm install -g @angular/cli@22

💡 ทำไมต้องติดตั้งอีกตัว เครื่องมือสร้างโปรเจกต์ NestJS ที่ใช้ในคาบ 1 (@nestjs/cli) กับเครื่องมือสร้างโปรเจกต์ Angular เป็นคนละตัวกัน ต้องมีทั้งคู่

✅ ตรวจสอบ

powershell
ng version

ต้องเห็นเลขเวอร์ชัน Angular CLI: 22.x.x ขึ้นมา

1.2 สร้างโปรเจกต์ Angular

powershell
cd C:\webdev\booklab\apps
ng new web --directory web --style tailwind --routing true --ssr false --skip-git --skip-install --defaults

💡 ความหมายของแต่ละตัวเลือก

  • --directory web = สร้างในโฟลเดอร์ชื่อ web (ให้เข้าชุดกับ apps/api ที่มีอยู่แล้ว)
  • --style tailwind = ใช้ Tailwind CSS ตั้งแต่ตอนสร้างโปรเจกต์เลย Angular จะติดตั้งและตั้งค่าไฟล์ที่จำเป็นให้อัตโนมัติทั้งหมด ไม่ต้องมาตั้งเองทีหลัง
  • --routing true = เตรียมระบบเปลี่ยนหน้า (Router) ไว้ให้ตั้งแต่ต้น จะได้ใช้ในคาบที่ 5
  • --ssr false = ปิดโหมด Server-Side Rendering (วิชานี้ไม่ใช้)
  • --skip-git = ไม่ต้องสร้าง Git repo
  • --skip-install = ยังไม่ต้องติดตั้ง package ตอนนี้ เดี๋ยวติดตั้งรวมจาก root เหมือนตอนสร้าง NestJS ในคาบ 1
  • --defaults = ตอบคำถามอื่น ๆ ทั้งหมดด้วยค่าเริ่มต้น ไม่ต้องมานั่งตอบทีละข้อ

✅ ต้องเห็น โฟลเดอร์ apps/web เกิดขึ้น พร้อมไฟล์ apps/web/.postcssrc.json และ apps/web/src/styles.css ที่มีบรรทัด @import 'tailwindcss'; อยู่แล้ว (Angular สร้างให้เองตาม --style tailwind ที่ระบุไว้)

1.3 ติดตั้งทุกอย่างจาก root

powershell
cd C:\webdev\booklab
npm install

ใช้เวลาสักครู่ เพราะ Angular มีเครื่องมือค่อนข้างเยอะ (รวมถึง Tailwind ที่ติดมาด้วยในคำสั่งก่อนหน้า)

💡 สังเกตว่าตอนนี้โปรเจกต์เราใช้ TypeScript สองเวอร์ชันพร้อมกัน เพราะ Angular 22 ต้องการ TypeScript รุ่นใหม่กว่า NestJS 11 ที่ทำไปในคาบ 1 ต้องการ npm ฉลาดพอที่จะจัดการให้แต่ละฝั่งได้เวอร์ชันที่ถูกต้องอัตโนมัติ ไม่ต้องทำอะไรเพิ่ม แค่รู้ไว้เฉย ๆ ว่าทำไมบางทีถึงเห็นโฟลเดอร์ typescript ซ้อนอยู่ทั้งที่ root และใน apps/web/node_modules

✅ ต้องเห็น บรรทัดท้ายบอกจำนวน package ที่ติดตั้งเพิ่ม

1.4 กัน VS Code ขีดเส้นแดงหลอก

โปรเจกต์เรามี TypeScript สองเวอร์ชัน (อธิบายไว้ในขั้นที่ 1.3) บางครั้ง VS Code จะเผลอไปใช้เวอร์ชันผิดตัวกับไฟล์ Angular แล้วขีดเส้นแดงทั้งที่โค้ดไม่ได้ผิด ป้องกันไว้ก่อนเลยด้วยการสร้างโฟลเดอร์ .vscode ที่ root ของโปรเจกต์ (C:\webdev\booklab\.vscode) แล้วสร้างไฟล์ settings.json ข้างใน

json
{
  "typescript.tsdk": "apps/web/node_modules/typescript/lib"
}

บันทึกแล้วกด Ctrl + Shift + P พิมพ์ TypeScript: Select TypeScript Version เลือก Use Workspace Version

💡 ไฟล์นี้บอก VS Code ว่า "ให้ใช้ TypeScript เวอร์ชันที่อยู่ใน apps/web เป็นตัวหลักในการตรวจโค้ด" ซึ่งตรงกับเวอร์ชันที่ Angular ต้องการพอดี

1.5 รันหน้าบ้านครั้งแรก

เปิด Terminal ใหม่ (ปล่อยตัวที่รัน API ทิ้งไว้)

powershell
cd C:\webdev\booklab
npm run dev:web

รอสักครู่แล้วเปิด http://localhost:4200

✅ ต้องเห็นหน้าเว็บต้อนรับของ Angular (โลโก้และลิงก์ต่าง ๆ ที่ Angular ใส่มาให้เป็นค่าเริ่มต้น) — คาบนี้เราจะลบออกแล้วเขียนของตัวเอง

💡 ตอนนี้เรามีโปรแกรมสองตัวรันพร้อมกัน

  • port 3000 = หลังบ้าน NestJS
  • port 4200 = หน้าบ้าน Angular

คนละโปรแกรม คนละ Terminal ถ้าปิดตัวไหนตัวนั้นก็ดับ

Angular Architecture & CORS Integration

⚠️ ถ้าจะรันทั้งสองด้วยคำสั่งเดียว ใช้ npm run dev แต่ตอนเรียนแนะนำให้แยก Terminal จะดูข้อความ error ง่ายกว่า

1.6 สำรวจไฟล์

text
apps/web/src/app/
├── app.ts          ← โค้ดของหน้าหลัก
├── app.html        ← หน้าตาของหน้าหลัก (ตอนนี้ยังเป็นหน้าต้อนรับของ Angular)
├── app.css         ← สไตล์เฉพาะของหน้าหลัก
├── app.config.ts   ← ตั้งค่ารวมของทั้งแอป
└── app.routes.ts   ← ตารางเส้นทาง URL

เปิด app.ts ดู

typescript
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}

💡 Component คืออะไร — แนวคิดหลักของ Angular คือตัวต่อเลโก้ ชิ้นหนึ่ง ที่รวม 3 อย่างไว้ด้วยกัน

  • .html = หน้าตา
  • .css = สไตล์
  • .ts = สมองที่ควบคุมพฤติกรรม

การ์ดหนังสือหนึ่งใบคือหนึ่งชิ้น ทำแม่พิมพ์ไว้ครั้งเดียว แล้วปั๊มออกมา 20 ใบใส่ข้อมูลคนละเล่มได้ หน้าเว็บทั้งหน้าคือเลโก้หลายชิ้นประกอบกัน

💡 @Component({...}) = ป้ายที่บอกว่าคลาสนี้เป็น component พร้อมบอกว่าไฟล์หน้าตาอยู่ไหน 💡 selector: 'app-root' = ชื่อแท็กที่เอาไปวางใน HTML ได้ เหมือน <div> แต่เป็นของเราเอง

1.8 แทนที่หน้าเริ่มต้นด้วยของเราเอง

หน้าที่เห็นตอนนี้เป็นหน้าต้อนรับที่ Angular ใส่มาให้ ลบทิ้งแล้วใส่ของเราเอง แทนที่ทั้งไฟล์ app.html

html
<h1 class="text-3xl font-bold text-blue-600 p-8">Booklab</h1>
<router-outlet />

กด Ctrl + S

✅ หน้าเว็บต้องเปลี่ยนเองทันทีโดยไม่ต้องกด refresh เหลือแค่คำว่า "Booklab" ตัวใหญ่สีน้ำเงิน

💡 เรียกว่า hot reload — Angular คอยจับตาไฟล์อยู่ พอบันทึกก็อัปเดตหน้าจอให้เลย

💡 <router-outlet /> = ช่องว่างที่บอกว่า "เอาหน้าที่ตรงกับ URL ปัจจุบันมาแสดงตรงนี้" จำเป็นต้องมีไว้เสมอ เดี๋ยวคาบที่ 5 จะได้ใช้จริงตอนทำหลายหน้า


ช่วงที่ 2 — Signal และการแสดงข้อมูล (25 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • ✅ NestJS (หลังบ้าน) (ทำไปแล้ว)
  • ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)

ต่อไป: แตกโค้ดออกเป็น component ย่อยที่ใช้ซ้ำได้

2.1 สร้าง component รายการหนังสือ

powershell
cd apps/web
npm exec -- ng generate component books/book-list --skip-tests

✅ ต้องเกิด 3 ไฟล์ ใน src/app/books/book-list/

⚠️ สังเกตว่าชื่อคลาสคือ BookList ไม่มีคำว่า Component ต่อท้าย และชื่อไฟล์ไม่มี .component — เป็นรูปแบบใหม่ของ Angular เวอร์ชันนี้ ถ้าไปเจอบทเรียนเก่าที่เขียนว่า book-list.component.ts ก็คือของเดิม ใช้แทนกันได้

2.2 สร้างแบบฟอร์มข้อมูล

สร้างไฟล์ apps/web/src/app/books/book.ts

typescript
export interface Book {
  id: number;
  title: string;
  author: string;
  year: number;
  category?: { id: number; name: string };
}

💡 เหมือน interface ที่เขียนฝั่ง API เป๊ะ ๆ เพราะข้อมูลชุดเดียวกันวิ่งข้ามมา 💡 เครื่องหมาย ? ที่ category? แปลว่า "มีหรือไม่มีก็ได้"

2.3 ใส่ข้อมูลจำลองและ signal

แทนที่ apps/web/src/app/books/book-list/book-list.ts

typescript
import { Component, signal } from '@angular/core';
import { Book } from '../book';

@Component({
  selector: 'app-book-list',
  imports: [],
  templateUrl: './book-list.html',
  styleUrl: './book-list.css',
})
export class BookList {
  books = signal<Book[]>([
    { id: 1, title: 'Clean Code', author: 'Robert C. Martin', year: 2008 },
    { id: 2, title: 'Refactoring', author: 'Martin Fowler', year: 1999 },
    { id: 3, title: "Don't Make Me Think", author: 'Steve Krug', year: 2000 },
  ]);
}

💡 signal คืออะไร — และทำไมสำคัญ คือกล่องที่ส่งเสียงได้ พอค่าข้างในเปลี่ยน มันจะตะโกนบอกหน้าจอเอง แล้วหน้าจออัปเดตตามทันที เราไม่ต้องเดินไปสั่งให้วาดใหม่

  • signal<Book[]>([...]) = สร้างกล่อง ใส่รายการหนังสือไว้
  • books() มีวงเล็บ = เปิดกล่องดูค่า
  • books.set(ของใหม่) = เปลี่ยนค่าในกล่อง

⚠️ ต้องมีวงเล็บทุกครั้งที่อ่านค่า ลืมใส่แล้วจะได้ตัวกล่องแทนที่จะได้ของข้างใน เป็นจุดที่พลาดกันบ่อยที่สุด

2.4 แสดงผลบนหน้าจอ

แทนที่ book-list.html

html
<div class="p-6">
  <h1 class="text-2xl font-bold mb-4">รายการหนังสือ</h1>

  <ul class="grid gap-3">
    @for (b of books(); track b.id) {
      <li class="border rounded-lg p-4">
        <p class="font-bold">{{ b.title }}</p>
        <p class="text-sm text-gray-600">{{ b.author }} · {{ b.year }}</p>
      </li>
    }
  </ul>
</div>

💡 @for (...) { } = คำสั่งวนซ้ำที่เขียนได้ใน HTML โดยตรง แปลว่า "หยิบหนังสือทีละเล่มจาก books() มาสร้าง <li> หนึ่งอัน"

💡 track b.id = บอก Angular ว่าใช้ id เป็นตัวแยกแยะว่าอันไหนเป็นอันไหน เพื่อให้อัปเดตหน้าจอได้เร็ว จำเป็นต้องใส่ ถ้าไม่ใส่จะ error

💡 {{ b.title }} = เอาค่าจากโค้ดมาแสดงบนหน้าจอ เรียกว่า binding หรือเส้นเชื่อมระหว่างข้อมูลกับสิ่งที่เห็น

💡 class="p-6 text-2xl font-bold" = คลาสของ Tailwind ที่ติดตั้งไว้แล้ว แต่ละตัวทำหน้าที่เดียว เช่น p-6 คือ padding, font-bold คือตัวหนา — ไม่ต้องเขียน CSS เองเลย

2.5 เอา component มาแสดงจริง

แก้ apps/web/src/app/app.routes.ts

typescript
import { Routes } from '@angular/router';
import { BookList } from './books/book-list/book-list';

export const routes: Routes = [
  { path: '', component: BookList },
];

แก้ apps/web/src/app/app.html ให้เหลือแค่

html
<router-outlet />

💡 <router-outlet /> = ช่องว่างที่บอกว่า "เอาหน้าที่ตรงกับ URL ปัจจุบันมาแสดงตรงนี้" 💡 path: '' = หน้าแรกสุด คือ localhost:4200 เฉย ๆ

✅ เปิด localhost:4200 ต้องเห็นรายการหนังสือ 3 เล่มในกรอบสวย ๆ


ช่วงที่ 3 — แตก component ย่อย (25 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • ✅ NestJS (หลังบ้าน) (ทำไปแล้ว)
  • ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)

ต่อไป: เพิ่มปุ่มกดโต้ตอบกับผู้ใช้

ตอนนี้โค้ดหน้าตาของการ์ดปนอยู่ในรายการ ถ้าอยากใช้การ์ดที่อื่นด้วยจะคัดลอกไปมา ลองแยกออกมาเป็นชิ้นเลโก้ของตัวเอง

3.1 สร้าง component การ์ด

powershell
npm exec -- ng generate component books/book-card --skip-tests

3.2 ให้การ์ดรับข้อมูลเข้ามาได้

แทนที่ book-card.ts

typescript
import { Component, input } from '@angular/core';
import { Book } from '../book';

@Component({
  selector: 'app-book-card',
  imports: [],
  templateUrl: './book-card.html',
  styleUrl: './book-card.css',
})
export class BookCard {
  book = input.required<Book>();
}

💡 input() คือช่องเสียบข้อมูลของตัวต่อ แม่พิมพ์การ์ดมีช่องให้เสียบว่าจะแสดงหนังสือเล่มไหน ชิ้นเดียวกันจึงแสดงข้อมูลต่างกันได้

input.required<Book>() = ช่องนี้ต้องเสียบ ถ้าลืมจะเตือนตั้งแต่ตอนพิมพ์ อ่านค่าด้วย book() มีวงเล็บ เหมือน signal เพราะมันคือ signal ชนิดหนึ่ง

3.3 หน้าตาของการ์ด

แทนที่ book-card.html

html
<div class="border rounded-lg p-4 hover:shadow-md transition">
  <p class="font-bold text-lg">{{ book().title }}</p>
  <p class="text-sm text-gray-600">{{ book().author }}</p>
  <div class="mt-2 flex items-center gap-2">
    <span class="text-xs bg-gray-100 px-2 py-1 rounded">{{ book().year }}</span>
    @if (book().category) {
      <span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
        {{ book().category?.name }}
      </span>
    }
  </div>
</div>

💡 @if (...) { } = แสดงเฉพาะเมื่อเงื่อนไขเป็นจริง ตรงนี้คือ "ถ้ามีหมวดหมู่ค่อยแสดงป้ายสีฟ้า" 💡 ?. ใน category?.name = "ถ้ามี category ค่อยหยิบ name" ป้องกัน error ตอนไม่มีข้อมูล

3.4 เอาการ์ดไปใช้ในรายการ

แก้ book-list.ts เพิ่ม import

typescript
import { Component, signal } from '@angular/core';
import { Book } from '../book';
import { BookCard } from '../book-card/book-card';

@Component({
  selector: 'app-book-list',
  imports: [BookCard],
  templateUrl: './book-list.html',
  styleUrl: './book-list.css',
})
export class BookList {
  books = signal<Book[]>([
    { id: 1, title: 'Clean Code', author: 'Robert C. Martin', year: 2008, category: { id: 1, name: 'Programming' } },
    { id: 2, title: 'Refactoring', author: 'Martin Fowler', year: 1999, category: { id: 1, name: 'Programming' } },
    { id: 3, title: "Don't Make Me Think", author: 'Steve Krug', year: 2000, category: { id: 2, name: 'Design' } },
  ]);
}

⚠️ ต้องใส่ BookCard ใน imports: [] ไม่งั้น Angular จะไม่รู้จักแท็ก <app-book-card> — เป็นจุดที่ลืมกันบ่อย

แก้ book-list.html

html
<div class="p-6 max-w-3xl mx-auto">
  <h1 class="text-2xl font-bold mb-4">รายการหนังสือ</h1>

  <div class="grid gap-3">
    @for (b of books(); track b.id) {
      <app-book-card [book]="b" />
    }
  </div>
</div>

💡 [book]="b" = เสียบข้อมูลเข้าช่อง book ของการ์ด วงเล็บเหลี่ยมแปลว่า "ค่านี้มาจากโค้ด" ถ้าไม่ใส่วงเล็บ Angular จะคิดว่าเราส่งข้อความคำว่า "b" ไปจริง ๆ

✅ หน้าเว็บต้องเหมือนเดิม แต่ตอนนี้โค้ดแยกเป็นชิ้นแล้ว


ช่วงที่ 4 — เพิ่มการโต้ตอบ (15 นาที)

🗺️ ตำแหน่งตอนนี้ในระบบ

  • ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
  • 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
  • ✅ NestJS (หลังบ้าน) (ทำไปแล้ว)
  • ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)

ต่อไป: คาบหน้า: เชื่อมหน้าบ้านนี้เข้ากับ API จริง

4.1 ทำปุ่มกรองตามหมวดหมู่

แก้ book-list.ts เพิ่มสองอย่าง

typescript
export class BookList {
  books = signal<Book[]>([ /* ...ของเดิม... */ ]);

  selected = signal<string>('ทั้งหมด');

  filtered() {
    const all = this.books();
    if (this.selected() === 'ทั้งหมด') return all;
    return all.filter((b) => b.category?.name === this.selected());
  }
}

แก้ book-list.html

html
<div class="p-6 max-w-3xl mx-auto">
  <h1 class="text-2xl font-bold mb-4">รายการหนังสือ</h1>

  <div class="flex gap-2 mb-4">
    @for (c of ['ทั้งหมด', 'Programming', 'Design']; track c) {
      <button
        (click)="selected.set(c)"
        class="px-3 py-1 rounded border"
        [class.bg-blue-600]="selected() === c"
        [class.text-white]="selected() === c">
        {{ c }}
      </button>
    }
  </div>

  <div class="grid gap-3">
    @for (b of filtered(); track b.id) {
      <app-book-card [book]="b" />
    }
  </div>
</div>

💡 (click)="..." = วงเล็บกลมคือเหตุการณ์ แปลว่า "เมื่อคลิกให้ทำสิ่งนี้" จำง่าย ๆ: วงเล็บเหลี่ยม [ ] = ข้อมูลไหลเข้าหน้าจอ / วงเล็บกลม ( ) = เหตุการณ์ไหลออกจากหน้าจอ

💡 selected.set(c) = เปลี่ยนค่าในกล่อง signal พอเปลี่ยนปุ๊บ หน้าจอวาดใหม่เองทันที นี่คือพลังของ signal

💡 [class.bg-blue-600]="เงื่อนไข" = ใส่คลาสนี้เฉพาะเมื่อเงื่อนไขเป็นจริง ใช้ทำปุ่มที่ถูกเลือกให้เป็นสีน้ำเงิน

✅ กดปุ่มแล้วรายการต้องเปลี่ยนตาม และปุ่มที่เลือกต้องเป็นสีน้ำเงิน

🚀 ถ้าเสร็จก่อนเพื่อน

  1. เพิ่มตัวเลขบอกจำนวนเล่มที่กำลังแสดงอยู่
  2. เพิ่มปุ่มเรียงตามปี จากใหม่ไปเก่า
  3. ทำให้การ์ดเปลี่ยนสีขอบเมื่อหนังสือเก่ากว่าปี 2000

❓ ปัญหาที่พบบ่อย

อาการ สาเหตุ วิธีแก้
'app-book-card' is not a known element ลืมใส่ใน imports: [] เพิ่ม BookCard เข้าไป
แสดงคำว่า [object Object] หรือฟังก์ชัน ลืมวงเล็บตอนอ่าน signal เปลี่ยน books เป็น books()
@for ขึ้น error ลืม track เพิ่ม track b.id
หน้าขาวเปล่า มี error ใน console กด F12 เปิด Console อ่านข้อความสีแดง
Tailwind ไม่ทำงาน พิมพ์ชื่อคลาสผิด ตรวจสะกด เช่น text-gray-600 ไม่ใช่ text-grey-600

📌 สรุปสิ่งที่ได้เรียน

สิ่งที่เขียน ความหมาย
signal(ค่า) กล่องเก็บค่าที่ประกาศตัวเองได้
ชื่อ() เปิดกล่องดูค่า
ชื่อ.set(ค่าใหม่) เปลี่ยนค่าในกล่อง
input.required<T>() ช่องรับข้อมูลจากแม่
@for (x of list; track x.id) วนแสดงทีละอัน
@if (เงื่อนไข) แสดงเมื่อเป็นจริง
[prop]="ค่า" ส่งข้อมูลเข้า
(click)="ทำอะไร" รับเหตุการณ์

คาบหน้า เอาข้อมูลจำลองออก แล้วดึงจาก API จริงที่เราทำไว้ในคาบที่ 2