Cách tôi giảm 67% Initial Bundle cho dự án Vue + Vite
Nếu bạn đang làm việc với một dự án Vue + Vite có nhiều module, có thể bạn đang gặp cùng một vấn đề mà tôi từng gặp: trang web tải chậm không phải vì code nhiều, mà vì code load sai thời điểm.
Bài viết này ghi lại những gì tôi đã làm — không có gì fancy, chỉ là thay đổi cách import — nhưng kết quả thì khá ấn tượng.
Chuyện gì đang xảy ra?
Dự án của tôi là một SPA khá lớn: hơn 10 module, hàng trăm component. Mọi thứ chạy ổn cho đến khi tôi để ý rằng lần đầu truy cập, trình duyệt phải tải một file JS nặng gần 1.5 MB.
Vấn đề nằm ở đây: Vite bắt đầu từ main.ts, đi theo tất cả các import statement, và nhồi mọi thứ vào một chunk duy nhất. Nếu bạn import tĩnh — Vite hiểu rằng "à, cái này cần ngay" — và đóng gói nó vào initial bundle.
main.ts
├── router/index.ts
│ ├── import LayoutA ← load ngay
│ ├── import LayoutB ← load ngay
│ └── ...modules/*/routes.ts
│ ├── import MainLayout ← load ngay
│ ├── import PageDashboard ← load ngay
│ └── ... (15+ components)
├── MainLayout.vue
│ ├── import ModalSettings ← load ngay
│ ├── import SidebarChat ← load ngay
│ └── ... (9 components)
└── HeavyChartPlugin (global plugin) ← load cho MỌI trang
User chỉ vào trang chủ thôi, nhưng phải tải code của cả trang Chat, trang Dashboard, trang Quản lý... Nghe vô lý đúng không?
Và đây là kết quả sau khi sửa
| Trước | Sau | Thay đổi | |
|---|---|---|---|
| Entry chunk | 1,487.79 KB | 479.95 KB | -67.7% |
| Entry chunk (gzip) | 412.34 KB | 158.58 KB | -61.5% |
| CSS preload | 25 files | 18 files | -28% |
| Tổng JS | 11.03 MB | 11.06 MB | ~0% |
Dòng cuối là điểm quan trọng nhất: tổng dung lượng không đổi. Tôi không xoá code nào cả — chỉ thay đổi thời điểm nó được load.
# Entry chunk trước tối ưu
- index-[hash].js 1,487.79 kB │ gzip: 412.34 kB
# Entry chunk sau tối ưu
+ index-[hash].js 479.95 kB │ gzip: 158.58 kB
Tôi đã làm gì?
1. Bỏ component ra khỏi global
Đây là sai lầm "kinh điển" mà ai cũng có thể mắc: đăng ký một plugin global trong main.ts dù nó chỉ dùng ở 3 component.
**❌ Trước**
Cả thư viện `vue3-apexcharts` được load cho **mọi trang**, kể cả trang không có biểu đồ nào.
```ts
// src/main.ts
import VueApexCharts from 'vue3-apexcharts';
const app = createApp(App)
.use(VueApexCharts as any) // ← Trang nào cũng phải gánh
```
<!-- slide -->
**✅ Sau**
Xoá khỏi global, import trực tiếp ở component cần dùng.
```ts
// src/main.ts — sạch sẽ hơn
const app = createApp(App)
.use(router)
.directive('click-outside', vClickOutside);
```
```vue
<!-- src/components/charts/MyChart.vue -->
<script>
import VueApexCharts from 'vue3-apexcharts';
export default {
components: { apexchart: VueApexCharts },
};
</script>
```
Câu hỏi tôi tự đặt ra: "Plugin này có dùng ở hơn một nửa số trang không?" — Nếu không, nó không xứng đáng là global.
2. Lazy load route components — thay đổi lớn nhất
Đây là nơi mang lại kết quả rõ rệt nhất. Hầu hết route files đều import tĩnh component ở đầu file:
**❌ Trước**
```ts
// src/modules/dashboard/routes.ts
import MainLayout from './layouts/index.vue';
import PageDashboard from './pages/Dashboard/index.vue';
const routes = [
{
path: '/dashboard',
component: MainLayout, // ← bundled ngay vào initial chunk
children: [
{ path: 'overview', component: PageDashboard },
],
},
];
```
Vite thấy `import MainLayout` → resolve cả module graph của nó → nhồi vào entry chunk.
<!-- slide -->
**✅ Sau**
```ts
// src/modules/dashboard/routes.ts
const routes = [
{
path: '/dashboard',
component: () => import('./layouts/index.vue'),
children: [
{
path: 'overview',
component: () => import('./pages/Dashboard/index.vue'),
},
],
},
];
```
`() => import()` nói với Vite: *"Tạo chunk riêng cho cái này, load khi nào user truy cập route."*
Tôi áp dụng cách này lên ~15 components trên 10 route files.
3. defineAsyncComponent cho những component lớn & không hiện ngay
Tối ưu này tôi áp dụng cho MainLayout — layout chính mà tất cả các trang đều dùng. Mọi thứ import tĩnh trong MainLayout sẽ nằm trong initial bundle của toàn bộ ứng dụng, nên mỗi KB tiết kiệm ở đây đều có tác động lên mọi route.
Giả sử trong layout của bạn có một component rất lớn (ví dụ: HeavyComponent), nhưng nó chỉ hiển thị khi người dùng bấm nút mở (v-if). Nếu import tĩnh theo thói quen thông thường, component này sẽ bị nhét vào initial bundle và khiến trang load chậm một cách vô ích.
**❌ Trước**
```ts
import AppHeader from '@/components/AppHeader.vue'; // Component nhẹ, hiện ngay
import HeavyComponent from '@/components/HeavyComponent.vue'; // Nặng, chỉ hiện khi gọi
export default {
components: {
AppHeader,
HeavyComponent
},
};
```
<!-- slide -->
**✅ Sau**
```ts
import { defineAsyncComponent } from 'vue';
import AppHeader from '@/components/AppHeader.vue';
export default {
components: {
AppHeader,
// Chỉ tải file JS của component này khi điều kiện v-if thành true
HeavyComponent: defineAsyncComponent(
() => import('@/components/HeavyComponent.vue')
)
},
};
```
4. ESLint rule để team không "undo" tối ưu
Tối ưu xong, lo nhất là teammate commit thêm route với static import. Nên tôi thêm luôn ESLint rule:
// eslint.config.mjs
{
files: ['src/**/routes.ts'],
rules: {
'no-restricted-syntax': ['warn', {
selector: 'Property[key.name="component"][value.type="Identifier"]',
message: 'Route component should use dynamic import: component: () => import("...")',
}],
},
},
Rule này dùng AST selector: nếu component trỏ đến một biến (Identifier) thay vì arrow function → cảnh báo ngay trong editor. Không cần code review mới phát hiện.
Những gì tôi rút ra
- Mặc định dùng
() => import()cho route — Không có lý do gì để static import component trong route config. - Global plugin phải thật sự global — Nếu chỉ dùng ở vài trang, hãy import local.
v-ifcomponent =defineAsyncComponent— Đặc biệt với modal, drawer, notification.- Encode rule vào ESLint — Tối ưu thủ công sẽ bị regression, tooling thì không.
Tất cả những thay đổi trên đều đơn giản — không cần refactor kiến trúc, không cần thêm library, chỉ cần hiểu Vite bundle code như thế nào và thay đổi cách import cho phù hợp.
All rights reserved