@loadable/component 实现服务端渲染代码分割

在构建 React SSR 应用时,代码分割(Code Splitting) 是优化性能的关键技术。然而,React 官方的 React.lazy 并不支持服务端渲染。本文将详细介绍如何使用 @loadable/component 生态在 SSR 应用中实现完整的代码分割方案。

核心流程概览

在深入细节之前,先了解整体工作流程:

┌─────────────────────────────────────────────────────────────────────┐
│                        构建阶段 (Build Time)                         │
├─────────────────────────────────────────────────────────────────────┤
│  @loadable/babel-plugin    →    标记动态导入组件                      │
│  @loadable/webpack-plugin  →    生成 loadable-stats.json            │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                     服务端渲染阶段 (SSR Phase)                        │
├─────────────────────────────────────────────────────────────────────┤
│  ChunkExtractor 读取 loadable-stats.json                            │
│  collectChunks() 收集渲染过程中使用的 chunks                          │
│  getScriptTags() 生成所需的 <script> 标签                            │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                     客户端水合阶段 (Hydration)                        │
├─────────────────────────────────────────────────────────────────────┤
│  浏览器并行下载所有注入的 JS 文件                                      │
│  loadableReady() 等待所有 chunks 加载完成                            │
│  hydrate() 执行水合,应用进入可交互状态                                │
└─────────────────────────────────────────────────────────────────────┘

详细流程说明

服务器端渲染(SSR)阶段:

  • ChunkExtractor 读取 loadable-stats.json 文件
  • renderToString(extractor.collectChunks(<App />)) 执行时,所有使用了 loadable() 的组件,如果在当前请求中被渲染到了,其对应的代码块(Chunk)信息就会被 ChunkExtractor 记录下来
  • 渲染完成后,ChunkExtractor.getScriptTags() 会查询统计文件获取实际的文件名称和所在路径,生成包含主 Bundle 和所有被记录的动态 Chunks 文件的 <script src="..."> 标签
  • 服务器将渲染好的 HTML 内容、包含 CSS/Link 标签和这些 <script> 标签 一起发送给客户端

客户端水合(Hydration)协调阶段:

  • 客户端浏览器接收到 HTML 后,会并行下载所有注入的 JS 文件,包括主 Bundle 和所有动态 Chunks 文件
  • 主 Bundle(包含 React 框架和 loadableReady 逻辑)下载完成立即执行
  • loadableReady 立即被调用,确保在所有必要的动态 chunk 被加载和执行完毕后,才执行 hydrate(),从而避免代码未就绪导致水合失败

客户端接管阶段:

  • 一旦 loadableReady 确认所有 Chunks 文件都已就位,它就会执行回调函数
  • 回调函数中的 hydrate(<App />) 才会开始执行,客户端开始水合
  • React 成功地水合服务器渲染的 DOM 结构,接管所有状态和事件监听器,应用进入完全交互状态

安装依赖

# npm
npm install @loadable/component @loadable/server
npm install --save-dev @loadable/babel-plugin @loadable/webpack-plugin

# yarn
yarn add @loadable/component @loadable/server
yarn add --dev @loadable/babel-plugin @loadable/webpack-plugin

# pnpm
pnpm add @loadable/component @loadable/server
pnpm add -D @loadable/babel-plugin @loadable/webpack-plugin

配置指南

1. 配置 Babel 插件

@loadable/babel-plugin 会在编译时为动态导入的组件添加必要的元数据。

// .babelrc 或 babel.config.json
{
  "plugins": ["@loadable/babel-plugin"]
}

2. 配置 Webpack 插件

@loadable/webpack-plugin 会生成 loadable-stats.json 文件,包含所有 chunks 的映射信息。

// webpack.config.js
const LoadablePlugin = require('@loadable/webpack-plugin');

module.exports = {
  // ...其他配置
  plugins: [
    new LoadablePlugin({
      filename: 'loadable-stats.json',
      writeToDisk: true,
    }),
  ],
};

3. 使用 loadable 动态导入组件

import loadable from '@loadable/component';

// 基础用法
const AsyncComponent = loadable(() => import('./OtherComponent'));

// 带 loading 状态
const AsyncWithFallback = loadable(() => import('./HeavyComponent'), {
  fallback: <div>加载中...</div>,
});

// 在组件中使用
function MyComponent() {
  return (
    <div>
      <AsyncComponent />
      <AsyncWithFallback />
    </div>
  );
}

4. 服务端设置 ChunkExtractor

import { renderToString } from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server';
import path from 'path';

// 统计文件路径
const statsFile = path.resolve('./dist/loadable-stats.json');

async function handleRequest(req, res) {
  // 创建 extractor 实例
  const extractor = new ChunkExtractor({ statsFile });

  // 使用 collectChunks 包装根组件
  const jsx = extractor.collectChunks(<App />);

  // 渲染为 HTML 字符串
  const html = renderToString(jsx);

  // 获取资源标签
  const scriptTags = extractor.getScriptTags(); // JS 脚本标签
  const linkTags = extractor.getLinkTags(); // 预加载链接标签
  const styleTags = extractor.getStyleTags(); // CSS 样式标签

  // 组装完整 HTML
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        ${linkTags}
        ${styleTags}
      </head>
      <body>
        <div id="root">${html}</div>
        ${scriptTags}
      </body>
    </html>
  `);
}

5. 客户端配置 loadableReady

所有 chunks 文件都是并行加载的,因此必须等待它们准备好再执行水合。

import { loadableReady } from '@loadable/component';
import { hydrateRoot } from 'react-dom/client';

// loadableReady 确保所有动态 chunk 加载完成后再水合
loadableReady(() => {
  const root = document.getElementById('root');
  hydrateRoot(root, <App />);
});

为什么需要 loadableReady

在 SSR 架构中,loadableReady 确保在 React 开始水合之前,所有在服务器端被标记为需要的异步代码块都已被浏览器加载并执行完毕

如果应用所需的脚本没有加载完成就进行水合,可能会导致:

  • UI 闪烁:React 无法找到某些组件的代码,可能导致渲染内容不匹配,从而引发部分 UI 重新渲染
  • 事件丢失:与异步加载组件相关的事件监听器无法被正确地挂载到 DOM 上,导致用户交互失效

高级用法

收集 Chunks 的两种方式

方式一:使用 collectChunks(推荐)

import { renderToString } from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server';

const statsFile = path.resolve('./dist/loadable-stats.json');
const extractor = new ChunkExtractor({ statsFile });

// collectChunks 本质上是语法糖,将应用包装在 Provider 中
const html = renderToString(extractor.collectChunks(<App />));
const scriptTags = extractor.getScriptTags();

方式二:使用 ChunkExtractorManager

import { renderToString } from 'react-dom/server';
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';

const statsFile = path.resolve('./dist/loadable-stats.json');
const extractor = new ChunkExtractor({ statsFile });

const html = renderToString(
  <ChunkExtractorManager extractor={extractor}>
    <App />
  </ChunkExtractorManager>,
);

const scriptTags = extractor.getScriptTags();

两种方式效果完全相同,都是将 extractor 实例通过 React Context 传递给子组件。

流式渲染支持

流式渲染可以分块发送 HTML,提高首字节时间(TTFB)首次内容绘制(FCP)

import { renderToNodeStream } from 'react-dom/server';
import { ChunkExtractor } from '@loadable/server';

async function handleRequest(req, res) {
  // 在流开始前发送 HTML 头部
  res.write('<!DOCTYPE html><html><head><title>My App</title></head><body>');

  const statsFile = path.resolve('./dist/loadable-stats.json');
  const extractor = new ChunkExtractor({ statsFile });
  const jsx = extractor.collectChunks(<App />);

  // 使用流式 API 渲染
  const stream = renderToNodeStream(jsx);

  // { end: false } 表示不要在流结束时自动关闭响应
  stream.pipe(res, { end: false });

  // 流结束后追加脚本标签
  stream.on('end', () => {
    res.end(`${extractor.getScriptTags()}</body></html>`);
  });
}

⚠️ 注意:流式渲染与预取 <link> 标签不兼容。因为当流开始发送内容时,<head> 部分已经发送完毕,无法再插入预加载标签。

预取优化(Prefetching)

通过在 <head> 中注入预加载标签,可以提前下载可能需要的资源:

const statsFile = path.resolve('./dist/loadable-stats.json');
const extractor = new ChunkExtractor({ statsFile });
const jsx = extractor.collectChunks(<App />);
const html = renderToString(jsx);

// 获取预加载链接标签
const linkTags = extractor.getLinkTags();

const fullHtml = `
  <!DOCTYPE html>
  <html>
    <head>
      ${linkTags}  <!-- 预加载链接必须在 head 中 -->
    </head>
    <body>
      <div id="root">${html}</div>
      ${extractor.getScriptTags()}
    </body>
  </html>
`;

预加载类型说明:

类型说明优先级
rel="preload"当前页面很快就会需要的资源
rel="prefetch"未来可能需要的资源(如下一页)

CSS 处理

如果使用了 mini-css-extract-plugin 提取 CSS,@loadable/server 可以自动识别:

const extractor = new ChunkExtractor({ statsFile });
const html = renderToString(extractor.collectChunks(<App />));

// 获取 CSS 标签
const styleTags = extractor.getStyleTags();

const fullHtml = `
  <!DOCTYPE html>
  <html>
    <head>
      ${styleTags}  <!-- 在 head 中注入,避免样式闪烁 -->
    </head>
    <body>
      <div id="root">${html}</div>
      ${extractor.getScriptTags()}
    </body>
  </html>
`;

禁用特定组件的 SSR

某些组件可能依赖浏览器环境(如 windowdocument),可以禁用其 SSR:

import loadable from '@loadable/component';

// 此组件不会在服务端渲染
const ClientOnlyComponent = loadable(() => import('./ClientOnly'), {
  ssr: false,
});

设置 ssr: false 后:

  • 服务端:返回空标记,不收集该组件的 chunk 信息
  • 客户端:正常加载和渲染该组件

与 React.lazy 的对比

特性React.lazy@loadable/component
SSR 支持❌ 不支持✅ 完整支持
Suspense 依赖✅ 必须❌ 可选
库级别分割❌ 不支持✅ 支持
完全动态导入❌ 不支持✅ 支持
官方维护✅ React 团队⚠️ 社区维护

选择建议:

  • 如果项目不需要 SSR,且 React.lazy 满足需求,优先使用官方方案
  • 如果项目需要 SSR,或需要更灵活的代码分割功能,选择 @loadable/component

常见问题排查

1. 水合不匹配

确保服务端和客户端使用相同的路由配置和初始状态。

2. Chunk 加载失败

检查 loadable-stats.json 是否正确生成,路径是否正确配置。

3. 样式闪烁

确保在 <head> 中注入 CSS 标签,避免内容先于样式加载。

总结

@loadable/component 为 React SSR 应用提供了完整的代码分割解决方案。核心要点:

  1. 构建时:通过 Babel 和 Webpack 插件收集 chunk 信息
  2. 服务端:使用 ChunkExtractor 收集渲染所需的资源
  3. 客户端:使用 loadableReady 确保所有代码就绪后再水合
  4. 优化:合理使用预加载和 CSS 提取,提升加载性能

掌握这套方案,可以在保证首屏性能的同时,有效减少主包体积,提升整体用户体验。

← 返回列表