Server Components在Next.js中的实战应用与常见陷阱解析

喧丹 ☘︎ 框架 阅读 2,586
赞 23 收藏
二维码
手机扫码查看
反馈

又踩坑了,Server Components里useEffect直接报错

今天上线前测一个新页面,突然控制台炸出一行红字:React Hook "useEffect" is called in a component that is neither a Client Component nor a Server Component.

Server Components在Next.js中的实战应用与常见陷阱解析

我盯着看了三秒——不是吧,我明明在组件顶部写了 "use client" 啊?赶紧翻文件,结果发现:这个组件是被另一个没加 "use client" 的 Server Component 直接 import 进来的。它根本没机会执行,连解析都没过,就卡在服务端编译阶段了。

这里我踩了个坑:以为只要我自己写的组件加了 "use client" 就万事大吉,完全忽略了它的“上游”是谁。Server Components 会递归检查整个导入链,只要中间有一个没标注、又用了 React Hook,就会直接 throw。不是运行时报错,是构建时就挂——Vercel 上 build 失败,本地 next dev 也直接崩,连热更新都等不到。

折腾了半天发现,问题不在写法,而在加载路径

一开始我以为是 Next.js 版本问题(刚升到 14.2.5),降级试了下,不行;又怀疑是 app/ 目录结构不对,把组件从 app/(main)/dashboard/page.tsx 挪到 app/dashboard/page.tsx,还是报错;甚至去翻了 Next 官方文档的 “Server and Client Components” 页面,看到那张经典的“数据流图”,才突然意识到:我的 DashboardPage 是 Server Component,但它里面直接 import ChartWidget from '@/components/chart-widget',而 chart-widget.tsx 虽然自己写了 "use client",但它的父级没做任何隔离——等于让 Server Component 去“执行”一个 client 组件,这根本违反了 SSR 的设计前提。

后来试了下发现,最简单的解法不是改组件,而是改调用方式:不能 import,得用 dynamic 异步加载,并显式声明 ssr: false

核心代码就这几行

我把原来直白的 import 全干掉了:

// ❌ 错误写法
import ChartWidget from '@/components/chart-widget'

换成动态 import:

// ✅ 正确写法(放在 Server Component 内部)
'use server'

import { Suspense } from 'react'
import dynamic from 'next/dynamic'

const ChartWidget = dynamic(
  () => import('@/components/chart-widget').then((mod) => mod.ChartWidget),
  {
    ssr: false,
    loading: () => <div className="h-64 bg-gray-100 rounded-lg animate-pulse" />
  }
)

export default function DashboardPage() {
  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-6">仪表盘</h1>
      <Suspense fallback={<div className="h-64 bg-gray-100 rounded-lg animate-pulse" />}>
        <ChartWidget />
      </Suspense>
    </div>
  )
}

注意几个关键点:

  • ssr: false 必须写,不然 Next 还是会尝试在服务端 render 这个组件,照样报错
  • loadingSuspense fallback 最好都配上,不然首屏会空白一帧(特别是 ChartWidget 里还带 useEffect + fetch 的时候)
  • import('@/components/chart-widget').then(...) 这种写法是为了支持命名导出(比如组件叫 ChartWidget 而不是默认导出),避免 default 冲突

顺手检查了下 chart-widget.tsx 自身的内容,确认它确实只用了 client-side 能力:

// components/chart-widget.tsx
'use client'

import { useEffect, useState } from 'react'

export function ChartWidget() {
  const [data, setData] = useState([])

  useEffect(() => {
    // 这里必须是 client-only 的逻辑
    fetch('https://jztheme.com/api/chart-data')
      .then(res => res.json())
      .then(setData)
  }, [])

  return (
    <div className="bg-white p-4 rounded-lg shadow-sm">
      <h3 className="font-medium mb-2">趋势图表</h3>
      <div className="text-sm text-gray-500">
        {data.length > 0 ? 共 ${data.length} 条数据 : '加载中...'}
      </div>
    </div>
  )
}

为什么非得这么绕?讲点原理

Server Components 的本质,是把组件当成“函数调用”来执行——它不生成 DOM,不绑定事件,不挂载生命周期,只是返回一段 JSON-like 的渲染描述(React Server Components Payload)。所以一旦你在里面写了 useEffectuseState,它连 parse 都 parse 不了,因为根本没有对应的 runtime 环境。

dynamic(..., { ssr: false }) 的作用,是告诉 Next:这个组件你别管服务端的事,直接扔给客户端去下载、执行、挂载。相当于在 Server Component 的输出树里,插了一个“占位符”,等 HTML 到浏览器后,再由客户端 JS 去异步加载并替换它。

这也是为什么你不能在 Server Component 里直接调 useState ——不是语法限制,是语义冲突。它压根就不该存在“状态”的概念。

还有两个小尾巴没完美解决

第一,ChartWidget 里如果要访问 windowdocument,现在没问题了;但如果它内部依赖某个全局库(比如 echarts),得确保这个库本身是 client-safe 的——我之前遇到过 echarts 在服务端 require 报错,最后是加了 if (typeof window !== 'undefined') 包一层才搞定。

第二,这种 dynamic 方式会导致 chunk 多一个 JS 文件,HTTP 请求多一次。不过我们项目里 chart 组件本来就是按需加载的,影响不大。真要极致优化,也可以把所有 client 组件提前打包进一个 client-bundle.js,但没必要——现阶段能跑通、不报错、不影响首屏体验,就先这样。

踩坑提醒:这三点一定注意

  • Server Component 里不能出现任何 useEffectuseStateuseRef,哪怕它被包在 if (false) 里也不行——Next 编译器是静态扫描的
  • "use client" 必须是文件最顶部、且前面不能有任何空行或注释(包括 BOM)
  • 不要迷信“加了 use client 就安全”,一定要看它被谁 import、谁在 render 它

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如用 React.createServerReference 或自定义 wrapper),欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论