Trình soạn website của Odoo là một khối JavaScript đồ sộ — kéo–thả, snippet, bảng màu, đủ thứ. Nếu nó nằm trong bundle chính thì mọi trang backend đều phải tải nó về, kể cả khi bạn cả đời không bấm nút "Sửa". Odoo không làm vậy: khối builder chỉ được tải xuống đúng khoảnh khắc bạn vào chế độ chỉnh sửa. Đó là nạp trễ (lazy load) — hoãn tải một phần giao diện nặng đến khi thật sự cần. Bài này mổ xẻ ba cách làm điều đó trong Odoo 19: loadBundle/loadJS, component LazyComponent dựng sẵn, và dynamic import() — tất cả soi từ lõi thật và bằng chứng network chụp trực tiếp.

Vì sao phải nạp trễ

Mọi thứ bạn nhét vào web.assets_backend sẽ đi vào lần tải đầu tiên của mỗi phiên làm việc. Với một widget dùng thường xuyên thì xứng đáng. Nhưng với một khối chỉ thỉnh thoảng mới đụng tới — trình soạn email, bản đồ, biểu đồ nặng, trình dựng web — bắt tất cả người dùng tải nó ngay từ đầu là lãng phí băng thông và làm chậm lúc khởi động. Nạp trễ đảo ngược: để yên cho tới khi cần, rồi mới đi lấy.

Ảnh chụp đoạn mã JavaScript và XML nền tối. Phần đầu nhập loadBundle loadJS loadCSS từ web core assets rồi trong hàm async gọi loadJS nạp một file thư viện và loadBundle nạp cả một bundle với chú thích tới đây thư viện nặng mới thực sự tải về. Phần giữa dùng thẻ LazyComponent với t-if state isEditing, thuộc tính Component trỏ website WebsiteBuilder, bundle là website website_builder_assets, kèm props. Phần cuối tự làm bằng dynamic import trong một class Dashboard: setup tạo useState với Chart null, hàm moChart await import bieu_do_nang rồi gán state Chart để render sau khi tải xong

Hình 1: Ba cách nạp trễ. loadJS/loadBundle (nhập từ @web/core/assets) đi lấy file hoặc cả bundle theo yêu cầu; LazyComponent là component dựng sẵn hoãn cả một component nặng; và dynamic import() — cú pháp chuẩn của JS — cho bạn tự kiểm soát thời điểm tải.

loadBundle: đi lấy cả một bó tài nguyên

Nền tảng của mọi thứ là hàm loadBundle trong @web/core/assets. Đưa cho nó tên một bundle, nó fetch danh sách file của bundle đó rồi tự chèn từng <script>/<link> vào trang. Cơ chế này không phải tôi suy đoán — đây là network chụp thẳng trên hệ thống đang chạy:

Ảnh chụp console và Network thật trên Odoo 19 backend đang mở, nạp bundle theo yêu cầu. Dòng lệnh lấy module web core assets rồi gọi await getBundle với web assets_frontend, trả về một đối tượng có jsLibs chứa đường dẫn web assets băm web assets_frontend min js và cssLibs chứa web assets_frontend min css. Bên dưới phần Network liệt kê hai lần GET tới web bundle web assets_frontend kèm lang vi_VN trả về 200 và web bundle web_tour interactive lang vi_VN trả về 200, kèm chú thích web bundle tên trả về danh sách file còn loadBundle chèn từng script link

Hình 2: getBundle("web.assets_frontend") gọi thẳng /web/bundle/web.assets_frontend và nhận về một bản kê file: một jsLibs và một cssLibs, mỗi cái là URL có mã băm nội dung. loadBundle chỉ là bước tiếp theo — chèn từng URL đó vào <head>. Để ý dòng /web/bundle/web_tour.interactive cũng được fetch sau khi trang đã mở: đó chính là nạp trễ đang chạy thật ngay trong backend.

Lõi của nó gọn đúng như mô tả:

loadBundle(bundleName, { targetDoc = document, css = true, js = true } = {}) {
    return getBundle(bundleName).then(({ cssLibs, jsLibs }) => {
        const promises = [];
        if (css && cssLibs) promises.push(...cssLibs.map((url) => loadCSS(url)));
        if (js && jsLibs)   promises.push(...jsLibs.map((url) => loadJS(url)));
        return Promise.all(promises);
    });
}

Và cả loadJS lẫn loadCSS đều có cache: gọi lại cùng một URL, nó trả về đúng promise cũ chứ không tải lần hai. Nạp trễ nhiều lần vẫn an toàn.

LazyComponent: hoãn cả một component

Trên loadBundle, Odoo 19 dựng sẵn một component tiện dụng tên LazyComponent. Bạn đưa cho nó tên một component (đã đăng ký trong registry lazy_components) và tên bundle chứa component đó; nó lo phần còn lại. Đây là mã lõi thật:

Ảnh chụp mã nguồn lõi Odoo 19 file web core assets js nền tối. Khai class LazyComponent kế thừa Component, có template xml gọi t-component Component với t-props componentProps, phần props khai Component kiểu String, bundle kiểu String, và props tuỳ chọn kiểu Object hoặc Function. Trong setup gọi onWillStart bất đồng bộ await loadBundle với this props bundle rồi gán this Component bằng registry category lazy_components get this props Component

Hình 3: LazyComponent trong lõi Odoo 19. Trong onWillStart (chạy trước khi render), nó await loadBundle(...) để kéo bundle về, rồi lấy component thật ra khỏi registry lazy_components. Template của nó chỉ là <t t-component="Component"/> — nên khi bundle chưa về, chưa có gì được vẽ; bundle về xong, component thật hiện ra.

Cách dùng thật trong module website gói gọn một dòng:

<LazyComponent t-if="state.isEditing"
    Component="'website.WebsiteBuilder'"
    bundle="'website.website_builder_assets'"
    props="() => this.websiteBuilderProps"/>

Mấu chốt là t-if="state.isEditing": chừng nào người dùng chưa bấm "Sửa", LazyComponent còn chưa tồn tại, nên onWillStart chưa chạy, bundle website.website_builder_assets chưa được tải. Bấm "Sửa" → state.isEditing thành true → component dựng lên → bundle mới đi về. Đúng khoảnh khắc, không sớm hơn một giây.

Để LazyComponent tìm được component, phía module nặng phải tự đăng ký vào registry:

registry.category("lazy_components").add("website.WebsiteBuilder", WebsiteBuilder);

dynamic import(): tự kiểm soát hoàn toàn

Khi không cần cả bộ máy bundle mà chỉ muốn hoãn một module JS, dùng thẳng cú pháp chuẩn của JavaScript: import() dạng hàm (khác import tĩnh ở đầu file). Nó trả về một promise, và trình nạp module của Odoo hiểu được:

async moChart() {
    const { BieuDoNang } = await import("./bieu_do_nang");
    this.state.Chart = BieuDoNang;   // gán vào state -> OWL render lại
}

Điểm khác biệt: import tĩnh ở đầu file luôn được gộp vào bundle và tải cùng lúc; import() động trong thân hàm chỉ chạy khi hàm được gọi. Giữ this.state.Chart = null ban đầu và dùng <t t-if="state.Chart" t-component="state.Chart"/> trong template — khối biểu đồ chỉ dựng sau khi người dùng thực sự mở tới nó.

Vài lưu ý khi nạp trễ

  • Luôn hoãn trong một xử lý bất đồng bộ (onWillStart, một handler async), đừng gọi loadBundle ở top-level module — thế thì mất luôn ý nghĩa "trễ".
  • Cho người dùng thấy trạng thái đang tải. Bundle nặng mất vài trăm mili giây; một spinner hay dòng "Đang tải..." trong lúc onWillStart chờ sẽ đỡ cảm giác treo.
  • Bắt lỗi tải. loadJS/loadCSS có thử lại vài lần rồi ném AssetsLoadingError; component nặng nên bọc trong error boundary để mạng lỗi không kéo sập cả trang.
  • Đừng lạm dụng. Nạp trễ trả giá bằng một lần fetch khi người dùng bấm. Thứ dùng liên tục cứ để trong bundle chính; chỉ hoãn những khối to và ít đụng tới.

Ba ý mang về

  1. Nạp trễ hoãn tải khối nặng đến đúng lúc cần — nền tảng là loadBundle/loadJS/loadCSS trong @web/core/assets, fetch /web/bundle/<tên> để lấy bản kê file rồi chèn <script>/<link> (có cache, gọi lại không tải lần hai).
  2. LazyComponent là component dựng sẵn của Odoo 19: đặt t-if để nó chỉ tồn tại khi cần, onWillStart sẽ loadBundle rồi lấy component thật từ registry lazy_components — đúng cách trình soạn web chỉ tải khi bấm "Sửa".
  3. dynamic import() cho kiểm soát thủ công: import tĩnh vào bundle, import() động chỉ tải khi hàm chạy; gán kết quả vào state để OWL render sau.

Viết được rồi thì cũng phải gỡ được. Phần sau chuyển sang debug OWL bằng devtools — cách nhìn vào cây component đang chạy, xem state/props, và lần ra vì sao một chỗ không render như mong đợi.