在 NextJS 中如何在服务器与客户端之间共享组件
Next.js 应用程序路由器将你的应用程序分为服务器组件和客户端组件。服务器组 … Read More
Socrates
Next.js 应用程序路由器将你的应用程序分为服务器组件和客户端组件。服务器组件在服务器端运行,用于保护敏感数据;客户端组件则在浏览器中运行,负责处理交互逻辑。挑战在于如何在保持各自环境规则不变的前提下,在这些组件之间共享数据和用户界面。
本指南会向你展示如何在 Next.js 中实现服务器组件与客户端组件之间的数据及组件共享。你将学习到组合模式、属性传递规则,以及何时使用哪种方法。
目录
什么是服务器组件和客户端组件?
在 Next.js 应用程序路由器中,所有组件默认都属于服务器组件。服务器组件仅在服务器端运行,它们可以从数据库中获取数据、使用 API 密钥,并将敏感逻辑置于浏览器之外;因此,它们不会向客户端发送 JavaScript 代码,从而减少了应用程序包的大小。
客户端组件既在服务器端运行(用于生成初始 HTML 内容),也在客户端运行(负责处理交互操作)。你需要在文件顶部使用 `"use client"` 指令来标记这些组件;它们可以使用 `useState`、`useEffect` 等函数,以及 `localStorage`、`window` 等浏览器 API。
关键规则是:**服务器组件可以导入并渲染客户端组件,但客户端组件不能直接导入服务器组件**。客户端组件只能通过属性(如 `children`)来接收服务器组件。
先决条件
在开始学习之前,请确保你已经具备以下条件:
-
对 React 有基本了解(包括组件、属性和钩子等概念)
-
拥有一个使用 Next.js 应用程序路由器的项目(Next.js 13 及更高版本)
-
已安装 Node.js(建议使用 18 及更高版本)
如果你还没有 Next.js 项目,可以使用以下命令创建一个:
npx create-next-app@latest my-app
如何通过属性在服务器和客户端之间传递数据
在服务器组件和客户端组件之间共享数据的最简单方法就是通过属性来传递。服务器组件负责获取数据,而客户端组件则接收这些数据并处理交互逻辑。
下面是一个基本的示例:一个页面(服务器组件)会获取一篇帖子的信息,并将点赞数传递给 LikeButton(客户端组件):
// app/post/[id]/page.jsx (服务器组件)
import LikeButton from '@/app/ui/like-button';
import { getPost } from '@/lib/data';
export default async function PostPage({ params }) {
const { id } = await params;
const post = await getPost(id);
return (
{post.title}
{post.content}
);
}
// app/ui/like-button.jsx (客户端组件)
'use client';
import { useState } from 'react';
export default function LikeButton({ likes,postId }) {
const [count, setCount] = useState(likes);
const handleLike = () => {
setCount((c) => c + 1);
// 调用 API 或服务器端接口来保存数据
};
return (
);
}
服务器组件在服务器端获取数据,客户端组件则接收这些原始数据(如 likes、postId),并负责管理状态和处理交互事件。这种模式使得数据获取工作在服务器端完成,而交互逻辑则在客户端实现。
如何将服务器组件作为子元素传递给客户端组件
你可以将一个服务器组件作为 children 属性(或任何其他属性)传递给客户端组件。服务器组件仍然会在服务器端进行渲染,而客户端组件接收的是已经渲染好的结果,而不是组件的源代码。
当你需要让客户端组件来包裹或控制由服务器端生成的内容的布局时,这种做法非常有用。例如,一个用于显示从服务器获取的数据的模态框:
// app/ui/modal.jsx (客户端组件)
'use client';
import { useState } from 'react';
export default function Modal({ children }) {
const [isOpen, setIsOpen] = useState(false);
return (
{isOpen && (
{children}
)}
);
}
// app/cart/page.jsx (服务器组件)
import Modal from '@/app/ui/modal';
import Cart from '@/app/ui/cart';
export default function CartPage() {
return (
);
}
// app/ui/cart.jsx(服务器组件,不使用“use client”)
import { getCart } from '@/lib/cart';
export default async function Cart() {
const items = await getCart();
return (
{items.map((item) => (
- {item.name}
))}
);
}
Cart是一个服务器组件,用于获取购物车数据。它会被作为children参数传递给Modal这个客户端组件。服务器会先渲染Cart组件,然后RSC响应中会包含渲染后的结果。客户端接收到这些数据后,会在模态框中显示它们。需要注意的是,购物车数据从不会在客户端上进行处理。
你也可以使用同样的模式来处理带有名称的属性(即插槽):
// app/ui/tabs.jsx(客户端组件)
'use client';
import { useState } from 'react';
export default function Tabs({ tabs, children }) {
const [activeIndex, setActiveIndex] = useState(0);
return (
{tabs.map((tab, i) => (
))}
{children[activeIndex]}
);
}
// app/dashboard/page.jsx(服务器组件)
import Tabs from '@/app/ui/tabs';
import Overview from '@/app/ui/overview';
import Analytics from '@/app/ui/analytics';
export default function DashboardPage() {
const tabs = [
{ id: 'overview', label: '概览' },
{ id: 'analytics', label: '分析数据' },
];
return (
);
}
Overview和Analytics也可以作为服务器组件来获取各自的数据。这些组件的内容会在服务器端被渲染出来,然后客户端会接收到已经预渲染好的结果。
服务器与客户端之间允许传递哪些属性?
从服务器组件传递给客户端组件的属性必须是可序列化的。React会将这些属性序列化到RSC响应中,从而能够将它们发送给客户端。
允许的类型
-
字符串、数字、布尔值
-
null和undefined -
普通对象(不能包含函数或类实例)
-
可序列化值的数组
-
JSX代码(可以用作服务器组件的子元素或其他属性)
-
服务器动作函数(使用
"use server"声明的函数)
不允许的类型
-
函数(服务器动作函数除外)
-
Date对象 -
类实例
-
符号
-
Map、Set、WeakMap、WeakSet -
具有自定义原型的对象
-
缓冲区、
ArrayBuffer以及类型化数组
如果需要传递一个Date》对象,请先将其转换为字符串或数字:
<ClientComponentcreatedAt={post.createdAt.toISOString()} />
如果使用MongoDB,需将ObjectId转换为字符串:
<PostThread userId={user._id.toString()] />
将服务器操作作为属性传递
服务器操作是带有"use server"标记的异步函数。你可以将它们作为属性传递给客户端组件。这些操作是按引用进行序列化的,而不是按值进行序列化。
// app/actions/post.js
'use server';
export async function likePost(postId) {
// 更新数据库
revalidatePath `/post/${postId}`);
}
app/post/[id]/page.jsx (服务器组件)
import LikeButton from '@/app/ui/like-button';
import { likePost } from '@/app/actions/post';
export default async function PostPage({ params }) {
const post = await getPost((await params).id);
return <LikeButton likes={postlikes} postId={post.id} onLike={likePost} />;
}
app/ui/like-button.jsx (客户端组件)
'use client';
export default function LikeButton({ likes, postId, onLike }) {
const handleClick = () => {
onLike(postId);
};
return <button onClick={handleClick}>{likes} likes<>/button>;
}
你也可以为这些参数绑定具体的值:
<LikeButton onLike={likePost.bind(null, post.id)} />
如何利用React Context和React.cache共享数据
React Context在服务器组件中是无法使用的。要想在服务器组件与客户端组件之间共享数据,你可以将客户端组件的Context提供者与React.cache结合使用,以实现服务器端的缓存功能。
创建一个被缓存的获取数据的函数:
lib/user.js
import { cache } from 'react';
export const getUser = cache(async () => {
const res = await fetch('https://api.example.com/user');
return res.json();
});
创建一个能够接收Promise并将其存储在Context中的提供者:
appproviders/user-provider.jsx
'use client';
import { createContext } from 'react';
export const UserContext = createContext(null);
export default function UserProvider({ children, userPromise }) {
return (
);
}
在根布局中,可以直接传递这个Promise,而无需等待其执行结果:
app/layout.jsx
import UserProvider from '@/appproviders/user-provider';
import { getUser } from '@/lib/user';
export default function RootLayout({ children }) {
const userPromise = getUser();
return (
);
}
客户端组件会使用use()来解析这些Promise:
// app/ui/profile.jsx
'use client';
import { use, useContext } from 'react';
import { UserContext } from '@/appproviders/user-provider';
export default function Profile() {
const userPromise = useContext(UserContext);
if (!userPromise) {
throw new Error('Profile必须在与UserProvider一起使用时才能使用');
}
const user = use(userPromise);
return 欢迎,{user.name}
;
}
为了处理组件的加载状态,需要用Suspense包装这些组件:
// app/dashboard/page.jsx
import { Suspense } from 'react';
import Profile from '@/app/ui/profile';
export default function DashboardPage() {
return (
正在加载用户信息... 服务器端组件可以直接调用getUser()。由于这个函数被封装在React.cache中,因此在同一请求中多次调用这个函数都会得到相同的结果:
// app/settings/page.jsx
import { getUser } from '@/lib/user';
export default async function SettingsPage() {
const user = await getUser();
return {user.name}的设置页面
;
}
React.cache是按请求来划分作用范围的。每个请求都有自己的缓存机制,不同请求之间的缓存数据不会相互共享。
如何在两种环境中使用第三方组件
有些第三方组件会使用useState、useEffect或浏览器API,但它们的源代码中并没有"use client"这一行。如果你在服务器端组件中使用这些组件,就会遇到错误。
你需要先为这些组件创建一个客户端组件作为包装器:
// app/ui/carousel-wrapper.jsx
'use client';
import { Carousel } from 'acme-carousel';
export default Carousel;
现在你就可以在服务器端组件中使用了:
// app/gallery/page.jsx
import Carousel from '@/app/ui/carousel-wrapper';
export default function GalleryPage() {
return (
图库
);
}
如果某个第三方组件已经在一个客户端组件中被使用了,那么就不需要再为其创建包装器了。父组件的"use client"配置就已经足够了:
'use client';
import { Carousel } from 'acme-carousel';
export default function Gallery() {
return
}
如何利用“仅限服务器端”和“仅限客户端”的配置来避免环境污染问题
很容易不小心将仅适用于服务器端的代码(如数据库连接信息、API密钥等)导入到客户端组件中。为了在构建阶段就能检测到这种错误,可以使用server-only这个包。
安装方法:
npm install server-only
需要将这个模块添加到那些绝对不能在客户端运行的文件的顶部:
// lib/data.js
import 'server-only';
export async function getSecretData() {
const res = await fetch('https://api.example.com/data', {
headers: {
authorization: process.env.API_KEY,
},
});
return res.json();
}
如果你将getSecretData(或这个文件中的任何其他代码)导入到客户端组件中,构建过程将会失败,并会显示明确的错误信息。
对于仅用于客户端的代码(例如那些使用window的代码),请使用client-only模块:
npm install client-only
// lib/analytics.js
import 'client-only';
export function trackEvent(name) {
if (typeof window !== 'undefined') {
window-analytics?.track(name);
}
}
如果将这样的代码导入到服务器端组件中,构建过程也会出现错误。
实际应用案例
示例1:使用共享服务器和客户端组件的布局
将整个布局结构作为服务器端组件来处理;只有那些需要与用户交互的部分才被设计为客户端组件:
// app/layout.jsx (服务器端组件)
import Logo from '@/app/ui/logo';
import Search from '@/app/ui/search';
export default function Layout({ children }) {
return (
{children}
);
}
// app/ui/logo.jsx (服务器端组件,无需使用任何特殊指令)
export default function Logo() {
return
};
// app/ui/search.jsx (客户端组件)
'use client';
import { useState } from 'react';
export default function Search() {
const [query, setQuery] = useState('');
return (
setQuery(e.target.value)}
/>
);
}
Logo组件保存在服务器上,而Search组件则是交互式的,会在客户端运行。整个页面就是由这两个组件组合而成的。
示例2:包含服务器数据和客户端添加到购物车功能的产品页面
// app/product/[id]/page.jsx (服务器端组件)
import { getProduct } from '@/lib/products';
import AddToCartButton from '@/app/ui/add-to-cart-button';
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await getProduct(id);
return (
{product.name}
{product.description}
${product.price}
);
}
// app/ui/add-to-cart-button.jsx(客户端组件)
'use client';
import { useState } from 'react';
export default function AddToCartButton({ productId, price }) {
const [added, setAdded] = useState(false);
const handleClick = () => {
// 调用服务器端操作或API
setAdded(true);
};
return (
);
}
示例3:主题提供者包裹服务器端内容
// appproviders/theme-provider.jsx(客户端组件)
'use client';
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
export function useTheme() {
return useContext(ThemeContext);
}
export default function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
{children}
);
}
// app/layout.jsx(服务器端组件)
import ThemeProvider from '@/appproviders/theme-provider';
export default function RootLayout({ children }) {
return (
{children}
);
}
ThemeProvider是一个客户端组件。它用于包裹children元素,而这些children可以是服务器端组件。应该尽可能将这类提供者放在代码结构的深层位置,这样Next.js才能优化静态服务器端组件的处理。
示例4:不使用指令共享通用功能模块
纯粹的通用功能模块(不包含任何钩子,也不依赖浏览器API)是可以被其他组件共享的。这类模块会在导入它们的组件的运行环境中被执行:
// lib/format.js(可共享代码,无需使用任何指令)
export function formatPrice(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
// 服务器端组件
import { formatPrice } from '@/lib/format';
export default async function ProductPage() {
const product = await getProduct();
return {formatPrice(product.price)}
;
}
// 客户端组件
'use client';
import { formatPrice } from '@/lib/format';
export default function PriceDisplay({ amount }) {
return {formatPrice(amount)};
}
formatPrice是一个纯粹的功能函数,无论是在服务器端组件还是客户端组件中都可以使用它。
结论
在Next.js中,实现服务器端组件与客户端组件之间的组件及数据共享,主要依赖于以下几种模式:
-
通过属性传递数据——服务器组件获取数据后,会将可序列化的值传递给客户端组件。
-
将服务器组件作为子元素嵌入到客户端组件中——客户端组件可以通过`children`属性或插槽来展示由服务器渲染生成的内容。
-
仅使用可序列化的属性——尽量使用基本数据类型、普通对象、数组以及服务器端提供的功能;对于`Date`和`ObjectId`这类类型,需要将其转换为字符串。
-
利用上下文与`React.cache`进行数据共享——可以使用客户端组件提供者,该提供者会接收一个Promise对象以及`React.cache`机制,从而实现服务器端的数据去重处理。
-
包装第三方组件——对于那些仅支持客户端功能的第三方库,需要为其添加`"use client"`包装器。
-
明确区分仅适用于服务器端的功能与仅适用于客户端的功能——这样可以避免在服务器端与客户端之间出现不必要的功能导入情况。
<请将服务器端组件放在树结构的顶层,而将客户端组件放置在叶子节点处。这样就可以减少发送到浏览器的JavaScript代码量,同时仍能在需要的地方保持交互功能。>