uniapp中9.9×3精度问题:浮点数原理与定点整数实践
“9.9 乘 3 等于多少?”这问题连刚上二年级的孩子都能一口报出答案,可你在 uniapp 里写一句{{ 9.9 * 3 }},界面上很可能蹦出29.700000000000003。前阵子我帮一个做小学数学口算练习的小程序做改版,题库里加了“一位小数乘法”这个模块,测试同学截了张图丢过来:题目是“9.9 × 3”,标准答案那栏写着29.700000000000003,孩子答 29.7 被判错。那一刻我就知道,这不是业务逻辑写错了,而是 JavaScript 数值类型本身的老毛病换了个地方冒出来。
这篇内容围绕“uniapp 里乘法结果尾巴很长”这件事展开,从浮点数在二进制里的存储方式讲起,到 uniapp 跨端渲染时这个数字要经过哪几道手,再到几种修法的真实效果对比,最后落回口算题库这个具体场景——出题、判题、展示三端怎么配合才能彻底绕开精度问题。不管你是刚接手游 uniapp 的新手,还是已经做过几个小程序、只是想找一份能直接抄的数值工具模块,看完都能少走几段弯路。文中所有代码都是纯前端逻辑,不涉及任何平台特定接口,换到 H5 或 App 端同样跑得通。
1. 那道 9.9×3 的错题:长小数尾巴的来历
1.1 先把现场复现出来
出问题的页面结构很简单:上方是题干,中间一个输入框,孩子填完点“确认”,下面显示判定结果。题干和标准答案都来自同一个计算函数。我把它抽出来单独跑了一遍:
const a = 9.9; const b = 3; console.log(a * b); // 29.700000000000003 console.log((a * b).toFixed(2)); // "29.70"有意思的地方在于,toFixed(2)出来的"29.70"看起来是干净的。那为什么页面上显示的还是长尾巴?继续往上翻代码,发现展示的地方写的是{{ answer }},answer直接就是a * b的结果,toFixed只加在了判题那一步的比较里。也就是说,判题逻辑其实没问题(后面我会说它其实也有问题,只是在这一例刚好蒙对),真正出丑的是渲染层。
同一个数在两处呈现不同结果,这提醒了我一件事:浮点数精度问题从来不是“算错了”,而是“同一个数在不同环节用了不同的表示”。理清这个前提,后面的排查才不容易跑偏。
再补一个更典型的例子,也是口算题库里高频出现的:
console.log(0.1 * 3); // 0.30000000000000004 console.log(0.7 + 0.1); // 0.7999999999999999 console.log(1.1 * 1.1); // 1.2100000000000002三条里有两条偏大、一条偏小。很多人第一反应是“JS 有 bug”,其实恰恰相反,JS 的行为完全符合国际标准,是我们在用十进制思维去要求一个二进制系统。
1.2 0.1 在二进制里根本没有“有限写法”
要理解这件事,得先接受一个反直觉的常识:十进制里的0.1,换成二进制是无限循环小数,就像十进制里的1/3 = 0.3333...永远写不完一样。
二进制小数每一位的权重是 1/2、1/4、1/8、1/16……用这些权重去凑出 0.1,得到的是一串0.0001100110011001100...,1100这四位无限循环。既然写不完,计算机只能截断,存一个最接近的近似值。
JavaScript 的Number用的是 IEEE 754 双精度格式,总共 64 位:1 位符号、11 位阶码、52 位尾数(隐含最高位 1,实际有效精度 53 位)。53 位二进制有效数字,折算成十进制大约是 15.95 位,所以一个Number能可靠表示的十进制有效数字大概是 15 到 17 位。
0.1存进去之后,实际值是:
0.1000000000000000055511151231257827021181583404541015625这已经比 0.1 大了一点点。再乘 3,误差跟着放大三倍,得到0.30000000000000004,这是规范化到 53 位有效数字之后的最近可表示值。
这里有个细节值得单独说一下:为什么控制台打印出来是0.30000000000000004这种看起来“只多了一位”的样子,而不是上面那串二十多位的小数?因为Number.prototype.toString用的是一种“最短往返表示”算法——在所有能把该二进制值唯一还原回来的十进制写法里,挑最短的那个。所以你看到的尾巴已经被“美化”过了,真实存储值比它长得多。
理解这一点非常关键:你看到的长度不代表误差的大小,只是最短可还原表示的长度。有些数误差明明很小,打印出来却很长;有些数误差挺大,反而看着很短。
1.3 为什么偏偏在乘法上暴露得最明显
口算题库里,加减乘除都会碰到小数,但用户反馈集中在乘法上,这不是巧合。原因有三层。
第一层是数学层面的。乘法的相对误差会累加:(a × (1+ε₁)) × (b × (1+ε₂)) ≈ a×b × (1+ε₁+ε₂)。两个操作数各自的相对误差会相加。加法虽然也有误差,但只在数量级悬殊时才明显(大数吃小数),乘法则几乎每次都会带出问题。
第二层是业务层面的。小学数学的口算题,答案通常是“好看的数”——29.7、1.21、0.08 这种,位数少、结尾是 0。恰恰是这种“人类觉得好看”的数,最容易落在二进制无法精确表示的位置上。0.5、0.25、0.125 这类能精确表示的反而少见。
第三层是展示层面的。“大九九乘法口诀表”里都是整数,1×1 到 9×9,怎么算都不会出问题。一旦扩展到“小数乘法”章节,整张表就塌了。我后来统计了一下题库里出问题的题目,几乎全部集中在带小数的乘法上,整数乘法一条都没事。
提示:排查这类问题不要一上来就改代码。先在控制台跑一遍
(a * b).toString()和a.toPrecision(20),把实际存储值打出来看看,很多“玄学”立刻就有了解释。
2. 在 uniapp 里,这个数字要过几道手
2.1 从输入框到变量:你拿到的是字符串
做口算练习,输入框是必需品。uniapp 里写<input v-model="form.answer" type="digit" />,看起来跟 Vue 项目里没什么区别,但编译到小程序端之后,v-model会被展开成:value加@input的组合,而e.detail.value拿到的类型是字符串,不是数字。
这就埋下了第一颗雷。孩子输入29.7,form.answer的值是"29.7",字符串。接下来代码里做比较:
if (form.answer === correctAnswer) { /* 判对 */ }字符串和数字做严格比较,永远是false,这一题必然判错。如果改成宽松比较==,"29.7" == 29.7是true,看起来能过,但宽松比较的隐式转换规则足够写一本书," " == 0也是true,"0x10" == 16还是true,用在判题场景风险太高,我不建议。
再往下还有第二颗雷。业务代码里如果写的是加法求和(比如统计一页题目的总分),"29.7" + "1.3"得到的是"29.71.3",字符串拼接。乘法因为运算符没有重载,"9.9" * 3会隐式转数字,反而“正常工作”,这恰恰让问题更隐蔽——同一个表单字段,做乘法没事,做加法炸了,开发者在测试时很容易漏掉。
我在这个项目里做的第一件事,就是把输入值在进入业务逻辑前统一转成定点整数,详见第 4 节的工具模块。原则只有一条:表单给什么类型我不管,进业务层之前一定转成整数。
2.2 逻辑层与渲染层之间的那道坎
uniapp 编译到小程序端时,采用的是逻辑层与视图层分离的架构。JS 代码跑在逻辑层,页面渲染跑在视图层,两边不是同一个执行环境,数据要通过序列化消息传递。
这个机制带来一个容易误解的点:很多人以为“经过一次序列化,精度就丢了”。实际上Number类型在传递过程中精度是不变的,序列化再反序列化,同一个0.1 + 0.2还是那个0.30000000000000004。真正会出问题的是Date、undefined、函数、BigInt这几类,其中BigInt的问题后文会专门说。
但跨层确实有个实实在在的坑:同一个计算不要在两个环境里各做一次。我见过这样的写法,模板里画一个合计:
<view>合计:{{ (price * count).toFixed(2) }} 元</view>然后在methods里又算一遍存到data里用于提交。两处算法不一致(一处toFixed、一处没做),显示和提交的数据就对不上。更麻烦的是模板表达式在每次重渲染时都会重新求值,如果有累加逻辑藏在里面,页面刷新一次数字就变一次。
我的规矩是:所有数值计算只在 JS 里做一次,模板只负责把算好的值原样吐出来。模板里出现运算符,一律视为代码异味。
2.3 不同端的表现差异,哪些是真的、哪些是心理作用
经常听到“这个问题 iOS 上没事,安卓上就出来了”。我拿同一个题库在几个环境下跑过对比,结论比较朴素:
| 环节 | 是否存在端差异 | 实际情况 |
|---|---|---|
| 加减乘除运算本身 | 基本没有 | 都遵循 IEEE 754,结果一致 |
toFixed/toPrecision | 极小概率有 | 老版本引擎对“恰好在中点”的舍入处理偶有出入 |
| 数字转字符串输出 | 轻微 | 最短往返算法实现细节略有不同,极少数值打印位数不一致 |
| 输入框取值类型 | 有 | 某些端返回字符串,某些端在type="number"时返回数字 |
| 长按、滚动等交互 | 有 | 与数值无关,但容易被误判成“数字问题” |
真正值得注意的是输入框取值类型那一行。如果你写死了type="number"并依赖它返回数字,在部分端上会拿到字符串,接着typeof判断走不同分支,行为就分叉了。
至于toFixed的端差异,我实测下来遇到的次数非常少,且都集中在若干年前的老环境上,日常开发不用太担心。但有一条经验值得记:不要在toFixed之后直接参与运算。它返回的是字符串,(1.005).toFixed(2) + 1会得到"1.001",这种错误在真机日志里很难一眼看出来。
3. 五种修法的实测对比
3.1 toFixed 不是万能药,它的坑比想象中深
大部分人的第一反应是toFixed(2)一把梭。它确实能解决大多数展示问题,但有两个前提必须搞清楚。
第一个前提:toFixed返回的是字符串。用完之后如果还要接着算,得Number()转回来,一转回来精度噪声又回来了。所以它只适合做“最后一公里”的展示格式化,不适合做中间计算。
第二个前提:toFixed的舍入结果可能和你的直觉不一致。
console.log((1.005).toFixed(2)); // "1.00" 而不是 "1.01" console.log((1.335).toFixed(2)); // "1.33" 而不是 "1.34" console.log((8.575).toFixed(2)); // "8.57" 而不是 "8.58"很多人以为这是“四舍六入五成双”的银行家舍入在作祟,其实不是。ECMAScript 规范里toFixed的行为是“取最接近的 n 位小数表示,如果恰好在中点则取较大的那个”,规则本身是“五入”的。问题出在输入值上:1.005存进内存时实际是1.00499999999999989341...,它压根就不在中点上,比中点小,自然舍下去。
这个现象在口算判题里是致命的。孩子算对了 1.01,你用toFixed(2)算标准答案得到 1.00,判错。而且这种错误极其隐蔽,因为它只在特定的数值上出现,测试时十道题可能一道都碰不上。
我的做法是:toFixed只用于最终的显示,绝不用于判题比较。判题走定点整数,见 4.2。
3.2 放大取整法:够用,但要知道它的边界
放大取整的思路很直白:先把两个小数都乘以 100 变成整数,乘完再除回去。
function mul(a, b, digits = 2) { const scale = Math.pow(10, digits); return Math.round(a * scale) * Math.round(b * scale) / (scale * scale); } console.log(mul(9.9, 3)); // 29.7 console.log(mul(1.1, 1.1)); // 1.21这个写法对小数量级的场景足够好用,但有三条边界必须清楚。
第一条,Math.round(a * scale)这一步本身就在做浮点乘法,1.15 * 100得到的是114.99999999999999,Math.round之后是 115,这一例蒙对了。但换个值就不一定,8.475 * 100得到847.4999999999999,Math.round得 847,而我们期望的是 848。也就是说,放大这一步本身就可能引入误差。
第二条,溢出。Number.MAX_SAFE_INTEGER是 9007199254740991,大约 9×10¹⁵。如果两个操作数放大 100 倍之后都接近 10⁷,乘积就到了 10¹⁶,超出安全整数范围,Math.round之后低位会丢。口算题不会这么大,但如果是电商价格计算场景,就得掂量了。
第三条,除法那一步又把结果拉回了浮点域,29.7本身在二进制里依旧不精确,只是打印出来好看。如果这个结果还要继续参与后续运算,误差会继续累积。
所以放大取整的定位是:适合“一步算完就展示”的场景,不适合链式计算。口算判题刚好属于前者,用它是可以的,但我更推荐直接用字符串解析版本,把放大那一步的误差也掐掉。
3.3 精度库:体积、API 风格与模板渲染的适配
一旦业务复杂起来(比如要算折扣、税费、分摊),放大取整就不够看了,这时候上精度库是正解。几个常见的:
| 库 | 压缩后体积(约) | 特点 | 适用场景 |
|---|---|---|---|
| big.js | 6 KB / gzip 约 3 KB | 极简,只做四则运算和比较 | 大多数业务计算 |
| decimal.js | 32 KB / gzip 约 12 KB | 功能全,支持任意精度、三角函数、进制转换 | 科学计算、金融 |
| bignumber.js | 18 KB / gzip 约 7 KB | 配置项多,误差、舍入模式可调 | 需要精细控制舍入 |
小程序主包体积一般有 2 MB 上限,具体以各平台文档为准,加个 3 KB 的 big.js 完全不心疼。我用得最多的是 big.js,API 简单:
import Big from 'big.js'; const r1 = new Big('9.9').times('3'); console.log(r1.toString()); // "29.7" console.log(r1.toFixed(2)); // "29.70" // 注意:构造函数传字符串比传数字安全 console.log(new Big(0.1).plus(0.2).toString()); // "0.3"这里有个容易踩的细节:new Big(0.1)和new Big('0.1')结果一样,因为构造时会用Number.prototype.toString()把数字转成最短往返字符串再解析。但如果你传的是个计算结果,比如new Big(0.1 + 0.2),它拿到的就是0.30000000000000004这个字符串,库再精确也救不回来。所以规矩是:能传字符串就传字符串,别把已经污染的数字喂给库。
第二个坑是渲染。big.js 返回的是 Big 对象,不是原始值。模板里写{{ result }}会渲染成一串对象信息。必须先.toString()或.toFixed()转成字符串再塞进data。
第三个坑是引入方式。小程序端对环境有要求,import Big from 'big.js'在大部分构建配置下没问题,少数情况下需要配transpileDependencies或者在vue.config.js里加require('big.js')的 hoisting 处理。项目打包报 “Big is not a constructor” 的时候,先看是不是被 Babel 处理成了default包裹。
3.4 BigInt:能力很强,但在跨端环境里要慎用
BigInt是 ES2020 的特性,能做任意精度的整数运算,理论上非常适合做定点计算。在 Node 环境里跑得好好的,搬到小程序上就得留神两件事。
第一是引擎支持。较新的基础库环境下BigInt是可用的,但项目如果还要兼容一些老设备,得先做能力检测:
const hasBigInt = typeof BigInt === 'function';第二是序列化。BigInt类型的值一旦进入setData的数据流,序列化会直接抛错:
JSON.stringify({ n: 1n }); // TypeError: Do not know how to serialize a BigInt因为跨层传数据本质上是序列化,所以BigInt绝对不能出现在要渲染的数据里。变通办法是转成字符串再传,但这又回到了字符串处理,绕了一圈。
我的结论是:口算题库这种场景用不上BigInt,定点整数完全够;BigInt更适合处理超大整数(比如身份证号、超长订单号)的运算,且只在逻辑层内部使用,结果转字符串后再往外传。
3.5 字符串竖式乘法:极端场景下的兜底
还有一种“笨办法”——用字符串模拟小学生竖式乘法,一位一位地算。这个方法在需要绝对精度的场合(比如超过 15 位有效数字的超长小数)是唯一可靠的路子。我做过一版,核心就是按位乘、按位进位,最后根据小数位数点小数点。
function stringMul(a, b) { // 只处理非负数字符串,省略符号判断 const da = (a.split('.')[1] || '').length; const db = (b.split('.')[1] || '').length; const ia = a.replace('.', ''); const ib = b.replace('.', ''); const res = new Array(ia.length + ib.length).fill(0); for (let i = ia.length - 1; i >= 0; i--) { for (let j = ib.length - 1; j >= 0; j--) { const p = res[i + j + 1] + Number(ia[i]) * Number(ib[j]); res[i + j + 1] = p % 10; res[i + j] += Math.floor(p / 10); } } let s = res.join('').replace(/^0+/, '') || '0'; const total = da + db; if (total > 0) { s = s.padStart(total + 1, '0'); s = s.slice(0, s.length - total) + '.' + s.slice(s.length - total); } return s; } console.log(stringMul('9.9', '3')); // "29.7" console.log(stringMul('1.1', '1.1')); // "1.21"这个函数没有精度问题,但它慢、代码长、边界多(符号、前导零、末位零)。我把它作为工具模块里的“兜底函数”,只在检测到操作数位数超过 12 位时才调用,日常走定点整数路径。绝大多数项目根本用不到它,知道有这条路就行了。
4. 口算应用场景:从结果精度倒推出题策略
4.1 出题端就锁定小数位,比事后修省事得多
前面折腾了半天都是“算完了怎么修”。真正的转折点是我意识到:口算题库根本不需要真的做小数乘法。题目是程序生成的,答案也是程序生成的,那为什么不从源头就生成整数?
原来的写法是先生成随机小数,再相乘:
const a = (Math.random() * 10).toFixed(1); // 已经变成字符串了 const b = Math.floor(Math.random() * 9) + 1; const answer = Number(a) * b; // 噪声从这里进来改成生成整数再拼装:
// 以 0.1 为最小单位:生成 1.0 ~ 9.9 之间的一位小数 const aInt = Math.floor(Math.random() * 90) + 10; // 10 ~ 99 const bInt = Math.floor(Math.random() * 9) + 1; // 1 ~ 9 const stem = `${aInt / 10} × ${bInt}`; // 仅用于展示 const answerInt = aInt * bInt; // 精确整数,单位是 0.1 const answerText = formatFixed(answerInt, 1); // "29.7"注意aInt / 10这一步也有浮点,但它只用于拼接题干字符串,不参与任何后续运算。答案aInt * bInt全程在整数域,297这个值精确无误。展示时用formatFixed(297, 1)得到"29.7",纯字符串操作,零误差。
这个思路可以推广:所有涉及小数的业务,内部一律用“最小货币单位”式的整数存储,只在展示层做一次缩放。价格用分、成绩用千分位、时长用毫秒,都是同一套逻辑。
4.2 判题不能直接比相等
即使用了定点整数,判题也得留个心。因为用户的答案是从输入框来的字符串,得先解析成同单位整数:
function parseFixed(input, digits) { let s = String(input == null ? '' : input).trim(); if (!/^-?\d*(\.\d*)?$/.test(s) || s === '' || s === '-' || s === '.') return null; const neg = s.charAt(0) === '-'; if (neg) s = s.slice(1); let [intPart = '0', fracPart = ''] = s.split('.'); fracPart = (fracPart + '0'.repeat(digits)).slice(0, digits); const v = Number(intPart + fracPart); if (!Number.isSafeInteger(v)) return null; return neg ? -v : v; }这段的关键在于用字符串拼接做放大,而不是Number(s) * 100,彻底避开了 3.2 里提到的 1.15×100 那个坑。
解析完再比较:
const userInt = parseFixed(form.answer, 1); const correctInt = answerInt; const isRight = userInt === correctInt;整数比较,绝对可靠。用户输入29.70,解析成297;输入29.7,也是297;输入29.75,因为只取一位小数,会被切成297,这就有问题了。所以还得加一步校验:解析时要检查原始输入的小数位数是否超过题目要求,超了就判格式错误,提示“本题答案保留一位小数”。
function fracLen(s) { const i = String(s).indexOf('.'); return i < 0 ? 0 : String(s).length - i - 1; } if (fracLen(form.answer) > 1) { return showTip('答案请保留一位小数'); }如果确实需要容差比较(比如允许孩子多写一位有效数字),再退到浮点比较,用相对误差:
const diff = Math.abs(Number(form.answer) - Number(answerText)); const ok = diff <= Math.abs(Number(answerText)) * 1e-9;1e-9这个阈值不是随便定的,它是给“15 到 17 位有效数字里有 1 位不确定”留出的余量,日常业务够用。
4.3 口诀表、顺口溜这类静态内容和动态计算的混排
标题后半段提到的“大九九乘法口诀表”和“数学顺口溜”,在这个项目里占的篇幅其实比动态出题还大。大九九是 1×1 到 9×9 的完整 81 条口诀,小九九只保留 45 条(去掉交换律重复项)。做练习册页面,两种都要支持。
这部分内容全是静态的,用二维数组写死最省事:
const BIG_TABLE = []; for (let i = 1; i <= 9; i++) { const row = []; for (let j = 1; j <= 9; j++) { row.push({ a: i, b: j, text: `${CN[i]}${CN[j]}${resultText(i * j)}` }); } BIG_TABLE.push(row); }i * j最大 81,远在安全整数范围内,不会有任何精度问题。这里真正要花心思的是格式化文本,因为口诀是中文表达,“二三得六”和“三四十二”的句式不一样:乘积小于 10 用“得”,大于等于 10 直接跟数字。
function resultText(n) { return n < 10 ? `得${CN[n]}` : CN[n]; }至于顺口溜,本质是一段富文本,跟计算无关。我把它做成独立的静态配置,和题库数据分开管理,避免编辑内容时误碰数值逻辑。混排页面的坑主要在两处:一是长列表渲染性能,81 个格子如果用v-for嵌套两层的写法,在低端机上首屏会卡顿,建议用scroll-view加分页加载;二是文字对齐,“一一得一”三个字和“六八四十八”五个字挤在同一列里,用等宽字体加text-align: center才看得舒服。
4.4 一个可以整段抄走的数值工具模块
把上面所有东西收拢成一个文件,大概长这样。这个模块不依赖任何第三方库,压缩后不到 1 KB,全部用字符串和整数运算,适合直接丢进utils/目录。
// utils/fixed.js export function parseFixed(input, digits) { let s = String(input == null ? '' : input).trim(); if (s === '' || !/^-?\d*(\.\d*)?$/.test(s)) return null; const neg = s.charAt(0) === '-'; if (neg) s = s.slice(1); let [ip = '0', fp = ''] = s.split('.'); fp = (fp + '0'.repeat(digits)).slice(0, digits); const v = Number(ip + fp); if (!Number.isSafeInteger(v)) return null; return neg ? -v : v; } export function mulFixed(aInt, bInt, digits) { const raw = aInt * bInt; // 放大 10^(2*digits) 倍 if (!Number.isSafeInteger(raw)) return null; return Math.round(raw / Math.pow(10, digits)); } export function formatFixed(intVal, digits) { if (!Number.isFinite(intVal)) return ''; const neg = intVal < 0; let s = String(Math.abs(intVal)).padStart(digits + 1, '0'); const ip = s.slice(0, s.length - digits); let fp = s.slice(s.length - digits).replace(/0+$/, ''); const out = fp ? `${ip}.${fp}` : ip; return neg ? `-${out}` : out; }三个函数的职责划分很清晰:parseFixed负责“外部字符串进整数域”,mulFixed负责“整数域内做乘法”,formatFixed负责“整数域回字符串”。业务代码只在这三个函数的边界上打交道,中间永远不出现浮点数。
配套的单元测试我建议至少覆盖这几组值:0.1、9.9、1.15、-3.75、空字符串、"1."、".5"、十六位以上的超长数字。这几组跑通,基本就能拦住 90% 的边界问题。
5. 上真机之前的自查清单
5.1 三个最容易被测试打回来的细节
第一,输入框的键盘类型和实际允许输入的内容对不上。type="digit"在很多端上带小数点,但少数端只给整数键盘,孩子根本输不进小数。稳妥做法是type="digit"再加一层输入过滤,非法字符直接拦掉,别指望键盘类型能兜住。
第二,末尾的零被吃掉了。29.70存在整数域里是2970,按两位小数格式化能得到29.70;如果按一位小数格式化就变成29.7。题目要求保留两位时,展示必须严格按两位来,别在格式化函数里统一replace(/0+$/,'')。我在formatFixed里做了去尾零,所以业务代码要判断是否走这个函数——要求固定位数的题目,直接调toFixed(digits)更保险。
第三,负号和零的组合。-0在 JS 里是存在的,formatFixed(-0, 2)会因为neg判断为false而输出"0.00",看着没问题;但formatFixed(-1, 2)输出"-0.01",如果孩子答错方向,显示上没问题。真正要防的是parseFixed("-0"),正则放行,走进来是-0,Number("-00")是-0,参与比较时-0 === 0为true,会误判。加上if (v === 0) return 0;这一句就干净了。
5.2 我踩过的一次排查弯路
说一个我自己的教训。项目上线后有家长反馈“9.8 × 5 显示成了 49,孩子答 49 被判错”。第一反应是精度问题,我翻来覆去检查格式化函数,跑了十几组数都没复现。后来把用户的题目 ID 捞出来,发现那道题的答案字段存的是49.0,但孩子输入的49被parseFixed按一位小数解析成了490,而标准答案因为某次数据迁移存成了整数49(没有放大),490 !== 49,判错。
问题不在浮点数,在数据单位不统一。一部分老题目的答案以“元”为单位存整数,新题目以“分”为单位存放大值,两套标准混在一张表里。这件事让我在后来的项目里养成了一个硬规矩:所有入库的数值字段必须带单位后缀,叫answerInt还是answerText一眼就能看出来,别叫answer。
排查这类问题有个很有效的办法:把标准答案和用户答案在比较前都console.log打出来,连同它们的typeof和放大倍数。很多“精度问题”其实都是单位问题、类型问题伪装的。
5.3 后续还能怎么扩展
这套定点整数的思路不只适用于口算题。分数运算可以按“最小公倍数放大”处理,单位换算可以按“最小单位”处理,甚至连“统计平均分保留两位”这种需求,也能先把总分乘以 100 再除以题数,全程整数。
如果后面要加混合运算,比如(1.5 + 2.5) × 3,只要保证每一步都在整数域完成、每步之后用对应的digits收敛,链式计算也不会出问题。我给自己定的规则是:任何进入表达式的值,必须是已经转换好的整数,中间不允许出现Number()的临时转换。这条规则执行得越严格,后期排查成本越低。
另外那个字符串竖式乘法的兜底函数,虽然在口算场景用不上,但我一直留着。遇到用户输入超长数字、或者要做大数交易金额计算的场景,它能救急。放在utils/里不占多少空间,比临时抓瞎强。
最后分享一个我自己一直在用的小习惯:每接一个新的 uniapp 项目,第一件事就是在utils/里建好这个fixed.js,然后在项目的README里写一行“所有小数计算走 fixed 模块,禁止直接乘除”。写在最前面的约定,比事后再改二十个页面省事得多。