1. 轮播组件的价值与需求拆解:为什么值得认真写一次
轮播图大概是整个前端领域里被复现率最高的组件,没有之一。不管你是写后端偶尔碰一下前端,还是在业务团队里天天跟营销页打交道,几乎都逃不过“做一个轮播”或者“改一个轮播”的需求。但正因为这个东西太常见了,很多人反而不愿意在上面花心思,随手find一个插件,或者写一个硬编码换图的函数,能用就行。等真正出问题的时候——比如快速点击导致动画错乱,比如移动端手势一顿操作之后指示器对不上了,比如数据一变视图不刷新——才意识到一个轮播组件远没有看上去那么简单。
这次我记录的,是我从数据驱动角度重新实现的一个JavaScript轮播组件。标题里“数据驱动”和“交互优化”其实是两件事:前者管的是组件的骨架,数据怎么来、怎么变、UI怎么跟着动;后者管的是血肉,用户怎么操作、动画怎么过渡、性能怎么保证。这篇文章不会只给代码,我更想讲清楚每一步为什么这么设计,以及我在实测中踩过的坑。适合正在手写前端组件、想理解状态驱动UI的读者,也适合那些项目里已经用了某些轮播插件但总被诡异行为折腾的人——你至少能知道问题出在哪儿。
先把需求拆开看,一个稍微像样点的轮播组件,至少包含下面这些能力:
- 根据数据列表渲染轮播项,数据变化时视图同步更新
- 上一张、下一张、跳转到指定索引
- 自动播放,鼠标悬停时暂停,离开后恢复
- 移动端滑动、桌面端拖拽,两种操作统一处理
- 指示器同步高亮,支持点击切换
- 无限循环,切换过程动画平滑,不能卡顿
- 合适的无障碍支持,键盘用户也能操作
这些需求单拎出来哪个都不算难,但合在一起,如果没有一个清晰的架构,很容易写着写着就变成一堆互相纠缠的DOM操作。这也是我推荐用数据驱动方式来写它的根本原因。
1.1 命令式实现为什么会越改越乱
很多人写轮播的第一步是这样:拿到一个列表,然后用JavaScript往容器里塞图片,再监听按钮click事件,切换容器的left值或者transform值。代码大概长这样:
javascript复制let index = 0;
const slides = document.querySelectorAll('.slide');
document.querySelector('.next-btn').addEventListener('click', () => {
index++;
if (index >= slides.length) index = 0;
slider.style.transform = `translateX(-${index * 300}px)`;
});
这段代码在小demo里跑得挺好的,但一旦业务复杂起来就难受了。数据不是写死的,可能是接口返回的;切换逻辑不止一处触发,按钮、指示器、自动播放、触摸手势都要改同一个index,每一处都得手动维护。更麻烦的是,如果数据中间插入一项,所有DOM元素对应的索引全变了,你写的那些“第几个元素”和“第几张图”的对应关系就会错位。
于是你开始加各种变量缓存、各种手动同步,最后变成几百行的意大利面条。这不是你代码能力不行,是模式选错了。
1.2 数据驱动到底“驱动”的是什么
数据驱动的核心其实一句话:视图是状态的函数。组件内部只维护一个状态对象,所有用户看到的UI,都是从状态里计算出来的。用户点了“下一张”,不是直接去改DOM,而是先改状态currentIndex,然后由渲染函数把状态“翻译”成DOM的实际变化。
这样做的好处有三个,都是我实测下来感受很深的:
第一,逻辑集中。不管用户是点按钮、划屏幕还是自动播放触发,最终做的事情都一样——改状态。你只需要在一个地方维护“状态怎么变”的规则。
第二,数据联动简单。接口返回了新列表,你只要把状态里的items替换掉,渲染函数自动处理DOM增删,不需要手工去找“第几个卡片要更新”。
第三,可预测、好调试。任何时候出现问题,打开控制台打印一下状态对象,就知道界面为什么长这样。而不是在一堆style.left=xxx中间猜哪一行改错了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据驱动渲染:状态、模板与视图的同步策略
我这次实现没有用任何框架,纯原生JavaScript,因为轮播作为一个组件,不该绑定某个框架。但设计思路跟React、Vue那一套是一致的:状态(State)→ 渲染函数(Render)→ 视图(View)。理解了这个,你把它迁移到任何框架里都会很轻松。
2.1 状态对象的设计:哪些数据该进state
不是所有数据都适合塞进状态,塞多了每次渲染都被迫执行,塞少了又会出现“两处数据不一致”的问题。我最终保留的状态字段就这些:
javascript复制const state = {
// 数据源:轮播项的列表,每一项包含图片、标题、链接等
items: [],
// 当前真实索引,0代表第一项
current: 0,
// 是否正在播放动画,用于拦截高频切换
isAnimating: false,
// 是否处于拖拽状态
dragging: false,
// 自动播放定时器句柄
timer: null
};
这里有一个容易忽略的设计细节:isAnimating和dragging为什么也放进了state?因为它们会直接影响UI渲染。比如动画过程中,指示器要显示禁用态;拖拽过程中,过渡动画要临时关闭,否则手指拖动的时候卡片会“弹”回去。它们本质上是UI状态的组成部分,放进state是合理的。
2.2 渲染函数:状态翻译成DOM的唯一出口
我写了一个独立的render()函数,它的职责很纯粹:读取state,更新DOM。
javascript复制function render() {
const viewportWidth = slider.clientWidth;
// 由于做了首尾克隆,真实渲染位置要整体偏移一位
const offset = -(state.current + 1) * viewportWidth;
track.style.transform = `translateX(${offset}px)`;
// 指示器高亮同步
indicators.forEach((ind, index) => {
ind.classList.toggle('active', index === state.current);
});
// 当前项的可访问性标记
slideElements.forEach((slide, index) => {
slide.setAttribute('aria-hidden', index === state.current ? 'false' : 'true');
});
}
这里的viewportWidth每次渲染时用clientWidth实时读取,而不是用某个缓存的宽度。原因很简单:轮播容器宽度可能因为窗口resize而变化,如果缓存一个写死的宽度,窗口一变所有位置全错。实时读取会触发一次layout,但在轮播这种规模的操作上,性能损失可以忽略,换来的是自适应能力,值了。
2.3 数据更新后如何高效驱动:列表Diff与重建策略
数据变化时,最简单粗暴的做法是整个track内部全部重新渲染。但这样有两个问题:一是如果轮播项里有图片,重建过程中图片可能重新加载或闪烁;二是如果用户正好在拖拽中,整个DOM被换掉,拖拽状态就丢了。
我采用的策略是分情况处理:
- 列表长度没变,只是某一项的data更新:找到对应的slide元素,只更新该元素内的文本和图片链接。
- 列表长度变化:重建轨道内的所有slide,同时重建首尾克隆和指示器。虽然成本高,但数据增删的场景本来就不频繁,可以接受。
javascript复制function updateItems(newItems) {
state.items = newItems;
state.current = 0;
stopAutoPlay();
buildSlides(); // 重建所有slide(含首尾克隆)
buildIndicators();
resetTrackPosition(); // 无动画回归初始位置
render();
startAutoPlay();
}
这里特别要注意resetTrackPosition的执行顺序。因为buildSlides之后轨道是全新的,还没有transform值,如果你不先设一个无动画的初始位置,用户会看到从旧位置闪跳到新位置的过程。正确做法是先把transition禁掉,设好位置,再强制触发一次重绘,最后恢复transition。这也是整个组件里最容易踩坑的地方之一,后面实操避坑里我会再展开说。
3. 核心交互逻辑:自动播放、切页与无限循环的实现
数据驱动框架搭好了,接下来是轮播真正的“灵魂”——切换逻辑。这里有两个大问题要处理好:一是无限循环怎么实现得自然不跳戏,二是自动播放怎么兼顾性能和用户体验。
3.1 无限循环的两种实现方案对比
无限循环有两种主流实现,我先说结论:纯JS索引取模的方案只适合极简单的场景,生产环境我更推荐首尾克隆。
方案一:索引取模。比如总共5张图,在索引0时点“上一张”,直接跳到索引4,然后动画从第1张“倒放”回第5张。看着没毛病?实际体验是:你从第一张往左边滑,结果动图片从左边的方向“倒着”飞回来,视觉上是一个很长距离的反向平移,特别突兀。如果你的轮播是竖屏Banner,一眼就被看出来是硬切。
方案二:首尾克隆。在轨道开头复制一份最后一张图,在结尾复制一份第一张图。真实数据5张,DOM里实际有7张。当你从第5张继续向后滑,会滑到第6个DOM元素(其实是一张复制出来的第1张图),过渡动画结束后,立刻关闭动画、把位置瞬移到第1个真实DOM元素的位置。由于第6个和第1个元素长得一模一样,用户视觉上感觉是循环了,但实际上你只是做了一次“偷偷的瞬移”。
我最终选择的是方案二,切换逻辑如下:
javascript复制function goTo(index, direction = 'next') {
// 防止动画过程中重复触发
if (state.isAnimating) return;
const lastIndex = state.items.length - 1;
if (index < 0 || index > lastIndex) return;
state.isAnimating = true;
const prev = state.current;
state.current = index;
// 处理从首尾跨越的场景
if (prev === lastIndex && index === 0) {
// 先滑到尾部克隆的第一张
moveTo(index + 1, true);
track.addEventListener('transitionend', jumpToRealStart, { once: true });
} else if (prev === 0 && index === lastIndex) {
// 先滑到头部克隆的最后一张
moveTo(index - 1, true);
track.addEventListener('transitionend', () => {
disableTransition();
moveTo(index + 1, false);
forceReflow();
enableTransition();
state.isAnimating = false;
}, { once: true });
} else {
moveTo(index + 1, true);
track.addEventListener('transitionend', () => {
state.isAnimating = false;
}, { once: true });
}
render();
}
moveTo里面的+1就是因为头部多了一张克隆项,真实索引为0的项在DOM里是第1个位置。搞懂这个偏移,整个循环逻辑就通了一半。
3.2 自动播放的正确姿势:暂停、恢复与防叠buff
自动播放本身很简单,setInterval每隔N秒调用一次goTo(next)就行。但有几个细节不做的话,实际用起来会非常难受:
细节一:页签不可见时暂停播放。用户切到其他标签页,浏览器会限制定时器频率,回来的时候可能跳了好几页。用visibilitychange事件监听,隐藏时stopAutoPlay,可见时重新startAutoPlay。
细节二:悬停暂停不能只监听容器本身。很多轮播是等鼠标移入容器才暂停,移出才恢复。但如果你的轮播里有很多链接,用户鼠标在链接和容器之间移动时,mouseenter/mouseleave一般不重复触发,基本没问题。真正需要注意的是,鼠标移出后不要在mouseleave里立刻恢复,否则用户刚点完按钮鼠标还停在按钮上,又快速移动了一下,可能触发恢复—暂停—恢复的抖动。我习惯加一个200ms的延迟恢复,实测体验好很多。
细节三:定时器不能重复创建。startAutoPlay里先clearInterval再setInterval,这是防止无限循环中快速“暂停-恢复”导致多个定时器叠buff的保险。这个习惯不仅轮播里要用,任何涉及定时器重入的场景都建议这么做。
javascript复制function startAutoPlay() {
stopAutoPlay();
if (!state.items.length) return;
state.timer = setInterval(() => {
const next = (state.current + 1) % state.items.length;
goTo(next, 'next');
}, 4000);
}
function stopAutoPlay() {
clearInterval(state.timer);
state.timer = null;
}
3.3 初始化位置与克隆项的时序问题
初始化时,我先把所有slide(含克隆)挂到track上,然后关闭过渡,把轨道定位到-(current + 1) * viewportWidth。这之后才能开启过渡。如果忘了关闭过渡,页面加载时用户会看到整个轨道从0滑到正确位置,像是一个动画故障。
类似的时序问题也出现在窗口大小变化时。resize过程中,旧的移动距离是基于旧宽度计算的,新宽度下位置会偏。我的处理是:resize时先禁用过渡、重新计算位置、再启用过渡。这一步不需要动画,因为用户只是在调窗口,不该看到卡片“滑动”。
4. 手势与跨端交互:触摸、拖拽、节流与响应式适配
跨端交互永远是轮播组件里最“惊喜”的部分。你以为手机上的触摸滑动和桌面端的鼠标点击是两码事,实际上用Pointer Events统一处理之后,代码量能省三分之一,而且行为更一致。
4.1 用Pointer Events统一鼠标和触摸
我最早只用touchstart/touchmove/touchend,结果桌面端外接触屏或者混合设备上总会出问题。Pointer Events的好处是鼠标、触摸、触控笔都归一到一套事件体系里。实现上,核心是记录起点、计算位移、最后判断是否翻页。
javascript复制let startX = 0;
let startY = 0;
let isDragging = false;
let dragDistance = 0;
track.addEventListener('pointerdown', (e) => {
if (state.isAnimating || state.items.length <= 1) return;
startX = e.clientX;
startY = e.clientY;
isDragging = true;
dragDistance = 0;
state.dragging = true;
stopAutoPlay();
// 拖拽过程中关闭过渡,让手指指哪打哪
track.style.transition = 'none';
track.setPointerCapture(e.pointerId);
});
track.addEventListener('pointermove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// 如果是纵向滑动意图明显,就不抢横向轮播
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 10) {
cancelDrag();
return;
}
e.preventDefault();
dragDistance = dx;
updateDragPosition(dx);
});
track.addEventListener('pointerup', () => {
if (!isDragging) return;
isDragging = false;
state.dragging = false;
track.style.transition = '';
finishDrag();
startAutoPlay();
});
这里有几个关键点。setPointerCapture很重要,它把后续的pointer事件强制绑定到track元素上,即使手指滑出了track范围,事件也不会丢失,避免拖到一半松手却没触发pointerup的bug。
纵向滑动意图的判断也是必须的。轮播通常嵌在页面里,用户上下滚动页面时,手指一般不是绝对垂直的,会有一点横向偏移。如果不做方向判断,用户滚动页面时轮播会被“拽”走。我的经验是:先判断位移绝对值,只有当横向位移大于纵向位移时才让轮播接管手势。一旦判定为纵向滚动,立刻取消拖拽,把控制权还给浏览器。
4.2 拖拽位移计算与松手判定阈值
拖拽过程中的实时位置,是在当前索引对应的基准位置上加上手指移动的偏移量。但有个边界问题:拖到第一张还要继续往右拖,或者拖到最后一张还要继续往左拖,该怎么办?
我的做法是加阻尼系数:越界后,继续拖动的位移只按0.3倍计算,给用户一种“拖不动”的物理反馈。
javascript复制function updateDragPosition(dx) {
const baseOffset = -(state.current + 1) * viewportWidth;
let extra = dx;
const isOverLeft = state.current === 0 && dx > 0;
const isOverRight = state.current === state.items.length - 1 && dx < 0;
if (isOverLeft || isOverRight) {
extra = dx * 0.3;
}
track.style.transform = `translateX(${baseOffset + extra}px)`;
}
松手时的判定阈值,我定为容器宽度的1/3,同时结合手指松开的瞬间速度。位移超过阈值就切下一页,没超过就回弹。瞬时速度的判断可以用最后一段移动距离和时间差估算,也可以简化成只看位移。我项目中用的是位移+速度结合,核心代码:
javascript复制function finishDrag() {
const threshold = viewportWidth / 3;
if (Math.abs(dragDistance) >= threshold) {
if (dragDistance < 0) {
goTo((state.current + 1) % state.items.length, 'next');
} else {
goTo((state.current - 1 + state.items.length) % state.items.length, 'prev');
}
} else {
// 回弹到当前位置
moveTo(state.current + 1, true);
state.isAnimating = false;
}
}
如果只做位移判断,用户慢悠悠拖了40%又松手,会觉得“还行但不够果断”;如果加上拖拽速度,快速滑动不到1/3也能触发翻页,手感会灵敏很多,接近原生App的体验。
4.3 响应式适配:宽度变化时不能只靠resize
轮播容器宽度由CSS决定,可能是百分比、媒体查询或者flex布局的结果。监听window.resize是最常见的办法,但有两个问题:一是移动端浏览器地址栏收起、弹起时也会触发resize,二是不管容器实际宽度多少,只要窗口变了一点点,就会触发你的事件。
我建议用ResizeObserver监听容器自身,而不是window。这样即使容器因为旁边元素折叠了而变宽变窄,也能准确感知,还不会有多余触发。
javascript复制const resizeObserver = new ResizeObserver(() => {
disableTransition();
updateTrackPosition();
enableTransition();
});
resizeObserver.observe(slider);
这里updateTrackPosition内部要读取新的clientWidth并重新计算transform。由于observer回调是异步的,而且可能在动画中途触发,我还会顺带检查state.isAnimating,如果正在动画就等它结束再处理,否则会出现动画被截断的闪跳。
5. 细节优化:动效、性能、无障碍与环境适配
轮播的核心功能做完之后,真正让它“好用”的其实是细节。这一节的内容,很多是我在项目上线后收到反馈才补上的,属于那种不做也能用、做了立刻提升体验的隐藏分。
5.1 动画性能:为什么只用transform和opacity
轮播切换动画的直观做法是控制left或者margin-left,但那会触发布局计算。更常见的隐藏性能问题是:轨道里每一张slide如果都设置width: 100%、flex-shrink: 0,浏览器在动画过程中会反复计算这些百分比。实测下来,对现代浏览器影响不大,但在低端Android机型上,动画会出现明显的掉帧。
我优化后的CSS约束大概是这样:
css复制.carousel-viewport {
overflow: hidden;
touch-action: pan-y;
}
.carousel-track {
display: flex;
will-change: transform;
}
.carousel-slide {
flex: 0 0 100%;
width: 100%;
transition: opacity 0.3s ease;
}
touch-action: pan-y单独拎出来说。它告诉浏览器:这个元素允许纵向滚动手势,但不允许横向滚动。这能避免移动端浏览器在某些情况下抢走横向触摸事件,导致我们自己的pointermove收不到。不加这一句,你会发现移动端拖拽时断断续续不跟手。
5.2 高频事件处理的节流策略
拖拽move事件触发频率很高,每秒能触发几十次甚至上百次。如果每次move都去操作DOM甚至触发rendered逻辑,肯定会有多余的性能开销。我的做法是双保险:
第一层,拖拽中只操作track.style.transform,不调用完整render()。因为拖拽中的UI状态是临时的,不需要同步更新指示器、不需要处理ARIA,等松手后再统一走一遍render()。
第二层,用requestAnimationFrame做帧级节流,保证一次浏览器重绘周期内最多只更新一次transform:
javascript复制let rafId = null;
track.addEventListener('pointermove', (e) => {
if (!isDragging) return;
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateDragPosition(e.clientX - startX);
rafId = null;
});
});
safe:e在回调里被引用时可能已经是下一次事件的对象了,所以要在进入requestAnimationFrame`之前把需要的数据先存下来。我代码里这样写:
javascript复制track.addEventListener('pointermove', (e) => {
if (!isDragging || rafId) return;
const deltaX = e.clientX - startX;
rafId = requestAnimationFrame(() => {
updateDragPosition(deltaX);
rafId = null;
});
});
这里deltaX在闭包里被捕获,不会因为事件对象复用而出错。这个细节不处理好,拖拽偶尔会“飘”一下。
5.3 无障碍支持:键盘操作与ARIA标记
轮播对无障碍的支持在国内项目里很少被重视,但想做成一个合格的组件,这块躲不掉。至少要做三件事:
键盘操作:在容器上设置tabindex="0",监听键盘事件,左右方向键切换,Home和End跳到首尾。注意不要和浏览器其他默认行为冲突,比如页面本身左右滚动。
ARIA标记:容器设置role="region"和aria-roledescription="carousel",每个slide设置aria-hidden,指示器按钮设置aria-label。这样屏幕阅读器用户能知道这是一个轮播,并且当前显示的是第几项。
减少动画:部分用户有前庭障碍,系统设置了“减少动态效果”。用prefers-reduced-motion查询,如果用户开启,就关闭自动播放和滑动动画,改为直接显示切换结果。
javascript复制const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
track.style.transition = 'none';
autoPlayEnabled = false;
}
5.4 与常见JavaScript运行时错误的隔离
热搜词里有人搜“javascript:void(0)报错”,这个在轮播组件里其实特别常见。早期很多示例代码喜欢在<a href="javascript:void(0)">上绑定点击事件,遇到某些浏览器安全策略或者扩展拦截时,会直接报错,导致后面的点击逻辑不执行。
我的建议是组件内部不依赖任何javascript:协议。指示器和控制按钮用<button>元素,没有href,自然不存在这个问题。如果因为样式兼容必须用<a>,那也请把href写成#并在点击事件里preventDefault(),而不是写javascript:void(0)。
另外一个多发的运行时错误是“cannot read properties of null”,排查下来十有八九是因为轮播容器在DOM还没渲染完时就被初始化了。所以组件初始化之前,一定要确认容器存在,必要的话等DOMContentLoaded或者使用defer加载脚本。
6. 实战踩坑总结与组件扩展思路
开发这个轮播组件的过程中,我踩过的坑至少有四五个是可以拿出来单说的。这些经验是我在文档里找不到、只能靠真实跑一遍才能发现的,写出来帮后面的人少走弯路。
6.1 克隆节点导致的图片双击放缩问题
加了首尾克隆后,我在移动端测试发现一个很怪的现象:手指快速滑动试图双击某张图片时,浏览器偶尔会触发图片缩放。原因是我克隆的节点里包含了<img>,而移动端双击<img>默认有缩放行为。
解法是在CSS里给slide内的图片设置pointer-events: none或者user-select: none。但要注意,如果图片上有链接,完全禁用pointer-events会让点击失效。我最后选择禁用图片的拖拽行为和双击缩放:
css复制.carousel-slide img {
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
链接的点击放在slide上的一个遮罩层或者单独处理,通过pointer-events的层级分配来解决,而不是一刀切。
6.2 无限循环快速点击下的状态错乱
无限循环的瞬移逻辑如果处理不当,最典型的表现是:快速连点“下一张”,到达克隆位置后,transitionend还没触发,又来了新的切换请求,导致最后位置偏了一位,指示器高亮和实际显示对不上。
解决思路就是状态机思想:动画期间不接受新的切换指令。goTo开头那句if (state.isAnimating) return就是干这个的。但光有拦截还不够,如果用户连续点了10次“下一张”,只执行1次,体验也很差。更好的做法是把“最新意图”记录下来,动画结束后继续执行未完成的操作:
javascript复制let pendingIndex = null;
function goTo(index) {
if (state.isAnimating) {
pendingIndex = index;
return;
}
performTransition(index);
}
function onTransitionEnd() {
state.isAnimating = false;
if (pendingIndex !== null) {
const next = pendingIndex;
pendingIndex = null;
goTo(next);
}
}
这样既不会在动画中乱跳,又不会丢失用户的连续操作意图。
6.3 图片懒加载与数据驱动如何配合
轮播数据如果是接口返回的,一次可能带几十张高清图,全部加载会拖慢首屏。我给这个组件做了懒加载:初始渲染时只加载第1张,进入动画前预加载下一张,其他图片默认只给占位背景和data-src属性。
javascript复制function preloadNext(imageIndex) {
const item = state.items[imageIndex];
if (!item || !item.src) return;
const img = new Image();
img.src = item.src;
}
然后在goTo即将切换时,对目标索引和它的前一张做预加载。配合render()里的src赋值逻辑,只有当slide即将进入可视范围时才真正发起图片请求。这里有个小建议:data-src要预先写在模板里,不要在切换瞬间才设置onload事件,否则图片加载状态不可控。
6.4 组件扩展思路:从轮播到走马灯、响应式多列
这个组件的架构,天然适合进一步扩展。数据驱动框架下,加一个“多列模式”非常容易:状态里加一个visibleCount,渲染时不是每张slide占100%宽度,而是每张占100% / visibleCount的宽度,切换时每次移动的步长改为current * (viewportWidth / visibleCount)。
走马灯式的无缝轮播也不难:克隆的数量从首尾各1张改成首尾各visibleCount张,切换逻辑基本不变,只是计算偏移时乘上多列的宽度。这些扩展都不需要重写主体逻辑,因为核心的“状态→渲染”模型没变。
我个人的体会是,如果你准备在业务里长期维护一个轮播组件,花一天时间把它重写成数据驱动是很值得的。别看市面上插件多,真要定制行为的时候,自己手里有一个结构清晰、每一行代码都知道为什么存在的组件,比什么都踏实。
最后再分享一个小的实用技巧:调试轮播时,把viewportWidth和state.current打印在页面上,你立刻能看出位置偏移是不是对的。这个看起来土气的方法,帮我找出的bug比任何调试工具都多。
