โหมดมืด
บทที่ 15 — Internationalization (i18n) + Localization
🟡 ก่อนอ่าน: ควรผ่านบท 1 (Components + Templates), บท 2 (Signals + State), บท 3 (Services + DI), บท 4 (Routing) และบท 9 (SSR) มาก่อน — โดยเฉพาะ SSR เพราะส่วน Transloco setup ต้องระวัง
localStorage(พื้นที่เก็บข้อมูลในเบราว์เซอร์) /navigator(object ข้อมูลเบราว์เซอร์) ที่รันบน server ไม่ได้
หลังจบบท คุณจะ:
- แยก i18n vs l10n vs g11n
- ใช้
@angular/localize(built-in compile-time) - ใช้ Transloco / ngx-translate (runtime switch)
- จัดการ pluralization, gender, date/number format ต่อ locale
- รองรับ RTL (Right-to-Left) สำหรับ Arabic/Hebrew
- Strategy: ต้องเลือก built-in หรือ library ตอนไหน
ใช้เวลา 2-3 ชั่วโมง
1. ทำไม i18n สำคัญ
การรองรับหลายภาษาไม่ใช่แค่ "ของแถม" — ผู้ใช้อินเทอร์เน็ตส่วนใหญ่ไม่ได้ใช้ภาษาอังกฤษเป็นหลัก และมักเลือกซื้อจากเว็บที่เป็นภาษาตัวเอง ก่อนเริ่มต้องเข้าใจศัพท์ที่สับสนกันบ่อย (i18n/l10n/g11n) และรู้ว่าการแปลภาษาที่แท้จริงครอบคลุมมากกว่าแค่คำ — รวมถึงรูปแบบวันที่ ตัวเลข สกุลเงิน และทิศทางการอ่าน:
text
🌏 หลักการ:
- > 75% ของผู้ใช้อินเทอร์เน็ตไม่ใช้ภาษาอังกฤษเป็นหลัก
- 56% ผู้ซื้อใน e-commerce ต้องการเว็บภาษาตัวเอง
- Enterprise: บังคับ EU multilingual
📌 ศัพท์:
- i18n — Internationalization (เตรียม code ให้รองรับหลายภาษา)
- l10n — Localization (แปลภาษา + ปรับ format)
- g11n — Globalization (เตรียม + localize + cultural)
- t9n — Translation (แค่แปล)l10n ครอบคลุมอะไรบ้าง (ไม่ใช่แค่แปลคำ)
text
✅ Text strings (UI labels, messages) — ข้อความบนหน้าจอ
✅ Plural rules (1 item / 2 items / ...) — กฎพหูพจน์
✅ Gender (he/she/they) — การกำหนดเพศ
✅ Date format (12/25/2026 vs 25/12/2026 vs 2026年12月25日) — รูปแบบวันที่
✅ Number format (1,000.50 vs 1.000,50 vs ١٬٠٠٠٫٥٠) — รูปแบบตัวเลข
✅ Currency ($100 vs ¥100 vs ₿100) — สกุลเงิน
✅ Time zone — เขตเวลา
✅ Calendar (Gregorian vs Buddhist vs Hijri) — ระบบปฏิทิน
✅ Address format — รูปแบบที่อยู่
✅ Phone format — รูปแบบเบอร์โทร
✅ RTL (Arabic, Hebrew, Persian, Urdu) — ภาษาที่อ่านจากขวาไปซ้าย
✅ Honorifics (Japanese: -san, -sama) — คำนำหน้านาม
✅ Cultural icons/colors (red = lucky CN / danger US) — ไอคอน/สีตามวัฒนธรรม2. 2 แนวทางใน Angular
text
A. @angular/localize (built-in, compile-time)
- 1 build = 1 locale (build 1 ครั้ง ได้ 1 ภาษา)
- ใช้ Angular i18n marker (i18n attribute)
- File: XLIFF / XMB (รูปแบบไฟล์มาตรฐานสำหรับเก็บคำแปล ที่นักแปลรับไปใช้งาน)
- SEO-friendly
- Performance: pre-rendered HTML, no runtime overhead (ไม่มีค่าใช้จ่ายตอนรัน)
❌ User switch language = page reload + load new bundle (ผู้ใช้เปลี่ยนภาษา = โหลดหน้าใหม่)
B. ngx-translate / Transloco (runtime, library)
- Single bundle for all locales (bundle เดียวรองรับทุกภาษา)
- JSON files load at runtime (โหลดไฟล์แปลตอนรัน)
- User switch instantly (สลับภาษาได้ทันที)
- More flexible (lazy load namespace) (ยืดหยุ่นกว่า — โหลด namespace แบบ lazy)
❌ Larger bundle (translations in JS) (bundle ใหญ่กว่า)
❌ Slight runtime cost (มีค่าใช้จ่ายตอนรันเล็กน้อย)เลือกแบบไหน?
text
Built-in @angular/localize:
✅ Public-facing app (SEO ต้องการ)
✅ Locale ไม่บ่อยเปลี่ยน
✅ Few locales (2-5) — มีไม่กี่ภาษา
✅ Heavy text content (blog, docs) — เนื้อหาข้อความเยอะ (บล็อก, เอกสาร)
Transloco / ngx-translate:
✅ Internal app (logged-in user) — แอปภายใน (ผู้ใช้ที่ login แล้ว)
✅ User switch ภาษาบ่อย
✅ Many locales (10+) — มีหลายภาษา (10 ขึ้นไป)
✅ Lazy-load namespaces — โหลดแยกตาม feature
✅ Use shared translations across micro-frontends — แชร์คำแปลข้าม micro-frontend📌 ณ ต้นปี 2026 — Transloco ดูมี active development ต่อเนื่องกว่า, รองรับ standalone/signals API ได้ดี และมี Angular-specific features (เช่น scope, SSR support) — ส่วน ngx-translate ยังมีชุมชนขนาดใหญ่และพบบ่อยใน legacy codebase; นี่เป็นภาพ ณ ช่วงเวลาหนึ่ง ควรตรวจสอบสถานะ maintenance ล่าสุดของทั้งสอง library ก่อนตัดสินใจใช้ในโปรเจกต์ใหม่
3. @angular/localize (Built-in)
Setup
bash
ng add @angular/localize→ Adds @angular/localize package + polyfill
→ (ng add = ติดตั้ง package + ตั้งค่าให้อัตโนมัติ ต่างจาก npm install ที่แค่ดาวน์โหลด package อย่างเดียว)
Mark Text for Translation
i18n attribute คือสัญญาณที่คุณติดบนแท็ก HTML เพื่อบอก Angular ว่า "ข้อความนี้ต้องแปล" เหมือนติดสติกเกอร์สีเหลืองบนเอกสารที่ต้องส่งนักแปล — Angular CLI จะวิ่งไปเก็บข้อความที่มีสติกเกอร์นี้ทั้งหมดออกมาไว้ในไฟล์เดียวเพื่อส่งให้นักแปล
ส่วนที่สำคัญมากคือ @@homeHero ซึ่งเป็น ID คงที่ที่คุณตั้งให้แต่ละข้อความ ถ้าไม่ตั้ง Angular จะ generate ID อัตโนมัติจาก content ของข้อความ ปัญหาคือ ถ้าคุณแก้ข้อความจาก "Welcome to our site" เป็น "Welcome!" ID จะเปลี่ยน — และนักแปลต้องแปลข้อความนั้นใหม่ทั้งหมดทุกภาษา เหมือนเปลี่ยนชื่อไฟล์แล้วต้อง re-link ทุกที่ที่อ้างถึง:
html
<!-- ❌ ไม่มี ID — Angular auto-generate จาก content, เปลี่ยนทุกครั้งที่แก้ข้อความ -->
<p i18n>Hello</p>
<!-- ✅ มี ID คงที่ — แก้ข้อความได้โดยไม่ทำให้งานแปลหาย -->
<p i18n="@@greet">Hello</p>html
<!-- Simple -->
<p i18n>Hello, World!</p>
<!-- With meaning + description (syntax: meaning|description@@id) -->
<p i18n="Home page hero message@@homeHero">Welcome to our site</p>
<!-- Plural -->
<p i18n>{count, plural, =0 {No items} =1 {1 item} other {{{count}} items}}</p>
<!-- Select (gender) -->
<p i18n>{gender, select, male {He} female {She} other {They}}</p>
<!-- Attribute -->
<img src="logo.svg" i18n-title title="My logo">
<input i18n-placeholder placeholder="Enter email">
<!-- Multiple attributes -->
<button
i18n="@@saveBtn"
i18n-aria-label="@@saveBtnAria"
aria-label="Save changes"
>
Save
</button>Mark in TypeScript
$localize ทำงานเหมือน template literal ธรรมดาที่คุณรู้จัก เช่น `Hello, ${name}!` แต่มีตัว $localize นำหน้า — มันคือ "ป้ายสีไฮไลต์" ที่ Angular CLI อ่านตอน build แล้วดึงข้อความออกไปใส่ไฟล์แปล เหมือนช่างแปลที่ใช้ highlighter mark ประโยคในต้นฉบับก่อนแปล ตอนรัน Angular จะใส่ค่าที่แปลแล้วกลับเข้ามาแทนที่
polyfill โดยทั่วไปคือโค้ดเสริมที่เติม feature ที่ environment (เบราว์เซอร์หรือ runtime) ไม่มีให้ในตัว ให้ใช้งานได้เหมือนมีมาแต่แรก — ในบริบทนี้ polyfill คือโค้ดที่ ng add @angular/localize เพิ่มให้อัตโนมัติ เพื่อให้ $localize มีอยู่ใน global scope ก่อน build เราจึงไม่ต้อง import เอง แค่เรียกใช้ได้เลย
📖
$localizeคืออะไร — เป็น tagged template literal ของ Angular (เหมือนhtml`...`) ที่บอกตัว build ให้ "ดึงข้อความนี้ออกไปแปล" Angular CLI จะอ่านโค้ดตอน build แล้ว generate ไฟล์ XLIFF ให้นักแปล — ส่วน:@@errorMsg:คือการตั้ง id ตายตัว (ไม่ให้ id เปลี่ยนเมื่อข้อความ source เปลี่ยน)⚠️ build-time vs runtime (สำคัญมาก):
@angular/localize= build-time —$localizeถูก "ฝัง" ค่าแปลเข้า bundle ตอนng build --localize→ 1 build ต่อ 1 ภาษา, สลับภาษา = โหลด bundle ใหม่- Transloco/ngx-translate = runtime — โหลดไฟล์แปลตอน runtime, สลับภาษาได้ทันทีโดยไม่โหลดหน้าใหม่
- ทั้งสองมีจุดอ่อนของตัวเอง — เลือกตามตาราง section 2
typescript
// ⚠️ ไม่ต้อง import $localize เอง — ng add @angular/localize เพิ่ม polyfill ให้อัตโนมัติ
// $localize เป็น global tagged template literal ที่ Angular inject ผ่าน polyfill
// (ถ้าต้องการ type support ให้ใส่ /// <reference types="@angular/localize" /> ใน tsconfig)
import { Component, signal, computed } from '@angular/core';
@Component({...})
export class GreetingComponent {
name = signal('World');
// `$localize` tag = mark string นี้ให้ Angular extract ออกไปแปล
greeting = computed(() => $localize`Hello, ${this.name()}!`);
showError() {
// `:@@errorMsg:` = ตั้ง custom id ให้ trans-unit (stable id)
alert($localize`:@@errorMsg:Something went wrong`);
}
}Extract Messages
bash
ng extract-i18n --output-path=src/locale→ Generates src/locale/messages.xlf (XLIFF 1.2 default)
XLIFF (XML Localization Interchange File Format) = ไฟล์ XML มาตรฐานที่เก็บข้อความต้นฉบับ + คำแปล
Flow: นักพัฒนาสร้าง → ส่งให้นักแปล → นักแปลเติมคำแปล → นักพัฒนาใช้ build
xml
<!-- messages.xlf -->
<xliff version="1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="homeHero" datatype="html">
<source>Welcome to our site</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/home.component.html</context>
<context context-type="linenumber">3</context>
</context-group>
<note priority="1" from="description">Home page hero message</note>
</trans-unit>
</body>
</file>
</xliff>Translate
ส่ง XLIFF ให้นักแปล → ได้คืน messages.th.xlf:
xml
<trans-unit id="homeHero">
<source>Welcome to our site</source>
<target>ยินดีต้อนรับสู่เว็บไซต์ของเรา</target>
</trans-unit>Configure Locales
json
// angular.json — ไฟล์ config หลักของโปรเจกต์ Angular อยู่ที่ root ของโปรเจกต์
// เพิ่มส่วน "i18n" ใต้ "projects" → ชื่อโปรเจกต์ของคุณ
{
"projects": {
"my-app": {
"i18n": {
"sourceLocale": "en",
"locales": {
"th": {
"translation": "src/locale/messages.th.xlf",
"baseHref": "/th/"
},
"ja": {
"translation": "src/locale/messages.ja.xlf",
"baseHref": "/ja/"
}
}
},
"architect": {
"build": {
"configurations": {
"production": {
"localize": true
},
"th": {
"localize": ["th"]
},
"ja": {
"localize": ["ja"]
}
}
}
}
}
}
}Build Per Locale
bash
# All locales
ng build --localize
# Single locale
ng build --configuration=th
# Output:
# dist/my-app/
# en/ ← English
# th/ ← Thai
# ja/ ← JapaneseServe in Dev
bash
ng serve --configuration=thDeploy Per Locale
text
Option A: Sub-path (recommended)
example.com/en/
example.com/th/
example.com/ja/
Option B: Subdomain
en.example.com
th.example.com
ja.example.com
Option C: Country domain
example.com (US)
example.co.th (Thailand)nginx
# Detect Accept-Language + redirect
location = / {
if ($http_accept_language ~ "^th") {
return 302 /th/;
}
if ($http_accept_language ~ "^ja") {
return 302 /ja/;
}
return 302 /en/;
}Built-in Pipes (Locale-aware)
html
<!-- Use locale of build -->
<p>{{ today | date }}</p> <!-- Aug 5, 2026 (en) / 5 ส.ค. 2026 (th) -->
<!-- ⚠️ DatePipe + locale 'th' ยังออกปี ค.ศ. ไม่ใช่ พ.ศ. — ต้องใช้ Intl API ด้วย 'th-TH-u-ca-buddhist' ถ้าต้องการ พ.ศ. -->
<p>{{ price | currency:'USD' }}</p> <!-- $100.00 (en) / US$100.00 (th) -->
<p>{{ ratio | percent:'1.0-2' }}</p> <!-- 50% -->
<p>{{ count | number:'1.0-2' }}</p> <!-- 1,234.50 (en) / 1.234,50 (de) -->→ Pipes ใช้ LOCALE_ID provider — ตั้งโดย --localize
4. Transloco (Runtime Switching)
ถ้าต้องการให้ผู้ใช้ "สลับภาษาได้ทันที" โดยไม่ต้องโหลดหน้าใหม่ ให้ใช้ไลบรารีแบบ runtime อย่าง Transloco — มันเก็บคำแปลไว้ในไฟล์ JSON ที่โหลดตอนทำงาน ทำให้เปลี่ยนภาษากลางคันได้ลื่นไหล มาดูตั้งแต่ติดตั้ง จัดไฟล์แปล ไปจนถึงใช้ในเทมเพลต:
bash
ng add @jsverse/transloco→ Prompts:
text
? Which languages do you need? en, th, ja
? Use SSR? No
? Use Angular i18n built-in fallback? NoSetup
📌 ลำดับการทำ (สำคัญ): โค้ดนี้ import
TranslocoHttpLoaderจากไฟล์transloco-loader.tsซึ่งเรายังไม่ได้สร้าง — ให้ข้ามไปสร้างไฟล์นั้นก่อนที่หัวข้อ "Loader" ด้านล่าง (จริง ๆng add @jsverse/translocoมักสร้างให้อัตโนมัติอยู่แล้ว) แล้วค่อยกลับมาตั้งค่าส่วนนี้ ไม่งั้นจะเจอ error ว่าหาไฟล์ไม่เจอ
typescript
// app.config.ts
// ⚠️ Step 1: สร้าง transloco-loader.ts ที่หัวข้อ "Loader" ด้านล่างก่อน
// ⚠️ Step 2: กลับมาตั้งค่าส่วนนี้ (ไม่งั้น import บรรทัดล่างจะ error เพราะยังไม่มีไฟล์)
import { provideTransloco } from '@jsverse/transloco';
import { TranslocoHttpLoader } from './transloco-loader';
export const appConfig: ApplicationConfig = {
providers: [
provideTransloco({
config: {
availableLangs: ['en', 'th', 'ja'],
defaultLang: 'en',
fallbackLang: 'en',
reRenderOnLangChange: true,
prodMode: !isDevMode()
},
loader: TranslocoHttpLoader
})
]
};Translation Files
text
assets/i18n/
├── en.json
├── th.json
└── ja.jsonjson
// assets/i18n/en.json
{
"welcome": "Welcome",
"greeting": "Hello, {{name}}!",
"items": {
"zero": "No items",
"one": "1 item",
"other": "{{count}} items"
},
"header": {
"title": "My App",
"menu": {
"home": "Home",
"about": "About"
}
}
}json
// assets/i18n/th.json
{
"welcome": "ยินดีต้อนรับ",
"greeting": "สวัสดี {{name}}!",
"items": {
"zero": "ไม่มีรายการ",
"one": "1 รายการ",
"other": "{{count}} รายการ"
},
"header": {
"title": "แอปของฉัน",
"menu": {
"home": "หน้าแรก",
"about": "เกี่ยวกับ"
}
}
}Use in Template
typescript
// Transloco 7+ รองรับ granular imports — แนะนำให้ใช้ใน standalone components เพื่อ tree-shaking ที่ดีกว่า:
// import { TranslocoDirective, TranslocoPipe } from '@jsverse/transloco';
// imports: [TranslocoDirective, TranslocoPipe, RouterLink]
// ตัวอย่างด้านล่างใช้ TranslocoModule (import ทั้ง module) เพื่อความกระชับ
import { TranslocoModule } from '@jsverse/transloco';
import { RouterLink } from '@angular/router';
@Component({
standalone: true,
imports: [TranslocoModule, RouterLink],
template: `
<!-- Pipe (recommended) -->
<h1>{{ 'welcome' | transloco }}</h1>
<!-- Pipe with params -->
<p>{{ 'greeting' | transloco:{ name: 'Anna' } }}</p>
<!-- Nested key -->
<h2>{{ 'header.title' | transloco }}</h2>
<!-- Structural directive ของ Transloco (ไม่ใช่ Angular built-in) จึงยังใช้ * prefix
ข้อดี: subscribe ครั้งเดียว ดีกว่าใช้ pipe หลายตัว (ลด re-render) -->
<ng-container *transloco="let t">
<h1>{{ t('welcome') }}</h1>
<p>{{ t('greeting', { name: user.name }) }}</p>
<nav>
<a [routerLink]="'/'">{{ t('header.menu.home') }}</a>
<a [routerLink]="'/about'">{{ t('header.menu.about') }}</a>
</nav>
</ng-container>
<!-- With scope (lazy load namespace) — scope 'admin' จะ prefix key ให้อัตโนมัติ -->
<ng-container *transloco="let t; scope: 'admin'">
<h1>{{ t('title') }}</h1> <!-- ไม่ต้องเขียน 'admin.title' — scope prefix ให้แล้ว -->
</ng-container>
`
})
export class HomeComponent {
user = { name: 'Anna' };
}Use in TypeScript
typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslocoService } from '@jsverse/transloco';
@Component({...})
export class MyComponent {
private translocoService = inject(TranslocoService);
// private notify = inject(NotificationService); // inject notification service ของคุณเอง
showError() {
const msg = this.translocoService.translate('errors.required');
console.error(msg); // แทน this.notify.error(msg) — inject NotificationService เองตามโปรเจกต์
}
changeLanguage(lang: string) {
this.translocoService.setActiveLang(lang);
}
activeLang = toSignal(
this.translocoService.langChanges$,
{ initialValue: this.translocoService.getActiveLang() }
);
}Loader
typescript
// transloco-loader.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Translation, TranslocoLoader } from '@jsverse/transloco';
@Injectable({ providedIn: 'root' })
export class TranslocoHttpLoader implements TranslocoLoader {
private http = inject(HttpClient);
getTranslation(lang: string) {
return this.http.get<Translation>(`/assets/i18n/${lang}.json`);
}
}Pluralization (Transloco MessageFormat)
ICU MessageFormat คือภาษากลางที่ใช้เขียนข้อความที่เปลี่ยนแปลงตามจำนวน ตัวอย่าง {count, plural, =0 {ไม่มีรายการ} one {1 รายการ} other {# รายการ}} อ่านได้ว่า ถ้า count เท่ากับ 0 ให้แสดง "ไม่มีรายการ" ถ้าเท่ากับ 1 ให้แสดง "1 รายการ" ถ้ามากกว่านั้นให้แสดง "N รายการ" โดยเครื่องหมาย # จะถูกแทนด้วยค่าจริงของ count ให้อัตโนมัติ
ปัญหาคือ Transloco แบบ default ใช้แค่ {{count}} ธรรมดา ซึ่งใส่ค่าลงไปได้ก็จริง แต่ไม่รองรับ logic แบบ "ถ้าจำนวนเท่านี้ให้แสดงอีกแบบ" เราจึงต้องลง plugin เพิ่มเพื่อให้เขียน ICU แบบด้านบนได้
⚠️ Transloco default ใช้
{{name}}(Mustache — ชื่อเรียก syntax{{...}}ที่มาจากรูปร่างคล้ายหนวด) ไม่รองรับ ICU{count, plural, ...}ตรง ๆ — ต้องลง plugin@jsverse/transloco-messageformat(ชื่อใหม่หลังย้ายจาก @ngneat → @jsverse ปี 2024) ก่อน📦 Package name ปี 2026:
@jsverse/transloco+@jsverse/transloco-messageformat(ของเก่า@ngneat/translocoยัง alias ได้แต่ deprecated)
bash
npm install @jsverse/transloco-messageformat
# (legacy: npm install @ngneat/transloco-messageformat)typescript
import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat';
providers: [
provideTransloco({ /* ... */ }),
provideTranslocoMessageformat()
]json
// en.json
{
"items": "{count, plural, =0 {No items} =1 {One item} other {# items}}"
}html
<p>{{ 'items' | transloco:{ count: 5 } }}</p> <!-- "5 items" -->Scope (Lazy Loading)
typescript
// admin/admin.routes.ts
import { provideTranslocoScope } from '@jsverse/transloco';
export const adminRoutes: Routes = [
{
path: '',
component: AdminComponent,
providers: [
provideTranslocoScope('admin') // load only when route activate
]
}
];text
assets/i18n/admin/
├── en.json
├── th.json
└── ja.json→ สำหรับ Micro-Frontend (MF) หรือแอปขนาดใหญ่: แยกไฟล์แปลตาม module/feature
5. Locale Switcher
เมื่อใช้ Transloco แล้ว เรามักทำปุ่มให้ผู้ใช้เลือกภาษาเอง ตัวอย่างนี้สร้างเมนูเลือกภาษา (พร้อมธงและชื่อภาษา) ที่เรียก setActiveLang() เพื่อสลับทันที และจำค่าที่เลือกไว้ใน localStorage เพื่อใช้ครั้งต่อไป:
⚠️ ตัวอย่างนี้ใช้ Angular Material (MatMenuModule, MatButtonModule) — ต้องรัน
ng add @angular/materialก่อน (ดูบทที่ 14) ถ้าไม่มี Material ให้ใช้<select>+<option>แทน
typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslocoService } from '@jsverse/transloco';
import { MatMenuModule } from '@angular/material/menu';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-lang-switch',
standalone: true,
imports: [MatMenuModule, MatButtonModule],
template: `
<button mat-button [matMenuTriggerFor]="menu">
🌐 {{ flag(activeLang()) }} {{ activeLang().toUpperCase() }}
</button>
<mat-menu #menu>
@for (lang of availableLangs; track lang) {
<button mat-menu-item (click)="change(lang)">
{{ flag(lang) }} {{ name(lang) }}
</button>
}
</mat-menu>
`
})
export class LangSwitchComponent {
private translocoService = inject(TranslocoService);
availableLangs = ['en', 'th', 'ja'];
activeLang = toSignal(
this.translocoService.langChanges$,
{ initialValue: this.translocoService.getActiveLang() }
);
flag(lang: string) {
return { en: '🇬🇧', th: '🇹🇭', ja: '🇯🇵' }[lang] ?? '🌐';
}
name(lang: string) {
return { en: 'English', th: 'ไทย', ja: '日本語' }[lang] ?? lang;
}
change(lang: string) {
this.translocoService.setActiveLang(lang);
localStorage.setItem('lang', lang);
document.documentElement.lang = lang;
}
}Persist + Restore
typescript
// app.config.ts — ส่วนของ providers array ใน export const appConfig: ApplicationConfig = { providers: [...] }
// 🔴 อย่าใส่ localStorage/navigator ใน config object โดยตรง — SSR crash
// (server ไม่มี localStorage หรือ navigator → ReferenceError ตอน render)
provideTransloco({
config: {
availableLangs: ['en', 'th', 'ja'],
defaultLang: 'en', // static fallback
// ...
},
loader: TranslocoHttpLoader
})🔴 SSR-safe locale detection:
SSR = Angular รัน code บน server ก่อนส่ง HTML ให้เบราว์เซอร์ — server ไม่มี localStorage หรือ navigator (ของพวกนี้มีแค่ในเบราว์เซอร์) ถ้าเรียกตอน app ตั้งค่า Angular จะ crash ก่อนหน้าจะโหลด ใน section 13 มีโค้ดสำเร็จที่ตรวจก่อนว่ารันบน browser หรือ server ด้วย isPlatformBrowser() — ถ้าใช้ SSR ควรข้ามไปดูก่อน
typescript// ใน APP_INITIALIZER (DI token ของ Angular สำหรับรันโค้ดก่อน app เปิดใช้งาน) หรือ resolver import { PLATFORM_ID, inject } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; function detectInitialLang(): string { const platformId = inject(PLATFORM_ID); if (!isPlatformBrowser(platformId)) { // SSR: อ่านจาก request header (Accept-Language) แทน return 'en'; // หรือ inject Request token } return localStorage.getItem('lang') ?? navigator.language.split('-')[0] ?? 'en'; }
6. Pluralization Deep
การนับพหูพจน์ไม่ได้ง่ายเหมือนภาษาอังกฤษทุกภาษา — บางภาษาไม่มีรูปพหูพจน์เลย (ไทย/ญี่ปุ่น) บางภาษามี 3-4 รูปตามจำนวน (รัสเซีย) เราจึงใช้มาตรฐานสองอย่างนี้ร่วมกัน:
- ICU MessageFormat (International Components for Unicode) — ภาษากลางสำหรับเขียนข้อความที่ขึ้นกับจำนวน/เพศ เช่น
{count, plural, one {1 item} other {# items}} - CLDR (Common Locale Data Repository) — ฐานข้อมูลกฎภาษาของ Unicode ที่บอกว่าแต่ละภาษามีรูปพหูพจน์อย่างไร (zero/one/two/few/many/other) เช่น ภาษาไทยมีแค่
other, ภาษารัสเซียมีถึง 4 รูป
เพื่อให้แปลถูกต้องทุกภาษา:
English (2 forms: one, other)
text
"1 item" / "5 items"Thai, Japanese, Chinese (1 form: other)
text
"1 รายการ" / "5 รายการ"
"1個の項目" / "5個の項目"Slavic (3-4 forms)
text
Russian:
- 1 файл (singular)
- 2-4 файла (few)
- 5-20 файлов (many)
- 21 файл (singular again)CLDR Rules
text
ICU MessageFormat รองรับ Unicode CLDR plural rules:
- zero
- one
- two
- few
- many
- otherลองดูตัวอย่างจริงว่า CLDR ทำงานอย่างไร สมมุติ count = 21 ในภาษารัสเซีย CLDR บอกว่าตัวเลขที่ลงท้ายด้วย 1 (แต่ไม่ใช่ 11) ให้เข้า category one จึงแสดง "21 файл" ซึ่งเป็นรูปเอกพจน์
จุดนี้ทำให้คนที่พูดอังกฤษงง เพราะเลข 21 ดูเหมือน "มากกว่าหนึ่ง" น่าจะเป็นพหูพจน์ แต่กฎภาษารัสเซียไม่คิดแบบนั้น นี่คือเหตุผลที่ไฟล์แปลภาษารัสเซียต้องมีครบทั้ง one, few, และ many
ส่วนภาษาไทยตรงข้ามกันเลย CLDR ระบุว่าไทยมีแค่ category other เดียว ไม่มีการเปลี่ยนรูปตามจำนวน ดังนั้นไฟล์ภาษาไทยไม่ต้องใส่ one, few, หรือ many เลย:
json
// ไฟล์ภาษารัสเซีย (ru.json) — ต้องมีครบสามรูปเพราะ CLDR กำหนดไว้
{
"items": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}"
// ↑ ลงท้าย 1 ยกเว้น 11 ↑ ลงท้าย 2-4 ↑ ลงท้าย 5-20 ↑ เคสอื่นๆ
}
// ไฟล์ภาษาไทย (th.json) — other อย่างเดียวพอ เพราะไทยไม่มีการเปลี่ยนรูปตามจำนวน
{
"items": "{count, plural, =0 {ไม่มีรายการ} other {# รายการ}}"
// ↑ # แทนค่า count อัตโนมัติ
}json
// ⚠️ keys one/few/many คือ CLDR plural category — ใช้งานจริงเฉพาะภาษาที่มี category นั้น
// English source ใช้แค่ one/other (และ =0 สำหรับเคสพิเศษ)
// few/many ใส่ในไฟล์ภาษาที่ต้องการเท่านั้น เช่น ภาษารัสเซีย/โปแลนด์
{
"items": "{count, plural, =0 {No items} one {1 item} other {# items}}"
}Angular Built-in Plural
html
<p i18n>{count, plural, =0 {No items} =1 {1 item} other {{{count}} items}}</p>Transloco Plural
bash
npm install @jsverse/transloco-messageformatjson
{
"items": "{count, plural, =0 {No items} one {1 item} other {{count} items}}"
}html
<p>{{ 'items' | transloco:{ count: 5 } }}</p>7. Number + Date + Currency
นอกจากข้อความ ตัวเลข/วันที่/สกุลเงินก็ต้องแสดงตามแต่ละประเทศ (เช่น 1,234.50 ในอังกฤษ แต่ 1.234,50 ในเยอรมัน) Angular มี pipe ที่ปรับตาม locale ให้ และเบราว์เซอร์ก็มี Intl API ในตัวที่ทรงพลัง (รองรับปฏิทินพุทธ, relative time ฯลฯ) — แนะนำให้ใช้ Intl สำหรับเรื่อง format ที่ไม่ใช่การแปลคำ:
Angular Pipes (CLDR-based)
html
<!-- Date -->
<p>{{ today | date:'medium' }}</p> <!-- Aug 5, 2026, 2:30 PM -->
<p>{{ today | date:'short' }}</p> <!-- 8/5/26, 2:30 PM -->
<p>{{ today | date:'full' }}</p> <!-- Tuesday, August 5, 2026, 2:30:00 PM -->
<p>{{ today | date:'yyyy-MM-dd' }}</p> <!-- 2026-08-05 -->
<!-- Custom locale -->
<p>{{ today | date:'medium':'':'th' }}</p> <!-- ส.ค. 5, 2026 -->
<p>{{ today | date:'medium':'':'ja' }}</p> <!-- 2026/08/05 -->
<!-- Currency -->
<p>{{ price | currency:'THB' }}</p> <!-- THB100.00 -->
<p>{{ price | currency:'JPY':'symbol':'1.0-0' }}</p> <!-- ¥100 -->
<p>{{ price | currency:'EUR':'code' }}</p> <!-- EUR 100.00 -->
<!-- Number -->
<p>{{ count | number:'1.2-2' }}</p> <!-- 1,234.56 -->
<p>{{ count | number:'1.2-2':'de' }}</p> <!-- 1.234,56 (German style) -->
<!-- Percent -->
<p>{{ ratio | percent:'1.0-2' }}</p> <!-- 50% -->Register Additional Locales
typescript
// app.config.ts (standalone pattern — Angular 17+)
import { ApplicationConfig, LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeTh from '@angular/common/locales/th';
import localeJa from '@angular/common/locales/ja';
registerLocaleData(localeTh);
registerLocaleData(localeJa);
export const appConfig: ApplicationConfig = {
providers: [
{ provide: LOCALE_ID, useValue: 'th' } // app-level locale
]
};💡 เลือกใช้แบบไหน — pipe หรือ Intl API?
- ✅ ใช้ Angular pipe (
| date,| currency,| number) เมื่อแสดงผลใน template ตรงๆ — เขียนสั้น อ่านง่าย และ Angular จัดการ locale ให้อัตโนมัติ- ✅ ใช้ Intl API เมื่อต้องการปฏิทินพุทธ (พ.ศ.), ตัวเลขไทย (๑๒๓), relative time (เมื่อวาน/ใน 2 วัน), หรือต้อง format ค่าใน TypeScript โดยไม่ผ่าน template
Intl API (Browser Native, Recommended)
🇹🇭 Thai locale specifics — ต้องระวัง:
- Buddhist calendar (พ.ศ.): ต้องระบุ
calendar: 'buddhist'หรือใช้ locale extension'th-TH-u-ca-buddhist'— ของ default'th'/'th-TH'ใน Angular ใช้ปี ค.ศ. ไม่ใช่ พ.ศ.- Thai digits (๑๒๓): locale extension
'th-TH-u-nu-thai'หรือ optionnumberingSystem: 'thai'- Currency
฿vsTHB: default Angular pipe คืนTHB100.00— ต้องcurrencyDisplay: 'symbol'(Intl) หรือregisterLocaleData(localeTh)ก่อนถึงจะได้฿100.00
typescript
// Date — Buddhist calendar
const fmtBuddhist = new Intl.DateTimeFormat('th-TH-u-ca-buddhist', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
fmtBuddhist.format(new Date()); // "5 สิงหาคม 2569" (พ.ศ.)
// Date — Thai digits + Buddhist
const fmtThaiDigits = new Intl.DateTimeFormat('th-TH-u-ca-buddhist-nu-thai', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
fmtThaiDigits.format(new Date()); // "๕ สิงหาคม ๒๕๖๙"
// Currency THB ออกเป็นสัญลักษณ์ ฿
const baht = new Intl.NumberFormat('th-TH', {
style: 'currency',
currency: 'THB',
currencyDisplay: 'symbol',
});
baht.format(1234.5); // "฿1,234.50"
// วิธีย่อ: ใส่ calendar option ตรง ๆ ใน DateTimeFormat โดยไม่ต้องใช้ locale extension -u-ca-buddhist
const fmt = new Intl.DateTimeFormat('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric',
calendar: 'buddhist',
});
fmt.format(new Date()); // "5 สิงหาคม 2569"
// Number
const num = new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY'
});
num.format(1000); // "¥1,000"
// Intl.RelativeTimeFormat — แสดงเวลาสัมพัทธ์ เช่น 'เมื่อวาน', 'ใน 2 วัน'
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
rtf.format(-1, 'day'); // "yesterday"
rtf.format(2, 'day'); // "in 2 days"
// Intl.ListFormat — รวมรายการเป็นประโยค เช่น 'apple, banana, and orange'
const list = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
list.format(['apple', 'banana', 'orange']); // "apple, banana, and orange"
// Plural rules
const pr = new Intl.PluralRules('en');
pr.select(1); // "one"
pr.select(5); // "other"→ ใช้ Intl API สำหรับงานที่ไม่ใช่การแปลคำ (วันที่, ตัวเลข, สกุลเงิน, การเรียงลำดับ)
8. RTL (Right-to-Left)
บางภาษา (อาหรับ, ฮีบรู, เปอร์เซีย) เขียนจากขวาไปซ้าย ทำให้ทั้งหน้าต้อง "กลับด้าน" — เมนู, ไอคอน, ระยะขอบ ต้องสลับซ้าย-ขวา เคล็ดลับสำคัญคือตั้ง dir="rtl" ที่ <html> และใช้ CSS logical properties (margin-inline-start แทน margin-left) เพื่อให้สไตล์ทำงานได้ทั้งสองทิศทางโดยไม่ต้องเขียนซ้ำ:
text
RTL languages:
- Arabic (ar) — ภาษาอาหรับ
- Hebrew (he) — ภาษาฮีบรู
- Persian/Farsi (fa) — ภาษาเปอร์เซีย/ฟาร์ซี
- Urdu (ur) — ภาษาอูรดู
- Yiddish (yi) — ภาษายิดดิชHTML Direction
html
<html lang="ar" dir="rtl">
<!-- All content mirrors -->
</html>
<html lang="en" dir="ltr">
<!-- Default LTR -->
<span dir="rtl">عربى</span> <!-- mixed -->
</html>Dynamic Switching
typescript
// ⚠️ โค้ดนี้ใช้ global `document` โดยตรง — ใช้ได้เฉพาะ browser-only app
// ถ้าใช้ SSR ให้ใช้ inject(DOCUMENT) แทน ดูตัวอย่างที่ section 13 (DirectionService เต็ม)
@Injectable({ providedIn: 'root' })
export class DirectionService {
private rtlLangs = ['ar', 'he', 'fa', 'ur', 'yi'];
setDirection(lang: string) {
const isRtl = this.rtlLangs.includes(lang);
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
document.documentElement.lang = lang;
}
}
// In language switch
change(lang: string) {
this.translocoService.setActiveLang(lang);
this.dirService.setDirection(lang);
}CSS Logical Properties (RTL-aware)
scss
/* ❌ Physical — break in RTL */
.box {
margin-left: 16px;
padding-right: 8px;
text-align: left;
border-left: 1px solid;
}
/* ✅ Logical — works for both */
.box {
margin-inline-start: 16px; /* left in LTR, right in RTL */
padding-inline-end: 8px;
text-align: start;
border-inline-start: 1px solid;
}CSS logical properties คิดในแง่ "ต้น (start)" และ "ปลาย (end)" ของทิศการอ่าน แทนที่จะยึด "ซ้าย/ขวา" ตายตัว
ใน LTR (อังกฤษ ซึ่งอ่านซ้ายไปขวา) start คือด้านซ้าย ส่วนใน RTL (อาหรับ ซึ่งอ่านขวาไปซ้าย) start คือด้านขวา
ดังนั้น margin-inline-start: 16px แปลว่า "ระยะห่างจากต้นบรรทัด" มันจึงกลายเป็นด้านซ้ายสำหรับอังกฤษ และกลายเป็นด้านขวาสำหรับอาหรับให้เองโดยอัตโนมัติ โดยเราไม่ต้องเขียนโค้ดเพิ่ม
text
LTR (อังกฤษ):
[☰] Home About ← เมนู + hamburger icon อยู่ซ้าย (start)
^
start = ซ้าย
RTL (อาหรับ):
About Home [☰] ← เมนู + hamburger icon ย้ายไปขวา (start) อัตโนมัติ
^
start = ขวาถ้าเขียน margin-left: 16px hamburger icon จะยังอยู่ซ้ายเสมอแม้ในโหมด RTL — ต้องเขียน override เพิ่ม แต่ถ้าใช้ margin-inline-start: 16px ก็ย้ายไปถูกที่ให้เองโดยไม่ต้องแตะโค้ด
| Physical | Logical |
|---|---|
margin-left | margin-inline-start |
margin-right | margin-inline-end |
padding-left | padding-inline-start |
border-left | border-inline-start |
left: 0 | inset-inline-start: 0 |
text-align: left | text-align: start |
float: left | float: inline-start |
Material RTL Support
typescript
// Material auto-detect direction from <html dir> อัตโนมัติ — ไม่ต้อง config เพิ่ม
// ถ้าต้องการ wrap component เฉพาะส่วนให้ใช้ directionality ใน CDK:
// import { Dir } from '@angular/cdk/bidi';
// <div [dir]="currentDir">...</div>Test RTL
javascript
// ทดสอบเร็ว: เปิด DevTools (F12) แล้วพิมพ์คำสั่งนี้ใน Console
document.documentElement.dir = 'rtl';→ ตรวจสอบ: icon position, animation direction, scroll, modal alignment
9. Common Locale Tasks
ส่วนนี้รวมงานเกี่ยวกับ locale ที่เจอบ่อยในแอปจริง พร้อมโค้ดสำเร็จ — แปลข้อความ validation, แปล error code จาก backend, เรียงลำดับตามภาษา (ใช้ Intl.Collator ไม่ใช่ .sort() ธรรมดา) และค้นหาแบบไม่สนเครื่องหมายวรรณยุกต์ (ในทีมจริงมักส่งคำแปลผ่าน TMS (Translation Management System — ระบบจัดการงานแปล) เช่น Crowdin, Lokalise — ดู section 14):
Translate Validator Messages
json
// errors.json (transloco)
{
"errors": {
"required": "{{field}} is required",
"email": "Invalid email",
"minLength": "Minimum {{min}} characters"
}
}typescript
import { inject, Injectable } from '@angular/core';
import { ValidationErrors } from '@angular/forms';
import { TranslocoService } from '@jsverse/transloco';
@Injectable({ providedIn: 'root' })
export class FormErrorService {
private transloco = inject(TranslocoService);
getMessage(field: string, errors: ValidationErrors | null): string {
if (!errors) return '';
const fieldName = this.transloco.translate(`fields.${field}`);
if (errors['required']) {
return this.transloco.translate('errors.required', { field: fieldName });
}
if (errors['email']) {
return this.transloco.translate('errors.email');
}
if (errors['minlength']) {
return this.transloco.translate('errors.minLength', {
min: errors['minlength'].requiredLength
});
}
return this.transloco.translate('errors.invalid');
}
}Translate Backend Error
json
// Backend returns error code
{ "error": "USER_NOT_FOUND" }typescript
// Frontend translate
const msg = this.transloco.translate(`api.errors.${error}`);Locale-aware Sorting
typescript
const names = ['Ångström', 'Brown', 'Çelik', 'Dvořák'];
// ❌ Default sort — wrong for non-ASCII characters (byte-order ไม่ใช่ locale-order)
names.sort(); // อาจเรียงผิดสำหรับอักขระพิเศษเช่น Å, Ç, Ř
// ✅ Intl.Collator — แทนที่ 'sv' ด้วย locale ที่เหมาะกับข้อมูล
// เช่น 'sv' สำหรับ Nordic/European, 'th' สำหรับภาษาไทย, 'ja' สำหรับญี่ปุ่น
const collator = new Intl.Collator('sv');
names.sort(collator.compare);
// Or shorthand
names.sort((a, b) => a.localeCompare(b, 'sv'));
// ตัวอย่างภาษาไทย
const thaiNames = ['กุ้ง', 'อ้อย', 'แนน', 'บิ๊ก'];
thaiNames.sort((a, b) => a.localeCompare(b, 'th'));Locale-aware Search
typescript
function fuzzySearch(items: string[], query: string, locale = 'en'): string[] {
const normalizedQuery = query.toLowerCase().normalize('NFD');
return items.filter(item =>
item.toLowerCase().normalize('NFD').includes(normalizedQuery)
);
}
// "cafe" matches "café" (after NFD normalize)10. SEO + Localized Routing
เว็บหลายภาษาควรให้แต่ละภาษามี URL ของตัวเอง (เช่น /en/..., /th/...) เพื่อให้ search engine เก็บ index แยกและผู้ใช้แชร์ลิงก์ถูกภาษา ส่วนนี้สอนการใส่ locale ใน route, ใช้ resolver สลับภาษาตาม URL และใส่ hreflang tag เพื่อบอก Google ว่ามีหน้าภาษาอื่น:
Route per Locale
typescript
// Approach: locale in URL
export const routes: Routes = [
{
path: ':lang',
children: [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'products/:slug', component: ProductComponent }
],
resolve: { lang: langResolver }
},
{ path: '', redirectTo: '/en', pathMatch: 'full' }
];resolver (ตัวแก้ปัญหาล่วงหน้า) คือโค้ดที่ Angular รันโดยอัตโนมัติ ก่อน component โหลด เหมือน รปภ. ที่ตรวจสอบก่อนให้เข้าห้อง — ในที่นี้ resolver ดึง locale จาก URL (:lang param เช่น /th/about ก็จะได้ th) แล้วตั้งภาษาให้ Transloco ก่อนที่ผู้ใช้จะเห็นหน้า จากนั้น return ค่า lang ให้ component ใช้ต่อผ่าน ActivatedRoute.data
DOCUMENT token = วิธี Angular-safe ในการเข้าถึง document ที่ทำงานได้ทั้งบน browser และ server (SSR) ต่างจากการเรียก document โดยตรงที่จะ crash บน server เพราะ server ไม่มี document object
typescript
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { TranslocoService } from '@jsverse/transloco';
export const langResolver: ResolveFn<string> = (route) => {
const lang = route.paramMap.get('lang') || 'en';
const transloco = inject(TranslocoService);
transloco.setActiveLang(lang);
// ⚠️ resolver รันบน server ด้วยตอน SSR — ใช้ inject(DOCUMENT) แทน global document
// (เรียก document ตรง ๆ จะ crash บน server เพราะไม่มี object นี้) เหมือน DirectionService ด้านบน
inject(DOCUMENT).documentElement.lang = lang;
return lang;
};hreflang Tags (SEO)
html
<!-- index.html -->
<link rel="alternate" hreflang="en" href="https://example.com/en">
<link rel="alternate" hreflang="th" href="https://example.com/th">
<link rel="alternate" hreflang="ja" href="https://example.com/ja">
<link rel="alternate" hreflang="x-default" href="https://example.com">typescript
// ⚠️ Dynamic hreflang — Meta service ใช้ไม่ได้!
// rel/hreflang/href = attribute ของ <link> tag ไม่ใช่ <meta>
// Meta.addTags() รองรับเฉพาะ <meta name|property|content> เท่านั้น
import { DOCUMENT } from '@angular/common';
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class HreflangService {
private doc = inject(DOCUMENT);
private platformId = inject(PLATFORM_ID);
setHreflang(links: { lang: string; href: string }[]) {
if (!isPlatformBrowser(this.platformId)) return; // SSR-safe — บน server ให้ใส่ hreflang ใน index.html แบบ static แทน (ดูตัวอย่างด้านบน) หรือใช้ Meta/TransferState จาก @angular/ssr
// ลบ link เก่าก่อน
this.doc.querySelectorAll('link[rel="alternate"][hreflang]').forEach(el => el.remove());
// ใส่ใหม่
for (const { lang, href } of links) {
const link = this.doc.createElement('link');
link.rel = 'alternate';
link.hreflang = lang;
link.href = href;
this.doc.head.appendChild(link);
}
}
}💡 ทางเลือกที่ง่ายกว่า: ถ้า hreflang ไม่เปลี่ยนต่อ page ให้ใส่ static ใน
index.htmlตอน build (ดูตัวอย่างบนสุดของ section) — ไม่ต้องมี service เลย
Localized URLs (slugs)
text
en: example.com/en/products/laptop
th: example.com/th/สินค้า/แล็ปท็อป
ja: example.com/ja/製品/ラップトップ→ ดีต่อ SEO + user experience
→ ต้องมี slug mapping ใน backend
11. Best Practices
สรุปแนวปฏิบัติที่ช่วยให้งาน i18n ไม่พังในระยะยาว — เช่น อย่า hardcode ข้อความ, อย่าต่อ string เอง (ลำดับคำแต่ละภาษาต่างกัน), เผื่อพื้นที่ให้ข้อความที่ยาวขึ้น (เยอรมันยาวกว่าอังกฤษ ~30%) และทดสอบด้วย pseudo-locale เพื่อจับข้อความที่ลืมแปล:
text
✅ Never hardcode strings in code — extract everything (อย่า hardcode ข้อความลงใน code)
✅ Use keys, not English text, as identifier (e.g. "user.welcome" not "Welcome") — ใช้ key ไม่ใช่ข้อความ
✅ Provide context to translator (@@id|description) — ให้บริบทแก่นักแปล
✅ Group related keys (header.menu.home, header.menu.about) — จัดกลุ่ม key ที่เกี่ยวข้อง
✅ Use scope/namespace for large apps — แยก namespace สำหรับแอปใหญ่
✅ Pluralization with ICU MessageFormat — ใช้ ICU สำหรับพหูพจน์
✅ Use CSS logical properties (RTL-ready) — ใช้ logical properties รองรับ RTL
✅ Test with RTL even if not shipping (catch issues early) — ทดสอบ RTL แม้ไม่ได้ launch ภาษา RTL
✅ Lazy-load translation files (per route/feature) — โหลดไฟล์แปลแบบ lazy
✅ Cache translations in browser (CDN + cache-control) — cache ไฟล์แปลที่ CDN
✅ Run app in pseudo-locale for QA (ƒáƙé ŁόƈáłιƵé) — ทดสอบด้วย pseudo-locale หา hardcoded strings
✅ Set document.documentElement.lang on switch — ตั้ง lang attribute ทุกครั้งที่เปลี่ยนภาษา
❌ Don't translate at component init (race condition) — อย่าแปลตอน component เริ่มต้น (อาจแปลก่อนไฟล์แปลโหลดเสร็จ)
❌ Don't translate variables (translate at display time) — อย่าแปลค่า variable (แปลตอนแสดงผล)
❌ Don't store translated text in DB (store key + translate frontend) — เก็บ key ไม่ใช่ข้อความแปลลง DB
❌ Don't concatenate strings ("Hello " + name + "!") — use template (อย่าต่อ string เอง)
❌ Don't assume word order — อย่าคิดว่าทุกภาษาเรียงคำเหมือนกัน (เยอรมัน: กริยาอยู่ท้าย, ไทย: ไม่มี verb conjugation)
❌ Don't assume text length (German often 30% longer) — เผื่อพื้นที่ ข้อความบางภาษายาวกว่า 30%
❌ Don't use icons alone (cultural — thumbs up = OK in US, rude in some MEA) — อย่าใช้ icon อย่างเดียวPseudo-locale (For QA)
json
// en-XA.json — pseudo
{
"welcome": "[!! Ŵéłƈόɱé !!]",
"items": "[!! {{count}} ítéɱś !!]"
}→ Test app in pseudo — catch hardcoded strings + text overflow
12. ⚠️ Common Mistakes
ตารางนี้รวมข้อผิดพลาดที่พบบ่อยตอนทำ i18n คู่กับวิธีที่ถูกต้อง — หลายข้อเป็นกับดักที่ดูเหมือนทำงานได้ในภาษาอังกฤษ แต่พังทันทีเมื่อเปลี่ยนภาษา:
| ❌ ผิด | ✅ ถูก | หมายเหตุ |
|---|---|---|
Concatenate string "Hello " + name | Use template "Hello {name}" | ลำดับคำแต่ละภาษาต่างกัน |
| Translate UI date manually | Use | date or Intl.DateTimeFormat | pipe/Intl รองรับ locale อัตโนมัติ |
Use Date.toLocaleString() without locale | Pass locale explicitly | ผลขึ้นกับ OS locale ของผู้ใช้ |
Hardcode currency symbol '$' | Use | currency:code | สัญลักษณ์แตกต่างตาม locale |
Format number .toFixed(2) | Use | number (locale-aware) | , และ . สลับกันในบางภาษา |
margin-left everywhere | Use margin-inline-start | physical property พังใน RTL |
| Translate to DB | Store key + translate at runtime | เก็บ key ไม่ใช่ข้อความที่แปลแล้ว |
| Translation in component code | Translation files | ยากต่อการจัดการและส่งนักแปล |
Sort with arr.sort() | Use Intl.Collator | byte-order ไม่ใช่ locale-order |
Compare with === (umlaut/normalize) | Normalize first | é กับ é อาจ ≠ กันก่อน normalize |
Forget lang attribute | Set document.documentElement.lang | screen reader + SEO ต้องการ |
Pluralize manually count + " " + (count === 1 ? "item" : "items") | Use ICU plural | พังทันทีกับภาษาที่มีหลาย plural form |
13. ตัวอย่างเต็ม — Multi-Lang App
รวมทุกอย่างในบทนี้เป็นแอปหลายภาษาที่พร้อมใช้จริง — มีครบทั้งการตรวจหาภาษาเริ่มต้นจากเบราว์เซอร์/localStorage, ตั้งค่า Transloco พร้อม plural, ปุ่มสลับภาษา และจัดการทิศทาง RTL ใช้เป็นแม่แบบของโปรเจกต์หลายภาษาได้เลย:
typescript
// app.config.ts
import { provideTransloco, TranslocoService } from '@jsverse/transloco';
import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat';
import { ApplicationConfig, provideAppInitializer, inject, PLATFORM_ID, isDevMode } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes'; // สร้างโดย Angular CLI ตอน ng new
import { TranslocoHttpLoader } from './transloco-loader';
// ⚠️ อย่าเรียก localStorage/navigator นอก function — server ไม่มี → ReferenceError ตอน SSR
// ต้องเรียกใน APP_INITIALIZER ที่รันหลัง Angular bootstrap (มี PLATFORM_ID check)
// 📖 APP_INITIALIZER / provideAppInitializer = สั่งให้ Angular รันโค้ดนี้ก่อน app เริ่มทำงาน (เตือนความจำจาก section 5 ด้านบน)
// provideAppInitializer (Angular 19+) = วิธีใหม่แทน { provide: APP_INITIALIZER, useFactory: ... } แบบเก่า
// ถ้าใช้ Angular 17-18 ให้ใช้: { provide: APP_INITIALIZER, useFactory: () => () => initLangFactory(), multi: true }
// (useFactory ต้อง return ตัว initializer function จริง ๆ — ถ้าเขียน () => initLangFactory เฉย ๆ จะ return ตัว function โดยไม่เรียก โค้ดจะไม่ทำงาน)
function initLangFactory() {
// 💡 inject() สองบรรทัดนี้เรียกได้เสมอ ไม่ว่ารันบน browser หรือ server (ปลอดภัยทั้งคู่)
// มีแค่ localStorage/navigator ด้านล่างเท่านั้นที่ต้องมี guard isPlatformBrowser ป้องกัน
const platformId = inject(PLATFORM_ID);
const transloco = inject(TranslocoService);
if (!isPlatformBrowser(platformId)) {
// SSR: ถ้าต้องอ่าน Accept-Language ให้ inject REQUEST token (Angular SSR)
return;
}
const saved = localStorage.getItem('lang');
const browser = navigator.language.split('-')[0];
const lang = saved ?? (['en', 'th', 'ja'].includes(browser) ? browser : 'en');
transloco.setActiveLang(lang);
}
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
provideTransloco({
config: {
// Transloco รับได้ทั้ง string[] หรือ {id, label}[]
// ใช้ object form ถ้าจะแสดง label ใน switcher (ดู section 4 ด้านบน ใช้ string form แบบสั้น)
availableLangs: [
{ id: 'en', label: 'English' },
{ id: 'th', label: 'ไทย' },
{ id: 'ja', label: '日本語' }
],
defaultLang: 'en', // ✅ static fallback (SSR-safe)
fallbackLang: 'en',
reRenderOnLangChange: true,
missingHandler: { useFallbackTranslation: true },
prodMode: !isDevMode()
},
loader: TranslocoHttpLoader
}),
provideTranslocoMessageformat(),
// ⭐ ตั้งภาษาตามผู้ใช้หลัง bootstrap (browser only)
provideAppInitializer(initLangFactory)
]
};typescript
// app.component.ts
import { Component, inject, effect } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterOutlet, RouterLink } from '@angular/router';
import { TranslocoModule, TranslocoService } from '@jsverse/transloco';
import { DirectionService } from './direction.service';
import { LangSwitchComponent } from './lang-switch.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, TranslocoModule, LangSwitchComponent],
template: `
<ng-container *transloco="let t">
<header>
<h1>{{ t('app.title') }}</h1>
<app-lang-switch />
</header>
<nav>
<a routerLink="/">{{ t('nav.home') }}</a>
<a routerLink="/about">{{ t('nav.about') }}</a>
</nav>
<main>
<router-outlet />
</main>
<footer>
{{ t('footer.copyright', { year: 2026 }) }}
</footer>
</ng-container>
`
})
export class AppComponent {
private translocoService = inject(TranslocoService);
private dirService = inject(DirectionService);
// ⚠️ ต้อง declare activeLang ก่อน constructor เพื่อให้ field ถูก initialize ก่อน effect() เรียกใช้
activeLang = toSignal(this.translocoService.langChanges$);
constructor() {
// Sync direction with language
effect(() => {
const lang = this.activeLang();
if (lang) {
this.dirService.setDirection(lang);
}
});
}
}typescript
// direction.service.ts
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DirectionService {
private doc = inject(DOCUMENT);
private platformId = inject(PLATFORM_ID);
private rtlLangs = new Set(['ar', 'he', 'fa', 'ur']);
setDirection(lang: string) {
// ⚠️ ฝั่ง SSR ก็ตั้ง dir/lang ได้ (Angular SSR เปิด DOCUMENT ให้)
// ใช้ DOCUMENT token แทน global document → ทำงานได้ทั้ง browser + server
const dir = this.rtlLangs.has(lang) ? 'rtl' : 'ltr';
this.doc.documentElement.dir = dir;
this.doc.documentElement.lang = lang;
}
}json
// assets/i18n/en.json
{
"app": {
"title": "My App"
},
"nav": {
"home": "Home",
"about": "About"
},
"footer": {
"copyright": "© {{year}} My Company"
},
"users": {
"list": "{count, plural, =0 {No users} one {1 user} other {# users}}",
"greeting": "Hello, {{name}}!"
},
"errors": {
"required": "{{field}} is required",
"email": "Invalid email",
"minLength": "Minimum {{min}} characters"
}
}json
// assets/i18n/th.json
{
"app": {
"title": "แอปของฉัน"
},
"nav": {
"home": "หน้าแรก",
"about": "เกี่ยวกับ"
},
"footer": {
"copyright": "© {{year}} บริษัทของฉัน"
},
"users": {
"list": "{count, plural, =0 {ไม่มีผู้ใช้} other {# คน}}",
"greeting": "สวัสดี {{name}}!"
},
"errors": {
"required": "กรุณากรอก {{field}}",
"email": "อีเมลไม่ถูกต้อง",
"minLength": "ขั้นต่ำ {{min}} ตัวอักษร"
}
}14. Translation Workflow
ในทีมจริง นักพัฒนาไม่ได้แปลเองทุกภาษา แต่ส่งคำให้นักแปลผ่านระบบจัดการการแปล (TMS) เช่น Crowdin หรือ Lokalise แล้วดึงผลลัพธ์กลับมา ส่วนนี้อธิบายขั้นตอนทั้งวงจร และวิธีทำให้มันอัตโนมัติด้วย CI:
text
Dev: เขียน key + ข้อความ English ต้นฉบับ
↓
Translation Management System (TMS) — ระบบจัดการงานแปล:
- Crowdin
- Lokalise
- Phrase
- POEditor
- Localizely
↓
Translator: แปลแต่ละ locale
↓
Sync back: ดาวน์โหลดไฟล์ JSON / XLIFF ที่แปลแล้ว
↓
DeployCrowdin Example
bash
# 1. ติดตั้ง Crowdin CLI
npm install -g @crowdin/cliyaml
# 2. ตั้งค่า crowdin.yml — ใส่ project_id และ api_token จาก Crowdin dashboard
project_id: 'XXXXXX'
api_token: 'XXXXX'
files:
- source: '/src/assets/i18n/en.json'
translation: '/src/assets/i18n/%two_letters_code%.json'bash
# 3. อัปโหลดไฟล์ต้นฉบับ (en.json) ไปให้นักแปล
crowdin upload sources
# 4. ดาวน์โหลดไฟล์แปลที่เสร็จแล้วกลับมา
crowdin downloadContinuous Localization (CI)
ตัวอย่างนี้ตั้ง GitHub Actions ให้อัปโหลดไฟล์แปลไป Crowdin อัตโนมัติทุกครั้งที่ push ไฟล์ en.json:
yaml
# .github/workflows/i18n.yml
on:
push:
paths: ['src/assets/i18n/en.json']
jobs:
upload:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install -g @crowdin/cli
- run: crowdin upload sources15. Checkpoint
ลองฝึกตามโจทย์ด้านล่างให้ครบทุกแง่มุมของ i18n — ตั้งแต่ built-in i18n, Transloco แบบ runtime, plural/format, RTL ไปจนถึงทดสอบด้วย pseudo-locale:
🛠️ Checkpoint 15.1 — Built-in i18n
- Mark text with
i18n— เพิ่ม attribute i18n ให้ข้อความที่ต้องการแปล - Extract messages.xlf — รัน
ng extract-i18nดึงข้อความออกมาเป็นไฟล์ - Translate to Thai — แปลเป็นภาษาไทยใน messages.th.xlf
- Build with
--localize— build แยกตาม locale
🛠️ Checkpoint 15.2 — Transloco
- Install + setup with 2-3 langs — ติดตั้งและตั้งค่า 2-3 ภาษา
- Translate UI (use
*translocodirective) — ใช้ directive แปล UI - Add language switcher — เพิ่มปุ่มเลือกภาษา
- Persist choice to localStorage — จำค่าที่เลือกไว้
🛠️ Checkpoint 15.3 — Plural + Format
- Pluralize with ICU — ใช้ ICU MessageFormat กับพหูพจน์
- Format date + currency + number per locale — format ตาม locale
- Use Intl.RelativeTimeFormat — แสดงเวลาสัมพัทธ์ เช่น "เมื่อวาน"
🛠️ Checkpoint 15.4 — RTL
- Add Arabic/Hebrew translation — เพิ่มไฟล์แปลภาษา RTL
- Convert CSS to logical properties — เปลี่ยน CSS เป็น logical properties
- Toggle RTL — verify layout mirrors — ทดสอบว่า layout กลับด้านถูกต้อง
🛠️ Checkpoint 15.5 — Pseudo-locale
- Create en-XA pseudo translation — สร้างไฟล์ pseudo-locale
- Run app — find hardcoded strings + overflow — หาข้อความที่ hardcode และ layout แตก
- Fix issues — แก้ปัญหาที่พบ
16. Resources
แหล่งเรียนรู้ต่อด้าน i18n ที่แนะนำ — ทั้งคู่มือทางการของ Angular, ไลบรารี Transloco และเอกสาร Intl/CLDR สำหรับเรื่อง format ตามภาษา:
Official
TMS
Reference
17. สรุปบท
✅ i18n = preparing code for many languages, l10n = translating + formatting
✅ 2 แนวทาง: @angular/localize (compile-time, SEO) vs Transloco/ngx-translate (runtime, flexible)
✅ Built-in: i18n attribute + ng extract-i18n + XLIFF + ng build --localize
✅ Transloco: *transloco directive + JSON files + TranslocoService for runtime switch
✅ Pluralization: ICU MessageFormat ({count, plural, =0 {...} one {...} other {...}})
✅ Date/Number/Currency: Angular pipes (with locale) หรือ Intl API native
✅ RTL: dir="rtl" + CSS logical properties (margin-inline-start)
✅ SEO: locale in URL + hreflang tags + localized slugs
✅ Best practices: extract everything, use keys (not English text), provide context
✅ TMS: Crowdin / Lokalise — workflow translator + developer
✅ Test with pseudo-locale before going to real translation
✅ Set document.documentElement.lang + dir on language change
🎉 จบ Angular Book — ครบ 15 บท!
หลังจากบทที่ 0-15 คุณมี:
| # | บท | สอนอะไร |
|---|---|---|
| 0 | Intro | philosophy, install, first project, CLI |
| 1 | Components + Templates | standalone, template, binding, control flow |
| 2 | Signals + State | signal, computed, effect, RxJS interop |
| 3 | Services + DI | service, DI, providers, InjectionToken |
| 4 | Routing | router, guards, lazy, resolver |
| 5 | Forms | template-driven, reactive, validation, FormArray |
| 6 | HTTP + RxJS | HttpClient, operators, interceptor |
| 7 | Change Detection + Performance | OnPush, signals, zoneless, defer |
| 8 | Testing | Jasmine, TestBed, mock, e2e |
| 9 | SSR + Modern | SSR, hydration, defer, deployment |
| 10 | NgRx + Signal Store | state management (scale) |
| 11 | Micro-frontends | Module Federation, Native Federation |
| 12 | Accessibility (a11y) | semantic, ARIA, keyboard, CDK a11y |
| 13 | Security | XSS, CSRF, CSP, auth pattern |
| 14 | Material + CDK | UI library + behavior primitives |
| 15 | i18n + l10n + RTL | multi-language + locale + RTL |
Roadmap หลังจบหนังสือ
| ลำดับ | ทำอะไร |
|---|---|
| 1 | สร้าง real project ครบ stack (Material + Auth + i18n + SSR + Test) |
| 2 | Contribute open source (Angular, Material, NgRx) |
| 3 | เรียน Nx Monorepo สำหรับ multi-app |
| 4 | เจาะลึก WebSocket/SSE real-time |
| 5 | เรียน Animation API เชิงลึก (route animation, AnimationBuilder) |
| 6 | สร้าง custom UI library (Storybook + ng-packagr) |
| 7 | PWA + Push Notifications + Background Sync |
| 8 | Web Worker + heavy computation |
| 9 | Schematic (custom ng generate) |
| 10 | Performance audit จริง (Lighthouse, WebPageTest, RUM) |
"หนังสือเล่มนี้คือจุดเริ่มต้น — ของจริงรอผู้ใช้งานจริง"