基于Canvas实现的鼠标点击效果-支持圆形、心形、自定义形状等等

954 阅读13分钟

写在开头

哈喽呀,各位早上好!😀

当前2024年10月02号,上午,各位UU国庆节快乐呀,假期才刚开始,请尽情玩耍。🥳🥳🥳

回到正题,这次要分享的是鼠标点击效果,具体效果如下,请诸君按需食用。

20241002-1.gif

(不知道为啥,生成的gif效果有点不尽人意😕,真实网页上操作效果还是不错的)

简单的图形绘制

虽然此案例的难度相对一般,不过在整体过程中还是存在一些技巧的。咱们按照由简入难的顺序,先从最为简单的部分入手,用 Canvas 来绘制一个圆形。

<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        padding: 0;
        margin: 0;
        height: 100vh;
        background-color: #111111;
        position: relative;
      }
      #canvas {
        /* 让canvas铺满屏幕 */
        display: block;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" />
    <script>
      const canvas = document.getElementById('canvas');
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      const ctx = canvas.getContext('2d');
      
      // 设置画笔颜色
      ctx.fillStyle = '#F89B30';
      // 开始绘制
      ctx.beginPath();
      // 绘制圆形
      ctx.arc(100, 100, 50, 0, 2 * Math.PI);
      // 填充颜色
      ctx.fill();
    </script>
  </body>
</html>

效果:

image.png

So easy吧!😋

再来绘制一个爱心形状:

<script>
const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");

// 绘制心形函数
function drawHeart(ctx, x, y, size) {
  ctx.fillStyle = '#F89B30';
  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.bezierCurveTo(
    x + size / 2,
    y - size / 2,
    x + size / 2,
    y + size / 4,
    x,
    y + size / 2
  );
  ctx.bezierCurveTo(
    x - size / 2,
    y + size / 4,
    x - size / 2,
    y - size / 2,
    x,
    y
  );
  ctx.closePath();
  ctx.fill();
}
// 绘制心形图案
drawHeart(ctx, 150, 150, 70);
</script>

效果:

image.png

Em...有点丑,但也算是一个心形啦,不要在意细节。😋

这里会涉及到一个 贝塞尔曲线 的概念,相信作为前端人员的你应该有所耳闻。这可是一个不错的东西,虽然有点复杂,但也值得学习。

...小编在这里就不过多赘述啦(绝对不是因为我也不会💀),如果你对它感兴趣的话可以自己去学习一下,要是觉得学起来有困难,咱们也可以借助工具嘛,直接问 AI 生成就可以啦。😋

封装思想

然后,你瞧上面哈,咱们已经能通过 Canvas 画出想要的图形啦。不过呢,你想啊,如果要画好多图形,每次都得单独去写,那也挺麻烦的!为了咱们后面做案例的时候用起来更顺手,有更多的选择余地,咱可以稍微动动脑筋,把绘制图形的这个过程给它包装包装,也就是封装一下。

也不难,来瞧瞧:

class Shape {
  constructor(type, x, y, size, color) {
    this.type = type;
    this.x = x;
    this.y = y;
    this.size = size;
    this.color = color;
  }
  draw(ctx) {
    ctx.fillStyle = this.color;
    switch (this.type) {
      case "circle":
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
        ctx.closePath();
        ctx.fill();
        break;
      case "heart":
        ctx.beginPath();
        ctx.moveTo(this.x, this.y);
        ctx.bezierCurveTo(
          this.x + this.size / 2,
          this.y - this.size / 2,
          this.x + this.size / 2,
          this.y + this.size / 4,
          this.x,
          this.y + this.size / 2
        );
        ctx.bezierCurveTo(
          this.x - this.size / 2,
          this.y + this.size / 4,
          this.x - this.size / 2,
          this.y - this.size / 2,
          this.x,
          this.y
        );
        ctx.closePath();
        ctx.fill();
        break;
      case "square":
        ctx.fillRect(
          this.x - this.size / 2,
          this.y - this.size / 2,
          this.size,
          this.size
        );
        break;
      case "triangle":
        ctx.beginPath();
        ctx.moveTo(this.x, this.y - this.size / 2);
        ctx.lineTo(this.x + this.size / 2, this.y + this.size / 2);
        ctx.lineTo(this.x - this.size / 2, this.y + this.size / 2);
        ctx.closePath();
        ctx.fill();
        break;
      default:
        console.error("Shape type not recognized");
    }
  }
}

增加了一个绘制图形的类,使用的时候:

const circle = new Shape("circle", 100, 100, 50, "#F89B30");
circle.draw(ctx);

const heart = new Shape("heart", 230, 100, 70, "#F89B30");
heart.draw(ctx);

const square = new Shape("square", 370, 100, 70, "#F89B30");
square.draw(ctx);

const triangle = new Shape("triangle", 500, 100, 70, "#F89B30");
triangle.draw(ctx);

效果:

image.png

是不是就很方便啦。👏

不过,你以为这就完了?😕

不不不,这封装还不够灵活,咱们目前只是在里面内置了四个图形的绘制过程而已啦。你想想看,如果以后我们突然想画长方形、梯形之类的其他图形,难道还得在 Shape 类里面硬塞进去吗?要是它是一个从 npm 下载的包,你根本改不了源码;或者这个类里面的逻辑复杂得像一团乱麻,不像现在这么简单,你要加个新图形,就得费好大劲儿去琢磨以前那些代码的情况,那可就头大了!

所以,既然要封装,咱们就得考虑周全点儿,给其留点"后路",不过具体要如何做,不着急,来个小插曲先。👉


现有如下一份数据:

const listData = [
  { name: "橙某人", sex: "男", age: 18 },
  { name: "柳如烟", sex: "女", age: 18 },
  { name: "欧阳飞飞", sex: "男", age: 30 },
  { name: "娜娜", sex: "女", age: 12 },
  { name: "八卦", sex: "未知", age: 60 },
  { name: "橙某人", sex: "女", age: 18 },
];

要求你进行一些数据的统计,如:

🍊统计一下男女的人数,结果大概这样子就行:{男: 2, 女: 3, 未知: 1}

很简单嘛,三下五除二的事:

const result = {};
listData.forEach(user => {
  if (result[user.sex]) {
    result[user.sex]++;
  }else {
    result[user.sex] = 1;
  }
})
console.log(result); // {男: 2, 女: 3, 未知: 1}

很简单,没毛病。👏

🍊再来,统计一下姓名、年龄不同的人数。

Em...也不难,改成函数就解决:

function statistics(data, key) {
  const result = {};
  data.forEach(item => {
    if (result[item[key]]) {
      result[item[key]]++;
    } else {
      result[item[key]] = 1;
    }
  });
  return result;
}
console.log(statistics(listData, 'sex')); // {男: 2, 女: 3, 未知: 1}
console.log(statistics(listData, 'name')); // {橙某人: 2, 柳如烟: 1, 欧阳飞飞: 1, 娜娜: 1, 八卦: 1}
console.log(statistics(listData, 'age')); // {12: 1, 18: 3, 30: 1, 60: 1}

可以了💯,下班下班。

🍊等等,再来一个,统计姓名长度不同的人数,结果要求是这样子:{2: 2, 3: 3, 4: 1}

我:❓❓❓(xxxxxx省略一万字)

这回得好好想一想,防止老登又来回首掏。😤

最终结果:

function statistics(data, key) {
  const result = {};
  data.forEach(item => {
    const keyStatistics = typeof key === 'string' ? item[key] : key(item);
    if (result[keyStatistics]) {
      result[keyStatistics]++;
    } else {
      result[keyStatistics] = 1;
    }
  });
  return result;
}
console.log(statistics(listData, 'sex')); // {男: 2, 女: 3, 未知: 1}
console.log(statistics(listData, 'name')); // ..
console.log(statistics(listData, 'age')); // ...
console.log(statistics(listData, item => item.name.length)); // {2: 2, 3: 3, 4: 1}

这下就不怕了,可以提前收工啦。😋

🍊再统计一下成年与未成年的人数?

Easy:

console.log(statistics(listData, item => item.age >= 18 ? '成年' : '未成年')); // {成年: 5, 未成年: 1}

好,插曲完结,相信你能有那么一丢丢体会吧。😂

然后,咱们回来看看,要如何来重新封装一下绘制图形过程呢❓

且看:

class Shape {
  constructor(type, x, y, size, color) {
    this.type = type;
    this.x = x;
    this.y = y;
    this.size = size;
    this.color = color;
  }
  draw(ctx) {
    if(this.color) {
      ctx.fillStyle = this.color;
    }
    switch (this.type) {
      case "circle":
        // ...
      case "heart":
        // ...
      case "square":
        // ...
      case "triangle":
        // ...
      case "custom":
        if (typeof this.customDraw === 'function') {
          this.customDraw(ctx, this.x, this.y, this.size, this.color);
        } else {
          console.error("Custom draw function is not defined");
        }
        break;
      default:
        console.error("Shape type not recognized");
    }
  }
  /** @description 允许外部设置自定义绘制函数 **/ 
  setCustomDraw(func) {
        this.customDraw = func;
  }
}

增加的代码不多,主要是提供了一种外部自定义的可能。

来看看具体使用情况:

// 通过自定义形式绘制长方形
const rectangle = new Shape('custom', 600, 80, 80, "#F89B30");
rectangle.setCustomDraw((ctx, x, y, size, color) => {
  const halfSize = size / 2;
  ctx.beginPath();
  ctx.rect(x, y, size, size / 2);
  ctx.closePath();
  ctx.fill();
});
rectangle.draw(ctx);

// 通过自定义形式绘制梯形
const trapezoid = new Shape('custom', 800, 100, 60, "#F89B30");
trapezoid.setCustomDraw((ctx, x, y, size, color) => {
  const halfSize = size / 2;
  ctx.beginPath();
  ctx.moveTo(x - halfSize, y + halfSize);
  ctx.lineTo(x + halfSize, y + halfSize);
  ctx.lineTo(x + size, y - halfSize);
  ctx.lineTo(x - size, y - halfSize);
  ctx.closePath();
  ctx.fill();
});
trapezoid.draw(ctx);

效果:

image.png

点击时绘制图形

经过上述的学习,咱们已经对 Canvas 绘制图形有了一定的了解啦。而接下来我们要将其与点击事件结合起来,大概过程就是,鼠标每点击一次,就在点击的位置绘制图形就可以了。

简单!如下:

canvas.addEventListener('click', e => {
  const { clientX, clientY } = e;
  const circle = new Shape("circle", clientX, clientY, 10, "#F89B30");
  circle.draw(ctx);
});

不过,还不够,每次创建的图形都是一样的颜色,没意思😪,咱们引入一些预置的颜色进行随机,如下:

const colors = ['#8A2BE2', '#D7263D', '#FFD700', '#3A86FF', '#00CED1', '#7CB342'];

canvas.addEventListener('click', e => {
  const { clientX, clientY } = e;
  // 每次随机取一个颜色
  const color = colors[Math.floor(Math.random() * colors.length)];
  const circle = new Shape("circle", clientX, clientY, 10, color);
  circle.draw(ctx);
});
image.png

而同样,图形大小也可以进行一个随机生成:

const size = Math.random() * 5 + 2;
const circle = new Shape("circle", clientX, clientY, size, color);
image.png

从这里可以看到,经过前面对 Shape 类的封装,我们很轻易就能通过控制一些参数变化来改变图形的行为。

图形变化的交互

回想小编最初展示的案例动态图,分析一波,其实原理就是在鼠标点击的那个瞬间,一次性创建了多个图形,它们的颜色是随机的、大小也是随机的,并且"位置更是随机的";而看着乱蹦跶的效果,本质是位置不停地移动,大小不断地缩小,直至在某个时刻直接隐藏该图形。

20241002-1.gif

依据这个原理过程,我们就能进行编码了。🙇

先来给 Shape 类增加一个可以改变图形位置与大小的方法。

class Shape {
  ...
  /** @description 不断更新图形的位置与大小 **/
  update() {
    // 位置不断向外移动(先向上)
    this.x += -2;
    this.y += -2;
    // 大小不断缩小
    this.size *= 0.98;
  }
}

处理点击行为与绘制图形:

const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const ctx = canvas.getContext("2d");

// 预置颜色
const colors = ['#8A2BE2', '#D7263D', '#FFD700', '#3A86FF', '#00CED1', '#7CB342'];
// 绘制的图形集合
let shapes = [];

// 监听点击事件
canvas.addEventListener('click', e => {
  createMouseEffect(e.clientX, e.clientY);
});

/** @description 创建鼠标交互效果 **/
function createMouseEffect(x, y) {
  // 每次点击绘制50个图形
  const shapeNum = 50;
  for (let i = 0; i < shapeNum; i++) {
    // 随机颜色与大小
    const color = colors[Math.floor(Math.random() * colors.length)];
    const size = Math.random() * 5 + 2;
    // 先把点击后创建的图形存起来一下
    shapes.push(new Shape('circle', x, y, size, color));
  }
}

// 开启一个定时器,不断进行绘制与清空,达到一个运动的场景
setInterval(() => {
  // 不断清空画布
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // 批量配置已经存在的图形
  drawShapes();
}, 10);

/** @description 创建鼠标交互效果 **/
function drawShapes() {
  for (let i = 0; i < shapes.length; i++) {
    const shape = shapes[i];
    // 更新形状(轨迹、大小)
    shape.update();
    shape.draw(ctx);
    // 形状大小到了一定程度就移除销毁
    if (shapes[i].size <= 2) {
      shapes.splice(i, 1);
      i--;
    }
  }
}

效果:

20241002-2.gif

❓ 咋像豌豆射手?不是绘制了50个图形,怎么只有一个呢?

不着急,调整一下:

class Shape {
  ...
  update() {
    // -2 改成 2
    this.x += 2;
    this.y += 2;
    this.size *= 0.98;
  }
}

效果:

20241002-3.gif

从这可以发现调整 xy增量,能控制图形的运动,也能改变图形的运动方向❗

而由于每次鼠标点击创建的50个图形,xy 是一样的,并且其增量也是一样的,所以它们都重叠在一起往同一个方向运动了。

为此,我们需要给它们不同的增量,让每个图形往不同的方向去运动。

class Shape {
  constructor(type, x, y, size, color, speedX, speedY) {
    // ...
    // 增量
    this.speedX = speedX;
    this.speedY = speedY;
  }
  ...
  update() {
    this.x += this.speedX;
    this.y += this.speedY;
    this.size *= 0.98;
  }
}

给每个图形随机生成一个"运动增量",修改 createMouseEffect 方法:

function createMouseEffect(x, y) {
  const shapeNum = 50;
  for (let i = 0; i < shapeNum; i++) {
    const color = colors[Math.floor(Math.random() * colors.length)];
    const size = Math.random() * 5 + 2;
    // 为每个图形生成随机的增量
    const speedX = (Math.random() * 2 - 1) * 2;
    const speedY = (Math.random() * 2 - 1) * 2;
    shapes.push(new Shape('circle', x, y, size, color, speedX, speedY));
  }
}

这样就大功告成啦,运行后能达到我们一开始看到的动效图了。🥳

优化-requestAnimationFrame

当然,这还没完呢!

由于使用了 setInterval API,我们就需要特别注意性能的问题。上面的代码还需要进一步优化,不能让定时器一直跑着,需要在有条件的时候才使用。不过,这仅是一方面问题,本身这里使用 setInterval API就不太合理了。❌

在以前没条件的时候,使用 setInterval API来完成动画方面的工作是没办法的事,但现在咱们有条件了嘛💰,需要做出一点改变了。

就不卖关子了,如果需要通过时间间隔不断地执行函数从而来完成动画的功能,现在是推荐使用 requestAnimationFrame API来完成。✅

window.requestAnimationFrame(callback) 方法会告诉浏览器你希望执行一个动画。它要求浏览器在下一次重绘之前,调用用户提供的回调函数。

来看看优化之后的情况:

function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawShapes();
    // 以递归调用的形式不断执行
    requestAnimationFrame(animate);
}

animate();

咱们只要把定时器换成一个函数,通过递归调用的形式让其不断执行就行了,都是老套路啦。😁

最终的效果就与小编开始展示的效果是一致的,动画看起来也更流畅一点噢。

再来插播一点额外内容🔉:

setInterval

  • setInterval API 是按照指定的时间间隔不断地执行函数。例如,setInterval(func, 10) 会尝试每隔 10 毫秒调用一次 func 。但实际上,它并不考虑浏览器的渲染帧率或者页面的性能状况。如果在执行函数的过程中,由于代码复杂或者其他因素导致执行时间超过了设定的间隔时间,就会出现排队等待执行的情况,这可能会导致性能问题,如页面卡顿。
  • 还有,假设在 setInterval API 的回调函数中有大量的复杂计算或者 DOM 操作,当这些操作的耗时总和超过了间隔时间,浏览器就需要同时处理多个排队的任务,这会占用大量的 CPU 资源。
  • 它适合用于一些不需要高精度动画效果的定时任务,比如定期检查服务器数据更新、简单的计数器、每隔一段时间更新网页上的小部件的状态等等,反正就是对动画流畅性没有严格要求,那么就可以尝试使用 setInterval API来完成需求。

requestAnimationFrame

  • requestAnimationFrame 是专门为了实现流畅的动画而设计的。它会在浏览器下一次重绘之前调用指定的函数,这个调用时机是由浏览器的渲染机制决定的。通常情况下,浏览器的刷新率是 60Hz(每秒 60 次),所以每一帧的时间间隔大约是 16.67 毫秒(1000/60)。请记住这个时间,记得在一次面试中就和面试官讨论过这个时间情况😓,真人真事,没开玩笑!
  • requestAnimationFrame 会自动适应浏览器的刷新率,并且会在页面处于非激活状态(如浏览器窗口被最小化或者切换到其他标签页)时暂停调用,从而节省资源。这使得它在处理动画时能够更加高效地利用浏览器的性能,避免不必要的资源浪费。
  • 它几乎所有需要在浏览器中实现流畅动画效果的场景都适合使用,如游戏开发、复杂的 UI 动画(如元素的平滑移动、缩放、旋转等)。还有,制作一个带有动画效果的图表,图表中的元素需要根据数据动态变化并且要求动画效果流畅,此时 requestAnimationFrame 就是更好的选择。

更多效果案例

心形效果:

function createMouseEffect(x, y) {
  const shapeNum = 50;
  for (let i = 0; i < shapeNum; i++) {
    const color = colors[Math.floor(Math.random() * colors.length)];
    const size = Math.random() * 5 + 25;
    const speedX = (Math.random() * 2 - 1) * 2;
    const speedY = (Math.random() * 2 - 1) * 2;
    shapes.push(new Shape("heart", x, y, size, color, speedX, speedY));
  }
}

20241003-1.gif

正方形效果:

function createMouseEffect(x, y) {
  const shapeNum = 50;
  for (let i = 0; i < shapeNum; i++) {
    const color = colors[Math.floor(Math.random() * colors.length)];
    const size = Math.random() * 5 + 20;
    const speedX = (Math.random() * 2 - 1) * 2;
    const speedY = (Math.random() * 2 - 1) * 2;
    shapes.push(new Shape("square", x, y, size, color, speedX, speedY));
  }
}

20241003-2.gif

梯形效果:

function createMouseEffect(x, y) {
  const shapeNum = 50;
  for (let i = 0; i < shapeNum; i++) {
    const color = colors[Math.floor(Math.random() * colors.length)];
    const size = Math.random() * 5 + 18;
    const speedX = (Math.random() * 2 - 1) * 2;
    const speedY = (Math.random() * 2 - 1) * 2;
    const trapezoid = new Shape('custom', x, y, size, color, speedX, speedY);
    trapezoid.setCustomDraw((ctx, x, y, size, color, speedX, speedY) => {
      ctx.fillStyle = color;
      const halfSize = size / 2;
      ctx.beginPath();
      ctx.moveTo(x - halfSize, y + halfSize);
      ctx.lineTo(x + halfSize, y + halfSize);
      ctx.lineTo(x + size, y - halfSize);
      ctx.lineTo(x - size, y - halfSize);
      ctx.closePath();
      ctx.fill();
    });
    shapes.push(trapezoid)
  }
}

20241003-3.gif

完整实现

<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      padding: 0;
      margin: 0;
      height: 100vh;
      background-color: #111111;
      position: relative;
    }
    #canvas {
      display: block;
    }
</style>
  </head>
<body>
  <canvas id="canvas" />
  <script>
    const canvas = document.getElementById("canvas");
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    const ctx = canvas.getContext("2d");
    const colors = [
        "#8A2BE2",
        "#D7263D",
        "#FFD700",
        "#3A86FF",
        "#00CED1",
        "#7CB342",
    ];
    let shapes = [];

    canvas.addEventListener("click", (e) => {
      createMouseEffect(e.clientX, e.clientY);
    });
    
    animate();

    class Shape {
      constructor(type, x, y, size, color, speedX, speedY) {
        this.type = type;
        this.x = x;
        this.y = y;
        this.size = size;
        this.color = color;
        this.speedX = speedX;
        this.speedY = speedY;
      }
      draw(ctx) {
        console.log('this.type', this.type)
        if (this.color) {
          ctx.fillStyle = this.color;
        }
        switch (this.type) {
          case "circle":
            ctx.beginPath();
            ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
            ctx.closePath();
            ctx.fill();
            break;
          case "heart":
            ctx.beginPath();
            ctx.moveTo(this.x, this.y);
            ctx.bezierCurveTo(
                this.x + this.size / 2,
                this.y - this.size / 2,
                this.x + this.size / 2,
                this.y + this.size / 4,
                this.x,
                this.y + this.size / 2
            );
            ctx.bezierCurveTo(
                this.x - this.size / 2,
                this.y + this.size / 4,
                this.x - this.size / 2,
                this.y - this.size / 2,
                this.x,
                this.y
            );
            ctx.closePath();
            ctx.fill();
            break;
          case "square":
            ctx.fillRect(
                this.x - this.size / 2,
                this.y - this.size / 2,
                this.size,
                this.size
            );
            break;
          case "triangle":
            ctx.beginPath();
            ctx.moveTo(this.x, this.y - this.size / 2);
            ctx.lineTo(this.x + this.size / 2, this.y + this.size / 2);
            ctx.lineTo(this.x - this.size / 2, this.y + this.size / 2);
            ctx.closePath();
            ctx.fill();
            break;
          case "custom":
            if (typeof this.customDraw === "function") {
              this.customDraw(ctx, this.x, this.y, this.size, this.color, this.speedX, this.speedY);
            } else {
              console.error("Custom draw function is not defined");
            }
            break;
          default:
            console.error("Shape type not recognized");
        }
      }
      setCustomDraw(func) {
        this.customDraw = func;
        this.type = "custom";
      }
      update() {
        this.x += this.speedX;
        this.y += this.speedY;
        this.size *= 0.98;
      }
    }
    function createMouseEffect(x, y) {
      const shapeNum = 50;
      for (let i = 0; i < shapeNum; i++) {
        const color = colors[Math.floor(Math.random() * colors.length)];
        const size = Math.random() * 5 + 4;
        const speedX = (Math.random() * 2 - 1) * 2;
        const speedY = (Math.random() * 2 - 1) * 2;
        shapes.push(new Shape('circle', x, y, size, color, speedX, speedY));   
      }
    }
    function drawShapes() {
      for (let i = 0; i < shapes.length; i++) {
        const shape = shapes[i];
        shape.update();
        shape.draw(ctx);
        if (shapes[i].size <= 2) {
          shapes.splice(i, 1);
          i--;
        }
      }
    }
    function animate() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      drawShapes();
      requestAnimationFrame(animate);
    }
  </script>
</body>
</html>




至此,本篇文章就写完啦,撒花撒花。

image.png