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~70KBdayjs~2KB
date-fns(全量)~70KBdate-fns(按需)/ Intl API~2KB / 0KB
lodash(全量)~70KBlodash-es(按需)~2KB
chart.js~60KBrecharts(按需)~30KB
highlight.js~40KBshiki(服务端)0KB client
uuid~3KBcrypto.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 &#123; X &#125; from 'lib' 转换为 import X from 'lib/X',绕过 barrel file,确保只打包用到的部分。

4.4 分析和消除重复依赖

# 检查是否有同一个库的多个版本
pnpm ls --depth 3 | grep react
# 如果看到 [email protected][email protected] 同时存在,说明有版本冲突
 
# 用 pnpm 的 dedupe 命令去重
pnpm dedupe

4.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 悄悄膨胀