在之前的文章中【用JS实现星星拖尾效果(文章末尾附带完整代码)-CSDN博客】,我分享了基于 DOM 的星星拖尾效果实现,得到了不少反馈。很多朋友希望这个效果能更灵活地应用到任意网页,而不是局限在特定页面。于是,我决定将它升级为一个浏览器扩展,并转向 Canvas 技术,以提升性能和视觉表现。这篇文章将聚焦扩展的开发过程,分享从 DOM 到 Canvas 的转变原因、实现细节,以及如何让星光拖尾适配所有网页。
星星拖尾效果最初是为单个页面设计的,但实际使用场景中,用户希望在浏览任何网页时都能看到这种炫酷的光标效果,比如在社交媒体、博客甚至视频网站上。为此,将效果封装成浏览器扩展是理想选择,原因如下:
目标是让用户安装扩展后,鼠标移动时就能在任意网页触发彩色星星拖尾,带来沉浸式的交互感。
在之前的 DOM 实现中,我通过创建大量 div 元素模拟星星,配合 CSS 动画实现淡出效果。但在实际测试中,尤其是在视频网站(bilibili)或动态页面上,频繁的 DOM 操作带来了明显问题:
这些困难促使我转向 Canvas 技术。Canvas 的优势在于:
将效果转为扩展并使用 Canvas,面临以下挑战:
以下是实现的核心步骤,结合代码讲解如何解决上述挑战:
通过 manifest.json,我定义了扩展的基本信息和注入规则:
{
"manifest_version": 3,
"name": "Cursor Shape - Star Trail Effect",
"version": "1.0",
"description": "Add beautiful star trail effects to your cursor movement on any webpage",
"permissions": ["activeTab"],
"content_scripts": [
{
"matches": [""],
"css": ["styles.css"],
"js": ["content.js"]
}
],
"icons": {
"128": "icon.svg"
}
}
在 content.js 中,我创建了一个全屏 Canvas,确保星星绘制不被页面内容遮挡:
const canvas = document.createElement('canvas');
canvas.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 999999;';
document.body.appendChild(canvas);
与 DOM 版本类似,我通过固定步长控制星星生成频率,避免密集轨迹:
function shouldSpawnStar(distance) {
accumulatedDistance += distance;
const maxVariation = spawnFrequency * frequencyVariation;
const currentThreshold = spawnFrequency + Math.random() * maxVariation;
if (accumulatedDistance >= currentThreshold) {
accumulatedDistance = 0;
return true;
}
return false;
}
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas);
为了管理星星,我定义了一个 Star 类,包含位置、尺寸、颜色、角度、速度等属性,并处理更新和绘制:
class Star {
constructor(x, y, size, color, angle, speed) {
this.x = x;
this.y = y;
this.size = size;
this.color = color;
this.angle = angle;
this.speed = speed;
this.alpha = 1;
this.rotation = Math.random() * (rotationRange[1] - rotationRange[0]) + rotationRange[0];
}
update() {
this.x += Math.cos(this.angle) * this.speed;
this.y += Math.sin(this.angle) * this.speed;
this.alpha *= fadeSpeed;
this.rotation += 2;
return this.alpha > 0.1;
}
draw() {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation * Math.PI / 180);
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
const x = Math.cos(angle) * this.size;
const y = Math.sin(angle) * this.size;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fillStyle = this.color;
ctx.globalAlpha = this.alpha;
ctx.fill();
ctx.restore();
}
}
function calculateSpreadVector(dx, dy) {
const angle = Math.atan2(dy, dx);
const direction = Math.random() < 0.5 ? -1 : 1;
const spreadAngle = (Math.random() * Math.PI / 3) * direction;
const finalAngle = angle + spreadAngle;
const speed = Math.sqrt(dx * dx + dy * dy) * 0.05;
return { angle: finalAngle, speed };
}
使用 requestAnimationFrame 驱动动画,每帧清空画布并更新星星:
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
stars = stars.filter(star => {
const isAlive = star.update();
if (isAlive) star.draw();
return isAlive;
});
requestAnimationFrame(animate);
}
虽然主要逻辑在 Canvas 中,styles.css 定义了淡出动画,供未来扩展(如混合 DOM 效果):
@keyframes fadeOut {
0% { opacity: 1; transform: scale(1) translate(0, 0) rotate(0deg); }
100% { opacity: 0; transform: scale(0.5) translate(var(--moveX), var(--moveY)) rotate(var(--rotate)); }
}
这个扩展成功实现了目标:
完整项目结构如下:
CursorShape/
├── manifest.json
├── styles.css
├── content.js
└── icon.png
manifest.json:
{
"manifest_version": 3,
"name": "Cursor Shape - Star Trail Effect",
"version": "1.0",
"description": "Add beautiful star trail effects to your cursor movement on any webpage",
"permissions": ["activeTab"],
"content_scripts": [
{
"matches": [""],
"css": ["styles.css"],
"js": ["content.js"]
}
],
"icons": {
"128": "icon.svg"
}
}
styles.css:
@keyframes fadeOut {
0% {
opacity: 1;
transform: scale(1) translate(0, 0) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0.5) translate(var(--moveX), var(--moveY)) rotate(var(--rotate));
}
}
.cursor-star {
position: fixed;
pointer-events: none;
will-change: transform, opacity;
z-index: 999999;
/* 使用GPU加速 */
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
}
.cursor-star.animate {
animation: fadeOut var(--duration) var(--timing) forwards;
}
.cursor-star-five {
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.cursor-star-glow {
filter: drop-shadow(0 0 4px currentColor);
}
content.js:
function createStarsTrail({
maxStars = 60,
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD', '#D4A5A5', '#9B59B6', '#3498DB', '#E67E22', '#2ECC71'],
baseSizeRange = [8, 15],
maxDistance = 100,
spawnFrequency = 15,
frequencyVariation = 0.6,
rotationRange = [-45, 45],
fadeSpeed = 0.95
} = {}) {
// 创建canvas元素
const canvas = document.createElement('canvas');
canvas.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 999999;';
document.body.appendChild(canvas);
// 获取canvas上下文
const ctx = canvas.getContext('2d');
// 设置canvas尺寸为窗口大小
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
// 初始化尺寸
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// 星星类
class Star {
constructor(x, y, size, color, angle, speed) {
this.x = x;
this.y = y;
this.size = size;
this.color = color;
this.angle = angle;
this.speed = speed;
this.alpha = 1;
this.rotation = Math.random() * (rotationRange[1] - rotationRange[0]) + rotationRange[0];
}
update() {
// 更新位置
this.x += Math.cos(this.angle) * this.speed;
this.y += Math.sin(this.angle) * this.speed;
// 更新透明度
this.alpha *= fadeSpeed;
// 更新旋转
this.rotation += 2;
return this.alpha > 0.1;
}
draw() {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation * Math.PI / 180);
// 设置透明度
ctx.globalAlpha = this.alpha;
// 绘制五角星
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
const x = Math.cos(angle) * this.size;
const y = Math.sin(angle) * this.size;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
// 填充颜色
ctx.fillStyle = this.color;
ctx.fill();
ctx.restore();
}
}
let stars = [];
let lastX = 0;
let lastY = 0;
let mouseX = 0;
let mouseY = 0;
let accumulatedDistance = 0;
let isMouseMoving = false;
// 计算星星大小
function calculateStarSize() {
return Math.random() * (baseSizeRange[1] - baseSizeRange[0]) + baseSizeRange[0];
}
// 计算扩散向量
function calculateSpreadVector(dx, dy) {
const angle = Math.atan2(dy, dx);
const direction = Math.random() < 0.5 ? -1 : 1;
const spreadAngle = (Math.random() * Math.PI / 3) * direction;
const finalAngle = angle + spreadAngle;
const speed = Math.sqrt(dx * dx + dy * dy) * 0.05;
return {
angle: finalAngle,
speed: speed
};
}
// 判断是否应该生成新星星
function shouldSpawnStar(distance) {
accumulatedDistance += distance;
const maxVariation = spawnFrequency * frequencyVariation;
const currentThreshold = spawnFrequency + Math.random() * maxVariation;
if (accumulatedDistance >= currentThreshold) {
accumulatedDistance = 0;
return true;
}
return false;
}
// 动画循环
function animate() {
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 更新和绘制所有星星
stars = stars.filter(star => {
const isAlive = star.update();
if (isAlive) {
star.draw();
}
return isAlive;
});
// 如果鼠标在移动,生成新星星
if (isMouseMoving) {
const dx = mouseX - lastX;
const dy = mouseY - lastY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (shouldSpawnStar(distance)) {
const size = calculateStarSize();
const { angle, speed } = calculateSpreadVector(dx, dy);
const color = colors[Math.floor(Math.random() * colors.length)];
stars.push(new Star(mouseX, mouseY, size, color, angle, speed));
// 限制最大星星数量
if (stars.length > maxStars) {
stars.shift();
}
}
lastX = mouseX;
lastY = mouseY;
}
requestAnimationFrame(animate);
}
// 启动动画
animate();
// 处理鼠标移动
const handleMouseMove = (event) => {
mouseX = event.clientX;
mouseY = event.clientY;
isMouseMoving = true;
clearTimeout(window.mouseMoveTimeout);
window.mouseMoveTimeout = setTimeout(() => {
isMouseMoving = false;
}, 100);
};
document.addEventListener('mousemove', handleMouseMove);
// 返回清理函数
return {
destroy: () => {
document.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('resize', resizeCanvas);
clearTimeout(window.mouseMoveTimeout);
if (canvas && canvas.parentNode) {
canvas.remove();
}
stars = [];
}
};
}
// 初始化星星效果
const starsTrail = createStarsTrail({
maxStars: 50,
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD', '#D4A5A5', '#9B59B6', '#3498DB', '#E67E22', '#2ECC71'],
baseSizeRange: [5, 12],
maxDistance: 100,
spawnFrequency: 10,
frequencyVariation: 0.5,
rotationRange: [-45, 45],
fadeSpeed: 0.98
});
icon去找一个自己喜欢的就可以了,记得可能需要修改JSON里的这部分代码:
"icons": {"128": "icon.svg" }
在浏览器中导入扩展的方式:
edge浏览器可进入这个链接:edge://extensions/
然后依次进行操作:
1)进入开发者模式
2)加载解压缩的扩展
3)导入这个文件夹并打开扩展