思路

  1. 绘制单个星星
  2. 在画布批量随机绘制星星
  3. 添加星星移动动画
  4. 页面resize处理

Vanilla JavaScript实现

  1. 初始化一个工程
 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
  1. 绘制单个星星

 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();

参考链接:

hsl() - CSS:层叠样式表 | MDN

  1. 在画布批量绘制星星

其实要绘制星星,我们只需要在画布上基于离屏画布来在指定位置将离屏画布渲染成图片即可,但是批量绘制以及后续的动画需要我们能记录每颗星星的位置、状态和行驶轨迹,所以可以考虑创建一个星星的类。

 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}
  1. 添加星星的移动动画
 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();

这样星星就动起来了。

  1. 页面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