background blurbackground mobile blur

1/1/1970

Mình đã triển khai i18n cho 20 ngôn ngữ trong 3 ngày như thế nào

Chào mọi người! Mình vừa hoàn thành một nhiệm vụ khổng lồ: dịch Foony sang 20 ngôn ngữ khác nhau. Việc này đụng gần như mọi file trong codebase, nhưng mình đã xử lý xong hết chỉ trong 3 ngày.

Bên dưới mình sẽ kể chi tiết mình đã làm thế nào, các con số cụ thể phía sau đợt thay đổi này, và vì sao mình lại tự viết thư viện dịch của riêng mình (một lần nữa) thay vì dùng thư viện chuẩn của ngành.

Vì sao không dùng i18next?

Khi bắt đầu nghĩ đến chuyện thêm dịch thuật, mình cũng nhìn qua lựa chọn chuẩn của ngành: i18nextreact-i18next.

Cuối cùng, mình quyết định tối ưu cho khả năng bảo trì bằng AI. i18next rất mạnh, nhưng API hơi "nhiều kiểu", khiến các LLM dễ bịa API hoặc sinh code không nhất quán. Bằng cách giới hạn thư viện lại chỉ còn t()interpolate(), mình đảm bảo hơn 10 agent chạy song song vẫn có thể viết code type-safe 100% với gần như không cần can thiệp thủ công.

Mình cũng hơi ngại việc phải phụ thuộc vào một hệ sinh thái lớn có thể phá vỡ compatibility về sau. Sau khi từng bị "phỏng tay" bởi các lần migrate đau đớn như React Router v5MUI v4 → v5, mình biết việc phá vỡ backwards-compatibility diễn ra khá thường xuyên trong thế giới JavaScript. Chi phí để tự thêm tính năng pluralization sau này vẫn rẻ hơn nhiều so với chi phí manually migrate 139k dòng code ngay bây giờ.

Mình muốn một thứ siêu đơn giản, cực nhẹ, và được thiết kế đúng theo nhu cầu của team mình.

Thế là mình tự viết.

Mình dựng một subset khoảng 3 KB, bị giới hạn rất rõ ràng, được thiết kế riêng để cho phép AI refactor tự động với độ chính xác cao. Nhờ vậy, mình có thể hoạt động như một engineer solo nhưng làm được khối lượng tương đương 5 người trong 3 tuần, chỉ trong 3 ngày.

Cách tự triển khai

Mình nghĩ ra một thư viện i18n tối giản, nặng khoảng 3 KB đã gzip. Nó expose hai hàm chính: getTranslation() cho các ngữ cảnh ngoài React và hook useTranslation() cho component.

Cả hai đều trả về t() để thay thế chuỗi đơn giản và interpolate() khi mình cần inject React component vào trong chuỗi dịch (như link hoặc icon). Cả hai hàm đều hỗ trợ thay thế biến, ví dụ "Hello {{thing}}", {thing: 'World'}.

Đây là hàm t() cốt lõi:

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;
}

Và hook React:

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]);
}

Phần lõi của cả thư viện chỉ khoảng 580 dòng code. Nó xử lý:

  • Lazy-load các file dịch để không phải gửi đủ cả 20 ngôn ngữ cho mọi người dùng.
  • Tách code dịch theo "namespace" (ví dụ common, misc, games/{gameId}).
  • Một locale "debug" hiển thị key thô để mình kiểm tra mọi thứ đã được nối dây đúng chưa.

Để hệ thống dễ bảo trì lâu dài, mình cũng viết tài liệu khá chi tiết trong shared/src/i18n/README.md, từ cấu trúc file cho đến ví dụ dùng trên cả client lẫn server. Vì mình không dùng thư viện chuẩn, nên tài liệu này khá quan trọng để onboard người mới (hoặc để phiên bản tương lai của chính mình hay các LLM nhớ lại cách hoạt động).

Con số cụ thể

Để bạn hình dung quy mô lần cập nhật này, đây là những gì đã thay đổi trong codebase:

  • Hỗ trợ 20 ngôn ngữ (cộng thêm một locale debug cho dev).
  • Tạo 360 file locale.
  • 139.031 dòng code dịch.
  • Thêm 3.938 lần gọi t() trên toàn bộ client.
  • Sửa 728 file source.
  • 18 file source tiếng Anh làm "nguồn sự thật" (16 game + common + misc).

Điều phối bằng các agent

Nếu làm tay thì chắc mình mất vài tháng với đống việc máy móc, lặp đi lặp lại. Thay vào đó, mình điều phối hơn chục agent của Cursor chạy song song để gánh phần nặng nhọc.

Mình bắt đầu bằng cách chia codebase thành từng "khu" dựa trên thư mục. Mỗi game trên Foony có một folder riêng và một namespace dịch riêng. Như vậy dung lượng tải ban đầu sẽ nhỏ, vì bạn chỉ tải bản dịch của đúng game bạn đang chơi.

Mình chạy nhiều agent Cursor cùng lúc. Mỗi agent được giao một khu riêng, ví dụ "chuyển game Cờ vua (Chess) sang dùng hệ thống dịch". Agent sẽ đi qua từng file, tìm các chuỗi hiển thị cho người dùng và thay bằng t('games/chess/some.key').

Sau đó, agent sẽ thêm key tương ứng vào file locale tiếng Anh phù hợp, kèm một comment JSDoc giải thích "cái gì" và "ở đâu". Ngữ cảnh này rất quan trọng khi dịch sang các ngôn ngữ khác, để LLM hiểu được "Save" là "Lưu cấu hình game" hay "Lưu bức vẽ Draw & Guess".

Kiểm tra chất lượng

Mình rà khá nhanh toàn bộ code được sinh ra. Các agent hoạt động tốt bất ngờ, nhưng thỉnh thoảng vẫn lỗi, kiểu đặt hook useTranslation sau một câu return sớm.

Phần dịch có type chặt chẽ giúp cực nhiều. Nó đảm bảo mỗi locale đều có đủ key cần thiết (và không có key thừa). Nó cũng đảm bảo mọi lần gọi t()interpolate() đều dùng các chuỗi dịch thật sự tồn tại.

Hệ thống type sẽ trích xuất mọi key dịch có thể từ các file nguồn tiếng Anh:

/**
 * 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

Nhờ vậy TypeScript autocomplete hoạt động cực chuẩn, và bất kỳ typo nào trong key dịch đều bị bắt ngay lúc compile. Agent không thể viết nhầm kiểu t('games/ches/name') vì TypeScript sẽ báo lỗi tức thì.

Bản địa hóa

Khi phần chuyển sang tiếng Anh đã xong, mình chia nhỏ phần việc còn lại cho các locale khác. Mỗi agent được giao nhiệm vụ chuyển một file locale tiếng Anh sang một ngôn ngữ cụ thể.

Ví dụ, mình cho agent một prompt kiểu như:

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.

Mình có nghĩ đến chuyện để Cursor tạo một script, lần lượt gửi từng file vào một LLM rồi cho nó sinh bản dịch, nhưng mình muốn tiết kiệm chi phí LLM một chút. Việc dùng script chỉ để update các bản dịch còn thiếu là cách hợp lý hơn, và chắc sau này mình cũng sẽ làm kiểu tương tự. Mình muốn theo dõi key nào cần cập nhật hoặc dịch thêm, nhưng cũng muốn mọi thứ giữ đơn giản. Có thể mình sẽ chuyển phần công việc dịch sang database hoặc thứ gì đó tương tự.

Mình cũng thêm một locale "debug" chỉ dùng trong môi trường development. Locale này cho mình xem mọi chuỗi đã được thay thế để kiểm tra mọi thứ chạy ổn (với lại trông cũng vui). Khi dùng locale debug, t() sẽ trả về key được bọc trong dấu ngoặc:

if (targetLocale === 'debug') {
  return `⟦${key}⟧`;
}

Vậy nên thay vì thấy "Welcome to Foony!", bạn sẽ thấy ⟦welcome⟧, rất dễ phát hiện chỗ nào còn thiếu dịch.

Cuối cùng, một agent khác triển khai routing kiểu /{locale}/**, để những đường dẫn như /ja/games/chess sẽ tự route sang đúng ngôn ngữ (trong ví dụ này là tiếng Nhật).

Dịch blog

Dịch các chuỗi UI là một chuyện, còn blog post thì sao? Mình không muốn phải dựng thêm và quản lý cả đống agent chỉ để dịch toàn bộ blog.

Mình giải bài toán này bằng cách nhờ một agent tạo một script (scripts/src/generateBlogTranslations.ts) để tự động hóa toàn bộ quá trình.

Cách hoạt động:

  1. Quét thư mục client/src/posts/en để tìm các file MDX tiếng Anh.
  2. Kiểm tra các locale khác (ví dụ posts/ja, posts/es) xem thiếu bản dịch nào.
  3. Nếu thiếu, script đọc nội dung tiếng Anh và gửi vào Gemini 3 Pro Preview với một prompt cụ thể để dịch nội dung mà vẫn giữ nguyên định dạng Markdown.
  4. Lưu file mới vào đúng thư mục.

Trên frontend, mình dùng import.meta.glob để dynamic import toàn bộ các file MDX này. Component PostPage chỉ việc kiểm tra locale hiện tại của người dùng và lazy-load đúng file MDX tương ứng. Nếu thiếu bản dịch (vì mình chưa chạy script), nó sẽ fallback về tiếng Anh một cách nhẹ nhàng.

Kết lại

Đến thời điểm này, mình đã có một trang web hoạt động đầy đủ, được dịch sang cả 20 locale!

Đó là 3 ngày khá điên rồ, nhưng kết quả là một site được bản địa hóa gần như hoàn chỉnh, tạo cảm giác "bản xứ" cho người dùng ở khắp nơi trên thế giới. Bằng việc tự xây một thư viện riêng, gọn nhẹ và tận dụng AI agent cho phần refactor nhàm chán, mình đã làm được thứ mà cách đây một năm gần như bất khả thi: full i18n trong 3 ngày cho một website phức tạp, chỉ bởi 1 engineer.

Tương lai của lập trình không nằm ở việc gõ code thật nhanh. Nó nằm ở chỗ biết điều phối các AI agent và có đủ hiểu biết sâu về domain để kiểm tra, xác nhận đầu ra của chúng.

8 Ball Pool online multiplayer billiards icon