失败的服务端SSR降级CSR

前沿

在活动页需要同时兼顾首屏渲染和接口不稳定,想用一个组件兜底:服务端优先渲染,失败时自动降级到客户端再拉一次。于是写了 DataSection(src/components/global/DataSection),记录这次尝试和它最终没完全达成预期的原因。

条件

  1. 全局公共使用,那么必须是公共组件,不用为每个涉及接口的组件处理(各种Wapper组件)
  2. 复用同一份业务代码片段,避免写两套(啥客户端一套、服务端一套的)
  3. 如果SSR 正常,dom元素仍然走SSR,如果失败,可接受全局dom降级客户端渲染

目的

  • SSR 正常:直接用服务端数据渲染,拿到完整 HTML/SEO。
  • SSR 失败:不要 500,也不要空白;在浏览器端再请求一次。

实现过程

  1. 服务端安全 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 }
}
  1. 统一入口 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>
  )
}
  1. 客户端兜底 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>
  )
}
  1. 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,
  }
}
  1. 实际落地
    在兑换商店页 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失败,对应渲染,但是这样工作量其实很大

小结

还在想解决方法

相关推荐
人间凡尔赛1 天前
2026 年 React Server Components 完全指南:Next.js 16 全栈开发最佳实践
前端·typescript·react·全栈·next.js
倾颜4 天前
pnpm 负责依赖,Turbo 负责任务:一次小型 Monorepo 治理的边界取舍
前端·next.js
wordpress资料库6 天前
Next.js更适合移动开发 不适合web开发
开发语言·前端·javascript·next.js
赵大仁6 天前
Next.js + Vercel AI SDK 实战:30 分钟搭出流式 Chat 页面
前端·ai·实战·react·next.js·vercel
飞天狗9 天前
Next.js 16 App Router 实战:服务端组件与流式渲染
前端·next.js
weixin_4713830311 天前
Next.js - 04 - 深入理解next.js渲染原理
前端·javascript·next.js
名字还没想好☜12 天前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
竹林81812 天前
用 wagmi v2 + Next.js 14 踩坑实录:手写一个支持多链的 NFT 市场前端
next.js
小Bk13 天前
我用 Next.js 16 + DeepSeek API 做了一个 AI 简历吐槽器,已开源
openai·next.js·deepseek
倾颜14 天前
AI 聊天长期记忆的向量化处理:从"关键词匹配"到"换种说法也能找到"
llm·agent·next.js