上次去健身房训练,我发现自己还在用手机备忘录记录卧推重量,翻到上个月的记录还要一条条往上滑,更别提判断“这个月到底进步了多少”了。真正让我下决心做这个小工具,是某天想确认“硬拉 100kg 做 5 次”和“硬拉 90kg 做 8 次”到底哪个强度更高,结果在纸上算了半天。如果你也有类似经历,说明你缺的不是训练热情,而是一个能自动计算重量进度、估算 1RM、记录每次训练的工具。
这篇文章就是围绕这个场景展开的。我会从需求分析、核心计算模型开始,一步步用纯前端技术实现一个健身房重量进度计算器,包含训练记录、1RM 估算、相对强度分析、历史数据持久化等功能。整个项目不需要后端服务,浏览器打开就能用,适合新手学习前端实战,也适合有健身习惯的开发者直接部署给自己用。项目虽然不大,但涉及数据建模、计算公式选择、本地存储、列表渲染、统计聚合这些前端日常开发的核心环节,读完后你可以举一反三,改成自己的训练日志、食谱记录或者体重管理工具。
需要提前说明的是,本文示例采用纯 HTML、CSS、JavaScript 实现,不依赖任何框架和构建工具。关于训练数据和计算公式,我会给出通用的健身领域估算方式,具体数值请结合你个人的训练感受调整。
1. 为什么要做健身重量进度计算器
1.1 训练记录的真实痛点
大多数健身爱好者在记录训练时,通常会遇到三个问题。
第一个问题是记录分散。今天用备忘录,明天用 Excel,后天可能直接不记,长期积累的数据很难统一查看。第二个问题是数据没有可比性。今天做“60kg × 10 次”,上周做“70kg × 5 次”,单纯看重量和次数都无法判断哪一次训练负荷更大,更无法判断自己是否在进步。第三个问题是训练计划调整靠感觉。下次训练该加多少重量,5kg 还是 2.5kg,如果没有数据支持,很容易练得比较随意。
这些问题本质上不是“记录动作”的问题,而是“记录之后如何计算、如何对比、如何指导下一步训练”的问题。重量进度计算器要解决的,就是第二个和第三个问题。
1.2 这个项目解决什么问题
一个健身重量进度计算器,核心能力可以概括为三点:
- 记录每次训练的基本数据:动作名称、使用重量、完成次数、训练日期。
- 根据重量和次数估算当次训练的最大力量水平,也就是常说的 1RM(One Repetition Maximum,单次最大重量)。
- 展示历史趋势,比如某个动作的历史最高 1RM、最近一次训练负荷、记录总数,让用户直观看到训练是否在进步。
在此基础上,还可以扩展出相对强度分析,也就是“当次训练重量占你估算最大重量的百分比”,方便判断训练是偏向力量、肌肥大还是耐力区间。
1.3 为什么选择纯前端方案
项目定位是个人工具,不需要多人协作,也不涉及复杂的后端逻辑,因此纯前端是最合适的方案。
纯前端有几个明显优点:第一,不需要搭建服务器,双击 HTML 文件就能运行;第二,使用 localStorage 存储数据,浏览器关闭再打开数据依然存在,足够满足个人记录需求;第三,代码结构简单,容易阅读和修改,方便你根据自己习惯调整功能。
当然,纯前端也有边界。localStorage 的存储空间一般在 5MB 左右,只适合存储文本型记录;数据保存在当前浏览器中,换浏览器或清理缓存会造成历史记录丢失。如果你的目标是做一个多端同步的完整产品,那需要引入后端和服务端数据库,本文暂时不涉及。
2. 核心概念:1RM、训练容量与相对强度
2.1 什么是 1RM
1RM 指某个动作你只能完成一次的最大重量。它被广泛用于衡量训练者的力量水平。但直接测试 1RM 风险较高,尤其是卧推、深蹲、硬拉这类复合动作,在无人保护的情况下尝试极限重量有受伤风险,所以实践中更常用“次最大重量”配合公式来估算 1RM。
比较常见的估算公式有两个:
- Epley 公式:
1RM = W × (1 + R / 30) - Brzycki 公式:
1RM = W × (36 / (37 - R))
其中 W 是使用的重量,R 是在该重量下完成的次数。Epley 公式实现简单,适合普通训练者;Brzycki 公式在次数较高时结果会保守一些。两个公式都是经验估算,不代表真实极限,但用于记录趋势变化已经足够。
还要说明的是,当次数 R 较高时,估算误差会增大。一般来说,次数在 10 次以内时,这些公式的参考价值更高;超过 15 次后,建议将其视为“训练耐力参考”而不是“最大力量参考”。
2.2 训练容量
训练容量一般用“重量 × 次数 × 组数”来计算。它代表一次训练的总工作量。虽然计算器没有强行引入组数,但当你连续记录多组数据时,可以通过同一动作多条记录的对比,间接观察容量的变化。
在进阶版本中,你可以把组数字段加入表单,然后统计某个动作的“总训练容量”:
训练容量 = 重量 × 次数 × 组数这个指标对跟踪训练量很有用,但它不能完全替代强度判断。例如,用很轻的重量做很多次,容量可能很高,但对增长最大力量帮助有限。
2.3 相对强度与应用
相对强度指的是“当次训练重量占 1RM 的百分比”。如果估算出的 1RM 是 100kg,你用的是 80kg,那么相对强度就是 80%。
不同相对强度对应不同的训练目标,这是很多健身计划常用的参考区间:
| 相对强度范围 | 大致训练目标 |
|---|---|
| 60% 以下 | 热身、动作技术练习、耐力训练 |
| 60% - 75% | 肌耐力、动作容量积累 |
| 75% - 85% | 肌肥大、增肌训练 |
| 85% - 95% | 最大力量发展 |
| 95% 以上 | 极限力量、神经适应 |
这个区间不是绝对标准,不同训练体系会有差异。在计算器中展示相对强度,可以帮助你快速判断某次训练的侧重点,也能在减脂或增肌期间更精准地控制强度。
3. 功能设计与技术选型
3.1 功能拆解
在动手写代码之前,我先把计算器需要具备的功能拆成下面几个模块:
- 记录表单:选择动作、输入重量、输入次数、提交记录。
- 记录列表:按时间倒序展示所有训练记录,每条记录显示动作、重量、次数、估算 1RM、相对强度、记录时间。
- 统计卡片:展示总记录数、历史最高 1RM、最近一次 1RM、训练总次数。
- 按动作聚合统计:按动作名称分组,展示每个动作的记录条数、历史最高 1RM、最近一次重量与次数。
- 数据持久化:使用 localStorage 保存记录,刷新页面不丢失。
- 数据管理:提供清空记录的功能,防止误操作造成数据不可恢复。
为了让示例更有实用价值,我还会在代码中预留数据和导出相关接口,方便后续扩展。
3.2 技术选型
本文选择的技术方案如下:
- 语言:HTML5、CSS3、原生 JavaScript(ES6+)
- 存储:localStorage
- 运行方式:浏览器直接打开,或使用 VS Code Live Server 本地启动
- 依赖:无第三方库
选择原生技术栈的原因很简单:功能规模不大,没有必要引入 React 或 Vue。原生 JavaScript 能让你更清楚地理解数据流——表单事件、数组操作、DOM 渲染、本地存储,这些是框架底层也在做的事情。
3.3 数据模型设计
训练记录的数据模型如下,这段对象结构会在 JavaScript 中反复使用:
{ "id": "unique-id-string", "exercise": "卧推", "weight": 80, "reps": 5, "rm": 93.3, "relativeIntensity": 85.7, "createdAt": "2025-01-01T12:00:00.000Z" }字段说明:
id:唯一标识,用于列表渲染时的 key,我用时间戳转换字符串生成。exercise:动作名称,来自下拉选择框。weight:使用重量,单位 kg。reps:完成次数。rm:当次训练估算出的 1RM,由公式计算得出。relativeIntensity:相对强度,等于weight / rm * 100,结果保留一位小数。createdAt:记录创建时间,使用 ISO 字符串,便于排序和格式化显示。
整个应用的顶层数据结构是一个数组:
{ "records": [] }虽然直接保存数组也可以,但封装成对象结构可以在未来扩展时加入schemaVersion字段,方便数据迁移。这个细节会在最佳实践部分展开。
4. 实战:搭建项目与页面结构
4.1 创建项目结构
新建一个项目文件夹,建议命名为gym-progress-calculator,目录结构如下:
gym-progress-calculator/ ├── index.html ├── style.css └── app.js本文把 HTML、CSS、JavaScript 拆成三个文件,便于阅读和维护。如果你想快速体验,也可以把三个文件的内容合并到一个 HTML 文件中。
4.2 编写页面骨架 index.html
先编写页面骨架。这个文件负责定义整个应用的页面结构,包括标题区域、表单区域、统计区域、列表区域。
<!-- 文件路径:gym-progress-calculator/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>健身重量进度计算器</title> <link rel="stylesheet" href="style.css" /> </head> <body> <div class="container"> <h1>健身重量进度计算器</h1> <p class="subtitle">记录训练重量,自动估算 1RM,跟踪每一次进步</p> <section class="card form-card"> <h2>添加训练记录</h2> <form id="record-form"> <div class="form-row"> <label> 动作 <select id="exercise" required> <option value="">请选择动作</option> <option value="卧推">卧推</option> <option value="深蹲">深蹲</option> <option value="硬拉">硬拉</option> <option value="肩推">肩推</option> <option value="划船">划船</option> <option value="引体向上">引体向上</option> </select> </label> <label> 重量 (kg) <input type="number" id="weight" min="0" step="0.5" placeholder="例如 60" required /> </label> <label> 次数 <input type="number" id="reps" min="1" step="1" placeholder="例如 8" required /> </label> </div> <button type="submit" class="btn-primary">添加记录</button> </form> </section> <section class="card stats-card"> <h2>整体统计</h2> <div class="stats-grid"> <div class="stat-item"> <span class="stat-value" id="total-count">0</span> <span class="stat-label">记录总数</span> </div> <div class="stat-item"> <span class="stat-value" id="total-reps">0</span> <span class="stat-label">累计次数</span> </div> <div class="stat-item"> <span class="stat-value" id="max-rm">0</span> <span class="stat-label">历史最高 1RM</span> </div> <div class="stat-item"> <span class="stat-value" id="latest-rm">0</span> <span class="stat-label">最近一次 1RM</span> </div> </div> </section> <section class="card group-card"> <h2>按动作统计</h2> <div id="group-stats" class="group-stats"></div> </section> <section class="card list-card"> <h2>历史记录</h2> <div class="list-actions"> <button id="sync-btn" class="btn-secondary">模拟同步</button> <button id="clear-btn" class="btn-danger">清空所有记录</button> </div> <table class="record-table"> <thead> <tr> <th>动作</th> <th>重量</th> <th>次数</th> <th>估算 1RM</th> <th>相对强度</th> <th>时间</th> </tr> </thead> <tbody id="record-list"></tbody> </table> <p class="empty-tip" id="empty-tip">还没有训练记录,添加一条试试吧。</p> </section> </div> <script src="app.js"></script> </body> </html>页面结构说明:
- 表单区域包含三个输入字段,动作选择器支持后续扩展更多动作,重量和次数都做了
required和min校验。 - 整体统计区域展示四个核心指标,数值由 JavaScript 动态更新。
- 按动作统计区域是动态容器,JS 会按动作名称分组渲染统计卡片。
- 历史记录区域使用表格展示全部记录,并预留了两个操作按钮。
4.3 编写样式 style.css
样式的目标不是花哨,而是清晰、能快速定位关键信息。下面这份样式会在移动端做简单的响应式处理。
/* 文件路径:gym-progress-calculator/style.css */ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f6f8; color: #1f2329; line-height: 1.6; } .container { max-width: 960px; margin: 0 auto; padding: 24px 16px; } h1 { font-size: 28px; margin-bottom: 4px; } .subtitle { color: #6b7280; margin-bottom: 24px; } .card { background: #ffffff; border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .card h2 { font-size: 18px; margin-bottom: 16px; } .form-row { display: flex; flex-wrap: wrap; gap: 16px; } .form-row label { display: flex; flex-direction: column; font-size: 14px; color: #374151; flex: 1 1 160px; } .form-row input, .form-row select { margin-top: 6px; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 16px; } .btn-primary, .btn-secondary, .btn-danger { display: inline-block; border: none; border-radius: 8px; padding: 10px 18px; font-size: 15px; cursor: pointer; margin-top: 14px; } .btn-primary { background: #2563eb; color: #fff; } .btn-primary:hover { background: #1d4ed8; } .btn-secondary { background: #e5e7eb; color: #1f2329; } .btn-danger { background: #fee2e2; color: #b91c1c; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; } .stat-item { text-align: center; padding: 12px; background: #f9fafb; border-radius: 10px; } .stat-value { display: block; font-size: 28px; font-weight: 700; color: #111827; } .stat-label { display: block; margin-top: 4px; font-size: 13px; color: #6b7280; } .group-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } .group-stat-card { background: #f0fdf4; border-radius: 8px; padding: 12px; } .group-stat-card h3 { font-size: 16px; margin-bottom: 8px; } .group-stat-card p { font-size: 14px; color: #374151; } .list-actions { display: flex; gap: 12px; margin-bottom: 12px; } .record-table { width: 100%; border-collapse: collapse; font-size: 14px; } .record-table th, .record-table td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #e5e7eb; } .record-table th { color: #6b7280; font-weight: 600; } .empty-tip { color: #9ca3af; text-align: center; padding: 20px 0; } @media (max-width: 640px) { .form-row { flex-direction: column; } .record-table { font-size: 13px; } .stat-value { font-size: 22px; } }需要说明的是,样式文件里没有使用任何外部字体或图标库,因此离线也能正常显示。
4.4 编写 JavaScript 核心逻辑 app.js
这一部分是项目的核心。我会按照“常量与工具函数、数据读写、表单交互、统计渲染、列表渲染、初始化”的顺序实现。
// 文件路径:gym-progress-calculator/app.js (function () { 'use strict'; const STORAGE_KEY = 'gym-progress-records'; // ---------- 工具函数 ---------- // 计算估算 1RM,这里使用 Epley 公式 function calcRM(weight, reps) { if (weight <= 0 || reps <= 0) { return 0; } return weight * (1 + reps / 30); } // 计算相对强度,即本次重量占估算 1RM 的百分比 function calcRelativeIntensity(weight, rm) { if (rm <= 0) { return 0; } return weight / rm * 100; } // 格式化日期,输出 2025/01/01 14:30 的格式 function formatTime(isoString) { const date = new Date(isoString); if (Number.isNaN(date.getTime())) { return '未知时间'; } const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}/${month}/${day} ${hours}:${minutes}`; } // 生成简单唯一 id function generateId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } // ---------- 数据读写 ---------- function loadRecords() { try { const rawData = localStorage.getItem(STORAGE_KEY); if (!rawData) { return []; } const parsed = JSON.parse(rawData); // 防止数据结构不正确导致页面崩溃 return Array.isArray(parsed) ? parsed : []; } catch (error) { console.error('读取本地记录失败:', error); return []; } } function saveRecords(records) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); } catch (error) { console.error('保存本地记录失败:', error); alert('保存失败,请检查浏览器存储空间是否已满,或是否存在隐私模式限制。'); } } // ---------- 表单事件 ---------- function handleAddRecord(event) { event.preventDefault(); const exerciseSelect = document.getElementById('exercise'); const weightInput = document.getElementById('weight'); const repsInput = document.getElementById('reps'); const exercise = exerciseSelect.value; const weight = parseFloat(weightInput.value); const reps = parseInt(repsInput.value, 10); // 前端校验,避免脏数据进入存储 if (!exercise) { alert('请选择动作'); return; } if (!Number.isFinite(weight) || weight <= 0) { alert('请输入有效的重量'); return; } if (!Number.isInteger(reps) || reps <= 0 || reps > 100) { alert('请输入有效的次数(1-100)'); return; } const rm = calcRM(weight, reps); const relativeIntensity = calcRelativeIntensity(weight, rm); const record = { id: generateId(), exercise, weight, reps, rm: Math.round(rm * 10) / 10, relativeIntensity: Math.round(relativeIntensity * 10) / 10, createdAt: new Date().toISOString() }; const records = loadRecords(); records.unshift(record); saveRecords(records); // 清空重量和次数,方便连续录入 weightInput.value = ''; repsInput.value = ''; renderAll(records); } // ---------- 统计渲染 ---------- function renderGlobalStats(records) { const totalCount = records.length; const totalReps = records.reduce((sum, record) => sum + record.reps, 0); const maxRM = records.reduce((max, record) => Math.max(max, record.rm), 0); const latestRM = records.length > 0 ? records[0].rm : 0; document.getElementById('total-count').textContent = totalCount; document.getElementById('total-reps').textContent = totalReps; document.getElementById('max-rm').textContent = maxRM.toFixed(1); document.getElementById('latest-rm').textContent = latestRM.toFixed(1); } function renderGroupStats(records) { const groupMap = {}; records.forEach((record) => { const group = groupMap[record.exercise] || { exercise: record.exercise, count: 0, maxRM: 0, latestWeight: 0, latestReps: 0, latestTime: '' }; group.count += 1; group.maxRM = Math.max(group.maxRM, record.rm); // records 数组是倒序的,第一条出现的记录就是最新的 if (!group.latestTime) { group.latestWeight = record.weight; group.latestReps = record.reps; group.latestTime = record.createdAt; } groupMap[record.exercise] = group; }); const container = document.getElementById('group-stats'); const groupList = Object.values(groupMap); if (groupList.length === 0) { container.innerHTML = '<p class="empty-tip">暂无分组数据</p>'; return; } let html = ''; groupList.forEach((group) => { html += ` <div class="group-stat-card"> <h3>${group.exercise}</h3> <p>记录次数:${group.count}</p> <p>最高 1RM:${group.maxRM.toFixed(1)} kg</p> <p>最近训练:${group.latestWeight} kg × ${group.latestReps} 次</p> <p>最近时间:${formatTime(group.latestTime)}</p> </div> `; }); container.innerHTML = html; } // ---------- 列表渲染 ---------- function renderRecordList(records) { const tbody = document.getElementById('record-list'); const emptyTip = document.getElementById('empty-tip'); if (records.length === 0) { tbody.innerHTML = ''; emptyTip.style.display = 'block'; return; } emptyTip.style.display = 'none'; // 使用 DocumentFragment 减少 DOM 操作次数 const fragment = document.createDocumentFragment(); records.forEach((record) => { const tr = document.createElement('tr'); const exerciseTd = document.createElement('td'); exerciseTd.textContent = record.exercise; const weightTd = document.createElement('td'); weightTd.textContent = record.weight + ' kg'; const repsTd = document.createElement('td'); repsTd.textContent = record.reps + ' 次'; const rmTd = document.createElement('td'); rmTd.textContent = record.rm.toFixed(1) + ' kg'; const intensityTd = document.createElement('td'); intensityTd.textContent = record.relativeIntensity.toFixed(1) + '%'; const timeTd = document.createElement('td'); timeTd.textContent = formatTime(record.createdAt); tr.append(exerciseTd, weightTd, repsTd, rmTd, intensityTd, timeTd); fragment.appendChild(tr); }); tbody.innerHTML = ''; tbody.appendChild(fragment); } function renderAll(records) { renderGlobalStats(records); renderGroupStats(records); renderRecordList(records); } // ---------- 清空与模拟同步 ---------- function handleClear() { const confirmed = window.confirm('确定要清空所有训练记录吗?此操作无法撤销。'); if (!confirmed) { return; } saveRecords([]); renderAll([]); } function handleSync() { const records = loadRecords(); if (records.length === 0) { alert('当前没有可同步的记录'); return; } // 这里模拟一个同步动作,后续可以替换为真实后端接口 const syncData = JSON.stringify({ records }, null, 2); console.log('模拟同步数据:', syncData); alert('模拟同步完成,请在浏览器控制台查看同步数据。'); } // ---------- 初始化 ---------- function init() { const form = document.getElementById('record-form'); const clearBtn = document.getElementById('clear-btn'); const syncBtn = document.getElementById('sync-btn'); form.addEventListener('submit', handleAddRecord); clearBtn.addEventListener('click', handleClear); syncBtn.addEventListener('click', handleSync); const records = loadRecords(); renderAll(records); } init(); })();代码中有几个需要特别注意的点。
第一,loadRecords使用try-catch包裹解析过程。localStorage 中可能会存在旧版本数据或人为改坏的 JSON 字符串,如果直接JSON.parse导致异常,整个应用会崩溃。加上容错后,即使数据异常,页面也能正常打开,只是历史记录会暂时读不出来。
第二,renderGroupStats依赖records数组的倒序特性来确定“最近一次训练”。因为新记录总是unshift到数组开头,所以遍历时第一次遇到某个动作,就是它最新的记录。
第三,列表渲染使用DocumentFragment批量插入 DOM,避免在循环中频繁操作真实 DOM。这个优化在数据量不大时效果不明显,但属于前端渲染的标准姿势,建议养成习惯。
5. 实战:运行与验证
5.1 本地启动方式
由于项目是纯静态文件,启动方式非常灵活。
方式一:直接双击index.html,用浏览器打开。这个方式最简单,但不推荐日常开发使用,因为本地文件模块化加载会有一些限制。
方式二:使用 VS Code 的 Live Server 插件。在 VS Code 中打开项目文件夹,右键index.html,选择 “Open with Live Server”,浏览器会自动打开本地服务地址,默认端口通常是5500或5501。这种方式更接近真实开发环境,浏览器开发者工具调试也更顺手。
5.2 手动测试步骤
打开页面后,按下面的步骤操作一遍:
- 在表单中选择动作“卧推”。
- 输入重量
80,次数5。 - 点击“添加记录”。
- 在历史记录列表中,应该出现一条新记录,估算 1RM 显示为
93.3 kg,相对强度为85.7%。 - 继续添加记录,例如“深蹲 100kg × 8”、“硬拉 120kg × 3”。
- 观察整体统计中的“记录总数”“累计次数”“历史最高 1RM”是否随之更新。
- 刷新浏览器,确认记录仍然存在,说明 localStorage 持久化生效。
5.3 预期计算结果校验
用 Epley 公式验证:
- 卧推 80kg × 5 次:
80 × (1 + 5/30) = 80 × 1.1667 ≈ 93.3 - 深蹲 100kg × 8 次:
100 × (1 + 8/30) = 100 × 1.2667 ≈ 126.7 - 硬拉 120kg × 3 次:
120 × (1 + 3/30) = 120 × 1.1 = 132.0
相对强度:
- 卧推:
80 / 93.3 ≈ 85.7% - 深蹲:
100 / 126.7 ≈ 78.9% - 硬拉:
120 / 132.0 ≈ 90.9%
这些计算结果会自动保留一位小数显示在页面上。如果浏览器控制台出现NaN或undefined,优先检查输入框是否被清空、表单数据是否正常传入函数。
6. 功能扩展:加入 Brzycki 公式与训练周期标记
上一版的核心功能已经可以日常使用了,但如果你想把它做得更贴合真实的周期化训练,还可以继续扩展。我建议优先加入两个功能:公式可切换,以及训练周期标记。
6.1 公式支持动态切换
在实际训练中,Epley 和 Brzycki 计算出的 1RM 会略有差异。你可以把公式做成可选项,让用户根据自己的偏好选择。
在app.js中,抽出计算函数,增加formulaType参数:
function calcRM(weight, reps, formulaType = 'epley') { if (weight <= 0 || reps <= 0) { return 0; } if (formulaType === 'brzycki') { // Brzycki 公式在次数接近 37 时会失真,需做边界判断 if (reps >= 36) { return weight * reps / 30; } return weight * (36 / (37 - reps)); } // 默认使用 Epley return weight * (1 + reps / 30); }然后在 HTML 的表单区域增加一个下拉框,在初始化时读取用户选择,保存到 localStorage 中。这样不同训练者可以根据自己的反馈选择更适合的估算模型。
6.2 训练周期标记
很多训练者在备赛期、增肌期和减脂期的训练强度完全不同。如果只记录重量和次数,回看历史时会少一个维度。
增加一个可选的“训练阶段”字段,例如“增肌期”“力量期”“减脂期”“技术期”。在数据模型中增加phase字段,列表和统计中按阶段筛选,就能观察不同阶段的训练量和强度分布。
这个扩展的难度不大,但能让工具从“记录器”变成“训练周期的分析器”。这也是很多商业健身 App 的核心功能之一,你可以用自己的代码实现。
7. 常见问题与排查思路
在实际开发和使用中,比较常见的问题整理如下:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 刷新页面后记录丢失 | 浏览器隐私模式限制 localStorage | 改用无痕模式测试,或检查浏览器是否开启“退出时清除数据” |
| 页面打开时报错,记录列表空白 | localStorage 中数据格式损坏 | 在控制台执行localStorage.removeItem('gym-progress-records')清掉旧数据 |
| 输入数字后提示无效 | 表单校验逻辑判断值过严 | 在控制台打印weight和reps,确认是否被解析为NaN |
| 1RM 计算结果过高或过低 | 次数过高导致公式失真 | 次数超过 15 次时,在页面提示“仅供参考” |
| 表格在手机端显示错位 | 没有适配小屏幕 | 让表格容器支持横向滚动,参考overflow-x: auto |
| 添加记录后列表没有刷新 | 事件绑定或渲染函数未正确调用 | 检查renderAll是否被调用,以及records是否更新 |
| 清空记录时误操作 | 没有二次确认 | 使用window.confirm确认,后续可改为弹出层确认 |
7.1 数据损坏的排查方法
如果你在开发过程中修改过数据结构,旧数据里可能缺少rm或relativeIntensity字段。这种情况最容易导致页面渲染异常。
排查时打开浏览器开发者工具,切到 Application 面板,在 Local Storage 中查看gym-progress-records的值。如果发现结构不对,可以先手动清空,然后重新添加测试记录。
7.2 localStorage 容量问题
localStorage 的存储上限因浏览器而异,一般在 5MB 左右。对于纯文本训练记录,每条数据约占 200 字节,这个容量足够记录上万条训练记录。但如果你未来想存图片、视频,就不能依赖 localStorage 了,需要引入 IndexedDB 或后端存储。
8. 最佳实践与工程建议
8.1 数据存储结构版本化
项目虽然简单,但需要考虑未来升级。直接保存数组可以满足当前需求,但如果你想增加字段、改变数据结构,旧数据就无法直接兼容。
建议在保存时增加版本号:
{ "version": 1, "records": [] }读取数据时检查version,当版本升级时执行迁移函数。这是真实项目中非常常见的数据兼容策略。
8.2 表单校验与用户反馈
前端校验不能替代后端校验,但对于纯前端工具,它是数据质量的第一道防线。当前代码中已经校验了空动作、非法重量、非法次数。你可以继续补充校验:
- 重量上限,例如不超过 500kg
- 次数上限,例如不超过 100 次
- 动作名称必须在预设列表中,防止人为修改 DOM 后提交任意内容
校验不通过时,除了alert,也可以使用页面内联提示,把错误信息展示在输入框下方,交互体验更好。
8.3 计算逻辑与渲染逻辑分离
在app.js中,calcRM和calcRelativeIntensity是纯函数,它们不接触 DOM,不依赖全局状态。这是值得保持的好习惯。
纯函数的好处是容易测试。比如,你可以在控制台执行:
calcRM(80, 5); // 93.33333333333334如果未来引入 Jest 或 Vitest,这类纯函数可以直接编写单元测试。渲染函数则需要模拟 DOM 环境,测试成本更高,所以尽量把业务逻辑放到纯函数中。
8.4 关于训练安全的提醒
这个计算器给出的 1RM 是估算值,不能替代真实极限测试。实际训练中,使用大重量前一定要充分热身,最好有训练伙伴保护。重量的增加建议循序渐进,比如每次增加 2.5kg 到 5kg,不要为了让数据好看而盲目加重。数据的价值在于指导长期进步,而不是鼓励短期冒险。
8.5 代码可维护性建议
从工程角度看,这个项目的代码还可以通过以下几种方式提升可维护性:
- 把动作列表抽成常量数组,渲染下拉选项时用 JavaScript 生成,而不是在 HTML 中硬编码。
- 把统计卡片渲染函数改成接收数据对象而不是直接操作 DOM,方便单元测试。
- 使用
class或函数式模块拆分“存储、计算、渲染”三层,为后续迁移到 Vue 或 React 做准备。 - 给关键函数添加 JSDoc 注释,方便其他开发者快速理解。
对于个人项目来说,这些措施不一定全部需要,但养成这样的编码意识,对职业生涯会有帮助。
9. 总结与下一步计划
这个健身重量进度计算器虽然功能简单,但完整地走通了需求分析、数据建模、核心计算、前端渲染、本地持久化、问题排查这一整套流程。通过这个项目,你可以掌握几个非常实用的前端技能:表格数据的增删展示、localStorage 的读写与容错、1RM 计算公式的工程化实现、按动作分组的统计聚合逻辑。
如果你打算继续完善它,我建议按照下面几个方向推进。
第一个方向是增加图表可视化。引入 Chart.js 或 ECharts,把每个动作的 1RM 变化画成折线图,进步趋势会非常直观。第二个方向是引入训练周期和周计划。你可以为每周设置训练目标,比如“本周卧推目标 85kg × 5”,完成自动打勾。第三个方向是支持数据导出导入。把 localStorage 中的数据导出为 JSON 文件,换设备时再导入,解决数据迁移问题。第四个方向是加入 RPE 自评,让训练者记录主观疲劳度,从而更准确判断状态调整计划。
我建议你先把当前版本用起来,记录两周真实训练数据,感受一下数据回看带来的节奏感。等积累了一些记录,再回头思考缺少什么功能,然后动手改代码。工具的价值不在代码量,而在坚持使用。
如果你在复现过程中遇到其他问题,欢迎在评论区交流。训练要持续记录,代码也要持续迭代,下一版见。