实现一个水纹涟漪轮播图

0 阅读3分钟

说在前面

办公室下午 4 点,产品同学把电脑转过来:

“这个轮播图能不能高级一点?现在一切图就像 PPT 翻页”

我:“要多高级?”

产品:“像水面一样扩散开那种,最好再有点电影感”

大家平时做轮播图,常见方案基本是 opacity 淡入淡出、translateX 滑动、或者 3D 翻转。够用是够用,但“惊喜感”比较有限。

效果展示

实现思路

整体结构

HTML :一个 canvas + 左右按钮 + 底部指示器。

<div class="carousel" id="carousel">
  <canvas id="canvas"></canvas>
</div>
<button class="nav-btn prev" id="prevBtn"></button>
<button class="nav-btn next" id="nextBtn"></button>
<div class="indicators" id="indicators"></div>
  • 轮播“画面”只交给 Canvas 负责。
  • 所有控件都是常规 DOM,交互实现更简单。
  • 视觉重活在 JS 里做,样式层只负责外观。

cover 裁剪 + DPR 适配

很多同学写 Canvas 容易糊,问题常出在高清屏适配。

function resizeCanvas() {
  const rect = canvas.parentElement.getBoundingClientRect();
  canvas.width = rect.width * devicePixelRatio;
  canvas.height = rect.height * devicePixelRatio;
  ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}

这段代码的作用:

  1. 物理像素devicePixelRatio 放大,保证清晰度。
  2. 再用 setTransform 把坐标系映射回 CSS 像素,避免你后面算坐标时崩溃。

另外它还实现了 cover 裁剪(类似 CSS background-size: cover),保证不同尺寸图片都能完整铺满画布,不留黑边。

“谁该出现”判定

const relDist = waveFront - dist;

if (relDist > waveWidth * waveCount) {
  useNewImage = true;
} else if (relDist > 0) {
  const wavePhase = (relDist / waveWidth) * Math.PI * 2;
  const amplitude = waveAmplitude * Math.sin(wavePhase)
    * Math.exp(-relDist / (waveWidth * waveCount) * 2);

  const angle = Math.atan2(dy, dx);
  srcX = x + Math.cos(angle) * amplitude;
  srcY = y + Math.sin(angle) * amplitude;

  useNewImage = relDist > waveWidth;
}

拆开理解:

  1. dist 是当前像素到波纹中心的距离。
  2. waveFront 是当前时刻波前推进到哪里。
  3. relDist = waveFront - dist 可以理解成“波前相对这个像素的位置关系”。
  4. 根据 relDist 分三段:
    • 波已过去:显示新图。
    • 波正在影响:做径向位移(扭曲),并按圈层逐步切换到新图。
    • 波还没到:继续显示旧图。

这就是为什么你会看到“像水波推着画面前进”的感觉,而不是生硬切换。

为什么要用离屏 Canvas?

这里用了 fromCanvastoCanvasoutputCanvas 三个离屏画布

const fromData = fromCtx.getImageData(0, 0, pw, ph);
const toData = toCtx.getImageData(0, 0, pw, ph);
const outputData = outputCtx.createImageData(pw, ph);

作用有三点:

  1. 避免每一帧重复去主画布读像素(成本高)。
  2. 输出先在离屏里 putImageData,最后一次性 drawImage 到主画布,流程更稳。
  3. 绕开 putImageData 不受 transform 影响的问题,DPR 场景更可控。

性能小技巧

像素级循环最怕卡顿,这里用了一个很实用的策略:按块采样。

const step = 2;
for (let y = 0; y < ph; y += step) {
  for (let x = 0; x < pw; x += step) {
    // 计算一次后,填充 step x step 区域
  }
}

这相当于“每 2x2 像素算一次”,能明显减轻运算量。因为波纹本身是连续变化的,人眼很难察觉这点采样损失,性能却能换来很大提升。

交互

这个文件里交互是完整闭环:

  1. goTo(index) 统一处理切换入口,防抖靠 isTransitioning
  2. next/prev 处理循环索引。
  3. setInterval(next, 4500) 自动播放。
  4. 鼠标悬停暂停、离开恢复。
  5. 键盘左右箭头也能切。
  6. visibilitychange 时页面隐藏就停播,避免后台空转。

源码地址

gitee

gitee.com/zheng_yongt…

github

github.com/yongtaozhen…


🌟 觉得有帮助的可以点个 star~

🖊 有什么问题或错误可以指出,欢迎 pr~

📬 有什么想要实现的功能或想法可以联系我~


公众号推广

关注公众号 前端也能这么有趣 ,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。