🧰 ของที่ใช้ตรวจในบทนี้
# npx next build
▲ Next.js 16.3.4 (Turbopack)
✓ Compiled successfully
Running TypeScript ...
Finished TypeScript ...
✓ Generating static pages ...
# เวอร์ชันจริงบนเครื่องที่ใช้ตรวจ
next 16.3.4
react 19.2.8
typescript 5.9.3
node v24.14.0
ทุกกล่องที่เขียนว่า "ผลรันจริง" ในบทนี้มาจากเครื่องจริง ไม่ได้พิมพ์เอง —
· ข้อความ error ทุกบรรทัดและเลข TSxxxx ก็อปมาจาก npx tsc --noEmit ตัวจริง
· HTML ที่โชว์ในหัวข้อ 2, 3, 5 และ 6 ได้มาจากการ curl ไปที่ server จริง แล้วตัดเฉพาะส่วนใน <main>
· โค้ดทุกก้อนที่ไม่ได้ติดป้ายว่า "พัง" ผ่าน npx next build จริง
เรื่องเดียวที่ถูกจัดให้เรียบร้อยก่อนเอามาโชว์ คือ ชื่อ path ·
โปรเจกต์ที่ใช้ตรวจวางหน้าทดสอบไว้ใต้โฟลเดอร์ย่อย (เช่น app/a71/…) เพราะมีบทอื่นใช้โปรเจกต์เดียวกันอยู่ ·
ในบทนี้ผมเขียน path กลับให้เป็นแบบที่คุณจะเห็นในโปรเจกต์ของคุณเอง
(app/page.tsx · http://localhost:3000/) จะได้ไม่ต้องแปลในหัว —
ตัวเลข บรรทัด คอลัมน์ ข้อความ error และ HTML ที่ออกมา ไม่ได้แก้แม้แต่ตัวเดียว
1 · Button ตัวแรก — component คือฟังก์ชันที่คืนหน้าตา
คุณเขียน Go มาแล้ว เพราะฉะนั้นประโยคนี้จะเข้าใจง่าย:
component คือฟังก์ชันธรรมดา ๆ ตัวหนึ่ง
ไม่ได้พิเศษกว่าฟังก์ชันที่คุณเขียนใน Go ตรงไหนเลย มันแค่มีข้อตกลงสองข้อ:
- รับ argument ได้ตัวเดียว — เป็น object ก้อนเดียวที่ทุกคนเรียกมันว่า
props - คืนหน้าตาออกมา — ไม่ใช่
stringไม่ใช่intแต่เป็นก้อน JSX ที่หน้าตาเหมือน HTML
จบแล้ว นั่นคือทั้งหมดของคำว่า component ·
ทีนี้มาดูของจริง — นี่คือ สไลด์หน้า 22 ทุกตัวอักษร
วางไว้ที่ components/Bases/Button.tsx:
// components/Bases/Button.tsx
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
const Button = (props: ButtonProps) => {
const { children, ...restProps } = props;
return <button {...restProps}>{children}</button>;
};
export default Button;
อ่านทีละบรรทัดแบบภาษาคน:
| บรรทัด | อ่านว่าอะไร |
|---|---|
export type ButtonProps = … |
ตั้งชื่อให้ "รายการของที่ปุ่มนี้รับได้" ว่า ButtonProps ·
โดยไม่เขียนเองสักช่อง แต่ไปยืมรายการทั้งใบของปุ่ม HTML จริงมาใช้ |
const Button = (props: ButtonProps) => { |
ฟังก์ชันชื่อ Button รับของก้อนเดียวชื่อ props ·
ตัว B ใหญ่ ไม่ใช่เรื่องความสวยงาม — หัวข้อ 5 จะพิสูจน์ให้ดู |
const { children, ...restProps } = props; |
แกะ props ออก: หยิบช่องชื่อ children ออกมาเก็บไว้ต่างหาก ·
ที่เหลือทั้งหมดกองรวมไว้ในกล่องชื่อ restProps |
return <button {...restProps}>{children}</button>; |
คืนปุ่ม HTML จริงหนึ่งปุ่ม · เท restProps ทั้งกล่องใส่ลงไปเป็น attribute ·
แล้วเอา children ไปวางไว้ตรงกลาง |
export default Button; |
ไฟล์นี้ส่งออกของหลักคือ Button · ที่อื่นจะ import ไปใช้ได้ |
มีเรื่องหนึ่งที่สไลด์ไม่ได้พูดแต่คนอ่านจะสะดุด:
ไฟล์นี้ไม่มี import React เลย แล้วทำไมเขียน React.ButtonHTMLAttributes ได้?
คำตอบคือ @types/react ประกาศชื่อ React ไว้เป็น
namespace ระดับ global ให้แล้ว — มันเป็นแค่ "ชื่อของกล่อง type"
ไม่ใช่ตัวแปรที่ต้องมีอยู่ตอนรัน จึงไม่ต้อง import
ผมลบ import React ออกแล้ว build จริง ผ่านสนิท:
$ npx tsc --noEmit
# (ไม่มี output — แปลว่าไม่มี error) exit code = 0
ไฟล์ Button.tsx คือแม่พิมพ์ — คุณทำไว้อันเดียว
แล้วปั๊มขนมออกมากี่ชิ้นก็ได้ ชิ้นละหน้าตาเหมือนกัน
เวลาเขียน <Button>บันทึก</Button> ในหน้าเว็บ นั่นคือการปั๊มขนมหนึ่งชิ้น
ไม่ใช่การเรียกแม่พิมพ์มาวางบนโต๊ะ
แม่พิมพ์อยู่ที่เดิมของมันเสมอ ปั๊มสิบชิ้นก็ยังใช้แม่พิมพ์อันเดิม
ที่ไม่เหมือนกัน: แม่พิมพ์ขนมจริงปั๊มออกมาได้แบบเดียวตายตัว
อยากได้ขนมรสอื่นต้องไปทำแม่พิมพ์ใหม่
แต่ component รับคำสั่งได้ตอนปั๊ม — <Button disabled> กับ
<Button type="submit"> ใช้แม่พิมพ์อันเดียวกัน แต่ได้ของคนละแบบ
ของที่ยื่นเข้าไปตอนปั๊มนั่นแหละคือ props ทั้งบทนี้พูดเรื่องนี้เรื่องเดียว
- component
- ฟังก์ชันที่รับ
propsแล้วคืน UI ออกมา · ในโปรเจกต์นี้เขียนเป็นไฟล์.tsxหนึ่งไฟล์ต่อหนึ่งตัว · ถาม senior ได้ว่า “ที่นี่แบ่ง component ยังไง อะไรควรอยู่ในcomponents/อะไรควรอยู่ในapp/” คำตอบต่างกันทุกทีม และมักไม่ได้เขียนไว้ที่ไหน - props
- ย่อมาจาก properties · คือ object ก้อนเดียวที่ component รับเข้ามา · props เป็นของอ่านอย่างเดียว (read-only) ห้ามแก้ค่าในนั้น — พิมพ์บอก AI ได้ว่า “extract these props into a typed props object” ตรงกว่าการบอกว่า “ทำให้ component รับค่าได้”
- JSX / TSX
- JSX คือไวยากรณ์ที่ให้เขียนอะไรที่หน้าตาเหมือน HTML ปนอยู่ในโค้ด JavaScript ·
TSX คือ JSX เวอร์ชันที่อยู่ในไฟล์ TypeScript · เพราะฉะนั้นไฟล์ที่มี JSX
ต้องนามสกุล
.tsxไม่ใช่.ts— จำข้อนี้ไว้ จะได้ไม่งงตอนหัวข้อ 4 - React.ButtonHTMLAttributes<HTMLButtonElement>
- type สำเร็จรูปที่แปลว่า “ทุก attribute ที่ปุ่ม HTML จริงรับได้” —
onClick,disabled,type,className,title… ครบทั้งชุด · ถาม AI ได้ว่า “what does React.ButtonHTMLAttributes include” แล้วให้มันไล่ให้ดู
2 · children — ของที่ยัดไว้ระหว่างแท็กเปิดกับแท็กปิด
คำถามที่คนอ่านสไลด์หน้า 22 ถามกันมากที่สุดคือ
“children โผล่มาจากไหน ไม่เห็นมีใครประกาศ”
คำตอบสั้น ๆ: คุณเป็นคนใส่มันเองตอนใช้งาน
เวลาเขียน <Button>กดฉันสิ</Button>
ข้อความ กดฉันสิ ที่อยู่ระหว่างแท็กเปิดกับแท็กปิด
ถูกยัดเข้าไปใน props ให้อัตโนมัติ ในช่องชื่อ children
พูดอีกแบบ — สองบรรทัดนี้แปลว่าเรื่องเดียวกันเป๊ะ:
ที่คุณเขียน
<Button type="button">กดฉันสิ</Button>
แบบที่มนุษย์อ่านสบายตา
ที่ React เห็นจริง ๆ
props = {
type: "button",
children: "กดฉันสิ"
}
children เป็นแค่ช่องหนึ่งใน object ไม่ต่างจาก type
ส่วนฝั่ง type — children ไม่ได้มาจากอากาศเหมือนกัน
มันติดมากับ React.ButtonHTMLAttributes<HTMLButtonElement>
เพราะ type ตัวนั้นสืบทอดมาจาก type ที่มีช่อง children?: React.ReactNode อยู่แล้ว
เพราะฉะนั้นในสไลด์หน้า 22 ไม่มีใครต้องเขียน children ลงใน type เลย — มันมาให้ฟรี
แล้วถ้า ลืมเอา children ไปวาง จะเป็นยังไง?
ตรงนี้คือกับดักจริงที่มือใหม่เจอกันเยอะ เพราะ มันไม่ error
// components/NoChild.tsx — สังเกตว่า return ไม่มี {children}
const NoChild = (props: NoChildProps) => {
const { children, ...restProps } = props;
return <button {...restProps}></button>;
};
// ใช้แบบนี้
<NoChild type="button">กดฉันสิ</NoChild>
npx tsc --noEmit จะฟ้องไหม? แล้ว HTML ที่ออกมาหน้าตายังไง?
tsc ไม่ฟ้องเลย · exit code = 0 และ HTML ที่ curl ได้จริงคือ:
$ curl -s http://localhost:3000/
<button type="button"></button>
ได้ปุ่มเปล่า — type="button" ไปถึงเรียบร้อย
แต่คำว่า "กดฉันสิ" หายไปเฉย ๆ
ทำไม tsc ถึงไม่ฟ้อง? เพราะคุณแกะ children ออกมาแล้วจริง ๆ
แค่ไม่ได้เอาไปใช้ต่อ ซึ่งในสายตา TypeScript ไม่ผิดอะไร ·
บทเรียนคือ “ไม่มี error” ไม่ได้แปลว่า “ถูก”
ต้องเปิดดูหน้าเว็บจริงหรือ curl ดู HTML จริงเสมอ
ในโค้ด <Button className="wide">ตกลง</Button> ค่าอะไรจะไปอยู่ในช่อง props.children?
ตัวเลือกที่ 2 ถูก · กติกาแบ่งง่ายมาก —
ของที่เขียนใน "หัว" ของแท็ก (ก่อน >) คือ props ปกติ
ส่วนของที่อยู่ "ระหว่าง" แท็กเปิดกับแท็กปิด คือ children
ตัวเลือกที่ 4 คือความเข้าใจผิดที่พบบ่อยที่สุด — children
ติดมากับ React.ButtonHTMLAttributes อยู่แล้ว ไม่ต้องประกาศเอง
3 · {...restProps} — ยกกล่องที่เหลือเทใส่ปุ่มจริง
จุดสามจุดในโค้ดสไลด์หน้า 22 ปรากฏสองที่ และทำคนละหน้าที่กัน ตรงนี้เป็นจุดที่คนสับสนบ่อย เลยแยกให้ดูชัด ๆ:
| ตำแหน่ง | เรียกว่าอะไร | ทำอะไร |
|---|---|---|
const { children, ...restProps } = props |
rest | เก็บ — "ที่เหลือทั้งหมดยัดใส่กล่องนี้" |
<button {...restProps}> |
spread | เท — "แกะกล่องนี้ออก แล้วโปรยของทุกชิ้นลงตรงนี้" |
ผลของสองบรรทัดนี้คือ ปุ่มของคุณรับ attribute ของปุ่ม HTML ได้ครบทุกตัวโดยไม่ต้องเขียนเพิ่มสักบรรทัด
ผมเขียนหน้าเว็บที่ใช้ Button จากสไลด์หน้า 22 ตรง ๆ:
// app/page.tsx
import { Button } from "@/components/Bases";
export default function Page() {
return (
<main>
<h1>บท 71</h1>
<Button type="button" title="ปุ่มแรก">กดฉันสิ</Button>
<Button disabled>ปุ่มที่กดไม่ได้</Button>
</main>
);
}
ผลรันจริง — HTML ที่ server ส่งออกมา (จัดบรรทัดใหม่ให้อ่านง่าย เนื้อหาไม่แก้):
$ curl -s http://localhost:3000/
<main>
<h1>บท 71</h1>
<button type="button" title="ปุ่มแรก">กดฉันสิ</button>
<button disabled="">ปุ่มที่กดไม่ได้</button>
</main>
สังเกตว่า type, title, disabled
ไปถึงปุ่มจริงครบทุกตัว ทั้งที่ในไฟล์ Button.tsx
ไม่มีคำว่า type หรือ disabled เขียนอยู่เลยแม้แต่คำเดียว —
นั่นคือของฟรีที่ {...restProps} แจกให้
แล้วมันฟรีจริงหรือแค่ปล่อยผ่านทุกอย่าง? ลองส่งของที่ปุ่ม HTML ไม่รู้จักดู:
// ส่ง variant ซึ่งไม่มีใน ButtonHTMLAttributes
<Button variant="outlined">กดฉันสิ</Button>
ผลรันจริง — npx tsc --noEmit หยุดทันที:
app/page.tsx(6,15): error TS2322: Type '{ children: string; variant: string; }'
is not assignable to type 'IntrinsicAttributes & ButtonProps'.
Property 'variant' does not exist on type 'IntrinsicAttributes & ButtonProps'.
ดีมาก — แปลว่า ButtonProps ไม่ได้เปิดรับทุกอย่างมั่ว ๆ
มันรับเฉพาะที่ปุ่ม HTML จริงรับได้เท่านั้น · ถ้าอยากเพิ่มของของตัวเองต้องประกาศ (หัวข้อ 6)
คุณสั่งของออนไลน์ส่งมาที่ออฟฟิศ พัสดุมาถึงเป็นกล่องรวมหนึ่งกล่อง
พนักงานหน้าตึกเปิดกล่อง หยิบเอกสารที่จ่าหน้าถึงเขาเองออกไปหนึ่งซอง
(นั่นคือ children)
แล้วยกที่เหลือทั้งกล่องส่งต่อขึ้นไปชั้นบนโดยไม่เปิดดู
(นั่นคือ {...restProps})
ข้อดีคือ วันไหนบริษัทเริ่มสั่งของชนิดใหม่ที่ไม่เคยมีมาก่อน
พนักงานหน้าตึกไม่ต้องเรียนรู้อะไรใหม่เลย — เขาแค่ส่งต่อเหมือนเดิม
ที่ไม่เหมือนกัน: พนักงานจริงส่งต่ออะไรก็ได้ที่เข้ามาในกล่อง
ของแปลกปลอมก็ผ่านไปได้
แต่ ButtonProps มียามอยู่หน้าประตูอีกชั้น —
ของที่ปุ่ม HTML ไม่รู้จักอย่าง variant
ถูกตีกลับตั้งแต่ตอน compile ด้วย TS2322 ยังไม่ทันเข้ากล่องด้วยซ้ำ
- rest / spread
- สัญลักษณ์เดียวกัน (
...) แต่คนละหน้าที่ · อยู่ฝั่งซ้ายของ=หรือใน pattern การแกะ = rest (เก็บที่เหลือ) · อยู่ในที่ที่กำลังสร้างของใหม่ = spread (โปรยออก) · ถาม AI ได้ว่า “is this dots a rest or a spread here” ถ้าอ่านโค้ดคนอื่นแล้วไม่แน่ใจ - prop spreading
- ท่าที่เขียน
<button {...restProps}>เพื่อส่ง props ต่อเป็นชุด · ถาม senior ได้ว่า “ทีมนี้ยอมให้ spread props ลง DOM element ไหม” — บางทีมห้าม เพราะทำให้ไล่ไม่ออกว่า attribute ไหนมาจากไหน และมี eslint rulereact/jsx-props-no-spreadingไว้ปิดโดยเฉพาะ - destructuring
- การแกะ object ออกเป็นตัวแปรย่อย ๆ ในบรรทัดเดียว (
const { children } = props) · ในโลก Go ที่คุณคุ้นเคย ไม่มีท่านี้ ต้องเขียนprops.Childrenทุกครั้ง — ผลลัพธ์เหมือนกัน ต่างแค่ความสั้น
4 · index.ts แบบ barrel — ทางเข้าเดียวของทั้งโฟลเดอร์
สไลด์หน้า 23 เพิ่มไฟล์ที่สองเข้ามา ชื่อ components/Bases/index.ts
พร้อมคำอธิบายบรรทัดเดียวว่า “To import and re-export components” ·
โค้ดเต็มคือ:
// components/Bases/index.ts — สังเกตนามสกุล .ts ไม่ใช่ .tsx เพราะไม่มี JSX ในไฟล์นี้
import Button, { type ButtonProps } from "./Button";
export type { ButtonProps };
export { Button };
ไฟล์นี้ไม่มีตรรกะอะไรเลย — มันแค่รับของเข้ามาจากไฟล์ข้าง ๆ แล้วส่งออกไปใหม่
ประโยชน์อยู่ที่ฝั่งคนใช้: แทนที่จะต้องรู้ชื่อไฟล์ทุกไฟล์ในโฟลเดอร์
ก็ import จากชื่อโฟลเดอร์เฉย ๆ ได้
ไม่มี index.ts — ต้องรู้ชื่อไฟล์ทุกไฟล์
import Button from "@/components/Bases/Button";
import Input from "@/components/Bases/Input";
import Card from "@/components/Bases/Card";
วันไหนย้าย Button.tsx ไปโฟลเดอร์อื่น ต้องไล่แก้ทุกไฟล์ที่ import
มี index.ts — จำแค่ชื่อโฟลเดอร์
import { Button, Input, Card }
from "@/components/Bases";
ย้ายไฟล์เมื่อไหร่ แก้ที่ index.ts ที่เดียว คนใช้ไม่รู้เรื่องด้วย
แล้วมันเป็นเวทมนตร์หรือเปล่า? ไม่ใช่ · มีกฎธรรมดา ๆ อยู่สองข้อที่ทำให้มันทำงาน:
-
ชื่อ
indexเป็นข้อตกลงของตัวหาไฟล์ (module resolver) ว่า “ถ้าชี้มาที่โฟลเดอร์ ให้เปิดไฟล์ชื่อindexในนั้น” ไม่มีอะไรพิเศษกว่านั้น -
@/ไม่ใช่ของ Next.js — มันคือ path alias ที่ตั้งไว้ในtsconfig.jsonของโปรเจกต์นี้จริง ๆ ว่า"paths": { "@/*": ["./*"] }· เปิดไฟล์ดูได้เลย
พิสูจน์ว่าไม่ใช่เวทมนตร์แบบง่ายที่สุด — ผมเปลี่ยนชื่อไฟล์ index.ts ทิ้งไปชั่วคราว
โดยไม่แตะไฟล์อื่นเลย แล้วรัน tsc:
app/page.tsx(1,24): error TS2307: Cannot find module '@/components/Bases'
or its corresponding type declarations.
พอเอาไฟล์กลับมา error หายทันที exit code กลับเป็น 0 ·
แปลว่า index.ts เป็นแค่ไฟล์ธรรมดาไฟล์หนึ่ง ที่บังเอิญตั้งชื่อตรงกับข้อตกลง
⚠️ คำว่า type ในไฟล์นี้มีสองที่ — และกฎจริงไม่ใช่ "ต้องมีทั้งสองที่"
สไลด์หน้า 23 เขียนคำว่า type ไว้สองแห่ง คือตอน import
({ type ButtonProps }) และตอน export (export type { … }) ·
ตำราส่วนใหญ่จะบอกว่า “ลบไม่ได้นะ เดี๋ยว TS1205” ·
ผมลองลบทีละที่แล้วรัน npx tsc --noEmit จริง ผลไม่ตรงกับที่ตำราบอก
บรรทัด import | บรรทัด export | ผลจริงจาก npx tsc --noEmit |
|---|---|---|
{ type ButtonProps } |
export type { … } |
✅ ผ่าน (exit 0) — แบบที่สไลด์เขียน |
{ type ButtonProps } |
export { … } |
✅ ผ่าน (exit 0) — ไม่ error! |
{ ButtonProps } |
export type { … } |
✅ ผ่าน (exit 0) |
{ ButtonProps } |
export { … } |
❌ TS1205 |
กฎจริงคือ “ต้องมีอย่างน้อยหนึ่งที่” ไม่ใช่ “ต้องมีทั้งสองที่” ·
แถวสุดท้ายคือแถวเดียวที่พัง — ลบคำว่า type ออกจากทั้งสองบรรทัด:
// components/Bases/index.ts — ไม่มีคำว่า type เลยสักที่
import Button, { ButtonProps } from "./Button";
export { ButtonProps };
export { Button };
components/Bases/index.ts(3,10): error TS1205: Re-exporting a type when
'isolatedModules' is enabled requires using 'export type'.
เหตุผลอ่านง่าย ๆ: ButtonProps เป็น type ล้วน ๆ
ที่หายไปหมดตอนแปลงเป็น JavaScript ไม่เหลือค่าจริงให้ export ·
โปรเจกต์ Next.js เปิด "isolatedModules": true ไว้ใน tsconfig.json
(เปิดไฟล์ดูได้) ซึ่งแปลว่า ตัวแปลงต้องอ่านไฟล์นี้ไฟล์เดียวแล้วตัดสินใจให้ได้
ว่าบรรทัดไหนต้องลบทิ้ง โดยห้ามเปิด Button.tsx ไปดู
ถ้าบรรทัด import เขียนว่า { type ButtonProps } ไว้แล้ว
ตัวแปลงก็รู้ตั้งแต่บรรทัดแรกแล้วว่านี่คือ type — บรรทัด export ไม่ต้องบอกซ้ำ ก็เลยไม่ error
แต่ถ้าไม่มีที่ไหนบอกเลย มันเดาไม่ออก จึงฟ้องให้เขียนให้ชัด
แล้วสรุปควรเขียนยังไง? — เขียนทั้งสองที่ตามสไลด์ไปเลย ·
ไม่ใช่เพราะ compiler บังคับ แต่เพราะมันกันอนาคต:
วันที่ใครไปแก้บรรทัด import (หรือโปรเจกต์เปิด verbatimModuleSyntax เพิ่ม)
ไฟล์นี้จะยังไม่พัง · นี่คือ "ทางที่ปลอดภัย" ไม่ใช่ "ข้อบังคับ" —
และการรู้ว่ามันต่างกันตรงไหน คือสิ่งที่ทำให้คุณไม่ต้องท่องกฎเป็นนกแก้ว
ตึกออฟฟิศมีหลายห้อง ถ้าไม่มีเคาน์เตอร์ประชาสัมพันธ์
คนมาติดต่อต้องจำเลขห้องเองทุกครั้ง ("ฝ่ายบัญชี ห้อง 512")
วันไหนฝ่ายบัญชีย้ายห้อง ทุกคนที่จำเลขเดิมไว้ต้องมาเรียนใหม่หมด
index.ts คือเคาน์เตอร์ประชาสัมพันธ์ —
คุณเดินเข้าตึกแล้วบอกว่า "ขอพบฝ่ายบัญชี" พอ เคาน์เตอร์จัดการเรื่องเลขห้องให้
ที่ไม่เหมือนกัน: เคาน์เตอร์จริงมีคนนั่ง มีค่าจ้าง มีเวลาเปิดปิด
แต่ index.ts ไม่เหลืออะไรตอนโปรแกรมทำงานจริง —
เป็นแค่ป้ายบอกทางตอน compile · ราคาที่จ่ายไม่ใช่ความช้าตอนรัน
แต่คือต้องมีคนคอยอัปเดตป้าย ลืมเติมชื่อ component ใหม่ลงไป
คนอื่นก็ import ไม่เจอ
- barrel file
- ไฟล์
index.tsที่ไม่ทำอะไรนอกจากรวบของจากไฟล์ข้าง ๆ มาส่งออกใหม่ · ถาม senior ได้ว่า “ที่นี่ใช้ barrel ไหม และใช้ถึงระดับไหน” — บางทีมเลิกใช้เพราะทำให้ bundler ตัดโค้ดที่ไม่ได้ใช้ทิ้งยากขึ้น - re-export
- การส่งออกของที่ตัวเองไม่ได้สร้าง แต่ import มาจากที่อื่น ·
เขียนย่อได้เป็น
export { Button } from "./Button";ในบรรทัดเดียวก็ได้ · สไลด์เลือกเขียนแบบสองบรรทัด (import ก่อน แล้วค่อย export) ซึ่งผลเหมือนกัน - path alias
- ทางลัดที่ตั้งไว้ใน
tsconfig.jsonให้เขียน@/components/…แทน../../components/…· เป็นของ TypeScript/bundler ไม่ใช่ของ Next.js · พิมพ์บอก AI ได้ว่า “convert these relative imports to the @/ alias” - isolatedModules
- โหมดที่บังคับให้แต่ละไฟล์แปลได้ด้วยตัวเองโดยไม่ต้องเปิดไฟล์อื่นดู ·
เป็นเหตุผลที่ต้องมีคำว่า
typeกำกับอย่างน้อยหนึ่งที่ ในเส้นทางของ type ที่ถูกส่งต่อ · ถาม AI ได้ว่า “why does isolatedModules require export type, and when is it not required” — ต่อท้ายด้วยท่อนหลังด้วย ไม่งั้นมันจะตอบแค่กฎกว้าง ๆ ที่ไม่ตรงกับสิ่งที่คุณเจอจริง
5 · ตัวอักษรตัวแรกของชื่อ component เปลี่ยนความหมายทั้งหมด
ถึงส่วนที่สำคัญที่สุดของบทนี้แล้ว ·
ในโค้ดสไลด์หน้า 22 ชื่อ component คือ Button ตัว B ใหญ่ ·
หลายคนคิดว่าเป็นธรรมเนียมความสวยงาม เหมือนตั้งชื่อ struct ใน Go
ไม่ใช่ · มันคือกฎที่เปลี่ยนความหมายของโค้ด:
<Button> ตัวใหญ่ → React อ่านว่า "เรียกฟังก์ชันชื่อ Button ที่อยู่ในไฟล์นี้"
<button> ตัวเล็ก → React อ่านว่า "สร้างแท็ก HTML ชื่อ button"
↑
ไม่สนใจเลยว่าคุณจะประกาศ const button ไว้ข้างบนหรือเปล่า
ปัญหาคือ ถ้าคุณเผลอตั้งชื่อ component เป็นตัวเล็ก และชื่อนั้นดันไปตรงกับแท็ก HTML ที่มีอยู่จริง จะไม่มีใครเตือนคุณเลยสักคน · มาดูของจริง
// ไฟล์ A — ชื่อ component เป็นตัวเล็ก
const button = (props: { children: React.ReactNode }) => {
return <span>ดาว ★ {props.children}</span>;
};
export default function Page() {
return (
<main>
<button>สวัสดี</button>
</main>
);
}
หน้านี้จะขึ้น error ไหม? แล้ว HTML ที่ออกมาจะมีดาว ★ หรือเปล่า?
ไม่มี error เลยแม้แต่ตัวเดียว — npx tsc --noEmit ผ่าน (exit 0)
· npx next build ผ่าน · หน้าเว็บเปิดได้ปกติ
แต่ HTML ที่ curl ได้จริงคือ:
$ curl -s http://localhost:3000/
<main><button>สวัสดี</button></main>
ไม่มีดาว · ไม่มี <span> —
ฟังก์ชัน button ที่คุณเขียนไว้ไม่ถูกเรียกเลยสักครั้ง
React มองว่า <button> คือแท็ก HTML และสร้างปุ่มธรรมดาให้
เทียบกับไฟล์เดียวกันเป๊ะ เปลี่ยนแค่ตัว b เป็น B:
$ curl -s http://localhost:3000/ # (ไฟล์เดิม เปลี่ยนแค่ b เป็น B)
<main><span>ดาว ★ <!-- -->สวัสดี</span></main>
คราวนี้ฟังก์ชันทำงาน · (<!-- --> ที่แทรกอยู่คือคั่นข้อความที่ React ใส่ให้เอง
เพื่อให้ตอน hydrate แยกออกว่าข้อความสองก้อนไม่ได้ติดกัน — ไม่ใช่บั๊ก)
แล้วถ้าชื่อตัวเล็กที่ไม่ตรงกับแท็ก HTML ตัวไหนล่ะ?
กรณีนั้นโชคดีหน่อย เพราะ TypeScript ฟ้อง · ผมลองตั้งชื่อว่า myButton:
app/page.tsx(8,7): error TS2339: Property 'myButton' does not exist
on type 'JSX.IntrinsicElements'.
app/page.tsx(8,23): error TS2339: Property 'myButton' does not exist
on type 'JSX.IntrinsicElements'.
คำว่า JSX.IntrinsicElements ในข้อความ error คือ
"รายชื่อแท็ก HTML ที่มีอยู่จริง" · TypeScript กำลังบอกว่า
"หาแท็กชื่อ myButton ในรายชื่อไม่เจอ" —
มันไม่เคยคิดจะไปหาฟังก์ชันชื่อเดียวกันเลย เพราะตัวแรกเป็นตัวเล็ก
| เขียนแบบนี้ | TypeScript | ผลจริงที่ได้ |
|---|---|---|
<Button> ตัวใหญ่ |
ผ่าน | ✅ เรียก component ของคุณ |
<button> ตัวเล็ก + ชื่อตรงแท็ก HTML |
ผ่าน (ไม่ฟ้องอะไรเลย) | ☠️ สร้างแท็ก HTML · ฟังก์ชันคุณถูกเมิน |
<myButton> ตัวเล็ก + ไม่ตรงแท็กไหน |
TS2339 |
❌ build ไม่ผ่าน — โชคดีที่พังตั้งแต่แรก |
เพื่อนเขียน const card = (props) => … แล้วใช้ <card>เนื้อหา</card> · จะเกิดอะไรขึ้น?
ตัวเลือกที่ 4 ถูก · card ไม่ใช่แท็ก HTML จริง
TypeScript จึงหาไม่เจอใน JSX.IntrinsicElements แล้วฟ้อง TS2339 ทันที
— กรณีนี้คือกรณีที่โชคดี เพราะพังตั้งแต่ยังไม่ทันรัน
กรณีที่โชคร้ายกว่าคือชื่อที่ดันตรงกับแท็ก HTML จริงอย่าง
button, form, section, header —
พวกนี้ผ่านหมดทั้ง tsc และ build แล้วให้ผลลัพธ์ผิดเงียบ ๆ
ตามที่กล่องเฉลยข้างบนแสดงไว้
6 · เพิ่ม props ของตัวเอง — และกับดักสองอันของ variant
ถึงตรงนี้ปุ่มของเรารับได้แค่ของที่ปุ่ม HTML รับได้ · สไลด์หน้า 30 แสดงภาพปุ่มสามแบบ (contained / outlined / text) แล้วหน้า 31 ก็โชว์โค้ดที่ทำให้มันเป็นจริง · ท่าคือเอา type สองก้อนมาบวกกัน:
type ButtonVariantTypes = "contained" | "outlined" | "text";
export type ButtonProps = {
variant?: ButtonVariantTypes;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
เครื่องหมาย & อ่านว่า "และ" —
"ของที่ปุ่มนี้รับได้ = ช่อง variant ของเรา บวกกับ ทุกอย่างที่ปุ่ม HTML รับได้"
ส่วน ? หลัง variant แปลว่า ไม่ส่งมาก็ได้
ทีนี้ในตัว component สไลด์แกะ props ออกมาสามอย่าง สังเกตบรรทัดนี้ให้ดี:
const { variant = "contained", children, className, ...restProps } = props;
มีของใหม่สองอย่างที่ต้องเข้าใจ:
-
variant = "contained"— ค่าตั้งต้น · ถ้าคนใช้ไม่ส่งvariantมา ให้ถือว่าเป็น"contained"จะได้ไม่ต้องเขียนifเช็คundefinedเอง -
classNameถูกดึงออกมาต่างหากไม่ปล่อยให้ตกอยู่ในrestProps· เพราะ component ต้องเอาไปผสมกับ class ของตัวเองก่อน
และนี่คือเหตุผลที่ต้องดึง variant ออกด้วย —
ถ้าปล่อยไว้ใน restProps แล้วเทลง <button>
มันจะไหลไปโผล่บนหน้าเว็บจริง · ผมลองแล้ว:
ลืมดึง variant ออก
const { children, ...restProps } = props;
return <button {...restProps}>{children}</button>;
ผลรันจริง — HTML ที่ออกมา:
<button variant="outlined">รั่ว</button>
variant กลายเป็น attribute จริงบน DOM ที่ไม่มีความหมายอะไรเลย ·
React 19 ไม่เตือน ไม่ error — ปล่อยผ่านเงียบ ๆ
(ผมเช็ค log ของ dev server แล้วไม่มี warning สักบรรทัด)
ดึง variant ออกก่อน
const { variant = "contained", children,
className, ...restProps } = props;
ผลรันจริง — HTML ที่ออกมา:
<button class="btn btn-outlined wide">ขอบเส้น</button>
variant ทำงานเสร็จหน้าที่ในฝั่ง component แล้วไม่ตามไป DOM ·
เหลือแต่ class ที่มีความหมายจริง
⚠️ กับดักอันที่สอง — บั๊กที่อยู่ในโค้ดสไลด์หน้า 31 บรรทัดที่ 27 จริง ๆ
กับดักอันแรก (ลืมดึง variant ออก) สไลด์ไม่ได้พลาด — แกดึงออกถูกแล้ว ·
แต่บรรทัดถัดมาที่เอา class มาต่อกัน มีบั๊กจริงซ่อนอยู่ · สไลด์เขียนไว้แบบนี้:
// สไลด์หน้า 31 บรรทัด 25 และ 27
const variantStyles = getVariantStyles(variant);
...
<button className={`${variantStyles} ${className}`} {...restProps}>
อ่านผ่าน ๆ ดูไม่มีอะไรผิด แต่ผมรันจริงแล้ว curl ดู HTML
ตอนที่คนใช้ไม่ส่ง className มา — ซึ่งเป็นกรณีปกติที่สุด:
// เขียน <Button>ค่าเริ่มต้น</Button> เฉย ๆ ไม่ส่ง className
<button class="btn btn-contained undefined">ค่าเริ่มต้น</button>
คำว่า undefined ติดไปเป็นชื่อ class จริง ๆ บนหน้าเว็บ ·
ไม่พังทันที แต่เละแน่ตอนที่มีคนเผลอเขียน CSS ชื่อ .undefined
หรือตอนไล่อ่าน HTML ใน DevTools
สาเหตุ: template literal (` `) แปลงทุกอย่างเป็นข้อความ
และ undefined แปลงเป็นข้อความได้ว่า "undefined"
วิธีแก้ที่ผมรันแล้วได้ผลสะอาด — กรองค่าว่างทิ้งก่อนต่อ:
const variantStyles = getVariantStyles(variant);
const merged = [variantStyles, className].filter(Boolean).join(" ");
return <button className={merged} {...restProps}>{children}</button>;
// ผลรันจริงหลังแก้:
// <button class="btn btn-contained">ค่าเริ่มต้น</button>
// <button class="btn btn-outlined wide">ขอบเส้น</button>
📌 หมายเหตุเรื่อง class ที่สไลด์ใช้ — โค้ดในสไลด์หน้า 31 คืน class อย่าง
px-2.5 py-1.5 border-2 rounded-md ซึ่งเป็น Tailwind CSS
ที่ยังไม่ได้สอนในบทนี้ ·
บทนี้ผมเลยเปลี่ยนเป็นชื่อ class ธรรมดา (btn btn-outlined)
เพื่อให้โฟกัสอยู่ที่เรื่อง props อย่างเดียว —
โครงสร้าง type และการแกะ props ยังเหมือนสไลด์ทุกตัวอักษร
ส่วน Tailwind รอ บทเรียน 72
ทำไมต้องดึง variant ออกจาก props ก่อน แทนที่จะปล่อยให้อยู่ใน restProps?
ตัวเลือกที่ 1 ถูก · {...restProps} เทของทุกชิ้นลงบน <button> ตรง ๆ
อะไรที่ไม่ได้ดึงออกก่อนก็ตามไปด้วยหมด
ตัวเลือกที่ 4 ผิด และนี่คือส่วนที่อันตราย — บทนี้รันจริงแล้วพบว่า
React 19 ไม่ error ไม่ warning เลย
แค่ปล่อย variant="outlined" ให้ไปนั่งอยู่บน DOM เงียบ ๆ ·
แปลว่าคุณจะไม่รู้ตัวจนกว่าจะเปิด DevTools ดูเอง
ลูกค้าสั่ง "ข้าวผัด ไม่ใส่ผัก โต๊ะ 5 จ่ายด้วยบัตร" ·
พนักงานเสิร์ฟต้องตัดข้อมูลการจ่ายเงินออกก่อนส่งใบสั่งเข้าครัว
เพราะแม่ครัวไม่มีอะไรทำกับคำว่า "จ่ายด้วยบัตร"
variant คือ "จ่ายด้วยบัตร" — เป็นข้อมูลของพนักงานเสิร์ฟ ไม่ใช่ของแม่ครัว
ส่วน disabled กับ type คือ "ไม่ใส่ผัก" ที่ต้องส่งต่อเข้าครัวจริง
ที่ไม่เหมือนกัน: แม่ครัวจริงเห็นคำว่า "จ่ายด้วยบัตร" ก็จะทักกลับมา
ว่าใบสั่งนี้แปลก ๆ นะ
แต่ <button> ของ React 19 ไม่ทัก —
มันเอาไปแปะบนหน้าเว็บให้เฉย ๆ แล้วปล่อยให้คุณไปเจอเองทีหลัง
เพราะฉะนั้นต้องเป็นคนตัดออกเอง ไม่มีใครช่วยตรวจให้
- intersection type (
&) - เอา type สองก้อนมารวมเป็นก้อนเดียวที่ต้องมีครบทั้งสองฝั่ง ·
อย่าสับสนกับ
|(union) ที่แปลว่า "อย่างใดอย่างหนึ่ง" · พิมพ์บอก AI ได้ว่า “extend this props type with the native button attributes” - default parameter value
- ค่าตั้งต้นที่เขียนไว้ตอน destructure (
variant = "contained") · ทำงานเฉพาะตอนค่าเป็นundefinedเท่านั้น — ถ้าส่งnullหรือ""มา ค่าตั้งต้นไม่ทำงาน - variant / design token
- variant คือชื่อที่วงการ UI ใช้เรียก "หน้าตาแบบหนึ่งของ component เดียวกัน" · สไลด์หน้า 30 ตั้งชื่อไว้ว่า contained / outlined / text ซึ่งเป็นชุดเดียวกับ Material Design · ถาม senior ได้ว่า “ทีมนี้เอาชื่อ variant มาจาก design system ตัวไหน” เพราะชื่อพวกนี้ควรตรงกับที่ดีไซเนอร์ใช้ใน Figma ไม่ใช่ตั้งเอง
ไฟล์ barrel เขียนบรรทัด import ไว้ว่า import Button, { type ButtonProps } from "./Button";
(แบบสไลด์) · แล้วคุณลบคำว่า type ออกจากบรรทัด export ให้เหลือ
export { ButtonProps }; · npx tsc --noEmit จะว่ายังไง?
ตัวเลือกที่ 4 ถูก · บทนี้ลองจริงทั้งสี่แบบแล้ว (ตารางในหัวข้อ 4) —
แบบนี้ tsc ไม่ฟ้องอะไรเลย exit 0
เหตุผล: isolatedModules แปลว่า "ตัวแปลงต้องอ่านไฟล์นี้ไฟล์เดียวแล้วรู้เรื่อง" ·
พอบรรทัดแรกเขียน { type ButtonProps } ไว้แล้ว
มันรู้ตั้งแต่บรรทัดแรกว่านี่คือ type — บรรทัด export ไม่ต้องบอกซ้ำ
ตัวเลือกที่ 1 คือคำตอบที่คนตอบกันมากที่สุด และเกือบถูก —
TS1205 มีจริง แต่ต้องลบคำว่า type ออก
ทั้งบรรทัด import และบรรทัด export ถึงจะเจอ ·
บทเรียนที่ติดตัวไปได้: อย่าท่องกฎ ให้รู้ว่ากฎมีไว้แก้ปัญหาอะไร
แล้วคุณจะเดาถูกเองว่าเคสไหนเข้าข่ายไม่เข้าข่าย
7 · ภารกิจ — ทำเอง แล้วพิสูจน์ด้วยผลรัน ไม่ใช่ด้วยความรู้สึก
🛠 สร้าง Badge component แล้วพิสูจน์ทีละข้อ
ในโปรเจกต์ Next.js ที่คุณสร้างไว้จากบทที่แล้ว ทำตามลำดับนี้ · ห้ามข้ามข้อ 5 กับ 6
-
สร้าง
components/Bases/Badge.tsxที่มี type ชื่อBadgePropsยืมมาจากReact.HTMLAttributes<HTMLSpanElement>แล้ว render เป็น<span>ที่เทrestPropsลงไปและวางchildrenไว้ตรงกลาง
(ลอกโครงจากสไลด์หน้า 22 ได้ตรง ๆ เปลี่ยนแค่buttonเป็นspan) -
เพิ่ม props ของตัวเองหนึ่งช่อง:
tone?: "info" | "danger"โดยใช้&บวกกับ type เดิม · ตั้งค่าตั้งต้นเป็น"info"ตอน destructure -
สร้าง
components/Bases/index.tsที่ re-export ทั้งBadgeและBadgeProps· อย่าลืมคำว่าtype -
ใน
app/page.tsximport จาก"@/components/Bases"แล้ววาง 3 ตัว: ตัวหนึ่งไม่ส่งtone· ตัวหนึ่งส่งtone="danger"· ตัวหนึ่งส่งtitle="ทดสอบ" -
รัน
npx tsc --noEmitให้ผ่าน (exit 0) ก่อน แล้วรันnpx next dev· เปิดหน้าเว็บ กด View Page Source แล้วอ่าน HTML จริงของ span ทั้งสามตัว — ตอบให้ได้ว่าtoneโผล่บน DOM หรือเปล่า และtitleไปถึงไหม -
ตั้งใจทำให้พังหนึ่งรอบ: เปลี่ยนชื่อ component จาก
Badgeเป็นbadgeทั้งไฟล์ (รวมที่เรียกใช้) แล้วรันnpx tsc --noEmitใหม่
คุณควรได้TS2339· ก็อปข้อความ error ทั้งบรรทัดเก็บไว้ แล้วลองเปลี่ยนชื่อเป็นspanตัวเล็กดูอีกรอบ — คราวนี้จะไม่มี error แต่หน้าเว็บได้ผลผิด · ให้ curl ดู HTML แล้วอธิบายให้ตัวเองฟังว่าทำไม
ทำครบหกข้อแล้ว ตอบคำถามนี้ให้ได้ก่อนกดเฉลย:
ทำไมข้อ 6 กรณี badge ถึงพัง แต่กรณี span ถึงไม่พัง
ทั้งที่ทั้งสองอันตัวแรกเป็นตัวเล็กเหมือนกัน?
เพราะ React ตัดสินจากตัวอักษรตัวแรกอย่างเดียว ไม่ได้ดูว่าคุณประกาศฟังก์ชันชื่อนั้นไว้หรือเปล่า:
-
ตัวแรกเป็นตัวเล็ก → React ถือว่าเป็นแท็ก HTML เสมอ
แล้วไปหาชื่อนั้นใน
JSX.IntrinsicElements -
badgeไม่มีในรายชื่อแท็ก HTML → TypeScript ฟ้องTS2339 -
spanมีในรายชื่อ → ผ่านฉลุย แล้วสร้างแท็ก<span>เปล่า ๆ ให้ · ฟังก์ชันที่คุณเขียนไว้ไม่ถูกเรียกเลย
บทเรียนที่เอาไปใช้ได้จริง: “build ผ่าน” ไม่เท่ากับ “ถูก” · ในงานจริง เวลาแก้ UI แล้วหน้าเว็บไม่เปลี่ยนตามที่คิด ให้เช็คตัวอักษรตัวแรกของชื่อ component เป็นอย่างแรก ๆ ก่อนจะไปนั่งไล่หา cache หรือ build system
8 · สรุปบทนี้เป็นสี่บรรทัด
| เรื่อง | จำแค่นี้พอ |
|---|---|
children |
คือของที่อยู่ระหว่างแท็กเปิดกับแท็กปิด · React ยัดเข้า props ให้เอง ·
ติดมากับ ButtonHTMLAttributes อยู่แล้ว ไม่ต้องประกาศ ·
ลืมวางแล้วไม่มีใครฟ้อง ได้ปุ่มเปล่า |
{...restProps} |
ทำให้ปุ่มรับ onClick / disabled / type ได้ครบ
โดยไม่ต้องเขียนเพิ่มสักบรรทัด · แต่ของที่ไม่ได้ดึงออกก่อนจะไหลไปโผล่บน DOM
และ React 19 ไม่เตือน |
index.ts |
เป็นแค่ไฟล์ธรรมดาที่ตั้งชื่อตรงกับข้อตกลง "เข้าโฟลเดอร์ให้เปิด index" ·
ลบทิ้งเมื่อไหร่ได้ TS2307 ทันที ·
re-export type ต้องมีคำว่า type อย่างน้อยหนึ่งที่
(บรรทัด import หรือบรรทัด export) · ไม่มีเลยสักที่ = TS1205 |
| ชื่อ component | ตัวแรกต้องเป็นตัวใหญ่ · ตัวเล็กที่ตรงกับแท็ก HTML =
ผิดเงียบ ๆ ไม่มี error · ตัวเล็กที่ไม่ตรงแท็กไหน = TS2339 |
✅ ถ้าตอบสี่ข้อนี้ได้ แปลว่าบทนี้ผ่านแล้ว
1. <Button>ตกลง</Button> — คำว่า "ตกลง" ไปอยู่ในช่องชื่ออะไรของ props?
2. ทำไม Button.tsx เขียน React.ButtonHTMLAttributes ได้ทั้งที่ไม่มี import React?
3. ถ้าลบ components/Bases/index.ts ทิ้ง โค้ดที่ import จาก "@/components/Bases" จะได้ error เลขอะไร?
4. เขียน <section> ตัวเล็กทั้งที่ตั้งใจจะเรียก component ตัวเอง — TypeScript จะฟ้องไหม แล้วหน้าเว็บจะได้อะไร?
อ้างอิง
- React — Your First Component — อ่านหัวข้อย่อย Step 1: Export the component ถึง Step 3: Add markup ให้จบ แล้วสังเกตกล่อง Pitfall ที่บอกว่าชื่อ component ต้องขึ้นต้นด้วยตัวใหญ่ — ตรงกับที่หัวข้อ 5 ของบทนี้ curl HTML ออกมาพิสูจน์ให้ดูแล้ว
- React — Passing Props to a Component —
อ่านหัวข้อ Forwarding props with the JSX spread syntax
ซึ่งคือ
{...restProps}ในสไลด์หน้า 22 เป๊ะ ๆ · คู่มือทางการเตือนไว้ด้วยว่า “ใช้ให้เท่าที่จำเป็น” เพราะทำให้ไล่ไม่ออกว่าอะไรถูกส่งต่อไปบ้าง - React — Passing JSX as children —
สั้นมาก อ่านไม่ถึงสองนาที · ตอบคำถาม “
childrenมาจากไหน” ตรง ๆ ว่ามันคือของที่อยู่ระหว่างแท็กเปิดกับแท็กปิด ถูกใส่ให้ในช่องชื่อchildrenอัตโนมัติ - React TypeScript Cheatsheet — Typing Component Props —
เลื่อนไปดูหัวข้อ Types or Interfaces? และตัวอย่างที่ใช้
React.ButtonHTMLAttributes· อธิบายว่าทำไมท่า "ยืม attribute ของ element จริงมาทั้งชุดแล้วบวก props ของตัวเองด้วย&" ถึงเป็นท่ามาตรฐานของ component library - TypeScript —
isolatedModules— อ่านย่อหน้าที่ยกตัวอย่าง re-export ของ type ซึ่งคือสาเหตุตรง ๆ ของTS1205ที่บทนี้รันให้ดูในหัวข้อ 4 · อ่านเพื่อเข้าใจว่าทำไมข้อจำกัด "ไฟล์เดียวต้องแปลได้เอง" ถึงทำให้ต้องเขียนคำว่าtypeให้ชัด — และทำไมพอบรรทัด import บอกไว้แล้ว บรรทัด export ถึงไม่ต้องบอกซ้ำ - Next.js — Project Structure —
ดูหัวข้อ Top-level folders เพื่อยืนยันว่า
components/ไม่ใช่โฟลเดอร์พิเศษของ Next.js — คุณจะตั้งชื่ออะไรก็ได้ · มีแต่app/และpublic/เท่านั้นที่ Next.js สนใจจริง