
把 0.0000005 交给 parseInt,返回 5。同一个值交给 Number,返回 5e-7。两个函数号称都在把输入变成数字,输入也是同一个,答案差了一百万倍。
parseInt(0.0000005); // 5
Number(0.0000005); // 5e-7
写出来的 0.0000005 和 5e-7 在 JS 里是同一个数,字面量只是写法不同。所以上面这一对结果不能怪调用者传错了东西。
规范里 parseInt 的第一步是 ToString
ECMA-262 描述 parseInt 时,第一步就是对参数做 ToString(x),之后的解析全部发生在得到的字符串上。传数字还是传字符串不改变路径:数字先变成一个字符串,再从这个字符串里找最长前缀。
数字转字符串这一步,规范不要求保住手写的写法,只要求给出最短的、能往返读回同一个数的那一串。0.0000005 的往返表示是 "5e-7"。parseInt 从里面认到 5 就停,e 和后面的全部丢掉,返回 5。
两条算法:前缀匹配和整串匹配
Number 走 ToNumber 里的 StringToNumber,用 StringNumericLiteral 这套语法:整串必须正好是一个数值字面量,指数和小数点都合法,多一个字符就整串判废,返回 NaN。
parseInt 要求的是开头有一段合法的数字前缀,尾部有什么都不管。这个差别在两个方向上都留下后果。parseInt("12px") 给 12,Number("12px") 给 NaN。反过来,parseInt("") 给 NaN,Number("") 给 0,因为空串在 StringNumericLiteral 语法里有定义,值是 0。
--- 前缀匹配 vs 整串匹配 ---
"12px" Number: NaN parseInt: 12 parseFloat: 12
"12 34" Number: NaN parseInt: 12 parseFloat: 12
"3.9abc" Number: NaN parseInt: 3 parseFloat: 3.9
"0x1f " Number: 31 parseInt: 31 parseFloat: 0
" -7" Number: -7 parseInt: -7 parseFloat: -7
parseFloat 是第三条路:它认小数点,也不要求整串合法,但遇到 0x 就停,所以 parseFloat("0x1f ") 是 0。
十五个用例,十一个不一致
同一批输入同时喂给两个函数,结果摆在一起看更清楚。下面这张表是本机跑出来的,中间那列是 String(x),也就是 parseInt 眼里真正的输入。
input String(x) Number(x) parseInt(x)
------------------------------------------------------------------------------------------------------------
5e-7 极小浮点数 "5e-7" 5e-7 5 <-- 不一样
1e+21 极大浮点数 "1e+21" 1e+21 1 <-- 不一样
1e-21 极小浮点数(另一个量级) "1e-21" 1e-21 1 <-- 不一样
"0x10" 十六进制字符串 "0x10" 16 16
"1e3" 普通科学计数法字符串 "1e3" 1000 1 <-- 不一样
" 12 " 前后有空白 " 12 " 12 12
"" 空串 "" 0 NaN <-- 不一样
"08" 前导零 "08" 8 8
"1_000" 数字分隔符形如字符串 "1_000" NaN 1 <-- 不一样
"Infinity" Infinity 字符串 "Infinity" Infinity NaN <-- 不一样
null null "null" 0 NaN <-- 不一样
undefined undefined "undefined" NaN NaN
"12px" 数字开头 + 单位 "12px" NaN 12 <-- 不一样
" 0b101" 二进制字面量 " 0b101" 5 0 <-- 不一样
true 布尔 "true" 1 NaN <-- 不一样
输入里有 15 个用例,Number 与 parseInt 结果不同的有 11 个:
5e-7, 1e+21, 1e-21, "1e3", "", "1_000", "Infinity", null, "12px", " 0b101", true
两个值得单独看的行。1e21 那一行里 String 出来的是 "1e+21",parseInt 认到 1 就返回,一个巨大的数字变成了 1。null 那一行,Number(null) 是 0,parseInt(null) 是 NaN,因为 ToString(null) 得到的是字符串 "null",parseInt 找不到数字前缀。
undefined 是唯一两边一致给 NaN 的用例,因为它无论怎么转都读不出数值。
十六进制那一处两边反而一样
radix 省略或者给 0 时,以 0x 或 0X 开头的串按 16 进制处理,parseInt("0x10") 是 16。显式写 parseInt("0x10", 10) 就变成 0:从 0 往后读到 x 停住,前缀是 "0",值是 0。而 Number("0x10") 也是 16。这一个用例上两条路径碰巧给同一个答案,容易被当成可以互换的证据。
--- radix 缺省与显式 10 的区别 ---
"0x10" parseInt(s) = 16 parseInt(s, 10) = 0 Number(s) = 16
"0X10" parseInt(s) = 16 parseInt(s, 10) = 0 Number(s) = 16
"010" parseInt(s) = 10 parseInt(s, 10) = 10 Number(s) = 10
"0o10" parseInt(s) = 0 parseInt(s, 10) = 0 Number(s) = 8
--- parseInt 的第二参数不合法时 ---
radix = undefined parseInt('10', radix) = 10
radix = 0 parseInt('10', radix) = 10
radix = 1 parseInt('10', radix) = NaN
radix = 10 parseInt('10', radix) = 10
radix = 16 parseInt('10', radix) = 16
radix = 37 parseInt('10', radix) = NaN
radix = -1 parseInt('10', radix) = NaN
radix = 2.5 parseInt('10', radix) = 2
radix 给 2.5 时被截成 2,所以 parseInt("10", 2.5) 是二进制的结果 2。给 37 或负数直接 NaN。
解析输入还是校验输入
分场景看,选择其实很固定。
解析那种带单位、带尾巴的脏值,要的就是前缀行为,比如从 "12px" 里取宽度、从 "3.9abc" 里取版本号,这时用 parseInt 或 parseFloat,radix 显式写出来,别靠缺省值。判断一个字符串整体是不是合法数字,要的是整串匹配,用 Number,失败判断用 Number.isNaN(x)。全局 isNaN 会先对参数做一次 ToNumber,isNaN("") 是 false,看着像通过了校验。
手里已经是数字、只是想取整的情况,用 Math.trunc 或 Math.floor,不要绕回 parseInt。这十五个用例里最容易被漏掉的是 1e21 那一行:一个本来很大的产值或时间戳,经过一次 parseInt 就变成了 1,而且不报错。取整这类需求我不用 parseInt,多绕一层字符串没有任何好处。
--- 数值先取整还是先转字符串 ---
3.7 String(x) = "3.7" parseInt(x) = 3
-3.7 String(x) = "-3.7" parseInt(x) = -3
2147483648 String(x) = "2147483648" parseInt(x) = 2147483648
1.9e+21 String(x) = "1.9e+21" parseInt(x) = 1
NaN String(x) = "NaN" parseInt(x) = NaN
Infinity String(x) = "Infinity" parseInt(x) = NaN
--- 单独看最反直觉的那个 ---
tiny = 5e-7
String(tiny) = "5e-7" (ToString 给的是最短往返表示,不是 0.0000005)
Number('5e-7') = 5e-7
parseInt('5e-7') = 5 (认到 5 就停,'e' 及之后全丢)
parseInt(tiny) = 5 (规范第一步是 ToString(x),所以等于上一行)
上面每段输出都在 node v26.8.1 上跑过,两个函数的差异不随版本变化,规范里就是两条不同的算法。