Web Performance Deep Dive

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": false cho 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-visualizer

Khi 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">

💡 prefetch khác preload. 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 dedupe

Trê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.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.