深入探索Client Components的工作原理与实战应用
我的写法,亲测靠谱
自从 Next.js 13 引入 App Router 和 Client Components 后,我折腾了不下三个项目,踩过一堆坑,也慢慢摸出了一套还算稳的写法。今天就聊聊我怎么用 Client Components 才不把自己搞崩溃。
首先,能不用 Client Component 就别用。很多人一看到要加交互,立马 "use client" 一贴,结果整个页面变重、首屏变慢。我一开始也这么干,后来发现很多组件其实根本不需要客户端状态。比如一个带 hover 效果的按钮?Tailwind 搞定。一个简单的展开/收起?用 details/summary 原生标签就行。别急着上 useState。
真正需要 Client Component 的场景,通常是:
- 用到 useState / useEffect
- 绑定 DOM 事件(onClick、onScroll 等)
- 用到浏览器 API(localStorage、navigator、window)
- 集成第三方 UI 库(比如 React-Beautiful-DnD、Chart.js)
我现在的习惯是:默认写成 Server Component,等真要用到上面这些功能时,再拆出一个最小化的 Client Component。比如一个搜索框,我只把输入框和按钮抽成 Client,其他筛选条件、历史记录列表都留在 Server Component 里渲染。
下面是我现在最常用的结构:
// components/SearchInput.js
'use client';
import { useState } from 'react';
export default function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜点啥..."
/>
<button type="submit">搜索</button>
</form>
);
}
// app/page.js
import SearchInput from '@/components/SearchInput';
import SearchResult from '@/components/SearchResult'; // 这个还是 Server Component
export default async function HomePage() {
const initialData = await fetchData(); // 服务端直接拿数据
return (
<div>
<h1>商品搜索</h1>
<SearchInput onSearch={async (q) => {
// 注意:这里不能直接在 Client Component 里调用 fetch
// 而是通过路由跳转或触发服务端 action
window.location.href = /search?q=${encodeURIComponent(q)};
}} />
<SearchResult data={initialData} />
</div>
);
}
这种写法的好处是:首屏数据由服务端直出,快;交互部分隔离,不污染主逻辑。而且 SearchInput 可以复用,不依赖具体业务。
这几种错误写法,别再踩坑了
我见过太多人把 Client Component 用得一团糟,自己给自己挖坑。下面这几个反面案例,都是我或者同事实际踩过的。
错误一:在 Client Component 里直接 fetch 数据
// ❌ 别这么干!
'use client';
import { useEffect, useState } from 'react';
export default function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser);
}, []);
if (!user) return <div>加载中...</div>;
return <div>你好,{user.name}</div>;
}
问题在哪?首屏白屏!用户看到的是空白,然后才加载。而 Server Component 可以直接在服务端把用户数据塞进来,首屏就有内容。正确的做法是:在 page 或 layout 里用 async 获取数据,再通过 props 传给 Client Component。如果必须在客户端发起请求(比如用户操作后),那就用 SWR 或 React Query,至少能加 loading 状态。
错误二:把整个页面标成 Client Component
有些同学为了省事,直接在 page 顶部加 "use client",结果整个页面变成 CSR,SEO 完蛋,首屏性能暴跌。Next.js 的核心优势就是混合渲染,你全用客户端,那不如直接用 Create React App。
错误三:在 Client Component 里引用 Server-only 的东西
比如这样:
// ❌ 报错:window is not defined
'use client';
import { cookies } from 'next/headers'; // 这是服务端 API!
export default function MyComponent() {
const theme = cookies().get('theme')?.value; // 崩!
return <div>当前主题:{theme}</div>;
}
记住:next/headers、next/server、fs、process.env(敏感变量)这些都只能在 Server Component 用。如果 Client Component 需要 cookie,得用 document.cookie 或者提前从服务端通过 props 传下来。
实际项目中的坑
除了上面那些明显错误,还有一些细节坑,不注意就半夜加班。
第一,事件处理函数的闭包陷阱。在 useEffect 里绑定事件,很容易拿到 stale state。比如:
// ❌ 错误示例
'use client';
import { useState, useEffect } from 'react';
export default function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
console.log(scrollY); // 永远是 0!
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []); // 依赖数组为空,handleScroll 不会更新
return <div>滚动了 {scrollY}px</div>;
}
解决方法?要么用 useRef 保存最新值,要么把 handleScroll 放进依赖数组(但要注意防抖)。我一般用 useRef:
// ✅ 修复版
'use client';
import { useState, useEffect, useRef } from 'react';
export default function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const scrollYRef = useRef(0);
useEffect(() => {
scrollYRef.current = scrollY;
}, [scrollY]);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
// 如果其他地方需要读取,用 scrollYRef.current
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <div>滚动了 {scrollY}px</div>;
}
第二,第三方库的水合问题。有些 UI 库(比如某些图表库)在服务端渲染时会报错,因为它们用了 window。这时候必须动态导入 + ssr: false:
// ✅ 安全用法
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('@/components/Chart'), { ssr: false });
export default function Dashboard() {
return (
<div>
<h1>数据看板</h1>
<Chart /> {/* 只在客户端渲染 */}
</div>
);
}
别偷懒直接 import,否则 build 时就挂了。
第三,状态管理别乱用 Context。很多人一上来就建个全局 Context,结果所有 Client Component 都 re-render。我现在的原则是:局部状态用 useState,跨组件状态用 Zustand 或 Jotai(比 Redux 轻),不到万不得已不用 React Context。Context 适合 theme、locale 这种低频更新的全局状态。
结尾唠叨两句
Client Components 是个好东西,但不是万能胶水。用好了,交互流畅、代码清晰;用不好,性能崩坏、bug 频出。我的核心思路就一条:最小化客户端边界——只在必要处切到客户端,其他尽量交给服务端。
以上是我踩坑后的总结,希望对你有帮助。有更好的方案欢迎评论区交流,比如你怎么处理复杂的客户端状态同步?或者有没有更优雅的第三方库集成方式?

暂无评论