🎢 别再用 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;
}
为什么这很糟糕?
- 并发限制:浏览器对同一域名的并发请求有限制。如果你的幻灯片有 10 张图,瞬间发起 10 个请求,其他资源(字体、图标、API)就得排队喝西北风。
- 缓存策略难搞:
<img>标签有天然的懒加载(loading="lazy")和浏览器缓存优化,而 CSS 背景图往往需要你自己写一堆IntersectionObserver来模拟懒加载。 - 无障碍性(A11y):屏幕阅读器读不到
background-image的alt属性。视障用户只会听到“这是一张图”,却不知道图里是“老板在画饼”还是“产品又在改需求”。
✅ 正确姿势:<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>
亮点解析:
bg-gradient-to-t:Tailwind 的神来之笔。以前我们为了文字清晰,可能会切一张“底部黑色渐变”的 PNG 盖在图上。现在?一行 CSS 搞定,减少了一个 HTTP 请求,而且任意分辨率下都清晰无比。aspect-[2/1]:防止页面加载时的布局抖动(CLS)。先占好坑位,图片慢慢加载,用户体验丝般顺滑。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?”
好问题!
- 场景特化:shadcn 的
Carousel是通用的,它提供了CarouselContent,CarouselItem等原子组件,适合你需要高度定制布局的场景(比如卡片墙、多列滚动)。但对于标准的“全屏/通栏幻灯片”,直接封装一个SlideShow更简洁,调用方只需传入slides数组,无需关心内部结构。 - 依赖最小化:我们的组件只依赖
embla-carousel-react和embla-carousel-autoplay。没有多余的 Context 嵌套,没有复杂的 Props 透传。 - 掌控感:当你理解了
emblaApi的工作原理,你就掌握了所有基于 Embla 的组件。下次产品经理说“我要一个垂直滚动的、带缩略图预览的、支持鼠标拖拽的轮播图”,你只需要改几行配置,而不是去翻文档找“有没有这个 API”。
📝 最终 Checklist
- 轻量级:核心逻辑不到 100 行。
- 高性能:利用
<img>原生能力,CSS 渐变替代图片资源。 - 可访问性:完整的
alt标签和aria-label。 - 体验佳:丝滑的自动播放、悬停暂停、平滑的指示器动画。
- 易维护:清晰的 Hooks 分离,逻辑与视图解耦。
下次再遇到轮播图需求,别再复制粘贴那段十年前的 jQuery 代码了。试试 Embla,你会发现,原来写轮播图也可以是一种享受。
彩蛋: 如果你的产品经理说“这个轮播图能不能根据用户的心情自动切换图片?”,请把这篇文章甩给他,然后告诉他:“技术上可以实现,但需要先采集您的脑电波数据,请问预算够吗?” 😉
(本文代码基于 React + TypeScript + Tailwind CSS + Embla Carousel,如有雷同,那是英雄所见略同。)