文章

代码分割与懒加载深度解析

从动态import的底层原理到React.lazy/Suspense的实际用法,再到Webpack SplitChunks的配置策略,全面掌握代码分割与懒加载

代码分割与懒加载深度解析

一句话概括

代码分割是现代前端性能优化的第一板斧——通过 import() 语法把应用拆成独立 chunk,首屏只加载用户眼前需要的代码,其余到用时再下载。效果立竿见影:首屏 JS 体积通常能砍掉 70%-90%。

核心知识点

1. 动态 import():一切代码分割的基石

import() 返回一个 Promise,Webpack/Vite 遇到它就会自动把目标模块拆成独立 chunk:

1
2
3
4
5
6
7
8
// 静态导入:一上来就加载,全部打进一个 bundle
import { heavyLib } from './heavyLib'

// 动态导入:返回 Promise,构建工具自动拆 chunk
button.addEventListener('click', async () => {
  const { heavyLib } = await import('./heavyLib')
  heavyLib.doSomething()  // chunk 到了才执行
})

构建后产物:

1
2
main.js        → 300KB(首屏必需)
chunk-1.js     → 500KB(heavyLib 被单独拆出,点击按钮才下载)

Webpack 的魔法注释可以精确控制 chunk 行为:

1
2
3
4
5
const module = await import(
  /* webpackChunkName: "my-chunk" */    // 自定义 chunk 名
  /* webpackPrefetch: true */           // 空闲时预加载
  './MyModule'
)

2. React.lazy + Suspense:组件级懒加载的标准模式

1
2
3
4
5
6
7
8
9
10
11
12
import { lazy, Suspense } from 'react'

// ✅ 组件不会进主 bundle,只在渲染到 <Dashboard /> 时才下载
const Dashboard = lazy(() => import('./Dashboard'))

function App() {
  return (
    <Suspense fallback={<div className="spinner">加载中…</div>}>
      <Dashboard />
    </Suspense>
  )
}

关键细节:React.lazy 内的函数必须返回一个 { default: Component } 形式的 Promise(所以 import() 天然符合)。Suspense 的 fallback 在 chunk 下载期间显示——如果 chunk 下载失败,React 不会回退而是 throw error,需要配合 ErrorBoundary 处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 生产环境:lazy + Suspense + ErrorBoundary 三件套
class ChunkErrorBoundary extends React.Component {
  state = { hasError: false }
  static getDerivedStateFromError() { return { hasError: true } }
  render() {
    return this.state.hasError 
      ? <button onClick={() => window.location.reload()}>加载失败,点我重试</button>
      : this.props.children
  }
}

// 嵌套使用
<ChunkErrorBoundary>
  <Suspense fallback={<Spinner />}>
    <Dashboard />
  </Suspense>
</ChunkErrorBoundary>

路由懒加载是最高回报的场景——每个页面的代码天然隔离,不访问就不下载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'

const Home       = lazy(() => import('./pages/Home'))
const UserCenter = lazy(() => import('./pages/UserCenter'))
const Admin      = lazy(() => import('./pages/Admin'))

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/user" element={<UserCenter />} />
        <Route path="/admin" element={<Admin />} />
      </Routes>
    </Suspense>
  )
}

3. Vue 的异步组件:defineAsyncComponent

Vue 不需要 lazy 包装器,直接用 defineAsyncComponent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup>
import { defineAsyncComponent } from 'vue'

// 基础用法
const AdminDashboard = defineAsyncComponent(() => import('./AdminDashboard.vue'))

// 高级配置:自定义 loading 与 error
const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: LoadingSpinner,        // 加载中显示
  errorComponent: ErrorRetry,               // 加载失败显示
  delay: 200,                               // 200ms 后才显示 loading(避免闪烁)
  timeout: 10000,                           // 10s 超时
})
</script>

<template>
  <Suspense> <!-- Vue3 的 Suspense 包裹异步组件 -->
    <AdminDashboard />
    <template #fallback>加载中…</template>
  </Suspense>
</template>

4. Webpack 的 SplitChunks 策略:自动提取公共依赖

路由级别的 import() 只解决了拆分问题,但多个 chunk 可能重复打包同一库(比如 lodash)——这时需要 splitChunks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// webpack.config.js
optimization: {
  splitChunks: {
    chunks: 'all',  // 对所有类型的 chunk 生效(async + initial)
    cacheGroups: {
      // 把 node_modules 中的依赖单独打出 vendor chunk
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendor',
        priority: 10,  // 优先级高于 default
      },
      // 公共业务代码
      common: {
        minChunks: 2,           // 至少被 2 个 chunk 引用
        reuseExistingChunk: true,
        priority: 5,
      },
    },
  },
}

产物效果:

1
2
3
vendor.js     → 800KB(react, react-dom, lodash…被引用者共享)
page-home.js  → 50KB
page-admin.js → 120KB

面试重点:chunks: 'all' vs chunks: 'async'——all 同时拆分 async 和同步 chunk,能把入口里的大库也拆出来;async 只拆分 import() 动态加载的部分。

5. 预加载与预取:主动控制加载时机

懒加载的代价是「点击后还要等下载」,可以通过预加载 / 预取弥补:

1
2
3
4
5
6
7
8
9
10
11
12
13
// prefetch(低优先级,空闲时拉取)
// 适合:「用户可能下一步点击」的页面
const About = lazy(() => import(
  /* webpackPrefetch: true */
  './About'
))

// preload(高优先级,与父 chunk 并行请求)
// 适合:「当前页面大概率需要」的模块
const Editor = lazy(() => import(
  /* webpackPreload: true */
  './Editor'
))

也可以用 <link> 在 HTML 层面控制:

1
2
3
4
5
<!-- Chrome 空闲时下载 next-page.js,不阻塞当前页面 -->
<link rel="prefetch" href="/next-page.js" as="script">

<!-- 当前页面需要的字体:高优先级并行下载 -->
<link rel="preload" href="/font.woff2" as="font" crossorigin>

其实你每天都在用

  1. 路由懒加载:/login 页面不需要加载 /admin 页面里的富文本编辑器、图表库、Excel 导出工具——路由分割是最自然也回报最高的分割方式。
  2. 组件级别的按需加载:模态框弹窗里的表单(Formik/ReactHookForm)、图表组件(ECharts/Recharts)、Markdown 编辑器——用户打开弹窗才加载,平时不占首屏。
  3. 第三方库的 vendor 抽取:react、react-dom、lodash 这些几乎不更新的库单独打 vendor chunk,业务代码更新时用户只需下载变化的业务 chunk,vendor 继续走缓存。
  4. Polyfill 按需加载:只在老浏览器触发时才动态加载 polyfill,现代浏览器完全不受影响。
  5. 条件加载的功能模块:A/B 测试的不同版本、权限控制的高级功能——看不到的用户不需要下载。

常见误解(FAQ)

❌ 误区:「懒加载一定让体验变好」

懒加载减少了首屏下载量,但增加了交互延迟——点击按钮 / 路由跳转后才开始下载 chunk,用户会看到 loading 闪烁。所以要判断:这个模块用户「现在必须看到」就静态导入,「可能会用到」就懒加载 + prefetch。

❌ 误区:「一个 chunk 越小越好」

chunk 太多会导致 HTTP/1.1 下并发连接瓶颈(虽然 HTTP/2 缓解了这个问题),而且每个 chunk 的请求都有 RTT 开销。合理的分割策略是:页面级 chunk(30-100KB)、vendor chunk(200-500KB)、公共逻辑单独一个 chunk。

❌ 误区:「Suspense 的 fallback 会在 chunk 加载失败时显示」

不会。chunk 加载失败时 Suspense 会把错误 throw 到 ErrorBoundary,fallback 只在成功加载过程中显示。生产环境必须配合 ErrorBoundary 处理网络异常场景。

❌ 误区:「Webpack 的 splitChunks 配置完就能自动最优」

cacheGroups 的 priority 和 minChunks 如果不调好,可能出现「同个模块被多个 vendor chunk 重复打包」或者「本该提取的公共代码没被提取」。最佳实践是配置完跑一下 webpack-bundle-analyzer 看看实际效果。

一句话总结

代码分割的目标不是「把所有 chunk 拆碎」,而是让首屏加载量最小化、让后续加载感知不到——路由懒加载是骨架,splitChunks 是血肉,prefetch 是灵魂。

本文由作者按照 CC BY 4.0 进行授权