失败的服务端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失败,对应渲染,但是这样工作量其实很大

小结

还在想解决方法

相关推荐
少卿15 小时前
Next.js 国际化实现方案详解
前端·next.js
鹿鹿鹿鹿isNotDefined1 天前
Antd5.x 在 Next.js14.x 项目中,初次渲染样式丢失
前端·react.js·next.js
程序员爱钓鱼4 天前
Next.js SSR 项目生产部署全攻略
前端·next.js·trae
程序员爱钓鱼4 天前
使用Git 实现Hugo热更新部署方案(零停机、自动上线)
前端·next.js·trae
用户29544156662386 天前
从 React2Shell 看 RSC 反序列化的系统性风险
next.js
狐篱7 天前
主题切换闪烁问题
前端·next.js
玲小珑7 天前
Next.js 近期高危漏洞完整指南:原理 + 攻击示例(前端易懂版)
前端·next.js
佐倉吹雪7 天前
[深度解析] CVE-2025-55182:当 React Server Components 成为 RCE 的入口
react.js·next.js