懒加载与代码分割

概述

当用户打开一个网页时,浏览器需要下载、解析和执行所有的 JavaScript 代码。这个过程消耗的时间和资源直接影响用户何时能够开始与页面交互。如果一个 Web 应用把所有的代码都打包到一个巨大的 bundle 中,用户可能需要等待数十秒才能看到页面内容——即使他们只使用了其中一小部分功能。

懒加载(Lazy Loading)和代码分割(Code Splitting)是解决这个问题的核心技术。懒加载的核心思想是「延迟加载」——不要一次加载所有内容,而是按需加载。代码分割则是将代码分解为多个独立的 chunk(块),每个 chunk 可以独立下载和缓存。

想象一下电商网站的结账流程:用户浏览商品、加入购物车、最后进入结算页面。大多数用户可能只浏览而不购买,但结账功能的代码却被发送到每一个用户。代码分割允许你将结账相关代码单独打包,只有真正进入结算页面的用户才会下载它。

本章将深入讲解现代前端开发中的懒加载和代码分割技术。我们会从 ES 动态导入语法开始,探讨 Webpack 和 Vite 等构建工具如何支持代码分割,然后学习 React 中的组件懒加载方案,最后讨论预加载和预获取策略来优化用户体验。

目标

  • 掌握动态 import() 语法与构建工具的代码分割机制
  • 熟练使用 React.lazy 和 Suspense 实现组件级懒加载
  • 理解路由级和组件级代码分割的最佳实践
  • 掌握预加载(Preload)和预获取(Prefetch)策略以优化用户体验

知识体系

1. 动态 import() 基础

ES6 引入了 import 语法用于静态导入,这种导入方式在代码执行前就会解析,所有的导入模块都会被包含在初始 bundle 中。动态 import() 则不同,它返回的是一个 Promise,允许我们在运行时决定加载哪个模块。

ES 动态导入

动态导入使用 import() 语法,接受一个模块路径作为参数,返回一个 Promise。当模块加载完成后,Promise 会被 resolve,then 参数中接收的就是完整的模块对象。

// 静态导入 — 构建时确定,包含在主 bundle 中
import { heavyFunction } from './heavy-module';

// 动态导入 — 运行时加载,自动拆分为独立 chunk
const module = await import('./heavy-module');
module.heavyFunction();

// 条件加载
async function loadAnalytics() {
  if (userConsented) {
    const { init } = await import('./analytics');
    init();
  }
}

// 使用 webpackChunkName 自定义 chunk 名称
const EditorModule = () => import(
  /* webpackChunkName: "editor" */
  /* webpackPrefetch: true */
  './components/Editor'
);

Webpack 在处理动态导入时会自动创建一个新的 chunk。默认情况下,生成的 chunk 文件名是数字 ID,不够友好。你可以使用 webpack 特定的注释(magic comments)来自定义 chunk 名称或添加预获取指令。

Webpack 的 magic comments 包括:

  • webpackChunkName:指定 chunk 的名称
  • webpackPrefetch:预获取,告诉 Webpack 在主 chunk 加载完成后闲时获取
  • webpackPreload:预加载,与主 chunk 并行获取

Vite 的代码分割

Vite 基于 Rollup 实现代码分割,提供了 manualChunks 配置选项来自定义分割策略。相比 Webpack,Vite 的配置更加简洁。

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 React 相关库打包到 vendor chunk
          'react-vendor': ['react', 'react-dom'],
          // 将 UI 库单独打包
          'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-popover'],
          // 将工具库单独打包
          'utils': ['date-fns', 'lodash-es'],
        },
      },
    },
  },
});

// 更灵活的分割策略
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react')) return 'react-vendor';
            if (id.includes('@radix-ui')) return 'ui-vendor';
            return 'vendor';
          }
        },
      },
    },
  },
});

manualChunks 的配置非常灵活,可以按模块路径来分组。将频繁更新的业务代码和几乎不变化的第三方库分开打包,可以优化缓存效率——当业务代码更新时,用户不需要重新下载没有变化的第三方库。

2. React 组件懒加载

在 React 应用中,懒加载通常应用于整个页面或重量级组件。React 提供了 React.lazySuspense 两个 API 来简化组件级懒加载的实现。

React.lazy + Suspense

React.lazy 接受一个返回动态 import 的函数,返回一个 React 组件。当组件首次渲染时,懒加载的模块才会被请求。Suspense 组件则用于包裹懒加载组件,并提供加载状态的 UI。

import { lazy, Suspense } from 'react';

// 懒加载组件
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));

// 带重试逻辑的懒加载
function lazyWithRetry(importFn, retries = 3) {
  return lazy(async () => {
    for (let i = 0; i < retries; i++) {
      try {
        return await importFn();
      } catch (error) {
        if (i === retries - 1) throw error;
        // 等待后重试,可能是网络临时故障
        await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
      }
    }
    throw new Error('Failed to load module');
  });
}

const HeavyChart = lazyWithRetry(() => import('./components/HeavyChart'));

// 使用 Suspense 包裹
function App() {
  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}

lazyWithRetry 是一个实用的增强函数,它在网络故障时自动重试。在不稳定的网络环境下,这可以显著提高用户体验。网络请求失败往往是暂时的,简单重试通常就能成功。

fallback prop 接受任何 React 元素,通常是一个骨架屏(Skeleton)组件。骨架屏相比传统的 loading spinner 体验更好,因为它提前展示了内容的结构,让用户对加载内容有预期。

命名导出的懒加载

React.lazy 默认只支持 default export 的懒加载。对于 named export,需要一些额外的处理。

// React.lazy 默认只支持 default export
// 对于 named export,使用以下模式

// 方式一:中间模块重导出
// HeavyChart.lazy.js
export { HeavyChart as default } from './HeavyChart';

const HeavyChart = lazy(() => import('./HeavyChart.lazy'));

// 方式二:在 import 中转换
const HeavyChart = lazy(() =>
  import('./components').then((module) => ({
    default: module.HeavyChart,
  }))
);

在实际项目中,通常建议对需要懒加载的组件使用 default export,这样可以简化懒加载的实现。

3. 路由级代码分割

路由级代码分割是最常见也是最有效的代码分割策略。它基于路由来划分代码边界,每个路由对应一个代码 chunk,用户在访问路由时才加载对应的代码。

// 路由级分割是最高效的分割策略
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Cart = lazy(() => import('./pages/Cart'));
const Checkout = lazy(() => import('./pages/Checkout'));

const router = createBrowserRouter([
  {
    path: '/',
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <Home />
      </Suspense>
    ),
  },
  {
    path: '/products',
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <Products />
      </Suspense>
    ),
    children: [
      {
        path: ':id',
        element: (
          <Suspense fallback={<ProductSkeleton />}>
            <ProductDetail />
          </Suspense>
        ),
      },
    ],
  },
  {
    path: '/checkout',
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <Checkout />
      </Suspense>
    ),
  },
]);

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

每个路由组件被包裹在自己的 Suspense 中,这意味着只有在用户导航到特定路由时,才会触发该路由组件的加载。这种模式在路由数量较多的大型应用中效果显著——用户只需要下载访问过的路由代码。

4. 预加载与预获取策略

懒加载的一个潜在问题是首次访问某个路由时的加载延迟。当用户点击导航到新路由时,需要等待代码下载和执行才能看到内容。为了改善这种体验,可以使用预加载和预获取策略。

交互预加载

在用户鼠标悬停时就开始预加载,可以显著减少导航到该路由时的等待时间。

// 鼠标悬停时预加载
function NavLink({ to, children, importFn }) {
  const handleMouseEnter = () => {
    // 触发 chunk 预加载
    importFn();
  };

  return (
    <Link to={to} onMouseEnter={handleMouseEnter}>
      {children}
    </Link>
  );
}

// 使用
const settingsImport = () => import('./pages/Settings');
const Settings = lazy(settingsImport);

<NavLink to="/settings" importFn={settingsImport}>
  Settings
</NavLink>

这个技术的原理是:当用户鼠标悬停在导航链接上时,代码 chunk 就在后台开始下载。当用户实际点击链接时,代码可能已经下载完成,用户可以直接看到页面内容,而不是等待加载状态。

Intersection Observer 预加载

对于页面底部的内容或组件,可以在它们接近可视区域时提前预加载。

function usePrefetchOnVisible(importFn) {
  const ref = useRef(null);
  const prefetched = useRef(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && !prefetched.current) {
          prefetched.current = true;
          importFn();
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [importFn]);

  return ref;
}

// 当用户滚动到附近时自动预加载
function ProductSection() {
  const chartImport = () => import('./HeavyChart');
  const ref = usePrefetchOnVisible(chartImport);

  return (
    <section ref={ref}>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart />
      </Suspense>
    </section>
  );
}

rootMargin: '200px' 设置了一个 200px 的缓冲区,意味着元素在距离可视区域 200px 时就开始被观察。这给了预加载一个提前量,确保在元素真正进入可视区域时已经加载完成。

Link Prefetch

浏览器原生的 <link rel="prefetch"> 标签可以在 HTML 中声明资源的预获取,让浏览器在闲时下载这些资源。

<!-- 浏览器空闲时预获取 -->
<link rel="prefetch" href="/static/js/settings.chunk.js" />

<!-- 高优先级预加载当前页面需要的资源 -->
<link rel="preload" href="/static/js/critical.chunk.js" as="script" />

prefetchpreload 的区别在于时机和优先级。preload 是高优先级的,会在当前页面加载完成后立即开始下载。prefetch 是低优先级的,浏览器会在闲时(不影响主页面加载)才下载。

5. 资源懒加载

代码分割只是懒加载的一种形式。图片、脚本、视频等资源同样适用懒加载策略。

图片懒加载

现代浏览器原生支持图片懒加载,只需添加 loading="lazy" 属性即可。

<!-- 原生 loading="lazy" -->
<img src="image.jpg" loading="lazy" alt="..." width="800" height="600" />

<!-- 首屏图片不应使用懒加载 -->
<img src="hero.jpg" loading="eager" fetchpriority="high" alt="..." />

关键是要区分首屏图片和次屏图片。首屏图片(LCP 候选元素)不应该使用懒加载,因为它们需要尽快加载以优化 LCP 指标。fetchpriority="high" 属性可以进一步提升关键图片的加载优先级。

第三方脚本懒加载

第三方脚本(如分析、监控、广告)通常不是首屏必需的,可以延迟加载。

// 延迟加载非关键第三方脚本
function loadScript(src) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    script.async = true;
    script.onload = resolve;
    script.onerror = reject;
    document.body.appendChild(script);
  });
}

// 页面空闲时加载分析脚本
if ('requestIdleCallback' in window) {
  requestIdleCallback(() => {
    loadScript('https://analytics.example.com/tracker.js');
  });
} else {
  setTimeout(() => {
    loadScript('https://analytics.example.com/tracker.js');
  }, 3000);
}

requestIdleCallback API 允许你在浏览器空闲时执行低优先级任务。相比 setTimeout,它能更好地响应用户的交互——如果用户正在活跃地与页面交互,requestIdleCallback 会推迟执行。

6. 分割策略选择

代码分割需要权衡。分割越细,缓存粒度越好,但 initial bundle 越小;如果分割太粗,所有代码在一个大文件中,缓存效率低。选择合适的分割策略需要考虑多个因素。

代码分割决策树:
┌──────────────────────┐
│ 该代码是首屏必需的吗?  │
└──────┬───────────────┘
       │
  Yes ──┤── No
  │     │
  │     ▼
  │  ┌──────────────────────┐
  │  │ 是否按路由自然分离?    │
  │  └──────┬───────────────┘
  │    Yes ──┤── No
  │    │     │
  │    │     ▼
  │    │  ┌──────────────────────┐
  │    │  │ 组件体积 > 30KB?     │
  │    │  └──────┬───────────────┘
  │    │    Yes ──┤── No
  │    │    │     │
  │    │    │     ▼
  │    │    │  保留在主 bundle
  │    │    ▼
  │    │  组件级懒加载
  │    ▼
  │  路由级代码分割
  ▼
主 bundle(保持精简)

判断是否需要分割的经验法则:

  • 首屏必需的代码留在主 bundle,确保最快的首次内容绘制
  • 按路由分离的代码使用路由级分割,用户只下载访问路由的代码
  • 体积超过 30KB 的非首屏组件考虑懒加载,过小的组件分割收益不明显
  • 第三方库单独打包,优化长期缓存

实战练习

练习 1:路由懒加载改造

将一个全量打包的 SPA 改造为路由级代码分割。使用 Webpack Bundle Analyzer 或 Vite 的 build analyzer 观察改造前后的 bundle 组成变化,使用 Lighthouse 测量页面加载时间的改善。

练习 2:智能预加载

实现一个基于用户行为预测的智能预加载系统:跟踪用户的导航模式,当检测到用户即将访问某个路由时提前预加载代码。实现一个简单的马尔可夫链模型来预测下一个可能访问的页面。

练习 3:Loading 状态优化

设计优雅的 Suspense fallback 组件,包括骨架屏和过渡动画。实现内容渐显动画,让用户感受到加载是「有进度」的,而不是无反馈地等待。


延展阅读


关键术语

术语 解释
Code Splitting 代码分割,将 bundle 拆分为按需加载的 chunk 的技术
Lazy Loading 懒加载,延迟加载非关键资源,只在需要时加载
Dynamic Import 动态导入,通过 import() 语法在运行时加载模块
Chunk 代码分割后的独立文件单元,可以独立下载和缓存
Prefetch 预获取,在浏览器空闲时提前下载资源
Preload 预加载,高优先级提前下载当前页面需要的资源
Suspense React 用于处理异步加载状态的组件,配合 lazy 使用
Skeleton 骨架屏,内容加载前的占位 UI,提升加载感知