正在加载,请稍候…

Webpack 和 Vite 构建优化:代码分割、Tree Shaking 与包分析

深入探讨构建工具优化:Webpack 代码分割策略、Tree Shaking 配置、块命名、Vite 构建优化、分析包大小以及减少初始加载时间。

Webpack 和 Vite 构建优化:代码分割、Tree Shaking 与包分析

构建优化:乘数效应

从 JavaScript 包中移除的每个千字节都会产生乘数效应:更小的下载量、更快的解析、更快的执行。在移动网络上,200KB 和 600KB 包之间的差异可能决定用户是留下还是离开。

本指南涵盖了最重要的技术,并附有具体的前后对比示例。

Webpack 和 Vite 构建优化:代码分割、Tree Shaking 与包分析示意图

理解 JavaScript 包成本

JavaScript 是最昂贵的资源类型:每个字节都必须下载、解压缩、解析、编译和执行。图片下载成本高但解析成本低。JS 在每一步都很昂贵。

100KB 的 JavaScript(gzip 后约 30KB)的成本:
1. 网络:下载 30KB
2. 解压缩:CPU + 内存
3. 解析:中端手机上约 80ms
4. 编译:中端手机上约 120ms
5. 执行:取决于代码复杂度

100KB 的图片:只有下载成本

基准:初始加载的 JS gzip 后 < 200KB 是大多数应用的合理目标。

Webpack 代码分割

代码分割将你的包分割成按需加载的块:

// webpack.config.js
module.exports = {
  entry: {
    main: './src/index.ts',
  },
  
  optimization: {
    splitChunks: {
      chunks: 'all',      // 分割异步和同步块
      cacheGroups: {
        // 供应商块:很少变化的库
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10,
        },
        
        // React 特定块
        react: {
          test: /[\/]node_modules[\/](react|react-dom|react-router)[\/]/,
          name: 'react-vendor',
          chunks: 'all',
          priority: 20,   // 更高优先级 = 优先
        },
        
        // 大型、不常用的库
        charts: {
          test: /[\/]node_modules[\/](chart.js|recharts|d3)[\/]/,
          name: 'charts-vendor',
          chunks: 'async', // 仅在动态导入时
          priority: 20,
        },
        
        // 多个入口点之间共享的公共代码
        common: {
          name: 'common',
          minChunks: 2,      // 至少在 2 个块中使用
          priority: 5,
          reuseExistingChunk: true,
        },
      },
    },
    
    // 内容哈希用于长期缓存
    chunkIds: 'deterministic',
    moduleIds: 'deterministic',
  },
  
  output: {
    filename: '[name].[contenthash].js',       // main.abc123.js
    chunkFilename: '[name].[contenthash].js',  // vendors.def456.js
  },
}

动态导入:基于路由的分割

// React Router 配合路由级代码分割
import { lazy, Suspense } from 'react'

// 每个路由成为一个单独的块
const Home = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() => 
  import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
)
const AdminPanel = lazy(() => 
  import(/* webpackChunkName: "admin" */ './pages/AdminPanel')
)

// 悬停时预加载(减少感知加载时间)
function NavLink({ to, prefetch, children }) {
  const handleMouseEnter = () => {
    if (prefetch) {
      // Webpack 魔法注释:开始下载但不渲染
      import(/* webpackPrefetch: true */ `./pages/${to}`)
    }
  }
  
  return (
    <Link to={to} onMouseEnter={handleMouseEnter}>
      {children}
    </Link>
  )
}

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  )
}

Webpack 和 Vite 构建优化:代码分割、Tree Shaking 与包分析示意图

Tree Shaking:移除死代码

Tree shaking 消除未使用的导出。但它需要正确的配置:

// package.json — 表明你的代码无副作用
{
  "sideEffects": false  // 所有内容都可以 tree-shake
  // 或者指定有副作用的文件:
  "sideEffects": [
    "*.css",
    "src/polyfills.js"
  ]
}

// webpack.config.js — tree shaking 仅在 production 模式下工作
module.exports = {
  mode: 'production',      // 启用 TerserPlugin(压缩)+ tree shaking
  optimization: {
    usedExports: true,     // 标记使用的导出
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            dead_code: true,    // 移除死代码
            pure_funcs: ['console.log'],  // 移除特定函数调用
          },
        },
      }),
    ],
  },
}

常见的 Tree Shaking 失败

// ❌ 默认导出阻止工具函数的 tree shaking
// utils.js
export default {
  formatDate,
  formatCurrency,
  formatBytes,
}

// ❌ 导入整个模块:
import utils from './utils'
utils.formatDate(date) // formatCurrency 和 formatBytes 仍然被打包!

// ✅ 命名导出启用 tree shaking
// utils.js
export function formatDate(date) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
export function formatBytes(bytes) { /* ... */ }

// ✅ 只导入你需要的内容
import { formatDate } from './utils'
// formatCurrency 和 formatBytes 被 tree-shake 掉

// ❌ CommonJS (require) 不可 tree-shake
const { formatDate } = require('./utils')  // 整个模块被包含

// ✅ ES modules (import) 可 tree-shake
import { formatDate } from './utils'

包分析

# Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer

# 在 webpack.config.js 中
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

module.exports = {
  plugins: [
    process.env.ANALYZE && new BundleAnalyzerPlugin({
      analyzerMode: 'static',     // 生成 HTML 报告
      openAnalyzer: true,
      reportFilename: 'bundle-report.html',
    })
  ].filter(Boolean),
}

# 运行:
ANALYZE=true webpack build
# 对于 Vite:
npm install --save-dev rollup-plugin-visualizer

# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    visualizer({
      filename: 'stats.html',
      open: true,
      gzipSize: true,
      brotliSize: true,
    })
  ],
})

# Source Map Explorer(适用于任何打包工具)
npm install --save-dev source-map-explorer
npx source-map-explorer dist/main.*.js

在分析器中查找什么:

  1. 你不知道被包含的大型库(例如,当你只使用一个函数时,lodash 被完整包含)
  2. 重复的模块(同一个库被打包多次)
  3. 生产包中的开发环境代码
  4. 意外过大的供应商块

Webpack 和 Vite 构建优化:代码分割、Tree Shaking 与包分析示意图

Vite 构建优化

// vite.config.ts
import { defineConfig, splitVendorChunkPlugin } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    react(),
    splitVendorChunkPlugin(),  // 自动分割供应商/应用块
  ],
  
  build: {
    target: 'es2020',           // 现代浏览器 — 更小的输出
    minify: 'esbuild',          // 快速压缩 (esbuild) 或彻底 (terser)
    sourcemap: true,            // 用于调试(不要部署到公共环境)
    
    rollupOptions: {
      output: {
        // 手动块分割
        manualChunks: (id) => {
          if (id.includes('node_modules')) {
            // React 生态系统一起
            if (id.includes('react') || id.includes('react-dom')) {
              return 'react'
            }
            // 重型库作为自己的块
            if (id.includes('monaco-editor')) return 'monaco'
            if (id.includes('chart.js') || id.includes('recharts')) return 'charts'
            if (id.includes('@tanstack')) return 'tanstack'
            
            // 其他所有:供应商
            return 'vendors'
          }
        },
        
        // 带内容哈希的资源命名
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]',
      },
    },
    
    // 如果块超过此大小则警告
    chunkSizeWarningLimit: 500,  // KB
  },
  
  // 依赖预打包
  optimizeDeps: {
    include: ['react', 'react-dom'],      // 预打包以加快开发速度
    exclude: ['@vite/client', '@vite/env'], // 不预打包
  },
})

压缩与资源优化

// Webpack 中的 Gzip/Brotli 压缩
const CompressionPlugin = require('compression-webpack-plugin')
const zlib = require('zlib')

plugins: [
  new CompressionPlugin({
    algorithm: 'gzip',
    test: /.(js|css|html|svg)$/,
    threshold: 10240,    // 仅压缩大于 10KB 的文件
    minRatio: 0.8,       // 仅当节省 20% 以上时压缩
  }),
  new CompressionPlugin({
    algorithm: 'brotliCompress',
    test: /.(js|css|html|svg)$/,
    compressionOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } },
    filename: '[path][base].br',
    deleteOriginalAssets: false,
  }),
]

// Nginx:提供预压缩文件
// nginx.conf
// gzip_static on;
// brotli_static on;

衡量构建性能

# Webpack 构建时间分析
npx webpack --profile --json > stats.json
# 上传到 https://webpack.github.io/analyse/

# 哪些 loader/plugin 慢?
npm install --save-dev speed-measure-webpack-plugin

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
module.exports = smp.wrap(webpackConfig)

# Vite 构建计时
vite build --reporter=verbose

# 跟踪包大小随时间变化(CI)
npx bundlewatch --config .bundlewatchrc.js
# 如果包超过定义阈值,CI 失败
// .bundlewatchrc.js
{
  "files": [
    { "path": "dist/assets/main-*.js", "maxSize": "200kB" },
    { "path": "dist/assets/vendors-*.js", "maxSize": "300kB" },
    { "path": "dist/assets/*.css", "maxSize": "50kB" }
  ],
  "ci": {
    "trackBranches": ["main"],
    "repoBranchIsBaseline": true
  }
}

构建优化是一项投资,在应用的整个生命周期中,每次页面加载都会带来回报。基于路由的代码分割、tree shaking、供应商块分离和压缩的组合,通常可以将初始包大小比未优化的构建减少 50-70%。

→ 使用整数进制转换器在二进制、八进制、十进制和十六进制之间转换数字。