@lehuan/swiper-loop-carousel
v1.4.0
Published
Swiper-based infinite loop carousel with thumbnail drag navigation, zoom, and multi-view modes
Maintainers
Readme
@lehuan/swiper-loop-carousel
基于 Swiper 的无限循环轮播组件,支持缩略图拖拽导航、键盘长按快速预览、滚轮/双指缩放、多视图模式切换。
针对万级图片量做了深度性能优化:Swiper Virtual 虚拟化、缩略图条虚拟化、增量缓存、内存自动回收。
截图
特性
- 无限循环 - 基于 Swiper Loop,首尾无缝衔接
- 缩略图条 - 拖拽导航、键盘长按三级加速、密度可调
- 多视图模式 - 单图 / 双图 / 三图,切换时一镜到底动画
- 缩放 - 滚轮缩放 + 双指缩放 + 拖动平移
- 万级图片 - Swiper Virtual + 缩略图虚拟化 + 增量缓存 + 内存回收
- 分页加载 - 内置
usePaginatedImageshook,滑动到末尾自动加载 - 国际化 - 内置中/英文,支持自定义覆盖
- 受控/非受控 - 两种打开模式,灵活集成
- 设置持久化 - 视图模式、缩略图密度、滚轮功能可选持久化到 localStorage
安装
npm install @lehuan/swiper-loop-carousel swiper motionTailwind CSS 配置
必须配置:本组件使用 Tailwind CSS utility class 实现样式,需要让消费者项目的 Tailwind 扫描到本包的编译产物。
Tailwind v3
在 tailwind.config.js 的 content 中追加包的路径:
module.exports = {
content: [
"./src/**/*.{ts,tsx}",
"./node_modules/@lehuan/swiper-loop-carousel/dist/**/*.{js,cjs}",
],
}Tailwind v4
在入口 CSS 文件中添加 @source 指令:
@import "tailwindcss";
@source "../node_modules/@lehuan/swiper-loop-carousel/";为什么需要这一步?
本组件所有样式都通过 Tailwind utility class(如 bg-black/90、text-white、rounded-xl 等)实现。这些 class name 在编译产物(dist/*.{js,cjs})中仍然是字符串字面量,Tailwind 的 content 扫描器可以解析它们并生成对应的 CSS。因此无需从包中额外导入 CSS 文件,也不会与项目的 Tailwind 配置冲突。
快速开始
基础用法(非受控模式)
import { SwiperLoopCarousel } from "@lehuan/swiper-loop-carousel";
import type { GalleryImage } from "@lehuan/swiper-loop-carousel";
const images: GalleryImage[] = [
{ id: 1, src: "/img1.jpg", thumbSrc: "/thumb1.jpg", alt: "Photo 1" },
{ id: 2, src: "/img2.jpg", thumbSrc: "/thumb2.jpg", alt: "Photo 2" },
// ...
];
function Gallery() {
return <SwiperLoopCarousel images={images} />;
}受控模式
function Gallery() {
const [isOpen, setIsOpen] = useState(false);
const [idx, setIdx] = useState(0);
return (
<>
<button onClick={() => { setIdx(0); setIsOpen(true); }}>打开轮播</button>
<SwiperLoopCarousel
images={images}
isOpen={isOpen}
initialIndex={idx}
onClose={() => setIsOpen(false)}
/>
</>
);
}万级图片 + 分页加载
import {
SwiperLoopCarousel,
CarouselI18nProvider,
usePaginatedImages,
} from "@lehuan/swiper-loop-carousel";
const allImages: GalleryImage[] = generateImages(10000);
function MassiveGallery() {
const { images, loadMore, hasMore, total } = usePaginatedImages(allImages, 200);
const [isOpen, setIsOpen] = useState(false);
const [idx, setIdx] = useState(0);
return (
<>
<button onClick={() => { setIdx(0); setIsOpen(true); }}>打开</button>
<SwiperLoopCarousel
images={images}
onNeedMore={loadMore}
hasMore={hasMore}
total={total} // 覆盖层显示 "3/10000" 而非 "3/200"
isOpen={isOpen}
initialIndex={idx}
onClose={() => setIsOpen(false)}
/>
</>
);
}国际化
import { CarouselI18nProvider } from "@lehuan/swiper-loop-carousel";
<CarouselI18nProvider lang="en">
<SwiperLoopCarousel images={images} />
</CarouselI18nProvider>
// 自定义覆盖
<CarouselI18nProvider lang="zh" overrides={{ close: "返回", prev: "上一页" }}>
<SwiperLoopCarousel images={images} />
</CarouselI18nProvider>设置持久化
开启后,视图模式、缩略图密度、滚轮功能会保存到浏览器 localStorage,关闭组件后重新打开(或打开其他相同组件)会复用同一套配置。
// 使用默认存储键(所有实例共享配置)
<SwiperLoopCarousel images={images} persistSettings />
// 使用自定义存储键(可按需隔离或共享)
<SwiperLoopCarousel images={images} persistSettings="my-gallery-settings" />API
SwiperLoopCarousel Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| images | GalleryImage[] | 必填 | 图片数据数组 |
| isOpen | boolean | undefined | 受控模式:是否打开。undefined 时使用内部非受控状态 |
| initialIndex | number | 0 | 受控模式:打开时定位到第几张图片 |
| onClose | () => void | - | 受控模式:关闭回调 |
| total | number | images.length | 图片总数(含未加载),用于覆盖层显示 "3/10000" |
| onNeedMore | () => void | - | 分页加载:滑动到接近末尾时触发 |
| hasMore | boolean | false | 是否还有更多图片可加载 |
| renderOverlay | (props) => ReactNode | - | 自定义覆盖层,替换默认的序号/alt/尺寸信息 |
| renderToolbar | (props) => ReactNode | - | 自定义工具栏,整体替换默认工具栏 |
| extraToolbarItems | ReactNode | - | 追加到默认工具栏右侧的额外内容 |
| extraOverlayContent | (props) => ReactNode | - | 追加到覆盖层区域的额外内容 |
| onDownload | (index: number) => void | - | 下载回调,传入后覆盖层显示下载按钮 |
| persistSettings | boolean \| string | undefined | 是否持久化设置到 localStorage。true 使用默认存储键,string 使用自定义存储键,undefined/false 不持久化 |
GalleryImage
interface GalleryImage {
id: number;
src: string; // 原图 URL
thumbSrc: string; // 缩略图 URL
alt: string; // 图片描述
width?: number; // 原始宽度(覆盖层显示)
height?: number; // 原始高度(覆盖层显示)
fileSize?: number; // 文件大小字节数(覆盖层显示)
sizeLabel?: string; // 自定义文件大小文本,优先于 fileSize
dimensions?: string;// 自定义尺寸文本,优先于 width×height
}Hooks
usePaginatedImages(allImages, pageSize?)
分页加载图片数据,避免一次性处理过多数据。
const { images, loadMore, hasMore, total, loaded } = usePaginatedImages(allImages, 200);| 返回值 | 类型 | 说明 |
|--------|------|------|
| images | GalleryImage[] | 当前已加载的图片切片 |
| loadMore | () => void | 加载下一批 |
| hasMore | boolean | 是否还有更多 |
| total | number | 全量图片总数 |
| loaded | number | 已加载数量 |
useImagePreloader(images)
图片预加载,获取原始尺寸。
const preloader = useImagePreloader(images);
preloader.preload([0, 1, 2]); // 预加载指定索引
preloader.preloadAround(5); // 预加载中心 ±3
preloader.isLoaded(0); // 是否已加载
preloader.getDims(0); // 获取 { w, h }
await preloader.waitFor(0); // 等待加载完成useWindowWidth()
响应式窗口宽度,150ms 防抖。
useLazyVisibleSet(itemCount)
基于 IntersectionObserver 的懒加载可见集合。
性能优化策略
万级图片场景下的多层优化:
| 层级 | 策略 | 效果 |
|------|------|------|
| 数据层 | usePaginatedImages 分页加载 | images.length 从 200 起步,按需增长 |
| Swiper 层 | Virtual 模式 (n > 20) | DOM 中只有 ~10 个 slide 节点 |
| React 层 | 增量缓存 + 可见范围替换 | 每次切图只创建 ~11 个 React Element |
| 缩略图层 | 虚拟化 + 相对偏移定位 | DOM 中 ~40 个缩略图,容器宽度恒定 ~2600px |
| MotionValue | 懒创建 + 自动回收 | 按需创建,远离当前索引的自动清理 |
| Swiper props | useMemo 缓存 | 避免 modules/virtual 配置变化触发重渲染 |
依赖
| 依赖 | 版本 | 说明 | |------|------|------| | react | ^18 || ^19 | Peer | | react-dom | ^18 || ^19 | Peer | | swiper | ^12 | Peer | | motion | ^11 || ^12 | Peer | | tailwindcss | ^3 || ^4 | Peer (可选) |
