Trong dev mọi thứ mượt như bơ, CI build xanh, deploy xong mở trang thì trắng bóc. Console báo lỗi ở đâu đó trong vendor chunk, còn ngồi sửa trong dev thì sửa kiểu gì cũng không tái hiện được. Kiểu bug đó mình gặp đủ nhiều để rút ra kết luận: phần lớn đến từ một chỗ — dev server và bước build production của Vite chạy trên hai bundler khác nhau.
Vì sao dev server nhanh đến vậy
Vite trong lúc dev gần như không đụng tới code của bạn. Nó chỉ làm hai việc:
- Pre-bundle dependency trong
node_modulesbằng esbuild — viết bằng Go nên nhanh, gom hàng trăm file nhỏ thành vài file ESM để trình duyệt đỡ bắn cả nghìn request. - Serve source dạng ESM gốc, biên dịch từng file theo yêu cầu.
Đó là lý do cold start tính bằng vài trăm mili giây: Vite không bundle app của bạn, trình duyệt tự fetch file nào cần. HMR cũng nhờ thế — Vite giữ module graph, sửa một file thì chỉ báo cho đúng nhánh phụ thuộc, không reload cả trang.
Còn vite build thì chạy bằng Rollup
Khác hẳn esbuild: Rollup phân tích tĩnh kỹ hơn, tree-shake mạnh hơn, có commonjsOptions, manualChunks, và cả một bộ luật riêng để đoán module nào có side effect. Nghĩa là thứ dev cho qua chưa chắc build cho qua.
Ba cái bẫy mình từng dính, đều là "dev chạy ngon, prod mới nổ":
1. Import chỉ để lấy side effect bị tree-shake mất
// src/main.ts — chỉ import để polyfill tự gắn vào window
import 'resize-observer-polyfill';
import { mountApp } from './app';
mountApp();
Trong package.json của app, nếu bạn khai "sideEffects": false (thói quen tốt để giảm bundle), Rollup tin lời khai đó và xoá luôn dòng import 'resize-observer-polyfill'. Dev server thì không xoá, vì nó phục vụ từng file nguyên trạng. Kết quả: prod thiếu polyfill, ResizeObserver is not defined ở đúng cái component khó thấy nhất.
2. Dynamic import có path biến đổi
export async function loadLocale(lang: string) {
const mod = await import(`./locales/${lang}.ts`);
return mod.messages;
}
Dev: browser tự fetch /src/locales/vi.ts, chạy tốt. Build: Rollup không phân tích được path động, phát warning cannot be analyzed rồi để nguyên dạng biểu thức — prod không tìm thấy chunk. Cách đúng là để bundler biết trước danh sách:
const locales = import.meta.glob<{ messages: Record<string, string> }>('./locales/*.ts');
export async function loadLocale(lang: string) {
const load = locales[`./locales/${lang}.ts`];
if (!load) throw new Error(`Locale khong ton tai: ${lang}`);
return (await load()).messages;
}
3. CJS interop
import legacy from 'legacy-analytics' với package chỉ có CommonJS: esbuild pre-bundle ở dev bọc interop nên legacy.init() gọi được, còn Rollup đôi khi không suy ra được named export và trả undefined. Fix gọn nhất là cho dev và build nhìn cùng một hình dạng module: optimizeDeps.include: ['legacy-analytics'], hoặc import default rồi destructure sau khi kiểm tra kiểu.
Cách phòng, chứ không phải cách sửa
- Test trên bản build, không chỉ trên dev.
vite build && vite previewrồi smoke test vài route chính trong CI. Đây là bước rẻ nhất và chặn được gần hết nhóm lỗi này. - Khai
sideEffectscho đúng. Chỉ đặtfalsekhi thật sự không có file nào tự gây tác dụng phụ. Thư viện nội bộ có file CSS hayinitGlobal()thì nên liệt kê tường minh:"sideEffects": ["*.css", "./src/register.ts"]. - Dùng
import.meta.globcho mọi chỗ import theo biến, thư viện i18n, registry plugin, danh sách trang — thay vì dựa vào may mắn. - Coi warning lúc build là lỗi.
cannot be analyzedhayis not exported byđều là điềm báo prod, đừng để log trôi. Nếu bạn từng đọc bài mình viết vềexports mapvà ESM/CJS khi build thư viện, đây chính là mặt còn lại của cùng một câu chuyện.
Điểm đáng nói: bạn không cần nhớ hết luật của Rollup. Chỉ cần nhớ Vite dev và Vite build là hai đường ống khác nhau, nên bất cứ thứ gì "chỉ đúng ở dev" đều phải kiểm bằng bản build thật.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Pre-bundle (optimizeDeps) | Bước esbuild gom dependency trong node_modules thành ít file ESM, chạy một lần rồi cache |
| Tree-shaking | Loại code không dùng khỏi bundle dựa trên phân tích tĩnh import/export |
| Side effect | Tác dụng phụ khi import module: gắn global, ghi file, đăng ký listener, nạp CSS |
| CJS interop | Lớp bọc cho package CommonJS để code ESM import được |
import.meta.glob |
API của Vite quét file theo pattern lúc build, trả map path sang hàm import lười |
manualChunks |
Cấu hình chia chunk thủ công cho Rollup khi build |
| HMR | Hot Module Replacement — thay module đang chạy mà không reload cả trang |
Vậy còn bạn, lần gần nhất dev xanh mà prod đỏ là do cái bẫy nào? Mình cá là side effect hoặc path động.
