React 部分注水(Partial Hydration):分析岛屿架构(Islands Architecture)对 React 的启示

拒绝“大水漫灌”:React 部分注水与岛屿架构的深度巡礼

各位同仁,各位老铁,各位在键盘前敲得手指都要起茧子的前端工程师们,大家好。

今天我们不聊 API,不聊 Hooks 的玄学,也不聊 TypeScript 的类型地狱。今天,我们要聊一个关于“效率”与“克制”的话题。我们要聊聊为什么你那个加载了 3 秒才显示出来的博客文章,明明只有一个“点赞”按钮需要交互,却非要把整个页面都灌满 JavaScript。

我们要聊的,是 React 19 带来的部分注水,以及它如何让我们重新拥抱那个古老但优雅的岛屿架构

第一部分:那个让我们抓狂的“全量注水”

在 React 的世界里,曾经有一个信仰,叫作“一致性”。

如果你使用过 React,尤其是早期的版本,或者那些还没跟上时代的旧框架,你一定经历过这种痛苦:浏览器收到 HTML,上面写着“Hello World”,然后你眼睁睁看着它变成一个 Loading 转圈圈,最后,那个转圈圈消失了,文字出现了。

这就是全量注水

想象一下,你开了一家餐厅。老板说:“我们要让所有服务员都听懂客人的话。”于是,你把一个只会点菜的哑巴服务员(HTML)扔进了一个全是学霸的培训班(React Runtime),强行让他学会怎么和客人对话。

结果呢?整个餐厅(页面)都停摆了。为什么?因为那个哑巴服务员正在拼命背诵台词,根本没空去给客人倒水。同时,整个餐厅的灯光都因为算力被占用而闪烁了一下。

在 React 的全量注水模式下,无论你的页面是 10 行代码还是 10000 行,React 都必须把所有的 HTML 都“洗”一遍,把所有的 DOM 节点都注册一遍事件监听器。如果页面里只有 1% 的内容是交互式的(比如一个搜索框),React 依然要费劲巴拉地去解析那 99% 的静态文本,试图给它们也绑上事件。

这就像你为了切一块豆腐,把整头猪都宰了。这不仅是浪费,简直是暴殄天物。

更糟糕的是,全量注水会阻塞主线程。用户点击了页面,页面卡顿了。为什么?因为 React 正在后台默默地把整个 HTML 转换为它那套复杂的内部状态树。这就像你让一个建筑师去搬砖,结果建筑师把图纸画完了,砖还没搬完。

这导致了什么?首屏加载慢,交互延迟高,用户体验极差。

第二部分:岛屿架构——回归直觉的解决方案

那么,我们该怎么办?难道我们要回到 2015 年以前,用 jQuery 手动写 $.get,然后手动拼接 HTML 字符串吗?

不,不需要那么极端。我们需要一种更聪明的策略,一种更符合直觉的策略。

这就是岛屿架构

这个概念最早由 Rob Morris 在 2017 年提出,后来被 React 团队采纳并发扬光大。它的核心思想非常简单,简单到像小学数学题:

将 UI 拆分为“静态海洋”和“交互岛屿”。

  • 海洋(静态部分): 页面的大部分内容,比如博客文章、新闻列表、产品详情。这些内容不需要用户点击就能展示,也不需要实时更新。它们是“静默的”。让它们保持 HTML 原生状态,或者由服务端渲染(SSR)出来即可。
  • 岛屿(交互部分): 那些需要状态、需要事件监听、需要复杂逻辑的组件。比如一个购物车、一个即时搜索框、一个点赞按钮。这些是“活跃的”,它们需要 JavaScript,需要 React 的加持。

岛屿架构的精髓在于:只给需要交互的部分加载 JavaScript。

这就好比一个旅游团。导游(静态 HTML)带着大家走,告诉大家哪里有风景,哪里有厕所。只有当游客想上厕所或者想买纪念品时,导游才会掏出一张地图(React 组件),告诉你怎么走。

第三部分:React 19 与部分注水——给岛屿装上引擎

以前,实现岛屿架构并不容易。你需要手动使用 useEffect 来控制组件的挂载,或者使用第三方库来处理 Suspense。这就像你要自己造一辆车来跑这段路,而不是直接买辆法拉利。

但是,React 19 的到来,彻底改变了游戏规则。它引入了部分注水

什么是部分注水?

简单来说,就是 React 不再试图“洗”遍整个页面。相反,它会识别出哪些区域是静态的,哪些区域是需要注水的。

React 19 利用 Suspense 作为边界。它就像一道大坝。大坝那边是静态内容,不需要水(JS);大坝这边是动态内容,需要水。

当 React 渲染 HTML 时,它看到 <Suspense fallback="Loading...">,它就知道:“哦,这个区域是个岛屿,我需要在这里停下来,加载完 React 的逻辑,然后再继续注水。”

这就实现了真正的部分注水。静态内容不需要等待 JavaScript 的加载和解析,它们可以直接展示。只有当用户真正需要与某个“岛屿”交互时,那个岛屿的 JavaScript 才会生效。

第四部分:代码重构——从“全量”到“部分”

为了让大家更好地理解,我们来进行一场代码重构的实战演练。

假设我们有一个电商详情页。这个页面包含:

  1. 商品图片(静态)
  2. 商品标题和描述(静态)
  3. SKU 选择器(交互)
  4. 加入购物车按钮(交互)
  5. 评论列表(静态)

旧代码(全量注水模式)

在旧代码中,我们可能把所有东西都放在一个 Client Component 里。

// components/ProductPage.jsx
'use client';

import { useState } from 'react';

export default function ProductPage() {
  const [quantity, setQuantity] = useState(1);
  const [cartMessage, setCartMessage] = useState('');

  return (
    <div className="product-page">
      <h1>限量版机械键盘</h1>
      <p>这是关于这款键盘的详细介绍,包含大量的静态文本。</p>
      <img src="/keyboard.jpg" alt="Keyboard" />

      <div className="interactive-section">
        <h2>选择规格</h2>
        {/* SKU 选择器逻辑 */}
        <select>
          <option>红轴</option>
          <option>青轴</option>
        </select>

        <button 
          onClick={() => setCartMessage('已添加到购物车!')}
          disabled={quantity === 0}
        >
          加入购物车
        </button>
        {cartMessage && <p>{cartMessage}</p>}
      </div>

      <div className="comments">
        <h3>用户评论 (1234 条)</h3>
        {/* 这里可能渲染了 100 条评论,但用户可能根本不看 */}
        <CommentList />
      </div>
    </div>
  );
}

问题分析:
哪怕用户只想看键盘的介绍,React 依然需要加载并运行所有组件的 JavaScript。如果评论列表里包含 100 个 CommentItem 组件,每个组件都有自己内部的状态(比如点赞),那么 React 就要解析 100 个组件的代码。这简直是灾难。

新代码(岛屿架构 + 部分注水)

现在,我们使用 React 19 的特性,将页面拆分为静态部分和交互部分。

// app/product/[id]/page.tsx (Next.js App Router 示例)
// 这是一个 Server Component,默认是静态的!
// React 19 默认不在这里注入 JavaScript!

import { Suspense } from 'react';
import { getProductDetails } from '@/lib/api';
import { ProductInfo } from '@/components/ProductInfo';
import { ProductActions } from '@/components/ProductActions';
import { CommentList } from '@/components/CommentList';

export default async function ProductPage({ params }: { params: { id: string } }) {
  // 1. 服务器端获取数据
  // 这里的数据获取是同步的,不会阻塞主线程,因为我们是在服务器上
  const product = await getProductDetails(params.id);

  return (
    <div className="product-layout">
      {/* 2. 静态内容区域:图片和标题 */}
      <div className="static-content">
        <h1>{product.name}</h1>
        <div dangerouslySetInnerHTML={{ __html: product.description }} />
        <img src={product.image} alt={product.name} />
      </div>

      {/* 3. 交互岛屿区域:SKU 和 购物车 */}
      <div className="interactive-island">
        <Suspense fallback={<div className="skeleton">加载规格中...</div>}>
          <ProductActions product={product} />
        </Suspense>
      </div>

      {/* 4. 评论区域:静态内容,但包含一个交互岛屿(点赞) */}
      <div className="comments-section">
        <h2>评论</h2>
        <Suspense fallback={<div>加载评论...</div>}>
          <CommentList productId={product.id} />
        </Suspense>
      </div>
    </div>
  );
}

代码解析:

  1. Server Component(服务端组件): 请注意,page.tsx 没有加 'use client'。这意味着它运行在 Node.js 服务器上。React 19 会将这个组件渲染为纯 HTML。没有 JavaScript! 用户打开页面,立刻就能看到图片和标题。这就像你直接拿到了打印好的海报,而不是拿到一堆胶卷。
  2. Suspense 边界: 我们用 <Suspense> 包裹了 ProductActionsCommentList。这是关键。
    • ProductActions 是一个 Client Component,里面包含 useStateonClick。它是“岛屿”。
    • CommentList 虽然是静态列表,但如果里面的评论需要“点赞”,那它也是一个“岛屿”。
  3. 部分注水过程:
    • 浏览器收到 HTML。
    • React 看到 ProductPage 是 Server Component,直接把 HTML 插入 DOM。速度极快。
    • React 遇到 <Suspense>,它检查 ProductActions 是否需要客户端逻辑。如果需要,它挂起渲染,开始下载 ProductActions 的 JS bundle。
    • 一旦 JS 加载完毕,React 只会“注水” ProductActions 这个岛屿。它不会去管图片和标题。
    • 如果用户滚动到评论区,CommentList 才会被注水。

第五部分:深入技术细节——Suspense 与 HydrationBoundary

你可能会有疑问:React 19 是怎么知道哪些部分需要注水,哪些不需要?

这里涉及到 React 19 的两个核心机制:SuspenseHydrationBoundary

1. Suspense:懒加载的魔法

Suspense 不仅仅用于数据获取。在岛屿架构中,它用于控制交互的范围

<Suspense fallback={<Skeleton />}>
  <InteractiveWidget />
</Suspense>

当 React 渲染到这个边界时,如果 InteractiveWidget 是一个 Client Component,React 会暂停。它不会立即尝试去“注水”整个父组件。它会等待 InteractiveWidget 的 JavaScript 加载完毕,或者它的数据加载完毕(如果使用了 async/await),然后再决定是否注入 JavaScript。

这就像在河上修了一座桥。只有当船(JS)来了,桥才架设。如果船不来,河面(静态内容)保持畅通。

2. HydrationBoundary:精准打击

在 React 19 之前,为了实现部分注水,开发者经常使用 useEffect 来延迟挂载组件。

function InteractiveComponent() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return <div>Interactive Content</div>;
}

这种做法虽然能工作,但非常丑陋。它会破坏 HTML 的语义结构,导致 SEO 问题,并且在某些情况下会导致布局偏移。

React 19 引入了 HydrationBoundary。这是一个底层的 API,但通常我们不需要直接调用它。它的工作原理是:

  1. React 识别出某个区域(比如 <InteractiveWidget />)需要客户端事件处理。
  2. 它不会为该区域生成 addEventListener,而是生成一个标记。
  3. 当页面加载完毕,React 开始扫描 DOM。
  4. 它只扫描 HydrationBoundary 内部的区域。对于边界外部的区域,React 直接忽略。它不做任何事件绑定,不做任何状态同步。
  5. 只有边界内部,React 才会像传统 React 应用一样,进行完整的注水过程。

这意味着,你的静态 HTML 可以保持原样,完全不受 React 的干扰。

第六部分:性能剖析——数据说话

让我们来算一笔账。

假设一个页面有 1MB 的 HTML 内容,其中只有 100KB 是交互式的。

传统全量注水:

  • 浏览器下载 1MB HTML。
  • 浏览器解析 1MB HTML(耗时 50ms)。
  • React 下载所有 JS bundle(假设 200KB)。
  • React 解析 200KB JS(耗时 20ms)。
  • React 遍历 1MB DOM,为每个节点尝试挂载事件(耗时 100ms+)。
  • 总耗时: 170ms+。而且这 170ms 是在主线程上阻塞的。

岛屿架构 + 部分注水:

  • 浏览器下载 1MB HTML。
  • 浏览器解析 1MB HTML(耗时 50ms)。用户立刻看到内容。
  • React 下载 100KB 交互组件 JS。
  • React 只解析 100KB JS(耗时 10ms)。
  • React 只为 100KB DOM 区域挂载事件(耗时 20ms)。
  • 总耗时: 80ms。而且大部分是在后台进行的,用户几乎感觉不到延迟。

收益: 交互性能提升了 50% 以上,首屏体验提升了 100%(因为用户不需要等待 JS 加载就能看到内容)。

第七部分:实战中的陷阱——不要过度设计

虽然岛屿架构听起来很完美,但作为资深工程师,我必须提醒你们:不要为了岛屿而岛屿。

如果整个页面都是静态的,那就不要用 React。用纯 HTML 或者静态站点生成器(SSG)。

岛屿架构的核心是交互的粒度

  • 错误的做法: 把每一个按钮、每一个输入框都做成一个独立的岛屿。这会导致 JS bundle 体积爆炸,网络请求过多,页面变得支离破碎。
  • 正确的做法: 按照业务逻辑划分。整个购物车是一个岛屿,整个评论区是一个岛屿,而不是每个评论都是岛屿。

另外,要注意Hydration Mismatch(注水不匹配)的问题。

虽然部分注水减少了不匹配的概率,但如果静态 HTML 和客户端渲染的 HTML 不一致(比如服务端渲染了 10 条评论,客户端只渲染了 5 条),React 会发出警告。

在岛屿架构中,由于我们使用了 Suspense,我们需要确保 Suspense 的 fallback 样式和真实内容的样式保持一致,否则用户会看到内容闪烁。

// 必须确保 Skeleton 和真实内容布局一致
const Skeleton = () => (
  <div style={{ display: 'flex', gap: '10px' }}>
    <div style={{ width: 100, height: 100, background: '#eee' }}></div>
    <div style={{ flex: 1 }}>
      <div style={{ width: '100%', height: '20px', background: '#eee', marginBottom: '10px' }}></div>
      <div style={{ width: '80%', height: '20px', background: '#eee' }}></div>
    </div>
  </div>
);

第八部分:Next.js 15 的加持——Server Actions

React 19 的部分注水在 Next.js 15 中得到了完美的落地。特别是 Server Actions 的引入,让岛屿架构的实现更加优雅。

以前,我们可能需要使用 useEffect 来调用 API。这会导致额外的网络请求,增加了延迟。

现在,我们可以直接在 Server Component 中调用 Server Action,而不需要将其包裹在 Client Component 中。

// app/product/[id]/page.tsx
import { addToCart } from '@/app/actions';

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <h1>My Product</h1>

      <Suspense fallback={<Loading />}>
        <AddToCartButton id={params.id} />
      </Suspense>
    </div>
  );
}

// components/AddToCartButton.tsx
'use client';

import { useTransition } from 'react';
import { addToCart } from '@/app/actions';

export function AddToCartButton({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button 
      onClick={() => startTransition(() => addToCart(id))}
      disabled={isPending}
    >
      {isPending ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

注意看 AddToCartButton。它是一个 Client Component,因为它使用了 useTransitiononClick。但是,它的父组件 ProductPage 是 Server Component。

当用户点击按钮时,React 只会注水 AddToCartButton 这个岛屿。服务器端处理逻辑,然后返回 HTML。React 只需要更新这一个按钮的状态。这比全量注水快了不知道多少倍。

第九部分:总结与展望

各位,我们今天探讨了 React 部分注水和岛屿架构。

从全量注水到部分注水,从“大水漫灌”到“岛屿战略”,这不仅仅是技术的升级,更是设计思维的转变。

我们不再执着于让 React 统治整个页面。我们开始学会利用 HTML 的原生优势,利用服务端渲染的优势,只把 React 用在刀刃上——也就是那些真正需要交互、需要复杂状态的地方。

这带来的好处是显而易见的:

  1. 更快的首屏加载速度。
  2. 更低的交互延迟。
  3. 更少的 JavaScript bundle 体积。
  4. 更好的用户体验。

当然,这并不意味着 React 不重要了。恰恰相反,React 变得更强大了,因为它终于学会了“克制”。它不再是一个试图控制一切的控制狂,而是一个聪明的合作伙伴,只在需要的时候介入。

所以,下一次当你写代码时,问问自己:“这个组件真的需要成为一座岛屿吗?还是它只是海面上的一朵浪花?”

如果它只是浪花,就让 HTML 去守护它。如果它是岛屿,那就让 React 来征服它。

好了,今天的讲座就到这里。希望大家都能写出更快、更轻、更优雅的 React 应用。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注