几周前的某天下午,我走进了一家没什么游客的水族馆,阳光透过水面撒在池底,那些流动的光斑像无声地在地面上跳舞。我站了很久,脑中突然冒出一个想法:我能不能用代码,把这静谧又灵动的场景复现出来?

于是,就有了这个项目 —— 一个可以随着指尖触碰而泛起水波的互动页面。


开端:从一个灵感开始的工程

我没打算一开始就做得多复杂。只是希望当用户滑动鼠标或触屏的时候,能看到一圈圈类似水面的波纹扩散出去。随着这个想法越来越清晰,我开始着手设计和实现它的核心机制:基于 Canvas 的涟漪动画。

工具栈我没有纠结太久。为了效率和现代化,我选了 Vue 3 + TypeScript + Vite 的组合。Canvas 自然是负责绘图的载体,渐变背景+圆形扩散+随机颜色,这几个关键点成了项目的基底。

我在纸上画了这样一张逻辑流图,简要概括了系统的运转机制:

用户交互(鼠标 / 触摸)
    ↓
计算位置坐标
    ↓
创建涟漪对象并记录
    ↓
动画帧循环更新
    ↓
清空画布,绘制背景
    ↓
绘制所有当前涟漪

再配上主题配置系统,整个系统就具备了可视风格与参数的可定制能力。


架构搭建:把灵感落在文件夹里

为了方便管理和后续维护,我将整个项目按以下结构进行划分:

src/
├── assets/               # 静态资源(主题配置)
│   └── themes.json
├── components/           # UI 组件
│   └── RippleCanvas.vue
├── composables/          # 组合式函数逻辑
│   └── useRippleEffect.ts
├── App.vue               # 根组件
└── main.ts               # 应用入口

入口 main.ts 并没有过多逻辑,只负责创建并挂载 Vue 实例:

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

这样可以保证后续逻辑的清晰度和可扩展性。


可配置的主题系统:颜色、速度与风格的总控中心

我希望即便不是开发者的人也能轻松调整涟漪效果,因此将所有动画参数、颜色方案、背景样式封装到一个 JSON 文件中:

{
  "themes": {
    "deepOcean": {
      "name": "深海之夜",
      "background": {
        "gradient": {
          "start": "#001f3f",
          "end": "#003366"
        }
      },
      "ripples": {
        "colors": ["#00B4D8", "#0077B6", "#0096C7"],
        "speed": 1.5,
        "fadeSpeed": 0.01,
        "maxRadius": 150,
        "lineWidth": 2
      }
    }
  },
  "defaultTheme": "deepOcean"
}

设计中我尤其注意到:

  • colors 使用多个蓝色调作为随机选择项,避免视觉疲劳。
  • speedfadeSpeed 分别控制扩散速度与淡出速率,通过参数化让风格更具延展性。
  • 背景渐变使用的是线性模式,从顶部到底部逐渐变深,模仿光线穿透海水的感觉。

涟漪动画的诞生:Canvas + JS + 一点灵感

整个动画核心的绘制和逻辑控制被封装在了一个组合函数中:useRippleEffect.ts。这个文件就像项目的“发动机”,驱动着每一次涟漪的形成、扩展与消散。

整体流程结构

我用流程图还原了一次完整的动画迭代过程:

简单制作一个互动页面_json

每个涟漪对象都有如下属性:

  • x, y: 出现位置
  • radius: 当前半径
  • alpha: 当前透明度
  • color: 本次随机选中的颜色
  • lineWidth: 线宽

这些数据保存在一个数组中,在每一帧循环里逐一更新它们的状态并绘制。


主组件实现:一个 canvas 就足够

UI 的主体是 RippleCanvas.vue 组件,代码并不多,但结构清晰。核心就是 Canvas 元素的挂载与监听:

<template>
  <canvas 
    ref="canvasRef"
    @mousemove="createRipple"
    @touchmove="handleTouch"
  />
</template>

其中:

  • canvasRef 用于获取 DOM 元素
  • createRipple 是鼠标事件创建涟漪的处理器
  • handleTouch 则是移动端触摸支持

组件挂载后,我设置了:

  1. Canvas 自动适应全屏窗口
  2. 监听窗口变化自动 resize
  3. 启动动画帧循环
  4. 离开页面时清理资源
onMounted(() => {
  const canvas = canvasRef.value
  const ctx = canvas?.getContext('2d')
  if (!canvas || !ctx) return

  resizeCanvas(canvas)
  window.addEventListener('resize', () => resizeCanvas(canvas))

  init(canvas, ctx, currentTheme)
  startAnimation()
})

onUnmounted(() => {
  stopAnimation()
})

交互细节:从一次触碰开始

项目支持两种交互方式:

  • 鼠标移动:直接触发单点涟漪
  • 触摸滑动:沿轨迹创建连续波纹

我在触摸事件中加了节流处理,防止频繁创建导致性能问题:

let lastTouchTime = 0
const touchInterval = 80 // 毫秒

const handleTouch = (e: TouchEvent) => {
  const now = Date.now()
  if (now - lastTouchTime < touchInterval) return
  lastTouchTime = now
  // 创建涟漪逻辑
}

此外,每次创建涟漪时会从主题中随机选一个颜色,确保画面不会太单调。


涟漪绘制的秘密:渐隐 + 线性扩散

每个涟漪其实就是一圈透明度逐渐降低、半径逐渐扩大的圆:

ctx.beginPath()
ctx.arc(ripple.x, ripple.y, ripple.radius, 0, 2 * Math.PI)
ctx.lineWidth = ripple.lineWidth
ctx.strokeStyle = ripple.color
ctx.globalAlpha = ripple.alpha
ctx.stroke()

涟漪状态更新则是:

ripple.radius += speed
ripple.alpha -= fadeSpeed

当透明度降为0或半径超出最大值时,涟漪就从数组中删除,等待下一次点击创建新的波纹。


高DPI 支持:避免 Retina 模糊

我在测试中发现一个问题:在 Retina 屏幕上,Canvas 渲染出来很模糊。查资料后才知道,Canvas 的像素密度需要手动适配高 DPI:

const ratio = window.devicePixelRatio || 1
canvas.width = window.innerWidth * ratio
canvas.height = window.innerHeight * ratio
canvas.style.width = `${window.innerWidth}px`
canvas.style.height = `${window.innerHeight}px`
ctx.scale(ratio, ratio)

通过设置真实像素与显示像素比例,并缩放绘图上下文,就能让画面变得锐利清晰。


构建与部署:Vite 带来的极速体验

这个项目在本地开发体验上几乎是无可挑剔的,主要得益于 Vite 的极速冷启动和模块热替换。构建配置相对简单,我的 vite.config.ts 中只做了基础设定:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 8080
  },
  build: {
    outDir: 'dist',
    emptyOutDir: true
  }
})

部署则更简单,打包后将 dist 文件夹上传到任何支持静态资源托管的平台即可,例如 GitHub Pages、Netlify、Vercel。

package.json 的脚本定义:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

实际部署时,我使用了 GitHub Pages,只需将打包目录推送至 gh-pages 分支即可完成。


Canvas 性能优化:内存与帧率之间的权衡

虽然 Canvas 本身性能不差,但在某些旧手机上,如果快速滑动或频繁点击,依然可能出现卡顿。我针对这类问题做了以下优化:

1. 控制涟漪最大数量

const MAX_RIPPLES = 100

if (ripples.value.length > MAX_RIPPLES) {
  ripples.value.shift()
}

这样可以避免内存堆积和帧率下降的问题,尤其在多点快速触发时,效果很明显。

2. 使用 requestAnimationFrame

原本我也考虑过 setInterval,但显然动画类的更新更适合 requestAnimationFrame,它能自动与浏览器刷新率保持一致,避免丢帧。


UI 设计考量:视觉流动与静谧感并存

整个页面没有 UI 元素,只有一整块画布。最初我也考虑加上切换主题、调节参数的面板,但那反而会破坏沉浸感。

我想营造的,是一种“屏保”式的互动场景 —— 你可以什么都不做,也能安静地欣赏画面;你也可以用指尖激起涟漪,与深蓝交互。

背景颜色选择:

渐变从 #001f3f#003366,这两个色值来自我在 Photoshop 里取色,还原了当时水族馆那个角落的光线深度。

涟漪颜色选择:

全部采用冷色系,但加入了轻微的亮度层级,从而让不同涟漪重叠时不会“糊”成一片,视觉上也有层次。


结构重用性设计:将逻辑与展示彻底分离

这个项目的 Canvas 动画逻辑虽然专属场景明显,但我仍刻意将其封装为一个组合式函数,原因有三:

  1. 便于测试和复用:将 useRippleEffect 拆出来后,可以独立测试其逻辑,未来有机会也可嵌入其他 UI 中。
  2. 便于参数注入与主题切换:组合函数接受外部参数,能够在不同配置下重复使用,不需要硬编码。
  3. 逻辑分离更易维护:未来即使我要添加其他动画形式(比如粒子、星星、下雪),也不会影响当前结构。

效果呈现:就像水光在数字中跳舞

最终的成品运行起来,正如我一开始想象的那样 —— 安静、纯粹,充满深度。它没有多余的交互或信息噪声,只有你、光影和流动。

每一圈波纹从指尖缓缓展开,融合进深蓝色的背景,又悄然消失;每一次点击,都像是一滴水落入数字的湖泊,泛起一场柔和的回响。

我用代码,模拟出了一种记忆中的自然场景——那种朦胧的、几乎要被遗忘的静谧。


项目复盘:我在涟漪中学到的那些东西

回头看这个项目,从构想到完成用了大约两周时间,虽然体量不大,但过程里我确实收获了很多。

我重新学习了 Canvas 动画

原以为这东西已经很熟,但真写起来才发现很多细节过去忽视了,比如 devicePixelRatio 的处理,比如 globalAlphastroke 的叠加效果,再比如如何做到既清空画布又保持层次感。

我深入用了 Vue 3 的组合式 API

过去我更多习惯使用 Options API,这次彻底用组合式写了一遍后,体会到逻辑拆分的好处,尤其在动画这种逻辑密集型的模块里,简直就是刚需。

我更理解了“交互”的设计

不只是加按钮、做响应,而是一种体验设计。什么时候该触发反馈?反馈该持续多久?是同步还是延迟?这些都需要揣摩和调试。

简单制作一个互动页面_前端_02

写在最后:这不是“酷炫动画”,而是内心的投影

如果你问我,“这个项目对你来说最特别的地方是什么?”我不会说是用了什么技术,也不会说用了多新的框架。我会说,是它让我用指尖,去触碰了记忆里那个水光闪动的瞬间。

我没有想要做一个“炫技”的作品,也没有打算做成开源库。它就像是我写的一首小诗,藏在浏览器里的静静一页。

当别人点开页面,滑动一下,看见那圈圈蓝色扩散出去,我希望他们也能短暂地感受到那种 —— 属于海洋、属于光、属于我们心底深处 —— 那份静谧与波动。