告别库依赖:用原生JS实现炫酷多图光标跟随拖尾效果
2025-04-20 13:02:56
好的,这是你要的博客文章:
告别库依赖:用原生 JS 实现炫酷的多图光标跟随拖尾效果
写前端效果的时候,经常会遇到一些挺有意思的需求。比如这次,咱们要搞一个这样的效果:鼠标在网格的某个格子里晃悠时,后面会跟着一串图片“尾巴”,就像 这个 Codepen 例子 里展示的那样。原版例子用了 GSAP 库,但项目里不想引入额外的库,那就得靠原生 JavaScript 来实现了。
一、 问题来了:光标一动,图片怎么只“抱团”移动?
你可能已经尝试写了一些代码,发现能让那一堆图片作为一个整体跟着鼠标跑了,就像下面这段代码这样:
// --- 问题代码片段 (只展示核心逻辑) ---
const posts = document.querySelectorAll('.js-post');
let activePost = null;
let activeCursor = null; // 指向整个图片容器 .js-cursor
let currentX = 0, currentY = 0;
let aimX = 0, aimY = 0;
const speed = 0.2;
const animate = () => {
if (activeCursor) {
// 使用线性插值 (lerp) 平滑移动
currentX += (aimX - currentX) * speed;
currentY += (aimY - currentY) * speed;
// 直接移动整个容器
activeCursor.style.left = currentX + 'px';
activeCursor.style.top = currentY + 'px';
}
requestAnimationFrame(animate);
};
animate();
posts.forEach(post => {
post.addEventListener('mouseenter', (e) => {
// ... (省略显示/隐藏逻辑)
activePost = post;
activeCursor = post.querySelector('.js-cursor'); // 获取图片容器
// ... (省略坐标初始化逻辑)
activeCursor.classList.add('is-visible');
});
post.addEventListener('mousemove', (e) => {
if (activePost === post && activeCursor) {
const rect = post.getBoundingClientRect();
// 更新目标位置 aimX, aimY
aimX = e.clientX - rect.left;
aimY = e.clientY - rect.top;
}
});
post.addEventListener('mouseleave', () => {
if (activePost === post && activeCursor) {
// ... (省略隐藏和重置逻辑)
activeCursor = null;
activePost = null;
}
});
});
搭配的 HTML 结构大概是这样,每个 .grid__item
里有个 .js-cursor
容器,里面放了好几张 <img>
:
<div class="grid">
<div class="grid__item js-post">
<div class="grid__item-number">1</div>
<!-- 图片容器 -->
<div class="grid__item-cursor js-cursor">
<img class="grid__item-image js-image" src="...">
<img class="grid__item-image js-image" src="...">
<img class="grid__item-image js-image" src="...">
<!-- ...更多图片 -->
</div>
</div>
<!-- ...更多 grid__item -->
</div>
还有一些 CSS 让图片容器 .grid__item-cursor
绝对定位,并 inicialmente 隐藏:
.grid__item{
/* ... */
position: relative; /* 作为绝对定位的参照物 */
overflow: hidden; /* 隐藏超出部分的图片 */
}
.grid__item-cursor{
position: absolute;
width: 150px; /* 图片容器尺寸 */
height: 200px;
transform: translate(-50%, -50%); /* 初始居中 */
pointer-events: none; /* 不阻挡鼠标事件 */
z-index: -1; /* 初始隐藏在数字后面 */
opacity: 0;
transition: opacity .3s ease .1s;
}
.grid__item-cursor.is-visible{
z-index: 1; /* 显示时在数字前面 */
opacity: 1;
}
.grid__item-image{
position: absolute; /* 让图片在容器内绝对定位,叠在一起 */
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover; /* 图片填满容器 */
}
这段代码的问题就在于,animate
函数里是直接移动了整个 .js-cursor
容器。所以,容器里的所有图片当然是“抱团”一起动啦,没有出现预想中那种一张跟一张的“拖尾”效果。
二、 为啥会这样?刨根问底找原因
原因其实很简单:咱们只给整个图片“群组”(.js-cursor
容器)设定了一套位置目标(aimX
, aimY
)和当前位置(currentX
, currentY
),并且动画循环只更新了这个容器的位置。
要想实现拖尾效果,核心在于每一张图片都需要独立地、带有延迟地去跟随前一张图片(或者鼠标)的位置 。也就是说,动画逻辑不能只作用于容器,而要精细到容器内的每一张图片元素。
三、 动手改造:让图片“跟屁虫”动起来
知道了原因,解决起来就有方向了。咱们不能只追踪和移动容器,而是要为容器里的每一张图片维护它们自己的位置状态,并让它们在动画循环中各自独立更新,同时又相互关联(后面一张跟前面一张)。
方案:给每张图片独立的“灵魂” (位置状态与动画)
大思路是:
- 记录状态 :当鼠标进入一个格子时,不再只记录一个
activeCursor
容器,而是要拿到这个容器里所有的图片元素,并为每一张图片创建一个独立的状态对象,用来存储它自己的currentX
,currentY
,aimX
,aimY
。 - 链式更新目标 :在
mousemove
事件里,我们只更新第一张图片的aimX
,aimY
,让它直接瞄准鼠标位置。 - 独立动画 :在
requestAnimationFrame
的动画循环里:- 第一张图片的
aimX
,aimY
是鼠标的实时位置。 - 第二张图片的
aimX
,aimY
不再是鼠标位置,而是第一张图片的当前位置 (currentX
,currentY
)。 - 第三张图片的
aimX
,aimY
则是第二张图片的当前位置。 - 依此类推,形成一个“位置传递链”。
- 每一张图片都根据自己的
aimX
,aimY
和currentX
,currentY
,以及speed
,独立计算并更新自己的style.left
和style.top
。
- 第一张图片的
听起来有点绕?别急,咱们一步步来分解实现。
1. 数据结构大调整:管理好每张图片的状态
首先,我们需要一个地方来存放当前活跃的格子里的所有图片及其状态。可以用一个数组来管理。
当鼠标 mouseenter
进入 post
时:
let activePost = null;
// activeImages 现在是一个数组,存放图片元素及其状态
// [{ el: imgElement1, currentX: 0, currentY: 0, aimX: 0, aimY: 0 }, ...]
let activeImages = [];
const speed = 0.2; // 整体跟随速度因子
// (animate 函数暂时不变,后面会大改)
// const animate = () => { ... }
// animate();
posts.forEach(post => {
// 获取当前 post 里的所有图片元素
const images = Array.from(post.querySelectorAll('.js-image'));
const cursorContainer = post.querySelector('.js-cursor'); // 容器还是需要的,用来控制整体显隐
post.addEventListener('mouseenter', (e) => {
// 清理上一个 post 的状态(如果需要的话)
if (activePost && activePost !== post && activeImages.length > 0) {
// 让上一个格子的图片容器立刻消失
const prevContainer = activePost.querySelector('.js-cursor');
if (prevContainer) {
prevContainer.classList.remove('is-visible');
}
// 清空活动图片数组
activeImages = [];
}
activePost = post;
// cursorContainer 控制显隐
cursorContainer.classList.add('is-visible');
// --- 核心改动:为每张图片初始化状态 ---
const rect = post.getBoundingClientRect();
const initialX = e.clientX - rect.left;
const initialY = e.clientY - rect.top;
activeImages = images.map(imgEl => ({
el: imgEl,
currentX: initialX, // 初始位置设为鼠标进入点
currentY: initialY,
aimX: initialX, // 目标也先设为进入点
aimY: initialY
}));
// 让图片元素立刻出现在鼠标位置 (避免闪烁)
// 注意:现在是直接给图片设置 transform,而不是给容器设置 left/top
activeImages.forEach(imgState => {
// 使用 transform 来定位,性能通常比 left/top 好
// translate(-50%, -50%) 是为了让图片中心对准鼠标
imgState.el.style.transform = `translate(${imgState.currentX}px, ${imgState.currentY}px) translate(-50%, -50%)`;
// 确保图片是可见的(如果之前有设置 opacity 或 visibility 的话)
imgState.el.style.opacity = '1'; // 假设用 opacity 控制,也可以用 display 或 visibility
});
});
// mousemove 和 mouseleave 后面再处理
});
这里有几个关键点:
activeImages
变成了一个对象数组,每个对象包含图片 DOM 元素 (el
) 和它的位置状态 (currentX
,currentY
,aimX
,aimY
)。- 进入格子时,遍历所有图片,创建这个状态数组,并将初始位置设置为鼠标进入点。
- 重要: 我们不再通过 CSS 的
left
/top
控制.js-cursor
容器的位置,而是直接用transform: translate(...)
来控制每一张图片的位置。这样性能更好,也能方便地实现-50%, -50%
的居中偏移。你可能需要调整一下 CSS,确保.grid__item-cursor
本身不再需要transform: translate(-50%, -50%)
或者left
/top
,它的作用主要是作为显隐切换的钩子以及可能的背景层。建议让.grid__item-cursor
尺寸为 0 或完全透明,仅作为逻辑容器。更好的做法可能是直接控制图片的显隐,而不是依赖父容器。
我们稍微调整下 CSS,让容器不再干扰图片定位,只负责管理区域和可能的全局效果(如果需要):
.grid__item-cursor{
position: absolute;
top: 0; /* 覆盖整个 grid_item */
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* 不阻挡鼠标事件 */
z-index: -1;
opacity: 0;
transition: opacity .3s ease .1s;
/* 移除 width, height, transform,让它不影响内部图片定位 */
}
.grid__item-cursor.is-visible{
z-index: 1;
opacity: 1;
}
.grid__item-image{
position: absolute;
width: 150px; /* 图片自身的尺寸 */
height: 200px;
object-fit: cover;
/* 初始让 JS 控制位置,这里可以不设 top/left */
opacity: 0; /* 初始隐藏,由 JS 控制显示 */
/* 移除 width: 100%, height: 100% 如果希望图片是固定尺寸 */
/* 加上 transform origin 如果需要 */
transform-origin: center center;
/* 添加一个过渡效果,让显隐更平滑 (可选) */
transition: opacity 0.3s ease;
}
2. 动画循环升级:链式跟随是关键
接下来改造 animate
函数。这个函数是核心,它会在每一帧执行:
const animate = () => {
// 只在有活动图片时执行
if (activeImages.length > 0) {
// 遍历所有活动图片的状态
for (let i = 0; i < activeImages.length; i++) {
const imgState = activeImages[i];
// ---- 目标位置更新逻辑 (链式) ----
if (i === 0) {
// 第一张图片的目标是鼠标的 aimX, aimY (这个 aimX/aimY 由 mousemove 更新)
// 这个 aimX, aimY 需要定义在 animate 函数外部,由 mousemove 更新
} else {
// 后面的图片,目标是它前面那张图片的 *当前* 位置
const prevImgState = activeImages[i - 1];
imgState.aimX = prevImgState.currentX;
imgState.aimY = prevImgState.currentY;
}
// ---- 当前位置更新逻辑 (lerp) ----
// 每张图片都独立地向自己的目标位置移动
imgState.currentX += (imgState.aimX - imgState.currentX) * speed;
imgState.currentY += (imgState.aimY - imgState.currentY) * speed;
// ---- 更新 DOM ----
// 使用 transform 更新图片位置
// 注意:我们组合了两个 translate。第一个是定位,第二个是居中补偿。
imgState.el.style.transform = `translate(${imgState.currentX}px, ${imgState.currentY}px) translate(-50%, -50%)`;
}
}
// 持续请求下一帧
requestAnimationFrame(animate);
};
// 定义全局的鼠标目标位置
let mouseAimX = 0;
let mouseAimY = 0;
// 启动动画循环
animate();
这里,animate
函数内部的循环是关键:
- 它遍历
activeImages
数组。 - 对于第一张图片(
i === 0
),它的目标aimX
,aimY
应该就是我们从mousemove
事件中获取的鼠标实时目标位置。我们把这个实时目标位置存在外部变量mouseAimX
,mouseAimY
中。 - 对于后续的图片(
i > 0
),它的目标aimX
,aimY
被设置成了它前一张图片(activeImages[i - 1]
)的当前位置currentX
,currentY
。这就是形成拖尾效果的“链式跟随”机制。 - 然后,每张图片都执行自己的线性插值(lerp),让
currentX
,currentY
靠近aimX
,aimY
。 - 最后,更新每张图片的
transform
样式。
3. 事件监听配合:捕捉鼠标,更新目标
现在需要更新 mousemove
和 mouseleave
事件监听器,以配合新的状态管理和动画逻辑。
posts.forEach(post => {
const images = Array.from(post.querySelectorAll('.js-image'));
const cursorContainer = post.querySelector('.js-cursor');
post.addEventListener('mouseenter', (e) => {
// ... (之前的 mouseenter 逻辑基本保持,确保初始化 activeImages 和设置初始 transform)
if (activePost && activePost !== post && activeImages.length > 0) {
const prevContainer = activePost.querySelector('.js-cursor');
if (prevContainer) prevContainer.classList.remove('is-visible');
// 让离开的格子的图片立刻隐藏或归位
activeImages.forEach(imgState => {
imgState.el.style.opacity = '0';
// 或重置 transform 到一个隐藏位置
// imgState.el.style.transform = 'translate(-50%, -50%) scale(0)';
});
activeImages = [];
}
activePost = post;
cursorContainer.classList.add('is-visible'); // 显示容器 (虽然现在是空的,但可用于背景或其他)
const rect = post.getBoundingClientRect();
const initialX = e.clientX - rect.left;
const initialY = e.clientY - rect.top;
// 初始化 activeImages
activeImages = images.map(imgEl => ({
el: imgEl,
currentX: initialX,
currentY: initialY,
aimX: initialX,
aimY: initialY
}));
// 初始化鼠标目标位置
mouseAimX = initialX;
mouseAimY = initialY;
// 立刻设置图片位置和可见性
activeImages.forEach(imgState => {
imgState.el.style.transform = `translate(${initialX}px, ${initialY}px) translate(-50%, -50%)`;
imgState.el.style.opacity = '1'; // 让图片可见
});
});
post.addEventListener('mousemove', (e) => {
// 只在当前 post 活跃时更新鼠标目标位置
if (activePost === post) {
const rect = post.getBoundingClientRect();
// ---- 更新全局的鼠标目标位置 ----
mouseAimX = e.clientX - rect.left;
mouseAimY = e.clientY - rect.top;
// 把这个目标直接赋给第一张图片的状态 (也可在 animate 中处理)
if (activeImages.length > 0) {
activeImages[0].aimX = mouseAimX;
activeImages[0].aimY = mouseAimY;
}
}
});
post.addEventListener('mouseleave', () => {
if (activePost === post) {
// 隐藏图片容器(如果还用它的话)
cursorContainer.classList.remove('is-visible');
// 让所有图片渐渐消失或归位 (可以通过CSS transition完成,或在JS动画里处理)
activeImages.forEach(imgState => {
imgState.el.style.opacity = '0';
// 你也可以在这里启动一个归位动画,比如让它们回到(0,0)点
// 或者 просто 设为 opacity 0 让 transition 生效
});
// 清空活动图片状态
activeImages = [];
activePost = null;
// 重置鼠标目标位置可能不是必须的,因为下次进入会重新计算
}
});
});
改动说明:
mousemove
事件现在只负责更新全局的mouseAimX
和mouseAimY
,并直接设定第一张图片的aimX
,aimY
。其他图片的aim
更新由animate
函数内部的链式逻辑完成。mouseleave
事件现在需要隐藏(或开始隐藏动画)所有activeImages
里的图片,并清空activeImages
数组和activePost
状态。 使用opacity
配合 CSStransition
是比较简单的隐藏方式。
4. 整合代码:完整实现
把上面所有的改动整合起来,一个更接近我们目标的纯 JS 实现就出来了。下面是完整的 JavaScript 代码示例:
const posts = document.querySelectorAll('.js-post');
let activePost = null;
// 存储当前激活的图片及其状态 { el: HTMLElement, currentX: number, currentY: number, aimX: number, aimY: number }[]
let activeImages = [];
let mouseAimX = 0; // 鼠标相对于活动 post 的目标 X 坐标
let mouseAimY = 0; // 鼠标相对于活动 post 的目标 Y 坐标
const speed = 0.15; // 跟随速度因子,可以调这个值试试效果
const animate = () => {
// 只有当有活动图片时才进行计算
if (activeImages.length > 0) {
for (let i = 0; i < activeImages.length; i++) {
const imgState = activeImages[i];
// 更新目标位置 (aim)
if (i === 0) {
// 第一张图片直接跟随鼠标目标
imgState.aimX = mouseAimX;
imgState.aimY = mouseAimY;
} else {
// 后续图片跟随前一张图片的当前位置
const prevImgState = activeImages[i - 1];
// 可以稍微加一点点延迟感,让 aim 不完全等于前一张的 current
// imgState.aimX = prevImgState.currentX;
// imgState.aimY = prevImgState.currentY;
// 或者加权平均,制造更柔和的连接(可选)
imgState.aimX += (prevImgState.currentX - imgState.aimX) * 0.5; // 0.5 是另一个平滑因子
imgState.aimY += (prevImgState.currentY - imgState.aimY) * 0.5;
}
// 更新当前位置 (current) - 使用线性插值 (lerp)
imgState.currentX += (imgState.aimX - imgState.currentX) * speed;
imgState.currentY += (imgState.aimY - imgState.currentY) * speed;
// 应用变换到图片元素
// 使用 translate(x, y) 定位,再用 translate(-50%, -50%) 把中心点对准坐标
imgState.el.style.transform = `translate(${imgState.currentX}px, ${imgState.currentY}px) translate(-50%, -50%)`;
}
}
// 请求下一帧动画
requestAnimationFrame(animate);
};
// 初始化并启动动画循环
animate();
posts.forEach(post => {
const images = Array.from(post.querySelectorAll('.js-image'));
// 找到图片容器,可能用来做整体显隐或其他效果,但不再控制位置
const cursorContainer = post.querySelector('.js-cursor');
post.addEventListener('mouseenter', (e) => {
// 如果当前有活跃的 post 且不是自己,先处理旧的
if (activePost && activePost !== post && activeImages.length > 0) {
const prevContainer = activePost.querySelector('.js-cursor');
if (prevContainer) {
prevContainer.classList.remove('is-visible'); // 隐藏旧容器
// 让旧图片消失
activeImages.forEach(imgState => {
imgState.el.style.opacity = '0';
});
}
activeImages = []; // 清空状态
}
activePost = post; // 设置当前活跃 post
if(cursorContainer) cursorContainer.classList.add('is-visible'); // 显示容器
// 获取 post 的边界,用于计算相对坐标
const rect = post.getBoundingClientRect();
const initialX = e.clientX - rect.left;
const initialY = e.clientY - rect.top;
// 更新鼠标初始目标位置
mouseAimX = initialX;
mouseAimY = initialY;
// 初始化图片状态数组
activeImages = images.map((imgEl, index) => {
// 给图片一个初始的随机偏移或者固定偏移可以避免一开始叠在一起(可选)
// const offsetX = (Math.random() - 0.5) * 10;
// const offsetY = (Math.random() - 0.5) * 10;
const offsetX = 0;
const offsetY = 0;
const state = {
el: imgEl,
currentX: initialX + offsetX,
currentY: initialY + offsetY,
aimX: initialX + offsetX,
aimY: initialY + offsetY,
};
// 设置图片初始 transform 和 opacity
state.el.style.transform = `translate(${state.currentX}px, ${state.currentY}px) translate(-50%, -50%)`;
state.el.style.opacity = '1'; // 显示图片
// 你甚至可以给不同的图片设置不同的 speed (更高级玩法)
// state.speed = speed * (1 + index * 0.1); // 例如,越后面的图片越慢
return state;
});
});
post.addEventListener('mousemove', (e) => {
if (activePost === post) {
const rect = post.getBoundingClientRect();
// 更新全局鼠标目标位置
mouseAimX = e.clientX - rect.left;
mouseAimY = e.clientY - rect.top;
}
});
post.addEventListener('mouseleave', () => {
if (activePost === post) {
if(cursorContainer) cursorContainer.classList.remove('is-visible'); // 隐藏容器
// 让所有图片消失
activeImages.forEach(imgState => {
imgState.el.style.opacity = '0';
// 如果 CSS 中设置了 opacity 的 transition,这里就会渐隐
});
// 清理状态
activeImages = [];
activePost = null;
}
});
});
记得配合前面调整过的 CSS 使用。这段代码应该就能实现你想要的、不依赖库的原生 JS 图片拖尾效果了。
进阶玩法:加点“料”
搞定了基本效果,还可以玩出更多花样:
- 调整
speed
: 这个值直接影响跟随的“黏滞感”。值越小,跟随越慢,拖尾越长;值越大,跟随越快,拖尾越短。可以尝试0.1
到0.3
之间的值看看效果。 - 不同图片不同速度 :在
activeImages
的状态对象里,可以为每张图片设置不同的speed
值。比如让后面的图片speed
稍小一点,会产生更明显的拉伸感。修改animate
函数里的speed
应用即可。 - 加入旋转或缩放 :在
animate
函数更新transform
时,除了translate
,还可以动态计算并加入rotate()
或scale()
。例如,可以根据鼠标移动的速度或方向来改变图片的角度。 - 鼠标速度影响效果 :可以在
mousemove
中计算鼠标移动的瞬时速度,然后用这个速度去影响speed
、图片间的间距、或者旋转缩放等,让效果更动感。 - 性能考量 :如果网格项非常多,或者每个网格项里的图片数量巨大,
requestAnimationFrame
里的计算量可能会增加。虽然现代浏览器处理这个基本没问题,但要注意:- 避免在
animate
循环里进行昂贵的 DOM 查询或计算。我们的实现已经把 DOM 查询放在mouseenter
里了,这是好的。 mousemove
事件触发频率很高。如果动画变得卡顿(可能性不大,但以防万一),可以考虑对mousemove
事件处理函数进行节流(throttle),降低mouseAimX/Y
的更新频率。但requestAnimationFrame
本身就是一种高效的节流方式,所以通常不需要额外处理mousemove
。- 确保图片资源本身不要太大,加载过慢也会影响体验。
- 避免在
现在,你应该可以用纯粹的 JavaScript 在你的网格项目中实现那个酷炫的图片拖尾效果了。动手试试看吧!