Server-side, dịch một chuỗi trong Python là gọi self.env._("...") hay dùng _ quen thuộc. Nhưng phần lớn giao diện Odoo giờ là JavaScript chạy trên trình duyệt — nút "Lưu", thông báo "Đã lưu", nhãn cột, tiêu đề dialog đều do OWL vẽ. Vậy làm sao một chuỗi gõ cứng trong file .js lại hiện ra đúng tiếng Việt cho người dùng Việt, tiếng Pháp cho người dùng Pháp? Câu trả lời là hàm _t. Bài này mổ xẻ nó trên Odoo 19: lấy bản dịch từ đâu, cơ chế "lười" mới đã xoá hẳn _lt, và các kiểu tham số — tất cả kiểm chứng bằng cách gõ _t thẳng trên hệ thống đang chạy.
_t là gì và nhập từ đâu
_t đến từ đúng một chỗ:
import { _t } from "@web/core/l10n/translation";
Nguyên tắc dùng nó rất đơn giản: mọi chuỗi mà người dùng sẽ đọc, hãy bọc bằng _t. Không bọc thì chuỗi đó vĩnh viễn là tiếng Anh (hoặc thứ tiếng bạn gõ), bất kể người dùng đổi ngôn ngữ.

Hình 1: Nhập và dùng _t. Mấu chốt: bọc chuỗi khi runtime, truyền tham số qua %s (theo thứ tự) hoặc %(tên)s (theo tên). Phần dưới là điểm mới của Odoo 19 — _t giờ "lười", và _lt đã bị bỏ hẳn (mục dưới nói kỹ).
Bản dịch đến từ đâu: /web/webclient/translations
_t không tự biết "Save" là "Lưu". Khi web client khởi động, nó gọi một endpoint để nạp toàn bộ bản dịch của ngôn ngữ hiện tại về bộ nhớ. Đây là phản hồi thật trên hệ thống của tôi:

Hình 2: Endpoint /web/webclient/translations — nguồn dữ liệu thật của _t. Nó trả về 175 module có chuỗi dịch; riêng module web đã có 4102 chuỗi. Mỗi phần tử là một cặp id (chuỗi gốc) → string (bản dịch). Để ý bản dịch được gom theo module (web, quan_ca_phe...) — đó là cái Odoo gọi là "context", giải thích ở cuối bài.
Dữ liệu này nạp bất đồng bộ. Trong lõi translation.js có một Deferred tên translationIsReady và cờ translatedTerms[translationLoaded] bật lên true khi nạp xong. Điểm này quan trọng cho phần tiếp theo.
Thử thật: gõ _t trên hệ thống đang chạy
Trên Odoo 19 với giao diện tiếng Việt, tôi mở console trình duyệt và gọi thẳng _t (lấy qua odoo.loader.modules.get). Kết quả không bịa:

Hình 3: _t chạy thật. _t("%s records", 4) cho "4 bản ghi" — vừa dịch, vừa chèn %s bằng 4. _t("Discard") → "Huỷ bỏ", _t("Save") → "Lưu". Và điều quan trọng: _t("Cà phê sữa đá quán ABC XYZ") — chuỗi không có trong bảng dịch — trả về đúng nguyên bản. _t không bao giờ làm mất chuỗi; không tìm thấy bản dịch thì nó trả lại y hệt cái bạn đưa vào.
Hành vi "không thấy thì trả nguyên bản" chính là dòng này trong lõi:
const translation =
translatedTerms[this.context]?.[source] ?? translatedTermsGlobal[source] ?? source;
Tra trong context của module trước, không có thì tra bảng chung, vẫn không có thì lấy chính source.
Điểm mới lớn của Odoo 19: _t đã "lười", _lt bị bỏ
Đây là thay đổi dễ vấp nhất khi nâng code cũ lên Odoo 19. Ở Odoo 16/17, _t dịch ngay lập tức khi được gọi. Vấn đề: nếu bạn gọi _t ở top-level của một module (ví dụ gán nhãn cho một field, một thuộc tính static), code đó chạy lúc nạp module — thời điểm bản dịch chưa nạp xong. Kết quả là chuỗi kẹt ở tiếng gốc. Giải pháp cũ là dùng hàm riêng _lt (lazy translate): nó không dịch ngay mà trả về một đối tượng, dịch trễ đến lúc thật sự cần in ra.
Odoo 19 gộp hai hàm làm một. Bây giờ _t tự lười: nó trả về một TranslatedString (lớp con của String) chỉ thực sự tra bản dịch khi bị ép thành chuỗi (.toString() / .valueOf() — thứ tự động xảy ra khi bạn nối chuỗi hay render ra template). Đọc lõi thấy rõ:
export function _t(source, ...substitutions) {
return appTranslateFn(source, odoo.translationContext, ...substitutions);
}
export function appTranslateFn(source, moduleName, ...substitutions) {
const string = new TranslatedString(source, substitutions, moduleName);
return string.lazy ? string : string.valueOf(); // lười thì trả nguyên object
}
Vì _t đã lười, _lt không còn cần và đã bị xoá khỏi @web/core/l10n/translation — không có export _lt, và trong toàn bộ mã nguồn Odoo 19 không còn ai import nó. Nếu bạn bê code cũ có import { _lt } from ... sang, nó sẽ gãy lúc nạp. Cách sửa: đổi hết _lt thành _t.
Một hệ quả cần nhớ: vì _t lười, nếu bạn ép nó ra chuỗi quá sớm (khi translatedTerms[translationLoaded] còn false), lõi ném lỗi thẳng:
if (this.lazy && !translatedTerms[translationLoaded]) {
throw new Error(`Cannot translate string: translations have not been loaded`);
}
Nghĩa là: khai static label = _t("...") ở top-level thì an toàn (nó chỉ là object lười), nhưng đừng cố String(_t("...")) ngay lúc module vừa nạp.
Tham số: %s, %(tên)s, và danh sách
_t không chỉ tra bảng, nó còn nội suy tham số — cùng cơ chế sprintf:
_t("Đã tải %s thẻ", 12); // "Đã tải 12 thẻ"
_t("Chào %(ten)s, hạng %(hang)s", // theo tên, không lo thứ tự
{ ten: "An", hang: "Vàng" });
_t("Mời %s!", ["An", "Bình", "Cường"]); // tự nối danh sách theo ngôn ngữ
Điều tinh tế: bản dịch được nội suy sau khi tra bảng, nên %s phải nằm nguyên trong cả chuỗi gốc lẫn chuỗi dịch (nhìn lại Hình 2: "%s records" → "%s bản ghi", giữ đúng %s). Người dịch không được đổi thứ tự %s nếu chuỗi có nhiều tham số vị trí — đó chính là lý do %(tên)s an toàn hơn: đặt tên thì thứ tự trong câu dịch tự do sắp lại.
Vì sao dịch lại gom theo module (context)
Nhìn lại Hình 2: bản dịch nằm trong modules.web, modules.quan_ca_phe... chứ không phải một rổ phẳng. Lý do: cùng một từ tiếng Anh có thể mang nghĩa khác nhau theo ngữ cảnh — "table" là cái bàn trong module nhà hàng, nhưng là bảng dữ liệu trong module bảng tính. Odoo dùng tên module làm "context" để tách chúng. Trình biên dịch tài nguyên (transpiler) tự chèn tên module vào mỗi lời gọi _t (chính là appTranslateFn(source, moduleName, ...) ở trên), nên bạn không phải khai tay — cứ _t("...") là đủ, Odoo tự biết nó thuộc module nào.
Ba ý mang về
- Bọc mọi chuỗi hiển thị bằng
_t(nhập từ@web/core/l10n/translation); bản dịch nạp từ/web/webclient/translationstheo ngôn ngữ người dùng, không thấy thì_ttrả nguyên bản chứ không mất chuỗi. - Odoo 19:
_tđã "lười" và_ltbị bỏ hẳn._ttrả vềTranslatedStringchỉ dịch khi bị ép thành chuỗi, nên dùng được cả ở top-level — code cũ import_ltphải đổi thành_t. Đừng ép ra chuỗi trước khi bản dịch nạp xong (ném lỗi). - Tham số qua
%s(thứ tự) hoặc%(tên)s(theo tên, an toàn hơn khi dịch); bản dịch được gom theo module làm context để cùng một từ khác nghĩa không đè lên nhau.
Dịch xong phần chữ, phần tiếp ta chuyển sang một kỹ thuật tối ưu tải trang của OWL: nạp component chỉ khi thật sự cần. Phần sau nói về nạp component trễ (lazy) — hoãn tải một khối giao diện nặng đến đúng lúc người dùng mở tới nó, thay vì gánh hết ngay từ đầu.