原生JS实现计算器、进制转换与BMI三合一工具 前阵子帮一个朋友整理手机里的工具类应用发现他装了三四个一个纯计算器、一个程序员进制转换、还有一个算体重的。问下来理由也简单每个都觉得“反正占不了多少空间”。可真到用的时候找图标的时间比算数的时间长。我后来干脆自己写了一个小东西把四则运算计算器、二八十十六进制转换和BMI 计算塞进同一个页面不带任何框架纯原生 JS压缩完三十多 KB断网也能跑。这篇文章就把这个项目的整套思路、关键算法、踩过的坑和自测清单一次讲清楚适合会一点点 JS、想把小工具做到“能长期用”的读者也适合已经写过 Demo、但总在精度和边界上翻车的人对照检查。1. 需求拆解三个功能为什么能塞进一个页面1.1 表面上是三个功能底层是三套完全不同的输入模型刚动手的时候我以为这是个“堆功能”的活真正拆开才发现这三个模块的输入模型差别大得离谱。计算器是流式输入用户一个一个按数字和运算符界面需要记住“我现在是按到第几个数、上一步的运算符有没有结算”本质上是个状态机。进制转换是结果导向用户粘一大串字符进来程序瞬间算出四个结果中间过程不重要重要的是校验和精度。BMI 则是表单式两个字段填完点一下输出一个带分级的结论。输入模型不同直接影响的就是 UI 组织方式和数据流设计。计算器需要一块大按钮区和实时更新的显示区进制转换需要多行只读输出加一个源进制选择BMI 需要两个带单位的输入框和一块结果卡片。如果硬用一个“万能输入组件”去套最后会写出一堆 if-else。我的做法是三个模块共用同一套外壳顶部切换、卡片容器、统一的输入组件样式内部各写各的逻辑彼此不共享状态。这个决策看起来朴素但它让后面每一个模块的代码都能独立测试、独立修改改计算器不会碰坏 BMI。还有一个容易被忽略的点是用户使用节奏。计算器是高频短操作一次可能只按三下进制转换是低频长操作粘一串 IP 地址或者颜色值进来BMI 是极低频一年算不了几次。节奏不同界面反馈的密度就该不同计算器要每一次按键都有回弹和数字变化进制转换要允许粘贴后立即全量刷新BMI 则应该在输入完成后再计算不要边打边跳结果否则输入“17”的时候会先算出一个荒唐的 BMI 再跳到“175”。1.2 为什么不做三个独立入口有人会说做三个 tab 也行为什么非要合并成一个这和工具类产品的打开成本有关。用户打开一个工具心理成本是“我要不要专门去找它”功能越单一这个成本占比越高。三个独立入口意味着三次决策合并成一个入口只需要一次。另一个更实际的原因是共享上下文算 BMI 的时候想顺便算一下“体重减 3 公斤后是多少”算完 BMI 可以直接切到计算器程序员算完一个十六进制颜色值可能想顺手算一下 RGB 平均值。这些跳转在独立应用之间是断的。当然合并也有代价代码体积、首屏加载、单一页面的逻辑耦合。我的控制方式是模块懒初始化——页面加载时只挂载当前 tab 的 DOM 事件切到哪个模块才初始化哪个避免三个模块的初始化逻辑同时跑。实测下来用一个简单的inited标记就够了代价极低收益是首屏少执行一大截逻辑。1.3 技术选型为什么是无框架单文件这个项目的选型我斟酌了一阵最后定的是原生 JS 单个 HTML 文件 内联 CSS不用任何框架、不用打包工具。理由有三条。第一总代码量小。三个模块加起来业务代码不到八百行引入一个框架框架本身的体积就是业务代码的好几倍属于典型的“用大炮打蚊子”。第二离线可用。工具类应用最怕的是“没网就用不了”纯静态单文件丢到任何静态托管上都能跑甚至可以保存成文件双击打开不需要任何服务端配合。第三维护成本低。这类小工具的特点是“写完就放那儿半年不动一次”无依赖意味着半年后回来改一行代码不需要重新装环境、不需要担心依赖版本冲突。如果你确实要把它套进某个平台的容器里这套代码同样能用只要把 DOM 操作替换成对应平台的组件写法核心的三个算法函数表达式求值、进制转换、BMI 分级一行都不用改。这是我把算法和 UI 严格分层的结果算法层是纯函数输入字符串输出字符串不碰 DOM。2. 计算器表达式求值为什么不能靠 eval2.1 用 eval 会踩的三个坑我见过太多 Demo 计算器直接eval(displayText)图省事能跑就行。但它有三个绕不过去的问题。精度问题是最常见的eval(0.10.2)得到的是0.30000000000000004直接显示在屏幕上非常难看。安全问题是可输入的场景下用户或者恶意粘贴的内容可能构造出意料之外的表达式虽然纯前端环境危害有限但一旦你把这套逻辑搬到有服务端的场景就会出大问题。表达能力问题是它不认×、÷、%这些显示符号你需要先做一轮替换替换的时候又容易漏掉全角字符。所以我的方案是自己写一个双栈法求值一个数字栈一个运算符栈边读边算。这个算法不用递归不用构建语法树一百行以内能写清楚性能也足够一次计算几十个字符微秒级。它还有个好处在于是“可控的”每一步出错你都能打日志定位而不是对着 eval 的一句SyntaxError干瞪眼。2.2 双栈求值的完整实现核心思路是维护两个栈遇到数字入数字栈遇到运算符就比较优先级如果栈顶运算符优先级不低于当前运算符就先弹出栈顶运算符和两个数字做一次运算把结果压回去。左括号无条件入栈右括号一直弹到遇见左括号为止。const OPS { : { p: 1, fn: (a, b) a b }, -: { p: 1, fn: (a, b) a - b }, *: { p: 2, fn: (a, b) a * b }, /: { p: 2, fn: (a, b) b 0 ? NaN : a / b }, %: { p: 2, fn: (a, b) b 0 ? NaN : a % b }, }; function evaluate(expr) { const nums []; const ops []; const tokens expr.match(/\d\.?\d*|[\-*/%()]/g) || []; for (const tk of tokens) { if (/^\d/.test(tk)) { nums.push(parseFloat(tk)); } else if (tk () { ops.push(tk); } else if (tk )) { while (ops.length ops[ops.length - 1] ! () { applyTop(nums, ops); } ops.pop(); // 丢弃左括号 } else { while (ops.length ops[ops.length - 1] ! ( OPS[ops[ops.length - 1]].p OPS[tk].p) { applyTop(nums, ops); } ops.push(tk); } } while (ops.length) applyTop(nums, ops); return nums[0]; } function applyTop(nums, ops) { const op ops.pop(); const b nums.pop(); const a nums.pop(); nums.push(OPS[op].fn(a, b)); }这段代码里我特意处理了两个细节。一是除零返回NaN而不是Infinity前者在界面上统一显示成“错误”后者会显示成一个夸张的数学符号用户看不懂。二是负号的处理上面这段代码其实处理不了“负数开头”的表达式比如-53因为词法分析时负号会被当成二元运算符。我在实际项目里的做法是在 token 化之前先做一次预处理把出现在开头或者左括号后面的负号改写成(0-1)*这个补丁不优雅但极其有效覆盖了绝大多数实际输入。更好的方案是在词法分析阶段判断“这个减号前面是数字还是运算符”我选前者是因为代码更短、更容易读懂。2.3 按键状态机的四个变量算法解决了“怎么算”但计算器的真正难点是“什么时候算”。用户按键的序列是1 2 这四步之间的状态转移必须清晰否则会出现按了运算符没反应、连按两个运算符报错、算完之后继续按数字变成拼接等一堆问题。我用四个变量描述整个状态变量含义典型取值场景current当前正在输入的数字字符串用户按了12值为12prev上一次的运算结果或暂存数按了后prev变成当前值pendingOp待执行的运算符按了后为waiting是否等待新数字按完运算符后置 truewaiting这个标志是最容易被漏掉的。它的作用有两个按数字键时判断是“追加到 current 后面”还是“清空 current 重新开始”按运算符时判断是“记录运算符”还是“先结算上一次运算再记录新运算符”。没有它1 2 3 这种连续运算就会算错或者出现12之后按把2吃掉的情况。我把状态转换写成了一个纯函数nextState(state, key)输入当前状态和按键输出新状态。好处是这部分逻辑可以完全脱离 DOM 单测用几十条用例把各种按键序列跑一遍比在浏览器里手点靠谱得多。这个做法是从一个做嵌入式键盘的朋友那儿学的他说硬件上的按键消抖和状态机思路和这种连续运算的状态处理是一回事。2.4 浮点精度三个处理层级前端做数值计算精度问题绕不过去。0.1 0.2这个经典例子在计算器里尤其刺眼因为你不能像后端那样把精度问题藏着用户一按就看见了。我按成本从低到高准备了三个处理层级实际项目里用的是前两个。第一级显示层截断。计算方法不变只在展示时用toPrecision(12)截到 12 位有效数字再parseFloat去掉尾部多余的零。这能解决 99% 的观感问题0.30000000000000004会显示成0.3。代价是如果用户真的需要 15 位精度会被吞掉。对这种随手用的工具来说这个取舍完全合理。function fmt(n) { if (!isFinite(n)) return 错误; const s parseFloat(n.toPrecision(12)); return String(Math.abs(s) 1e13 ? s.toExponential(6) : s); }第二级整数化运算。把参与运算的数字乘以 10 的 n 次方转成整数算完再除回去。关键在于 n 怎么取——我取的是两个操作数小数位数的较大值简单粗暴但覆盖了绝大多数情况。这个方法对加减法效果最好乘除法仍可能有残余误差不过配合第一级截断基本看不出来。第三级十进制库。引入一个任意精度小数库彻底解决。我没用因为对这个小工具来说它带来的体积和复杂度远超收益。如果你的场景涉及金额计算那必须上第三级而且要在需求评审阶段就定下来不要等上线前才发现账对不上。3. 进制转换看着最简单坑却最多3.1 parseInt 的舒适区和它的天花板进制转换的第一版我十分钟就写完了因为 JS 内置了parseInt和toStringconst n parseInt(ff, 16); // 255 n.toString(2); // 11111111这个组合在中小数值范围内极其稳但它有两个天花板。一是精度天花板JS 的 Number 是 IEEE 754 双精度安全整数上限是 2 的 53 次方减一超过这个范围parseInt的结果就不再精确。处理 IP 地址、颜色值、权限位这类场景完全够用但如果有人粘一个 64 位的内存地址进来结果就是错的而且错得毫无提示。二是小数天花板toString(radix)对小数部分的支持是“有但不可靠”(0.1).toString(2)会返回一长串你没法控制保留几位也没法保证结果符合预期。针对第一点我的做法是判断输入长度如果超过 15 个字符就切到BigInt分支。BigInt支持任意精度整数BigInt(0x hexStr)或者BigInt(0b binStr)都能直接解析转回去用big.toString(radix)。代价是BigInt不能和 Number 混算得在入口处做一次统一的类型判断别让两种类型流到下游。3.2 小数进制转换必须手写小数部分我老老实实手写了因为这是唯一能控制精度的办法。算法就是课本上那个乘基取整正序排列。拿 0.625 转二进制举例0.625 乘 2 得 1.25取出整数部分 10.25 乘 2 得 0.5取出 00.5 乘 2 得 1.0取出 1。结果是0.101。function fracToBase(frac, base, maxDigits 16) { let out ; let f frac; let i 0; while (f 0 i maxDigits) { f * base; const d Math.floor(f); out d 10 ? String(d) : String.fromCharCode(87 d); f - d; i; } return out; }这里必须设一个位数上限否则像 0.1 转二进制这种无限循环的情况会直接死循环把页面卡死。我设的是 16 位够用且不会太长。另外要注意循环的退出条件里f 0是不够的浮点误差会让f变成一个极小的正数而永远不归零所以位数上限是硬约束不能省。整数部分如果走手写路线算法是除基取余逆序排列用BigInt做除法能保证大数也正确。我在项目里是两条路都留着常规范围走Number超长走BigInt入口处一句判断分流逻辑不复杂但能省掉很多“为什么这个地址转出来不对”的追问。3.3 负数、全角字符和非法输入真实用户粘进来的东西远比你想的脏。我一开始只做了“去掉首尾空格”上线之后收到的反馈里一半是粘了带空格的、一半是粘了全角字符的。所以校验逻辑我后来重写了一遍按这个顺序处理去空白把所有空白字符包括全角空格统统删掉而不是只 trim 首尾。判符号单独记录是不是负数后面的转换只处理绝对值最后再补符号。转小写十六进制里的A-F统一转小写避免大小写混输。正则校验按源进制生成合法字符集比如二进制只允许[01.]十六进制允许[0-9a-f.]一旦有非法字符立即中断并提示“第 n 位字符不合法”。第 4 步的提示粒度我纠结过。只说“输入不合法”用户会抓狂说“第 n 位不合法”体验好很多实现上就是在正则匹配失败时用search定位索引。多写三行代码投诉量能降一大截。小数点的处理也有坑用户可能粘进来多个小数点或者小数点开头、结尾。我的处理是只认第一个小数点后面的直接判为非法同时允许.5这种省略整数位的写法内部补成0.5。这个规则和大多数编程语言的浮点字面量规则一致用户不会觉得奇怪。3.4 一个输入框驱动四路输出的联动方案界面上的做法我改过一版。最初是四个输入框用户要在哪个框里输就在哪个框里输结果用户在十进制框输了255二进制框还是空的因为他不知道另外三个框也能输。后来改成一个输入框 一个源进制选择器下面四行只读输出二、八、十、十六源进制那一行的输出和输入框保持一致。这个改动带来的核心问题是“什么时候刷新”。用户每敲一个字符就刷新一次会导致输到一半出现红色的错误提示体验很差。我的处理是输入时只做轻量校验并灰显输出失焦或者停顿 400 毫秒后才全量刷新。这个 400 毫秒是试出来的短了还是会打断长了会显得卡。如果你做的工具面向的是程序员群体他们对延迟更敏感可以把停顿时间压到 250 毫秒左右。另外我加了一个“点任意一行输出即可复制”的功能用navigator.clipboard.writeText复制成功后那一行闪一下绿色。这个小细节用过的人都说顺手比放一个复制按钮省地方。4. BMI 计算公式三行产品细节三十行4.1 公式与分级标准要先定死BMI 的公式就一行体重千克除以身高米的平方。BMI kg / (m * m)。麻烦的从来不是公式而是分级区间用哪一套、以及边界值算哪边。国内常用的分级是这样的区间分类常见提示色小于 18.5偏瘦蓝18.5 至 23.9正常绿24.0 至 27.9超重橙28.0 及以上肥胖红我在代码里没有用浮点比较去判区间而是把 BMI 值乘以 10 取整用整数去和 185、240、280 这些边界比。这样做的原因是浮点比较在边界上非常容易出现23.900000000000002 24这种判断虽然方向对了但如果你反过来写就会有微妙差异。整数化之后边界判断干净利落也方便写测试用例。还有一点必须做免责说明。BMI 只是一个粗筛指标肌肉量大的人会被判成超重孕妇、儿童、老人都不适用。这个工具不是医疗产品界面上要有一行小字说明“结果仅供参考不构成任何健康建议”。这行字我从第一版就写上了不是怕担责任是因为真的有人会拿它当诊断依据。4.2 单位换算和输入边界校验身高输入的坑比想象中多。用户可能填175、1.75、175cm、1.75米甚至175厘米。我的策略是默认按厘米处理输入框右侧固定显示单位标签用户填175就是 175 厘米简单明确。如果检测到数值小于 3就认为用户填的是米自动乘 100 换算并在界面上给一句轻提示。这个“自动识别”只在明确的小数值时启用避免把 2.5 厘米的误输入当成 2.5 米。体重同理默认千克。有些人习惯用斤我在旁边加了个单位切换选中斤之后内部统一乘 0.5 转成千克再算。内部永远只保留一种单位这是避免单位混淆的根本办法千万不要在公式里混着用。边界校验的清单我列在下面每一项都是实际遇到过或者想过的情况空值不计算结果区显示占位文案。非数字正则拦掉输入框标红。零或负数直接拒绝提示“请输入大于 0 的数值”。身高小于 50 厘米或大于 250 厘米提示可能填错了单位。体重小于 5 千克或大于 500 千克同上。结果超出 10 到 100 的范围不给分级只显示数值并提示检查输入。最后一条是我后加的。有一次测试输入身高 1 厘米、体重 80 千克算出来的 BMI 是 800000分级逻辑直接给出了一个荒唐的结论。加了范围兜底之后异常输入不会产出异常结论。4.3 结果呈现数字、分级和一句话解读结果的呈现方式我改了三次。第一版只有一个大数字太单薄第二版加了分级文字和颜色好一些但还是冷冰冰第三版加了一句根据分级生成的解读比如正常区间显示“落在常见参考区间内”偏瘦显示“低于常见参考区间下沿”。这句解读我用的是中性表述不给建议只做描述。视觉上用的是一根横向刻度条把四个区间按比例铺开用一个游标标出当前值的位置。这个做法比纯文字直观得多用户一眼就能看出自己离下个区间还有多远。刻度条实现很简单就是一个div加绝对定位的游标宽度百分比用(bmi - 15) / (35 - 15)算超出范围时把游标钉在两端不要让它跑到容器外面去。还有一个细节数值保留一位小数。保留两位会给人“很精确”的错觉实际上体重秤的精度根本到不了那个级别取整又太粗糙18 和 19 之间跨了一个完整区间。一位小数是这类健康指标的惯例也符合用户的心理预期。5. 界面组织三个模块怎么共存不打架5.1 切换时保留状态的取舍三个 tab 切换要不要保留各自的输入状态我一开始的做法是每次切换都重置理由是“干净”。用了两天自己就受不了了——算完 BMI 想切到计算器核对一下切回来发现刚才填的身高体重全没了。后来改成保留状态每个模块的数据存在各自的闭包变量里切换只是控制display不销毁 DOM。保留状态带来的副作用是“用户想清空怎么办”。解法是在每个模块的角落放一个明确的“重置”按钮而不是靠切换来隐式清空。这个改动很小但它把“状态归零”从一个副作用变成了一个显式操作用户对界面的掌控感立刻不一样了。DOM 不销毁还有一个好处切换是瞬时的没有重新渲染的开销。代价是初始加载时三个模块的 DOM 都要在体积大一点。三十 KB 的总量下这不是问题但如果你的工具模块更多就得考虑用模板字符串按需插入 DOM 了。5.2 移动端的三个硬性约束这个小工具主要在手机上用所以有几条约束我当成硬指标来卡。触控目标不小于 44 像素。计算器的按键尤其要注意早期版本为了塞下更多按键把高度压到了 36 像素误触率高得离谱尤其是和-挨着的时候。改成 48 像素之后误触基本消失代价是纵向要多占一点空间把上面的显示区压薄一点就匀出来了。输入框字号不小于 16 像素。这条是老生常谈但每次都会忘。小于 16 像素时移动端浏览器在聚焦输入框的瞬间会自动放大页面用户输完还得手动缩回去。把字号设成 16 像素这个行为就消失了比你写一堆 viewport 相关的 meta 都管用。键盘弹出时的可视区域。BMI 的两个输入框在页面下半部分键盘弹起来会遮住结果区。我的处理是给容器加scroll-margin-bottom并在输入框聚焦时用scrollIntoView把结果区带到可视范围内。这个处理不完美但比什么都不做强太多。另外计算器的按键我统一加了touch-action: manipulation去掉移动端 300 毫秒的点击延迟。这个属性比监听touchstart再手动preventDefault干净得多也不会影响滚动。6. 常见问题与排查实录6.1 问题速查表这张表是我把上线后收到的反馈和自测中遇到的问题整理出来的按现象查原因能省不少时间。现象大概率原因处理方式计算 0.10.2 显示一长串未做显示层截断用toPrecision(12)后parseFloat大数进制转换结果错误超出安全整数范围判断长度后用BigInt分支小数转换页面卡死循环没有位数上限加maxDigits硬约束十六进制输入报错全角字符或大写字母先归一化再正则校验BMI 边界值分类错误浮点比较精度问题乘 10 取整后比较按了运算符没反应状态机缺waiting标志补状态变量并写单测移动端输入框聚焦页面放大字号小于 16px调大到 16px 以上连续点击 号结果乱跳未做按钮防抖加 200ms 节流或禁用态粘贴带空格的内容报错只做了 trim用正则删除所有空白切换 tab 后数据丢失切换时重置了状态保留闭包状态另设重置按钮6.2 三个印象最深的坑第一个坑是parseFloat的贪婪。parseFloat(12abc)返回12不报错。我一开始用它做输入校验结果是用户输入12cm这种内容也能通过算出来的 BMI 莫名其妙。后来全部换成先正则匹配再转换/^\d(\.\d)?$/匹配通过才parseFloat问题消失。这个教训是校验和转换必须是两步不能指望转换函数帮你做校验。第二个坑是计算器的连按问题。用户在等结果的时候会连点等号第一下算出结果第二下又拿结果和上一次的操作数算了一遍数字越算越小。我一开始用节流效果不好因为节流只是降低频率不改变逻辑。正确做法是在算完之后把pendingOp清空后续再按等号直接返回当前值不做任何运算。一行赋值解决的问题我绕了两次弯。第三个坑是BigInt和 JSON 的冲突。我原本想把计算历史存到本地存储里结果JSON.stringify遇到BigInt直接抛异常。处理办法是在序列化之前统一转成字符串反序列化时再根据标记还原。如果你也打算做历史记录功能这个坑提前避开。提示所有涉及数值输入的模块校验逻辑必须放在事件处理的最前端任何计算都应该建立在“数据已经合法”的前提上。事后补救的成本远高于入口拦截。7. 上线前的自测清单功能写完不等于能用我给自己定了一份自测清单每次改动核心代码都会跑一遍。第一块是边界值计算器要测除零、连续运算符、等号连按、超长小数进制转换要测 0、负数、.5这种省略整数位、16 位以上大数、含非法字符的输入BMI 要测空值、零、负数、身高 1 厘米、体重 800 千克。这些用例不写自动化也能手点十分钟能过一遍能挡掉绝大部分低级错误。第二块是跨环境。同一个页面在手机上、在桌面浏览器窄窗口下、在深色模式下都要看一眼。深色模式是最容易漏的因为我写 CSS 变量的时候容易只定义浅色那套深色下边框和占位文字会糊成一片。我的做法是把所有颜色都收敛到七八个 CSS 变量里切主题只换变量不写两套样式。第三块是断网和冷启动。把网络断开刷新页面看三个模块是否都能用。这一条主要是防止自己不小心引入了外部字体或者图标库那种“本地好好的、用户那边一堆方块”的事故八成都是这个原因。第四块是误操作恢复。故意乱按一通看能不能通过“重置”按钮回到干净状态故意在输入框里粘一大段垃圾看程序是报错还是崩溃。工具类应用最忌讳的是崩溃宁可给出难看的提示也不能让页面白屏。我在每个模块的入口都包了一层 try-catch出错时把状态重置并给一句“输入有误已重置”至少用户还能继续用。这套东西我从最初的一个下午写完的 Demo陆陆续续改了小半年才觉得“敢给别人用”。中间最大的体会是工具类项目的价值不在功能数量而在边界处理的密度。计算器谁都会写写完之后那几十条边界用例才是真正花时间的地方进制转换的算法课本上就有但让用户粘任何格式的内容都不报错、不崩溃靠的是那一层层校验和兜底。我现在的习惯是任何一个小工具在发布前先自己当三天真实用户把不顺手的点记下来往往能记出十几条改动这些改动的价值比再堆一个新功能高得多。