第 20 章

打包优化、Bundle 分析与 Turbopack

第20章:打包优化、Bundle 分析与 Turbopack

Bundle 优化是回报率最高的性能工作——在不改变产品功能的前提下,将客户端 JS 体积减少 30-60%,直接转化为更快的 TTI。

本章核心问题:如何发现和解决 Bundle 膨胀?动态导入和 optimizePackageImports 如何工作?Turbopack 的现状如何?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Bundle 大小为什么重要

JavaScript bundle 大小直接影响两个核心指标:

首次内容绘制(FCP)和交互时间(TTI):浏览器必须先下载、解析、执行 JavaScript 才能让页面变为可交互状态。在 4G 网络下,1MB 的 JavaScript 需要约 2-3 秒解析(JS 解析比下载消耗更多 CPU)。在中低端设备上,相同的 JS 解析时间可能是高端设备的 3-5 倍。

重复访问体验:CDN 会缓存静态资源,但如果 bundle 的内容哈希每次发布都变化(即使只改了一行代码),用户必须重新下载整个 bundle。良好的 bundle 拆分策略能让大部分不变的代码长期被缓存。

Next.js 的 App Router 在这方面已经做了大量默认优化(Server Components 默认不发送到客户端、自动代码分割等),但还有很多常见的"坑"会让 bundle 意外膨胀。

@next/bundle-analyzer:可视化 Bundle 组成

@next/bundle-analyzer 生成交互式树状图,让你直观地看到哪些包占用了最多空间:

npm install --save-dev @next/bundle-analyzer
// next.config.ts
import type { NextConfig } from 'next'
import withBundleAnalyzer from '@next/bundle-analyzer'

const bundleAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: true,  // 分析完成后自动打开浏览器
})

const nextConfig: NextConfig = {
  // ... 其他配置
}

export default bundleAnalyzer(nextConfig)
# 运行分析
ANALYZE=true npm run build

分析完成后,会打开两个 HTML 文件:一个是客户端 bundle(client.html),另一个是服务端 bundle(server.html)。对 SEO 和性能影响最大的是客户端 bundle。

读懂 Bundle 分析图

树状图中每个矩形块代表一个模块,面积代表其大小。颜色代表所在的 chunk。重点关注:

常见的 Bundle 膨胀来源

整包导入 lodash

// 错误:导入整个 lodash(~70KB gzipped)
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')

// 正确方式一:按需导入
import sortBy from 'lodash/sortBy'

// 正确方式二:使用 lodash-es(支持 tree shaking)
import { sortBy } from 'lodash-es'

// 最佳方式:用原生 JS 替代(零 bundle 成本)
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name))

moment.js 的语言包问题

moment.js 默认将所有语言包打包在一起,导致大约 330KB 的 bundle。解决方案:

// next.config.ts
import webpack from 'webpack'

const nextConfig: NextConfig = {
  webpack(config) {
    // 只保留中文和英文语言包
    config.plugins.push(
      new webpack.ContextReplacementPlugin(
        /moment[/\\]locale$/,
        /zh|en/
      )
    )
    return config
  },
}

或者直接迁移到 date-fns(tree shaking 友好,按需导入):

import { format, parseISO } from 'date-fns'
import { zhCN } from 'date-fns/locale'

const formatted = format(parseISO(dateStr), 'yyyy年MM月dd日', { locale: zhCN })

图标库的全量导入

// 错误:导入整个图标库(react-icons ~2MB)
import { FaUser, FaHome, FaSearch } from 'react-icons/fa'

// 这种导入方式表面上看只导入了 3 个图标
// 但实际上 react-icons 的包结构使得 tree shaking 效果因构建工具而异

// 最安全的方式:使用直接路径导入
import FaUser from 'react-icons/fa/FaUser'

更好的方案是使用 SVG 图标文件直接内联,或使用 @heroicons/react 等专为 tree shaking 设计的图标库:

import { MagnifyingGlassIcon, UserIcon } from '@heroicons/react/24/outline'

Level 2 · 它是怎么运行的(3-5年经验)

Tree Shaking 的陷阱

Tree shaking 的前提是 ES Module 的静态导入结构。以下情况会破坏 tree shaking:

副作用导入:如果一个模块执行副作用(修改全局变量、注册插件),bundler 无法安全地删除它。在 package.json 中明确声明无副作用:

// 你自己库的 package.json
{
  "sideEffects": false
}

// 或只声明有副作用的文件
{
  "sideEffects": ["*.css", "./src/polyfills.js"]
}

桶文件(Barrel files):这是 Next.js 项目中最常见的 tree shaking 杀手。

// components/index.ts(桶文件)
export { Button } from './Button'
export { Modal } from './Modal'
export { DataTable } from './DataTable'  // DataTable 依赖重型图表库
export { Chart } from './Chart'
// 使用时只导入 Button
import { Button } from '@/components'
// 但 bundler 必须导入整个 components/index.ts
// 导致 DataTable 和 Chart 也被包含

optimizePackageImports:自动优化桶文件

Next.js 15 内置了 optimizePackageImports 配置,对常用库自动处理桶文件问题:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: [
      '@mui/material',
      '@mui/icons-material',
      'lucide-react',
      'date-fns',
      'lodash-es',
      // 添加你项目中使用的任何有桶文件问题的库
    ],
  },
}

这个配置告诉 Next.js 对这些包进行特殊处理:即使你从桶文件导入,Next.js 也会将导入语句转换为直接路径导入,实现真正的按需加载。

Level 3 · 规范怎么定义的(资深)

动态导入:按需加载重型组件

对于首屏不需要的重型客户端组件,使用 next/dynamic 延迟加载:

// 普通动态导入
import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded" />,
  ssr: false,  // 如果组件依赖浏览器 API
})

// 在组件中使用
export default function Dashboard() {
  return (
    <div>
      <h1>仪表盘</h1>
      {/* HeavyChart 的代码只在这个组件进入视口时下载 */}
      <HeavyChart data={chartData} />
    </div>
  )
}

ssr: false 的使用场景

ssr: false 告诉 Next.js 完全跳过这个组件的服务端渲染,仅在客户端加载。适用于:

// 富文本编辑器通常不兼容 SSR
const RichTextEditor = dynamic(
  () => import('@/components/RichTextEditor'),
  {
    ssr: false,
    loading: () => (
      <div className="border rounded-lg p-4 min-h-[200px] bg-gray-50">
        加载编辑器中...
      </div>
    ),
  }
)

条件动态导入

某些功能只有特定用户才需要(如管理员工具),可以根据条件决定是否加载:

'use client'
import dynamic from 'next/dynamic'
import { useUser } from '@/hooks/useUser'

const AdminPanel = dynamic(() => import('./AdminPanel'))

export function ConditionalAdminPanel() {
  const { user } = useUser()

  if (!user?.isAdmin) return null

  // AdminPanel 的代码只有管理员才会下载
  return <AdminPanel />
}

Turbopack:下一代打包工具

Turbopack 是 Next.js 团队用 Rust 编写的新一代打包工具,在 Next.js 15 中已稳定支持开发模式。

开发模式启用 Turbopack

# package.json
{
  "scripts": {
    "dev": "next dev --turbo"
  }
}

Turbopack vs Webpack:性能对比

官方数据(基于大型应用测试):

指标 Webpack Turbopack 提升
冷启动时间 ~15 秒 ~2 秒 约 7x
HMR(热更新) ~500ms ~50ms 约 10x
内存占用 ~1.5GB ~600MB 约 60%

Turbopack 的速度优势来自两个核心设计:

  1. 增量计算:只重新计算受变更影响的部分,而不是重新打包整个项目
  2. 并行化:Rust 的并发能力让 Turbopack 充分利用多核 CPU

Turbopack 在 Next.js 15 的现状

截至 Next.js 15,Turbopack:

// next.config.ts
const nextConfig: NextConfig = {
  // 为 Turbopack 配置别名(替代 webpack 的 resolve.alias)
  turbo: {
    resolveAlias: {
      'react-native': 'react-native-web',
    },
    // Turbopack 的 loader 配置(语法与 webpack 不同)
    rules: {
      '*.svg': {
        loaders: ['@svgr/webpack'],
        as: '*.js',
      },
    },
  },
}

何时切换到 Turbopack

适合切换的情况

暂缓切换的情况

生产构建的进阶优化

分析 Next.js 构建输出

next build

构建完成后,Next.js 打印每个路由的大小统计:

Route (app)                    Size     First Load JS
┌ ○ /                         5.2 kB   87.4 kB
├ ○ /blog                     3.1 kB   85.3 kB
├ ● /blog/[slug]              2.8 kB   85.0 kB
├ ○ /dashboard                15.2 kB  97.4 kB  ← 需要关注
└ ○ /login                    4.1 kB   86.3 kB

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML

"First Load JS" 是该路由从服务器加载时客户端需要下载的 JS 总量,包括共享 chunk。建议将首屏 JS 控制在 100KB 以内。超过这个数字时,审查该路由引入了哪些客户端组件。

分包策略:splitChunks 调优

Next.js 默认的分包策略适合大多数场景,但对于特定需求可以调整:

// next.config.ts
const nextConfig: NextConfig = {
  webpack(config, { isServer }) {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          // 将 React 相关包分为独立 chunk,长期缓存
          framework: {
            name: 'framework',
            chunks: 'all',
            test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
            priority: 40,
            enforce: true,
          },
          // 将大型第三方包分为独立 chunk
          lib: {
            test: /[\\/]node_modules[\\/]/,
            name(module: { context: string }) {
              const packageName = module.context.match(
                /[\\/]node_modules[\\/](.*?)([\\/]|$)/
              )?.[1]
              return `npm.${packageName?.replace('@', '')}`
            },
            priority: 30,
            minChunks: 1,
            reuseExistingChunk: true,
          },
        },
      }
    }
    return config
  },
}

实战:将一个真实项目的 Bundle 优化 40%

以下是一个真实优化案例的操作步骤:

初始状态:Dashboard 页面 First Load JS:234KB

步骤一:运行 bundle analyzer,识别最大模块 发现 moment.js(330KB)、@mui/icons-material(全量导入)占据了大部分空间。

步骤二:替换 moment.js 将所有 moment 调用替换为 date-fns,节省约 280KB(date-fns 只导入用到的函数)。

步骤三:修复图标库导入import { Add, Delete, Edit } from '@mui/icons-material' 改为:

import Add from '@mui/icons-material/Add'
import Delete from '@mui/icons-material/Delete'
import Edit from '@mui/icons-material/Edit'

节省约 60KB。

步骤四:将图表组件改为动态导入 Dashboard 中的 LineChart 组件依赖 recharts(~80KB),改为动态导入后,只有用户滚动到图表区域时才下载。

最终结果:First Load JS 从 234KB 降到 138KB(-41%),TTI 改善约 800ms(在中端设备上测试)。

Level 4 · 边界与陷阱(所有人)

陷阱1:桶文件(Barrel files)是最常见的 tree shaking 杀手——import { Button } from '@/components' 会把整个目录的所有组件都打包进来。

陷阱2:ssr: false 的动态导入完全跳过服务端渲染——只适用于依赖浏览器 API 或与 SSR 不兼容的第三方库。

陷阱3:Turbopack 开发模式稳定但生产构建仍用 webpack——两者理论上可能有细微差异,虽然实践中极少遇到。

小结

Bundle 优化是 Next.js 性能工作中回报率最高的领域之一。核心工作流程是:用 @next/bundle-analyzer 发现问题,用动态导入延迟加载重型组件,用 optimizePackageImports 自动处理桶文件,用更轻量的替代库(date-fns 代替 moment、lodash-es 代替 lodash)减少基础 bundle 大小。Turbopack 在开发模式下已经可以显著提升开发体验,但生产构建仍建议使用稳定的 webpack 路径。这些优化手段结合起来,可以在不改变任何产品功能的前提下,将客户端 JS 体积减少 30-60%,直接转化为更快的 TTI 和更好的用户体验。

本章评分
4.8  / 5  (9 评分)

💬 留言讨论