loadable component实现服务端渲染

流程

服务器端渲染(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 结构,接管所有状态和事件监听器,应用进入完全交互状态。

Install

npm install @loadable/server && npm install --save-dev @loadable/babel-plugin @loadable/webpack-plugin
# or using yarn
yarn add @loadable/server && yarn add --dev @loadable/babel-plugin @loadable/webpack-plugin

Guide

1. 配置@loadable/babel-plugin@loadable/webpack-plugin

// .babelrc
{
  "plugins": ["@loadable/babel-plugin"]
}
// webpack.config.js
const LoadablePlugin = require('@loadable/webpack-plugin')

module.exports = {
  // ...
  plugins: [new LoadablePlugin()],
}

2. 动态导入组件

import loadable from '@loadable/component'

const OtherComponent = loadable(() => import('./OtherComponent'))

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  )
}

3. 服务端设置 ChunkExtractor

// ChunkExtractor:用于解析构建信息并提取资源
import { ChunkExtractor } from '@loadable/server'

// 统计文件,包含所有代码分割后的chunks极其对应的文件路径信息
const statsFile = path.resolve('../dist/loadable-stats.json')

// 创建一个extractor实例,并传入统计文件的路径
const extractor = new ChunkExtractor({ statsFile })

// 使用extractor实例的collectChunks方法包装React应用根组件<YourApp />
const jsx = extractor.collectChunks(<YourApp />)

// 将包装后的 JSX 渲染成 HTML 字符串
const html = renderToString(jsx)

// 获取所有必须的JS文件的<script>标签(包括应用主入口文件和所有被动态导入的代码块)
const scriptTags = extractor.getScriptTags() // or extractor.getScriptElements();

// 获取预加载/预取 (preload/prefetch) 资源的 <link> 标签,用于优化性能
const linkTags = extractor.getLinkTags() // or extractor.getLinkElements();

// 获取所有必需的 CSS 文件 的 <style> 或 <link> 标签 (如果使用了 "mini-css-extract-plugin")
const styleTags = extractor.getStyleTags() // or extractor.getStyleElements();

4. 客户端添加 loadableReady

所有chunks文件都是并行加载的,因此必须等待它们准备好再使用 loadableReady

服务器端渲染 (SSR) 架构中,确保在 React 开始水合 (Hydration) 之前,所有在服务器端被标记为需要的、并且通过 <script> 标签注入到 HTML 中的异步加载的代码块(chunks),都已经被浏览器加载并执行完毕

关键原理

  • 异步加载和并行下载: @loadable/component 允许我们将应用分割成多个代码块 (Chunks)。在 SSR 过程中,服务器会识别出当前页面需要哪些代码块,并将它们的 .js 文件作为独立的 <script> 标签注入到 HTML 中。浏览器会并行下载这些脚本文件。
  • 同步点: 由于这些脚本是异步且并行加载的,我们不能确定它们何时完成。
import { loadableReady } from '@loadable/component'

// loadableReady:暂停客户端的React渲染/水合过程,直到@loadable/component
// 知道当前页面所需的所有动态加载的脚本都已加载并解析完毕
// 只有当所有必要代码到位后,才会执行传入的回调函数,开始客户端的 水合 (Hydrate) 过程
loadableReady(() => {
  // 获取HTML中用于挂在React应用的根DOM元素
  const root = document.getElementById('main')
  // 使用React DOM的 hydrate 方法(而不是 render)
  // 
  hydrate(<App />, root)
})

水合 (Hydrate) 的作用: React 会尝试将服务器端生成的 HTML 结构与客户端的 React 状态和事件监听器关联起来,而不是重新创建整个 DOM 树。

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

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

5. 收集chunks

基本的API:

// 方法一
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:本质上是一个语法糖,将传入的<YourApp />元素包装在一个Provide组件中
const html = renderToString(extractor.collectChunks(<YourApp />))
const scriptTags = extractor.getScriptTags() // or extractor.getScriptElements();

渲染: 当 React 的 renderToString 函数执行时,应用被渲染。在渲染过程中,任何遇到的 loadable() 组件都会从这个 Provider 中获取 extractor 实例,并把自己需要的代码块信息报告给它。

结果: 渲染结束后,extractor 实例就拥有了当前页面所需的所有代码块信息。

// 方法二
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}>
    <YourApp />
  </ChunkExtractorManager>
)

const scriptTags = extractor.getScriptTags() // or extractor.getScriptElements();

**直接使用 Provider:**接将 <YourApp /> 放在了 <ChunkExtractorManager> 内部。

**传入 extractor:**通过 extractor 属性将已经创建好的 ChunkExtractor 实例传递给 Manager。

效果等同: 这种方式和 extractor.collectChunks(<YourApp />) 的效果是完全一样的,都是将 extractor 实例通过 React Context 传递给子组件。

在上述两种方式渲染出 html 之后,extractor 实例就可以用于生成客户端所需的资源标签。

  1. 提取脚本标签
  • const scriptTags = extractor.getScriptTags()
    • 返回一个包含多个 <script> 标签的字符串
    • SSR模式下,这些标签会被自动标记为 async,意味着浏览器会异步下载和执行它们,以避免阻塞 DOM 渲染。(注意:<link rel="stylesheet"> 不会被 loadable 标记为异步,也不会影响样式的即时应用)
  • const scriptTags = extractor.getScriptElements()
    • 返回一个 React 元素数组,而不是字符串。这在希望在服务器端以 JSX 的方式操作或组合这些标签时很有用。
  1. 客户端协调(loadableReady

"You have to wait for them to be ready using loadableReady."

  • 正如我们之前讨论的,因为这些脚本是异步加载的,服务器端只是生成了它们的标签。
  • 在客户端,我们必须使用 loadableReady 函数来等待这些标记为 async 的所有 Chunks 文件都加载并执行完毕后,才能安全地启动 hydrate() 过程。

6. 流式渲染 (renderToNodeStream)

流式渲染(如使用 renderToNodeStream)的目的是分块发送 HTML 给客户端,提高首字节时间 (TTFB)首次内容绘制 (FCP)。因为流式渲染无法像 renderToString 那样等待整个应用渲染完成再返回结果,所以必须在 流结束时 才能安全地注入所有必需的脚本标签。

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

// if you're using express.js, you'd have access to the response object "res"

// 在React开始渲染之前,手动写入HTML文档的开头部分(包括<head>和<body>标签的开始)
// 因为React流只输出应用内容
res.write('<html><head><title>Test</title></head><body>')

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

// 使用流式 API 渲染包装后的 JSX,得到一个 Node.js 可读流
const stream = renderToNodeStream(jsx)

// 将 React 渲染的 HTML 分块传送到服务器响应对象 (res)
// { end: false }非常关键,它告诉pipe方法不要在流结束时自动关闭HTTP响应,以便能手动追加最后的标签
stream.pipe(res, { end: false })

// 监听流的 'end' 事件。这个事件表明所有应用组件内容都已渲染并发送完毕,流结束:
// 1. 安全地调用 chunkExtractor.getScriptTags(),获取当前页面所需的所有脚本标签(包括主 Bundle 和动态 Chunks)
// 2. 将这些脚本标签和剩余的 HTML 结束标签 (</body></html>) 追加到响应中
// 3. 使用 res.end() 关闭 HTTP 响应
stream.on('end', () =>
  res.end(`${chunkExtractor.getScriptTags()}</body></html>`),
)

注意:流式渲染与预取 <link> 标签不兼容

原因:预取/预加载 <link> 标签(通过 extractor.getLinkTags() 获取)通常必须放在 <head> 标签内

在流式渲染中,当流开始发送应用内容时,<head> 部分已经发送完毕。React (主要是React17,18之后的版本提供了新的API)在流渲染时才决定需要哪些动态资源,此时再获取的动态 chunk 的 preload/prefetch 标签已经无法插入 <head> 中,因此,使用 loadable 的动态 chunk preload/prefetch 与流式渲染模式不兼容。不过其它静态 preload/prefetch 仍然可用。

7. 预取 (Prefetching)

通过在服务器端生成的 HTML 响应中,将未来可能需要的异步代码块<link rel="preload"><link rel="prefetch"> 标签提前注入到 <head> 标签中,告知浏览器预先下载或建立连接,从而减少客户端导航或组件显示时的加载延迟。

import path from 'path'
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server'

const statsFile = path.resolve('../dist/loadable-stats.json')
// 创建 ChunkExtractor 实例,它会解析 loadable-stats.json,获取所有代码块的资源信息
const extractor = new ChunkExtractor({ statsFile })

// 包装<YourApp />,渲染过程中,ChunkExtractor会记录当前页面所需的动态导入的代码块
const jsx = extractor.collectChunks(<YourApp />)

// 使用 renderToString 将应用渲染成 HTML 字符串
// 渲染完成后,extractor 已完成对所需资源的记录
const html = renderToString(jsx)

// 调用getLinkTags()方法,ChunkExtractor 会根据统计文件中的信息,以及当前页面所需的代码块,
// 生成对应的 <link rel="preload">(预加载)或 <link rel="prefetch">(预取)标签的字符串
const linkTags = extractor.getLinkTags() // or chunkExtractor.getLinkElements();

// 将生成的 linkTags 字符串精确地注入到最终 HTML 的 <head> 标签内
const html = `<html>
  <head>${linkTags}</head>
  <body>
    <div id="root">${html}</div>
  </body>
</html>`

getLinkTags() 的作用

@loadable/component 允许在使用 loadable() 时,通过配置(例如 Webpack Magic Comments)来指定资源的加载策略:

  • preload (预加载,rel="preload"): 用于加载当前页面很快就会需要的资源。优先级高。
  • prefetch (预取,rel="prefetch"): 用于加载未来某个时间(例如用户点击链接后)可能会需要的资源。优先级低。

getLinkTags() 方法就是将这些策略转化为浏览器可识别的 <link> 标签。

注意:预取/预加载 <link> 标签在 HTML 标准中必须位于文档的 <head> 区域

原因:renderToString 是一个同步 API,它会等待整个应用渲染完成,因此服务器有充足的时间在发送 HTML 前,将 linkTags 准确地插入到 <head> 中。

8. CSS

如果 Webpack 配置中使用了像 mini-css-extract-plugin 这样的插件,将 CSS 从 JavaScript Bundle 中分离并提取成单独的 .css (chunk)文件,那么 @loadable/server 就能自动识别并处理这些文件。

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

const statsFile = path.resolve('../dist/loadable-stats.json')
const extractor = new ChunkExtractor({ statsFile })
const html = renderToString(extractor.collectChunks(<YourApp />))
// ChunkExtractor不仅能识别JavaScript文件,还能识别出与之关联的CSS资源
// 并返回 <link rel="stylesheet" ...> 标签
const styleTags = extractor.getStyleTags() // or extractor.getStyleElements();

可能存在首屏闪烁,为加载样式的问题:

  • 服务端提前注入 CSS:通过getStyleTags() 获取返回的<link><style> 标签,在SSR返回HTML时注入到到 <head> 中。

    const styleTags = extractor.getStyleTags(); // 返回 <link> 标签
    const html = `
    <html>
      <head>
        ${styleTags}
      </head>
      <body>
        <div id="root">${appHtml}</div>
      </body>
    </html>`;
    
  • style-loader 内联(开发环境可用):开发环境中使用 style-loader 内联 CSS,首屏立即有样式,方便调试。生产环境一般不推荐用,因为会增加 HTML 大小,缓存不如外部 CSS 好。

  • 异步加载剩余 CSS chunk:其他不影响首屏的 CSS chunk 仍然通过 <link><link rel="preload"> 异步加载

9. 禁用SSR

用于处理那些不适合或不需要在服务器端渲染的组件,从而防止 SSR 过程中因环境不匹配而抛出错误。

有些组件可能严重依赖浏览器环境(例如使用 windowdocument 对象,或依赖第三方客户端库)。如果在 Node.js 服务器环境渲染它们会导致错误。通过设置 ssr: false,可以明确告诉 @loadable/component 在服务器端跳过这个组件的渲染和资源收集。

import loadable from '@loadable/component'

// This dynamic import will not be processed server-side
const Other = loadable(() => import('./Other'), { ssr: false })
  1. 客户端行为不变:在浏览器端,这个组件依然会作为一个**动态导入(Chunk)**被正常加载和渲染。
  2. 服务器端行为(关键):
    • ChunkExtractor 遇到这个被设置了 { ssr: false }Other 组件时:
      • 它将不会尝试在服务器端渲染这个组件的内容
      • 不会将这个组件对应的 JS 和 CSS 代码块包含到 getScriptTags()getStyleTags() 的输出中。
      • 这个 chunk 文件仍然会被 Webpack 构建,只是不被 SSR 记录和注入。
    • 服务器返回的 HTML:Other 组件的位置,服务器通常会返回一个空的标记(例如一个空的 <div>),而不是实际的组件内容。

10. 与React.lazy的区别

  1. Suspense

    如果你正在使用 React.lazy 并且它满足你的需求,那么你不需要切换到 @loadable/componentReact.lazy 是 React 官方推荐的、由 React 团队维护的代码分割方案。

    如果你的项目需要服务器端渲染 (SSR),或者需要更灵活、更高级的代码分割功能,那么 @loadable/component 是你所需的解决方案。

  2. 服务器端渲染 (SSR)

    这是两者之间最主要的区别

    • **React.lazyReact.lazy + Suspense 不能在 SSR 中渲染出内容,会返回占位组件。因为 React.lazy 依赖于 <Suspense>,而 React 的 <Suspense> 功能在当前版本中尚未在服务器端完全可用。因此,如果你在 Node.js 环境中渲染包含 React.lazy 的应用,会导致错误。
    • @loadable/component 提供了完整的 SSR 解决方案。它使用 @loadable/server 库中的 ChunkExtractor 工具来追踪和收集服务器端渲染过程中所需的代码块,并注入必要的 <script> 标签,确保水合(Hydration)成功。
  3. 库级别分割 (Library Splitting)

    • @loadable/component 支持使用 Render Props 等高级模式,允许你将代码分割逻辑应用于整个库或应用级别,提供更大的灵活性。

    • React.lazy 功能相对简单,不支持这种复杂的库级别分割模式。

  4. 完全动态导入

    • 概念: 指的是在 import() 函数中传入一个动态变量,例如 import(./${file})。Webpack 会自动将所有可能匹配的文件都分割成独立的 Chunks。

    • @loadable/component 支持此功能。这允许你创建非常灵活的可重用组件,例如根据传入的 props.page 动态加载不同的页面组件。

    • React.lazy 不支持此功能。React.lazy 要求 import() 中的路径是静态可分析的字符串。

← 返回列表