You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Preact/Fresh使用Context报错:Converting circular structure to JSON如何解决?

在Fresh/Preact中使用Context解决"Converting circular structure to JSON"错误

问题背景

我在Fresh/Preact应用里创建了一个全局状态Context,但渲染时抛出Converting circular structure to JSON错误。我知道这是Fresh的服务端渲染(SSR)导致的——AppContext.Provider的value里包含函数,序列化时会因为函数闭包的自引用触发循环结构错误。我试过用Preact signals,但遇到了其他问题,希望保留Context方案(熟悉Next.js的Context用法)。

我的AppContext代码如下:

import { createContext } from "preact"
import { useContext, useState } from "preact/hooks"

export type Theme = "light" | "dark"
export type Language = "english" | "norsk"

interface IAppContext {
  theme: Theme,
  language: Language,
  toggleTheme: () => void,
  toggleLanguage: () => void
}

const AppContext = createContext<IAppContext>({
  theme: "light",
  language: "english",
  toggleLanguage: () => {},
  toggleTheme: () => {}
})

export const useAppContext = () => useContext(AppContext)

export default function AppContextProvider({ children }: any) {
  const [theme, setTheme] = useState<Theme>("light")
  const [language, setLanguage] = useState<Language>("english")
  const toggleTheme = () => setTheme(_theme => _theme === "light" ? "dark" : "light")
  const toggleLanguage = () => setLanguage(_language => _language === "english" ? "norsk" : "english")

  const context: IAppContext = {
    theme,
    language,
    toggleTheme,
    toggleLanguage
  }

  return <AppContext.Provider value={context}>{children}</AppContext.Provider>
}

解决思路

Fresh的SSR会把组件树序列化用于客户端hydration,而Context的value里包含的函数(比如toggleTheme)会携带闭包引用(比如引用组件内部的setTheme),这些引用会形成循环结构,导致JSON序列化失败。

要保留Context方案,核心是让包含函数的Context Provider只在客户端渲染,避免服务端尝试序列化它。

具体实现

步骤1:将Context Provider改为客户端组件

创建一个客户端专属的Provider组件,确保它只在浏览器环境中运行:

// components/ClientAppContextProvider.tsx
/** @jsx h */
import { createContext } from "preact"
import { useContext, useState } from "preact/hooks"

export type Theme = "light" | "dark"
export type Language = "english" | "norsk"

interface IAppContext {
  theme: Theme,
  language: Language,
  toggleTheme: () => void,
  toggleLanguage: () => void
}

const AppContext = createContext<IAppContext>({
  theme: "light",
  language: "english",
  toggleLanguage: () => {},
  toggleTheme: () => {}
})

export const useAppContext = () => useContext(AppContext)

export default function ClientAppContextProvider({ children }: { children: preact.ComponentChildren }) {
  // 客户端初始化时读取本地存储,保持状态持久化(可选)
  const [theme, setTheme] = useState<Theme>(() => {
    if (typeof window !== "undefined") {
      return localStorage.getItem("theme") as Theme || "light"
    }
    return "light"
  })
  const [language, setLanguage] = useState<Language>(() => {
    if (typeof window !== "undefined") {
      return localStorage.getItem("language") as Language || "english"
    }
    return "english"
  })

  const toggleTheme = () => {
    setTheme(prev => {
      const newTheme = prev === "light" ? "dark" : "light"
      localStorage.setItem("theme", newTheme)
      return newTheme
    })
  }

  const toggleLanguage = () => {
    setLanguage(prev => {
      const newLang = prev === "english" ? "norsk" : "english"
      localStorage.setItem("language", newLang)
      return newLang
    })
  }

  const context: IAppContext = {
    theme,
    language,
    toggleTheme,
    toggleLanguage
  }

  return <AppContext.Provider value={context}>{children}</AppContext.Provider>
}

步骤2:在_app.tsx中用ClientOnly包裹Provider

Fresh的ClientOnly组件会在服务端只渲染fallback内容,客户端加载完成后再渲染内部的客户端组件,这样服务端不会序列化Provider里的函数:

// routes/_app.tsx
import { AppProps } from "$fresh/server.ts"
import { ClientOnly } from "$fresh/runtime.ts"
import ClientAppContextProvider from "../components/ClientAppContextProvider.tsx"

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ClientOnly fallback={<div>Loading...</div>}>
      <ClientAppContextProvider>
        <Component {...pageProps} />
      </ClientAppContextProvider>
    </ClientOnly>
  )
}

步骤3:在使用Context的组件中兼容服务端渲染

如果使用useAppContext的组件是服务端渲染的,需要判断环境,避免在服务端调用客户端才有的Context方法:

// components/ThemeToggle.tsx
/** @jsx h */
import { useAppContext } from "../components/ClientAppContextProvider.tsx"
import { useIsClient } from "$fresh/runtime.ts"

export default function ThemeToggle() {
  const isClient = useIsClient()
  const { theme, toggleTheme } = useAppContext()

  if (!isClient) {
    // 服务端渲染静态占位按钮
    return <button>切换主题</button>
  }

  return (
    <button onClick={toggleTheme}>
      切换到{theme === "light" ? "深色" : "浅色"}模式
    </button>
  )
}

为什么这样有效

  • ClientOnly组件确保服务端不会渲染包含函数的Context Provider,避免了序列化循环结构的问题。
  • 客户端组件中的状态和函数只会在浏览器环境中初始化和运行,符合Preact Context的使用逻辑。
  • 可选的本地存储读取让状态在页面刷新后得以保留,提升用户体验。

内容的提问来源于stack exchange,提问作者Noobster

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 19:10:32