1. 缘起:一个黄金投资者的“懒人”需求与技术选型

几年前我开始接触银行的积存金业务,说白了就是定期买点黄金,想着既能攒点钱又能抗通胀。但每次交易都挺麻烦的,买入价、克重、手续费、目标利润……得拿个计算器按半天,还得自己记在小本本上。时间一长,本子找不到了,到底赚了亏了心里也没个准数。我就琢磨着,能不能自己写个小工具,把这些计算和记录都自动化了?

一开始,我考虑过几种方案。用Excel?功能是强,但每次打开电脑、找文件、手动更新金价,太不“丝滑”了。开发一个完整的App?成本太高,光是安卓和iOS双端开发就够我喝一壶的,更别提上架审核的麻烦了。思来想去,纯前端的小程序成了最理想的形态。它有几个无法拒绝的优点:无需安装,点开即用数据完全保存在本地浏览器,隐私有保障;开发技术栈相对统一,用我熟悉的前端三件套(HTML、CSS、JavaScript)就能搞定。

技术栈的敲定也经历了一番纠结。Vue和React生态固然庞大,但对于我这个单人开发、功能明确的小项目来说,有点“杀鸡用牛刀”的感觉。我最终选择了最“朴素”但也最直接的方式:原生JavaScript + 少量现代工具。UI框架上,我选了Tailwind CSS,它的实用性类名让我能快速搭建出简洁美观的界面,不用在样式文件里反复横跳。构建工具则用了Vite,启动快、热更新灵敏,开发体验非常爽快。这个技术选型背后的逻辑很清晰:轻量、聚焦、快速验证想法。我不需要应对复杂的组件通信和状态管理,核心诉求就是做好计算、存好数据、画好图表。

2. 核心架构:如何让一个“静态”页面活起来

别看最后呈现出来的就是一个单页面应用(SPA),里面的门道可不少。要让这个工具真正有用,它必须解决三个核心问题:实时数据从哪来?复杂计算怎么搞?用户数据存哪里? 这三点构成了整个项目的骨架。

2.1 数据获取:与“沉默”的API斗智斗勇

最头疼的就是实时金价。理想情况是直接调用银行官方的公开API,但现实很骨感,各大银行的积存金数据接口要么不开放,要么有复杂的鉴权。我试过几种方案:直接抓取银行官网的页面?动态渲染的内容很难稳定获取。找第三方金融数据平台?很多需要付费,而且数据源未必精准。

最后,我采用了一种 “主渠道+备用方案” 的策略。主渠道是通过分析某银行手机App的网络请求(在合规和仅用于个人学习的前提下),找到了一个返回JSON格式金价数据的接口。这个接口相对稳定,但为了防止它某天“罢工”,我设置了备用方案:在页面上提供一个手动输入金价的入口。同时,我会在每次成功获取到数据后,在本地存储一个带有时间戳的缓存,如果下一次请求失败或超时,就优雅地降级显示上一次的缓存数据,并明确提示用户“数据可能非最新”。

获取数据的代码逻辑大致是这样的:

// 金价数据服务模块
class GoldPriceService {
  constructor() {
    this.cacheKey = 'gold_price_cache';
    this.cacheExpiry = 60 * 1000; // 缓存1分钟
  }

  async fetchLatestPrice() {
    try {
      // 尝试从主API获取
      const response = await fetch('https://api.example.com/latest-price', {
        // 这里可以添加一些必要的headers,比如User-Agent模拟
        headers: { 'Content-Type': 'application/json' },
        timeout: 5000 // 设置超时
      });

      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

      const data = await response.json();
      const priceData = {
        price: data.currentPrice,
        change: data.change,
        timestamp: Date.now(),
        source: 'api'
      };

      // 更新缓存
      this._saveToCache(priceData);
      return priceData;

    } catch (apiError) {
      console.warn('API请求失败,尝试使用缓存:', apiError.message);
      // 降级到缓存
      return this._getFromCache();
    }
  }

  _saveToCache(data) {
    localStorage.setItem(this.cacheKey, JSON.stringify(data));
  }

  _getFromCache() {
    const cached = localStorage.getItem(this.cacheKey);
    if (!cached) {
      // 连缓存都没有,返回一个默认值并提示手动输入
      return { price: null, change: 0, timestamp: null, source: 'none' };
    }

    const parsed = JSON.parse(cached);
    // 检查缓存是否过期
    if (Date.now() - parsed.timestamp > this.cacheExpiry) {
      parsed.source = 'stale_cache'; // 标记为过期缓存
    } else {
      parsed.source = 'fresh_cache';
    }
    return parsed;
  }
}

2.2 状态管理与数据流:一个简约而不简单的“大脑”

随着功能增加,页面上的状态越来越多:当前金价、用户输入的各项参数、计算出的结果、交易记录列表、图表配置……如果让这些状态散落在各个函数和全局变量里,很快就会变成一团乱麻。我引入了基于 观察者(Pub/Sub)模式 的简易状态管理。

我创建了一个中央 Store 对象,所有需要跨组件共享的状态都放在这里。任何组件修改了状态,只需要调用 Store.update('key', value),然后触发一个自定义事件。其他关心这个状态变化的组件(比如计算器、图表、持仓面板)会监听这个事件,并自动更新自己的视图。这样做的好处是解耦:计算模块只管埋头算,算完了更新状态;显示模块只管监听状态变化,然后渲染。它们彼此不知道对方的存在,维护起来清晰多了。

// 简易状态管理中心
const Store = (function() {
  const state = {
    currentGoldPrice: 0,
    inputAmount: '',
    inputPrice: '',
    transactionHistory: [],
    chartType: 'line'
  };

  const events = {};

  return {
    getState(key) {
      return key ? state[key] : { ...state };
    },

    update(key, value) {
      const oldValue = state[key];
      state[key] = value;
      // 通知所有订阅了该key变化的监听器
      if (events[key]) {
        events[key].forEach(callback => callback(value, oldValue));
      }
      // 同时通知全局状态更新监听器(用于需要响应多个状态变化的场景)
      if (events['*']) {
        events['*'].forEach(callback => callback(key, value, oldValue));
      }
    },

    subscribe(key, callback) {
      if (!events[key]) events[key] = [];
      events[key].push(callback);
      // 返回一个取消订阅的函数
      return () => {
        events[key] = events[key].filter(cb => cb !== callback);
      };
    }
  };
})();

// 在计算器组件中使用
// 当用户输入金额时
document.getElementById('amount-input').addEventListener('input', (e) => {
  Store.update('inputAmount', e.target.value);
});

// 在持仓面板组件中订阅
const unsubscribe = Store.subscribe('currentGoldPrice', (newPrice) => {
  // 金价变了,重新计算并更新我的持仓盈亏显示
  updateHoldingProfit(newPrice);
});

2.3 持久化存储:用户的“小金库”绝对不能丢

交易记录是用户最核心的资产,丢了可比丢钱还难受(因为记不清了)。浏览器提供了几种本地存储方案:localStorageIndexedDBWeb SQL(已废弃)。localStorage 简单易用,但容量有限(通常5MB),且只能存字符串。对于可能积累大量交易记录的场景,我选择了 IndexedDB

IndexedDB 是一个事务型的数据库,容量大(远超市面上所有小程序的交易记录需求),支持索引查询,性能也好。我用一个封装好的库 idb 来简化操作。每次用户新增、修改、删除交易,都会同步到 IndexedDB。同时,我还实现了一个 “双保险”机制:在每次 IndexedDB 操作成功后,会额外将关键数据(如交易记录摘要)用 JSON.stringify 后存一份到 localStorage 作为一个极简的备份。这样即使 IndexedDB 在某些极端浏览器环境下出问题,还能从 localStorage 恢复最后一份有效数据。

为了进一步提升数据安全感,我增加了手动导出/导入功能。用户可以随时将完整的数据库导出为一个加密的 JSON 文件,下载到本地。需要恢复时,再上传这个文件。这个文件我做了简单的混淆和格式校验,虽然不是军用级加密,但足以防止普通用户误操作导致数据损坏。

3. 功能实现:拆解那些“看起来简单”的细节

有了稳固的架构,具体功能的实现就是按部就班的“搬砖”了。但即使是搬砖,也有省力高效的技巧和容易踩的坑。

3.1 交易计算器:不只是加减乘除

计算器的核心是几个数学公式,比如:

  • 买入克数 = 买入金额 / 买入单价
  • 盈亏平衡点 = (买入单价 * 买入克数 + 手续费) / 买入克数。这里的“手续费”在卖出时产生,所以计算平衡点时需要预估。
  • 目标卖出价 = (总成本 + 目标利润) / 买入克数。

难点在于实时性和联动。用户每修改一个输入框(买入价、金额、手续费率),所有计算结果都要立刻、准确地更新。这里必须处理好浮点数精度问题。JavaScript 里 0.1 + 0.2 并不等于 0.3。对于金融计算,这是不可接受的。我的解决方案是,将所有涉及金钱的计算,都先转换为以分为单位的整数进行计算,最后再除以100转换回元。或者使用 Number.toFixed(2) 在显示时进行四舍五入,但在内部存储和连续计算时,使用 Math.round() 处理。

另一个细节是防抖(Debounce)。如果每次按键都触发全套计算并更新DOM,在快速输入时会导致页面卡顿。我为输入框绑定了防抖函数,只在用户停止输入300毫秒后才执行计算。

3.2 数据可视化:让数字会“说话”

持仓收益、利润趋势,光看表格数字不够直观。我引入了 Chart.js 这个轻量而强大的图表库。它上手简单,文档友好,能满足基本的折线图、柱状图、饼图需求。

实现利润趋势图时,我需要将交易记录按时间排序,然后计算每个时间点上的累计利润。这里有个小技巧:对于尚未卖出的持仓,其“浮动盈亏”也会实时影响累计利润曲线。所以我的图表数据源是一个动态数组,包含两部分:一是已完结交易的实际利润累计值,二是基于当前金价计算的未完结持仓的浮动盈亏。

饼图则用来展示盈利、亏损、未卖出交易的数量或金额占比。Chart.js 的配置项非常丰富,我可以轻松调整颜色、标签格式、提示框内容,让图表看起来更专业。

3.3 交易记录管理:CRUD的优雅实现

这就是一个典型的增删改查(CRUD)列表。但要做好用户体验,需要考虑几点:

  1. 性能:当记录超过几百条时,一次性渲染所有DOM节点会慢。我实现了虚拟滚动的雏形:只渲染可视区域及前后缓冲区的记录。
  2. 排序与筛选:用户可以按买入日期、买入单价、利润等多种方式排序。筛选功能可以快速查看“仅盈利”或“仅未卖出”的记录。这些操作都在前端完成,利用数组的 sort()filter() 方法,配合 IndexedDB 的索引,速度很快。
  3. 批量操作:提供了“一键导出所有记录为Excel”的功能。这里用到了 SheetJS 库,它能在浏览器端直接生成 .xlsx 文件,非常方便。

4. 开发加速器:DeepSeek如何成为我的“副驾驶”

在整个开发过程中,DeepSeek(我主要用的是其代码模型)扮演了至关重要的“副驾驶”角色。它不是一个替我写完整功能的工具,而是一个强大的“加速器”和“问题解决伙伴”。

4.1 代码片段生成与优化

当我需要写一个复杂的函数,比如计算持有天数的年化收益率时,我可以直接用自然语言描述:“写一个JavaScript函数,传入买入价格、当前价格、持有天数,返回年化收益率百分比,考虑365天。” DeepSeek 能立刻给我一个基础实现。但这只是起点,我会接着问:“这个函数在买入价格为零或持有天数小于1天时怎么处理?加上错误处理。” 或者“如何优化这个计算避免浮点误差?” 通过这样多轮对话,我能快速得到一个健壮、可靠的代码片段,效率远超自己从头琢磨和搜索。

4.2 解决那些“诡异”的Bug

前端开发总会遇到一些稀奇古怪的Bug。有一次,我的图表在iOS Safari上显示异常,但在Chrome上好好的。我把错误信息和部分相关代码丢给 DeepSeek,它分析后提示可能是 Safari 对 Canvas 某些API的支持差异,并给出了兼容性写法建议和替代方案。另一个例子是 IndexedDB 在无痕模式下可能受限,DeepSeek 帮我写出了检测和优雅降级到 localStorage 的逻辑。

4.3 文档和代码解释

有时我需要快速了解一个不太熟悉的库(比如 Chart.js 的某个插件),或者回顾自己几个月前写的一段“天书”般的逻辑。我会把代码块贴给 DeepSeek,让它“用中文解释这段代码做了什么,以及每一行的作用”。这比我自己重新阅读理解要快得多,尤其是在赶时间修复问题的时候。

4.4 设计决策的“参谋”

在技术选型时,我也会和 DeepSeek 讨论。比如:“我的小程序需要本地存储大量交易记录,localStorageIndexedDB 怎么选?各自的优缺点和典型代码示例是什么?” 它能从容量、性能、API复杂度、浏览器支持度等多个维度给出对比,帮助我做出更 informed 的决策。当然,最终决定权在我,但它提供的多维信息极大地缩短了我的调研时间。

5. 打磨与部署:从代码到产品

功能开发完,只算完成了一半。把它变成一个用户愿意用、喜欢用的产品,还需要很多打磨。

5.1 响应式设计与交互细节

我利用 Tailwind CSS 的响应式工具类,确保从桌面大屏到手机小屏,界面都能自动适配。在移动端,按钮做得更大,输入框间距更宽,避免误触。所有的用户操作,比如保存成功、删除确认,都添加了轻量的反馈提示(Toast),让用户感知到程序在正常工作。

5.2 性能优化

虽然是个前端应用,但性能体验很重要。我做了几件事:

  • 代码分割:利用 Vite 的动态导入,将图表库这类较大的第三方库进行按需加载,减少初始包体积。
  • 图片与图标:全部使用 SVG 或内联的 Data URI,避免额外的HTTP请求。图标来自 Heroicons 这类纯 SVG 图标库。
  • 计算缓存:对于基于当前金价和持仓列表计算总收益这种可能频繁触发的操作,如果持仓和金价在短时间内未变化,则直接返回缓存的计算结果。

5.3 部署与分发

纯前端项目部署最简单。我直接构建出静态文件(dist 目录),然后扔到了 Vercel 上。Vercel 提供了免费的自动化部署、全球CDN和自定义域名(我用了自己的子域名)。每次我往 GitHub 仓库推送代码,Vercel 会自动拉取、构建并发布新版本,实现了简单的 CI/CD。

为了让用户更容易找到和访问,我生成了一个二维码,用户用手机扫一下就能直接打开小程序。我还利用浏览器的“添加到主屏幕”功能,引导用户将这个小程序像原生App一样安装在手机桌面上,体验更接近原生应用。

5.4 隐私与免责

这是金融相关工具必须严肃对待的部分。我在应用醒目位置强调了“所有数据仅保存在您的浏览器本地,我们不会收集任何信息”。每个数据导出功能都明确告知用户文件保存在哪里。免责声明也写得清清楚楚:本工具计算结果仅供参考,不构成投资建议,实际交易请以银行系统为准。这些文字虽然枯燥,但建立了最基本的信任。

回过头看这个项目,从最初的一个计算需求,到最终形成一个功能完整、体验流畅的小产品,技术选型的“做减法”和开发过程中的“借力”AI,是两个关键决策。它验证了一个想法:对于很多垂直领域的工具类需求,一个精心设计的纯前端解决方案,配合现代开发工具和AI辅助,完全可以由个人开发者快速、高质量地实现并交付。如果你也有一个想解决的具体问题,不妨也试试这条路径,从构思到上线的过程,本身就是一次宝贵的学习和创造之旅。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐