Show

使用 AI 重构我的 JS 特效库

上文回顾:

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

还设计了一个 Logo

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

。直到今天……

契机

现在,距离咱的 JS 飘落特效库项目初次开发,已经过去了快一年半了,距离上次更新也已经过去快一年了。面对项目中纷繁复杂的代码,咱感觉越来越力不从心,想优化重构也无从下手。上个月,咱另一个项目要用到这个特效库,也只能先凑合用着。

不过前段时间,有人给咱这个默默无闻的项目提了1个 Issue,问了雪花的样式等问题,这使我重新想起了这个项目。正好咱听说,最近 Copilot 免费了,遂萌生了借助 AI 的力量来彻底重构此项目的想法。之前咱就对 AI 在代码方面的能力有所耳闻(听说已经能直接生成整个项目),也用它解决过一些简单问题,这次直接来个大的,看看效果如何。

体验 AI

GitHub Copilot · Your AI pair programmer

Copilot 目前已支持免费使用,每个人每月有 50 条对话和 2000 行代码提示的额度。咱不清楚这个 50 条,一问一答占一条还是占两条,不过就算占两条咱感觉也足够用了。至于 2000 行代码补全应该是 VS Code 插件里的,咱暂时不用,先只使用网页版试试。支持的模型如图。先前就听说 Claude 3.5 Sonnet 在代码方面比 GPT 4o 更厉害,于是直接用上。

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

上面是一些对话(这是第二次的,第一次的对话过期了)。不过,出了点小问题,它竟然没发给我完整的代码,省略了好多东西,好几个图案的类都没有。跟它说了它才发。中间还卡住了一次,好惊险~

不过,结果是美好的。新的代码看起来井然有序,通俗易懂,十分巧妙。补全图案数据后尝试运行,除了没法下雨、图案太大、雪花太小之外,都与旧版效果一致。

Debug 后发现,是因为雨的尺寸是定死的,而它就省略了这个参数,导致错位了。而大小的问题是因为它没给每个图案随机设定大小。

js
class Rain extends FallingElement {
  constructor(x, y, config) { //参数应改为 x, y, size, config(在调用时必须传 4 个参数)
    super(x, y, 2, config); // 雨滴宽度固定为2
    this.bounces = [];
    this.updateVelocity();
  }
}

仔细分析其代码,发现整个程序已经被完全重构了。

旧代码结构

js
//读配置,准备图案,解析配置,设置淡出
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 () {}
}
//除此之外还有很多外置的变量散落在各处

新代码结构

js
// 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())
  };
}

他自己说的改进点(翻译):

  1. 更好的代码组织:
    • 引入了适当的类层次结构,包括基本FallingElement类和每种效果类型的单独类
    • 通过FallingConfig类进行集中配置管理
    • 更清晰地分离配置、动画和元素管理之间的关注点
  2. 性能提升:
    • 通过重用元素而不是重新创建元素来减少内存分配
    • 通过适当的清理实现更高效的画布渲染
    • 更好地处理窗口调整事件
    • 实现了元素池以减少垃圾收集
  3. 更灵活的配置:
    • 配置验证和规范化
    • 通过getEffectConfig()键入特定配置
    • 更容易扩展新的效果类型
    • 更好的违约管理
  4. 更好的可维护性:
    • 清晰的类结构使代码更容易理解和修改
    • 通过继承减少代码重复
    • 一致的命名约定和编码风格
    • 添加了正确的TypeScript风格文档
  5. 增强功能:
    • 改进的季节检测逻辑
    • 更好地处理移动屏幕
    • 销毁时更可靠的清理
    • 更易于调试,代码结构更清晰

代码现在更加模块化、高性能和可维护,同时保持相同的功能。基于类的结构使得将来更容易扩展新的功能或效果类型。配置更稳健,类型更安全。动画系统具有更好的内存管理和画布处理,效率更高。

就咱看来,其层次结构确实更加清晰易懂,配置已经能被很好地处理,而且已经不再有乱七八糟的散落在各处的变量。并且,新代码完全舍弃了旧的getRandom()函数,把随机部分放在各自的类中来分别处理,简化了运行逻辑。

新设置的主效果控制类FallingEffect负责统一管理某种效果,包括其画布相关、元素初始化、以及动画的启动与循环。由于效果细分已经在配置处理中实现,这里四种效果可以直接用同一套代码,不再需要if判断,十分简洁。

另外,咱也用 4o 模型生成了一次,结果代码并没有大改,基本保持了原来的结构,而且改了好多次都运行不起来……

进一步改进

咱在理解代码、修复 Bug、对数值处理进行必要修改,并且合入演示项目之后,开始进行进一步的改进。这个过程咱也是把新旧代码对比着来分析的。

移动端

新代码没有了关于移动端的特殊处理,这导致图案在小小的手机屏幕上看起来太大,速度太快。咱添加回来。

js
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 帧。

js
  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 组件内,由于咱已经取消了子自定义开关,现在组件的代码可以大大简化,不用再进行繁复的判断。

而且,咱在组件里也进行了合并配置的操作,这样一来,网站主人在自定义时就不必完整传入所有配置,而是只传想改的就可以了。

js
//合并配置
this.masterConfig_full = {
    ...defaultConfig,
    ...this.masterConfig
}

//给组件传入这个
naturalFallingConfig: {
    imgNumSetting: [40, 40, 80, 50],
    wind_x: -50
}

最后,当然是重新整理一份文档了,现在整个项目的使用更加简单,文档也会更加清晰。

性能

咱一直希望这个程序能有较好的性能,尽可能减少资源占用,如果打开页面因为这个导致电脑风扇狂转就不好了。咱也有使用这些工具监测性能:

  • 电脑任务管理器,查看 CPU 和 GPU 占用
  • 浏览器任务管理器
  • 控制台性能选项卡

示例项目的开发环境的占用如图,分别是同时开启 4 个特效并且开启水花时,以及只开花朵时。图案数量都是默认(暂未查看只运行核心 JS 的占用)

就数据和体感上来说已经比网上多数项目要啦。

测试时还发现了一个重要的问题,就是动画结束之后 GPU 内存并未释放。在频繁修改测试时卡顿非常明显。在查阅 JS 垃圾回收相关资料后,咱在调用销毁后将相关变量置空,成功解决了问题。

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
  }

用户体验相关

一个好的特效库,除了要实现想要的效果,更重要的是明确好自己在整个网站中的定位——一个装饰品,仅仅是一个装饰,不能喧宾夺主,甚至干扰阅读。

为此,咱这个项目一开始就设计了淡入淡出功能,让这些风景轻轻地来,又轻轻地离开,只为给你一些惊喜,却又不敢过多停留,生怕打扰了你。

js
// 处理淡出定时器
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的链接就可以调试啦。

  • 使用 AI 重构我的 JS 特效库
  • 作者:天雪酱  发布于:2025-01-13  更新于:2025-02-25  许可协议:若无特别说明,均为 CC BY-NC-SA
为网站配置 Artalk 评论系统
知乎备份脚本翻新