🎢 别再用 div 硬扛轮播图了:手搓一个比 shadcn 还“丝滑”的 Embla 幻灯片组件

3 阅读7分钟

🎢 别再用 div 硬扛轮播图了:手搓一个比 shadcn 还“丝滑”的 Embla 幻灯片组件

前言: 在这个“万物皆可 Carousel”的年代,如果你还在用原生的 setInterval 配合 transform: translateX 手写轮播图,那你的手指可能已经在键盘上磨出火星子了。

今天,我们不谈那些臃肿的 UI 库,也不谈 shadcn 那套虽然优雅但有时候显得有点“杀鸡用牛刀”的 Carousel 组合拳。我们要聊的是 Embla Carousel —— 那个轻得像羽毛、快得像闪电、还能让你觉得自己是个架构师的轻量级神器。

准备好了吗?我们要用不到 100 行代码,手搓一个带自动播放、无限循环、渐变遮罩和动态指示器的幻灯片组件。顺便聊聊为什么图片做背景可能是个“性能陷阱”。


🤔 为什么又是轮播图?

产品经理:“这个 Banner 图要能自动滚,还要能手动点,手机上要丝滑,电脑上要大气,最好还能在用户发呆的时候自动暂停……”

开发者(内心 OS):“好的收到,我去找个 50kb 的库引入一下。” 或者 开发者(头铁版):“不用库!我自己写!left: -${index * 100}% 走起!”

结果:

  • 手写版:在 iOS 上卡顿如老牛拉破车,触摸滑动时手指仿佛在抹玻璃。
  • 重型库版:打包体积多了 30kb,就为了个轮播图?

这时候,Embla Carousel 带着它的 useEmblaCarousel Hook 闪亮登场。它没有复杂的 DOM 结构,没有隐式的魔法,只有纯粹的数学和物理引擎。


🛠️ 核心架构:像搭乐高一样简单

我们今天的组件 SlideShow 核心逻辑非常清晰,就像剥洋葱(不会辣眼睛的那种):

1. 插件系统:自动播放的“心跳”

Embla 最骚的操作之一就是插件化。自动播放不是写死的,是个插件。

const plugins = autoplay
  ? [Autoplay({ 
      delay: interval, 
      stopOnInteraction: false, // 用户点了也不停?狠人!
      stopOnMouseEnter: true    // 鼠标放上去就停,贴心暖男
    })]
  : [];

看,这就好比给汽车装引擎。想要自动驾驶?插上 Autoplay 插件。想要手动挡?传个空数组 [] 完事。这种设计模式让代码的可读性简直感人。

2. Hook 的双剑合璧

const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: 'start' }, plugins);
  • emblaRef:挂在 DOM 上的“锚点”,告诉 Embla 在哪施展魔法。
  • emblaApi:这是真正的“遥控器”。想跳转?想监听?想销毁?全靠它。

知识点小贴士:这里体现了 React 中 Ref 持久化可变对象 的思想。emblaApi 不是状态(State),它不需要触发重渲染,它是命令式的控制器。

3. 状态同步:单向数据流的舞蹈

很多新手容易在这里晕倒:“我到底该信 useState 还是信 emblaApi?”

答案是:Embla 是真理,React State 只是它的影子。

const onSelect = useCallback((api: EmblaApi) => {
  const i = api.selectedScrollSnap(); // 获取真实索引
  setSelectedIndex(i);                // 同步给 React,为了渲染指示器
  onIndexChange?.(i);                 // 通知父组件
}, [onIndexChange]);

useEffect(() => {
  if (!emblaApi) return;
  // 监听 'select' 和 'reInit' 事件
  emblaApi.on('select', onSelect);
  emblaApi.on('reInit', onSelect);
  return () => {
    //  Cleanup!不然内存泄漏教你做人
    emblaApi.off('select', onSelect);
    emblaApi.off('reInit', onSelect);
  };
}, [emblaApi, onSelect]);

这段代码的精髓在于:不要试图去控制 Embla 的内部滚动,而是监听它的变化,然后更新 UI。 这就是所谓的“受控与非受控的完美结合”。


🎨 视觉魔法:拒绝 HTTP 请求风暴

来看看我们的 JSX 部分,这里藏着一个重要的性能优化点。

❌ 错误示范:CSS Background Image

很多教程会教你这样写:

.slide {
  background-image: url('huge-image.jpg');
  background-size: cover;
}

为什么这很糟糕?

  1. 并发限制:浏览器对同一域名的并发请求有限制。如果你的幻灯片有 10 张图,瞬间发起 10 个请求,其他资源(字体、图标、API)就得排队喝西北风。
  2. 缓存策略难搞<img> 标签有天然的懒加载(loading="lazy")和浏览器缓存优化,而 CSS 背景图往往需要你自己写一堆 IntersectionObserver 来模拟懒加载。
  3. 无障碍性(A11y):屏幕阅读器读不到 background-imagealt 属性。视障用户只会听到“这是一张图”,却不知道图里是“老板在画饼”还是“产品又在改需求”。

✅ 正确姿势:<img> 标签 + 绝对定位遮罩

我们的代码里是这样处理的:

<div className="relative aspect-[2/1] w-full">
  <img
    src={slide.image}
    alt={slide.title} // SEO  无障碍性 满分
    className="h-full w-full object-cover"
  />
  {/* 渐变遮罩层:用 CSS 梯度代替半透明黑底图片 */}
  {(slide.title || slide.subtitle) && (
    <div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/60 to-transparent p-4">
      <h3 className="mb-1 text-lg font-bold text-white">{slide.title}</h3>
      {slide.subtitle && <p className="text-sm text-white/90">{slide.subtitle}</p>}
    </div>
  )}
</div>

亮点解析

  1. bg-gradient-to-t:Tailwind 的神来之笔。以前我们为了文字清晰,可能会切一张“底部黑色渐变”的 PNG 盖在图上。现在?一行 CSS 搞定,减少了一个 HTTP 请求,而且任意分辨率下都清晰无比。
  2. aspect-[2/1]:防止页面加载时的布局抖动(CLS)。先占好坑位,图片慢慢加载,用户体验丝般顺滑。
  3. object-cover:无论图片是宽是窄,统统填满容器,不拉伸变形。

🕹️ 交互细节:指示器的“变脸”戏法

底部的圆点指示器不仅仅是装饰,它是用户安全感的来源。

<button
  key={i}
  className={cn(
    'h-1.5 w-2 rounded-full transition-all', // 重点:transition-all
    i === selectedIndex 
      ? 'w-6 bg-primary'   // 选中:变长,变色
      : 'w-1.5 bg-muted-foreground/40' // 未选中:短小,低调
  )}
  onClick={() => emblaApi?.scrollTo(i)}
/>

这里的 transition-all 是关键。当 selectedIndex 变化时,按钮的宽度从 w-2 变成 w-6。如果没有过渡动画,它会瞬间跳变,显得生硬。加上过渡后,它就像一条呼吸的小鱼,灵动可爱。

性能小知识width 的变化通常会触发浏览器的 Layout(重排),但在现代浏览器和简单的场景下,配合 will-change 或者像这样简单的宽度变化,通常也能跑得飞快。如果追求极致,可以用 scaleX 变换来模拟宽度变化,不过对于这种小控件,transition-all 足够用了。


🚀 总结:为什么我们要“重复造轮子”?

你可能问:“shadcn 不是已经有 Carousel 组件了吗?为什么还要自己封装一个 SlideShow?”

好问题!

  1. 场景特化:shadcn 的 Carousel 是通用的,它提供了 CarouselContent, CarouselItem 等原子组件,适合你需要高度定制布局的场景(比如卡片墙、多列滚动)。但对于标准的“全屏/通栏幻灯片”,直接封装一个 SlideShow 更简洁,调用方只需传入 slides 数组,无需关心内部结构。
  2. 依赖最小化:我们的组件只依赖 embla-carousel-reactembla-carousel-autoplay。没有多余的 Context 嵌套,没有复杂的 Props 透传。
  3. 掌控感:当你理解了 emblaApi 的工作原理,你就掌握了所有基于 Embla 的组件。下次产品经理说“我要一个垂直滚动的、带缩略图预览的、支持鼠标拖拽的轮播图”,你只需要改几行配置,而不是去翻文档找“有没有这个 API”。

📝 最终 Checklist

  • 轻量级:核心逻辑不到 100 行。
  • 高性能:利用 <img> 原生能力,CSS 渐变替代图片资源。
  • 可访问性:完整的 alt 标签和 aria-label
  • 体验佳:丝滑的自动播放、悬停暂停、平滑的指示器动画。
  • 易维护:清晰的 Hooks 分离,逻辑与视图解耦。

下次再遇到轮播图需求,别再复制粘贴那段十年前的 jQuery 代码了。试试 Embla,你会发现,原来写轮播图也可以是一种享受。


彩蛋: 如果你的产品经理说“这个轮播图能不能根据用户的心情自动切换图片?”,请把这篇文章甩给他,然后告诉他:“技术上可以实现,但需要先采集您的脑电波数据,请问预算够吗?” 😉

(本文代码基于 React + TypeScript + Tailwind CSS + Embla Carousel,如有雷同,那是英雄所见略同。)