首页 / 文章 / 资源发现而非传输:通过HTTP/3预加载React应用

资源发现而非传输:通过HTTP/3预加载React应用

了解为何HTTP/3会导致在React应用中资源延迟加载成为真正的瓶颈,以及preloadModule、preinit、Early Hints和分块技术如何解决这一问题。

4462 词

将服务器升级为HTTP/3虽能提升数据传输速度,但许多React应用的实际响应速度提升并不明显。通常的原因在于,流程中耗时的部分从来不是数据传输本身,而是浏览器得知某个资源存在的那一刻。本指南将这两个问题区分开来,指出QUIC真正能发挥作用的环节,并介绍那些有助于提前完成资源发现的工具:React 19的资源API、基于意图的模块预加载、103 Early Hints响应、流式服务端渲染以及适用于多路复用传输的分块策略。

表面看似正常但实际仍很慢的工作流程

想象一个团队通过4G连接来调试分析仪表板。所有关键数据块都已预加载,网络传输状态看起来很正常,但“交互时间”却依然远远低于目标值。每个数据块一旦开始下载就会很快完成,由此可见HTTP/3确实发挥了作用。

仔细观察会发现情况有所不同。虽然下载速度很快,但请求的发起时间却较晚。React需要先下载、启动、运行并渲染内容,直到达到懒加载的边界点,浏览器才会意识到需要Dashboard.js。在开始请求该数据块的第一个字节之前,已经过去了数百毫秒。这种延迟源于网络上游的某个环节——即大多数性能检查清单中从未单独提及的资源发现阶段,而非资源传输阶段。

资源传输与资源发现是两个不同的问题

将“加载资源”这一过程拆分为两个问题会有所帮助:

  • 传输速度:一旦发出请求,数据字节从服务器传送到浏览器的速度有多快?
  • 发现时间:浏览器究竟在何时意识到自己需要这些数据字节?

从样式表中引用的网页字体会让这种差异更加明显。其流程如下:

HTML → CSS → @font-face rule → font request

无论连接速度多快,只有在HTML文件到达、CSS被获取并解析,且找到@font-face规则并与渲染后的文本匹配之后,才能发起字体请求。这就存在一定的发现延迟。<link rel="preload">标签并不会让字体下载速度加快哪怕一毫秒,它只是能让浏览器更早地发出请求而已。本指南其余内容几乎都是对这一理念的不同阐述。

哪种工具能解决哪些问题

下面介绍的每种技术都针对加载流程中的不同阶段:

  • preconnect:在发起任何请求之前,浏览器应先与哪些源地址建立连接?
  • preload:浏览器会哪些特定文件发现得太晚?
  • preloadModulemodulepreload:哪些 ES 模块代码段应在执行前被获取并编译?
  • preinitpreinitModule:哪些资源不仅需要被获取,还必须尽早应用或执行?
  • prefetch:在下次导航时可能需要的内容,但优先级较低。
  • 103 Early Hints:在 HTML 准备完成之前,服务器已经知晓什么信息?
  • 流式 SSR:服务器如何逐步揭示内容及其背后的资源?
  • HTTP/3 和 QUIC:一旦请求生成,其数据字节是如何高效传输的?
  • 只有最后一项与传输相关,其余均涉及资源发现、时机控制或调度,而这些方面恰恰体现了其他优势通常所在的位置。

    HTTP/2 多路复用的不足之处

    在HTTP/1.1协议下,浏览器通过为每个主机建立多个TCP连接来实现并行处理,通常最多为六个,且每个连接一次仅能传输一个资源。HTTP/2则改用单个连接来交错传输多个数据流:

    HTTP/1.1                     HTTP/2
    ────────────────             ────────────────────
    TCP conn 1 → JS              One connection
    TCP conn 2 → CSS               ├── Stream A: JS
    TCP conn 3 → Font              ├── Stream B: CSS
    TCP conn 4 → Image             ├── Stream C: Font
                                   └── Stream D: Image
    

    这确实是一大进步,但HTTP/2依然基于TCP协议,而TCP只能保证单个字节流的严格有序传输。它无法区分哪些字节属于JavaScript数据流,哪些属于字体数据流。一旦有数据包丢失,TCP会暂停其后所有数据的传输,直到重传的数据到达,即便丢失的包只是C数据流的一部分,而其他三个数据流并未受到影响。

    这就是TCP的头部阻塞现象。在网络状况良好的情况下这种问题很少显现;但在信号不稳定的移动网络上,它正是导致HTTP/2的多路复用功能无法完全实现其承诺的主要原因。

    HTTP/3 下的 QUIC 变化

    HTTP/3 用运行在 UDP 之上且能自行处理加密的 QUIC 替代了 TCP:

    HTTP/2          HTTP/3
    ────────        ────────
    HTTP/2          HTTP/3
      ↓               ↓
     TCP            QUIC
      ↓               ↓
     TLS            UDP
      ↓               ↓
     IP             IP
    

    独立流消除了传输层中的 HOL 阻塞

    由于 QUIC 在传输协议内部管理流,因此每个流都可以独立恢复。丢失的数据包仅会阻塞其所属的流:

    Stream A ──────────────────── ✓
    Stream B ──────────────────── ✓
    Stream C ──────── X ─ retry
    Stream D ──────────────────── ✓
    

    A、B和D流持续传输,而C在等待重传。测量结果显示,在TCP性能最差的情况下效果最为显著。2025年7月发表的一项在六个国家开展的Catchpoint研究指出,在丢包严重的链路中,数据首次到达的时间中位数下降了41.8%。Wix的内部测试显示,连接建立速度提升了33%,p75 LCP指标也改善了20%。在稳定的宽带链路中,相比HTTP/2的优势仅约为5%。这种不对称性本身具有参考价值:性能提升集中在HOL阻塞问题严重的区域。请将这些数据视为相关研究的示例,而非适用于您流量的保证值,应自行对用户数据进行测试。

    首次传输数据前的往返次数更少

    通过基于TCP的HTTP/2,新建连接需要先进行TCP握手,再单独进行TLS协商,因此在任何应用数据开始传输之前要经历两次往返。而QUIC则将TLS 1.3的交换过程整合到连接建立阶段,仅需一次往返即可完成。对于重复访问的用户,0-RTT恢复机制允许加密后的请求数据与初始数据包一同传输。在150毫秒的跨洲链路中,这能为每次新建连接节省150到300毫秒的时间。需要注意的是,0-RTT数据可能会被重放,因此服务器通常只接受用于如获取静态资源这类可重试的请求。

    能够跨越网络切换保持连接的机制

    TCP通过源IP地址、目标IP地址和端口的组合来标识连接。当手机从Wi-Fi切换到蜂窝网络时,其地址会发生变化,从而导致TCP连接中断。而QUIC则使用不可见的连接ID,因此会话可以迁移到新的路径上。由于通勤列车驶离车站,并不会导致正在预取的路由数据块需要重新开始。

    HTTP/3是否让预加载变得多余?

    并非如此,理解其中原因正是整个话题的核心。HTTP/3优化的是资源的传输方式;而预加载优化的是资源请求的时机。二者作用于链条中的不同环节:

    Browser
      │
      │  ← "I don't know I need this yet"
      ↓
    Resource discovery    ← preload operates here
      │
      ↓
    Request
      │
      ↓
    QUIC transport        ← HTTP/3 operates here
      │
      ↓
    Server
    

    传输协议无法获取那些尚未有人请求的内容。事实上,更快的传输速度反而会让延迟问题更加明显。假设某个数据块的传输时间从300毫秒降至80毫秒,原本部分隐藏在总时间中的400毫秒延迟现在则占据了绝大部分。瓶颈只是发生了转移,并未消失。

    为何React要向浏览器隐藏依赖项

    <img>标签的源文件以及<link>标签的样式表这类普通HTML资源能够很早就被找到,因为浏览器的解析器(及其预加载扫描功能)在读取文档时就能发现它们。而由客户端渲染的React则需要在更长的处理流程之后,某些依赖项才会显现出来:

    HTML → main.js → React executes → render → lazy() → discover Dashboard.js → download → render
    

    使用 React.lazy() 时,导入操作会在 JavaScript 执行之后进行。在主代码包被下载、解析、编译并运行,且 React 渲染到足以处理懒加载组件之前,浏览器无法得知 Dashboard.js 的存在。如果是从速度较慢的手机首次访问,可能需要数秒时间才会开始请求该代码块。

    const Dashboard = lazy(() => import("./Dashboard"));
    // The browser has no idea Dashboard.js exists
    // until this renders. And it only renders after
    // React has fully bootstrapped.
    

    Suspense 能改善等待时间,但无法加快组件加载速度

    一个常见的误解是认为将组件包裹在 Suspense 中就能解决问题。实际上它并不会改变代码块请求的时间:

    <Suspense fallback={<Loading />}>
      <Dashboard />
    </Suspense>
    

    Suspense的作用在于协调:当懒加载组件尚未加载完成时,React会显示备用内容而非阻塞整个页面结构。这有助于提升用户体验质量,但并非用于预测资源加载情况的机制。请求仍会在同一较晚的时间点发起。之后HTTP/3可以高效地传输数据块,但它无法改变数据到达所需的时间。

    React 19的资源API及其实际功能

    React 19在react-dom中提供了一组函数,允许组件在渲染过程中意识到资源需求时,将相关提示传递给浏览器的调度器。这些函数并非简单的HTML标签封装:React会对它们进行去重处理,并在服务端渲染时将其插入文档头部,以便浏览器能更早地看到这些内容。

    preconnect:预热资源地址

    当跨源请求必定会很快发起时,可使用preconnect。它会在事先启动DNS解析、建立连接以及进行TLS握手。

    import { preconnect } from "react-dom";
    // Call this when you know a cross-origin
    // request is coming - not just "might be coming."
    preconnect("https://cdn.example.com");
    

    仅将此功能用于那些必定会访问的资源地址。每条已预热的连接都会在客户端和服务器端消耗资源,而未被使用的连接则会被直接丢弃。

    preload:提前获取特定文件

    preload会指示浏览器开始下载某个已知的资源,但不会立即执行或应用该资源。字体就是典型的例子,因为否则它们会被CSS解析所掩盖:

    import { preload } from "react-dom";
    // Font hidden behind CSS - the browser won't
    // find this until it processes @font-face.
    // Preload surfaces it earlier.
    preload("/fonts/inter.woff2", {
      as: "font",
      crossOrigin: "anonymous",
    });
    

    请注意crossOrigin: "anonymous"选项。字体始终以CORS模式请求,因此如果不设置该选项进行字体预加载,生成的请求将与实际请求不匹配,从而导致浏览器重复下载该文件。

    preloadModule:获取并编译 ES 模块

    preloadModule 对 ES 模块实现了相同的功能,而且更进一步:它会下载该模块、解析并编译它,然后将其保存在模块映射中,这样一旦有 import() 调用时就能立即使用该模块。

    import { preloadModule } from "react-dom";
    // Use this for lazy route chunks you know
    // are likely to be needed soon.
    preloadModule("/assets/Dashboard-abc123.js");
    

    这非常适合那些很可能很快就会用到的延迟加载路由片段。

    preinit 和 preinitModule:获取并投入使用

    preinitpreinitModule 是功能更强的版本。它们不仅会获取资源,还会使其立即生效:样式表会被插入并应用,脚本在到达后会立即执行。

    import { preinit } from "react-dom";
    // You don't just want this downloaded -
    // you want it applied before render.
    preinit("/styles/app.css", { as: "style" });
    

    对于 CSS 来说,这两个阶段之间的时间差最为重要。预加载的样式表虽然已被下载,但并未被应用。如果在该样式表在首次绘制之前就需要使用,即便提前进行了下载,渲染仍要等到有元素实际引入该样式表之后才会继续。preinit 能同时处理这两个步骤。而对于脚本,则需采取相反的谨慎态度:只有那些可以立即安全运行的 preinit 代码才适合使用。

    根据用户操作触发 preloadModule

    在启动时为每个路由都调用 preloadModule 会浪费带宽。最佳时机是在用户的操作意图显现之时,通常是指针移入某个导航链接,或键盘焦点落在该链接上,且就在点击之前的瞬间。

    下面的组件负责实现这些功能。它会渲染一个普通的锚点,因此即使没有 JavaScript,链接依然可以正常使用;同时在 onMouseEnteronFocus 事件中调用 preloadModule,以便键盘操作的用户也能受益;最终的导航功能则由 React Router 的 navigate 方法处理:

    import { preloadModule } from "react-dom";
    import { useNavigate } from "react-router-dom";
    
    function NavLink({ to, chunkPath, children }) {
      const navigate = useNavigate();
      return (
        <a
          href={to}
          onMouseEnter={() => preloadModule(chunkPath)}
          onFocus={() => preloadModule(chunkPath)}
          onClick={(e) => {
            e.preventDefault();
            navigate(to);
          }}
        >
          {children}
        </a>
      );
    }
    
    // Usage
    <NavLink to="/dashboard" chunkPath="/assets/Dashboard-abc123.js">
      Dashboard
    </NavLink>
    

    鼠标悬停与点击之间的时间间隔通常在 100 到 400 毫秒左右。通过 HTTP/3,大小适中的数据块往往能在这一时间窗口内传输完成:以 10 Mbps 的速度,150 KB 的数据大约需要 120 毫秒。等到用户真正点击时,模块早已被编译到模块映射中,懒加载的边界问题也会立即得到解决,因此不会出现 Suspense 的降级处理。

    onMouseEnter处理程序能够实现的是任何传输协议都无法做到的功能:它在发起导航请求之前,就将用户的操作意图转化为关于资源的实际信息。之后HTTP/3会高效地处理数据传输,各层各司其职。

    有两点实际注意事项。首先,chunkPath必须是打包工具生成的哈希化文件名,因此在实际项目中应从构建清单中获取,而非手动输入。其次,触摸设备没有悬停功能,如果注重移动端导航体验,可以考虑使用onTouchStart或基于视口范围的触发机制。

    打包工具级的预加载

    对于在空闲时间进行大量低优先级预加载的操作,webpack支持在动态导入语句中添加特殊注释来实现:

    const Dashboard = lazy(
      () => import(/* webpackPrefetch: true */ "./Dashboard")
    );
    

    请注意,webpackPrefetch会生成一个<link rel="prefetch">提示,浏览器会将其视为在空闲时间进行的、针对未来可能发生的导航的低优先级任务。这与用于处理当前所需资源的高优先级preloadmodulepreload信号不同。

    Vite采用更自动化的方法,会为您自动生成modulepreload链接。而使用webpack则需要特殊的注释或插件。该提示的原始HTML格式如下:

    <!-- Vite generates these for lazy chunks automatically -->
    <link rel="modulepreload" href="/assets/Dashboard-abc123.js">
    <link rel="modulepreload" href="/assets/vendor-react-def456.js">
    

    严格来说,Vite 生成的 HTML 包含针对入口代码块及其静态导入的 modulepreload 链接。对于动态导入的代码块,Vite 的运行时辅助工具会在 import() 被调用的瞬间为这些依赖插入预加载链接,从而使代码块及其导入内容并行加载而非依次加载。具体行为可参考你所使用 Vite 版本的构建文档。

    与普通的 rel="preload" 不同,modulepreload 能让浏览器在模块到达时立即解析并编译它,而无需等到执行时刻。通过 HTTP/3,多个此类提示会通过独立的 QUIC 流传输,因此供应商代码块中的数据包丢失不会影响仪表板代码块的加载。

    从服务器推送到 103 Early Hints

    HTTP/2试图通过服务器推送功能来解决服务器端的资源发现问题:服务器会主动发送浏览器尚未请求的资源。将相关信息提前传递的目标是正确的,但实际执行失败了。由于服务器无法可靠地判断浏览器是否已缓存该资源,因此常常会推送重复内容,浪费带宽,还会与浏览器认为更紧急的资源争夺资源占用空间。最终Chrome停止了对服务器推送功能的支持。

    取代它的模型更合理地分配了职责:服务器负责提供信息,而浏览器则决定何时获取哪些资源。

    103 Early Hints 将这一机制付诸实践。在服务器仍在生成完整响应时,它会发送一个包含 Link 头部的临时 103 状态码。浏览器可以立即开始获取这些资源,等到最终包含 HTML 的 200 OK 响应到达时,部分资源可能已经加载完成。

    Browser                    Server
      │                          │
      │──── GET / ─────────────→ │
      │                          │ (generating HTML...)
      │ ←─── 103 Early Hints ─── │
      │   Link: </assets/main.js>; rel=modulepreload
      │   Link: </assets/vendor.js>; rel=modulepreload
      │                          │
      │ (fetching chunks now...) │ (still generating...)
      │                          │
      │ ←─── 200 OK + HTML ───── │
      │   (chunks already downloading or done)
    

    截至本文撰写时,NGINX 在 2025 年 6 月的 1.29.0 版本中已内置对 Early Hints 的支持,而 Cloudflare 则在其控制面板中提供相关开关功能。在 Node.js 中,响应对象提供了 writeEarlyHints() 方法,你可以在自定义服务器或中间件中,在发送实际响应之前调用它:

    // In a custom server or middleware
    res.writeEarlyHints({
      link: [
        "</assets/main.js>; rel=modulepreload; as=script",
        "</assets/vendor.js>; rel=modulepreload; as=script",
        "</assets/Dashboard.js>; rel=modulepreload; as=script",
      ],
    });
    
    // Then proceed with normal response
    res.status(200).send(html);
    

    该代码片段在最终响应时使用了类似 Express 的 res.status().send() 方法。而在纯 Node.js 的 http 服务器中,则应使用 res.writeHead()res.end()。只有当服务器确实需要执行耗时操作,如数据库查询或页面渲染时,Early Hints 才能发挥作用,否则浏览器会处于空闲状态。

    corewebvitals.io 基于 Chrome DevTools 的计时数据发现,将关键 CSS 文件通过 Early Hints 加载,可使 LCP 指标的加载时间比在 HTML 中常规预加载的方式提前约 35%。对于 React 应用而言,其优势在于能在服务器仍在运行时就开始下载主代码块,而非等到 HTML 已加载完成之后。

    结合流式 SSR、Early Hints 与 HTTP/3

    React的流式服务器渲染提供了另一种解决方案。服务器不会等到整个页面准备就绪才发送响应,而是分阶段发送HTML:

    HTML shell → Suspense fallback → more HTML → resolved content → hydration
    

    在渲染过程中,服务器能够知道哪些Suspense区域即将被渲染以及它们依赖哪些数据块。这些信息可以在HTML流开始之前通过Early Hints传递给浏览器。以下是简化的时间线:

    0ms  ── Browser sends request
         ── Server starts rendering
    1ms  ── Server knows Dashboard boundary will render
         ── Server sends 103 Early Hints: Dashboard.js
         ── Browser starts fetching Dashboard.js
    50ms ── Server streams HTML shell
         ── Browser starts parsing
    120ms── Server streams Dashboard content
         ── Dashboard.js already downloaded
         ── Hydration starts immediately
    

    对比一下没有Early Hints的相同应用,此时发现过程需要等待客户端操作:

    0ms  ── Browser sends request
    50ms ── Server streams HTML shell
         ── Browser starts parsing
         ── Browser discovers <script> tags
         ── main.js starts downloading
    180ms── React executes
         ── Hits Dashboard lazy boundary
         ── Dashboard.js request starts (now)
    300ms── Dashboard.js downloads
         ── Hydration starts
    

    HTTP/3缩短了两个时间线中的每个数据段长度。Early Hints所改变的是Dashboard请求的启动时机,这能带来不同的、通常更大的节省效果。如果你的框架渲染功能已经由其他基础组件实现,那么React 19.2的SSR基础组件,如Activity和部分预渲染功能相关文章会进一步阐述流式传输边界的生成方式。

    重新思考多路复用传输中的数据块粒度

    长期以来,人们建议尽量减少HTTP请求次数,这是由于HTTP/1.1存在六连接限制,而HTTP/2的复用功能也仅能在单个TCP流上实现部分并行处理。有了独立的QUIC流后,请求数量不再像以前那样成为决定性因素,因此你可以更积极地拆分代码,而无需承担同样的每次请求惩罚。

    一个合理的默认拆分方式如下:

    • React和ReactDOM:单独的、稳定的供应商代码块。这类代码很少变动,因此可以拥有较长的缓存有效期。
    • 路由库:出于同样的稳定性考虑,也需单独成块。
    • 诸如图表库或编辑器之类的大型第三方库:每个库对应一个代码块,这样更新其中一个不会影响其他库。
  • 路由组件:通过 React.lazy() 为每个路由生成一个代码块,这样导航时只会加载所需内容。
  • 共享工具函数:由打包工具生成一个单独的共享代码块,加载一次后可在多个路由中重复使用。
  • 管理功能或极少使用的功能:为这些功能生成独立的懒加载代码块,从未打开过对应页面的用户不会下载这些代码块。
  • 这样做的优势在于缓存管理的精准性。修改 Dashboard.tsx 时只会使该路由的代码块失效,而第三方库的代码块仍可保留在缓存中,而这只有通过细粒度的拆分才能实现。在 HTTP/3 协议下,生成的 8 到 15 个代码块会通过独立的流进行加载,彼此之间不会因 HOL 现象而阻塞。

    Vite 在无需配置的情况下即可处理大部分此类情况。在 webpack 中,splitChunks.cacheGroups 用于实现相同的策略。下面的配置会生成一个 React 供应商代码块、一个路由器代码块以及一个仅包含异步功能的图表代码块;priority 值用于决定当某个模块同时符合多个条件时优先属于哪一组,而 chunks: "async" 则可使图表相关代码不会被包含在初始加载中:

    // webpack.config.js
    module.exports = {
      optimization: {
        splitChunks: {
          cacheGroups: {
            reactVendor: {
              test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
              name: "vendor-react",
              chunks: "all",
              priority: 40,
            },
            routerVendor: {
              test: /[\\/]node_modules[\\/](react-router|react-router-dom)[\\/]/,
              name: "vendor-router",
              chunks: "all",
              priority: 30,
            },
            chartsVendor: {
              test: /[\\/]node_modules[\\/](recharts|d3)[\\/]/,
              name: "vendor-charts",
              chunks: "async",
              priority: 20,
            },
          },
        },
      },
    };
    

    有一个需要注意的问题:过小的代码块会增加浏览器中每个文件的解析和编译开销。而且并非所有访问者都能使用HTTP/3——阻止443端口UDP流的企业防火墙很常见,这类用户只能使用HTTP/2,这样一来部分请求处理成本又会重新出现。在将代码拆分到比路由层级更细的级别之前,请先进行测试。通常按路由或按大型库来拆分是合适的粒度,而按组件拆分则往往不合适。如果你的技术栈是Next.js,Turbopack的代码分块控制功能相关内容介绍了Next.js中的类似设置。

    为何预加载所有内容仍会适得其反

    多路复用技术允许多个数据流共享同一连接,但它并不会为每个数据流提供无限的带宽,也不会让它们具有同等的重要性。20个modulepreload提示仍然共享同一条传输通道;竞争只是分散在更多的数据流之中。

    因此,有意义的问题不是“什么可以预加载?”,而是“浏览器会在何时才意识到某个重要资源缺失?”作为起点,可考虑以下几点:

    • 关键字体:使用preload
    • 关键样式表:使用preload;如果在页面渲染前就必须应用,则可使用preinit
    • 关键JavaScript模块:使用preloadModule
    • 重要的跨域CDN或API源:使用preconnect
  • 页面加载时显示的英雄图片:使用preload并设置fetchpriority="high"
  • 页面中较靠后的图片:无需预加载。
  • 延迟加载的路由模块:通过preloadModule实现,由用户操作触发。
  • 分析脚本:无需预加载。
  • 聊天插件:无需预加载,延迟加载即可。
  • 仅管理员可访问的代码:无需预加载。
  • 很可能下一步要访问的内容:以较低优先级使用prefetch
  • 服务器已知晓的关键资源:使用103 Early Hints机制。
  • 应将每条建议视为“可考虑选项”,而非强制规则。并不存在通用的预加载列表,只有那些既重要又在特定应用中较晚才被识别的资源需要预加载。

    fetchpriority也同样受到限制。浏览器已经通过复杂的启发式方法对资源进行了优先级排序,将所有资源的优先级都设为high实际上等同于不设置任何优先级。只有当检测结果显示浏览器做出了错误判断时,才需要覆盖默认值。

    审查加载策略的五个问题

    在审计React应用程序如何加载资源时,按顺序思考以下问题:

    1. 该资源在何时被发现?如果真实答案是“JavaScript运行之后”或“React渲染之后”,那么很可能有机会让它更早被加载。
    2. 用户何时需要它?某样东西可能很重要,但并不一定需要立即使用。这一区别决定了是使用preload(当前时刻)还是prefetch(空闲时间)。
  • 能否更早地传递这些信息?按所需工作量从低到高排序大致为:preconnect,接着是preloadpreloadModule,然后是preinit,再是103种早期提示,最后是基于服务器渲染内容生成的提示的流式SSR。
  • 它会与什么竞争资源?每种提示都有机会成本。预加载仪表板相关内容意味着其他内容会得到较少的处理资源,因此需要明确那是哪些内容。
  • 限制因素是网络还是CPU?预加载2 MB大小的代码包虽然能提前开始下载,但对解析、编译和执行时间并无影响。如果主线程是限制因素,那么更早发现问题只会改变用户等待的位置,而不会改变等待时间的长短。
  • 各层之间的关联

    从整体上看,加载现代 React 应用程序涉及六层结构,每一层都有其独立的职责范围:

    Application intent (React knows which routes and components are needed)
           ↓
    Resource APIs (preconnect / preload / preloadModule / preinit)
           ↓
    Server-side surfacing (103 Early Hints / streaming SSR)
           ↓
    Browser resource scheduler (priority, cache, bandwidth estimation)
           ↓
    HTTP/3 / QUIC transport (independent streams, 0-RTT, connection migration)
           ↓
    Network
    

    旧的方法会尽可能将资源推送到客户端:使用服务器推送、预加载所有内容,以及合并代码包以减少请求次数。而当前的方法则是在每一层向浏览器提供更丰富的信息,由浏览器自行决定调度策略。HTTP/3 提供了一种能够高效处理这些决策的传输机制。提前提示功能能在 HTML 生成之前就将服务器的信息传递给浏览器。React 的资源 API 允许应用程序在组件渲染完成时明确表达其需求。

    没有任何一种层可以替代另一种。HTTP/3无法获取那些尚未被发现的资源。当服务器不知道该使用哪条路由时,早期提示就毫无用处。而且,在中端手机上需要800毫秒才能编译的2 MB大型代码包,preloadModule也无法挽救这种情况。

    关键要点

    HTTP/3对预加载影响最大的并非传输速度的提升,而是更快的传输使得资源发现的成本相对上升。当400毫秒的传输时间缩短到80毫秒时,原本隐藏在其中的发现延迟便成了主要成本,此时针对旧有瓶颈制定的策略就不再适用了。

    • 预加载资源是因为浏览器否则会来不及获取,而不仅仅是因为该资源很重要。那些能及时被发现的重要资源无需提示,而那些晚才被发现的不重要资源也不值得给予提示。
    • Suspense只能改善用户等待时的体验,无法让数据块更快加载。
    • 选择最有效的最低限度提示:对于资源地址使用preconnect,对于文件使用preload,对于模块使用preloadModule,当需要应用或运行某些内容时则使用preinit
    • 根据悬停、聚焦等用户操作信号触发按路由分块的预加载,同时让早期提示和流式服务端渲染展示服务器已知晓的信息。
    • 按路由及大型库进行拆分,牢记HTTP/2的回退方案,在进一步细化之前先进行性能测试。

    HTTP/3 并没有让预加载变得过时,它只是更清晰地阐明了预加载的用途。

    相关阅读