我最早接触“网络计算器”这个概念,是在一个前端技术讨论群里。有个人问:怎么用网页实现一个计算器,不用后端,纯粹浏览器里跑的那种。我当时下意识觉得,这不就是套一层<button>加eval()吗,十几分钟就能写完。结果真正动手之后才发现,里面坑不少,甚至可以说,把一个看起来“简单”的计算器做成真正能用的版本,比想象中要复杂得多。这篇文章就把我实现一个简易版网络计算器的完整过程写下来,从界面到计算引擎,从边界处理到部署上线,给正在做类似练手项目或者想自己写工具的朋友一个参考。
这个项目适合什么人呢?一种是前端新手,想找一个不太复杂但覆盖很多基础知识的项目来练手;另一种是已经在用现成计算器组件,但想更深入理解计算逻辑、想去掉依赖自己控制行为的开发者。无论哪种,读完这篇文章,你都能收获一套能跑的代码,以及代码背后那些“为什么这么做”的思考。
1. 需求拆解:这个简易计算器到底“简”在哪里
很多人拿到“简易版网络计算器”这个需求,第一反应就是“简单”。但实际上,简单是分层次的。如果只是按下一个按钮,显示一个数字,那是真简单;可一旦涉及连续运算、括号、小数、退格、清空、异常处理,复杂度就上来了。所以动手之前,先把需求一条条列清楚。
1.1 核心功能清单
我把我的项目范围划定为以下几条:
- 支持 0-9 的数字输入,小数点和正负号切换。
- 支持加、减、乘、除四种基本运算。
- 支持括号,能处理类似
(2+3)*4这样的表达式。 - 支持连续运算,也就是
1+2+3这种,边按边出结果或等到按等于再出结果。 - 支持清空(C)、退格(←)、百分号(%)等常用按键。
- 显示表达式和结果,最好有两个区域,一个是当前输入/表达式,一个是实时结果预览。
你会发现,这些功能加起来,已经超出了“简易”两个字。但真正做的时候,每一项都对应着具体的实现细节。
1.2 明确的非目标
做项目最怕无限扩展需求。我也明确划掉了一些功能,比如:
- 不做科学计算(三角函数、对数、幂运算等),前期只做四则运算。
- 不做历史记录,页面关掉就没了。
- 不做后端存储,所有计算都在浏览器本地完成。
- 不需要用户系统,不需要登录。
这么做的好处是,我可以把全部精力集中在“计算逻辑”本身,而不是被各种辅助功能牵走。
1.3 技术选型:纯前端还是带后端
既然是“网络计算器”,那就避不开一个问题:需不需要服务器?我的结论是:不需要。计算器的计算逻辑完全可以放在浏览器端,使用 JavaScript 完成。这样部署成本最低,随便找个静态托管平台就能上线,也完全符合“网络”的含义——通过 URL 访问的网页应用。
如果带个后端用 Python 或 Java 算,当然也可以,但对于这个项目来说纯属自找麻烦。你要额外处理接口、并发、跨域、部署环境,而用户感知到的结果却没有任何提升。所以我的选型是:
- HTML:负责结构。
- CSS:负责布局和样式。
- JavaScript:负责全部交互和计算逻辑。
这个组合还有一个额外好处:只要你浏览器不关,断网都能继续用(虽然叫“网络”计算器,但计算本身离线可用)。
2. 界面搭建:用纯HTML/CSS做一个不丑的键盘面板
界面是计算器的门面。我不建议一上来就引入 UI 框架,因为计算器就那么几个按钮,纯手写完全可以控制得非常好,而且能练到布局基本功。
2.1 整体布局方案
我的布局选择是 CSS Grid,因为计算器的按钮天然就是网格结构,用 Grid 可以精确控制行列和间距。整体结构如下:
- 底部键盘区域:4 行 5 列,共 20 个按键。
- 顶部显示区域:分为表达式行和结果行。
这里我没有用 Flex 是刻意的。Flex 更擅长一维排列,而计算器的二维网格特性很明显。如果你用 Flex 去模拟网格,代码量会大很多,还容易在处理列间距时出问题。
一个典型的按键布局如下:
C ← % ÷ 7 8 9 × 4 5 6 - 1 2 3 + ± 0 . =注意=键我放在右下角,÷和×实际上在底层对应的分别是/和*,但界面上要显示成数学符号,这个映射后面要处理好。
2.2 按钮结构与事件绑定
HTML 里直接用button元素,每个按钮加上自定义属性><button>function updateDisplay() { const exprElement = document.getElementById('expr'); const length = exprElement.textContent.length; const baseFontSize = 32; if (length > 12) { exprElement.style.fontSize = (baseFontSize * 12 / length) + 'px'; } else { exprElement.style.fontSize = baseFontSize + 'px'; } }
这里用了一个简单的线性收缩规则:超过 12 个字符以后,每多一个字符,字号等比缩小。虽然不算精致,但实测足够应对绝大多数输入。
另外,结果行通常会设计得比表达式行大一号,因为用户最关注的是结果。我习惯把结果行放在最下面,右对齐,这是计算器界面的通用惯例,用户不会觉得陌生。
3. 计算引擎:三种方案对比,我为什么选择了表达式解析
界面只是壳,计算引擎才是灵魂。我在做的时候评估了三种方案,这里直接说结论:我最终选择了“自写表达式解析 + 逆波兰计算”的方案。但我先把三种方案都列出来,因为了解另外两条路为什么走不通,能帮助你理解这条路的优势。
3.1 方案A:按钮逐个入栈
这是最“笨”但最容易想到的方法。每按一个数字或运算符,就往栈里压,等按下=时再统一计算。
伪代码如下:
if (按下数字) 压入数字栈 if (按下运算符) 压入运算符栈 if (按下等号) { while (运算符栈不空) 取两个数字和一个运算符进行运算 }这个方案的问题非常明显:它没有考虑运算符优先级。1 + 2 * 3,如果是纯栈方式,从左到右算出来就是 9,而正确结果是 7。当然你可以引入优先级比较,按下+时发现栈顶*优先级更高,就先计算一次。但一旦涉及括号,逻辑就会变得非常拗口,代码越写越乱,最后等于自己发明一个残缺的表达式解析器。
3.2 方案B:eval直接求值
核心代码就是一行:
const result = eval(expression);这个方案写起来最快,跑起来也没毛病,但它的问题不在功能,而在安全和可控性。虽然我们自己的计算器不会有人恶意输入,但如果把表达式字符串交给eval,理论上用户可以在输入框里注入类似constructor.constructor('return process')()之类的代码。在浏览器控制台里试试就知道,eval的能力远不止算术求值。
哪怕不考虑安全,eval也不支持自定义错误处理。用户输入1/0,它会返回Infinity;输入1+*,它会直接抛异常。你还需要额外做很多字符串校验,到头来跟自写解析器的代码量差不多。所以我最终把eval排除掉了。
3.3 方案C:Tokenizer + 调度场算法 + 后缀表达式求值
这是编译原理里经典的表达式求值路线,虽然听起来很高大上,但实现起来其实非常朴素:
- 把输入的字符串拆成一个个 token,比如
12,+,(,3.5。 - 用调度场算法把中缀表达式转成后缀表达式,也就是逆波兰表达式。
- 对后缀表达式进行一遍线性扫描,遇到数字就压栈,遇到运算符就弹出两个数算出结果再压栈,最后栈里只剩一个数,就是结果。
这个方案的优点是逻辑清晰、优先级和括号都在第二步里被自然处理了,而且完全没有eval的安全问题。它还能让我很容易地添加新的运算符。
3.4 选型理由与整体流程
我选方案 C 的核心理由有三个:
第一,可控性。每一步都是自己写的,出现任何错误都能单步调试,不用面对黑盒。
第二,扩展性。以后想加平方、幂运算,只需要扩展 tokenizer 的识别规则和运算符优先级表,不用改动整体流程。
第三,学习价值。这个项目原本就有练手的成分,调度场算法是计算机科学里的经典算法,写一遍之后对表达式求值的理解会上升一个台阶。
整个计算流程大致是:
输入字符串 -> 词法分析 -> Token列表 -> 中缀转后缀 -> 后缀表达式 -> 后缀求值 -> 结果4. 表达式解析的实现细节:从字符串到可计算栈
这一章是全文的重点,我把每步实现拆开来讲,并配上关键代码。
4.1 Tokenizer:把输入拆成最小单元
Token 就是表达式中不可再分的最小单元。数字23是一个 token,运算符+是一个 token,左括号(也是一个 token。我的 token 结构很简单:
class Token { constructor(type, value) { this.type = type; // 'number' | 'operator' | 'paren' this.value = value; // 数字值或运算符字符 } }Tokenizer 的写法也很直接,遍历字符串中的每个字符:
function tokenize(input) { const tokens = []; let i = 0; while (i < input.length) { const ch = input[i]; if (ch >= '0' && ch <= '9') { let numStr = ''; while (i < input.length && (input[i] >= '0' && input[i] <= '9' || input[i] === '.')) { numStr += input[i]; i++; } tokens.push(new Token('number', parseFloat(numStr))); continue; } if ('+-*/'.includes(ch)) { tokens.push(new Token('operator', ch)); i++; continue; } if (ch === '(' || ch === ')') { tokens.push(new Token('paren', ch)); i++; continue; } // 空格直接跳过 if (ch === ' ') { i++; continue; } throw new Error(`无法识别的字符: ${ch}`); } return tokens; }注意我在这里处理数字时,把连续的数字和小数点拼在一起,直到遇到非数字或非小数点为止。这样3.14会被解析成一个完整的数字 token,而不是3、.、14三个 token。
这里有个容易忽略的坑:如果用户输入了两个小数点,比如1.2.3,parseFloat只会解析前面合法的一部分,然后后面再报错。所以更好的做法是在组数字时检查小数点的数量,超过一个就直接抛异常。我在最终版本里加了如下判断:
let dotCount = 0; for (let j = 0; j < numStr.length; j++) { if (numStr[j] === '.') dotCount++; } if (dotCount > 1) { throw new Error('数字格式不正确'); }4.2 中缀转后缀:调度场算法
调度场算法是 Edsger Dijkstra 发明的(没错,就是最短路径算法那个 Dijkstra),它将我们习惯的中缀表达式1 + 2 * 3转换成计算机容易计算的后缀形式1 2 3 * +。
核心规则是:
- 遇到数字,直接输出到输出列表。
- 遇到运算符,弹出输出栈中所有优先级更高或等于当前运算符的运算符,再把自己压栈。
- 遇到左括号,直接压栈。
- 遇到右括号,弹出所有运算符直到左括号,并把左括号丢弃。
我用一张表定义运算符优先级:
| 运算符 | 优先级 |
|---|---|
+/- | 1 |
*// | 2 |
实现代码如下:
function infixToPostfix(tokens) { const output = []; const opStack = []; const precedence = { '+': 1, '-': 1, '*': 2, '/': 2 }; for (const token of tokens) { if (token.type === 'number') { output.push(token); } else if (token.type === 'operator') { while ( opStack.length > 0 && opStack[opStack.length - 1].type === 'operator' && precedence[opStack[opStack.length - 1].value] >= precedence[token.value] ) { output.push(opStack.pop()); } opStack.push(token); } else if (token.value === '(') { opStack.push(token); } else if (token.value === ')') { while ( opStack.length > 0 && opStack[opStack.length - 1].value !== '(' ) { output.push(opStack.pop()); } // 弹出左括号 if (opStack.length > 0 && opStack[opStack.length - 1].value === '(') { opStack.pop(); } else { throw new Error('括号不匹配'); } } } while (opStack.length > 0) { const token = opStack.pop(); if (token.value === '(') { throw new Error('括号不匹配'); } output.push(token); } return output; }这里有一个细微点:相同优先级的运算符,遇到时是弹出还是不弹?我的实现是弹出,也就是按从左到右的顺序计算。10 - 3 - 2,转成后缀是10 3 - 2 -,求值结果是 5,是对的。如果相同优先级不弹出,就会变成10 3 2 - -,结果就变成 9 了。所以>=这个判断非常重要。
4.3 后缀表达式求值
转成后缀之后,求值就很简单了:
function evalPostfix(postfix) { const stack = []; for (const token of postfix) { if (token.type === 'number') { stack.push(token.value); } else if (token.type === 'operator') { const b = stack.pop(); const a = stack.pop(); if (a === undefined || b === undefined) { throw new Error('表达式格式错误'); } let result; switch (token.value) { case '+': result = a + b; break; case '-': result = a - b; break; case '*': result = a * b; break; case '/': if (b === 0) { throw new Error('除数不能为零'); } result = a / b; break; } stack.push(result); } } if (stack.length !== 1) { throw new Error('表达式格式错误'); } return stack[0]; }整个计算过程就是:取两个数,算,再把结果放回去。后缀表达式的精妙之处在于,它已经天然处理了优先级和括号,求值阶段根本不需要关心这些。你只需要按顺序执行即可。
4.4 处理一元负号和正号
一个隐藏需求是:用户输入-5或者(-3+2)*4怎么处理?-5里的-并不是减号,它是一元负号。我的解决方案比较务实:在 tokenizer 阶段,如果+或-出现在表达式开头,或者紧跟在左括号后面,就把它当作一元运算符处理。
实现方式有两种:
一种是在 tokenizer 时直接把它解析成数字。规则是:如果-后面跟的是数字,就把整个数字-(后面部分)解析成一个数字 token。比如-5tokenize 成一个数字-5。如果是-(3+2)这种,就需要把一元符号保留。
另一种是直接在 tokenizer 输出一个特定的 token,比如'neg',然后在调度场算法里把它当作优先级最高的前缀运算符处理。这种方法更通用,但实现起来要额外加一套逻辑。
我偷了个懒,采用了一个取巧的办法:在按钮层处理。当用户按±按钮时,直接在显示层把当前数值取反,然后再把取反后的数字作为完整数字输入。如果输入框里是3+,按±就变成3+(-,这样看起来很奇怪。
为了解决这个问题,我在按钮层做了一些限制:
- 如果当前输入为空,按
±直接插入-,后续数字跟在后面。 - 如果当前输入是一个完整的数字,按
±就把这个数字取反替换。 - 如果当前输入末尾是运算符或者左括号,按
±就插入(-,并在结束位置自动补上逻辑。
坦白说,这些规则组合起来还是比较繁琐的,但这是纯按钮交互下比较自然的结果。如果你只做键盘输入,直接允许输入-作为一元前缀,会简单很多。这个取舍大家可以根据自己的交互目标来定。
5. 边界情况与防御性编程:容易翻车的几个场景
写计算器最难的不是正常流程,而是各种花式输入。我在测试阶段踩过不少坑,这里把最典型的几个列出来。
5.1 连续运算符和空操作数
比如用户输入1++2,如果直接交给 tokenizer,会解析出数字1、运算符+、运算符+、数字2。调度场算法处理时,第二个+会因为没有左操作数而让求值阶段报错。
我的处理原则是:在按钮层就拦截这类非法输入。具体规则如下:
- 运算符不能连续输入。如果上一个输入是运算符,再按运算符就替换旧运算符。
- 小数点不能重复出现在同一个数字里。
- 左括号不能直接跟在数字后面,除非你用了隐式乘法(比如
2(3)),我选择不支持这种写法。 - 右括号不能出现在没有左括号匹配的情况下。
这些规则写起来比较繁琐,但可以通过一个集中式函数管理:
function canAppendKey(currentExpr, key) { if (key === '+' || key === '-' || key === '*' || key === '/') { const lastChar = currentExpr[currentExpr.length - 1]; if (!lastChar) return true; // 允许以负号开始 if ('+-*/(.'.includes(lastChar)) return false; } return true; }这里-被特殊情况放行了,是为了支持-5这种输入,但放行之后又会跟连续运算符冲突。所以我的最终策略是:-可以出现在开头或(之后,其他情况按运算符规则拦截。
5.2 除以零和浮点误差
除以零直接抛出异常,这是清晰的做法。异常信息要友好,不能显示一堆堆栈。我在 UI 层捕获到异常后,会把结果显示成“错误”或“除数不能为零”。
浮点误差是另一个大坑。0.1 + 0.2在 JavaScript 里算出来是0.30000000000000004。这个不能靠计算逻辑解决,只能在显示时处理。
我的方案是:对结果进行舍入,保留十位有效数字。
function formatResult(result) { if (result === Infinity || result === -Infinity) return '错误'; if (Number.isNaN(result)) return '错误'; return parseFloat(result.toPrecision(12)).toString(); }toPrecision(12)可以处理大部分浮点噪声,同时又不会把大数变成科学计数法。实测0.1 + 0.2会显示0.3。
5.3 空表达式和括号不匹配
用户可能直接按=,或者输入(1+2这种缺右括号表达式。调度场算法在弹出运算符到输出时,如果发现栈里还有左括号,就会抛异常,这个在前面代码里已经处理了。空表达式更方便,直接忽略=,保持显示屏不变即可。
5.4 键盘输入与按钮输入的协调
一个合格的网页计算器应该支持实体键盘输入。数字、运算符、回车、退格、Escape 这些都需要映射。
我的映射表如下:
| 键盘按键 | 计算器动作 |
|---|---|
0-9 | 输入数字 |
. | 输入小数点 |
+ - * / | 输入运算符 |
Enter | 执行= |
Backspace | 退格 |
Escape | 清空 |
() | 输入括号 |
这里有个细节:键盘上的*和/与计算器界面上的×÷不一致,需要在事件处理时统一转换成界面符号,再进入 tokenizer。实际我用的是映射函数:
function normalizeKey(key) { const map = { '*': '×', '/': '÷' }; return map[key] || key; }而 tokenizer 里再反着映射一次:
const expr = expression.replace(/×/g, '*').replace(/÷/g, '/');这看起来有点多此一举,但好处是界面显示的是用户熟悉的符号,内部统一用标准运算符,逻辑不混淆。
6. 测试与部署:算对不算完,还要能上线
代码写完之后,最重要的一步是测试。我没有引入 Jest 之类的测试框架,因为项目很小,用 Node.js 自带的能力就能做简单单元测试。
6.1 单元测试思路
我把计算核心写成了一个独立模块calculator.js,导出一个函数:
function calculate(expression) { // tokenize -> infixToPostfix -> evalPostfix return result; }然后在 Node.js 里跑一段测试脚本:
const calculate = require('./calculator'); const cases = [ ['1+2', 3], ['2*3+4', 10], ['(2+3)*4', 20], ['10-3-2', 5], ['1.5+2.5', 4], ['1/0', '错误'], ['0.1+0.2', 0.3], ['-5+3', -2] ]; for (const [expr, expected] of cases) { const result = calculate(expr); const ok = Math.abs(result - expected) < 1e-10 || result === expected; console.log(ok ? 'PASS' : 'FAIL', expr, result, expected); }测试结果全过之后,再把模块挂到页面的事件处理上。把核心逻辑和 UI 分离,这也是我这个项目最有价值的地方之一。以后换任何前端框架,计算引擎都不用动。
6.2 手动测试清单
单元测试只测了纯计算,但交互层面很多问题是没法自动化的。我建议所有想复刻这个项目的朋友都过一遍这个清单:
- 快速连续按键,是否有延迟或漏按键。
- 表达式很长时,显示是否正常收缩。
- 按
=后再输入数字,应该重新开始输入,而不是追加到上次结果后面。 - 按
=后再按退格,应该能回到原来的表达式还是取消结果,这个交互要提前想好。 - 按
C清空后,所有内部状态是否重置。 - 在 Chrome、Firefox、Safari 三种浏览器里分别测试,尤其注意 CSS Grid 的兼容性,现在主流浏览器都支持,但手机端的触摸反馈要额外处理。
6.3 部署选项
既然叫“网络计算器”,最终肯定要让别人通过网址访问。静态站点是最省事的。我用的方案是部署到任意支持静态文件的平台,把index.html、style.css、main.js三个文件传上去即可。
本地先建一个简单的目录结构:
calculator/ ├─ index.html ├─ style.css ├─ calculator.js └─ main.js开发时直接用 VS Code 的 Live Server 插件预览。上线时,把整个目录丢给静态托管服务,得到的 URL 就是你的网络计算器地址。
如果你后续想加一些动态功能,比如历史存储、云同步,再考虑加后端和数据库。但就当前这个“简易版”而言,静态部署已经足够。
为什么我建议你亲手写一遍这个项目
在网上随便搜一下“网络计算器”,能搜到无数现成插件和开源代码,直接下载下来改改就能用。但我觉得,“简易版网络计算器的实现”这个项目真正值钱的地方,不在于那个计算器本身,而在于你动手实现它的过程中被逼着去搞清楚的那些问题:运算符优先级怎么处理、括号嵌套怎么对应、浮点数误差怎么兜底、交互逻辑怎么防呆。
我写完这个小项目之后的直接感受是,再看任何跟“表达式”相关的需求,比如自定义公式引擎、Excel 表格里的计算函数,都不会觉得无从下手了。调度场算法和 tokenizer 的思路是通用的,你今天用它写计算器,明天就能用它写更多东西。
如果你也想练手,我建议一个顺序:先不看我给的完整代码,自己实现一遍 tokenizer 和调度场算法,跑通(1+2)*3之后再去看别人的实现,对比一下你的细节处理差在哪。这个过程收获会非常大。我在实现过程中犯过的错,踩过的坑,上面都写了,希望你能少走一遍我走过的弯路。