跳到主要内容

懒加载与代码分割

问题

什么是代码分割?React 如何实现懒加载?Suspense 的工作原理是什么?

答案

代码分割(Code Splitting) 是将 JavaScript 打包产物分割成多个小块,按需加载的技术。React 通过 React.lazySuspense 实现组件级别的懒加载。

为什么需要代码分割?

问题代码分割的价值
首屏加载慢只加载首屏需要的代码
大文件阻塞分成小块并行加载
资源浪费用户可能不访问的功能不加载

React.lazy 基本用法

动态导入组件

import React, { Suspense, lazy } from 'react';

// 动态导入组件
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Profile = lazy(() => import('./Profile'));

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

function LoadingSpinner() {
return <div className="spinner">Loading...</div>;
}

命名导出处理

React.lazy 只支持默认导出。对于命名导出,需要创建中间模块:

// ManyComponents.tsx - 命名导出
export function ComponentA() { return <div>A</div>; }
export function ComponentB() { return <div>B</div>; }

// ComponentA.tsx - 重新默认导出
export { ComponentA as default } from './ManyComponents';

// 使用
const ComponentA = lazy(() => import('./ComponentA'));

或者使用 .then() 处理:

const ComponentA = lazy(() =>
import('./ManyComponents').then(module => ({
default: module.ComponentA
}))
);

Suspense 深入理解

Suspense 的工作原理

// Suspense 的简化原理
class Suspense extends React.Component {
state = { hasError: false };

static getDerivedStateFromError(error: any) {
// Promise 被捕获
if (error instanceof Promise) {
return { hasError: true };
}
throw error;
}

componentDidCatch(error: any) {
if (error instanceof Promise) {
// 等待 Promise 完成后重新渲染
error.then(() => {
this.setState({ hasError: false });
});
}
}

render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
Promise 抛出机制

React.lazy 加载组件时会抛出一个 Promise,Suspense 捕获这个 Promise 并显示 fallback。当 Promise resolve 后,Suspense 重新渲染子组件。

嵌套 Suspense

可以嵌套多个 Suspense,提供不同粒度的加载状态:

function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header />
<main>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<Content />
</Suspense>
</main>
</Suspense>
);
}

路由级代码分割

React Router

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Suspense, lazy } from 'react';

// 路由级懒加载
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const NotFound = lazy(() => import('./pages/NotFound'));

function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

预加载优化

// 鼠标悬停时预加载
const Dashboard = lazy(() => import('./pages/Dashboard'));

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
const handleMouseEnter = () => {
if (to === '/dashboard') {
// 预加载 Dashboard 组件
import('./pages/Dashboard');
}
};

return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
// 使用 webpackPrefetch 预获取
const Dashboard = lazy(() =>
import(/* webpackPrefetch: true */ './pages/Dashboard')
);

// webpackPreload: 当前导航需要
// webpackPrefetch: 未来导航可能需要

组件级代码分割

条件渲染懒加载

const HeavyEditor = lazy(() => import('./HeavyEditor'));
const SimpleViewer = lazy(() => import('./SimpleViewer'));

function DocumentView({ isEditing }: { isEditing: boolean }) {
return (
<Suspense fallback={<EditorSkeleton />}>
{isEditing ? <HeavyEditor /> : <SimpleViewer />}
</Suspense>
);
}

模态框懒加载

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

function App() {
const [isOpen, setIsOpen] = useState(false);

return (
<div>
<button onClick={() => setIsOpen(true)}>
Open Settings
</button>

{isOpen && (
<Suspense fallback={<ModalSkeleton />}>
<SettingsModal onClose={() => setIsOpen(false)} />
</Suspense>
)}
</div>
);
}

错误边界处理

懒加载失败(网络错误等)需要错误边界处理:

import { Component, ErrorInfo, ReactNode } from 'react';

interface ErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Lazy load failed:', error, errorInfo);
}

render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

// 使用
function App() {
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
);
}

重试机制

function lazyWithRetry<T extends React.ComponentType<any>>(
componentImport: () => Promise<{ default: T }>,
retries = 3,
interval = 1000
): React.LazyExoticComponent<T> {
return lazy(async () => {
let lastError: Error | undefined;

for (let i = 0; i < retries; i++) {
try {
return await componentImport();
} catch (error) {
lastError = error as Error;
await new Promise(resolve => setTimeout(resolve, interval));
}
}

throw lastError;
});
}

// 使用
const Dashboard = lazyWithRetry(() => import('./Dashboard'));

打包分析与优化

Webpack Bundle Analyzer

npm install --save-dev webpack-bundle-analyzer
# 分析打包结果
npx webpack-bundle-analyzer build/stats.json

分割策略

// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// 第三方库单独打包
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
},
// React 相关单独打包
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react',
priority: 20,
},
// 公共模块
common: {
minChunks: 2,
name: 'common',
priority: 5,
},
},
},
},
};

Vite 代码分割

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
router: ['react-router-dom'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
},
},
},
},
});

常见面试问题

Q1: React.lazy 和 Suspense 的原理是什么?

答案

React.lazy 原理

  1. lazy() 接收一个返回 Promise 的函数
  2. 首次渲染时,调用该函数加载组件
  3. 在组件加载完成前,抛出一个 Promise

Suspense 原理

  1. Suspense 捕获子组件抛出的 Promise
  2. 显示 fallback 内容
  3. 等待 Promise resolve 后,重新渲染子组件
// 简化实现
function lazy<T extends ComponentType<any>>(
load: () => Promise<{ default: T }>
): LazyExoticComponent<T> {
let Component: T | null = null;
let promise: Promise<void> | null = null;

return function LazyComponent(props: any) {
if (Component) {
return <Component {...props} />;
}

if (!promise) {
promise = load().then(module => {
Component = module.default;
});
}

throw promise; // 抛出 Promise,Suspense 捕获
};
}

Q2: 代码分割的最佳实践有哪些?

答案

策略说明示例
路由级分割每个路由一个 chunk页面级组件
组件级分割大型/非首屏组件弹窗、图表、编辑器
库分割第三方库单独打包React、lodash
预加载用户可能访问的页面hover 预加载、空闲预加载
// 1. 路由级分割
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

// 2. 组件级分割 - 大型组件
const RichEditor = lazy(() => import('./components/RichEditor'));

// 3. 预加载 - hover 时
const handleMouseEnter = () => {
import('./pages/Dashboard'); // 预加载
};

// 4. 预加载 - 空闲时
requestIdleCallback(() => {
import('./pages/Settings');
});

Q3: 如何处理懒加载失败的情况?

答案

  1. 使用 Error Boundary
<ErrorBoundary fallback={<ErrorUI onRetry={retry} />}>
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
  1. 实现重试机制
const LazyComponent = lazy(async () => {
for (let i = 0; i < 3; i++) {
try {
return await import('./Component');
} catch (error) {
if (i === 2) throw error;
await new Promise(r => setTimeout(r, 1000));
}
}
throw new Error('Failed to load');
});
  1. 提供离线降级方案
const LazyComponent = lazy(async () => {
try {
return await import('./Component');
} catch {
return { default: OfflineFallback };
}
});

Q4: Suspense 可以用于数据加载吗?

答案

React 18 的 Suspense 支持数据加载,但需要配合支持 Suspense 的数据获取库。

// 使用 React Query(支持 Suspense)
import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
// 会抛出 Promise,Suspense 捕获并显示 fallback
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});

return <div>{user.name}</div>;
}

function App() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId="123" />
</Suspense>
);
}

原理:数据获取库在数据未就绪时抛出 Promise,与 React.lazy 机制相同。

Q5: 动态 import 和 React.lazy 有什么区别?

答案

特性import()React.lazy
用途加载任意 JS 模块加载 React 组件
返回值Promise<module>LazyExoticComponent
配合使用任何场景必须配合 Suspense
错误处理try/catchError Boundary
// import() - 加载普通模块
const mathModule = await import('./math');
const result = mathModule.add(1, 2);

// React.lazy - 加载组件
const LazyComponent = lazy(() => import('./Component'));

// 使用
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>

相关链接