代码分割与懒加载

中等 🟡性能优化
8 个标签
预计阅读时间:33 分钟
性能优化代码分割懒加载webpackViteReact.lazy动态导入Tree Shaking

代码分割与懒加载

代码分割(Code Splitting)和懒加载(Lazy Loading)是减少初始加载时间、提高应用性能的重要策略。它们的核心思想是:不要一次性把所有代码都塞给用户,而是按需分批交付

一句话理解概念

把打包后的 JavaScript 想象成一本很厚的书。如果不做代码分割,用户打开网页就相当于必须先把整本书搬回家才能读第一页;而代码分割是把书拆成很多小册子,用户先拿到"第一章"就能开始阅读,其他章节等真正翻到的时候再去取。懒加载就是这个"翻到才取"的动作。

为什么重要

现代前端应用(尤其是 SPA)打包体积动辄几 MB,如果全部塞进一个 `bundle.js`:

首屏必须等整个包下载 + 解析 + 执行完才能交互,白屏时间长
用户可能只访问首页,却下载了整站所有页面的代码,浪费带宽
JS 解析执行会阻塞主线程,直接拖累 FID / INP 等交互指标

一个真实的对比数据:某中后台管理系统未分割时首包 3.2MB,Gzip 后约 900KB,4G 网络首屏可交互(TTI)约 6.8s;按路由分割后首包降到 280KB(Gzip 后 95KB),TTI 降到 2.1s,提升约 3 倍。

| 指标 | 未分割 | 按路由分割 | 提升 |

| 首包体积(Gzip) | 900KB | 95KB | -89% |

| TTI (4G) | 6.8s | 2.1s | -69% |

| LCP | 4.2s | 1.8s | -57% |

代码分割的概念

定义:

将代码分割成多个小块(chunk)
按需加载这些代码块
减少初始加载体积
提高首次内容绘制速度

分割策略:

按路由分割(最常用,收益最大)
按组件分割(大组件、弹窗、图表)
按功能分割(编辑器、富文本、地图)
按第三方库分割(vendor 拆分,利用长缓存)

懒加载技术

动态导入(Dynamic Import):

ES 模块动态导入语法 `import()`
`import()` 是一个函数,返回 Promise
打包工具(webpack / Vite / Rollup)会以它为边界自动切分 chunk
支持在任意时机调用,实现真正的"用到才加载"
javascriptCode
// 静态导入:构建时确定,打进主包
import { heavyUtil } from './heavy-util';

// 动态导入:运行时按需加载,单独切出一个 chunk
button.addEventListener('click', async () => {
  // 只有用户点击按钮,才会去下载这段代码
  const { heavyUtil } = await import('./heavy-util');
  heavyUtil();
});

// 动态导入配合错误处理与加载态
async function loadEditor() {
  try {
    showSpinner();
    const { createEditor } = await import('./rich-editor');
    return createEditor();
  } catch (err) {
    console.error('富文本编辑器加载失败', err);
    showError('编辑器加载失败,请刷新重试');
  } finally {
    hideSpinner();
  }
}

魔法注释(Magic Comments):

webpack 支持通过特殊注释控制 chunk 的名字与加载行为。

javascriptCode
// 指定 chunk 名字,便于在网络面板中识别
const module = await import(
  /* webpackChunkName: "chart" */
  './chart'
);

// 预取:空闲时提前下载(未来可能用到)
import(
  /* webpackChunkName: "settings" */
  /* webpackPrefetch: true */
  './settings-page'
);

// 预载:与父 chunk 并行下载(当前导航很可能马上用到)
import(
  /* webpackChunkName: "login-modal" */
  /* webpackPreload: true */
  './login-modal'
);

React 中的实现:

`React.lazy()` 包裹动态导入的组件
`Suspense` 提供加载中的 fallback UI
支持路由级别与组件级别的代码分割
javascriptCode
import React, { Suspense, lazy } from 'react';

// 组件级懒加载
const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  const [showChart, setShowChart] = React.useState(false);
  return (
    <div>
      <button onClick={() => setShowChart(true)}>显示图表</button>
      {showChart && (
        <Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
}

React 路由级代码分割(React Router v6):

javascriptCode
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

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

// 用高阶函数统一包裹 Suspense,避免重复
function withSuspense(Component) {
  return (
    <Suspense fallback={<PageLoading />}>
      <Component />
    </Suspense>
  );
}

const router = createBrowserRouter([
  { path: '/', element: withSuspense(Home) },
  { path: '/dashboard', element: withSuspense(Dashboard) },
  { path: '/settings', element: withSuspense(Settings) },
]);

function App() {
  return <RouterProvider router={router} />;
}

处理懒加载失败与重试(生产必备):

网络抖动或新版本发布导致旧 chunk 404 是常见线上问题,需要加重试与兜底。

javascriptCode
// 带重试的懒加载封装
function lazyWithRetry(importFn, retries = 3, interval = 500) {
  return lazy(() =>
    new Promise((resolve, reject) => {
      const attempt = (n) => {
        importFn()
          .then(resolve)
          .catch((error) => {
            if (n <= 0) {
              // 常见于发布后旧 chunk 被清理,强制刷新拿新资源
              window.location.reload();
              reject(error);
              return;
            }
            setTimeout(() => attempt(n - 1), interval);
          });
      };
      attempt(retries);
    })
  );
}

const Reports = lazyWithRetry(() => import('./pages/Reports'));

Vue 中的实现:

路由懒加载:路由配置里用 `() => import()`
动态导入组件
`defineAsyncComponent` 定义异步组件(含加载态、错误态、超时)
javascriptCode
// Vue Router 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import(/* webpackChunkName: "dashboard" */ './Dashboard.vue'),
  },
];

// defineAsyncComponent 完整配置
import { defineAsyncComponent } from 'vue';

const AsyncChart = defineAsyncComponent({
  loader: () => import('./Chart.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorFallback,
  delay: 200,      // 展示 loading 前的延迟,避免闪烁
  timeout: 10000,  // 超时后展示 errorComponent
});

Webpack 配置

SplitChunks 配置:

javascriptCode
module.exports = {
  optimization: {
    // 把 webpack 运行时抽成单独文件,业务代码变化时它不变,缓存更稳定
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',        // 同时处理异步和同步导入
      minSize: 20000,       // 生成 chunk 的最小体积(字节)
      maxSize: 244000,      // 超过则尝试进一步拆分,利于缓存
      minChunks: 1,         // 模块被引用多少次才拆分
      maxAsyncRequests: 30, // 按需加载时最大并行请求数
      maxInitialRequests: 30, // 入口点最大并行请求数
      cacheGroups: {
        // 把体积大、更新慢的框架单独抽出,长期缓存
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          priority: 40,
        },
        // 其余第三方库
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: 20,
        },
        // 被多处复用的公共业务代码
        common: {
          minChunks: 2,
          priority: 10,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

各参数含义速查:

| 参数 | 默认值 | 作用 |

| chunks | async | 处理哪种导入:async 仅异步,initial 仅同步,all 全部 |

| minSize | 20000 | 生成 chunk 的最小字节数,小于则不拆 |

| maxSize | 0 | chunk 最大字节数,超过尝试再拆(0 为不限制) |

| minChunks | 1 | 模块被引用几次才拆分 |

| maxAsyncRequests | 6 | 按需加载的最大并行请求数 |

| maxInitialRequests | 4 | 入口初始加载的最大并行请求数 |

代码分割策略:

提取公共代码(common chunk)
提取第三方库(vendor / framework)
提取运行时代码(runtimeChunk)
自定义分割策略(cacheGroups)

Vite / Rollup 的手动分包

Vite 底层用 Rollup,通过 `manualChunks` 控制分包。

javascriptCode
// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react')) return 'framework';
            if (id.includes('echarts')) return 'charts';
            return 'vendor';
          }
        },
      },
    },
    chunkSizeWarningLimit: 500, // 超过 500KB 警告
  },
};

预加载与预缓存

资源提示(Resource Hints):

htmlCode
<!-- preload:当前页面关键资源,高优先级,提前下载 -->
<link rel="preload" as="font" href="/fonts/main.woff2" crossorigin>
<link rel="preload" as="image" href="/hero.webp">

<!-- prefetch:未来可能用到的资源,空闲时低优先级下载 -->
<link rel="prefetch" href="/js/next-page.chunk.js">

<!-- preconnect:提前建立连接(DNS + TCP + TLS) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- dns-prefetch:仅提前做 DNS 解析,兼容性兜底 -->
<link rel="dns-prefetch" href="https://api.example.com">
preload:告诉浏览器当前页面很快就会用到该资源,让它提前高优先级下载。适合首屏关键 CSS、字体、hero 图。
prefetch:预测用户下一步可能访问的资源(如下一路由的 chunk),浏览器空闲时低优先级下载。不要滥用,以免浪费带宽。
preconnect:提前完成 DNS 解析 + TCP 握手 + TLS 握手,减少后续请求延迟,适合 CDN、API 域名。
dns-prefetch:只做 DNS 预解析,作为 preconnect 的兼容降级。

基于用户意图的智能预加载(hover 预取):

javascriptCode
// 鼠标悬停链接时,提前加载目标路由的代码
const prefetched = new Set();

function prefetchOnHover(link, importFn) {
  link.addEventListener('mouseenter', () => {
    if (prefetched.has(link.href)) return;
    prefetched.add(link.href);
    importFn(); // 触发动态 import,chunk 提前进缓存
  }, { once: true });
}

prefetchOnHover(
  document.querySelector('a[href="/reports"]'),
  () => import('./pages/Reports')
);

Service Worker 预缓存:

缓存静态资源,实现离线访问
预缓存关键资源(precache manifest)
运行时缓存(runtime caching)
后台同步(background sync)
javascriptCode
// 一个最简 Service Worker 预缓存示例
const CACHE = 'app-v1';
const PRECACHE = ['/', '/index.html', '/js/main.chunk.js', '/css/main.css'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)));
});

self.addEventListener('fetch', (event) => {
  // 缓存优先,命中直接返回,未命中走网络
  event.respondWith(
    caches.match(event.request).then((res) => res || fetch(event.request))
  );
});

Tree Shaking 与按需加载配合

代码分割解决"分批加载",Tree Shaking 解决"删掉没用的代码",两者互补。

javascriptCode
// ❌ 全量导入,可能把整个库打进包(取决于库是否支持 ESM)
import _ from 'lodash';
_.debounce(fn, 300);

// ✅ 按需导入,配合 Tree Shaking 只保留用到的部分
import debounce from 'lodash-es/debounce';
debounce(fn, 300);

// package.json 中标记无副作用,帮助打包器安全删除未用导出
// {
//   "sideEffects": false
// }

真实案例:电商详情页的分割实践

某电商商品详情页包含:商品信息(首屏)、评价列表(滚动到中部)、推荐商品(底部)、分享弹窗(点击才用)。

优化前所有模块打进一个包,首包 620KB。优化方案:

首屏商品信息 → 保留在主包
评价列表 → Intersection Observer 滚动到视口再 `import()`
推荐商品 → 同上,懒加载
分享弹窗 → 点击分享按钮才 `import()`
javascriptCode
// 滚动进入视口才加载评价模块
const reviewSlot = document.querySelector('#reviews');
const io = new IntersectionObserver((entries) => {
  entries.forEach(async (entry) => {
    if (entry.isIntersecting) {
      const { renderReviews } = await import('./reviews');
      renderReviews(entry.target);
      io.unobserve(entry.target);
    }
  });
}, { rootMargin: '200px' }); // 提前 200px 预加载,避免用户看到空白

io.observe(reviewSlot);

结果:首包降到 210KB(-66%),LCP 从 3.6s 降到 1.7s,分享弹窗代码(80KB)只有约 12% 的用户会真正加载,节省了大量无效流量。

性能影响

优势:

减少初始加载时间
提高首屏渲染速度
节省带宽(用户没访问的功能不下载)
改善用户体验与交互指标

注意事项与常见坑:

| 坑 | 现象 | 解决 |

| 过度分割 | chunk 太多太碎,HTTP 请求数暴增反而变慢 | 合并小 chunk,设置合理 minSize |

| 懒加载首屏组件 | 首屏内容还要多一次网络往返,LCP 变差 | 首屏组件用静态导入,不要懒加载 |

| 未处理加载失败 | 发布后旧 chunk 404,白屏报错 | 加重试 + 兜底刷新 |

| 无 loading 态 | 切换路由时白屏闪烁 | Suspense / loadingComponent |

| 瀑布式请求 | 一个 chunk 加载完才请求下一个 | prefetch / preload 并行化 |

| vendor 频繁失效 | 业务改动导致 vendor hash 变化,缓存全失效 | 抽 runtimeChunk,稳定 vendor 分组 |

更多框架中的实现

Next.js 动态导入(next/dynamic):

Next.js 在 React.lazy 之上封装了 `next/dynamic`,同时支持关闭 SSR、自定义 loading。

javascriptCode
import dynamic from 'next/dynamic';

// 关闭 SSR:某些只在浏览器可用的库(如地图、图表)必须这样
const MapView = dynamic(() => import('../components/MapView'), {
  ssr: false,
  loading: () => <p>地图加载中...</p>,
});

// 命名导出也可以懒加载
const Chart = dynamic(() =>
  import('../components/charts').then((mod) => mod.BarChart)
);

export default function Page() {
  return (
    <div>
      <MapView />
      <Chart />
    </div>
  );
}

Nuxt 的自动分割:

Nuxt 会自动对 `pages/` 目录按路由分割,无需手动配置。组件级懒加载用 `Lazy` 前缀即可。

htmlCode
<template>
  <!-- 组件名加 Lazy 前缀,Nuxt 自动懒加载 -->
  <LazyHeavyChart v-if="show" />
</template>

Angular 路由懒加载:

typescriptCode
const routes: Routes = [
  {
    path: 'admin',
    // loadChildren 触发独立 chunk,进入路由才下载
    loadChildren: () =>
      import('./admin/admin.module').then((m) => m.AdminModule),
  },
];

微前端与模块联邦(Module Federation)

Webpack 5 的模块联邦(Module Federation)让多个独立部署的应用在运行时共享代码,是大型微前端的核心方案。它本质上是一种"跨应用的运行时代码分割"。

javascriptCode
// host 应用 webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        // 运行时从远程加载子应用暴露的模块
        cart: 'cart@https://cdn.example.com/cart/remoteEntry.js',
      },
      shared: {
        // 共享依赖,避免重复加载 React
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true },
      },
    }),
  ],
};

// 使用远程模块
const RemoteCart = React.lazy(() => import('cart/Cart'));

真实案例:某平台由 6 个团队维护的微前端组成,通过模块联邦共享 React、设计系统组件库,避免了每个子应用各自打包一份框架,整体首屏 JS 从 2.1MB 降到 780KB。

Bundle 分析实操

优化前先测量。用可视化工具找出"到底谁最大、谁被重复打包"。

bashCode
# webpack
npm i -D webpack-bundle-analyzer
# 在 webpack 配置里加插件后构建,会打开交互式树图

# Vite
npm i -D rollup-plugin-visualizer

# 通用:基于 source map 分析线上产物
npx source-map-explorer dist/js/*.js
javascriptCode
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false }),
  ],
};

// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
  plugins: [visualizer({ filename: 'stats.html', gzipSize: true })],
};

分析时重点看:有没有 moment/lodash 全量引入、有没有同一个库被打进多个 chunk、有没有本该懒加载的大组件混进了主包。

弱网与移动端的分割策略

网络越差,代码分割的价值越大,但也要避免"碎片化请求"拖累弱网。

javascriptCode
// 根据网络状况自适应:弱网时不做激进 prefetch
const conn = navigator.connection;
const saveData = conn && conn.saveData;
const slow = conn && /2g/.test(conn.effectiveType);

function maybePrefetch(importFn) {
  if (saveData || slow) return; // 省流量模式/弱网:跳过预取
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => importFn());
  } else {
    setTimeout(importFn, 2000);
  }
}

maybePrefetch(() => import('./pages/Reports'));

数据对比:在 3G 弱网下,过度分割(30+ 个小 chunk)反而比适度分割(8 个 chunk)慢 40%,因为每个请求都有连接与调度开销。经验值:单个异步 chunk 控制在 20-150KB(Gzip 前)较合理。

真实案例:SaaS 后台首屏优化全过程

某 SaaS 后台优化前 bundle 分析发现三个问题:整包引入了 `echarts`(全量 1MB)、`moment` + 全部 locale(230KB)、以及所有路由打进主包。

优化动作:

1.echarts 改按需引入 + 图表页懒加载
2.moment 换成 dayjs(体积 2KB)
3.所有路由 React.lazy 分割
4.antd 组件按需(配合 Tree Shaking)
javascriptCode
// echarts 按需引入,只打进用到的图表类型
import * as echarts from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer]);

| 阶段 | 主包(Gzip) | TTI(4G) | 说明 |

| 优化前 | 900KB | 6.8s | 全量打包 |

| echarts 按需+懒加载 | 560KB | 4.2s | 图表页才下载 |

| 换 dayjs | 490KB | 3.6s | 省掉 moment locale |

| 路由分割 | 180KB | 2.3s | 首屏只加载首页 |

| antd 按需 | 95KB | 2.1s | Tree Shaking 生效 |

常见面试问答

Q:动态 import 和静态 import 的本质区别?

静态 import 在编译时确定依赖关系、被打进引用它的 chunk;动态 import 是运行时按需加载、会被打包器切成独立 chunk,返回 Promise。

Q:preload 和 prefetch 的区别?

preload 是当前页面必需的高优先级资源,立即下载;prefetch 是未来可能用到的资源,浏览器空闲时低优先级下载。

Q:为什么要抽 runtimeChunk?

webpack 运行时代码(模块映射等)每次构建都可能变化,若混在 vendor 里会导致 vendor 的哈希频繁变化、长缓存失效。单独抽出后 vendor 更稳定。

Q:代码分割一定能提速吗?

不一定。首屏组件被懒加载、过度分割导致请求过多、瀑布式加载等都会适得其反。要用数据验证。

性能预算与 CI 监控

分割做完不是终点,要防止体积在迭代中悄悄反弹。设定"性能预算"并在 CI 中卡住。

javascriptCode
// webpack 性能预算:超限则构建告警/失败
module.exports = {
  performance: {
    hints: 'error',            // 超限直接报错,阻断合并
    maxEntrypointSize: 250000, // 入口点最大 250KB
    maxAssetSize: 250000,      // 单资源最大 250KB
  },
};

也可以在 CI 里用 size-limit 精确到每个 chunk 的 Gzip/Brotli 体积:

javascriptCode
// .size-limit.js
module.exports = [
  { path: 'dist/main.*.js', limit: '100 KB', gzip: true },
  { path: 'dist/framework.*.js', limit: '45 KB', gzip: true },
];
// package.json scripts: "size": "size-limit"
// CI 中运行 npm run size,超限退出码非 0,阻断发布

SSR 流式渲染下的分割

在 React 18 的流式 SSR 中,`Suspense` 不只是客户端 loading,它还能让服务端"先发送已就绪的部分 HTML,未就绪的部分用占位符,稍后流式补齐",配合 `renderToPipeableStream` 实现渐进式呈现。

javascriptCode
import { Suspense } from 'react';

function Page() {
  return (
    <>
      <Header />                {/* 立即渲染 */}
      <Suspense fallback={<Skeleton />}>
        <SlowComments />        {/* 数据就绪后流式补齐,不阻塞首屏 */}
      </Suspense>
    </>
  );
}

这样即便某块数据慢,用户也能先看到首屏骨架,显著改善感知性能。分割在这里从"减少下载"延伸到了"渐进式呈现"。

分割粒度与 HTTP/2 的关系

HTTP/1.1 时代浏览器每个域名并发有限(约 6 个),所以倾向"合并请求、少而大";HTTP/2 多路复用后并发不再是瓶颈,可以更细粒度分割、更好地利用缓存。但细分仍有上限——每个 chunk 都有解析、执行、调度成本,且过多小文件的 Gzip 压缩率反而下降。

| 场景 | 分割倾向 |

| HTTP/1.1 | 少而大,减少请求数 |

| HTTP/2/3 | 可更细,利用多路复用与缓存 |

| 弱网(2G/3G) | 适度,避免过多往返 |

| 强缓存命中率优先 | 稳定 vendor + 内容哈希 |

最佳实践

按路由分割代码(收益最大,优先做)
懒加载非关键组件(弹窗、图表、编辑器)
首屏关键组件保持静态导入,不要懒加载
预加载关键资源(preload),预取下一步资源(prefetch)
用 hover / Intersection Observer 做智能预加载
懒加载务必处理失败与 loading 态
合理设置分割粒度,避免过度分割
抽离 vendor / runtime,配合内容哈希与长缓存
结合 Service Worker 做离线与运行时缓存
用打包分析工具(webpack-bundle-analyzer / rollup-plugin-visualizer)持续监控分割效果
考虑用户网络环境,弱网下更需要精细分割

总结

| 维度 | 要点 |

| 核心目标 | 按需分批交付代码,缩短首屏可交互时间 |

| 主要手段 | 动态 import、React.lazy/Suspense、Vue 异步组件 |

| 分割维度 | 路由 > 组件 > 功能 > 第三方库 |

| 配置工具 | webpack splitChunks、Vite manualChunks |

| 加速补充 | preload / prefetch / preconnect、Service Worker |

| 常见收益 | 首包体积降 60-90%,TTI/LCP 提升 2-3 倍 |

| 关键红线 | 不懒加载首屏、处理加载失败、避免过度分割 |

代码分割不是"分得越细越好",而是"该分的分、该合的合"。始终以真实的用户访问路径和网络环境为依据,用数据(bundle 分析 + 真实性能指标)驱动优化决策。