文章摘要
加载中...|
此内容根据文章生成,并经过人工审核,仅用于文章内容的解释与总结

为了让博客底色更加灵动富有科幻感,本阶段重磅集成了粒子吸附网格以及指针轨迹拖尾特效。

安装依赖:

该功能基于 HTML5 Canvas 原生 2D 绘图 API 研发,代码完全自包含在 Vue 组件中,无需安装任何额外的外部 npm 依赖包

1. Canvas 粒子网格特效封装 (CanvasNest.vue)

我将原生 Canvas 粒子动力学封装为了一个声明式的 Vue 全局组件,通过优化算法消除了大量粒子在渲染循环中的重复距离计算开销(O(N) 级别优化)。

点击查看 CanvasNest.vue 完整代码
vue
// .vitepress/theme/components/CanvasNest.vue
<template>
  <canvas ref="canvasRef" class="canvas-nest"></canvas>
</template>

<script setup>
import { onMounted, onUnmounted, ref, watch } from "vue";

const props = defineProps({
  count: {
    type: Number,
    default: 150,
  },
  opacity: {
    type: Number,
    default: 0.7,
  },
  zIndex: {
    type: Number,
    default: -1,
  },
});

const canvasRef = ref(null);
const isMobile = ref(false);
let context = null;
let animationId = null;
let width, height;

const particles = [];
const mouse = { x: null, y: null, max: 20000 };

const fixedColors = [
  "rgba(255, 0, 0, 1.0)",
  "rgba(0, 255, 0, 1.0)",
  "rgba(0, 0, 255, 1.0)",
  "rgba(255, 255, 0, 1.0)",
  "rgba(0, 255, 255, 0.8)",
  "rgba(255, 0, 255, 0.8)",
  "rgba(255, 165, 0, 0.8)",
  "rgba(127, 255, 212, 1.0)",
  "rgba(0, 255, 127, 1.0)",
];

const checkMobile = () => {
  isMobile.value =
    /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
      navigator.userAgent,
    ) || window.innerWidth <= 768;
};

const resize = () => {
  if (!canvasRef.value) return;
  width = canvasRef.value.width = window.innerWidth;
  height = canvasRef.value.height = window.innerHeight;
  checkMobile();
};

const initParticles = () => {
  particles.length = 0;
  // 确保 count 是数字
  let totalParticles = Number(props.count) || 150;
  // 移动端减少粒子数量以提升性能
  if (isMobile.value) {
    totalParticles = Math.min(totalParticles, 60);
  }
  for (let i = 0; i < totalParticles; i++) {
    const x = Math.random() * width;
    const y = Math.random() * height;
    const xa = 2 * Math.random() - 1;
    const ya = 2 * Math.random() - 1;
    const color = fixedColors[Math.floor(Math.random() * fixedColors.length)];
    particles.push({ x, y, xa, ya, max: 6000, color });
  }
};

const draw = () => {
  if (!context) return;
  context.clearRect(0, 0, width, height);

  const total = particles.length;

  for (let i = 0; i < total; i++) {
    const p = particles[i];

    // 移动
    p.x += p.xa;
    p.y += p.ya;

    // 碰撞边界
    if (p.x > width || p.x < 0) p.xa *= -1;
    if (p.y > height || p.y < 0) p.ya *= -1;

    // 绘制粒子
    context.fillStyle = p.color;
    context.fillRect(p.x - 0.5, p.y - 0.5, 1, 1);

    // 与其他粒子连线
    for (let j = i + 1; j < total; j++) {
      const n = particles[j];
      const dx = p.x - n.x;
      const dy = p.y - n.y;
      const dist = dx * dx + dy * dy;

      if (dist < n.max) {
        const alpha = (n.max - dist) / n.max;
        context.beginPath();
        context.lineWidth = alpha / 2;
        // 更稳健的颜色替换逻辑
        const colorBase = p.color.substring(0, p.color.lastIndexOf(",") + 1);
        context.strokeStyle = `${colorBase} ${alpha})`;
        context.moveTo(p.x, p.y);
        context.lineTo(n.x, n.y);
        context.stroke();
      }
    }

    // 鼠标交互 (仅在非移动端开启)
    if (!isMobile.value && mouse.x !== null) {
      const dx = p.x - mouse.x;
      const dy = p.y - mouse.y;
      const dist = dx * dx + dy * dy;

      if (dist < mouse.max) {
        // 吸附效果
        if (dist >= mouse.max / 2) {
          p.x -= 0.03 * dx;
          p.y -= 0.03 * dy;
        }

        // 绘制到鼠标的连线
        const alpha = (mouse.max - dist) / mouse.max;
        context.beginPath();
        context.lineWidth = alpha / 2;
        const colorBase = p.color.substring(0, p.color.lastIndexOf(",") + 1);
        context.strokeStyle = `${colorBase} ${alpha})`;
        context.moveTo(p.x, p.y);
        context.lineTo(mouse.x, mouse.y);
        context.stroke();
      }
    }
  }

  animationId = requestAnimationFrame(draw);
};

const handleMouseMove = (e) => {
  mouse.x = e.clientX;
  mouse.y = e.clientY;
};

const handleMouseOut = () => {
  mouse.x = null;
  mouse.y = null;
};

// 监听数量变化并重新初始化
watch(
  () => props.count,
  () => {
    initParticles();
  },
);

onMounted(() => {
  context = canvasRef.value.getContext("2d");
  resize();
  initParticles();
  draw();

  window.addEventListener("resize", resize);
  window.addEventListener("mousemove", handleMouseMove);
  window.addEventListener("mouseout", handleMouseOut);
});

onUnmounted(() => {
  cancelAnimationFrame(animationId);
  window.removeEventListener("resize", resize);
  window.removeEventListener("mousemove", handleMouseMove);
  window.removeEventListener("mouseout", handleMouseOut);
});
</script>

<style scoped>
.canvas-nest {
  position: fixed;
  top: 0;
  left: 0;
  pointer-events: none;
  z-index: v-bind(zIndex);
  opacity: v-bind(opacity);
}
</style>

2. 配置提取与全局挂载

为了使粒子参数即改即生效,我将配置项提取到 themeConfig.mjs 中,并在 Background.vue 中进行动态注册:

javascript
// .vitepress/theme/assets/themeConfig.mjs
background: {
  canvasNest: {
    enable: true,
    count: 300,
    opacity: 0.7,
    zIndex: -1,
  }
}

3. 全局状态与个性化开关

通过 Pinia 管理 showCanvasNest 开关,并在左下角 “个性化配置” (Settings) 中暴露按钮,允许访客随时开启或屏蔽粒子特效(在移动端已自动做降级减数处理,防止低端机发热)。

评论 隐私政策