文章摘要 FakeGPT
加载中...|
为了让博客底色更加灵动富有科幻感,本阶段重磅集成了粒子吸附网格以及指针轨迹拖尾特效。
安装依赖:
该功能基于 HTML5 Canvas 原生 2D 绘图 API 研发,代码完全自包含在 Vue 组件中,无需安装任何额外的外部 npm 依赖包。
1. Canvas 粒子网格特效封装 (CanvasNest.vue)
我将原生 Canvas 粒子动力学封装为了一个声明式的 Vue 全局组件,通过优化算法消除了大量粒子在渲染循环中的重复距离计算开销(
点击查看 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) 中暴露按钮,允许访客随时开启或屏蔽粒子特效(在移动端已自动做降级减数处理,防止低端机发热)。
Canvas 粒子特效与个性化开关https://blog.chgr.cc/posts/2026/0504
评论 隐私政策