思路
- 绘制单个星星
- 在画布批量随机绘制星星
- 添加星星移动动画
- 页面resize处理
Vanilla JavaScript实现
- 初始化一个工程
1pnpm create vite@latest
2
3
4
5cd <工程目录> pnpm install pnpm dev
1body {
2 background-color: black;
3 overflow: hidden;
4}
1"use strict";
2import './style.css';
3
4document.querySelector('#app').innerHTML = `
5 <canvas id="canvas"></canvas>
6`;
7
- 绘制单个星星
1const hue = 220;
2
3const offscreenCanvas = document.createElement("canvas");
4const offscreenCtx = offscreenCanvas.getContext("2d");
5offscreenCanvas.width = 100;
6offscreenCanvas.height = 100;
7const half = offscreenCanvas.width / 2;
8const middle = half;
9
10const gradient = offscreenCtx.createRadialGradient(
11 middle,
12 middle,
13 0,
14 middle,
15 middle,
16 half
17);
18
19gradient.addColorStop(0.01, "#fff");
20gradient.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`);
21gradient.addColorStop(0.5, `hsl(${hue}, 64%, 6%)`);
22gradient.addColorStop(1, "transparent");
23
24
25offscreenCtx.fillStyle = gradient;
26offscreenCtx.beginPath();
27offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
28offscreenCtx.fill();
参考链接:
- 在画布批量绘制星星
其实要绘制星星,我们只需要在画布上基于离屏画布来在指定位置将离屏画布渲染成图片即可,但是批量绘制以及后续的动画需要我们能记录每颗星星的位置、状态和行驶轨迹,所以可以考虑创建一个星星的类。
1
2const stars = [];
3const maxStars = 1000;
4
5
6const random = (min, max) => {
7 if (!max) {
8 max = min;
9 min = 0;
10 }
11 if (min > max) {
12 [min, max] = [max, min];
13 }
14 return Math.floor(Math.random() * (max - min + 1)) + min;
15};
16
17const maxOrbit = (_w, _h) => {
18 const max = Math.max(_w, _h);
19 const diameter = Math.round(Math.sqrt(max * max + max * max));
20 return diameter / 2;
21};
22
23class Star {
24 constructor(_ctx, _w, _h) {
25 this.ctx = _ctx;
26
27 this.maxOrbitRadius = maxOrbit(_w, _h);
28
29 this.orbitRadius = random(this.maxOrbitRadius);
30
31 this.radius = random(60, this.orbitRadius) / 12;
32
33 this.orbitX = _w / 2;
34 this.orbitY = _h / 2;
35
36 this.elapsedTime = random(0, maxStars);
37
38 this.speed = random(this.orbitRadius) / 500000;
39
40 this.alpha = random(2, 10) / 10;
41 }
42
43 draw() {
44
45 const x = Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX;
46 const y = Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY;
47
48
49 const spark = Math.random();
50 if (spark < 0.5 && this.alpha > 0) {
51 this.alpha -= 0.05;
52 } else if (spark > 0.5 && this.alpha < 1) {
53 this.alpha += 0.05;
54 }
55
56
57
58 this.ctx.globalAlpha = this.alpha;
59
60 this.ctx.drawImage(offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
61
62 this.elapsedTime += this.speed;
63 }
64}
获取当前画布,批量添加星星
1const canvas = document.getElementById('canvas');
2const ctx = canvas.getContext('2d');
3let w = canvas.width = window.innerWidth;
4let h = canvas.height = window.innerHeight;
5
6for (let i = 0; i < maxStars; i++) {
7 stars.push(new Star(ctx, w, h));
8}
- 添加星星的移动动画
1function animation() {
2
3 ctx.globalCompositeOperation = 'source-over';
4 ctx.globalAlpha = 0.8;
5 ctx.fillStyle = `hsla(${hue} , 64%, 6%, 1)`;
6 ctx.fillRect(0, 0, w, h);
7
8 ctx.globalCompositeOperation = 'lighter';
9 stars.forEach(star => {
10 star.draw();
11 });
12 window.requestAnimationFrame(animation);
13}
14
15animation();
这样星星就动起来了。
- 页面resize处理
其实只需要在resize事件触发时重新设定画布的大小即可
1window.addEventListener('resize', () => {
2 w = canvas.width = window.innerWidth;
3 h = canvas.height = window.innerHeight;
4});
但是有一个问题,就是星星的运行轨迹并没有按比例变化,所以需要添加两处变化
1
2class Star {
3 constructor(_ctx, _w, _h) {
4
5 update(_w, _h) {
6
7 const ratio = maxOrbit(_w, _h) / this.maxOrbitRadius;
8
9 if (ratio !== 1) {
10
11 this.maxOrbitRadius = maxOrbit(_w, _h);
12
13 this.orbitRadius = this.orbitRadius * ratio;
14 this.radius = this.radius * ratio;
15
16 this.orbitX = _w / 2;
17 this.orbitY = _h / 2;
18 }
19 }
20
21 draw() {
22}
23
24
25function animation() {
26
27 stars.forEach(star => {
28 star.update(w, h);
29 star.draw();
30 });
31
32}
React实现
react实现主要需要注意resize事件的处理,怎样避免重绘时对星星数据初始化,当前思路是使用多个useEffect
1import React, { useEffect, useRef, useState } from 'react';
2
3const HUE = 217;
4const MAX_STARS = 1000;
5
6const random = (min: number, max?: number) => {
7 if (!max) {
8 max = min;
9 min = 0;
10 }
11 if (min > max) {
12 [min, max] = [max, min];
13 }
14 return Math.floor(Math.random() * (max - min + 1)) + min;
15};
16
17const maxOrbit = (_w: number, _h: number) => {
18 const max = Math.max(_w, _h);
19 const diameter = Math.round(Math.sqrt(max * max + max * max));
20 return diameter / 2;
21};
22
23
24const getOffscreenCanvas = () => {
25 const offscreenCanvas = document.createElement('canvas');
26 const offscreenCtx = offscreenCanvas.getContext('2d')!;
27 offscreenCanvas.width = 100;
28 offscreenCanvas.height = 100;
29 const half = offscreenCanvas.width / 2;
30 const middle = half;
31 const gradient = offscreenCtx.createRadialGradient(middle, middle, 0, middle, middle, half);
32 gradient.addColorStop(0.01, '#fff');
33 gradient.addColorStop(0.1, `hsl(${HUE}, 61%, 33%)`);
34 gradient.addColorStop(0.5, `hsl(${HUE}, 64%, 6%)`);
35 gradient.addColorStop(1, 'transparent');
36
37 offscreenCtx.fillStyle = gradient;
38 offscreenCtx.beginPath();
39 offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
40 offscreenCtx.fill();
41 return offscreenCanvas;
42};
43
44class OffscreenCanvas {
45 static instance: HTMLCanvasElement = getOffscreenCanvas();
46}
47
48class Star {
49 orbitRadius!: number;
50 maxOrbitRadius!: number;
51 radius!: number;
52 orbitX!: number;
53 orbitY!: number;
54 elapsedTime!: number;
55 speed!: number;
56 alpha!: number;
57 ratio = 1;
58 offscreenCanvas = OffscreenCanvas.instance;
59 constructor(
60 private ctx: CanvasRenderingContext2D,
61 private canvasSize: { w: number, h: number; },
62 ) {
63 this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
64 this.orbitRadius = random(this.maxOrbitRadius);
65 this.radius = random(60, this.orbitRadius) / 12;
66 this.orbitX = this.canvasSize.w / 2;
67 this.orbitY = this.canvasSize.h / 2;
68 this.elapsedTime = random(0, MAX_STARS);
69 this.speed = random(this.orbitRadius) / 500000;
70 this.alpha = random(2, 10) / 10;
71 }
72
73 update(size: { w: number, h: number; }) {
74 this.canvasSize = size;
75 this.ratio = maxOrbit(this.canvasSize.w, this.canvasSize.h) / this.maxOrbitRadius;
76 if (this.ratio !== 1) {
77 this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
78 this.orbitRadius = this.orbitRadius * this.ratio;
79 this.radius = this.radius * this.ratio;
80 this.orbitX = this.canvasSize.w / 2;
81 this.orbitY = this.canvasSize.h / 2;
82 }
83 }
84
85 draw() {
86 const x = (Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX);
87 const y = (Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY);
88 const spark = Math.random();
89
90 if (spark < 0.5 && this.alpha > 0) {
91 this.alpha -= 0.05;
92 } else if (spark > 0.5 && this.alpha < 1) {
93 this.alpha += 0.05;
94 }
95
96 this.ctx.globalAlpha = this.alpha;
97 this.ctx.drawImage(this.offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
98 this.elapsedTime += this.speed;
99 }
100}
101
102const StarField = () => {
103 const canvasRef = useRef<HTMLCanvasElement | null>(null);
104 const animationRef = useRef<number | null>(null);
105 const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
106 const [initiated, setInitiated] = useState(false);
107 const [stars, setStars] = useState<Star[]>([]);
108
109
110 useEffect(() => {
111 if (canvasRef.current && canvasSize.w !== 0 && canvasSize.h !== 0 && !initiated) {
112 const ctx = canvasRef.current!.getContext('2d')!;
113 const _stars = Array.from({ length: MAX_STARS }, () => new Star(ctx, canvasSize));
114 setStars(_stars);
115 setInitiated(true);
116 }
117 }, [canvasSize.w, canvasSize.h]);
118
119 useEffect(() => {
120 if (canvasRef.current) {
121 const resizeHandler = () => {
122 const { clientWidth, clientHeight } = canvasRef.current!.parentElement!;
123 setCanvasSize({ w: clientWidth, h: clientHeight });
124 };
125 resizeHandler();
126 addEventListener('resize', resizeHandler);
127 return () => {
128 removeEventListener('resize', resizeHandler);
129 };
130 }
131 }, []);
132
133 useEffect(() => {
134 if (canvasRef.current) {
135 const ctx = canvasRef.current.getContext('2d')!;
136 canvasRef.current!.width = canvasSize.w;
137 canvasRef.current!.height = canvasSize.h;
138 const animation = () => {
139 ctx.globalCompositeOperation = 'source-over';
140 ctx.globalAlpha = 0.8;
141 ctx.fillStyle = `hsla(${HUE} , 64%, 6%, 1)`;
142 ctx.fillRect(0, 0, canvasSize.w, canvasSize.h);
143
144 ctx.globalCompositeOperation = 'lighter';
145 stars.forEach((star) => {
146 if (star) {
147 star.update(canvasSize);
148 star.draw();
149 }
150 });
151
152 animationRef.current = requestAnimationFrame(animation);
153 };
154
155 animation();
156 return () => {
157 cancelAnimationFrame(animationRef.current!);
158 };
159 }
160 }, [canvasSize.w, canvasSize.h, stars]);
161 return (
162 <canvas ref={canvasRef}></canvas>
163 );
164};
165
166export default StarField;
个人笔记记录 2021 ~ 2025