写在开头
Hello,各位UU早上好呀! 😀
今是2025年04月12日,小编已经躺平三四个月没来写文章了,主要原因是去年年底进行了跳槽,在新公司业务比较忙,实在是没有时间上来水✏,绝对不是懒哈!😋
然后呢,跳槽还是比较顺利的,在新公司一切都挺好,工作日每天忙忙碌碌,从早晨吃饭-上班-下班-吃饭-午休-上班-下班-吃饭-回宿舍-洗澡-睡觉,周而复始;而周末一有时间就外出约好友爬山、逛公园、看美景、吃美食,一切都挺好,平平淡淡、顺顺利利。
最近读到两句鸡汤,也分享给大家👻:
-
当知足凌驾于欲望之上,幸福将会贯彻一生!
-
别人开导只是问诊,自己醒悟方为良药!
那么,回到正题,本次要分享的是关于自动滚动定位的功能,效果如下,请诸君按需食用哈。

需求背景
最近在做公司业务时,遇到了一个关于横向图片选择器的交互小需求。👀
具体来说,当用户在横向的图片选择器中点击某张图片时,需要自动调整滚动位置,确保点击的图片以及其后续几张图片能够完整显示在可视区域内。这种交互能很好的提升用户体验,在移动端和桌面端的应用中比较常见,比如图片浏览器、商品展示列表等场景。
虽然这个需求虽然并不复杂,但实现起来全是细节,如:
- 滚动范围的计算
- 容器宽度的动态适配
- 滚动动画的平滑效果。
- 懒加载
- 键盘上下键选择
- ...
实现过程
小编最开始是在比较复杂的业务项目中实现的该功能,但为了更好的理解,咱们先用基础的三剑客来实现,文章最后也会提供 Vue3
版本,可以自行选择。😋
由于精简过代码,咱们来直接瞧瞧:
html
<!DOCTYPE html>
<html>
<head>
<style>
.demo-container {
margin: 30px 0;
padding: 20px;
background-color: #f9f9f9;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.image-selector {
width: 100%;
height: 150px;
overflow-x: auto;
overflow-y: hidden;
display: flex;
align-items: center;
box-sizing: border-box;
margin: 20px 0;
background-color: #f0f0f0;
border-radius: 8px;
padding: 10px;
}
.image-item {
min-width: 80px;
width: 80px;
height: 120px;
margin-right: 10px;
border-radius: 4px;
overflow: hidden;
border: 3px solid #ddd;
box-sizing: border-box;
cursor: pointer;
background-color: #fff;
transition: all 0.3s;
flex-shrink: 0;
}
.image-item.active {
border-color: #3498db;
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.btn {
display: inline-block;
padding: 8px 16px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
margin: 5px;
}
.btn:hover {
background-color: #2980b9;
}
</style>
</head>
<body>
<div class="demo-container">
<h3>演示案例</h3>
<div id="imageSelector" class="image-selector">
<!-- 图片将通过JS动态生成 -->
</div>
<button id="addBtn" class="btn">添加更多图片</button>
<button id="resetBtn" class="btn">重置</button>
<button id="prevBtn" class="btn">上一个</button>
<button id="nextBtn" class="btn">下一个</button>
</div>
</body>
</html>
逻辑部分:
js
<script>
// 先把所有相关DOM先获取一下
const container = document.getElementById("imageSelector");
const addBtn = document.getElementById("addBtn");
const resetBtn = document.getElementById("resetBtn");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
/** @name 图片资源 **/
const imageUrls = [
"https://picsum.photos/200/300?random=1",
"https://picsum.photos/200/300?random=2",
"https://picsum.photos/200/300?random=3",
"https://picsum.photos/200/300?random=4",
"https://picsum.photos/200/300?random=5",
"https://picsum.photos/200/300?random=6",
"https://picsum.photos/200/300?random=7",
"https://picsum.photos/200/300?random=8",
"https://picsum.photos/200/300?random=9",
"https://picsum.photos/200/300?random=10",
"https://picsum.photos/200/300?random=11",
"https://picsum.photos/200/300?random=12",
"https://picsum.photos/200/300?random=13",
"https://picsum.photos/200/300?random=14",
"https://picsum.photos/200/300?random=15",
];
/** @name 当前选择的图片索引 **/
let currentIndex = 0;
// 宽度 + 边距
const itemWidth = 80 + 10;
/** @name 初始化图片 **/
function initImages() {
container.innerHTML = "";
imageUrls.forEach((url, index) => {
const item = document.createElement("div");
item.className = "image-item";
if (index === currentIndex) item.classList.add("active");
item.innerHTML = `<img src="${url}" alt="截图 ${index + 1}">`;
item.addEventListener("click", () => selectImage(index));
container.appendChild(item);
});
}
// 初始化
initImages();
/** @name 选择图片 **/
function selectImage(index) {
// 移除之前选中的active类
document.querySelectorAll(".image-item").forEach((el) => {
el.classList.remove("active");
});
currentIndex = index;
// 添加active类
container.children[index].classList.add("active");
// 自动滚动逻辑
scrollToItem(index);
}
/** @name 滚动定位 **/
function scrollToItem(index) {
const containerWidth = container.clientWidth;
const scrollLeft = container.scrollLeft;
const itemLeft = index * itemWidth;
// 计算理想滚动位置,确保点击项和后续3个项都能显示
const itemsToShow = 4;
const idealScrollLeft = itemLeft - (containerWidth - itemWidth * itemsToShow) / 2;
// 限制滚动范围
const maxScrollLeft = container.scrollWidth - containerWidth;
const targetScrollLeft = Math.max(0, Math.min(idealScrollLeft, maxScrollLeft));
// 只有当当前位置与目标位置不同时才滚动
if (Math.abs(scrollLeft - targetScrollLeft) > 1) {
container.scrollTo({ left: targetScrollLeft, behavior: "smooth" });
}
}
/** @name 添加更多图片 **/
function addImage() {
const newIndex = imageUrls.length;
const newImageUrl = `https://picsum.photos/200/300?random=${newIndex + 1}`;
imageUrls.push(newImageUrl);
// 创建新的图片项
const item = document.createElement("div");
item.className = "image-item";
item.innerHTML = `<img src="${newImageUrl}" alt="截图 ${newIndex + 1}">`;
item.addEventListener("click", () => selectImage(newIndex));
// 将新图片项添加到容器中
container.appendChild(item);
// 选中新添加的图片
selectImage(newIndex);
}
/** @name 重置 **/
function reset() {
currentIndex = 0;
initImages();
container.scrollTo({ left: 0, behavior: "smooth" });
}
/** @name 上一个 **/
function prevImage() {
if (currentIndex > 0) {
selectImage(currentIndex - 1);
}
}
/** @name 下一个 **/
function nextImage() {
if (currentIndex < imageUrls.length - 1) {
selectImage(currentIndex + 1);
}
}
/** @name 键盘事件处理 **/
function handleKeyDown(e) {
switch (e.key) {
case "ArrowUp":
prevImage();
break;
case "ArrowDown":
nextImage();
break;
}
}
/** @name 初始化事件监听器 **/
function initEventListeners() {
addBtn.addEventListener("click", addImage);
resetBtn.addEventListener("click", reset);
prevBtn.addEventListener("click", prevImage);
nextBtn.addEventListener("click", nextImage);
document.addEventListener("keydown", handleKeyDown);
}
initEventListeners();
</script>
图片资源可以使用这个在线网站,非常好用:传送门。
比较核心的逻辑是通过计算点击项的位置和容器宽度,确定最佳的滚动位置。这里小编单独写了一个 scrollToItem()
方法来完成这个事情,可以自己瞧瞧,都标记了详细注释。😊
懒加载
以上基础功能就都已经实现了,还有一个懒加载图片的能力,可以参考小编的另一篇文章:传送门
懒加载功能使用 IntersectionObserver 对象来实现非常简单,这里就不做过多介绍啦。👻
Vue3版本hook
🙊呃...由于小编项目使用的是 TS
语言,如果你想要 JS
版本,最快的方式就是直接复制代码丢给AI,让它帮你出一个 JS
版本即可。😋
Vue3
版本还增加了另外的一些小功能:
- 边界检测
- 指示箭头
- 惯性滚动效果
- 触摸设备支持
- 懒加载功能
ts
import { ref, onMounted, onUnmounted } from "vue";
interface UseAutoScrollOptions {
itemWidth: number; // 单个图片项的宽度(包括边距)
itemsToShow: number; // 可视区域内显示的图片数量
}
export function useAutoScroll(options: UseAutoScrollOptions) {
const container = ref<HTMLDivElement | null>(null);
const images = ref<string[]>([]);
const currentIndex = ref(0);
const showPrevArrow = ref(false);
const showNextArrow = ref(false);
let touchStartX = 0;
let touchDeltaX = 0;
const { itemWidth, itemsToShow } = options;
/** @name 初始化图片 */
const initImages = (initialImages: string[]) => {
images.value = initialImages;
updateArrows();
};
/** @name 选择图片 */
const selectImage = (index: number) => {
if (index < 0 || index >= images.value.length) return;
currentIndex.value = index;
scrollToItem(index);
updateArrows();
};
/** @name 滚动到指定图片 */
const scrollToItem = (index: number) => {
if (!container.value) return;
const containerWidth = container.value.clientWidth;
const itemLeft = index * itemWidth;
const idealScrollLeft =
itemLeft - (containerWidth - itemWidth * itemsToShow) / 2;
const maxScrollLeft = container.value.scrollWidth - containerWidth;
const targetScrollLeft = Math.max(0, Math.min(idealScrollLeft, maxScrollLeft));
container.value.scrollTo({
left: targetScrollLeft,
behavior: "smooth",
});
};
/** @name 添加图片 */
const addImage = (url: string) => {
images.value.push(url);
updateArrows();
};
/** @name 更新箭头显示状态 */
const updateArrows = () => {
if (!container.value) return;
const containerWidth = container.value.clientWidth;
const maxScrollLeft = container.value.scrollWidth - containerWidth;
showPrevArrow.value = container.value.scrollLeft > 0;
showNextArrow.value = container.value.scrollLeft < maxScrollLeft;
};
/** @name 触摸开始事件 */
const handleTouchStart = (e: TouchEvent) => {
touchStartX = e.touches[0].clientX;
};
/** @name 触摸移动事件 */
const handleTouchMove = (e: TouchEvent) => {
touchDeltaX = e.touches[0].clientX - touchStartX;
};
/** @name 触摸结束事件 */
const handleTouchEnd = () => {
if (Math.abs(touchDeltaX) > 50) {
if (touchDeltaX > 0) {
selectImage(currentIndex.value - 1);
} else {
selectImage(currentIndex.value + 1);
}
}
touchDeltaX = 0;
};
/** @name 懒加载图片 */
const lazyLoadImages = () => {
if (!container.value) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
const src = img.getAttribute("data-src");
if (src) {
img.src = src;
observer.unobserve(img);
}
}
});
},
{ root: container.value, threshold: 0.1 }
);
container.value.querySelectorAll("img[data-src]").forEach((img) => {
observer.observe(img);
});
};
/** @name 初始化事件监听器 */
const initEventListeners = () => {
if (!container.value) return;
container.value.addEventListener("scroll", updateArrows);
container.value.addEventListener("touchstart", handleTouchStart);
container.value.addEventListener("touchmove", handleTouchMove);
container.value.addEventListener("touchend", handleTouchEnd);
};
/** @name 移除事件监听器 */
const removeEventListeners = () => {
if (!container.value) return;
container.value.removeEventListener("scroll", updateArrows);
container.value.removeEventListener("touchstart", handleTouchStart);
container.value.removeEventListener("touchmove", handleTouchMove);
container.value.removeEventListener("touchend", handleTouchEnd);
};
onMounted(() => {
initEventListeners();
lazyLoadImages();
});
onUnmounted(() => {
removeEventListeners();
});
return {
container,
images,
currentIndex,
showPrevArrow,
showNextArrow,
initImages,
selectImage,
addImage,
};
}
在 Vue
组件中使用该 Hook
:
js
<template>
<div class="image-selector-container">
<!-- 箭头可以自行换个好看一点的图片替代\(^o^)/~ -->
<button v-if="showPrevArrow" @click="selectImage(currentIndex - 1)">←</button>
<div ref="container" class="image-selector">
<div
v-for="(image, index) in images"
:key="index"
class="image-item"
:class="{ active: index === currentIndex }"
@click="selectImage(index)"
>
<img :data-src="image" alt="图片" />
</div>
</div>
<button v-if="showNextArrow" @click="selectImage(currentIndex + 1)">→</button>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import { useAutoScroll } from "./useAutoScroll";
export default defineComponent({
setup() {
const { container, images, currentIndex, showPrevArrow, showNextArrow, initImages, selectImage, addImage } =
useAutoScroll({ itemWidth: 90, itemsToShow: 4 });
// 初始化图片
initImages([
"https://picsum.photos/200/300?random=1",
"https://picsum.photos/200/300?random=2",
"https://picsum.photos/200/300?random=3",
"https://picsum.photos/200/300?random=4",
]);
return {
container,
images,
currentIndex,
showPrevArrow,
showNextArrow,
selectImage,
addImage,
};
},
});
</script>
<style>
.image-selector-container {
display: flex;
align-items: center;
}
.image-selector {
display: flex;
overflow-x: auto;
width: 100%;
height: 150px;
}
.image-item {
min-width: 80px;
height: 120px;
margin-right: 10px;
border: 2px solid #ddd;
transition: border-color 0.3s;
}
.image-item.active {
border-color: #3498db;
}
</style>
至此,本篇文章就写完啦,撒花撒花。
