Bundle 分析与代码分割
JavaScript 是影响页面交互性能的第一大因素。100KB 的 JS 需要下载、解析、编译、执行——在中端手机上可能耗时 500ms+。Next.js 的 App Router 已经做了很多自动优化(按路由分割、Server Components 零 JS),但你仍然可能因为不当引入而把 500KB 的库打进首屏 bundle。本章讲清如何分析 bundle 组成、利用 Tree Shaking 消除死代码、用动态导入延迟加载重型组件。
1. Bundle 分析
1.1 为什么要分析 Bundle
你可能不知道的事实:
import dayjs from 'dayjs'只用了格式化日期,但打包了 dayjs 的所有 locale(+200KB)import { Button } from 'antd'如果 Tree Shaking 不生效,会打进整个 antd(+1MB)- 一个
console.log(bigObject)把一个大 JSON 文件拖进了 client bundle
不分析 bundle,这些问题完全不可见。
1.2 @next/bundle-analyzer
Next.js 官方的 bundle 分析工具,基于 webpack-bundle-analyzer,生成可视化的 treemap:
pnpm add -D @next/bundle-analyzer// next.config.ts
import type { NextConfig } from 'next'
import bundleAnalyzer from '@next/bundle-analyzer'
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
const nextConfig: NextConfig = {
// 你的其他配置...
}
export default withBundleAnalyzer(nextConfig)# 运行分析
ANALYZE=true pnpm build构建完成后会自动打开两个 HTML 页面:
- Client bundles:发送到浏览器的 JS(这是你需要重点关注的)
- Server bundles:服务端的 JS(通常不用太在意大小)
1.3 如何解读分析结果
在 treemap 中,面积越大 = 体积越大。重点关注:
- 占比超过 30% 的单个库:考虑替换为更轻量的替代品
- 出现在多个 chunk 里的库:可能需要提取为共享 chunk
- 不该出现在 client bundle 的代码:比如数据库驱动、大型 JSON 数据
// ❌ 常见陷阱:在客户端代码中引入了服务端的大型库
'use client'
import { format } from 'date-fns' // +70KB gzip(可能不需要这么大的库)
// ✅ 替代方案 1:用原生 Intl API(0KB)
const formatted = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
// ✅ 替代方案 2:用轻量库 dayjs(2KB gzip)
import dayjs from 'dayjs'
dayjs(date).format('YYYY-MM-DD')1.4 常见大型依赖替代方案
| 原始库 | 大小 (gzip) | 替代方案 | 大小 (gzip) |
|---|---|---|---|
| moment.js | ~70KB | dayjs | ~2KB |
| date-fns(全量) | ~70KB | date-fns(按需)/ Intl API | ~2KB / 0KB |
| lodash(全量) | ~70KB | lodash-es(按需) | ~2KB |
| chart.js | ~60KB | recharts(按需) | ~30KB |
| highlight.js | ~40KB | shiki(服务端) | 0KB client |
| uuid | ~3KB | crypto.randomUUID() | 0KB |
2. Tree Shaking
2.1 原理
Tree Shaking(树摇)是编译器在打包时自动移除未使用代码的优化。名字来源于"摇动一棵树,让枯叶掉落"。
前提条件:必须使用 ES Module 的 import/export(而非 CommonJS 的 require/module.exports)。因为 ES Module 是静态声明——编译器在不运行代码的情况下就能确定哪些导出被使用了。
// ✅ ES Module — 可以 Tree Shaking
import { clsx } from 'clsx' // 只打包 clsx 函数
// ❌ CommonJS — 无法 Tree Shaking
const clsx = require('clsx') // 打包整个模块2.2 确保 Tree Shaking 生效
检查库的 package.json:好的库会同时提供 ES Module 和 CommonJS 版本:
{
"main": "./dist/index.cjs", // CommonJS(Node.js 用)
"module": "./dist/index.mjs", // ES Module(打包工具用)
"sideEffects": false // 关键!告诉打包工具:这个库没有副作用,未使用的导出可以安全移除
}sideEffects: false 是 Tree Shaking 的加速器。如果一个模块有副作用(比如修改全局变量、注册 polyfill),打包工具即使没看到它的导出被使用,也不敢移除它。sideEffects: false 给了打包工具"安全许可"。
避免 barrel exports 陷阱:
// lib/utils/index.ts — barrel file(桶文件)
export { formatDate } from './date'
export { formatCurrency } from './currency'
export { generatePDF } from './pdf' // 这个很大(引入了 pdfkit)
// 如果你只用 formatDate,理论上应该只打包 date.ts
// 但如果 Tree Shaking 不完美,可能把 pdf.ts 也拖进来
// ✅ 安全做法:直接从具体文件导入
import { formatDate } from '@/lib/utils/date'2.3 标记你自己的代码
在 package.json(或 next.config.ts)中标记你的代码没有副作用:
// package.json
{
"sideEffects": [
"*.css", // CSS 文件有副作用(需要被加载)
"./src/polyfills.ts" // polyfill 有副作用
]
}3. 动态导入(Dynamic Import)
3.1 原理
next/dynamic 是 React lazy() 的增强版,让你延迟加载组件——组件的 JS 只在需要时才下载。这是代码分割的核心手段。
App Router 中,Server Components 本身不会发送 JS 到客户端,所以动态导入主要用于客户端组件中的重型依赖。
3.2 基本用法
import dynamic from 'next/dynamic'
// 基础:懒加载组件
const HeavyChart = dynamic(() => import('@/components/heavy-chart'), {
loading: () => <div className="h-[400px] animate-pulse bg-gray-100 rounded-lg" />,
})
// 禁用 SSR:某些组件只能在浏览器中运行(依赖 window、DOM API)
const CodeEditor = dynamic(() => import('@/components/code-editor'), {
ssr: false,
loading: () => <div className="h-[600px] bg-gray-900 rounded-lg" />,
})
// 在页面中使用 — 滚动到视口时或条件渲染时才加载
export default function DashboardPage() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setShowChart(true)}>显示图表</button>
{showChart && <HeavyChart data={chartData} />} {/* 点击后才加载 */}
<CodeEditor /> {/* 滚动到这里时加载(SSR 阶段显示 loading) */}
</div>
)
}3.3 命名导出的动态导入
// 如果组件不是 default export
const MarkdownRenderer = dynamic(
() => import('@/components/markdown').then((mod) => mod.MarkdownRenderer),
)3.4 什么时候该用动态导入
| 场景 | 用不用动态导入 | 原因 |
|---|---|---|
| 首屏可见组件 | ❌ 不用 | 动态导入反而会延迟首屏渲染(多一次网络请求) |
| 折叠区域内容 | ✅ 用 | 用户滚动到才加载 |
| 条件渲染的重组件 | ✅ 用 | 用户触发操作后才加载 |
| 模态框 / 抽屉 | ✅ 用 | 点击后才加载 |
| 代码编辑器 / 富文本 | ✅ 用 | 这些库通常 100KB+,且只在特定页面用 |
| 图表库 | ✅ 用 | recharts/echarts 通常 50KB+ |
| 只在浏览器运行的库 | ✅ 用(ssr: false) | 比如 canvas 操作库 |
3.5 结合 Intersection Observer 懒加载
更精细的控制——滚动到可见时才加载:
'use client'
import { useRef, useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
const HeavyComponent = dynamic(() => import('@/components/heavy-component'))
export function LazySection() {
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect() // 加载后不再观察
}
},
{ rootMargin: '200px' }, // 提前 200px 开始加载
)
if (ref.current) observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return (
<div ref={ref} className="min-h-[400px]">
{isVisible ? <HeavyComponent /> : <div className="animate-pulse bg-gray-100 h-[400px] rounded-lg" />}
</div>
)
}4. Chunk 优化策略
4.1 Next.js 的自动分割
App Router 自动做了这些分割:
- 按路由分割:每个
page.tsx是一个独立的 chunk,访问/about不会下载/dashboard的代码 - 按 layout 分割:共享的
layout.tsx提取为单独的 chunk,子路由共享 - 框架代码分离:React、Next.js 运行时提取为单独的、高缓存率的 chunk
- Server Components 零 JS:Server Component 的代码完全不出现在 client bundle 中
4.2 Server Components = 天然代码分割
App Router 的最大优势:Server Components 默认不发送 JS 到客户端。充分利用这一点:
// app/dashboard/page.tsx — Server Component
// 这些 import 的库完全不会出现在 client bundle 中!
import { db } from '@/lib/db'
import { formatReport } from '@/lib/report-utils'
import { marked } from 'marked'
export default async function DashboardPage() {
const data = await db.query.reports.findMany()
const summary = formatReport(data) // 服务端计算,零 client JS
const html = marked(summary) // marked 库只在服务端运行
return (
<div>
<h1>Dashboard</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
<InteractiveChart data={data} /> {/* 只有这个 Client Component 会发送 JS */}
</div>
)
}4.3 优化第三方库
// next.config.ts
const nextConfig: NextConfig = {
// 优化第三方库的打包
experimental: {
optimizePackageImports: [
'lucide-react', // 图标库:只打包用到的图标
'@radix-ui/react-icons',
'date-fns', // 日期库:只打包用到的函数
'lodash-es', // 工具库:只打包用到的函数
'recharts', // 图表库
],
},
}optimizePackageImports 的原理:对于那些用 barrel file(index.ts 统一导出)的库,正常 Tree Shaking 可能不够彻底。这个配置会自动把 import { X } from 'lib' 转换为 import X from 'lib/X',绕过 barrel file,确保只打包用到的部分。
4.4 分析和消除重复依赖
# 检查是否有同一个库的多个版本
pnpm ls --depth 3 | grep react
# 如果看到 [email protected] 和 [email protected] 同时存在,说明有版本冲突
# 用 pnpm 的 dedupe 命令去重
pnpm dedupe4.5 Bundle 预算
设定 JS 大小预算,在 CI 中自动检查:
// scripts/check-bundle-size.mts
import { readdir, stat } from 'fs/promises'
import { join } from 'path'
const BUDGET = {
'page.js': 100 * 1024, // 单个页面 JS < 100KB
'layout.js': 50 * 1024, // 布局 JS < 50KB
'total-first-load': 200 * 1024, // 首屏总 JS < 200KB
}
async function checkBundleSize() {
const chunksDir = '.next/static/chunks'
const files = await readdir(chunksDir)
let totalSize = 0
const violations: string[] = []
for (const file of files) {
if (!file.endsWith('.js')) continue
const fileStat = await stat(join(chunksDir, file))
totalSize += fileStat.size
// 检查单文件大小
if (fileStat.size > 150 * 1024) {
violations.push(`⚠️ ${file}: ${(fileStat.size / 1024).toFixed(0)}KB (超过 150KB)`)
}
}
console.log(`\n📦 Total client JS: ${(totalSize / 1024).toFixed(0)}KB`)
if (violations.length > 0) {
console.log('\n❌ Bundle size violations:')
violations.forEach((v) => console.log(v))
process.exit(1)
}
console.log('✅ Bundle size within budget')
}
checkBundleSize()本章小结
- Bundle 分析:用
@next/bundle-analyzer可视化 bundle 组成,找出大型依赖 - Tree Shaking:确保用 ES Module 导入,标记
sideEffects,避免 barrel export 陷阱 - 动态导入:折叠区域、模态框、重型库用
next/dynamic延迟加载,首屏组件不要用 - Server Components:充分利用 SC 的零 JS 优势,把数据处理和重型库留在服务端
- Bundle 预算:在 CI 中设定预算,防止 bundle 悄悄膨胀