深入探索Client Components的工作原理与实战应用

Zz利利 框架 阅读 2,999
赞 21 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

自从 Next.js 13 引入 App Router 和 Client Components 后,我折腾了不下三个项目,踩过一堆坑,也慢慢摸出了一套还算稳的写法。今天就聊聊我怎么用 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/headersnext/serverfsprocess.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 频出。我的核心思路就一条:最小化客户端边界——只在必要处切到客户端,其他尽量交给服务端。

以上是我踩坑后的总结,希望对你有帮助。有更好的方案欢迎评论区交流,比如你怎么处理复杂的客户端状态同步?或者有没有更优雅的第三方库集成方式?

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

暂无评论