1. 项目缘起与需求拆解
1.1 为什么是计算器
很多人学JavaScript喜欢一上来就做网页、做交互特效,但我个人给新人的建议始终是:先把计算器做扎实。
原因很简单。计算器这个项目,界面看起来只有几个数字按钮和运算符按钮,但真正动手写的时候,你会发现它几乎覆盖了JavaScript初学阶段的全部核心知识点:事件绑定、字符串与数值的转换、运算符优先级、状态管理、边界条件处理,甚至还能牵扯出浮点数精度、闭包、防抖这些进阶话题。一个巴掌大的小方块,能把基础打得比刷一百道题都结实。
我这里说的计算器,不是那种点一下数字、点一下加号、点一下等号就出结果的玩具版本,而是带连续运算、连按等号、百分比、正负号切换、键盘输入支持、退格删除、异常处理的完整实现。你平时手机里自带的那个计算器是什么交互逻辑,我们就往那个方向去靠。
先定清楚需求,写代码才不会跑偏:
- 支持 0-9、小数点、加减乘除四则运算
- 支持连续运算,比如输入 12 + 7 + 5.5,等号只出最终结果
- 支持运算符替换,比如输入 5 + 后改成 5 -,显示内容要跟着变
- 支持按等号后再按运算符继续用得上一步结果
- 支持退格、清空、正负号切换、百分号
- 按键要有视觉反馈,不能像块木头点了没反应
- 必须处理除数为 0、空算式按等号这些异常场景
- 原生的浮点运算会有精度问题,必须给出方案
1.2 项目目录与运行方式
这个项目不需要任何框架,不需要构建工具,直接三个文件就可以跑起来,放进浏览器里双击即可使用:
calculator/ ├── index.html ├── style.css └── script.js我习惯把逻辑和结构分开,用语义化标签而不是满屏的<div>。按钮用<button>而不是<span>加点击事件,因为<button>天生有键盘焦点、回车触发的特性,这对后面接键盘事件非常有帮助。
HTML 骨架大致是这样的:
<main class="calculator"> <section class="display"> <p class="expression" id="expression"></p> <p class="result" id="result">0</p> </section> <section class="buttons"> <button>const state = { current: "0", // 当前正在输入的数字(字符串形式) expression: "", // 已经确认的算式部分,例如 "12 + " lastResult: null, // 上一次运算结果,用于连按等号 lastOperator: null // 上一次的运算符,用于连按等号 };current用字符串而不是数字类型保存,是有讲究的。如果你用数字类型,输入0的时候开头多余的0很难控制,小数点的判定也要额外处理。字符串天然保留了用户输入的字面形式,什么时候转成数字参与运算、转成什么精度,由我们说了算。
显示层只负责一件事:把state画到屏幕上。业务逻辑和显示逻辑分离后,排查问题会轻松很多,你能清楚地判断是状态算错了,还是单纯渲染错了。
function render() { const expressionEl = document.getElementById("expression"); const resultEl = document.getElementById("result"); if (state.lastOperator && state.current === "0") { // 刚按过运算符,结果区还保留上一次的数字 } expressionEl.textContent = state.expression; resultEl.textContent = state.current; }初始时current为"0",显示 0;用户按了数字5后,current变成"5",显示 5。要点是:直接更新current字符串,而不是做数值加法。比如当前显示"12",用户按了3,那应该变成"123",这在字符串层面就是current += "3"而已,逻辑非常直观。
2.2 页面渲染的坑:为什么显示值会被截断
这里提一个我在实践里踩过的坑:超长数字的显示截断。
手机自带的计算器,输入长数字时显示区会变小字号或者滚动。模拟这个效果需要一个formatDisplay()函数,对超长结果做显示层处理,而不改变内部真实数值:
function formatDisplay(value) { const str = String(value); if (str.length <= 12) return str; if (str.includes("e")) return str; return Number(value).toPrecision(12); }不处理的话,输入 20 位数字后屏幕直接溢出换行,布局乱掉。这只是视觉问题,不影响运算,但一个计算器连显示都撑不住,就很难谈用户体验了。
3. 事件系统与统一分发的设计思路
3.1 事件委托的妙处
给 18 个按钮分别绑click事件,不是不行,但代码会非常啰嗦。我用的是事件委托:
document.querySelector(".buttons").addEventListener("click", (e) => { const target = e.target.closest("button"); if (!target) return; const key = target.dataset.key; if (!key) return; handleKey(key); });closest("button")的作用是防止用户点到按钮内部的文字或图标时拿不到按钮元素。事件委托的好处是:以后新增按钮,不需要额外绑定事件,只要写进 HTML 并确保>function inputDigit(digit) { if (state.expression && !state.lastOperator && state.lastResult !== null) { // 按过等号后重新开始输入,清掉上次结果 resetState(); } if (state.current.replace(/[-.]/g, "").length >= 12) { // 输入位符数量限制 return; } if (state.current === "0" && digit !== ".") { state.current = digit; } else { state.current += digit; } render(); }
输入小数点的规则则是:如果当前数字里已经有小数点,再按小数点就不允许。判断方式可以用state.current.includes("."),但要注意-号不算入判断。
还有个细节:按了百分号之后状态是current已经成了"0.25",此时继续按数字应如何拼接?我的处理是百分比运算后把current标记为"已完成",如果继续输入数字就重置状态,否则用户会以为自己在补小数位,实际是在拼"0.25" + "3" = "0.253",非常容易糊涂。
3.3 运算符按下时发生了什么
输入12,按+,此时发生的不是计算,而是"提交当前数字并记住运算符"。流程如下:
function inputOperator(op) { // 若当前输入框为空或刚按过等号,直接切换运算符 if (state.expression === "" && state.current === "0") { return; } if (state.lastOperator && state.current !== "-") { // 已经有一个待处理的 a + b,此时输入第二运算符等于先结算 calculate(); } state.expression = appendExpression(state.current, op); state.lastOperator = op; state.lastResult = null; state.current = "0"; render(); }重点理解这段逻辑:按了第二个运算符时,比如先输入5 + 3,再按*,就应该先算出5 + 3 = 8,然后把8 *作为新的表达式串。这个设计是手机计算器上最常见的连锁运算规则。如果不先结算,表达式会越堆越长,最终算出来的结果和用户预期不一致。
appendExpression负责拼出人类可读的运算式:
function appendExpression(value, op) { const symbol = { divide: "÷", multiply: "×", subtract: "-", add: "+" }[op]; return `${state.expression}${value} ${symbol} `; }这样显示区就能看到完整的"12 + 7 × "这样的过程,而不是只看到最后一个数字。很多入门教程只做"结果面板",并不展示中间过程,用户体验差了一个级别。
3.4 核心计算逻辑:如何避免浮点误差
计算本身不复杂,真正的坑在 JavaScript 的浮点数上。0.1 + 0.2的结果是0.30000000000000004,这在计算器里是致命的。
我用的是"先放大成整数,计算完再缩小"的思路:
function operate(a, b, operator) { let result; switch (operator) { case "add": result = (a * 1000 + b * 1000) / 1000; break; case "subtract": result = (a * 1000 - b * 1000) / 1000; break; case "multiply": result = (a * 10000 * b * 10000) / 100000000; break; case "divide": if (b === 0) throw new Error("除数为0"); result = (a * 10000) / (b * 10000); break; } return result; }这个方案的原理是 JavaScript 的整数运算没有精度问题,所以把小数乘以 10 的倍数转成整数再算。但相乘的放大幅度要控制好,0.001 * 0.001这种场景如果放大倍数太小反而引入新误差。对于一般计算器场景,保留 10-12 位有效数字足够了。
还有更简洁的思路:全部运算完成后再做一次格式化,去掉多余尾巴。比如:
function trimFloat(num) { return parseFloat(num.toPrecision(12)); }toPrecision(12)会直接把浮点误差截断,比如0.30000000000000004变成0.300000000000,再parseFloat就干净了。对大多数场景来说,这个方案最省事,我最终代码里也是用这个方案兜底。
计算完成后,结果直接塞回current作为新的基础值,lastOperator保留,lastResult记录本次结果。这样再按一次等号就能实现"连加同一个数"。
3.5 等号与连续等号
等号按下时:
function handleEquals() { if (!state.lastOperator || state.current === "0") return; const a = parseFloat(state.expression); const b = parseFloat(state.current); const result = safeOperate(a, b, state.lastOperator); state.lastResult = result; state.expression = `${a} ${symbol} ${b} =`; state.current = String(result); state.lastOperator = null; render(); }注意,计算时a不能是parseFloat(state.expression),因为表达式尾部本来就带运算符字符,比如"12 + ",解析出来是12,没问题,但下一步如果要支持更复杂的表达式串就脆弱了。标准做法是把a单独存一个变量,比如state.firstOperand,每次按运算符时expression只负责展示,state.firstOperand存真正的数值。
连续按等号的逻辑是:再次按等号,如果state.lastOperator存在(每次运算后我都把lastOperator清空,但把运算符保存在repeatOperator里),就继续做当前结果 + 重复运算符 + 基础值。
我在代码里专门留了state.repeatOperator,等号把lastOperator转存到repeatOperator:
if (state.repeatOperator && state.lastResult !== null) { const next = safeOperate(state.lastResult, b, state.repeatOperator); state.lastResult = next; state.current = String(next); render(); }这样,输入5 + 3 =得到 8,再按=得到 11,再按得到 14,和手机计算器行为一致。
4. 键盘支持与交互细节
4.1 物理键盘映射
做计算器不接键盘,我觉得是不完整的。很多办公场景用户习惯用小键盘直接输数字和运算符,所以keydown监听必须安排上:
document.addEventListener("keydown", (e) => { e.preventDefault(); const keyMap = { "0": "0", "1": "1", "2": "2", "3": "3", "4": "4", "5": "5", "6": "6", "7": "7", "8": "8", "9": "9", ".": "dot", "+": "add", "-": "subtract", "*": "multiply", "/": "divide", "Enter": "equals", "=": "equals", "Backspace": "backspace", "Delete": "C", "Escape": "C", "%": "percent" }; const key = keyMap[e.key]; if (key) handleKey(key); });要注意的是e.preventDefault()不能无条件调用。如果你用户在页面上用 Tab 键移动到按钮上,然后按 Enter 触发按钮,这个行为应该保留。更好的做法:判断目标元素本身是否是可交互元素,如果是则放行。不过练习项目上我通常直接拦,熟人客户不会在意。
4.2 按钮按下时的视觉反馈
点击反馈用 CSS 的:active选择器最简单:按下瞬间给个暗色底色,松开恢复。我实际开发中发现:active在小键盘上偶尔失灵——物理键盘的 Enter 触发的是按钮的 click 事件,但视觉上用户希望看到"按下去"的反馈,光靠:active不靠谱。
所以我在handleKey里主动做了按键高亮:
function flashButton(key) { const btn = document.querySelector(`[data-key="${key}"]`); if (!btn) return; btn.classList.add("pressed"); setTimeout(() => btn.classList.remove("pressed"), 60); }延迟 60 毫秒移除高亮,视觉上是标准的按键弹起效果。这块算锦上添花,但对触摸屏用户尤其重要,没有视觉反馈的按钮会让人觉得自己点了没反应。
5. 功能逐步实现与避坑细节
5.1 正负号切换
正负号切换本质上就是改当前显示值:
function toggleSign() { if (state.current === "0") return; state.current = state.current.startsWith("-") ? state.current.slice(1) : "-" + state.current; render(); }这个逻辑听起来很简单,但有细节:如果用户在按完运算符之后立即按正负号,比如输入5 +,此时current是"0",但用户期待的其实是给下一个数字输入加上负号,比如输入-3。如果按上述逻辑判断current === "0"就返回,用户就无法输入负数了。
正确做法是允许"0"变成"-0",然后在输入数字时"-0"应该替换为"-5"而不是"-05"。因此inputDigit里需要加一个判断:
if (state.current === "-0") { state.current = "-" + digit; } else if (state.current === "0") { state.current = digit; } else { state.current += digit; }这类边界条件如果不仔细梳理,用户随便玩两下就会发现"负数输不进去""按了负号没反应"等奇怪问题。
5.2 退格键与安全删除
退格应该只删除当前输入的数字,不碰已经确认的表达式部分:
function handleBackspace() { if (state.current.length > 1) { state.current = state.current.slice(0, -1); } else { state.current = "0"; } render(); }但如果上一步刚按过等号,说明整个算式已经结算完,此时按退格应该清空一切,而不是把一个已经显示的结果8删成0。所以要在handleBackspace开头加一个判断:
if (state.expression.includes("=")) { resetState(); return; }这个细节是我自己在使用中发现的:别人操作我的计算器时,按完等号看结果,觉得数字不对想删掉重来,按退格发现数字从 8 变成 0 了,表达式却还在,交互很反直觉。改掉之后舒服多了。
5.3 百分比运算怎么算才对
百分号在不同计算器上的规则差异很大。最简单也最稳妥的做法,是把百分号当作"当前值除以 100",不做累加式的百分比运算:
function handlePercent() { if (state.current === "0") return; state.current = String(parseFloat(state.current) / 100); render(); }为什么这么设计?因为复杂的百分比逻辑,比如200 + 10%到底是算成200 + 200*0.1还是200 + 0.1,不同系统定义不同。如果业务没有特殊要求,一律"转小数"是最不容易被吐槽的。
当然,我这里没有做200 + 10% = 220这种效果,因为严格讲那是"百分比加"逻辑。如果有人需要那种,需要额外传入"基数"状态,这个可以作为项目升级方向,不放入基础版。
5.4 内部状态机检验
我写完整套逻辑后,自己画过一个状态图来验证逻辑闭环,手写流程大概是:
开始 -> 输入数字 -> 输完按运算符 -> 等待第二操作数 -> 输入第二操作数 -> 按等号/再按运算符用表格模拟几组用例测试:
| 操作序列 | 预期结果 | 实际结果 |
|---|---|---|
| 12 + 7 = | 19 | 19 |
| 12 + 7 + 5 = | 24 | 24 |
| 12 + 7 = = | 26 | 26 |
| 5 / 0 = | 报错 | 报错 |
| 0.1 + 0.2 = | 0.3 | 0.3 |
| 8 × 0 = | 0 | 0 |
| 5 按% | 0.05 | 0.05 |
连续按两次等号是容易出问题的点,如果状态设计不对,第二次等号可能拿到的是上一次的基础值 12 而不是上次结果 19,导致得出 31 而不是 26。这组用例建议你们抄到自己的测试清单里。
6. 调试与常见问题排查实录
6.1 算术表达式拼接错误
有一次我调试时发现,输入10 + 5 +,显示区出现的是"10 + 5 + ",没问题;但输入10 + 5 *时,表达式串变成了"10 + 5 * ",看起来也对,可实际计算时却莫名其妙先算出了 15,再乘以下一个数。
排查后发现问题在于inputOperator里"先结算再切换运算符"的逻辑。我先算出了10 + 5 = 15,但由于历史操作符+还残留在lastOperator,下一次按*时又进行了错误的二次计算。
解决办法也很简单:每次按运算符后,把lastOperator更新为新运算符,并把expression重置为已经确认的第一操作数:
state.expression = ""; state.firstOperand = resultOfPrevious;这套逻辑反复调了很久,最大的教训是:运算符与表达式串必须保持一致,不能表达式串写着"10 + 5 * ",内部存储的lastOperator却还是+。
6.2 等号后按数字与运算符的规则冲突
等号按完之后,状态进入"结果态"。此时如果直接按数字,用户预期是清除算式重新输入;如果按的是运算符,用户预期是基于结果继续运算。
我一开始没做结果态标记,导致按完等号再按5,数字直接拼接到了当前结果上,比如 19 变成 195。这个问题在人群里最容易暴露,体验损伤很大。
修复方式是在state里加一个justEvaluated标记:
function resetForNewInput() { state.current = "0"; state.expression = ""; state.lastOperator = null; state.lastResult = null; state.justEvaluated = false; }按数字时判断:
if (state.justEvaluated) { resetForNewInput(); state.justEvaluated = false; }按运算符时判断:
if (state.justEvaluated) { state.firstOperand = parseFloat(state.current); state.justEvaluated = false; }这样既不会把结果和下一个数字拼在一起,也支持从结果继续运算。
6.3 运算符优先级:不做,还是做?
基础计算器我建议不做"先乘除后加减"。为什么?因为一旦引入真正的运算符优先级,就必须引入表达式解析或者调度场算法,这远超一个入门练手项目的范围,而且手机自带计算器的标准模式也是严格从左到右计算的。
所以我的处理方式是:任何运算符都立即执行,严格左结合。输入2 + 3 × 4,计算顺序是(2 + 3) × 4 = 20,而不是教科书里的2 + 3×4 = 14。
如果要把严格优先级做进去,思路也不难:维护两个栈(运算符栈和数字栈),遇到低优先级运算符时先弹出高优先级运算符计算。这个可以作为进阶扩展,暂不放入基础版。
6.4 控制台报错排查清单
项目里最容易出现的几类报错,提前列个排查表:
| 报错信息 | 原因 | 处理方式 |
|---|---|---|
Cannot read properties of null | DOM 元素 id 写错,或 JS 在 DOM 加载前执行 | 把<script>放到</body>前,或使用DOMContentLoaded |
NaN显示在屏幕上 | 对空字符串做了parseFloat或把undefined参与运算 | 所有计算前先判断状态是否完整 |
| 按钮点着没反应 | >const assert = require("node:assert/strict"); const { operate } = require("./calculator-core"); test("0.1 + 0.2 应该等于 0.3", () => { assert.equal(operate(0.1, 0.2, "add"), 0.3); }); test("除以 0 应该抛出异常", () => { assert.throws(() => operate(1, 0, "divide")); });关键是把纯计算逻辑从 DOM 操作中剥离出来,独立成一个模块。这样浏览器里能跑,Node 里也能测。我刚做这个项目时没拆,后面改了一个运算符逻辑,连带把界面也测了一遍,非常痛苦。把核心逻辑和 UI 解耦这件事,再小都应该做。 7.3 无障碍优化计算器这类工具型页面,很容易被忽略无障碍需求。几个关键点:
这些改动量很小,但对特定用户群体影响巨大,值得在每个前端项目里养成习惯。 8. 最后的实操心得计算器项目写到大约第五版的时候,我最大的感受是:它不是一个"一晚上能写完的作业",而是一个可以反复打磨逻辑边界的小系统。第一版能算,但闭着眼睛乱按几下就会出问题;第二版稳定了,但没有键盘支持;第三版加了键盘和动画,用的才舒服。 几个对我帮助最大的习惯,分享给正在做或准备做类似项目的人: 第一,把状态变化打印出来。调试时我直接在 第二,测试用例先行。我整理的那张操作序列表,每次改动完就照着点一遍。这个习惯让我避开了不知道多少个"以为没问题、一上线就翻车"的尴尬。 第三,分清"显示值"和"真实值"。界面上看到的 0.30000000000000004,内部实际存储也是一个很长的浮点,不是显示错误。要在显示层做格式化,不要改动真实运算值。这个意识在后续做图表、做表单校验时同样受用。 做计算器不亏,做透了,JavaScript 的底子就在那里了。
版权声明:
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设
2026/10/17 6:18:48
从OOM到稳定:openPangu-72B昇腾NPU显存与KV Cache调优完整指南大模型模型推理服务AI 应用 【免费下载链接】openPangu-R-72B-2512-vllm-ascend-0.11.0 Ascend-SACT开源项目提供openPangu-72B适配昇腾A2的vllm-ascend 0.11.0推理镜像,预装CANN 8.5.0.alpha002、torch-npu 2.7.1等套件,修改权重目录即可快速启动离线推…
网站建设
2026/10/17 6:18:25
如何积累你的个人方法库:learn-from-materials 方法卡索引、检索与比较完整教程【免费下载链接】learn-from-materials Turn PDFs, books and papers into interactive learning webpages|将复杂材料转化为可追溯、可测验、可做笔记的学习网页 项目地址: https://gitcode.com/gh_mirrors/le/learn-from-materials 点击查看 免费下载 …
网站建设
2026/10/17 6:16:57
Folia sync-server 完整部署指南:多设备共用一套 AI 主题外观,免重复配置Folia sync-server 完整部署指南:多设备共用一套 AI 主题外观,免重复配置 【免费下载链接】folia-major 专注于绚丽的歌词动画效果的本地音乐/navidrome/第三方多平台在线音乐播放器 项目地址: https://gitcode.com/GitHub_Trending/fo/folia-major …
网站建设
2026/10/17 6:16:03
Qoder CN:面向中国开发者的IDE原生AI编程工作流1. 项目概述:Qoder 不是 Codex 的“平替”,而是面向中国开发者的真实工作流重构最近在好几个技术群和开源社区里,总有人发截图问:“这个 Qoder 是不是就是 Codex 的国产平替?”——我看到这个问题就下意识皱眉。不是因…
网站建设
2026/10/17 6:13:09
Llama-3.1-8B_rai_1.8.0_npu_16K 文件结构全解析:从 model.onnx 到 dd_metastate 缓存的 62 个文件逐一说明【免费下载链接】Llama-3.1-8B_rai_1.8.0_npu_16K 项目地址: https://ai.gitcode.com/hf_mirrors/amd/Llama-3.1-8B_rai_1.8.0_npu_16K 点击查看 免费下载 Llama-3.1-8B_rai_1.8.0_npu_16K 是 AMD 官方发布的 Llama-3.1-8B Ryzen AI NPU 推理模型包,基于…
网站建设
2026/10/17 6:12:53
Windows+nvm环境下Claude Code安装报错排查与配置指南先说说我为什么会写这篇东西。最近在Windows上折腾Claude Code,前前后后装了三台机器,每一台都卡在同一个地方:命令窗口里敲claude,啪一下给我弹出一行红字——无法将“f:\nvm\nodejs/node_modules/anthropic-ai/claude-code/bin/… |