返回

告别库依赖:用原生JS实现炫酷多图光标跟随拖尾效果

javascript

好的,这是你要的博客文章:


告别库依赖:用原生 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),并且动画循环只更新了这个容器的位置。

要想实现拖尾效果,核心在于每一张图片都需要独立地、带有延迟地去跟随前一张图片(或者鼠标)的位置 。也就是说,动画逻辑不能只作用于容器,而要精细到容器内的每一张图片元素。

三、 动手改造:让图片“跟屁虫”动起来

知道了原因,解决起来就有方向了。咱们不能只追踪和移动容器,而是要为容器里的每一张图片维护它们自己的位置状态,并让它们在动画循环中各自独立更新,同时又相互关联(后面一张跟前面一张)。

方案:给每张图片独立的“灵魂” (位置状态与动画)

大思路是:

  1. 记录状态 :当鼠标进入一个格子时,不再只记录一个 activeCursor 容器,而是要拿到这个容器里所有的图片元素,并为每一张图片创建一个独立的状态对象,用来存储它自己的 currentX, currentY, aimX, aimY
  2. 链式更新目标 :在 mousemove 事件里,我们只更新第一张图片aimX, aimY,让它直接瞄准鼠标位置。
  3. 独立动画 :在 requestAnimationFrame 的动画循环里:
    • 第一张图片的 aimX, aimY 是鼠标的实时位置。
    • 第二张图片的 aimX, aimY 不再是鼠标位置,而是第一张图片当前位置 (currentX, currentY)。
    • 第三张图片的 aimX, aimY 则是第二张图片当前位置。
    • 依此类推,形成一个“位置传递链”。
    • 每一张图片都根据自己aimX, aimYcurrentX, currentY,以及 speed,独立计算并更新自己的 style.leftstyle.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. 事件监听配合:捕捉鼠标,更新目标

现在需要更新 mousemovemouseleave 事件监听器,以配合新的状态管理和动画逻辑。

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 事件现在只负责更新全局的 mouseAimXmouseAimY,并直接设定第一张图片的 aimX, aimY。其他图片的 aim 更新由 animate 函数内部的链式逻辑完成。
  • mouseleave 事件现在需要隐藏(或开始隐藏动画)所有 activeImages 里的图片,并清空 activeImages 数组和 activePost 状态。 使用 opacity 配合 CSS transition 是比较简单的隐藏方式。

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 图片拖尾效果了。

进阶玩法:加点“料”

搞定了基本效果,还可以玩出更多花样:

  1. 调整 speed : 这个值直接影响跟随的“黏滞感”。值越小,跟随越慢,拖尾越长;值越大,跟随越快,拖尾越短。可以尝试 0.10.3 之间的值看看效果。
  2. 不同图片不同速度 :在 activeImages 的状态对象里,可以为每张图片设置不同的 speed 值。比如让后面的图片 speed 稍小一点,会产生更明显的拉伸感。修改 animate 函数里的 speed 应用即可。
  3. 加入旋转或缩放 :在 animate 函数更新 transform 时,除了 translate,还可以动态计算并加入 rotate()scale()。例如,可以根据鼠标移动的速度或方向来改变图片的角度。
  4. 鼠标速度影响效果 :可以在 mousemove 中计算鼠标移动的瞬时速度,然后用这个速度去影响 speed、图片间的间距、或者旋转缩放等,让效果更动感。
  5. 性能考量 :如果网格项非常多,或者每个网格项里的图片数量巨大,requestAnimationFrame 里的计算量可能会增加。虽然现代浏览器处理这个基本没问题,但要注意:
    • 避免在 animate 循环里进行昂贵的 DOM 查询或计算。我们的实现已经把 DOM 查询放在 mouseenter 里了,这是好的。
    • mousemove 事件触发频率很高。如果动画变得卡顿(可能性不大,但以防万一),可以考虑对 mousemove 事件处理函数进行节流(throttle),降低 mouseAimX/Y 的更新频率。但 requestAnimationFrame 本身就是一种高效的节流方式,所以通常不需要额外处理 mousemove
    • 确保图片资源本身不要太大,加载过慢也会影响体验。

现在,你应该可以用纯粹的 JavaScript 在你的网格项目中实现那个酷炫的图片拖尾效果了。动手试试看吧!