Giảm 45% bundle size — Tree-shaking không tự cứu bạn đâu
Bundle JavaScript 1.2MB gzipped. Trên desktop mạng nhanh thì không ai thấy vấn đề. Nhưng đặt vào bối cảnh user mobile 4G ở Việt Nam — parse time 3 giây, TTI (Time to Interactive) 6 giây. Nút "Mua ngay" hiện ra nhưng bấm không được vì main thread đang bận parse JavaScript. Đó là vấn đề thực tế tôi gặp trên một SPA e-commerce.
Vấn đề không phải ở bundler — mà ở cách bạn viết code
Nhiều người nghĩ chỉ cần dùng webpack hoặc Vite là tree-shaking tự động xử lý hết. Sai. Tree-shaking chỉ hoạt động khi module hệ thống và cách viết code cho phép.
Side effects phá hỏng tree-shaking
// utils/index.js — barrel file kinh điển
export { formatDate } from './date';
export { formatCurrency } from './currency';
export { trackEvent } from './analytics'; // ← side effect ở đây
export { logger } from './logger';// analytics.js
import posthog from 'posthog-js';
// Dòng này chạy khi module được import, dù bạn không dùng trackEvent
posthog.init('phc_xxx', { api_host: 'https://app.posthog.com' });
export function trackEvent(name, props) {
posthog.capture(name, props);
}Khi bạn import formatDate từ barrel file, bundler thấy analytics.js có side effect (gọi posthog.init), nên không dám loại bỏ. Kết quả: cả posthog-js (45KB gzipped) lọt vào bundle dù bạn chỉ cần format ngày tháng.
Cách fix: sideEffects flag trong package.json
{
"name": "my-app",
"sideEffects": [
"./src/polyfills.js",
"*.css"
]
}Đánh dấu rõ ràng file nào có side effect. Bundler sẽ mạnh tay loại bỏ phần còn lại nếu không được import trực tiếp.
⚠️ Đừng set
"sideEffects": falsecho toàn bộ project nếu bạn chưa kiểm tra kỹ. CSS imports, polyfills, module augmentation — tất cả sẽ bị loại bỏ âm thầm.
Phân tích bundle — bước đầu tiên bắt buộc
Trước khi optimize phải biết cái gì chiếm nhiều nhất. Dùng webpack-bundle-analyzer hoặc vite-bundle-visualizer:
# Webpack
npx webpack --profile --json=stats.json
npx webpack-bundle-analyzer stats.json
# Vite
npx vite build --mode production
npx vite-bundle-visualizerKhi nhìn treemap, tập trung vào:
- Top 5 packages lớn nhất — thường là low-hanging fruit
- Duplicate packages — cùng library khác version nằm song song
- Packages bạn không biết — transitive dependencies có thể bất ngờ lớn
Trên project e-commerce kia, treemap cho thấy: moment.js (67KB gzipped, dùng đúng 2 function), lodash full bundle (25KB, dùng 4 functions), và posthog-js bị kéo vào vì barrel file.
Thay thế heavy dependencies
moment.js → date-fns hoặc Temporal
// Trước: import cả moment (67KB gzipped)
import moment from 'moment';
const formatted = moment(date).format('DD/MM/YYYY');
// Sau: import đúng function cần (0.5KB cho function này)
import { format } from 'date-fns';
const formatted = format(date, 'dd/MM/yyyy');moment.js nổi tiếng là không tree-shakeable vì kiến trúc mutable prototype. Chuyển sang date-fns giảm ngay 60KB+ gzipped.
lodash → lodash-es hoặc native
// Trước: kéo cả lodash vào bundle
import { debounce, groupBy, uniqBy, cloneDeep } from 'lodash';
// Sau: dùng lodash-es (tree-shakeable) hoặc native
import debounce from 'lodash-es/debounce';
// Hoặc tự viết debounce — 15 dòng thay vì dependency
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}💡 Trước khi thêm utility library, tự hỏi: "Mình dùng bao nhiêu function?" Nếu dưới 5, hãy cân nhắc tự viết hoặc dùng native API.
Dynamic import — lazy load đúng cách
Tree-shaking loại bỏ code không dùng. Dynamic import tách code dùng nhưng chưa cần ngay ra chunk riêng.
Route-based code splitting
// React + React Router
import { lazy, Suspense } from 'react';
const ProductPage = lazy(() => import('./pages/ProductPage'));
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
function App() {
return (
<Routes>
<Route path="/product/:id" element={
<Suspense fallback={<PageSkeleton />}>
<ProductPage />
</Suspense>
} />
<Route path="/checkout" element={
<Suspense fallback={<PageSkeleton />}>
<CheckoutPage />
</Suspense>
} />
<Route path="/admin/*" element={
<Suspense fallback={<PageSkeleton />}>
<AdminDashboard />
</Suspense>
} />
</Routes>
);
}Trước khi split: 1 bundle 1.2MB. Sau khi split: initial bundle 380KB, mỗi route chunk 50-200KB load on demand.
Component-level splitting cho heavy libraries
Không phải lúc nào cũng split theo route. Đôi khi một component cụ thể kéo theo library nặng:
// Chart component dùng recharts (180KB gzipped)
// Chỉ load khi user scroll đến section analytics
import { lazy, Suspense, useRef } from 'react';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';
const AnalyticsChart = lazy(() => import('./AnalyticsChart'));
function Dashboard() {
const chartRef = useRef(null);
const isVisible = useIntersectionObserver(chartRef, { threshold: 0.1 });
return (
<div>
<DashboardHeader />
<DashboardStats />
<div ref={chartRef}>
{isVisible ? (
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
) : (
<ChartSkeleton />
)}
</div>
</div>
);
}Chart component chỉ load khi user scroll đến — tiết kiệm 180KB trên initial load.
Prefetch — thông minh hơn lazy load đơn thuần
Dynamic import giảm initial bundle nhưng tạo latency khi navigate. Giải pháp: prefetch chunk khi browser idle.
// Prefetch route chunk khi user hover vào link
function PrefetchLink({ to, children, ...props }) {
const handleMouseEnter = () => {
// Webpack magic comment để prefetch
if (to === '/checkout') {
import(/* webpackPrefetch: true */ './pages/CheckoutPage');
}
};
return (
<Link to={to} onMouseEnter={handleMouseEnter} {...props}>
{children}
</Link>
);
}Hoặc dùng <link rel="prefetch"> cho chunk biết trước:
<!-- Prefetch chunk tiếp theo khi browser idle -->
<link rel="prefetch" href="/assets/checkout-chunk.js" as="script">💡
prefetchkhácpreload. Preload = "cần ngay bây giờ, high priority". Prefetch = "có thể cần sớm, low priority, load khi idle". Nhầm lẫn 2 cái này sẽ phản tác dụng.
Duplicate packages — kẻ thù thầm lặng
Khi hai dependency cùng dùng chung library nhưng khác version, bundler sẽ include cả hai. Ví dụ: package A dùng tslib@2.5.0, package B dùng tslib@2.6.2 — bundle chứa 2 bản tslib.
# Tìm duplicate packages
npx webpack --stats-children | grep "duplicate"
# Hoặc dùng yarn
yarn dedupe
# Hoặc pnpm
pnpm dedupeTrên project kia, sau khi dedupe: loại bỏ 3 bản tslib thừa, 2 bản regenerator-runtime, 1 bản core-js cũ. Tổng tiết kiệm: 35KB gzipped.
Module concatenation (Scope hoisting)
Webpack mặc định wrap mỗi module trong một function. Với hàng trăm modules, overhead cộng lại đáng kể. Module concatenation gộp modules cùng scope:
// webpack.config.js
module.exports = {
optimization: {
concatenateModules: true, // mặc định true ở production mode
},
};Kiểm tra bằng:
npx webpack --stats-modules-space 50 | grep "concatenated"Nếu thấy ít module được concatenated, nguyên nhân thường là: CommonJS imports (không phải ESM), dynamic requires, hoặc sideEffects chưa khai báo đúng.
Kết quả
| Kỹ thuật | Bundle giảm |
|---|---|
| Fix barrel file + sideEffects | -120KB |
| moment → date-fns | -67KB |
| lodash → lodash-es + native | -22KB |
| Route-based code splitting | -820KB initial |
| Lazy load heavy components | -180KB initial |
| Dedupe packages | -35KB |
| Tổng initial bundle | 1.2MB → 380KB |
TTI trên mobile 4G giảm từ 6 giây xuống 2.8 giây. Nút "Mua ngay" phản hồi ngay khi user thấy nó.
Bài học: tree-shaking chỉ là điểm khởi đầu. Nếu bạn không kiểm soát side effects, không phân tích bundle, và không lazy load thông minh — bundler không thể cứu bạn. Hãy coi bundle size như technical debt: nếu không monitor liên tục, nó sẽ phình ra mà không ai nhận ra.