Giới Thiệu
Chào các lập trình viên! Sau khi dẫn dắt nhiều dự án Next.js và chứng kiến sự khó khăn của các lập trình viên mới trong việc xử lý kích thước bundle, tôi nghĩ đã đến lúc chia sẻ một số chiến lược đã thử nghiệm cho việc code splitting trong App Router.
Thực Trạng: Tại Sao Người Dùng Của Bạn Bỏ Cuộc?
Hãy tưởng tượng: Bạn đã xây dựng một nền tảng thương mại điện tử tuyệt vời với tất cả các tính năng mà bạn có thể tưởng tượng. Biểu đồ, bản đồ, trình chỉnh sửa văn bản phong phú, chức năng xuất PDF - tất cả đều có mặt. Nhưng người dùng của bạn lại nhấn nút quay lại nhanh hơn cả khi bạn nói "dynamic import."
Thủ phạm? Chính là gói 2MB ban đầu bao gồm toàn bộ tính năng, mặc dù 80% người dùng không bao giờ cần đến bảng điều khiển quản trị hay tạo một PDF nào.
App Router: Thay Đổi Cuộc Chơi (Nhưng Không Phải Phép Màu)
Với Next.js 13+ App Router, các thành phần server của React tự động được code-split theo mặc định. Đây là tin tuyệt vời, nhưng có một điều cần lưu ý - các thành phần client vẫn cần chú ý của chúng ta. Tin tôi đi, đó là nơi phép thuật tối ưu hóa thực sự diễn ra.
Tình Huống Thực Tế: Xây Dựng Một Dashboard SaaS
Hãy để tôi dẫn bạn qua một dự án thực tế, một dashboard phân tích SaaS. Có nhiều tính năng nặng nề:
- Một dashboard trực quan dữ liệu phức tạp (Chart.js + D3)
- Một trình chỉnh sửa văn bản phong phú cho báo cáo (Monaco Editor)
- Chức năng xuất PDF (jsPDF)
- Widget hỗ trợ chat thời gian thực
Cách Thông Minh: Phân Tách Thành Phần Chiến Lược
Dưới đây là cách chúng tôi giải quyết dashboard với dynamic imports:
javascript
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
// Lazy load heavy components
const AnalyticsDashboard = dynamic(() => import('./AnalyticsDashboard'), {
loading: () => <DashboardSkeleton />, // Hiển thị khi đang tải
ssr: false // Không render trên server
})
const ReportEditor = dynamic(() => import('./ReportEditor'), {
loading: () => <EditorSkeleton />
})
const ChatWidget = dynamic(() => import('./ChatWidget'), {
ssr: false // Widget chỉ cho trình duyệt với các phụ thuộc window/localStorage
})
export default function DashboardPage() {
const [activeTab, setActiveTab] = useState('overview')
const [showChat, setShowChat] = useState(false)
return (
<div className="dashboard">
<nav>
<button onClick={() => setActiveTab('overview')}>Tổng Quan</button>
<button onClick={() => setActiveTab('analytics')}>Phân Tích</button>
<button onClick={() => setActiveTab('reports')}>Báo Cáo</button>
</nav>
{activeTab === 'analytics' && <AnalyticsDashboard />}
{activeTab === 'reports' && <ReportEditor />}
{showChat && <ChatWidget onClose={() => setShowChat(false)} />}
</div>
)
}
Kết Quả: Kích thước bundle ban đầu giảm từ 1.8MB xuống còn 350KB. Người dùng ở tab tổng quan có trải nghiệm mượt mà hơn.
Mẹo Chuyên Nghiệp: Tải Thư Viện Bên Ngoài Thông Minh
Đây là một mẫu mà tôi sử dụng cho các phụ thuộc bên ngoài nặng nề. Thay vì nhập jsPDF ở cấp cao nhất, chúng tôi tải nó theo yêu cầu:
javascript
'use client'
import { useState, useRef } from 'react'
export default function InvoiceGenerator() {
const [isGenerating, setIsGenerating] = useState(false)
const jsPdfRef = useRef(null)
const generatePDF = async (invoiceData) => {
setIsGenerating(true)
// Tải jsPDF chỉ khi thực sự cần
if (!jsPdfRef.current) {
const jsPdfModule = await import('jspdf')
jsPdfRef.current = jsPdfModule.default
}
const pdf = new jsPdfRef.current()
// ... logic tạo PDF
setIsGenerating(false)
}
const preloadPDF = async () => {
// Tải trước khi người dùng di chuột qua nút
if (!jsPdfRef.current) {
const jsPdfModule = await import('jspdf')
jsPdfRef.current = jsPdfModule.default
}
}
return (
<button
onMouseEnter={preloadPDF}
onClick={() => generatePDF(invoiceData)}
disabled={isGenerating}
>
{isGenerating ? 'Đang Tạo...' : 'Tải Xuống PDF'}
</button>
)
}
Mẫu này mang đến cho chúng tôi lợi ích của cả hai thế giới: kích thước gói ban đầu nhỏ và trải nghiệm người dùng phản hồi qua việc tải trước.
Khía Cạnh Server Component
Đây là thứ đã làm nhiều lập trình viên nhầm lẫn ban đầu - bạn có thể nhập động các thành phần server:
javascript
import dynamic from 'next/dynamic'
const HeavyServerComponent = dynamic(() => import('./HeavyServerComponent'))
export default function ServerPage() {
return (
<div>
<h1>Trang Của Tôi</h1>
<HeavyServerComponent />
</div>
)
}
Nhưng đây là điều quan trọng: điều này không tối ưu hóa việc tải phía server. Điều này cho phép lazy loading bất kỳ thành phần client nào lồng ghép bên trong thành phần server đó. Đây là một sự phân biệt tinh tế nhưng quan trọng.
Khi Nào KHÔNG Nên Sử Dụng Dynamic Imports
Học hỏi từ những sai lầm (vâng, tôi cũng đã mắc phải):
Đừng nhập động:
- Nội dung ở trên-fold (kẻ giết LCP)
- Các thành phần tiện ích nhỏ (<5KB)
- Các yếu tố điều hướng quan trọng
- Các thành phần cần thiết cho SEO
Nên nhập động:
- Các hộp thoại modal và overlay
- Các thành phần nặng nề theo tính năng
- Các widget bên thứ ba
- Các bảng điều khiển quản trị và các tính năng ít sử dụng
- Các thành phần cụ thể cho thiết bị
Mẫu Nâng Cao: Tải Tính Năng Điều Kiện
Đối với SaaS của chúng tôi, chúng tôi đã triển khai tải dựa trên cờ tính năng:
javascript
'use client'
import { useFeatureFlag } from '@/hooks/useFeatureFlag'
import dynamic from 'next/dynamic'
const AdvancedAnalytics = dynamic(() => import('./AdvancedAnalytics'))
const BasicAnalytics = dynamic(() => import('./BasicAnalytics'))
export default function AnalyticsWrapper() {
const hasAdvancedFeatures = useFeatureFlag('advanced-analytics')
return hasAdvancedFeatures ? <AdvancedAnalytics /> : <BasicAnalytics />
}
Điều này cho phép chúng tôi giao hàng với các kích thước bundle khác nhau dựa trên các cấp độ đăng ký của người dùng. Người dùng cao cấp có các thư viện biểu đồ nặng; người dùng cơ bản có phiên bản nhẹ hơn.
Đo Lường Thành Công: Những Con Số Quan Trọng
Sau khi triển khai các mẫu này trên dashboard của chúng tôi:
- Kích thước bundle ban đầu: 1.8MB → 350KB (-81%)
- Thời gian tương tác: 4.2s → 1.8s (-57%)
- Thời gian cho Contentful Paint đầu tiên: 2.1s → 0.9s (-57%)
- Tỷ lệ thoát: 23% → 8% (-65%)
Lợi Ích Trải Nghiệm Lập Trình
Một lợi ích không ngờ: các bản dựng phát triển của chúng tôi trở nên nhanh chóng hơn nhiều. Các lần tải lại nóng giảm từ 3-4 giây xuống dưới 1 giây vì chúng tôi không biên dịch các khối lớn không cần thiết.
Kết Luận
Code splitting trong App Router không chỉ là về hiệu suất - mà còn là xây dựng các ứng dụng bền vững, có thể mở rộng. Bắt đầu với các tính năng nặng (biểu đồ, trình chỉnh sửa, PDF), triển khai tải trước thông minh và luôn đo lường tác động.
Hãy nhớ: người dùng của bạn không quan tâm đến các tính năng đẹp mắt của bạn nếu họ không thể tải ứng dụng của bạn. Hãy làm cho trải nghiệm ban đầu nhanh chóng, sau đó nâng cấp dần với dynamic imports.