跳到主要内容

构建产物分析与Tree-Shaking

1. Bundle Analyzer

可视化看构建产物里谁最大:

1.1 Vite

npx vite-bundle-visualizer
# 产出 stats.html

或插件:

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

export default {
plugins: [
visualizer({ open: true, gzipSize: true })
]
}

1.2 Webpack

npx webpack-bundle-analyzer dist/stats.json
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
plugins: [new BundleAnalyzerPlugin({ analyzerMode: 'static' })]
}

1.3 Next.js

ANALYZE=true next build
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})

2. Tree Shaking

编译器移除未使用的代码。

2.1 前提条件

  • 使用 ES Module(import/export),不是 CommonJS(require
  • 包的 package.json 声明 "sideEffects": false
  • 不使用副作用 import(import './styles.css' 需声明)

2.2 sideEffects

// package.json
{
"sideEffects": false
}

// 或精确指定有副作用的文件
{
"sideEffects": ["*.css", "*.scss", "./src/polyfills.ts"]
}

2.3 检查 Tree Shaking 效果

# 看哪些模块被 tree-shaken
npx vite build --debug
# 或看 bundle analyzer 中是否包含不需要的模块

3. 代码分割

3.1 按路由

// React
const Dashboard = React.lazy(() => import('./pages/Dashboard'))
const Settings = React.lazy(() => import('./pages/Settings'))

function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
}

3.2 按功能

// 用户点击才加载重编辑器
const loadEditor = () => import('./Editor')

button.onclick = async () => {
const { Editor } = await loadEditor()
// 使用 Editor
}

3.3 Vendor 拆分

// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ui': ['@radix-ui/react-dialog', '@headlessui/react'],
'vendor-utils': ['lodash-es', 'date-fns'],
}
}
}
}

4. 依赖替换(减体积)

大包轻量替代
lodashlodash-es(按需 import)
momentdayjs / date-fns
axios原生 fetch + tiny wrapper
uuidcrypto.randomUUID()
classnamesclsx(1KB)

4.1 import cost

VS Code 插件 Import Cost 实时显示每个 import 体积。

4.2 按需导入

// ✗ 全量引入
import _ from 'lodash'
_.get(obj, 'a.b')

// ✓ 按需
import get from 'lodash-es/get'
get(obj, 'a.b')

// 或用 babel-plugin-import / unplugin-auto-import

5. size-limit(CI 预算)

// package.json
{
"size-limit": [
{ "path": "dist/assets/index-*.js", "limit": "150 KB", "gzip": true },
{ "path": "dist/assets/vendor-*.js", "limit": "200 KB", "gzip": true },
{ "path": "dist/**/*.css", "limit": "50 KB", "gzip": true }
]
}
npx size-limit
# ✓ index.js: 120 KB (limit: 150 KB)
# ✗ vendor.js: 210 KB (limit: 200 KB) — EXCEEDED

CI 集成:

- run: npx size-limit

超出 = CI 失败 = PR 必须优化或调整预算。

5.1 bundlesize(替代)

{
"bundlesize": [
{ "path": "dist/assets/*.js", "maxSize": "200 kB" }
]
}

6. 分析常见大包

bundle analyzer 里常见"巨物":

典型大小解决
react-dom130KB无法替代,确保只一份
@mui/material200KB+按需 import
chart.js / echarts200-500KB按需注册组件
monaco-editor5MB+Web Worker + 按需加载
moment300KB(含 locale)换 dayjs
lodash70KB 全量lodash-es 按需
aws-sdk几十 MB@aws-sdk/client-* 按需

7. Source Map 分析

# source-map-explorer
npx source-map-explorer dist/assets/index-*.js

比 bundle analyzer 更精确(基于 sourcemap 逆向)。

8. 监控 bundle 增长

PR 自动评论 bundle size 变化:

# GitHub Action: compressed-size-action
- uses: preactjs/compressed-size-action@v2
with:
repo-token: $&#125;&#125; secrets.GITHUB_TOKEN &#125;&#125;

PR 评论里显示:

+12 KB gzip (index.js: 145 KB → 157 KB)

9. 常见反模式

  • 全量 import lodash / antd:几百 KB 冗余
  • 不做代码分割:首次加载 2MB JS
  • 不看 bundle analyzer:不知道什么大
  • moment 全 locale:300KB 里 250KB 是语言包
  • 不设 size-limit:bundle 悄悄膨胀
  • dynamic import 用在首屏关键组件:LCP 反而慢
  • devDependencies 跑进生产:如 storybook、testing-library
  • polyfill 全量引入:core-js 全集 100KB+。用 useBuiltIns: 'usage'

10. 延伸阅读