React 中的客户端和服务器端数据获取
这是 React 17 中客户端和服务器端数据获取方法的概述、它们的优缺点以及即将推出的数据获取 Suspense将如何改变它们。
那么我们怎样获取呢?
React 支持以下获取方法:
- Fetch-on-Render:渲染时触发抓取。
- 获取后渲染:我们尽早开始获取数据,并且仅在数据准备好时进行渲染。
- “边获取边渲染”:我们尽早开始获取数据,然后立即开始渲染,无需等待数据准备就绪。从某种意义上说,“先获取后渲染”是“边获取边渲染”的一个特例。
毋庸置疑,客户端和服务器环境之间的抓取方式可能有所不同,甚至应用程序的不同部分之间也可能有所不同。例如,考虑一下Apollo 的工作原理。
在服务器端,如果我们使用getDataFromTree
,则需要实现Fetch-on-Render,因为渲染应用时会触发数据获取。或者,我们可以改用Prefetching ,并根据渲染开始时间获取Fetch-Then-Render或Render-as-You-Fetch 。
在客户端,Fetch-on-Render是默认方法,因为钩子就是这样useQuery
工作的。我们也可以使用Prefetching,本质上就是Render-as-You-Fetch。
最后,在客户端,我们可以延迟初始渲染,直到预取完成以实现Fetch-Then-Render,但这可能不是一个好主意。
实际上,我们可以混合使用不同的数据获取方式。例如,在客户端,我们可以将所有页面查询移至页面组件,并仅在所有数据都到达后才渲染其内容。这样,页面内容将有效地使用“先获取后渲染”的方法,而页面组件本身则会使用“渲染时获取”或“获取时渲染”的方法。
在整篇文章中,我们将重点关注获取方法的“纯”形式。
给我看代码!
以下示例粗略地说明了服务器端和客户端的获取方法(截至 React 17)。
渲染时获取
/** Server-side part. Express middleware. */
async function ssrMiddleware(_, res) {
/** Request-specific store for our data. */
const store = createStore();
const app = createElement(App, { store });
/**
* Render the app (possibly multiple times) and wait for
* registered promises.
* Server-side fetching can be disabled.
*/
if (process.env.PREFETCH) {
await getDataFromTree(app);
}
/**
* Render the final variant of the app and send it alongside the
* store.
*/
res.send(
`<!doctype html>
<body>
<div id="root">${renderToString(app)}</div>
<script>window.STORE=${JSON.stringify(
store.extract()
)}</script>
<script src="bundle.js"></script>
</body`
);
}
/**
* Client-side part. Hydrate the received markup with the store from
* SSR.
*/
hydrate(
createElement(App, { store: createStore(window.STORE) }),
document.getElementById("root")
);
/** Isomorphic App component. */
const App = ({ store }) => {
const [user, refetch] = useQuery(store, "user", fetchUser);
return (
<div>
{user ? user.name : "Loading..."}
<button onClick={refetch}>Refetch</button>
</div>
);
};
/** A hook for all fetching logic. */
function useQuery(store, fieldName, fetchFn) {
/** Server-side-only helper from the getDataFromTree utility. */
const ssrManager = useSsrManager();
/**
* If no data on the server side, fetch it and register the
* promise.
* We do it at the render phase, because side effects are
* ignored on the server side.
*/
if (ssrManager && !store.has(fieldName)) {
ssrManager.add(
fetchFn().then((data) => store.set(fieldName, data))
);
}
/**
* If no data on the client side, fetch it.
* We do it in a passive effect, so render isn't blocked.
*/
useEffect(() => {
if (!store.has(fieldName)) {
fetchFn().then((data) => store.set(fieldName, data));
}
});
/** Subscribe to a store part. */
const data = useStoreValue(store, fieldName);
const refetch = () =>
fetchFn().then((data) => store.set(fieldName, data));
return [data, refetch];
}
先获取后渲染
/** Server-side part. Express middleware. */
async function ssrMiddleware(_, res) {
/** Request-specific store for our data. */
const store = createStore();
const app = createElement(App, { store });
/**
* Fill the store with data.
* Server-side fetching can be disabled.
*/
if (process.env.PREFETCH) {
await App.prefetch(store);
}
/**
* Render the first and final variant of the app and send it
* alongside the store.
*/
res.send(
`<!doctype html>
<body>
<div id="root">${renderToString(app)}</div>
<script>window.STORE=${JSON.stringify(
store.extract()
)}</script>
<script src="bundle.js"></script>
</body`
);
}
/**
* Client-side part. Hydrate the received markup with the store from
* SSR, enriched by cleint-side initial fetching.
*/
hydrate(
createElement(App, {
store: await App.prefetch(createStore(window.STORE)),
}),
document.getElementById("root")
);
/** Isomorphic App component. */
const App = ({ store }) => {
const [user, refetch] = useQuery(store, "user", fetchUser);
return (
<div>
{user ? user.name : "Loading..."}
<button onClick={refetch}>Refetch</button>
</div>
);
};
/** A function for initial fetching. */
App.prefetch = async (store) => {
if (!store.has("user")) {
/** We explicitly prefetch some data. */
store.set("user", await fetchUser());
}
return store;
};
/** A hook for fetching in response to a user action. */
function useQuery(store, fieldName, fetchFn) {
/** Subscribe to a store part. */
const data = useStoreValue(store, fieldName);
const refetch = () =>
fetchFn().then((data) => store.set(fieldName, data));
return [data, refetch];
}
即取即渲染
/** Server-side part. Express middleware. */
async function ssrMiddleware(_, res) {
/** Request-specific store for our data. */
const store = createStore();
const app = createElement(App, { store });
/**
* Fill the store with data.
* Server-side fetching can be disabled.
*/
if (process.env.PREFETCH) {
const prefetchPromise = App.prefetch(store);
/** We "render-as-we-fetch", but it's completely useless. */
renderToString(app);
await prefetchPromise;
}
/**
* Render the final variant of the app and send it alongside the
* store.
*/
res.send(
`<!doctype html>
<body>
<div id="root">${renderToString(app)}</div>
<script>window.STORE=${JSON.stringify(
store.extract()
)}</script>
<script src="bundle.js"></script>
</body`
);
}
/**
* Client-side part. Start client-side initial fetching and immediately
* hydrate the received markup with the store from SSR.
*/
const store = createStore(window.STORE);
App.prefetch(store);
hydrate(createElement(App, { store }), document.getElementById("root"));
/** Isomorphic App component. */
const App = ({ store }) => {
const [user, refetch] = useQuery(store, "user", fetchUser);
return (
<div>
{user ? user.name : "Loading..."}
<button onClick={refetch}>Refetch</button>
</div>
);
};
/** A function for initial fetching. */
App.prefetch = async (store) => {
if (!store.has("user")) {
/** We explicitly prefetch some data. */
store.set("user", await fetchUser());
}
return store;
};
/** A hook for fetching in response to a user action. */
function useQuery(store, fieldName, fetchFn) {
/** Subscribe to a store part. */
const data = useStoreValue(store, fieldName);
const refetch = () =>
fetchFn().then((data) => store.set(fieldName, data));
return [data, refetch];
}
渲染时获取、获取后渲染、边获取边渲染
获取开始时间
如您所见,Fetch-Then-Render和Render-as-You-Fetch使得更早开始获取成为可能,因为请求不会等待渲染启动。
无数据渲染
Fetch-Then-Render很简单:没有数据,组件就永远不会被渲染。
然而,使用Fetch-on-Render或Render-as-You-Fetch时,数据可以在渲染之后到达,因此组件必须能够显示一些“无数据”状态。
捕捉瀑布
获取瀑布是指本应并行化的请求被无意地变为连续化的情况。
由于请求是分散的,渲染时获取(Fetch-on-Render)机制很容易造成这样的瀑布流。某个父组件可以获取自身数据,然后将这些数据传递给新渲染的子组件,而子组件本身又可以触发一个完全不使用已传递数据的请求。这样就形成了一个清晰的瀑布流。
另一方面,Fetch-Then-Render强制将请求集中处理(很可能是按页面处理),从而消除了产生瀑布的风险。然而,由于我们已将所有请求集中到一个 Promise 中,因此必须等待所有请求完成后才能进行渲染,这并不理想。
“即取即渲染”也会强制请求集中,但是,由于渲染不会延迟,我们可以在数据到达时显示它们。
服务器端渲染次数
从 React 17 开始,我们不能在渲染期间等待数据。
对于Fetch-Then-Render来说,这不是问题。由于请求是集中式的,我们可以简单地等待所有请求,然后只渲染一次应用程序。
然而,Fetch-on-Render强制我们至少渲染应用两次。其理念是:渲染应用,等待所有发起的请求完成,然后重复该过程,直到没有其他请求需要等待。如果您觉得它效率低下且不适合生产环境,不用担心:Apollo早已沿用这种方法。
Render-as-You-Fetch与Fetch-Then-Render非常相似,但效率略低(它需要两次渲染,其中一次是无用的)。事实上,它根本不应该在服务器端使用。
封装获取逻辑
使用Fetch-on-Render,可以轻松地将客户端和服务器端代码封装在单个钩子中。
相比之下,“先获取后渲染”和“边获取边渲染”模式迫使我们拆分数据获取逻辑。一方面,存在初始数据获取。它发生在渲染之前(在 React 之外),并且可能在服务器端和客户端都发生。另一方面,存在仅响应用户操作(或其他事件)的客户端数据获取,它仍然发生在渲染之前,但很可能位于 React 内部。
访问 React 特定数据
对于Fetch-on-Render来说,所有操作都在 React 内部进行。这意味着获取数据的代码可以访问 props(我们最关心的是 URL 参数),并且保证始终获取正确页面的数据。
先获取后渲染和边获取边渲染稍微复杂一些。初始获取发生在 React 之外。因此,我们必须做一些额外的工作来确定当前页面以及 URL 参数是什么。
然而,事件驱动的获取通常驻留在 React 中,并且可以访问 props 和其他所有内容。
React 18 中会发生哪些变化?
React 18 将支持Suspense 进行数据获取。
使用推荐的 API,任何一种获取方法都会导致服务器端的单次渲染(从某种意义上说,我们不会丢弃以前渲染的部分)。
一般来说,使用 Suspense 时,只有当组件的数据准备好时我们才会渲染该组件,否则组件将暂停,当数据准备好时我们会再次尝试。
所有其他提到的优点和缺点将保持不变。
正如您所见,Render-as-You-Fetch在服务器端和客户端都能同样有效地工作,并且它将完全取代Fetch-Then-Render,因为后者已经没有任何优势了。
Fetch-on-Render仍将作为一种更方便(但效率较低)的替代方案。
概括
渲染时获取 | 先获取后渲染 | 即取即渲染 | |
---|---|---|---|
获取开始时间 | ❌ 数据获取延迟到渲染 | ✔️尽快开始抓取 | ✔️尽快开始抓取 |
无数据渲染(无 Suspense) | ❌ 总是 | ✔️ 从不 | ❌有时 |
无数据渲染(Suspense) | ✔️ 从不 | ⚠️ 它完全被Render-as-You-Fetch取代 | ✔️ 从不 |
捕捉瀑布 | ❌ 隐式瀑布图,但我们独立显示数据 | ❌ 只有明确的瀑布,但我们展示“全有或全无” | ✔️ 只有明确的瀑布图,并且我们独立显示数据 |
服务器端渲染次数(无 Suspense) | ❌ 至少两次渲染 | ✔️ 一次渲染 | ❌ 两个渲染,其中一个毫无用处 |
服务器端渲染次数(Suspense) | ✔️ 一次渲染 | ⚠️ 它完全被Render-as-You-Fetch取代 | ✔️ 一次渲染 |
封装获取逻辑 | ✔️ 是 | ❌ 没有 | ❌ 没有 |
访问 React 特定数据 | ✔️ 是 | ❌ 初始获取是在 React 之外完成的 | ❌ 初始获取是在 React 之外完成的 |
使用 Suspense 进行数据获取 | ✔️ 效率较低但更方便 | ⚠️ 它完全被Render-as-You-Fetch取代 | ✔️这是推荐的方法 |