Web Performance · Part 8 — JavaScript Performance & Bundle
Client JavaScript is often costly because it must download, parse, compile, and execute. Measure bundles, split by route/component, tree-shake, lazy-load, and trim dependencies.
JavaScript thường đắt hơn một tài nguyên ảnh cùng kích thước vì ngoài truyền byte, nó còn phải parse → compile → execute; phần thực thi và công việc DOM/render do script kích hoạt có thể chặn main thread, ảnh hưởng INP. Vì vậy “ít JS phía client hơn” thường là một trong những đòn bẩy hiệu năng mạnh nhất.
1. Vì sao byte JS đắt hơn byte ảnh
Image 200KB: download ──▶ decode ──▶ paint
JS 200KB: download ──▶ parse ──▶ compile ──▶ EXECUTE (blocks main thread) ──▶ ...
Chi phí không chỉ phụ thuộc số KB mà còn vào code, engine và thiết bị. Trên điện thoại tầm thấp, parse/compile/execute một bundle vừa phải vẫn có thể tạo long task; hãy xem trace thay vì suy luận từ transfer size đơn thuần.
2. Đo bundle trước đã
Đừng đoán cái gì nặng. Dùng bundle analyzer để thấy thành phần:
# Vite: use rollup-plugin-visualizer
npm i -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [visualizer({ open: true, gzipSize: true, brotliSize: true })],
});
Nhìn treemap để phát hiện: thư viện to bất ngờ (moment.js, lodash full, icon pack nguyên cục), code trùng lặp, hay dependency lẽ ra chỉ cần ở server. Đây là bước cho ROI cao nhất.
3. Code Splitting: chỉ tải JS cần cho lúc này
Bundler/framework hiện đại có thể tự tách vendor hoặc route, nhưng ranh giới không phải lúc nào cũng khớp nhu cầu sản phẩm. Dynamic import() tạo một async boundary rõ ràng để phần code ít dùng chỉ tải khi cần.
Tách theo route
Mảnh đáng tách nhất là theo trang. Dynamic import() báo bundler tạo chunk riêng:
import { lazy, Suspense } from 'react';
// each route becomes its own chunk, loaded only on navigation
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Tách theo component nặng
Thành phần nặng và ít dùng (editor rich text, biểu đồ, modal phức tạp) nên tải khi cần:
const Chart = lazy(() => import('./Chart')); // load only when user opens the chart tab
function Report({ showChart }: { showChart: boolean }) {
return <div>{showChart && <Suspense fallback={<Spinner />}><Chart /></Suspense>}</div>;
}
Có thể preload theo ý định người dùng để giảm độ trễ mà không tải từ đầu: gọi cùng import() khi pointer hover hoặc focus; module cache bảo đảm lần render sau dùng lại request đó.
const Chart = lazy(() => import('./Chart'));
const preloadChart = () => void import('./Chart');
function ChartButton({ onClick }: { onClick: () => void }) {
return (
<button onPointerEnter={preloadChart} onFocus={preloadChart} onClick={onClick}>
Mở biểu đồ
</button>
);
}
Chỉ làm vậy khi xác suất người dùng mở tính năng đủ cao; preload/prefetch bừa vẫn tranh băng thông. Phần 14 đào sâu resource hints và Speculation Rules.
4. Tree Shaking: loại code chết
Tree shaking loại bỏ export không dùng tới lúc build. Để nó hoạt động:
- Ưu tiên ES modules (
import/export) vì bundler phân tích tĩnh đáng tin cậy hơn. Một số bundler tối ưu được CommonJS, nhưngrequire()động khiến tree shaking khó hoặc kém ổn định hơn. - Import có chọn lọc, không import cả thư viện:
// ❌ pulls (nearly) all of lodash into the bundle
import _ from 'lodash';
_.debounce(fn, 200);
// ✅ only take what you need (or use lodash-es / sub-modules)
import debounce from 'lodash-es/debounce';
- Tránh side-effect ở module top-level khiến bundler không dám loại; tác giả package chỉ nên khai báo
"sideEffects": falsekhi mọi module thật sự an toàn để bỏ. Khai sai có thể làm mất import CSS hoặc code đăng ký chạy ở top-level.
5. Cắt giảm thư viện bên thứ ba
Dependency thường là phần lớn bundle. Chiến lược:
- Thay thư viện nặng bằng giải pháp phù hợp hơn: dùng
Intlcho format ngày/số,date-fnstheo hàm cho phép tính ngày, hoặcTemporalkhi runtime mục tiêu hỗ trợ (hay bạn chấp nhận polyfill);axios→fetchnếu không cần abstraction riêng; lodash → native hoặc import theo hàm khi semantics tương đương. - Kiểm tra “giá” trước khi thêm: tra trên bundlephobia để biết một package nặng bao nhiêu trước khi cài.
- Import động cho thư viện chỉ dùng đôi khi: ví dụ thư viện xuất PDF chỉ tải khi người dùng bấm “Xuất PDF”.
async function exportPdf(data: Report) {
const { jsPDF } = await import('jspdf'); // load only when export is actually needed
// ...
}
6. Giảm chi phí execute, không chỉ tải
Tải ít hơn là một nửa; nửa còn lại là chạy ít hơn lúc khởi động:
- Hoãn khởi tạo không cần ngay: đừng init mọi thứ lúc load; init khi dùng.
- Tránh công việc nặng đồng bộ ở top-level module (chạy ngay khi import).
- Hydration tốn kém (SSR): cân nhắc partial/islands hydration để không hydrate cả trang một lần (kiến trúc islands của Astro chính là tối ưu này — JS tối thiểu, hydrate theo đảo).
7. Checklist JS performance
- Đã đo bundle (analyzer) chưa? Có thư viện to bất ngờ không?
- Code-split theo route chưa?
- Thành phần nặng/ít dùng có lazy load không?
- Tree shaking hoạt động (ESM, import chọn lọc)?
- Thư viện nặng có thể thay/bỏ không (moment, lodash full)?
- Có preload theo ý định (pointer/focus) cho chunk xác suất dùng cao?
- Khởi động có làm việc nặng đồng bộ thừa không?
Tóm tắt
- Byte JS thường đắt vì phải parse + compile + execute và có thể tạo thêm DOM/render work trên main thread.
- Đo bundle trước bằng analyzer; tìm thư viện to và code trùng.
- Code splitting theo route + component nặng; preload theo pointer/focus khi có tín hiệu ý định đủ mạnh.
- Tree shaking cần ESM + import chọn lọc; cắt thư viện nặng (moment/lodash full).
- Giảm cả chi phí execute lúc khởi động (hoãn init, islands/partial hydration).
Phần tiếp theo: Ảnh & media — định dạng hiện đại, responsive images, lazy loading đúng cách.