用 React + Shadcn UI 打造高性能现代化轮播图组件

0 阅读5分钟

React + Shadcn UI 打造现代化轮播图组件

在实际项目开发中,从零实现一个功能完整、交互流畅且性能良好的轮播图组件成本较高。为此,我们采用 shadcn/ui 提供的 Carousel 基础结构,结合 Embla Carousel 的 Autoplay 插件,快速构建一个现代化、可维护、高性能的轮播图组件。
先看效果:

20260120-1616-10.8871372.gif 整个开发过程可分为三个清晰阶段:

  • 1. 搭建基础轮播结构:使用 shadcn/ui 的 Carousel 组件实现滑动容器
  • 2. 集成自动播放能力:通过 embla-carousel-autoplay 插件启用定时轮播,并支持用户交互
  • 3. 添加指示器(Dots)并实现双向同步:实时反映当前页码,支持点击跳转

初步配置

  • 安装轮播图组件 (shadcn-ui)
    shadcn 提供了 CarouselCarouselContentCarouselItem 一组组件。

    层次结构:

      Carousel
        CarouselContent
          CarouselItem
    
    npx i shadcn@latest add carousel -D
    
  • 引入第三方自动播放插件
    上述安装还不支持自动轮播功能,因此继续引入:

    pnpm i embla-carousel-autoplay
    

为什么需要使用第三方库?
虽然 shadcn 组件库提供了很多方便的组件,但不可能将所有功能都内置完善——这会导致体积过大、下载繁琐。shadcn 的设计哲学是“尽量简单,追求性能”,自动播放功能作为可选插件引入,正体现了其高度可定制性。


组件设计

// 选择使用第三方库
import Autoplay from 'embla-carousel-autoplay';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  type CarouselApi,
} from '@/components/ui/carousel';

export interface SlideData {
  id: number | string;
  image: string;
  title?: string;
}

interface SlideShowProps {
  slides: SlideData[];
  autoPlay?: boolean;
  autoPlayDelay?: number;
}

const SlideShow: React.FC<SlideShowProps> = ({
  slides,
  autoPlay = true,
  autoPlayDelay = 3000,
}) => {
  return (
    <div className="relative w-full">
      <Carousel className="w-full">
        <CarouselContent>
          {slides.map(({ id, image, title }, index) => (
            <CarouselItem key={id}>
              <div className="relative aspect-[16/9] w-full rounded-xl overflow-hidden">
                <img
                  src={image}
                  alt={title || `slide ${index + 1}`}
                  className="h-full w-full object-cover"
                />
                {title && (
                  <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-4 text-white">
                    <h3 className="text-lg font-bold">{title}</h3>
                  </div>
                )}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
      </Carousel>
    </div>
  );
};

export default SlideShow;
  • 定义轮播图中单个数据的接口

    export interface SlideData {
      id: number | string;
      image: string;
      title?: string;
    }
    
  • 定义轮播图组件的 props 接口

    interface SlideShowProps {
      slides: SlideData[];
      autoPlay?: boolean;       // 是否自动播放
      autoPlayDelay?: number;   // 自动播放间隔时间
    }
    

轮播图的结构划分
  • Carousel(轮播图容器)
    最外层的父级容器,也是整个组件的“大脑”。
    职责:

    • 负责轮播图的初始化
    • 向组件传递状态(翻到第几页、是否自动播放)
    • 处理事件
  • CarouselContent(轮播图内容容器)
    负责包含所有滑动项的长轨道,可以把它想象成一个电影胶卷——虽然你一次只能看到固定大小的视图,但实际上是一个非常长的轨道,每项都平铺在轨道上。

  • CarouselItem(轮播图单个项)
    每个轮播图项都被包裹在一个 CarouselItem 中。
    职责:

    • 负责显示单个轮播图项的内容
    • 控制每个项的显示
  • title(底部标题)

开发中需要注意:如果缺少了 CarouselItem,所有项将会平铺在页面中,所以三者缺一不可。


关键样式设计
  • overflow-hidden
    确保 CarouselContent 不会溢出其容器,从而隐藏超出部分——这里就是让图片适应组件的圆角。

    [ 容器 (圆角) ]
          |
          V
    /-----------\  <-- 图片超出的尖角被切掉了,顺应容器的圆角
    |  图片内容  |
    -----------/
    
  • aspect-[16/9]
    设置图片比例为 16:9。

  • object-cover
    将图片等比例缩放,超出部分裁剪。

  • bg-gradient-to-t from-black/60 to-transparent
    背景色渐变。底部标题在现代网页设计中很常见:

    • to-t 代表 to-top,即渐变方向从下往上
    • from-black/60 to-transparent 表示从 60% 深度的黑色过渡到透明

自动播放设置

embla-carousel-autoplay 引入 Autoplay 插件。

shadcn 引入的轮播图组件支持定制插件,我们可以在组件中传递插件选项来启用自动播放功能: tsx

// 用户交互时停止自动播放
const plugin = useRef(
  autoPlay ? Autoplay({ delay: autoPlayDelay, stopOnInteraction: true }) : null
);

关键点:为什么选择 useRef?——性能优化
useRef 可以在组件的整个生命周期内保持对插件实例的引用,而不会因为组件重新渲染而丢失。
而轮播图的自动播放功能 Autoplay 是耗性能的,使用 useRef 可以避免在每次渲染时都创建新的插件实例,从而提高性能。

Carousel 中添加事件:

<Carousel
  className="w-full"
  plugins={plugin.current ? [plugin.current] : []}
  opts={{ loop: true }}
  onMouseEnter={() => plugin.current?.stop()}
  onMouseLeave={() => plugin.current?.reset()}
>
  {/* ... */}
</Carousel>
  • plugins={plugin.current ? [plugin.current] : []}
    将插件注入轮播图组件。Autoplay 返回一个对象,包含方法如 stop()reset(),用于控制自动播放的启停。同时 Carousel 会在组件挂载时调用其 init 初始化函数,确保插件正常工作。
  • opts={{ loop: true }}
    开启循环播放。
  • onMouseEnter={() => plugin.current?.stop()}
    鼠标移入时停止自动播放。
  • onMouseLeave={() => plugin.current?.reset()}
    鼠标移出时重新开始自动播放。

轮播图的指示器

直到上面一步,轮播图的自动播放功能已经基本完成。但通常一个完整的轮播图还需包含指示器,用于提示用户当前所处位置及是否还有下一项可滑动。

要实现此功能,我们需要管理一个状态,用于记录当前轮播图的索引,并据此渲染指示器。

指示器代码:

<Carousel>
  {/* ... */}
</Carousel>

{/* 指示器 */}
<div className="absolute bottom-3 left-0 right-0 flex justify-center gap-2">
  {
    // i (index) 重要;_ 为占位符(不会用到,但 map 需要两个参数)
    slides.map((_, i) => (
      <button
        key={i}
        className={`h-2 w-2 rounded-full transition-all ${
          selectedIndex === i ? 'bg-white w-6' : 'bg-white/50'
        }`}
      />
    ))
  }
</div>

因为指示器在逻辑上独立于轮播图,要做到双向同步(轮播 ↔ 指示器),我们需要获取轮播图的 API:

  • 当轮播图滚动时 → 指示器更新
  • 当点击指示器时 → 轮播图跳转到对应位置

代码实现:

const [selectedIndex, setSelectedIndex] = useState<number>(0); // 当前选中的索引
const [api, setApi] = useState<CarouselApi | null>(null);     // 轮播图的 API

CarouselApi 可以当作轮播图的“遥控器”——拿到这个对象就掌握了轮播图的行为。
主要作用:

  • 控制播放
  • 获取状态
  • 监听事件

Carousel 组件中,通过 setApi 将 API 暴露出来。当 Carousel 组件挂载时,会调用 setApi 将 API 实例传递给我们:

setApi={setApi} // 向外暴露 API:将 setApi 作为 prop 传入,由 Carousel 初始化后回调

至此,我们实现了轮播图与指示器的双向通信。

继续编写同步逻辑:

useEffect(() => {
  if (!api) return;
  const onSelect = () => setSelectedIndex(api.selectedScrollSnap());
  api.on('select', onSelect);
  return () => {
    api.off('select', onSelect); // 清理监听器,防止内存泄漏
  };
}, [api]);
  • 通过 api.selectedScrollSnap() 获取当前选中项的索引。
  • 监听 'select' 事件:当轮播图滚动时,触发 onSelect 更新 selectedIndex
  • 注意:为避免内存泄漏,在组件卸载或更新时需移除事件监听。

最后,通过动态类名实现指示器高亮效果:

  • selectedIndex === i 时:bg-white w-6(高亮且拉长)
  • 否则:bg-white/50(半透明小圆点)

并添加 transition-all 实现平滑过渡。


最终代码

import {
  useState,
  useEffect,
  useRef, // 持久化存储对象,常用于 DOM 或插件引用
} from 'react';

// 选择使用第三方库
import Autoplay from 'embla-carousel-autoplay';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  type CarouselApi,
} from '@/components/ui/carousel';

export interface SlideData {
  id: number | string;
  image: string;
  title?: string;
}

interface SlideShowProps {
  slides: SlideData[];
  autoPlay?: boolean;
  autoPlayDelay?: number;
}

const SlideShow: React.FC<SlideShowProps> = ({
  slides,
  autoPlay = true,
  autoPlayDelay = 3000,
}) => {
  const [selectedIndex, setSelectedIndex] = useState<number>(0);
  const [api, setApi] = useState<CarouselApi | null>(null);

  useEffect(() => {
    if (!api) return;
    const onSelect = () => setSelectedIndex(api.selectedScrollSnap());
    api.on('select', onSelect);
    return () => {
      api.off('select', onSelect);
    };
  }, [api]);

  // Embla 插件模式
  // useRef 创建一个对象 { current: ... }
  const plugin = useRef(
    // Autoplay 耗性能,所以使用 useRef
    autoPlay ? Autoplay({ delay: autoPlayDelay, stopOnInteraction: true }) : null
  );

  return (
    <div className="relative w-full">
      <Carousel
        className="w-full"
        setApi={setApi} // 向外暴露 API setApi 作为参数传递 Carousel 进行初始化
        plugins={plugin.current ? [plugin.current] : []}
        opts={{ loop: true }}
        onMouseEnter={() => plugin.current?.stop()}
        onMouseLeave={() => plugin.current?.reset()}
      >
        <CarouselContent>
          {slides.map(({ id, image, title }, index) => (
            <CarouselItem key={id}>
              <div className="relative aspect-[16/9] w-full rounded-xl overflow-hidden">
                <img
                  src={image}
                  alt={title || `slide ${index + 1}`}
                  className="h-full w-full object-cover"
                />
                {title && (
                  <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-4 text-white">
                    <h3 className="text-lg font-bold">{title}</h3>
                  </div>
                )}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
      </Carousel>

      {/* 指示器 */}
      <div className="absolute bottom-3 left-0 right-0 flex justify-center gap-2">
        {
          // i (index) 重要;_ 为占位符
          slides.map((_, i) => (
            <button
              key={i}
              onClick={() => api?.scrollTo(i)} // 点击跳转(可选增强)
              className={`h-2 w-2 rounded-full transition-all ${
                selectedIndex === i ? 'bg-white w-6' : 'bg-white/50'
              }`}
            />
          ))
        }
      </div>
    </div>
  );
};

export default SlideShow;

总结

轮播图虽小,却是用户体验的重要一环。通过本次实践,我们不仅快速构建了一个功能完整、视觉现代的轮播组件,更深入运用了几项关键技术和工程思想:

  • 组合优于继承:shadcn/ui 本身不“大而全”,而是提供基础结构,通过插件(如 embla-carousel-autoplay)按需扩展,体现了现代 UI 库的模块化设计理念。
  • 性能意识:使用 useRef 持久化插件实例,避免重复创建;在 useEffect 中正确清理事件监听,防止内存泄漏——这些细节是高性能 React 应用的基石。
  • 状态同步与双向通信:借助 CarouselApi 实现轮播图与指示器的实时联动,展示了如何在不破坏组件封装的前提下,建立跨部分的状态协调机制。
  • 类型安全先行:通过 TypeScript 明确定义 SlideData 和 SlideShowProps,提升代码可读性与健壮性,为团队协作和长期维护打下基础。
  • 用户体验优先:自动播放可暂停、循环无缝衔接、指示器高亮反馈、底部渐变遮罩……每一个细节都服务于“自然、流畅、无感”的交互体验。

最终,我们收获的不仅是一个轮播组件,更是一种以用户为中心、以性能为底线、以组合为手段的现代前端开发思维。愿你在下一个组件中,也能如此从容地平衡效率与品质。