

1/1/1970
ผมทำ i18n ให้รองรับ 20 ภาษาได้ภายใน 3 วันยังไง
สวัสดี! เพิ่งทำภารกิจใหญ่เสร็จไปหมาดๆ คือแปล Foony ให้รองรับ 20 ภาษาแตกต่างกัน งานนี้ต้องไปยุ่งแทบจะทุกไฟล์ใน codebase เลย แต่ก็ยังเก็บให้เรียบได้ภายในแค่ 3 วัน
ด้านล่างนี้จะเล่าให้ฟังทีละส่วนว่าทำยังไง ตัวเลขต่างๆ ที่ตามมาจากการเปลี่ยนแปลงรอบนี้ แล้วก็เหตุผลว่าทำไมถึงเลือกเขียนไลบรารีแปลภาษาเองอีกรอบ แทนที่จะใช้ตัวมาตรฐานของวงการ
ทำไมไม่ใช้ i18next ?
ตอนแรกที่เริ่มมองเรื่องการทำหลายภาษา ก็ลองดูตัวมาตรฐานในวงการก่อนเลยคือ i18next กับ react-i18next
แต่สุดท้ายเลือกโฟกัสที่เรื่อง ให้ AI ดูแลต่อได้ง่าย แทน i18next ทรงพลังก็จริง แต่ที่ API มีหลายแบบมากๆ ทำให้ LLM ชอบมโนหรือเขียนโค้ดไม่สอดคล้องกันได้ง่าย พอผมบีบให้ไลบรารีเหลือแค่ t() กับ interpolate() แบบเรียบง่าย ก็มั่นใจได้ว่า agent กว่า 10 ตัวที่รันขนานกันจะเขียนโค้ดที่ type-safe 100% ได้แทบไม่ต้องให้คนมาคอยแก้
ผมก็แอบกลัวการไปผูกชีวิตกับ ecosystem ใหญ่ๆ ที่วันดีคืนดีอาจปล่อย breaking change มาอีก ก่อนหน้านี้เคยโดนย้ายเวอร์ชันแบบเจ็บๆ อย่าง React Router v5 กับ MUI v4 → v5 มาแล้ว เลยรู้ดีว่าการพัง backward compatibility แบบรวดเร็วเนี่ยเป็นเรื่องปกติมากในโลก JavaScript ค่าเจ็บปวดของการค่อยๆ เพิ่มฟีเจอร์อย่างการทำพหูพจน์ทีหลัง ยังถูกกว่าค่าต้องมานั่ง migrate โค้ด 139k บรรทัดด้วยมือในตอนนี้เยอะ
เลยอยากได้อะไรที่โคตรเรียบง่าย เบามาก แล้วก็ออกแบบมาตรงกับความต้องการของทีมจริงๆ
ก็เลยเขียนเองซะเลย
ผมเลยทำชุดย่อยขนาดแค่ 3 KB ที่ออกแบบมาแบบมีข้อจำกัดชัดเจน เพื่อให้ AI ช่วย refactor โค้ดได้อย่างแม่นยำและค่อนข้างอัตโนมัติ ผลคือวิศวกรคนเดียวอย่างผมสามารถทำงานเท่าทีม 5 คนที่ต้องใช้เวลา 3 สัปดาห์ ให้เสร็จได้ภายใน 3 วัน
การลงมือทำเวอร์ชัน custom เอง
ผมออกแบบ i18n library ที่เล็กสุดๆ ขนาดรวม gzip แล้วประมาณ 3 KB เท่านั้น โดยมันโผล่ออกมาให้ใช้แค่สองฟังก์ชันหลักคือ getTranslation() สำหรับบริบทที่ไม่ใช่ React แล้วก็ hook useTranslation() สำหรับใช้ใน component
สองตัวนี้จะคืน t() สำหรับการแทนที่สตริงแบบง่ายๆ แล้วก็ interpolate() สำหรับเคสที่ต้องเอา React component ไปยัดในสตริงแปล เช่นลิงก์หรือไอคอน ทั้งคู่รองรับการแทนที่ตัวแปร เช่น "Hello {{thing}}", {thing: 'World'}
ตัวอย่างฟังก์ชัน t() แกนหลักมีหน้าตาประมาณนี้:
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
ส่วน React hook ก็ประมาณนี้:
export function useTranslation() {
const [language] = useLanguage();
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
แกนของทั้งไลบรารีมีแค่ประมาณ 580 บรรทัด เอง โดยมันจัดการเรื่องต่างๆ ให้ครบ เช่น
- lazy-load ไฟล์แปลภาษา เพื่อไม่ต้องส่งทั้ง 20 ภาษาไปให้ผู้ใช้ทุกคน
- แยกโค้ดของ translation ตาม "namespace" (เช่น
common,misc,games/{gameId}) - มี locale แบบ "debug" ที่แสดง key ดิบๆ เพื่อให้ผมเช็กได้ว่าทุกอย่างต่อสายถูกต้อง
เพื่อให้ระบบดูแลง่ายในระยะยาว ผมเลยเขียนเอกสารแบบละเอียดไว้ใน shared/src/i18n/README.md ครอบคลุมตั้งแต่โครงสร้างไฟล์ ไปจนถึงตัวอย่างการใช้งานทั้งฝั่ง client และ server เพราะเราไม่ได้ใช้ไลบรารีมาตรฐาน การมี reference แบบนี้สำคัญมากสำหรับรับสมาชิกทีมใหม่เข้ามา หรือเอาไว้นึกเตือนตัวเอง (หรือ LLM ตัวอื่นๆ) ในอนาคตว่ามันทำงานยังไง
มาดูตัวเลขกันหน่อย
เพื่อให้เห็นภาพสเกลงานอัปเดตรอบนี้ นี่คือสิ่งที่เปลี่ยนไปใน codebase:
- รองรับ 20 ภาษา (แถมมี debug locale สำหรับตอน dev ด้วย)
- มีไฟล์ locale ใหม่ 360 ไฟล์
- โค้ดส่วน translation รวม 139,031 บรรทัด
- เพิ่มการเรียก
t()ฝั่ง client ไปทั้งหมด 3,938 จุด - แก้ไฟล์ซอร์สโค้ดไป 728 ไฟล์
- ไฟล์ภาษาอังกฤษที่เป็น source of truth ทั้งหมด 18 ไฟล์ (เกม 16 ไฟล์ + common + misc)
จัดการทุกอย่างด้วยเหล่า agent
ถ้าต้องมานั่งทำทุกอย่างด้วยมือ คงกินเวลาเป็นเดือนๆ กับงานรูทีนที่น่าเบื่อสุดๆ แทนที่จะเป็นแบบนั้น ผมเลยใช้ Cursor agents กว่าหนึ่งโหลมาช่วยยกของหนักให้รันพร้อมๆ กัน
เริ่มจากการแบ่ง codebase ออกเป็น “section” ตามโฟลเดอร์ต่างๆ เกมแต่ละเกมบน Foony จะมีโฟลเดอร์ของตัวเอง และมี translation namespace ของตัวเอง แบบนี้จะช่วยให้ขนาดตอนโหลดครั้งแรกเล็กลง เพราะเราจะโหลดเฉพาะสตริงแปลของเกมที่กำลังเล่นอยู่เท่านั้น
ผมรัน Cursor agents หลายตัวพร้อมกัน แล้วมอบหมาย section ให้แต่ละตัวไปดูแล เช่น “แปลงเกมหมากรุกให้ใช้ระบบแปลภาษา” agent ก็จะไล่เปิดทีละไฟล์ หา string ที่โผล่ให้ผู้ใช้เห็น แล้วแทนที่ด้วย t('games/chess/some.key')
จากนั้น agent จะเอา key นั้นไปเพิ่มในไฟล์ locale ภาษาอังกฤษที่ถูกต้อง พร้อมใส่คอมเมนต์ JSDoc อธิบายว่า string นั้นคืออะไร แล้วอยู่ตรงไหน ข้อมูลบริบทพวกนี้สำคัญมากตอนเอาไปแปลเป็นภาษาอื่นๆ เพราะช่วยให้ LLM แยกออกได้ว่า “Save” ที่เห็นหมายถึง “บันทึกการตั้งค่าเกม” หรือ “บันทึกภาพวาดใน Draw & Guess” กันแน่
การตรวจคุณภาพ
ผมไล่รีวิวโค้ดที่ agent สร้างขึ้นทั้งหมดแบบเร็วๆ ผลงานของพวกมันดีเกินคาดนะ แต่ก็มีพลาดบ้างเป็นครั้งคราว อย่างเช่นเอา hook useTranslation ไปวางหลัง return ตัวแรกซะอย่างนั้น
การทำให้ระบบแปลภาษาเป็น strongly-typed ช่วยได้เยอะมาก ทำให้มั่นใจได้ว่าแต่ละ locale มี key ครบทุกอันที่ต้องมี (และไม่มี key แปลกปลอม) แถมยังช่วยเช็กได้ด้วยว่าการเรียก t() กับ interpolate() ใช้สตริงที่มีอยู่จริงในระบบแปล
type system จะดึง key ที่เป็นไปได้ทั้งหมดออกมาจากไฟล์ภาษาอังกฤษต้นฉบับ:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
ผลที่ได้คือ autocomplete ของ TypeScript ทำงานสมบูรณ์สุดๆ แล้วถ้าพิมพ์ key ผิดแม้แต่นิดเดียวก็จะโดนจับได้ตั้งแต่ตอน compile agent เลยไม่สามารถเผลอเขียนอะไรอย่าง t('games/ches/name') ได้ เพราะ TypeScript จะฟ้องทันที
ขั้นตอนการแปลภาษา
พอแปลงฝั่งภาษาอังกฤษเสร็จเรียบร้อย ผมก็แบ่งงานแปล locale ที่เหลือออกเป็นชิ้นย่อยๆ ให้แต่ละ agent รับผิดชอบไฟล์ locale ภาษาอังกฤษทีละไฟล์ แล้วแปลงมันไปเป็นภาษาที่กำหนด
ตัวอย่างเช่น ผมให้ prompt กับ agent ประมาณนี้:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
ผมเคยคิดจะให้ Cursor เขียนสคริปต์สำหรับส่งไฟล์แต่ละไฟล์เข้า LLM แล้วให้มันช่วยสร้างทุกอย่างให้ แต่สุดท้ายอยากประหยัดค่า LLM มากกว่า การใช้สคริปต์ที่อัปเดตเฉพาะสตริงที่ยังไม่มีคำแปลอยู่แล้วเลยเป็นทางเลือกที่ดีกว่า และน่าจะใช้แนวทางใกล้ๆ กันนี้อีกในอนาคต ผมเองก็อยากติดตามให้ได้ว่าสตริงไหนต้องอัปเดตหรือยังไม่ได้แปลอยู่ แต่ตอนนี้ยังอยากให้ระบบมันเรียบง่ายก่อน อาจจะย้ายงานแปลทั้งหมดไปอยู่ในฐานข้อมูลหรืออะไรทำนองนั้นทีหลังก็ได้
ผมเพิ่ม locale แบบ “debug” ที่ใช้ได้เฉพาะตอน develop ด้วย เอาไว้ดูว่าสตริงไหนถูกแทนที่แล้วบ้าง เพื่อเช็กว่าระบบทำงานปกติ (แถมมันดูเท่ดีด้วย) เวลาใช้ debug locale ฟังก์ชัน t() จะคืนค่าเป็น key ที่หุ้มด้วยวงเล็บแบบนี้:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
แทนที่จะเห็นข้อความ “Welcome to Foony!” คุณจะเห็นเป็น ⟦welcome⟧ แทน ทำให้สังเกตสตริงที่ยังไม่ได้แปลหรือผูก key ผิดได้ง่ายมาก
สุดท้ายก็ให้อีก agent ไปทำระบบ route แบบ /{locale}/** เพื่อให้ URL อย่าง /ja/games/chess วิ่งไปหน้าภาษาให้ถูกต้อง (ในตัวอย่างนี้ก็คือภาษาญี่ปุ่น)
แล้วบล็อกล่ะ จะแปลยังไง
การแปลสตริงใน UI นั้นอีกเรื่องหนึ่ง แต่แล้วบล็อกโพสต์ล่ะจะทำยังไง ผมไม่อยากต้องสปิน agent เพิ่มขึ้นมาอีกเยอะๆ แค่เพื่อแปลบล็อกทั้งหมดของตัวเอง
เลยแก้ปัญหาด้วยการให้ agent ตัวหนึ่งเขียนสคริปต์ (scripts/src/generateBlogTranslations.ts) ขึ้นมา จัดการทุกอย่างแบบอัตโนมัติ
หลักการทำงานก็ประมาณนี้:
- สแกนโฟลเดอร์
client/src/posts/enหาไฟล์ MDX ภาษาอังกฤษ - ตรวจดูว่าในโฟลเดอร์ locale อื่นๆ (เช่น
posts/ja,posts/es) ขาดไฟล์แปลตัวไหนบ้าง - ถ้าพบว่าไฟล์แปลหายไป ก็จะอ่านเนื้อหาภาษาอังกฤษ แล้วส่งเข้า Gemini 3 Pro Preview พร้อม prompt เฉพาะกิจ ให้ช่วยแปลโดยคงรูปแบบ Markdown เอาไว้
- จากนั้นก็เซฟไฟล์ใหม่ไปไว้ในตำแหน่งที่ถูกต้อง
ฝั่ง frontend ผมใช้ import.meta.glob สำหรับ import ไฟล์ MDX เหล่านี้แบบ dynamic จากนั้น component PostPage ก็แค่เช็ก locale ปัจจุบันของผู้ใช้ แล้ว lazy-load ไฟล์ MDX ให้ตรงภาษา ถ้าภาษาไหนยังไม่มีคำแปล (เพราะผมยังไม่ได้รันสคริปต์สร้างไฟล์) ก็จะ fallback กลับไปใช้ภาษาอังกฤษอย่างนุ่มนวล
สรุป
มาถึงตรงนี้ เว็บไซต์ทั้งเว็บก็รองรับครบทั้ง 20 locale แบบใช้งานได้จริงแล้ว!
3 วันที่ผ่านมาเรียกได้ว่าโหดใช้ได้ แต่ผลลัพธ์คือเว็บที่ถูกแปลครบทุกภาษา จนผู้ใช้จากทั่วโลกเข้าแล้วรู้สึกเหมือนเว็บทำมาเพื่อภาษาของตัวเองจริงๆ การเลือกสร้างไลบรารีเล็กๆ แบบ custom แล้วใช้ AI agents มาช่วยจัดการงาน refactor น่าเบื่อๆ ทำให้ผมทำสิ่งที่เมื่อปีก่อนยังแทบเป็นไปไม่ได้เลยสำเร็จลงได้ นั่นคือทำ i18n ให้เว็บใหญ่ซับซ้อนเสร็จใน 3 วันโดยใช้วิศวกรคนเดียว อนาคตของการเขียนโปรแกรมไม่ใช่แค่ “พิมพ์โค้ดได้เร็ว” อีกต่อไป แต่คือการออกแบบวงออร์เคสตร้าให้เหล่า AI agent ทำงานร่วมกัน แล้วเราต้องมีความเข้าใจโดเมนลึกพอที่จะตรวจและยืนยันผลลัพธ์ของพวกมันได้