using both loaders 这套机制是 React Router (以及类似现代框架) 的精髓所在,它巧妙地平衡了服务器渲染(SSR)的速度和客户端渲染(CSR)的灵活性。
我们可以把整个主题分成两个部分来理解:
- 默认模式 :
loader
负责首屏,clientLoader
负责后续导航。 - 增强模式 :使用
clientLoader.hydrate = true
强制clientLoader
在首屏加载时也运行。
第一部分:默认模式(解释第一个代码块)
jsx
// route("products/:pid", "./product.tsx");
import type { Route } from "./+types/product";
import { fakeDb } from "../db";
// 1. 服务端加载器
export async function loader({ params }: Route.LoaderArgs) {
// 直接从数据库或内部模块获取数据
return fakeDb.getProduct(params.pid);
}
// 2. 客户端加载器
export async function clientLoader({
serverLoader,
params,
}: Route.ClientLoaderArgs) {
// 通过 fetch 调用一个公开的 API
const res = await fetch(`/api/products/${params.pid}`);
// 调用 serverLoader() 来复用 loader 的数据
const serverData = await serverLoader();
// 合并两边的数据
return { ...serverData, ...(await res.json()) };
}
export default function Product({
loaderData,
}: Route.ComponentProps) {
/* ... */
}
这段代码展示了 loader
和 clientLoader
如何协同工作,它们各自在最适合自己的环境中发挥作用。
场景 A:用户首次访问页面 (Initial Load / SSR)
当用户在浏览器地址栏输入 http://your-site.com/products/123
并回车时:
-
请求到达服务器:服务器识别出这个 URL 匹配产品详情页的路由。
-
loader
函数运行:- 环境 :在服务器上运行。
- 目的:为页面的首次渲染(SSR)提供核心数据。
- 执行 :它直接调用
fakeDb.getProduct('123')
。因为在服务器内部,它可以安全、快速地直接访问数据库、文件系统或内部模块(这里是fakeDb
),无需通过网络请求。 - 返回 :一个包含产品数据的对象,例如
{ id: '123', name: '超酷键盘' }
。
-
clientLoader
函数不运行 :在这次初始加载中,clientLoader
被完全忽略。 -
服务器渲染 HTML :服务器使用
loader
返回的数据渲染<Product>
组件,生成一个完整的 HTML 页面,然后发送给浏览器。 -
用户体验 :用户几乎立刻就能看到一个包含产品名称和描述的完整页面,实现了极快的首屏加载速度(FCP/LCP) 。
场景 B:用户在站内进行导航 (Client-side Navigation)
用户已经在网站上,现在他从首页点击了一个链接,跳转到 /products/456
。
-
请求在客户端被拦截:React Router 阻止了浏览器重新加载整个页面,而是启动了客户端导航。
-
loader
函数不运行:它在服务器上的任务已经完成了。 -
clientLoader
函数运行:-
环境 :在用户的浏览器中运行。
-
目的:为客户端的页面更新获取数据。
-
执行:
- 它发起一个
fetch
请求到/api/products/456
。为什么不直接访问数据库?因为浏览器无法直接访问服务器的数据库,它必须通过一个公开的 API 端点来请求数据。这是一种更安全、更规范的做法。 - 神奇的
serverLoader()
:它调用了serverLoader()
。这是一个由 React Router 提供的特殊函数,它实际上会执行与该路由关联的loader
函数的逻辑 。这意味着它可以复用loader
的数据获取能力! - 数据合并 :它将
serverLoader()
返回的基础数据和从 API 获取的额外数据(或更新的数据)进行合并。例如,serverData
可能有{ name, description }
,而 API 返回的数据可能有{ liveStock: 50, reviewsCount: 188 }
。最终返回给组件的是一个合并后的大对象。
- 它发起一个
-
-
组件更新 :React 使用
clientLoader
返回的数据重新渲染<Product>
组件,更新页面内容。 -
用户体验:页面内容无刷新更新,应用感觉流畅、快速,就像一个桌面应用。
小结 :这种默认模式是最佳实践。loader
保证了最快的首屏速度,clientLoader
保证了后续导航的安全和高效,并通过 serverLoader()
实现了逻辑复用。
第二部分:增强模式(解释第二个代码块)
jsx
export async function loader() { /* ... */ }
export async function clientLoader() { /* ... */ }
// 关键指令!
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <div>Loading...</div>;
}
export default function Product() { /* ... */ }
这段代码展示了如何通过 hydrate = true
来改变默认行为,强制 clientLoader
在首次加载时也参与进来。
场景 C:用户首次访问页面 (但这次使用了 hydrate = true
)
用户再次在浏览器中输入 http://your-site.com/products/123
并回车。
-
服务器 :和之前一样,服务器运行
loader
函数获取基础数据。 -
浏览器:浏览器接收到服务器发送的初始 HTML。
-
水合(Hydration)开始:浏览器下载完 JavaScript,React 开始接管页面。
-
React Router 检查 :它发现这个路由的
clientLoader
上有一个hydrate = true
的标记。 -
行为改变:
- React Router 暂停用服务器数据渲染最终组件。
- 它会立即显示
<HydrateFallback>
组件。用户会看到 "Loading..."。 - 同时,它在浏览器中执行
clientLoader
函数。
-
clientLoader
执行 :clientLoader
运行,它可能会:- 从
localStorage
读取用户偏好(比如暗黑模式)。 - 调用
serverLoader()
获取服务器的基础数据。 - 将两者合并,形成一个最终的、完全个性化的数据对象。
- 从
-
最终渲染 :
clientLoader
返回数据后,React Router 使用这个新数据 来完成<Product>
组件的渲染,替换掉 "Loading..."。
为什么需要这种增强模式?
这种模式的核心目的是为了在页面首次对用户可用之前,融合服务端数据和纯客户端数据。
- 没有
hydrate=true
:页面先显示服务器给的内容,然后JS加载完,可能会再去读localStorage
,然后页面内容再变一次(比如从亮色模式"闪烁"到暗黑模式)。这种体验不佳。 - 有了
hydrate=true
:用户会先看到一个加载状态,然后直接看到根据其偏好设置好的最终界面,没有中间的"闪烁"过程。
总结
特性/场景 | 默认模式 (hydrate = false ) |
增强模式 (hydrate = true ) |
---|---|---|
首屏加载时 | 只运行 loader (在服务器) |
先运行 loader (服务器), 再运行 clientLoader (客户端) |
客户端导航时 | 只运行 clientLoader (在客户端) |
只运行 clientLoader (在客户端) |
首屏用户体验 | 极快看到内容,但可能是通用版本 | 可能会先看到加载状态,但最终内容是完全个性化的 |
典型用途 | 大部分静态内容和通用数据展示 | 需要结合用户本地设置(如主题)、或依赖浏览器API才能获取数据的页面 |
数据流 | SSR数据直接渲染 | SSR数据 + 客户端数据 -> 合并后渲染 |
通过这种设计,React Router 提供了极大的灵活性,让开发者可以根据页面的具体需求,选择最优的数据加载策略。