纯前端健身重量进度计算器:1RM估算与训练数据管理实战 📅 发布时间:2026/8/29 7:46:33 👁 浏览次数: 上次去健身房训练我发现自己还在用手机备忘录记录卧推重量翻到上个月的记录还要一条条往上滑更别提判断“这个月到底进步了多少”了。真正让我下决心做这个小工具是某天想确认“硬拉 100kg 做 5 次”和“硬拉 90kg 做 8 次”到底哪个强度更高结果在纸上算了半天。如果你也有类似经历说明你缺的不是训练热情而是一个能自动计算重量进度、估算 1RM、记录每次训练的工具。这篇文章就是围绕这个场景展开的。我会从需求分析、核心计算模型开始一步步用纯前端技术实现一个健身房重量进度计算器包含训练记录、1RM 估算、相对强度分析、历史数据持久化等功能。整个项目不需要后端服务浏览器打开就能用适合新手学习前端实战也适合有健身习惯的开发者直接部署给自己用。项目虽然不大但涉及数据建模、计算公式选择、本地存储、列表渲染、统计聚合这些前端日常开发的核心环节读完后你可以举一反三改成自己的训练日志、食谱记录或者体重管理工具。需要提前说明的是本文示例采用纯 HTML、CSS、JavaScript 实现不依赖任何框架和构建工具。关于训练数据和计算公式我会给出通用的健身领域估算方式具体数值请结合你个人的训练感受调整。1. 为什么要做健身重量进度计算器1.1 训练记录的真实痛点大多数健身爱好者在记录训练时通常会遇到三个问题。第一个问题是记录分散。今天用备忘录明天用 Excel后天可能直接不记长期积累的数据很难统一查看。第二个问题是数据没有可比性。今天做“60kg × 10 次”上周做“70kg × 5 次”单纯看重量和次数都无法判断哪一次训练负荷更大更无法判断自己是否在进步。第三个问题是训练计划调整靠感觉。下次训练该加多少重量5kg 还是 2.5kg如果没有数据支持很容易练得比较随意。这些问题本质上不是“记录动作”的问题而是“记录之后如何计算、如何对比、如何指导下一步训练”的问题。重量进度计算器要解决的就是第二个和第三个问题。1.2 这个项目解决什么问题一个健身重量进度计算器核心能力可以概括为三点记录每次训练的基本数据动作名称、使用重量、完成次数、训练日期。根据重量和次数估算当次训练的最大力量水平也就是常说的 1RMOne Repetition Maximum单次最大重量。展示历史趋势比如某个动作的历史最高 1RM、最近一次训练负荷、记录总数让用户直观看到训练是否在进步。在此基础上还可以扩展出相对强度分析也就是“当次训练重量占你估算最大重量的百分比”方便判断训练是偏向力量、肌肥大还是耐力区间。1.3 为什么选择纯前端方案项目定位是个人工具不需要多人协作也不涉及复杂的后端逻辑因此纯前端是最合适的方案。纯前端有几个明显优点第一不需要搭建服务器双击 HTML 文件就能运行第二使用 localStorage 存储数据浏览器关闭再打开数据依然存在足够满足个人记录需求第三代码结构简单容易阅读和修改方便你根据自己习惯调整功能。当然纯前端也有边界。localStorage 的存储空间一般在 5MB 左右只适合存储文本型记录数据保存在当前浏览器中换浏览器或清理缓存会造成历史记录丢失。如果你的目标是做一个多端同步的完整产品那需要引入后端和服务端数据库本文暂时不涉及。2. 核心概念1RM、训练容量与相对强度2.1 什么是 1RM1RM 指某个动作你只能完成一次的最大重量。它被广泛用于衡量训练者的力量水平。但直接测试 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、原生 JavaScriptES6存储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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title健身重量进度计算器/title link relstylesheet hrefstyle.css / /head body div classcontainer h1健身重量进度计算器/h1 p classsubtitle记录训练重量自动估算 1RM跟踪每一次进步/p section classcard form-card h2添加训练记录/h2 form idrecord-form div classform-row label 动作 select idexercise 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 typenumber idweight min0 step0.5 placeholder例如 60 required / /label label 次数 input typenumber idreps min1 step1 placeholder例如 8 required / /label /div button typesubmit classbtn-primary添加记录/button /form /section section classcard stats-card h2整体统计/h2 div classstats-grid div classstat-item span classstat-value idtotal-count0/span span classstat-label记录总数/span /div div classstat-item span classstat-value idtotal-reps0/span span classstat-label累计次数/span /div div classstat-item span classstat-value idmax-rm0/span span classstat-label历史最高 1RM/span /div div classstat-item span classstat-value idlatest-rm0/span span classstat-label最近一次 1RM/span /div /div /section section classcard group-card h2按动作统计/h2 div idgroup-stats classgroup-stats/div /section section classcard list-card h2历史记录/h2 div classlist-actions button idsync-btn classbtn-secondary模拟同步/button button idclear-btn classbtn-danger清空所有记录/button /div table classrecord-table thead tr th动作/th th重量/th th次数/th th估算 1RM/th th相对强度/th th时间/th /tr /thead tbody idrecord-list/tbody /table p classempty-tip idempty-tip还没有训练记录添加一条试试吧。/p /section /div script srcapp.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 classempty-tip暂无分组数据/p; return; } let html ; groupList.forEach((group) { html div classgroup-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确认是否被解析为NaN1RM 计算结果过高或过低次数过高导致公式失真次数超过 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 自评让训练者记录主观疲劳度从而更准确判断状态调整计划。我建议你先把当前版本用起来记录两周真实训练数据感受一下数据回看带来的节奏感。等积累了一些记录再回头思考缺少什么功能然后动手改代码。工具的价值不在代码量而在坚持使用。如果你在复现过程中遇到其他问题欢迎在评论区交流。训练要持续记录代码也要持续迭代下一版见。