前沿
在活动页需要同时兼顾首屏渲染和接口不稳定,想用一个组件兜底:服务端优先渲染,失败时自动降级到客户端再拉一次。于是写了 DataSection(src/components/global/DataSection),记录这次尝试和它最终没完全达成预期的原因。
条件
- 全局公共使用,那么必须是公共组件,不用为每个涉及接口的组件处理(各种Wapper组件)
- 复用同一份业务代码片段,避免写两套(啥客户端一套、服务端一套的)
- 如果SSR 正常,dom元素仍然走SSR,如果失败,可接受全局dom降级客户端渲染
目的
- SSR 正常:直接用服务端数据渲染,拿到完整 HTML/SEO。
- SSR 失败:不要 500,也不要空白;在浏览器端再请求一次。
实现过程
- 服务端安全 fetch
api-client.ts 用 safeFetch 包装,服务端请求失败不抛错,返回 null,避免整个页面崩掉。
typescript
src/components/global/DataSection/api-client.ts
const isServer = typeof window === "undefined"
export async function safeFetch<T>(fetcher: () => Promise<T>): Promise<T | null> {
if (!isServer) {
throw new Error("safeFetch should only be called on server")
}
try {
return await fetcher()
} catch (error) {
console.error("[Server] API failed:", error)
return null
}
}
export async function safeFetchAll<T extends readonly unknown[]>(
fetchers: readonly (() => Promise<T[number]>)[]
): Promise<{ [K in keyof T]: T[K] | null }> {
const results = await Promise.allSettled(fetchers.map(fetcher => fetcher()))
return results.map(result => (result.status === "fulfilled" ? result.value : null)) as { [K in keyof T]: T[K] | null }
}
- 统一入口 DataSection
SSR 有数据(initialData.code === 0)就直接渲染 children;否则走客户端降级。
typescript
// src/components/global/DataSection/index.tsx
import React from 'react'
import ClientFallback from './ClientFallback'
import type { CommonApiRes } from '@/lib/service/type'
interface Props<T> {
queryKey: string | string[]
initialData: CommonApiRes<T> | null
fetcher: () => Promise<CommonApiRes<T>>
children: (data: T) => React.ReactNode
}
export default function DataSection<T>(props: Props<T>) {
const { queryKey, initialData, fetcher, children } = props
// ✅ 服务端渲染路径
if (initialData !== null && initialData.code === 0) {
return (
<React.Fragment>
{children(initialData.res)}
</React.Fragment>
)
}
// ❌ 客户端降级路径
return (
<ClientFallback
queryKey={queryKey}
fetcher={fetcher}
>
{children}
</ClientFallback>
)
}
- 客户端兜底 ClientFallback
用 React Query 触发浏览器端请求,loading 时展示骨架,拿到数据后复用 children。
typescript
// src/components/global/DataSection/ClientFallback.tsx
'use client'
import React from 'react'
import { Loading } from '../Loading'
import { useServerData } from './useServerData'
import type { CommonApiRes } from '@/lib/service/type'
interface Props<T> {
queryKey: string | string[]
fetcher: () => Promise<CommonApiRes<T>>
children: (data: T) => React.ReactNode
}
export default function ClientFallback<T>(props: Props<T>) {
const { queryKey, fetcher, children } = props
const { data, loading, ...rest } = useServerData(
queryKey,
null,
fetcher
)
return (
<React.Fragment >
{loading && <Loading />}
{data && children(data)}
</React.Fragment>
)
}
- Hook 细节
useServerData 包装 useQuery,控制启用与重试。
typescript
// src/components/global/DataSection/useServerData.ts
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import type { CommonApiRes } from "@/lib/service/type"
type UseServerDataOptions<T> = Omit<UseQueryOptions<T, Error>, "queryKey" | "queryFn">
export function useServerData<T>(
queryKey: string | string[],
initialData: CommonApiRes<T> | null,
fetcher: () => Promise<CommonApiRes<T>>,
options?: UseServerDataOptions<CommonApiRes<T>>
) {
const key = Array.isArray(queryKey) ? queryKey : [queryKey]
const query = useQuery<CommonApiRes<T>, Error>({
queryKey: key,
queryFn: fetcher,
enabled: initialData === null,
initialData: initialData ?? undefined,
staleTime: initialData ? 0 : 5 * 60 * 1000,
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
...options,
})
return {
...query,
data: query.data?.res ?? null,
loading: query.isLoading,
error: query.error,
refetch: query.refetch,
isRefetching: query.isRefetching,
}
}
- 实际落地
在兑换商店页 src/app/act/(1st)/exchange-store/page.tsx 同时传入服务端结果和客户端 fetcher。
ini
const exchangeRes = await ExchangeStoreApi.getExchangeList()
<DataSection
queryKey="exchange-list"
initialData={exchangeRes}
fetcher={() => ExchangeStoreClientApi.getExchangeList()}>
{data => <p className="text-[38px] text-white">{data?.carnivalNum || 0}</p>}
</DataSection>
SSR 成功则直出;失败则客户端重试并显示 loading。
解决了什么
- 服务端 fetch 失败不再直接抛出错误页面,可回退到客户端请求 (实际场景:我的项目前期的接口是需要token,所以只能从app通过bridge获取token,导致接口部分只能是客户端渲染,后期改了可以服务端渲染,但是需要更新app版本,这样就存在版本兼容问题)。
为什么方案不行(遗留与限制)
- 请求成本、水合不一致、不通过safeFetch返回null导致SSR抛错等等其他问题,这些其实可以解决。组件实现里最主要的问题是函数不能序列化传递的问题,如果我想保持这样的写法的话(主要这种写法是比较优雅了、工作量少,不用单独处理),这里最难处理的是children通过传递data问题,但是这个问题我目前没有思考到好的方式
ini
<DataSection
...
fetcher={() => ExchangeStoreClientApi.getExchangeList()}>
{data => (
<p className="text-[38px] text-white">{data?.carnivalNum || 0}</p>
)}
</DataSection>
解决方案
解决方案其实有一些,尝试过很多
fetch方面,比如我写一个fetchMap+注册表,这样就可以不传,但是也只是解决了fetch,data => 只能在客户端
比如在包一层DataSectionWrapper,use client组件,但是弊端就是如果SSR成功,原本SSR的dom全部变成client的dom,丢失了框架的优势
在比如为每一个涉及接口的组件单独写一个Render Wapper,去判断返回成功or失败,对应渲染,但是这样工作量其实很大
小结
还在想解决方法