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

小结

还在想解决方法

相关推荐
FungLeo3 天前
成为全栈·Next.js 网站前台篇·响应式、可访问性与错误状态:内容站不能只验桌面首页
react.js·响应式设计·next.js·移动端适配·成为全栈·web 可访问性
FungLeo4 天前
成为全栈·Next.js 网站前台篇·站点设置如何驱动页头、页脚与 SEO
react·isr·next.js·metadata·成为全栈·站点配置
FungLeo6 天前
成为全栈·Next.js 网站前台篇·同源 BFF 代理:API、附件、Cookie 与跨站写入保护
react·csrf·next.js·bff·成为全栈·route handler
FungLeo6 天前
成为全栈·Next.js 网站前台篇·评论系统:叠楼、回复、删除与内容审核如何落到前台
react.js·内容审核·next.js·评论系统·树形数据·成为全栈
cindershade9 天前
一条请求的接力赛:用运行时责任重画 RSC 边界
性能优化·next.js
zach10 天前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js
zach10 天前
Vue/React SPA 打包部署后,子路由刷新 401 未登录问题彻底解决
前端·nginx·next.js
FungLeo12 天前
成为全栈·Next.js 网站前台篇·数据获取与缓存:60 秒再验证背后到底发生了什么
react·isr·next.js·成为全栈·react cache·opennext
名字还没想好☜20 天前
React 文件上传实战:预览 URL 回收、多文件逐个进度与拖拽放置区踩坑
前端·react·next.js
moMo1 个月前
NestJS 入门:用"模块化 + 装饰器"读懂大型后端框架
next.js