返回

React服务端组件用Swiper?解决数据与轮播难题

javascript

React 服务端组件遇上客户端轮播?搞定 Basehub 数据与 Swiper 的共存难题

写前端代码的时候,经常会遇到需要从某个地方(比如 CMS)拿数据,然后用一个炫酷的前端库(比如轮播)展示出来的情况。这听起来挺直接的,对吧?但如果你在用 React Server Components (RSC) 来获取数据,同时又想用像 Swiper.js 这种需要在浏览器里跑的库,问题可能就来了。

一个常见场景就是:用一个 async 组件从 Basehub 拉取项目列表,想直接在这个组件里用 Swiper 把它们变成轮播。结果呢?要么报错,要么轮播压根不动。

就像下面这段代码遇到的问题:

// 这个组件既尝试 async 获取数据(服务端特性)
// 又尝试直接渲染 <Swiper> (需要客户端环境)
const Projects = async () => {
    return (
        <Pump
            queries={[
                // ... Basehub query ...
            ]}
        >
            {async ([data]) => {
                'use server'; // 这个 'use server' 在这里可能不是预期作用

                if (!data.projects.items.length) {
                    return <div>No projects found</div>;
                }

                const projectPosts = data.projects.items;

                return (
                    <section className='flex flex-col h-screen min-h-[400px]'>
                        <InnerNav />
                        {/* Swiper 是一个客户端库,依赖浏览器 API 和交互 */}
                        <Swiper className='grow relative flex flex-row  swiper'>
                            <div className='flex flex-row h-full'> {/* Swiper 的直接子元素应是 SwiperSlide */}
                                {projectPosts.map((projects, index) => (
                                    {/* 把复杂的结构直接放进 map 可能导致 Swiper 结构错误 */}
                                    <div
                                        className='h-full flex-grow flex flex-row swiper-slide '
                                        key={index}
                                    >
                                        {/* ... slide content ... */}
                                    </div>
                                ))}
                            </div>
                        </Swiper>
                    </section>
                );
            }}
        </Pump>
    );
};

用户猜得没错,问题很可能出在 服务端逻辑和客户端逻辑混在了一个组件里

问题出在哪?服务端 vs 客户端

理解 React Server Components 和 Client Components 的区别是关键。

  1. 服务端组件 (Server Components):

    • 默认情况下,在 Next.js (以及其他支持 RSC 的框架) 里,组件都是服务端组件。
    • 它们在服务器上渲染。这意味着它们可以直接访问后端资源(比如数据库、文件系统、像 Basehub 这样的 CMS API),可以直接用 async/await 获取数据。很方便!
    • 但是!它们不能使用那些依赖浏览器环境的 Hooks(比如 useState, useEffect, useRef)或者浏览器 API(比如 window, document),也不能绑定事件监听器(比如 onClick)。
  2. 客户端组件 (Client Components):

    • 需要用 "use client"; 指令明确标记在文件的最顶端。
    • 它们会在浏览器中渲染和执行(技术上说,它们也会在服务器上进行预渲染/SSR,然后在浏览器中 "hydrate")。
    • 它们可以使用 React 的所有 Hooks,可以操作 DOM,可以添加交互。像 Swiper.js 这样的 UI 库,因为它需要处理触摸滑动、更新样式、管理内部状态等,所以它 必须 在客户端组件中使用。

你把 async(暗示服务端执行数据获取)和 <Swiper>(需要客户端环境)放在同一个组件 Projects 里,这就产生了冲突。虽然 Basehub 的 <Pump> 本身设计得可能能在客户端或服务端工作,但 Swiper 的要求是明确的:它需要客户端环境。代码里那个 'use server' 放在 <Pump> 的回调里,作用域可能也和你预想的不一样,它通常用于定义 Server Actions,而不是标记整个渲染块为服务端执行。

而且,你的 Swiper 结构也有点问题:<Swiper> 的直接子元素 必须<SwiperSlide> 组件。你的代码里是先套了一个 <div>,再在里面 map 出各个 div,这会让 Swiper 找不到它的幻灯片。

解决方案:职责分离

解决这个问题的核心思路是:让服务端组件负责数据获取,让客户端组件负责渲染交互式 UI(比如 Swiper 轮播)。

方案一:创建专门的客户端轮播组件(推荐)

这是最清晰、最符合 React 设计模式的做法。

1. 创建服务端组件 (获取数据)

这个组件就叫 ProjectsPage 或者类似的,它只做一件事:用 <Pump> 从 Basehub 拉数据。

// app/projects/page.tsx (或者你放页面的地方)
// 这个文件默认是 Server Component,不用加 'use server'

import { Pump } from '@basehub/react-pump';
import ProjectCarousel from './ProjectCarousel'; // 引入我们接下来要创建的客户端组件
import InnerNav from '@/components/InnerNav'; // 假设你有这个组件

const ProjectsPage = async () => {
    return (
        <Pump
            queries={[
                {
                    __typename: true,
                    projects: {
                        items: {
                            _title: true,
                            descriptions: {
                                plainText: true,
                            },
                            projectImage: { url: true, width: true, height: true, alt: true },
                        },
                        // 可以加上 next { revalidate: 60 } 来控制缓存时间
                    },
                },
            ]}
            // 可以在这里加个 loading state
            // loading={<div>Loading projects...</div>}
        >
            {async ([data], { loading }) => {
                // 'use server'; // 在 Pump 回调里不需要 'use server'
                                // Pump 的设计会处理数据获取

                if (loading) {
                  return <div>加载中...</div>;
                }

                // 处理错误情况更稳妥
                if (!data || !data.projects || !data.projects.items.length) {
                    return <section className="p-4">未能找到项目。</section>;
                }

                const projectPosts = data.projects.items;

                // 这里把获取到的数据 projectPosts 传递给客户端组件
                return (
                    <section className='flex flex-col h-screen min-h-[400px]'>
                        <InnerNav />
                        {/* 把数据作为 prop 传给客户端组件 */}
                        <ProjectCarousel projects={projectPosts} />
                    </section>
                );
            }}
        </Pump>
    );
};

export default ProjectsPage; // 导出页面组件

注意:

  • 服务端组件 ProjectsPage 现在很干净,只管拿数据和组织页面结构。
  • 它把从 Basehub 获取到的 projectPosts 数组作为 prop 传递给了 ProjectCarousel 组件。传递的数据必须是可序列化的(比如 JS 对象、数组、字符串、数字等),这通常没问题。

2. 创建客户端组件 (渲染轮播)

新建一个文件,比如 ProjectCarousel.tsx,这个组件负责接收数据并使用 Swiper 展示轮播。

// app/projects/ProjectCarousel.tsx (或 components/ProjectCarousel.tsx)

'use client'; // **关键!**  告诉 React 这是个客户端组件

import { Swiper, SwiperSlide } from 'swiper/react';
import { Navigation, Pagination, Autoplay } from 'swiper/modules'; // 按需导入 Swiper 模块

// 引入 Swiper 的核心 CSS 和模块 CSS (根据你的项目设置调整路径)
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';
// 如果需要其他效果,比如 Cube, EffectFade 等,也需要引入对应 CSS

// 定义传入的 props 类型 (使用 TypeScript 的话)
interface Project {
    _title: string;
    descriptions?: { plainText: string };
    projectImage?: { url: string; width?: number; height?: number; alt?: string };
}

interface ProjectCarouselProps {
    projects: Project[];
}

const ProjectCarousel: React.FC<ProjectCarouselProps> = ({ projects }) => {

    // 在这里可以使用 useState, useEffect 等客户端 Hooks (如果需要)

    return (
        <Swiper
            // Swiper 配置选项
            modules={[Navigation, Pagination, Autoplay]} // 注册模块
            spaceBetween={0} // Slide 之间的间距
            slidesPerView={1} // 同一时间显示多少个 Slide
            navigation // 启用前进后退按钮
            pagination={{ clickable: true }} // 启用分页器并允许点击切换
            // loop={true} // 是否循环播放
            // autoplay={{ delay: 3000, disableOnInteraction: false }} // 自动播放配置
            className='grow relative flex flex-row swiper' // 保持你的样式类名
            // 确保 Swiper 组件有明确的高度或其父容器能撑开高度
        >
            {projects.map((project, index) => (
                // **关键!**  每个轮播项必须用 <SwiperSlide> 包裹
                <SwiperSlide key={index} className='h-full flex-grow flex flex-row'> {/* 直接在 SwiperSlide 上加类名 */}
                    {/* Slide 内部结构 */}
                    <div className='flex-grow w-full bg-slate-100 p-4 flex items-center justify-center'>
                        {project.projectImage?.url && (
                             <img
                                src={project.projectImage.url}
                                // 如果图片来自外部源,可能需要配置 next.config.js 的 images 域
                                // 或者直接用 <img> 标签,注意性能影响
                                // 如果用 Next/Image, 可能需要加载器或取消优化 unoptimized={true}
                                alt={project.projectImage?.alt || project._title}
                                style={{ maxHeight: '100%', maxWidth: '100%', objectFit: 'contain' }} // 控制图片显示
                            />
                        )}
                    </div>
                    <div className='p-6 w-[500px] relative flex flex-col justify-start pb-8 gap-4'>
                        <h2 className='text-2xl font-bold'>{project._title}</h2>
                        <p>{project.descriptions?.plainText}</p>
                    </div>
                </SwiperSlide>
            ))}
        </Swiper>
    );
};

export default ProjectCarousel;

重点解释:

  • 'use client'; 必须放在文件最上方 。这是区分客户端组件和服务端组件的标记。
  • ProjectCarousel 接收 projects 数组作为 prop。
  • Swiper 结构: <Swiper> 组件的直接子元素现在是 projects.map() 产生的 <SwiperSlide> 组件列表。这是正确的结构。
  • Swiper CSS: 别忘了引入 Swiper 的 CSS 文件。import 'swiper/css'; 是必须的,其他模块(navigation, pagination 等)的 CSS 也要按需引入。
  • Swiper 模块: Swiper.js 采用模块化设计。你需要什么功能(如导航按钮、分页器、自动播放),就 import 对应的模块,并在 <Swiper>modules prop 里注册它们。
  • 类型定义 (TypeScript): 如果用 TS,为 props 定义类型是个好习惯,能提高代码健壮性。
  • 图片处理:<img> 标签可以直接显示来自 Basehub 的 URL。如果 Basehub 的图片 URL 不是你 Next.js 应用 next.config.jsimages.remotePatterns (或 domains) 允许的来源,Next.js 的 <Image> 组件默认会报错。你可以配置 next.config.js,或者直接用 <img>,或者给 <Image> 组件加上 unoptimized={true} 属性(这会牺牲 Next.js 的图片优化)。用 <img> 时,注意通过 CSS 或 style 控制图片大小和显示方式(maxHeight, maxWidth, objectFit 等)。

为什么这种方式更好?

  • 关注点分离: 服务端组件专注数据,客户端组件专注 UI 和交互。代码更清晰,更容易维护。
  • 性能: 服务端组件可以更快地获取数据,且其代码不会被打包进客户端的 JavaScript bundle(除非它被客户端组件导入并使用,这里是传递数据,不是组件本身)。Swiper 相关的 JS 只会在客户端加载。
  • 符合 React 模式: 这是 React 和 Next.js 推荐的处理方式。

方案二:检查你的“拆分失败”尝试

你提到尝试把 Slide Item 拆分成单独组件时,轮播只渲染第一项。这通常指向几个可能的原因:

  1. Swiper 结构错误: 即使拆分了,你可能还是没有保证 <SwiperSlide><Swiper>直接 子元素。例如,如果你在 map 里面渲染的是你的自定义组件,需要确保你的自定义组件 内部 返回的是一个 <SwiperSlide> 包裹的内容。
    • 错误示范:
      // <MySlideItem> 组件内部可能没有 <SwiperSlide>
      <Swiper>{data.map(item => <MySlideItem data={item} />)}</Swiper>
      
    • 正确示范:
      // MySlideItem 组件定义
      const MySlideItem = ({ data }) => (
        <SwiperSlide> {/* SwiperSlide 在自定义组件内部 */}
          {/* ... 幻灯片内容 ... */}
        </SwiperSlide>
      );
      // 使用
      <Swiper>{data.map(item => <MySlideItem key={item.id} data={item} />)}</Swiper>
      
  2. CSS 或布局问题: 拆分后,父元素的 Flexbox 或 Grid 布局可能影响了 Swiper 或 SwiperSlide 的渲染。检查父容器 (<section>, Swiper 组件本身) 的样式,确保它们能正确容纳并显示轮播内容。特别是 Swiper 需要有确定的尺寸才能工作。你的代码里 <Swiper className='grow ...'> 依赖于父元素的高度,检查 section 是否真的有你期望的高度 (h-screen)。
  3. Key 问题: map 循环需要唯一的 key prop,通常用数据的 ID。如果 key 重复或不当,可能导致 React 渲染异常。
  4. Swiper 初始化问题: 虽然少见,但有时动态添加/复杂的 Slide 结构可能需要在数据加载或组件挂载后手动调用 Swiper 的 update() 方法。不过,使用官方 React 组件 (swiper/react) 通常能自动处理这些。

如果你坚持想拆分 Slide Item 组件(比如为了复用或整理代码),确保遵循上述的正确结构,并且把包含 Swiper 的父组件标记为 'use client';

// 再次强调分离后的客户端组件结构
'use client';

import { Swiper, SwiperSlide } from 'swiper/react';
// ... import swiper css and modules ...

// 单个幻灯片的组件 (可选拆分)
const ProjectSlide = ({ project }) => {
  return (
    // SwiperSlide 必须是最外层元素
    <SwiperSlide className='h-full flex-grow flex flex-row'>
        <div className='flex-grow w-full bg-slate-100 p-4 flex items-center justify-center'>
            {/* ... image ... */}
        </div>
        <div className='p-6 w-[500px] relative flex flex-col justify-start pb-8 gap-4'>
            {/* ... title, description ... */}
        </div>
    </SwiperSlide>
  );
};


// 包含 Swiper 的主轮播组件
const ProjectCarousel = ({ projects }) => {
  return (
    <Swiper
      // ... Swiper config ...
      className='grow relative flex flex-row swiper'
    >
      {projects.map((project, index) => (
        // 直接渲染 ProjectSlide, 它内部返回 SwiperSlide
        <ProjectSlide key={index} project={project} />
      ))}
    </Swiper>
  );
};

export default ProjectCarousel; // 这个文件整体是 'use client'

进阶技巧:动态加载客户端组件

如果 Swiper 库或者你的轮播组件很大,并且不是页面首屏必须立即展示的内容,可以考虑动态加载 (dynamic import) 这个客户端组件,减少初始页面的 JavaScript 包大小,提升加载性能。

在你的服务端页面组件 (ProjectsPage) 中,这样导入客户端轮播:

// app/projects/page.tsx

import dynamic from 'next/dynamic';
import { Pump } from '@basehub/react-pump';
import InnerNav from '@/components/InnerNav';

// 动态导入客户端轮播组件,禁止 SSR (因为 Swiper 依赖 window)
const ProjectCarousel = dynamic(() => import('./ProjectCarousel'), {
  ssr: false, // 非常重要,告诉 Next.js 不要在服务端渲染这个组件
  loading: () => <div className="flex justify-center items-center h-64">轮播加载中...</div> // 可选的加载状态
});

const ProjectsPage = async () => {
    // ... Pump 和数据获取逻辑不变 ...
            {async ([data], { loading }) => {
                // ... loading 和错误处理 ...
                const projectPosts = data.projects.items;

                return (
                    <section className='flex flex-col h-screen min-h-[400px]'>
                        <InnerNav />
                        {/* 这里渲染的是动态加载的组件 */}
                        <ProjectCarousel projects={projectPosts} />
                    </section>
                );
            }}
    // ...
};

export default ProjectsPage;

设置 ssr: false 很关键,因为 Swiper 需要浏览器环境的 window 对象,在 Node.js 服务端渲染时会报错。动态加载并禁用 SSR 可以解决这个问题,同时带来性能好处。

总而言之,处理 React Server Components 和需要客户端交互的库(如 Swiper.js)时,最好的办法就是把它们分开:用服务端组件取数据,然后把数据传给一个标记了 'use client' 的客户端组件来处理 UI 和交互。这样代码清晰,也符合框架的设计理念。