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.

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:

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:

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 handlerasync), đừng gọiloadBundleở 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
onWillStartchờ sẽ đỡ cảm giác treo. - Bắt lỗi tải.
loadJS/loadCSScó thử lại vài lần rồi némAssetsLoadingError; 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ề
- Nạp trễ hoãn tải khối nặng đến đúng lúc cần — nền tảng là
loadBundle/loadJS/loadCSStrong@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). LazyComponentlà component dựng sẵn của Odoo 19: đặtt-ifđể nó chỉ tồn tại khi cần,onWillStartsẽloadBundlerồi lấy component thật từ registrylazy_components— đúng cách trình soạn web chỉ tải khi bấm "Sửa".dynamic import()cho kiểm soát thủ công:importtĩ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.