Skip to content
Watchara NoisriphanAI Builder

พิมพ์เพื่อเริ่มค้นหา

Claude Code เปลี่ยนวิธีเขียนโปรแกรมของผมอย่างไร

AI เขียนโค้ดได้เร็วมาก แต่บั๊กที่รอดไปถึง production คือบั๊กที่เครื่องเราจำลองไม่ได้ — build tool, header ของ host และ security policy บันทึกจากการสร้างเว็บนี้ทั้งเว็บในวันเดียว พร้อมวิธีปิดช่องว่างระหว่าง local กับของจริง

อ่าน 14 นาที

เว็บไซต์ที่คุณกำลังอ่านอยู่นี้ถูกสร้างขึ้นทั้งหมดในวันเดียวด้วย Claude Code โดยเขียนสเปกไว้ในไฟล์ AGENTS.md ก่อน แล้วให้ agent ไล่ทำตามทีละเฟส ตั้งแต่วางโครงโปรเจกต์ ระบบคอนเทนต์ หน้าเว็บ ระบบค้นหา เทสต์ ไปจนถึง deploy

สิ่งที่น่าสนใจไม่ใช่ความเร็ว แต่คือ ประเภทของบั๊กที่เหลืออยู่หลังจบวัน เพราะมันเปลี่ยนไปจนต้องเปลี่ยนวิธีทำงานตาม

โครงของวันนั้น

สเปกใน AGENTS.md แบ่งงานเป็น 6 เฟส และงานเดินตามลำดับนั้นจริง — ดูได้จาก git log ของ repo นี้ที่ commit เรียงตามเฟสแทบตรงตัว

เฟสขอบเขตตัวอย่างสิ่งที่ทำ
1Foundationตั้งโปรเจกต์ Astro, TypeScript, Tailwind, lint/format
2Contentcontent collections + schema ของแต่ละ collection
3Core pagesหน้าแรก บทความ โปรเจกต์ builder log about contact
4Discoveryค้นหา หมวดหมู่ แท็ก RSS sitemap structured data
5Qualityaccessibility, responsive, unit test, e2e test, 404
6LaunchOG image, analytics, Search Console, deploy production

เฟส 1–5 แทบไม่มีปัญหาอะไรที่ต้องคิดนาน โค้ดออกมา แล้วก็รันได้ ปัญหาจริง ๆ มากระจุกอยู่ที่รอยต่อระหว่าง “รันบนเครื่องเรา” กับ “รันบนของจริง”

บั๊กที่รอดมาถึง production เป็นบั๊กคนละพันธุ์

ข้อสังเกตที่ชัดที่สุดของวันนั้นคือ

บั๊กเชิงตรรกะ (logic bug) ถูกจับได้เร็วมาก บั๊กที่รอดมาถึง production คือบั๊กที่ เครื่อง local ไม่มีทางจำลองได้

เหตุผลตรงไปตรงมา: logic bug อยู่ในโค้ดที่ agent เขียนและอ่านได้ทั้งก้อน แต่บั๊กอีกกลุ่มหนึ่งไม่ได้อยู่ในโค้ดเลย — มันอยู่ใน สิ่งที่ build tool เขียนทับให้ และ สิ่งที่ host ใส่เพิ่มให้ตอน serve ซึ่งทั้งสองอย่างนี้ไม่ปรากฏตัวตอนรันในเครื่อง

สามหัวข้อถัดไปคือกรณีจริงทั้งสามแบบ

กรณีที่ 1 — build tool เขียนโค้ดของเราใหม่

ระบบค้นหาของเว็บนี้ใช้ Pagefind ซึ่งต้องโหลด bundle ด้วย dynamic import() ตอนเปิดกล่องค้นหาครั้งแรก เขียนแบบตรงไปตรงมาก็คือ

const pagefind = await import('/pagefind/pagefind.js');

โค้ดนี้ถูก แต่ใช้ไม่ได้ เพราะ Vite จะเขียน dynamic import() ทุกจุดใหม่ ให้กลายเป็น helper ของตัวเองชื่อ __vitePreload และในสคริปต์ที่ Astro inline ลงไปใน HTML argument ที่ชื่อ __VITE_PRELOAD__ ไม่ถูกแทนที่ด้วยค่าจริง

ผลคือ browser เจอ identifier ที่ไม่มีอยู่ แล้ว throw ReferenceError ตั้งแต่ก่อนจะเริ่ม import ด้วยซ้ำ และเพราะโค้ดครอบ try/catch ไว้เพื่อรองรับกรณี “ยังไม่ได้ build index” มันเลยไปโผล่เป็นข้อความว่าดัชนีค้นหายังไม่ถูกสร้าง ซึ่งชี้ผิดที่โดยสิ้นเชิง

ทางแก้คือย้าย import ออกไปอยู่ใน is:inline script ที่ Vite ไม่แตะ

<script is:inline>
  window.__loadPagefind = () => (window.__pagefindPromise ??= import('/pagefind/pagefind.js'));
</script>

บทเรียนที่เอาไปใช้ต่อได้: โค้ดที่คุณอ่านในไฟล์ไม่ใช่โค้ดที่ browser รัน เมื่ออาการพังไม่ตรงกับโค้ดที่เห็น ให้ไปเปิดดู output ที่ build ออกมาจริง ๆ ก่อนจะไปแก้ logic

กรณีที่ 2 — security policy มีอยู่แค่บน host

หลังจากใส่ Content Security Policy เข้าไปตามหลัก security ระบบค้นหาพังทันทีบนเว็บจริง

สาเหตุคือ Pagefind ทำงานด้วย WebAssembly และ script-src จะบล็อกการ compile WASM ถ้าไม่ระบุ 'wasm-unsafe-eval' ไว้ด้วย

"script-src": "'self' 'unsafe-inline' 'wasm-unsafe-eval' …"

ประเด็นสำคัญไม่ใช่ตัว directive ที่ขาด แต่คือ ไม่มีวิธีเจอมันจากในเครื่อง astro preview ไม่ได้ใส่ CSP ให้ มีแต่ host จริงเท่านั้นที่ใส่ วันนั้นเทสต์ e2e ทั้ง 35 ตัวผ่านหมดในขณะที่เว็บจริงค้นหาไม่ได้

กรณีที่ 3 — ไฟล์ config ที่ host ไม่อ่าน

security header ชุดแรกถูกเขียนไว้ในไฟล์ public/_headers ซึ่งเป็นรูปแบบที่ Cloudflare Pages และ Netlify อ่าน แต่ Vercel ไม่อ่าน ต้องเขียนเป็น vercel.json แยกอีกไฟล์

ไฟล์ config ที่ host ไม่รู้จักจะไม่แจ้ง error ใด ๆ — มันแค่เงียบ build ผ่าน deploy ผ่าน เว็บขึ้น และไม่มี header สักตัว จะรู้ก็ต่อเมื่อไปยิงดู response header ของเว็บจริงเท่านั้น

ทำไมเทสต์ผ่านหมดทั้งที่เว็บพัง

รวบสามกรณีข้างบนเข้าด้วยกันแล้วจะเห็นรูปแบบเดียวกัน: สิ่งที่ทำให้พังไม่มีอยู่ในสภาพแวดล้อมที่เทสต์รัน

สิ่งที่ตรวจastro devastro previewproduction
โค้ดหลัง build ผ่าน Viteไม่ตรงตรงตรง
ดัชนี Pagefindไม่มีมีมี
security header จาก hostไม่มีไม่มีมี
Content Security Policyไม่มีไม่มีมี
การอ่าน vercel.jsonไม่ไม่ใช่

แถวสามแถวล่างคือหลุมทั้งหมด เทสต์ e2e ที่รันบน astro preview ครอบคลุมพฤติกรรมของหน้าเว็บได้ครบ แต่ครอบคลุม สภาพแวดล้อมที่หน้าเว็บนั้นไปอยู่ ไม่ได้เลย

วิธีปิดช่องว่าง: เทสต์ตัว config ไม่ใช่แค่ตัวเว็บ

เมื่อรู้ว่าเทสต์แบบรันเว็บเข้าไม่ถึงชั้นนี้ วิธีที่ใช้จริงคือเขียน unit test ที่ assert ตัวไฟล์ config โดยตรง (tests/unit/security-headers.test.ts) โดยตรวจสามอย่าง

  1. มี CSP อยู่จริงทั้งใน vercel.json และ public/_headers
  2. policy ในสองไฟล์ เหมือนกันเป๊ะ เพื่อให้ย้าย host แล้วพฤติกรรมไม่เปลี่ยน
  3. policy มี 'wasm-unsafe-eval' เพื่อให้ค้นหายังทำงาน
it("allows WebAssembly, which Pagefind's search index requires", () => {
  expect(vercelCsp).toContain("'wasm-unsafe-eval'");
});

เทสต์แบบนี้ไม่ได้พิสูจน์ว่าเว็บทำงาน มันพิสูจน์แค่ว่า การตั้งค่าที่เคยทำให้พังจะไม่ย้อนกลับมา ซึ่งเป็นสิ่งที่เทสต์แบบเปิดเว็บทำแทนไม่ได้

หลักที่ได้จากตรงนี้มีสองข้อ

  • อะไรที่เทสต์อัตโนมัติเข้าไม่ถึง ต้อง ไปดูของจริงด้วยตาเอง อย่างน้อยหนึ่งครั้ง บั๊ก CSP กับ header ถูกเจอเพราะไปยิงดู response ของเว็บจริง ไม่ใช่เพราะเทสต์ฟ้อง
  • พอเจอแล้ว ให้แปลงสิ่งที่เจอเป็น assertion ที่ระดับที่มันอยู่จริง บั๊กใน config ต้องมีเทสต์ที่ระดับ config

เขียนสเปกก่อน แล้วกฎถูกบังคับโดยไม่ต้องเถียงใหม่

อีกเรื่องที่เปลี่ยนวิธีทำงานชัดเจนคือการมี AGENTS.md ที่ระบุกฎไว้ล่วงหน้า ก่อนจะเริ่มเขียนโค้ดบรรทัดแรก

สเปกไฟล์นี้ไม่ได้มีแค่ว่าจะใช้เทคโนโลยีอะไร แต่รวมถึงกฎที่ปกติต้องมาคุยกันทุกครั้ง เช่น

  • §31 Content Preservation Rules — ห้ามแต่งประสบการณ์ส่วนตัว ห้ามแต่งตัวเลข ลูกค้า หรือรายได้ ให้ทำเครื่องหมายข้อความที่ยังไม่มีหลักฐานรองรับ
  • §32 — โปรเจกต์ที่ยังไม่เสร็จให้ใช้ฟิลด์ status: building ไม่ใช่ซ่อนด้วย draft
  • §35 Definition of Done — งานจะเสร็จก็ต่อเมื่อผ่านทั้ง mobile/desktop, keyboard, type check, lint, เทสต์ และมี metadata ครบ

ผลที่วัดได้คือกฎพวกนี้ถูกบังคับใช้ตอนทำงานจริงโดยไม่ต้องพูดซ้ำ schema ของ content collection ในโปรเจกต์นี้ถูกเขียนให้ เข้มโดยตั้งใจ — frontmatter ผิดคือ build พัง ไม่ใช่หน้าเว็บเพี้ยน — และฟิลด์ที่บรรยายผลลัพธ์จริง อย่าง results ถูกทำให้ optional โดยเจตนา เพื่อไม่ให้มีแรงจูงใจไปแต่งตัวเลขมาใส่ให้ field ครบ

ในโค้ดจริงจะเห็นคอมเมนต์กำกับไว้ตรง ๆ ว่า

/** Only fill in when there is evidence. Never estimate (§31). */
results: z.array(z.string()).optional(),

จุดที่ควรใส่ความเห็นส่วนตัวจึงถูกเว้นว่างไว้เป็น HTML comment แทนที่จะถูกแต่งขึ้นมา (รวมถึงในบทความนี้ด้วย)

บั๊กที่เหลือ: เรื่องที่ไม่เกี่ยวกับ environment

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

เวอร์ชันล่าสุดไม่ใช่เวอร์ชันที่ใช้ได้ — ตอนนั้น TypeScript 7 ออกแล้ว แต่ @astrojs/check ประกาศ peer dependency เป็น ^5 || ^6 และ typescript-eslint เป็น <6.1.0 ถ้าลงเวอร์ชันล่าสุดไปเลย ทั้งคำสั่ง type check และ lint จะพังพร้อมกัน ต้องล็อกไว้ที่ typescript: ^6.0.3 ตามที่เห็นใน package.json

พฤติกรรม default ของ browser แย่งงานเราไป — กด Escape แล้วกล่องค้นหาไม่ปิด เพราะ <input type="search"> เอา Escape ไปเคลียร์ค่าในช่องก่อนแล้วหยุด event ไว้ ทำให้ Escape-to-close ของ <dialog> ไม่ได้ทำงาน ต้องดักเอง

input?.addEventListener('keydown', (event) => {
  if (event.key !== 'Escape') return;
  event.preventDefault();
  dialog?.close();
});

เนื้อหาตัวอย่างเป็น draft: true ทั้งหมด — ซึ่งแปลว่า production build จะได้เว็บเปล่า เทสต์เป็นตัวจับได้ ไม่ใช่คน และนี่คือกรณีที่เทสต์คุ้มค่าที่สุดของวัน: มันจับสิ่งที่ไม่มีใครคิดจะไปตรวจ

สรุปเป็นวิธีทำงาน

สิ่งที่เอาไปใช้กับโปรเจกต์อื่นได้โดยไม่ต้องเป็นเว็บ Astro

  1. แยกให้ออกว่าบั๊กอยู่ชั้นไหน — โค้ดที่เราเขียน, output ที่ build tool สร้าง, หรือสภาพแวดล้อมที่ host ใส่ให้ วิธีหาแต่ละชั้นไม่เหมือนกัน
  2. อย่าเชื่อว่าเทสต์ผ่าน = ใช้งานได้ ถ้าเทสต์ไม่ได้รันในสภาพแวดล้อมเดียวกับของจริง ให้ระบุออกมาเป็นข้อ ๆ เลยว่ามีอะไรบ้างที่ต่างกัน
  3. เทสต์ config ที่ระดับ config ถ้ามันเป็นสิ่งที่ทำให้พังได้
  4. ไปดูของจริงหลัง deploy อย่างน้อยหนึ่งรอบต่อฟีเจอร์ — ยิงดู header, เปิดใช้งานจริง
  5. เขียนสเปกก่อนเริ่ม กฎที่เขียนไว้ล่วงหน้าคือกฎที่ไม่ต้องมาเถียงกันใหม่ทุกครั้ง

AI ทำให้ขั้นตอน “เขียนโค้ดให้ได้ตามที่ต้องการ” สั้นลงมาก แต่ขั้นตอน “ตรวจว่ามันทำงานในโลกจริงหรือเปล่า” ยังยาวเท่าเดิม และตอนนี้มันกลายเป็นส่วนที่กินเวลามากที่สุดของงาน

อ่านต่อ

แชร์:FacebookXLinkedIn

รับบทเรียน AI ที่ใช้ได้จริง ทางอีเมล

หนึ่งบทเรียน AI ที่ใช้ได้จริง การแกะโปรเจกต์ หรือข้อคิดจากการสร้างของ สัปดาห์ละครั้ง

ไม่มีสแปม ยกเลิกได้ทุกเมื่อ