从零打造 AI 全栈应用(四):别小看幻灯片,一个 Carousel 组件背后的工程化与性能思维

27 阅读4分钟

在上一篇文章 《从零打造 AI 全栈应用(三):一个 BackToTop 组件背后的工程化与性能思维》中,我们拆解了一个看似简单却极易被忽视的组件,并通过它讨论了事件监听、性能优化与工程边界的问题。

但真实项目中,工程能力往往体现在细节

这篇文章,我们从一个再常见不过的组件 —— 幻灯片 / Carousel 入手,聊聊它背后隐藏的组件设计、状态管理、性能优化和工程化取舍。


一、为什么一个幻灯片组件值得单独写一篇?

很多同学会觉得:

幻灯片不就是个 UI 组件吗?能滑就行。

但在真实项目(尤其是首页、活动页、AI 产品的内容入口)中,它往往意味着:

  • 页面首屏核心组件
  • 高频渲染、长时间驻留
  • 涉及自动播放、交互、状态同步
  • 很容易成为性能与可维护性的隐患

所以我们这次的目标不是「写一个能跑的轮播图」,而是:

写一个工程上站得住脚的 Carousel 组件


二、技术选型:为什么是 shadcn/ui + Embla?

1️⃣ shadcn/ui 的 Carousel 设计哲学

shadcn/ui 提供的并不是一个“黑盒组件”,而是一组组合式组件

  • Carousel
  • CarouselContent
  • CarouselItem

特点很明显:

  • 结构清晰,层次分明
  • 不强绑定样式
  • 底层基于 Embla Carousel,性能成熟

本质上,它更像是一个“轮播能力的外壳”,而不是一个定死的 UI。

这点非常重要 —— 可定制性 = 长期可维护性


2️⃣ 自动播放为什么用插件,而不是自己写 setInterval

自动播放我们选择的是:

import AutoPlay from 'embla-carousel-autoplay'

原因很简单:

  • 和 Embla API 深度适配
  • 内部处理了生命周期、交互中断
  • 不需要自己处理定时器清理

工程中一个重要原则:
能用成熟插件解决的,不要重复造轮子


三、组件设计:从 Props 到职责边界

1️⃣ 数据结构设计

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

几个刻意的设计点:

  • id 不强制 number,兼容服务端数据
  • title 可选,UI 自动适配
  • 组件只关心展示所需的最小数据

不要把组件变成业务垃圾桶。


2️⃣ 组件 Props:只暴露真正需要的能力

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

这里没有:

  • 当前 index 的受控状态
  • 复杂回调

原因是:

这是一个偏展示型组件,而不是业务中枢


四、状态管理:selectedIndex 为什么是私有状态?

const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [api, setApi] = useState<CarouselApi | null>(null);

关键点:

  • selectedIndex 不从外部传入
  • 通过 CarouselApi 与底层同步
useEffect(() => {
  if (!api) return;
  setSelectedIndex(api.selectedScrollSnap());

  const onSelect = () => {
    setSelectedIndex(api.selectedScrollSnap());
  };

  api.on('select', onSelect);
  return () => api.off('select', onSelect);
}, [api]);

这段代码的工程意义:

  • UI 状态 来源单一(底层 carousel)
  • React 状态只是一个映射
  • 避免“双向状态不同步”

面试常考:如何避免状态源混乱?
这个例子非常典型。


五、自动播放:为什么一定要用 useRef

const plugin = useRef(
  autoPlay
    ? AutoPlay({ delay: autoPlayDelay, stopOnInteraction: true })
    : null
);

如果不用 useRef 会发生什么?

  • 每次 render 都创建新插件实例
  • 自动播放被反复重置
  • 性能抖动、行为不可控

useRef 的本质价值:

在 React 渲染体系外,持久化一个可变对象

这是一个非常典型、非常“面试级别”的用法。


六、交互细节:为什么鼠标移入要暂停?

onMouseEnter={() => plugin.current?.stop()}
onMouseLeave={() => plugin.current?.reset()}

这是一个看似很小,但体验影响极大的细节:

  • 用户正在看内容
  • 自动切走 = 强干扰

好的组件,不是功能多,而是尊重用户行为


七、指示点设计:状态驱动,而不是 DOM 操作

slides.map((_, i) => (
  <button
    key={i}
    className={`h-2 w-2 rounded-full transition-all
      ${selectedIndex === i ? 'bg-white w-6' : 'bg-white/50'}`}
  />
));

几个工程要点:

  • 循环渲染,不操作 DOM
  • 动态类名完全由状态驱动
  • transition-all 提供平滑过渡

React 组件的本质:
UI = f(state)


八、CSS 与性能:渐变背景为什么优于图片?

bg-gradient-to-t from-black/60 to-transparent

相比图片背景,渐变的优势:

  • 不需要额外 HTTP 请求
  • 减少并发资源下载
  • GPU 友好
  • 更容易适配深浅色

在高性能场景下,
能不用图片,就不用图片


九、总结:一个小组件,体现哪些工程能力?

回看这个 Carousel,你至少可以聊清楚:

  • 组件拆分与职责边界
  • 第三方库的正确使用方式
  • React 状态与外部状态同步
  • useRef 的真实应用场景
  • UI 细节与性能取舍

面试官真正想看的,不是你会不会写轮播图,
而是你为什么这么写