ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaScript手写计算器:状态机、浮点精度与边界处理实战

JavaScript手写计算器:状态机、浮点精度与边界处理实战

JavaScript实现计算器,听起来像是一门课程的第一课作业,可这个作业的坑有多深,只有亲自动手写过的人才知道。我最早写这个项目的时候,以为难点在界面布局,后来发现界面半天就能做完,真正的难点全藏在“用户乱按一通”之后的逻辑处理里。这篇文章我打算从拆需求、写界面、绑事件、设计运算核心、处理浮点精度、收拾边界条件这几个方面,把一笔一笔的实操经验都翻出来,适合刚开始接触原生JavaScript、想做点能跑的东西的新人,也适合那些已经能写出页面但一碰交互逻辑就心虚的开发者。

1. 计算器项目的真实难点:不是算出9+9,而是把用户操作变成可靠状态

1.1 一个看似简单的问题背后,藏着三类麻烦

很多人拿到这个题目,第一反应是:界面上放一堆按钮,监听点击,把数字和运算符拼成一个字符串,塞进一个函数里算一下,完事。如果你只是交一个静态Demo,这个思路确实够用。但只要你把计算器当成一个给别人用的工具,问题马上冒出来:用户不按你的逻辑出牌。

比如用户输入“1 + 2 + 3”,然后按等号。这时候你是每按一次加号就立刻算一次,还是等按等号时再一次性结算?这两种策略得到的结果可能一样,但中间状态完全不同。再比如用户连续按两次加号,第二次加号是覆盖第一次,还是忽略?又比如用户按了等号之后不按清空,直接输数字,计算器该接着上一次结果继续算,还是开启一个新的算式?这三个问题只要有一个没想清楚,写出来的代码就会在某个角落留下奇怪的行为。

我把这些麻烦大致分成三类。第一类是操作顺序不可控,用户的点击顺序千变万化,你没办法假设常量;第二类是数值精度问题,JavaScript里的浮点数计算会给出各种看起来离谱的尾巴;第三类是显示与内部状态的一致性,屏幕上显示什么、内部拿什么值参与计算,这两者一旦脱节,计算器就会变成“看起来对,用起来错”的玩具。这三类问题才是这个项目真正的骨架,界面反而是最不费脑子的部分。

1.2 先把需求写成行为清单,再开始动代码

我后来养成了一个习惯:写代码之前,先把需求列成一张表。计算器项目不用整得跟软件需求文档一样复杂,但至少要把用户能做什么操作、每种操作对应什么预期结果写清楚,否则写到一半很容易自己推翻自己。

我做的这版标准型计算器,功能范围是:0到9的数字输入、小数点、四则运算、等号、清空键、退格键、正负号切换。百分比和括号一开始不做,因为百分比在不同计算器上的行为差异很大,括号则需要一套真正的表达式解析器,作为第一版来说容易把状态机搅浑。功能范围明确之后,行为清单大概是这样的。

用户操作预期行为
数字键追加到当前显示数字,若刚按过等号则清空旧状态并开启新算式
小数点若当前数字已包含小数点则忽略,否则追加
运算符(加、减、乘、除)保存当前数字为操作数,若之前已有未执行的运算符则先结算,再记录新运算符
等号若存在未执行的运算符,则执行运算并显示结果,保留重复计算所需的信息
清空键重置所有内部状态,显示归零
退格键仅删除当前输入数字的最后一个字符
正负号翻转当前显示数字的符号

这张表现在看起来平平无奇,但当时它帮我避免了很多反复改代码的尴尬。比如我原本没写“等号后按数字开启新算式”这条,结果做了一个加号,得到了结果,结果再按数字时数字直接拼到了结果后面,屏幕变成了“58这样”。把行为清单补上之后,所有支线逻辑都有了依据,代码写起来心里踏实很多。

2. 界面和事件层:按键如何被解析成一次有效输入

2.1 布局用flex或grid都行,但真正决定成败的是每个按钮的标签设计

界面这块我不打算花太多篇幅,但有一个点值得单独说:按钮上的数据标签。很多人做计算器,会把“+”、“-”这些符号直接写在按钮文字里,然后通过判断按钮的文本内容来区分功能。这样做的缺点是,一旦你要做多语言切换,或者按钮文案调整,事件代码也要跟着改。

我习惯在按钮上额外挂两个自定义数据属性:一个是动作类型,一个是动作参数。比如数字键的数据类型是digit,参数就是对应的数字;运算符键的数据类型是operator,参数就是对应的运算符。这样一个结构既清晰,也为后面的统一事件处理打好基础。

<div class="calculator" id="calculator"> <div class="screen" id="screen">0</div> <div class="keypad" id="keypad"> <button>const keypad = document.getElementById('keypad'); keypad.addEventListener('click', (event) => { const button = event.target.closest('button'); if (!button) return; const action = button.dataset.action; const value = button.dataset.value; handleAction(action, value); });

这里有一个细节:用event.target.closest('button')而不是直接判断event.target。因为如果按钮内部以后加了图标元素,点击图标时event.target指向的是图标而不是按钮,closest能自动向上找最近的按钮元素,避免这种边界问题。这个习惯在其他需要事件委托的场景里也很有用。

事件委托把“点击到哪个按钮”这件事统一收集起来,后续所有的逻辑分发都在handleAction里做。这样界面层只负责提供动作类型和参数,不接触任何计算细节。

2.3 键盘支持:别让用户只能用鼠标,映射表要稳定统一

一个像样的计算器不该只能点鼠标。桌面场景下,用户很自然地会去按键盘上的数字键和小键盘区域。我在做键盘支持的时候,踩过一个教训:一开始用keypress事件,后来发现某些浏览器对这个事件的支持已经变了,而且回车键、退格键行为也不太统一。切换成keydown事件之后,一切就稳定了。

键盘映射的核心是判断event.key。数字键直接对应'0'到'9',运算符对应'+'、'-'、'*'、'/',回车键或等号键触发等号逻辑,退格键触发退格,Escape键触发清空。需要特别注意的是,键盘上的Enter和=都可能被当成触发等号的键,而小键盘上的Enter在有些浏览器里返回的也是'Enter',所以两者都要判断。

document.addEventListener('keydown', (event) => { const key = event.key; if (key >= '0' && key <= '9') { handleAction('digit', key); } else if (key === '+') { handleAction('operator', '+'); } else if (key === '-') { handleAction('operator', '-'); } else if (key === '*') { handleAction('operator', '*'); } else if (key === '/') { event.preventDefault(); handleAction('operator', '/'); } else if (key === 'Enter' || key === '=') { handleAction('equals'); } else if (key === 'Backspace') { handleAction('backspace'); } else if (key === 'Escape') { handleAction('clear'); } else if (key === '.') { handleAction('decimal'); } });

这里有一个单独要提的细节:键盘上的/键,在某些浏览器里会触发快速查找功能,所以要加event.preventDefault()拦住默认行为。同理,回车键在表单环境下可能触发提交,不过这个场景下没有表单,风险不大,但了解这一点以后在更复杂的页面里很有用。

3. 运算核心:从eval冲动到状态机,我对连续运算的取舍过程

3.1 eval 只适合拿来画演示图,一旦出现在真实项目里就是在引雷

说句实话,我最早写计算器的时候也干过用eval的蠢事:维护一个字符串,用户按什么就往上拼什么,按等号时eval一下。页面确实能跑,但它把所有问题都掩盖了,包括运算符优先级、连续运算、显示值和真实值不一致等等。直到某次我输入了一个带小数点的算式,屏幕上的数字和我心里想的结果差了一截,我才开始意识到这条路走不通。

eval的问题在于它接收的是字符串,不是结构化数据。你很难在按等号之前知道用户到底输入了什么,也很难判断某个数字到底是正在输入的数字,还是已经参与过运算的中间结果。更别提如果你哪天想让计算器支持自定义表达式,或者接入更多输入来源,字符串拼接的思路很快就会变成一团乱麻。所以我后来把所有逻辑推翻重写,改成状态机模型,这一步回头看是值得的。

3.2 状态机模型:五六个变量就能撑起一台标准计算器

状态机这个词听起来很高端,其实就是把“当前计算器处在什么状态”用几个变量明确表达出来,再定义清楚每种用户操作会让状态发生什么变化。我用到的核心状态变量有这些。

  • currentInput:当前正在输入的数字,初始是“0”
  • accumulator:已经累计的运算结果
  • pendingOperator:已经保存、但还没有执行的运算符
  • waitingForOperand:是否正在等待用户输入新的操作数
  • justEvaluated:是否刚执行过等号计算,用于处理连续按等号
  • lastOperand:最近一次等号计算时的右侧操作数,用于连续按等号时重复运算

核心运算函数只做一件事:给定左侧数字、运算符、右侧数字,返回计算结果。除数为零时返回一个特殊值,让上层去处理错误提示。

function applyOperation(left, operator, right) { switch (operator) { case '+': return left + right; case '-': return left - right; case '*': return left * right; case '/': return right === 0 ? null : left / right; default: return right; } }

状态流转规则用一段伪代码说明可能更清楚。每当用户按下一个数字,如果waitingForOperand为真,说明上一个操作数已经结束,当前输入应该替换而不是追加;如果justEvaluated为真,说明等号刚算完,此时应该清空旧的累计状态,开始一个全新算式。用户按下运算符时,如果之前已经保存了待执行的运算符,并且当前确实有新输入的操作数,就先结算一次;如果用户连着按运算符,只需要更新pendingOperator,不能重复结算。用户按等号时,如果存在待执行的运算符,就用当前输入作为右侧操作数执行运算;如果刚按过等号,则用上次保存的lastOperand作为右侧操作数,实现“2 + 3 = 5,再按一次 = 得到 8”的效果。

这个模型当时我调试了很久,最后发现它其实可以整理成一张状态表,每次用户操作都按表来,代码分支非常清晰。这也是为什么我说计算器项目练的不是DOM操作,而是状态管理,把这张表画清楚,代码怎么写都顺。

3.3 用手推演一遍“12 + 7 - 5 =”,你就什么都明白了

光说变量定义还不够直观,我拿一个具体算式走一遍。初始状态是:currentInput为“0”,accumulator为0,pendingOperator为空,waitingForOperand为假。

按“1”,再按“2”,currentInput变成“12”。按“+”:因为pendingOperator为空,所以把当前输入数字12存入accumulator,然后记录pendingOperator为“+”,同时把waitingForOperand设为真。按“7”,读数字时发现waitingForOperand为真,就把currentInput从“12”替换为“7”,并关掉该标志。

按“-”:此时pendingOperator不为空,且用户输入了新的操作数7,所以先执行一次结算,12 + 7 = 19,19存入accumulator,屏幕也显示19。然后把pendingOperator更新为“-”,再次把waitingForOperand设为真。按“5”,currentInput变成“5”。按“=”:执行 19 - 5 = 14,显示14,accumulator变成14,最后记录lastOperand为5,justEvaluated设为真。

这个推演过程看起来繁琐,但当你写代码写不下去时,用纸笔这样走一遍比看十篇教程都管用。我当时甚至把每一步的状态变化画成表格贴在显示器旁边,调试时对着表一眼就知道哪个分支写错了。

4. 精度、显示与格式化:0.1+0.2这个坎,和它身后的显示哲学

4.1 0.30000000000000004 是怎么冒出来的

只要你写过JavaScript,就一定见过0.1 + 0.2的结果不是0.3,而是0.30000000000000004。我第一次看到这个结果时也愣了半天,因为数学直觉告诉我这就是0.3。后来才明白,计算机内部存储小数用的是二进制浮点数,用二进制去表示十进制的0.1,就像用十进制去表示1/3一样,永远只能近似,无法精确。

具体来说,JavaScript里的数字类型遵循IEEE 754双精度标准,底层用64位二进制来存储一个数,其中分数位有53位精度。0.1在二进制里是一个无限循环小数,存储时必然被截断,于是计算时产生的微小误差就积累到了显示结果里。这不是JavaScript独有的问题,Python、Java、C都会遇到,只是JavaScript的默认小数显示策略让它暴露得特别明显。

4.2 我采用的方案:内部保持原始浮点数,显示时做一次“收尾”

明白了原理之后,我的第一反应是:能不能让计算永远不产生误差?答案是不能,除非你全程用整数或字符串模拟十进制运算。对一台标准计算器来说,追求这种绝对精确没有意义,用户能接受的底线是显示结果看起来干净。所以我采用了一套折中方案:内部计算时直接使用浮点数,显示值统一经过一个格式化函数,把那些多余的尾巴清掉。

function cleanNumber(value) { if (!isFinite(value)) { return value; } const fixed = value.toFixed(12); const cleaned = parseFloat(fixed); if (Math.abs(cleaned) >= 1e16) { return cleaned.toExponential(10); } return String(cleaned); }

这段代码的思路是先保留12位小数,再用parseFloat把末尾多余的0去掉。比如0.1 + 0.2内部算出来是0.30000000000000004,经过toFixed(12)变成0.300000000000,再parseFloat就得到了0.3。如果数字大到一定程度,就转成科学计数法,保住高位信息。

这里有一个坑必须提醒:格式化只应该发生在显示层,不该反过来污染内部计算结果。原因是,如果你每次都把内部值四舍五入到固定小数位再参与下一步运算,连续运算时误差会累积得很快。比如0.1 + 0.2 + 0.3,如果每步都截断到两位小数还好,但一旦你截断到更少的位数,结果会越来越离谱。正确的做法是:内部永远存原始浮点结果,只在最终显示时做清理。

4.3 显示值不等于内部值,这个概念越早建立越好

显示层和内部值分离,这个理念在做计算器的时候体现得特别明显,但它其实是前端开发里放之四海皆准的原则。界面上看到的永远是一个“被格式化过”的、方便阅读的版本,而真正参与运算、存储的是未经展示处理的原始数据。

举一个实际案例。用户输入0.1 + 0.2,界面显示0.3。如果用户不按任何键,继续按“+ 0.4”,那么参与运算的应该是0.3这个显示值,还是内部那个0.30000000000000004?答案应该是显示值0.3。因为在我们的格式化策略里,内部值已经通过parseFloat转变成了干净的0.3,后续运算自然基于这个值。但如果你在某个版本里写的是直接toFixed(2)却不转回数字,那参与运算的就会变成一个带有额外误差的二进制表示,长时间使用之后误差会被逐渐放大。

我在项目里做了一个约定:内部所有状态变量里存的都是数字类型,只有在screen元素上显示的才是字符串。每次运算结果在存入状态变量之前,必须经过cleanNumber里的数值清理逻辑,确保后续运算拿到的都是干净数字。这个约定执行之后,很多隐蔽的小误差问题自然消失了。

5. 边界条件汇总:连按等号、连按运算符、除以0、超大数字,这些我全踩过

5.1 连按等号的行为,最容易被新手漏掉

如果用户输入2 + 3,按一次等号得到5,再按一次等号,他期望得到什么?我测试过身边不少人,大部分人的预期是得到8,也就是继续用上一次的运算符和右侧数重复计算。少数人觉得应该是5,因为他们认为第二次等号是重复“上一次完整的运算”。主流计算器用的是前者,我刚写第一版时完全没想到这个需求,导致用户再按一次等号时毫无反应。

实现连等逻辑的关键是等号运算时把右侧操作数保存下来,并在justEvaluated为真时继续使用它。比如2 + 3 = 5,保存lastOperand为3;再按一次等号,因为justEvaluated为真,执行5 + 3 = 8;再按一次就是8 + 3 = 11。这个逻辑一旦加上,计算器的“手感”一下子就对了。

5.2 连按运算符和先按运算符,我的处理策略是“覆盖”

另一种高频边界场景是连按运算符,比如输入1 + + 2 =。我的处理策略是:如果当前正处于等待操作数状态,也就是用户刚按过运算符,那么再次按键直接覆盖之前记录的运算符。这样1 + + 2等价于1 + 2。还有人会设计成忽略第二次运算符,直接不响应。两种方案各有道理,我选择覆盖,是因为它更符合大多数人使用实物的习惯,不会让你觉得按键“失灵”。

还有一个我一开始没处理的场景:算式一开始就按运算符,比如用户直接按“+”,再按“5”,再按“=”。合理的行为是把它当作0 + 5 = 5来处理。状态机里当pendingOperator为空且accumulator为初始状态时,按运算符就把当前显示值存入accumulator,显示值在没有输入数字时就是0,所以自动变成0 + 5 = 5。这个行为不需要额外写代码,但设计时要想明白,否则你可能会在初始化上写出多余的分支。

5.3 除以0不崩溃:错误提示要温和,行为要干脆

除数为零在JavaScript里不会抛错,会得到Infinity或-Infinity。如果你直接把Infinity显示出来,普通用户会一头雾水,而且继续用这个值运算会产生更诡异的结果。我的做法是在applyOperation里对除数为零的情况返回null,上层拿到null时把屏幕显示为“不能除以0”,然后重置内部状态,让用户从干净状态重新开始。这里千万不要用alert弹窗,因为弹窗会打断操作节奏,在真实输入环境里非常烦人。

5.4 前导0、连续小数点、退格键,这些操作都需要输入过滤

输入细节上也有不少要收拾的地方。比如用户先按了好几个0,显示应该是“0”而不是“000”;用户输入“0.5”时,数字前面可以有0,但不能再追加0;用户按小数点时,如果当前数字已经包含小数点,应该忽略这次点击;退格键删到只剩一个字符时,应该把显示重置为“0”,而不是显示空字符串。

这些过滤逻辑通常集中在处理数字输入的函数里,用一个简单的字符串判断就能完成。我习惯在这个函数里同时处理“刚按过等号后按数字”的重置逻辑,保证所有进入状态机的输入都是经过净化的,避免脏数据流入运算部分。如果你偷懒不做这些过滤,用户操作速度一快,屏幕上就会出现“00.5.3”这类完全不可解析的内容。

5.5 屏幕装不下时怎么办:显示截断与科学计数法的取舍

数字无限增长是有可能的,比如用户连续按“999999999”,屏幕装不下,或者算出一个天文数字。我的处理策略是分两步:第一步限制输入,当显示字符串长度达到上限时,数字键不再追加,但退格键仍然有效;第二步是格式化输出,数值超过一定量级时自动切换成科学计数法,保证信息不丢。

这里有一个容易被忽略的平衡问题:如果一味压缩字号来塞更多数字,计算结果会显得拥挤,而且用户根本读不清;如果直接截断数字,又会造成精度损失。科学计数法是更可靠的选择,因为它在不丢高位信息的前提下把数字压到最短。我在cleanNumber里设置了一个阈值,超过1e16时使用指数表示,实测下来兼顾了可读性和准确性。

5.6 这些边界条件不是靠聪明绕开的,而是靠一张测试清单

边界条件太多了,靠脑子记不现实,更靠谱的做法是维护一张验证清单。我每改完一个分支,就会按清单把典型场景跑一遍。下表是我实际用过的一组用例。

输入操作期望显示备注
0.1 + 0.2 =0.3浮点精度收尾
2 + 3 = =5,再按=得到8连续等号
1 + + 2 =3连续运算符覆盖
9 ÷ 0 =不能除以0除零保护
000123123前导0消除
1.2.3停留在1.2连续小数点忽略
5 + =10运算符前默认0
1 ÷ 3 =0.333333333333小数位数截断后收尾

这张清单里的每一项,都是我实际遇到过的真实问题。写完这版计算器后,我意识到这类边界测试单的价值不比代码本身低,因为计算器的逻辑分支很多,任何一次改动都可能引入回归,有一张能反复执行的用例清单,心里才有底。

6. 做完这版计算器后,我对“从零手写”这件事的看法

6.1 同类库和组件那么多,为什么这一版仍值得自己写

如果你和我说,现在前端界有各种成熟的组件库,拖一个按钮进来就有现成的计算器,何必从零手写,我只能说:用手写代码换来的理解,是直接拿组件换不来的。这个项目最值钱的部分不是最后那几百行代码,而是你从“想清楚一件小事”到“把它变成代码”的整个过程。

我记得有一次给组里新人讲代码,他看了半天状态机的实现,问我为什么不直接把表达式存成字符串再解析。我说你也试一下纯手工实现,把用户操作变成可靠的状态迁移,写完你就明白了。他果然去写了一天,第二天回来跟我说,以前从来不知道自己在DOM操作上没什么问题,也不会想到一个计算器里居然藏着这么多逻辑分支。这个项目的价值就在这里:它可以让你在一百行代码的范围内,完整体验到状态管理、边界思考、模块分离这些大项目里才能碰到的问题。

6.2 如果你要在这个基础上继续加功能,建议按这个顺序来

第一版做完之后,你可以在这个稳定骨架上逐步加功能。我建议的顺序是:先加百分比键和平方根键,它们不触及表达式结构,只是新增两类运算;然后让计算器支持记忆键(M+、M-、MR、MC),这会在状态变量里多一块独立存储;再往后如果你想要括号和真正的四则运算优先级,那就需要升级成递归下降解析器或调度场算法,状态机就撑不住了,要换思路。

我一直觉得,普通计算器做到状态机这层就够了,但如果你还想继续往里钻,可以把计算器当成一个小型编译原理项目来做:把输入按键解析成“词法单元”,再按优先级生成“语法树”,最后求值。这个方向很有意思,做完之后你再去看那些支持括号的计算器,脑内的建模方式会完全不一样。

6.3 最后分享一个我写计算器时一直用的习惯

说了这么多技术细节,最后聊点个人的小习惯。我每写一个带交互状态的项目,都会先把“用户操作与状态变化”的对应关系写在一张纸上,或者记在文档里,写完代码后再对照着把分支逻辑逐条过一遍。计算器是这个习惯的绝佳练习场,因为它的状态数量不多,但每种状态之间的迁移路径几乎全被覆盖过。每过一遍,我对“代码就是状态迁移”这句话的理解都会更深一点。

另外一个建议是:别急着把按钮排版和炫酷样式放到前面做。先把一个功能完整、逻辑可靠的标准型计算器做出来,再考虑换肤、音效、键盘布局这些锦上添花的东西。功能内核不结实,界面再漂亮也是空壳;内核结实了,后面你想怎么加都很从容。这个道理我在写程序这些年里反反复复验证过,计算器是体会它最划算的一次实践。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进