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ữ.

Ảnh chụp đoạn mã JavaScript nền tối. Phần đầu nhập _t từ web core l10n translation rồi gọi notification add với _t bọc chuỗi Đã lưu thẻ thành viên, một lời gọi có tham số phần trăm s chèn số lượng, và một lời gọi dùng tham số có tên phần trăm ten s và phần trăm hang s. Phần sau minh hoạ Odoo 19 _t đã lười trả về TranslatedString con của String chỉ dịch khi bị ép thành chuỗi nên dùng được ở top-level làm nhãn static label, kèm chú thích Odoo 16 17 phải import _lt còn Odoo 19 đã bỏ _lt chỉ còn _t

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:

Ảnh chụp phản hồi JSON nền tối của GET web webclient translations trả về 200. Đối tượng có các khoá lang bằng null, hash, lang_parameters, multi_lang bằng true, và modules chứa 175 module có chuỗi dịch. Trong đó module web có mảng messages gồm 4102 chuỗi, liệt kê vài phần tử mỗi phần tử có id là chuỗi gốc và string là bản dịch: phần trăm s records thành phần trăm s bản ghi, phần trăm s Files thành phần trăm s tệp, thăng Code editor thành thăng Trình chỉnh sửa mã. Cuối cùng có module quan_ca_phe cũng có mảng messages riêng, kèm chú thích mỗi module là một context

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:

Ảnh chụp console trình duyệt nền tối gõ trực tiếp trên Odoo 19 giao diện tiếng Việt bản dịch đã nạp. Dòng gọi _t với %s records và số 4 trả về chuỗi 4 bản ghi kèm chú thích dịch và chèn %s bằng 4. Gọi _t Discard trả về Huỷ bỏ, _t Save trả về Lưu, _t New trả về Mới. Gọi _t với Cà phê sữa đá quán ABC XYZ trả về đúng nguyên bản vì không có bản dịch. Dòng cuối kiểm tra translatedTerms translationLoaded trả về true

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ề

  1. 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/translations theo ngôn ngữ người dùng, không thấy thì _t trả nguyên bản chứ không mất chuỗi.
  2. Odoo 19: _t đã "lười" và _lt bị bỏ hẳn. _t trả về TranslatedString chỉ dịch khi bị ép thành chuỗi, nên dùng được cả ở top-level — code cũ import _lt phải đổi thành _t. Đừng ép ra chuỗi trước khi bản dịch nạp xong (ném lỗi).
  3. 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.