上文回顾:
出于对技术与自然之美的热爱,咱创建了一个项目,在其中集齐了一年四季的风景:春花,夏雨,秋叶,冬雪。

还设计了一个 Logo

不过呢,由于项目代码复杂度的增加以及自己热情的消退,这个项目后来搁置了

契机
现在,距离咱的 JS 飘落特效库项目初次开发,已经过去了快一年半了,距离上次更新也已经过去快一年了。面对项目中纷繁复杂的代码,咱感觉越来越力不从心,想优化重构也无从下手。上个月,咱另一个项目要用到这个特效库,也只能先凑合用着。
不过前段时间,有人给咱这个默默无闻的项目提了1个 Issue,问了雪花的样式等问题,这使我重新想起了这个项目。正好咱听说,最近 Copilot 免费了,遂萌生了借助 AI 的力量来彻底重构此项目的想法。之前咱就对 AI 在代码方面的能力有所耳闻(听说已经能直接生成整个项目),也用它解决过一些简单问题,这次直接来个大的,看看效果如何。
体验 AI
Copilot 目前已支持免费使用,每个人每月有 50 条对话和 2000 行代码提示的额度。咱不清楚这个 50 条,一问一答占一条还是占两条,不过就算占两条咱感觉也足够用了。至于 2000 行代码补全应该是 VS Code 插件里的,咱暂时不用,先只使用网页版试试。支持的模型如图。先前就听说 Claude 3.5 Sonnet 在代码方面比 GPT 4o 更厉害,于是直接用上。

传文件,目前只能传仓库里的。


上面是一些对话(这是第二次的,第一次的对话过期了)。不过,出了点小问题,它竟然没发给我完整的代码,省略了好多东西,好几个图案的类都没有。跟它说了它才发。中间还卡住了一次,好惊险~
不过,结果是美好的。新的代码看起来井然有序,通俗易懂,十分巧妙。补全图案数据后尝试运行,除了没法下雨、图案太大、雪花太小之外,都与旧版效果一致。
Debug 后发现,是因为雨的尺寸是定死的,而它就省略了这个参数,导致错位了。而大小的问题是因为它没给每个图案随机设定大小。
class Rain extends FallingElement {
constructor(x, y, config) { //参数应改为 x, y, size, config(在调用时必须传 4 个参数)
super(x, y, 2, config); // 雨滴宽度固定为2
this.bounces = [];
this.updateVelocity();
}
}
仔细分析其代码,发现整个程序已经被完全重构了。
旧代码结构
//读配置,准备图案,解析配置,设置淡出
const readyCreate = (mc, c) => {
startFall(t, mc, sNum) //每种图案都执行一次
}
function getSeason() {}
//生成随机值,包括位置、大小、运动函数
const getRandom = (option, type) => {}
//各种图案的类
class Petal {
constructor(x, y, s, a, fn) {
}
draw(ctx) {
}
update() {
//更新状态并判断是否越界
}
}
class Leaf {}
class Snow {}
class Drop {}
class Bounce {}
//准备画布,进入循环创建,里面又分别判断了图案种类
function startFall(t, mc, sNum) {
//下雨专属操作
function updateRain() {}
//动画requestAnimationFrame
const asd = function () {}
}
//除此之外还有很多外置的变量散落在各处
新代码结构
// Core configuration and state management
class FallingConfig {
constructor(masterConfig = {}, clientConfig = {}) {
//合并配置,无需再传入整个自定义配置
this.config = {
...FallingConfig.DEFAULT,
...masterConfig,
...clientConfig
};
//this.validateConfig();
}
//获得某种图案专属配置
getEffectConfig(type) {}
return {
...baseConfig,
...configs[type]
};
}
function getSeason() {}
// Base class for falling elements
class FallingElement {}
// 具体效果类实现
class Petal extends FallingElement {
constructor(x, y, size, config) {
// 配置其状态
this.updateVelocity();// 配置其速度
}
updateVelocity() {}
update() {}
draw(ctx) {}
}
class Leaf extends FallingElement {}
class Snow extends FallingElement {}
class Bounce {}
class Rain extends FallingElement {}
// 主效果控制类
class FallingEffect {
constructor(type, config) {
// 必要的定义以及处理淡出定时器,调用方法
this.initCanvas();
this.initElements();
this.animate();
}
initCanvas() {} // 初始化画布
resizeCanvas() {}
initElements() {} // for循环创建某种元素实例
animate() {} // requestAnimationFrame
destroy() {}
}
// 对外导出的主函数
export function fallingCreate(masterConfig, clientConfig) {
//创建所有 FallingEffect 实例;
// 返回控制接口
return {
destroy: () => effects.forEach(effect => effect.destroy())
};
}
他自己说的改进点(翻译):
- 更好的代码组织:
- 引入了适当的类层次结构,包括基本
FallingElement
类和每种效果类型的单独类 - 通过
FallingConfig
类进行集中配置管理 - 更清晰地分离配置、动画和元素管理之间的关注点
- 引入了适当的类层次结构,包括基本
- 性能提升:
- 通过重用元素而不是重新创建元素来减少内存分配
- 通过适当的清理实现更高效的画布渲染
- 更好地处理窗口调整事件
- 实现了元素池以减少垃圾收集
- 更灵活的配置:
- 配置验证和规范化
- 通过
getEffectConfig()
键入特定配置 - 更容易扩展新的效果类型
- 更好的违约管理
- 更好的可维护性:
- 清晰的类结构使代码更容易理解和修改
- 通过继承减少代码重复
- 一致的命名约定和编码风格
- 添加了正确的
TypeScript
风格文档
- 增强功能:
- 改进的季节检测逻辑
- 更好地处理移动屏幕
- 销毁时更可靠的清理
- 更易于调试,代码结构更清晰
代码现在更加模块化、高性能和可维护,同时保持相同的功能。基于类的结构使得将来更容易扩展新的功能或效果类型。配置更稳健,类型更安全。动画系统具有更好的内存管理和画布处理,效率更高。
就咱看来,其层次结构确实更加清晰易懂,配置已经能被很好地处理,而且已经不再有乱七八糟的散落在各处的变量。并且,新代码完全舍弃了旧的getRandom()
函数,把随机部分放在各自的类中来分别处理,简化了运行逻辑。
新设置的主效果控制类FallingEffect
负责统一管理某种效果,包括其画布相关、元素初始化、以及动画的启动与循环。由于效果细分已经在配置处理中实现,这里四种效果可以直接用同一套代码,不再需要if
判断,十分简洁。
另外,咱也用 4o 模型生成了一次,结果代码并没有大改,基本保持了原来的结构,而且改了好多次都运行不起来……
进一步改进
咱在理解代码、修复 Bug、对数值处理进行必要修改,并且合入演示项目之后,开始进行进一步的改进。这个过程咱也是把新旧代码对比着来分析的。
移动端
新代码没有了关于移动端的特殊处理,这导致图案在小小的手机屏幕上看起来太大,速度太快。咱添加回来。
optimizeMobile() {
if (this.type === 'snow') {
this.config.size -= 0.3
}
else if (this.type === 'rain') {
this.config.wind_speed = this.config.wind_speed / 1.5
this.config.wind_deviation = this.config.wind_deviation / 1.5
}
else this.config.size = Math.floor(this.config.size / 1.2)
this.config.count = Math.floor(this.config.count / 2.2)
this.config.gravity = this.config.gravity / 2
}
帧率
咱在移动端真机测试时发现,动画的运行速度还是很快,像是在快进一样,显得非常急躁、不自然。咱的手机屏幕是 120Hz 的,而电脑是 60Hz 的,问题应该就出在这上面。
MDN 也有提到这个问题:Window:requestAnimationFrame() 方法 - Web API | MDN
window.requestAnimationFrame()
方法会告诉浏览器你希望执行一个动画。它要求浏览器在下一次重绘之前,调用用户提供的回调函数。对回调函数的调用频率通常与显示器的刷新率相匹配。虽然 75hz、120hz 和 144hz 也被广泛使用,但是最常见的刷新率还是 60hz(每秒 60 个周期/帧)。为了提高性能和电池寿命,大多数浏览器都会暂停在后台选项卡或者隐藏的
<iframe>
中运行的requestAnimationFrame()
。备注: 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用
requestAnimationFrame()
。requestAnimationFrame()
是一次性的。警告: 请确保总是使用第一个参数(或其他一些获取当前时间的方法)来计算动画在一帧中的进度,否则动画在高刷新率的屏幕中会运行得更快。有关方法请参考下面的示例。
如需避免在高刷新率屏幕上动画运行得太快,可以限定动画运行的总次数(计数,结果导向),或者使用时间戳来控制动画速度(在回调函数中,比较当前时间和上一帧的时间戳,决定是否更新)。
咱这个就只能用后一个方案。最终代码被改成这个样子。注意parseInt()
是必须的,否则会变成 30 帧。
constructor(type, config) {
let fps = 60 // 目标帧率
this.t = parseInt(1000 / fps) //每帧时间
this.lastTimestamp = 0
}
animate(timestamp) {
if (this.destroyed) return;
const deltaTime = timestamp - this.lastTimestamp;
if (deltaTime >= this.t) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 更新并绘制所有元素
this.elements = this.elements.filter(element => {
if (element.update()) {
element.draw(this.ctx);
return true;
}
return false;
});
this.lastTimestamp = timestamp;
}
requestAnimationFrame((timestamp) => this.animate(timestamp));
}
Vue 组件
现在咱已经重制了核心 JS,那么下一步的计划就是改进 Vue 组件,主要计划为简化组件内代码,取消子自定义项的开关等等。
首先简化逻辑,整个配置内不再有子配置(下雨设置),也不再有自定义开关。这样一来核心 JS 将可以专注于效果实现,而不是处理访客自定义配置。在 Vue 组件内,由于咱已经取消了子自定义开关,现在组件的代码可以大大简化,不用再进行繁复的判断。
而且,咱在组件里也进行了合并配置的操作,这样一来,网站主人在自定义时就不必完整传入所有配置,而是只传想改的就可以了。
//合并配置
this.masterConfig_full = {
...defaultConfig,
...this.masterConfig
}
//给组件传入这个
naturalFallingConfig: {
imgNumSetting: [40, 40, 80, 50],
wind_x: -50
}
最后,当然是重新整理一份文档了,现在整个项目的使用更加简单,文档也会更加清晰。
性能
咱一直希望这个程序能有较好的性能,尽可能减少资源占用,如果打开页面因为这个导致电脑风扇狂转就不好了。咱也有使用这些工具监测性能:
- 电脑任务管理器,查看 CPU 和 GPU 占用
- 浏览器任务管理器
- 控制台性能选项卡
示例项目的开发环境的占用如图,分别是同时开启 4 个特效并且开启水花时,以及只开花朵时。图案数量都是默认(暂未查看只运行核心 JS 的占用)

就数据和体感上来说已经比网上多数项目要好啦。
测试时还发现了一个重要的问题,就是动画结束之后 GPU 内存并未释放。在频繁修改测试时卡顿非常明显。在查阅 JS 垃圾回收相关资料后,咱在调用销毁后将相关变量置空,成功解决了问题。
destroy() {
this.destroyed = true;
if (this.canvas && this.canvas.parentNode) {
this.canvas.parentNode.removeChild(this.canvas);
}
this.elements = []
this.canvas = null
this.ctx = null
this.config = null
}
用户体验相关

为此,咱这个项目一开始就设计了淡入淡出功能,让这些风景轻轻地来,又轻轻地离开,只为给你一些惊喜,却又不敢过多停留,生怕打扰了你。
// 处理淡出定时器
if (this.config.fadeOut && this.config.fadeOutTime >= 1) {
setTimeout(() => {
this.config.isTimeOver = true;
}, this.config.fadeOutTime * 1000);
setTimeout(() => {
this.destroy();
}, this.config.fadeOutTime * 1000 + 17000);
}
第一个定时器到期后,越界的图案将不再重新出现,届时云收雨散,天朗气清;等第二个定时器到期后,将触发销毁程序,整个特效都将消失,释放内存,不过美妙的体验将会一直留在你的心里。
咱的网站使用的时候就开启了淡入淡出功能,并且减少了图案数量,让它更加温和,美妙。
下一步的计划
咱虽然重制了核心 JS,但暂时没时间改进 Vue 组件,所以 Vue 组件就等下次再优化吧

INFO信息
2025-02-25 Vue 组件回炉重造完成,目前基本能用了
其他
如何用手机访问电脑上的测试项目:
网上已经有了教程:如何利用手机访问电脑本地的localhost?。然后命令行中还需要操作:
之前运行 pnpm dev 显示的:
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
根据提示运行 pnpm dev --host
运行pnpm dev --host
后,手机访问Network
的链接就可以调试啦。