

1/1/1970
Cách Mình Triển Khai i18n cho 20 Ngôn Ngữ Trong 3 Ngày
Xin chào! 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. Đây là một công việc đồ sộ, đụng chạm đến gần như mọi tệp trong codebase, nhưng mình đã xử lý xong tất cả chỉ trong 3 ngày.
Dưới đây mình sẽ chia sẻ cách mình làm điều đó, các con số cụ thể đằng sau thay đổi này, và lý do mình quyết định tự viết thư viện dịch thuật riêng (một lần nữa) thay vì dùng chuẩn của ngành.
Tại sao không dùng i18next?
Khi mới bắt đầu nghĩ đến việc thêm bản dịch, mình đã cân nhắc lựa chọn chuẩn ngành: i18next và react-i18next.
Thay vào đó, mình quyết định tối ưu hóa cho khả năng bảo trì bởi AI. i18next rất mạnh, nhưng API đa dạng của nó có thể khiến LLM bịa đặt hoặc viết code thiếu nhất quán. Bằng cách giới hạn thư viện chỉ còn t() và interpolate() đơn giản, mình đảm bảo hơn 10 agent chạy song song có thể viết code an toàn kiểu 100% với gần như không cần con người can thiệp.
Mình cũng e ngại việc bị ràng buộc vào một hệ sinh thái lớn có thể đưa ra các thay đổi phá vỡ tương thích sau này. Sau khi đã trải qua các đợt nâng cấp đau đớn như React Router v5 và MUI v4 → v5, mình biết việc phá vỡ tương thích ngược nhanh chóng là chuyện quá phổ biến trong thế giới JavaScript. Chi phí thêm tính năng số nhiều sau này thấp hơn nhiều so với chi phí di chuyển thủ công 139 nghìn dòng code bây giờ.
Mình muốn một thứ gì đó đơn giản hết mức, cực kỳ nhẹ, và phù hợp chính xác với nhu cầu của team mình.
Vậy nên mình tự viết.
Mình đã xây dựng một tập con bị giới hạn 3 KB được thiết kế đặc biệt để cho phép AI tái cấu trúc tự động với độ chính xác cao. Điều này cho phép mình, chỉ với vai trò một kỹ sư duy nhất, hoàn thành khối lượng công việc 3 tuần của một team 5 người chỉ trong 3 ngày.
Triển Khai Tùy Chỉnh
Mình đã nghĩ ra một thư viện i18n tối giản, chỉ khoảng 3 KB sau khi gzip. Nó cung cấp hai hàm chính: getTranslation() cho các ngữ cảnh không phải React và một hook useTranslation() cho các component.
Các hàm này trả về t() để thay thế chuỗi đơn giản và interpolate() khi mình cần chèn các React component vào 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'}.
Các key tuân theo ký pháp "slash-dot" (dấu gạch chéo cho đường dẫn tệp đến file localization, dấu chấm cho các đối tượng lồng nhau trong tệp). Để đảm bảo tính duy nhất, các key dịch trong một tệp không được chứa dấu gạch chéo xuôi.
Đâ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à React hook:
export function useTranslation() {
const [language] = useLanguage();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
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 toàn bộ thư viện chỉ khoảng 580 dòng code. Nó xử lý:
- Tải lười (lazy-load) các tệp dịch để không gửi cả 20 ngôn ngữ đến mỗi người dùng.
- Chia nhỏ code các bản dịch theo "namespace" (ví dụ:
common,misc,games/{gameId}). - Một locale "debug" hiển thị các key thô để mình có thể xác minh mọi thứ được kết nối đúng cách.
Để đảm bảo hệ thống dễ bảo trì, mình cũng đã thêm tài liệu hướng dẫn đầy đủ trong shared/src/i18n/README.md, bao gồm mọi thứ từ cấu trúc tệp đến ví dụ sử dụng cho cả client và server. Vì mình không dùng thư viện chuẩn, việc có tài liệu tham khảo này rất quan trọng để hướng dẫn thành viên mới (hoặc nhắc nhở chính mình trong tương lai hay LLM về cách nó hoạt động).
Các Con Số
Để bạn hình dung quy mô của bản cập nhật này, đây là những gì đã thay đổi trong codebase:
- 20 ngôn ngữ được hỗ trợ (cộng thêm một locale debug cho dev).
- 360 tệp locale được tạo.
- 139.031 dòng code dịch thuật.
- 3.938 lệnh gọi
t()được thêm vào client. - 728 tệp nguồn được sửa đổi.
- 18 tệp nguồn tiếng Anh đóng vai trò là nguồn chân lý (16 game + common + misc).
Điều Phối với Các Agent
Làm việc này thủ công sẽ mất hàng tháng làm việc máy móc, đến mụ mị đầu óc. Thay vào đó, mình đã điều phối hơn một chục agent Cursor đồng thời để gánh phần việc nặng nhọc.
Mình bắt đầu bằng cách chia codebase thành các "phần" dựa trên thư mục. Mỗi game trên Foony có thư mục riêng và namespace dịch riêng. Điều này giúp giữ kích thước tải ban đầu nhỏ vì bạn chỉ cần tải bản dịch cho game bạn đang chơi.
Mình chạy nhiều agent Cursor cùng lúc. Mình giao cho mỗi agent một phần cụ thể, ví dụ "chuyển đổi game Cờ Vua sang dùng bản dịch", và nó duyệt từng tệp một, tìm các chuỗi hiển thị cho người dùng và thay thế bằng t('games/chess/some.key').
Sau đó agent sẽ thêm key đó vào tệp locale tiếng Anh tương ứng kèm comment JSDoc giải thích "cái gì" và "ở đâu" của chuỗi đó. Ngữ cảnh này rất quan trọng khi tạo bản dịch cho các ngôn ngữ khác, vì nó giúp LLM hiểu liệu "Save" nghĩa là "Lưu Cấu Hình Game" hay "Lưu Hình Vẽ Đoán Hình của bạn".
Kiểm Soát Chất Lượng
Mình đã nhanh chóng xem xét tất cả code được tạo ra. Các agent làm tốt một cách đáng ngạc nhiên, nhưng đôi khi vẫn mắc lỗi, ví dụ như đặt hook useTranslation sau câu lệnh return sớm.
Bản dịch được định kiểu mạnh đã giúp ích rất nhiều. Điều này đảm bảo tất cả các bản dịch cho mỗi locale có đủ các key đúng (và không có key sai). Nó cũng đảm bảo các lệnh gọi đến t() và interpolate() đều dùng các chuỗi dịch thực sự tồn tại.
Hệ thống kiểu trích xuất tất cả các key dịch có thể có từ các tệp 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
Điều này cho gợi ý tự động hoàn hảo trong TypeScript, và bất kỳ lỗi đánh máy nào trong key dịch đều được phát hiện tại thời điểm biên dịch. Các agent không thể mắc lỗi như t('games/ches/name') vì TypeScript sẽ ngay lập tức báo lỗi.
Bản Địa Hóa
Sau khi chuyển đổi tiếng Anh xong, mình chia nhỏ các tác vụ locale còn lại. Mình giao cho mỗi agent chịu trách nhiệm chuyển đổi một tệp locale tiếng Anh sang một ngôn ngữ cụ thể.
Ví dụ, mình đưa cho các agent một prompt như thế này:
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ân nhắc việc để Cursor tạo một script đưa từng tệp này vào LLM và cho nó tự tạo, nhưng mình muốn tiết kiệm chút chi phí LLM. Dùng script chỉ để cập nhật các bản dịch còn thiếu là cách tiếp cận tốt hơn, và mình có lẽ sẽ dùng giải pháp tương tự trong tương lai. Mình muốn theo dõi chuỗi nào cần cập nhật / dịch, nhưng vẫn muốn giữ mọi thứ đơn giản. Có thể mình sẽ chuyển công việc dịch thuật sang database hoặc gì đó.
Mình cũng đã thêm một locale "debug" chỉ có sẵn trong môi trường phát triển. Nó cho phép mình xem tất cả các chuỗi đã được thay thế để xác minh mọi thứ hoạt động (cộng thêm mình thấy nó cũng ngầu). Khi bạn dùng locale debug, t() 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⟧, giúp dễ dàng phát hiện các bản dịch còn thiếu.
Cuối cùng, một agent khác đã triển khai routing /{locale}/** để những thứ như /ja/games/chess sẽ điều hướng đến đúng ngôn ngữ (trong trường hợp này là tiếng Nhật).
Dịch Blog
Dịch các chuỗi UI là một chuyện, nhưng còn các bài blog thì sao? Mình không muốn phải tạo và quản lý thêm cả đống agent để dịch tất cả các bài blog của mình.
Mình đã giải quyết bằng cách để một agent tạo một script (scripts/src/generateBlogTranslations.ts) tự động hóa toàn bộ quá trình.
Đây là cách nó hoạt động:
- Nó quét thư mục
client/src/posts/enđể tìm các tệp MDX tiếng Anh. - Nó kiểm tra các bản dịch còn thiếu trong các thư mục locale khác (ví dụ:
posts/ja,posts/es). - Nếu một bản dịch bị thiếu, nó đọc nội dung tiếng Anh và đưa vào Gemini 3 Pro Preview với một prompt cụ thể để dịch nội dung trong khi vẫn giữ nguyên định dạng Markdown.
- Nó lưu tệp mới vào đúng vị trí.
Ở phía frontend, mình dùng import.meta.glob để import động tất cả các tệp MDX này. Component PostPage của mình sau đó chỉ cần kiểm tra locale hiện tại của người dùng và lazy-load tệp MDX phù hợp. Nếu một bản dịch bị thiếu (vì mình chưa chạy script), nó sẽ tự động chuyển về tiếng Anh một cách mượt mà.
Ngày 4: Tự Động Tạo Bản Dịch
Mình biết giải pháp ban đầu sẽ không mở rộng được. Vậy nên, giờ đã có i18n rồi, đã đến lúc làm cho nó vững chãi hơn một chút bằng cách tiếp cận dựa trên database.
Tóm lại: khi văn bản tiếng Anh hoặc comment JSDoc thay đổi, các bản dịch cần được tạo lại. Việc theo dõi thủ công cái gì cần cập nhật sẽ dễ sai sót và lãng phí thời gian của lập trình viên.
Vậy là mình xây dựng giải pháp đã dự định ban đầu: một hệ thống tạo bản dịch dựa trên PostgreSQL.
Lược Đồ Database
Mình đã thêm một bảng translations vào database PostgreSQL với cấu trúc sau:
key: Key dịch theo ký pháp "slash-dot" (ví dụ:"games/yacht/nested.name","config.timeLimit.label").en_value: Giá trị nguồn tiếng Anhtarget_locale: Mã locale đích (ví dụ:"es","fr","zh")target_value: Giá trị đã dịchcontext: Một trường JSONB chứa JSDoc cho key này và tất cả key tổ tiêncreated_atvàupdated_at: Dấu thời gian để theo dõi
Chỉ mục duy nhất nằm trên (key, target_locale, en_value, context). Điều này rất quan trọng: bằng cách bao gồm context trong ràng buộc duy nhất, chúng ta có thể tự động phát hiện khi comment JSDoc thay đổi và tạo lại bản dịch. Các bản dịch cũ vẫn được giữ lại để tham khảo lịch sử.
Script Tạo Bản Dịch
Mình đã tạo scripts/src/generateLocalizations.ts để tự động hóa toàn bộ quy trình dịch thuật:
- Trích xuất key tiếng Anh: Dùng phân tích AST (ts-morph) để trích xuất tất cả key dịch từ các tệp
shared/src/i18n/locales/en/**, chỉ xử lý các default export - Trích xuất ngữ cảnh JSDoc: Phân tích comment JSDoc cho mỗi key và tất cả các key tổ tiên (đối tượng cha) để cung cấp ngữ cảnh phong phú
- Truy vấn database: Kiểm tra các bản dịch hiện có trong PostgreSQL, đối chiếu trên
key,target_locale,en_value, VÀcontext. Nếu bất kỳ thứ nào trong số này thay đổi, bản dịch sẽ được tạo lại. - Xác định key thiếu/thay đổi: Tìm các key cần dịch hoặc có giá trị/comment tiếng Anh đã thay đổi
- Gộp bản dịch theo lô: Nhóm theo locale và tiền tố namespace để gọi LLM hiệu quả hơn (cũng làm bản dịch nhanh hơn). Tuy nhiên, nếu lô quá lớn, chất lượng dịch sẽ kém đi.
- Tạo bản dịch: Dùng GPT 5.1 với ngữ cảnh đầy đủ (JSDoc, ngôn ngữ+vùng, tông giọng, từ điển, ví dụ). Mình có đọc được rằng 5.1 tốt hơn 5.2 cho việc viết (nghe không nhạt nhẽo), nhưng chưa kiểm chứng.
- Kiểm tra QA: Xác thực việc giữ nguyên placeholder, ví dụ
{{name}}, tính toàn vẹn của key, định dạng JSON - Lưu vào database: Lưu các bản dịch với ngữ cảnh đầy đủ (JSDoc + JSDoc tổ tiên)
- Tạo các tệp locale: Đọc từ database và ghi các tệp TypeScript locale được định dạng đúng cách với các kiểu
RecursivePartial
Lợi Ích Chính
Cách tiếp cận này mang lại nhiều cải tiến cho trải nghiệm phát triển:
- Tự động tạo lại: Khi văn bản tiếng Anh HOẶC comment JSDoc thay đổi, bản dịch sẽ tự động được tạo lại. Nên nếu ai đó nói một bản dịch không tốt, việc tạo lại bản dịch bằng cách cung cấp thêm ngữ cảnh dưới dạng comment trở nên rất dễ dàng.
- Ngữ cảnh phong phú: Comment JSDoc cung cấp ngữ cảnh dịch (ví dụ: "Thông báo lỗi hiển thị cho người chơi, tối đa 15 ký tự"), giúp LLM tạo bản dịch chính xác hơn
- Ngữ cảnh tổ tiên: JSDoc của đối tượng cha cung cấp ngữ cảnh namespace (ví dụ: "Thành tựu khi tham gia một game mà tất cả trứng đều bị phá hủy"), giúp rõ ràng hơn một chút
- Theo dõi lịch sử: Các bản dịch cũ được lưu trong database. Chúng không chiếm nhiều dung lượng, nên mình không thấy lý do gì phải xóa bây giờ, và việc xem lịch sử cũng khá thú vị.
Chi Tiết Kỹ Thuật
Triển khai này dùng nhiều kỹ thuật để đảm bảo độ tin cậy và hiệu quả:
- Trích xuất dựa trên AST để đảm bảo lấy được đúng comment
- Xử lý song song dùng Semaphore cho việc dịch theo lô đồng thời
- Logic thử lại với độ trễ tăng dần theo cấp số nhân khi API thất bại. Các lệnh gọi LLM nổi tiếng là không ổn định.
Script có thể chạy bằng npm run generate-localizations từ thư mục scripts. Khi chạy, nó kết nối với PostgreSQL và xử lý tất cả các bản dịch còn thiếu hoặc đã thay đổi cho tất cả các locale được hỗ trợ.
Kết Luận
Đến lúc này, mình đã có một trang web hoạt động đầy đủ được dịch sang cả 20 locale!
Đây là 3 ngày điên rồ, nhưng kết quả là một trang web được bản địa hóa hoàn toàn, mang lại cảm giác (gần như) bản địa cho người dùng trên khắp thế giới. Bằng cách xây dựng một thư viện tùy chỉnh, nhẹ nhàng và tận dụng các AI agent cho công việc tái cấu trúc tẻ nhạt, mình đã làm được điều mà chỉ một năm trước còn là không tưởng: i18n đầy đủ trong 3 ngày cho một trang web phức tạp bởi 1 kỹ sư duy nhất. Tương lai của lập trình không phải là viết code thật nhanh. Mà là điều phối các AI agent và sở hữu chuyên môn sâu trong lĩnh vực để xác minh đầu ra của chúng.