Day 3
Angular พื้นฐาน — สร้างหน้าเว็บชิ้นแรก
เวลา: 120 นาที จบคาบนี้จะได้: หน้าเว็บที่แสดงการ์ดหนังสือเป็นรายการสวยงาม (ยังใช้ข้อมูลจำลอง ยังไม่ต่อ API)
ช่วงที่ 1 — รู้จัก Angular (45 นาที)
🗺️ ตำแหน่งตอนนี้ในระบบ
- ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
- 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
- ✅ NestJS (หลังบ้าน) (ทำไปแล้ว)
- ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)
ต่อไป: ใช้ signal แสดงรายการหนังสือแบบไดนามิก
1.1 ติดตั้ง Angular CLI
npm install -g @angular/cli@22
💡 ทำไมต้องติดตั้งอีกตัว เครื่องมือสร้างโปรเจกต์ NestJS ที่ใช้ในคาบ 1 (
@nestjs/cli) กับเครื่องมือสร้างโปรเจกต์ Angular เป็นคนละตัวกัน ต้องมีทั้งคู่
✅ ตรวจสอบ
ng version
ต้องเห็นเลขเวอร์ชัน Angular CLI: 22.x.x ขึ้นมา
1.2 สร้างโปรเจกต์ Angular
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
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 ข้างใน
{
"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 ทิ้งไว้)
cd C:\webdev\booklab
npm run dev:web
รอสักครู่แล้วเปิด http://localhost:4200
✅ ต้องเห็นหน้าเว็บต้อนรับของ Angular (โลโก้และลิงก์ต่าง ๆ ที่ Angular ใส่มาให้เป็นค่าเริ่มต้น) — คาบนี้เราจะลบออกแล้วเขียนของตัวเอง
💡 ตอนนี้เรามีโปรแกรมสองตัวรันพร้อมกัน
- port 3000 = หลังบ้าน NestJS
- port 4200 = หน้าบ้าน Angular
คนละโปรแกรม คนละ Terminal ถ้าปิดตัวไหนตัวนั้นก็ดับ

⚠️ ถ้าจะรันทั้งสองด้วยคำสั่งเดียว ใช้
npm run devแต่ตอนเรียนแนะนำให้แยก Terminal จะดูข้อความ error ง่ายกว่า
1.6 สำรวจไฟล์
apps/web/src/app/
├── app.ts ← โค้ดของหน้าหลัก
├── app.html ← หน้าตาของหน้าหลัก (ตอนนี้ยังเป็นหน้าต้อนรับของ Angular)
├── app.css ← สไตล์เฉพาะของหน้าหลัก
├── app.config.ts ← ตั้งค่ารวมของทั้งแอป
└── app.routes.ts ← ตารางเส้นทาง URL
เปิด app.ts ดู
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
<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 รายการหนังสือ
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
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
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
<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
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 ให้เหลือแค่
<router-outlet />
💡
<router-outlet />= ช่องว่างที่บอกว่า "เอาหน้าที่ตรงกับ URL ปัจจุบันมาแสดงตรงนี้" 💡path: ''= หน้าแรกสุด คือlocalhost:4200เฉย ๆ
✅ เปิด localhost:4200 ต้องเห็นรายการหนังสือ 3 เล่มในกรอบสวย ๆ
ช่วงที่ 3 — แตก component ย่อย (25 นาที)
🗺️ ตำแหน่งตอนนี้ในระบบ
- ✅ เตรียมเครื่องมือ (ทำไปแล้ว)
- 🟢 Angular (หน้าบ้าน) ← กำลังทำช่วงนี้
- ✅ NestJS (หลังบ้าน) (ทำไปแล้ว)
- ✅ PostgreSQL (ฐานข้อมูล) (ทำไปแล้ว)
ต่อไป: เพิ่มปุ่มกดโต้ตอบกับผู้ใช้
ตอนนี้โค้ดหน้าตาของการ์ดปนอยู่ในรายการ ถ้าอยากใช้การ์ดที่อื่นด้วยจะคัดลอกไปมา ลองแยกออกมาเป็นชิ้นเลโก้ของตัวเอง
3.1 สร้าง component การ์ด
npm exec -- ng generate component books/book-card --skip-tests
3.2 ให้การ์ดรับข้อมูลเข้ามาได้
แทนที่ book-card.ts
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
<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
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
<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 เพิ่มสองอย่าง
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
<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]="เงื่อนไข"= ใส่คลาสนี้เฉพาะเมื่อเงื่อนไขเป็นจริง ใช้ทำปุ่มที่ถูกเลือกให้เป็นสีน้ำเงิน
✅ กดปุ่มแล้วรายการต้องเปลี่ยนตาม และปุ่มที่เลือกต้องเป็นสีน้ำเงิน
🚀 ถ้าเสร็จก่อนเพื่อน
- เพิ่มตัวเลขบอกจำนวนเล่มที่กำลังแสดงอยู่
- เพิ่มปุ่มเรียงตามปี จากใหม่ไปเก่า
- ทำให้การ์ดเปลี่ยนสีขอบเมื่อหนังสือเก่ากว่าปี 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