#千分符
#题目描述
将数字字符串转换为千分位格式(例如将 1234567.89 变为 1,234,567.89),其中第二个参数用于表示保留的小数位数。
#思路分析
- 千分符只存在整数部分,需要将负号、整数、小数拆分出来。
- 以小数点为界,拆分为整数部分和小数部分。
- 将整数部分 3 个为一组拆分。
具体实现上有:
- 顺序遍历。核心逻辑是在遍历的时候判断剩余的字符长度是否是 3 的倍数,如果是就插入分隔符。
- 逆序遍历。3 个为一组是从个位数开始的,所有可以从右往左遍历,维护一个 count,每当 count 是 3 的倍数就插入分隔符。
- 正则表达式。使用正则将字符每 3 个为一组拆分。然后替换原字符的时候带上分割符。
- 使用内置的 Intl API 实现。
#示例代码
顺序遍历
逆序遍历
正则表达式
Intl API
prepareData.js
// 从同目录下的 prepare-data.js 文件中引入 prepareData 函数
// prepareData 负责:去除负号、四舍五入、拆分整数部分和小数部分
import { prepareData } from './prepare-data.js';
/**
* 将数字字符串格式化为带千位分隔符的字符串
* 算法思路:从整数部分的最高位(左侧)向最低位(右侧)遍历,
* 利用"当前字符之后还剩余的位数"是否为 3 的倍数来判断是否插入逗号。
* 例如 "1234567",在索引 0(字符 '1')之后还剩 6 位,6 % 3 === 0,插入逗号;
* 在索引 1(字符 '2')之后还剩 5 位,5 % 3 !== 0,不插入;以此类推。
*
* @param {number|string} numStr - 输入数字,例如 "1234567.89" 或 -42
* @param {number} precision - 保留的小数位数,例如 2 表示保留两位小数
* @returns {string} 格式化后的字符串,例如 "1,234,567.89"
*
* 示例:
* formatForward("1234567.89", 2) => "1,234,567.89"
* formatForward("-9876543.1", 1) => "-9,876,543.1"
* formatForward("0.999", 2) => "1.00"
*
* ⚠️ 注意:底层使用 toFixed 做四舍五入,存在极少数浮点精度边界问题,
* 例如 formatForward("1.005", 2) 可能返回 "1.00" 而非 "1.01"。
*/
export function formatForward(numStr, precision) {
// 调用 prepareData 对输入做预处理,返回三个字段:
// integerPart —— 整数部分字符串,例如 "1234567"
// isNegative —— 布尔值,表示原始数字是否为负数
// decimalPart —— 小数部分字符串(含小数点),例如 ".89";无小数时为 ""
const { integerPart, isNegative, decimalPart } = prepareData(
numStr,
precision,
);
// result:用于拼接最终格式化结果的字符串,初始为空
let result = '';
// 获取整数部分的字符总长度,用于控制循环次数及计算剩余位数
// 例如 integerPart = "1234567",len = 7
const len = integerPart.length;
// 从整数部分的第一个字符(最高位)向最后一个字符(个位)逐位遍历
// 例如 "1234567" 的索引从 0 到 6,依次取到 '1', '2', '3', '4', '5', '6', '7'
for (let i = 0; i < len; i++) {
// 将当前位置的数字字符追加到结果字符串
// 例如:i=0 时追加 '1',i=1 时追加 '2',以此类推
result += integerPart[i];
// 计算当前字符之后还剩余多少个数字字符(不含当前字符)
// 公式:len - 1 是最后一个字符的索引,再减去 i 就是当前字符后面的字符数量
// 例如 len=7,i=0 时 remainingLen=6,i=1 时 remainingLen=5,...,i=6 时 remainingLen=0
const remainingLen = len - 1 - i;
// 判断是否需要在当前字符后面插入逗号:
// 条件一:remainingLen > 0,即当前字符后面还有字符(最后一位个位不插入逗号)
// 条件二:remainingLen % 3 === 0,即后面剩余的字符数恰好是 3 的倍数,
// 这意味着当前字符正好是某个"千位分组"的最高位
// 例如 len=7:
// i=0,remainingLen=6,6%3===0 → 插入逗号('1' 后面是 6 位,分成两组)
// i=1,remainingLen=5,5%3!==0 → 不插入
// i=2,remainingLen=4,4%3!==0 → 不插入
// i=3,remainingLen=3,3%3===0 → 插入逗号('4' 后面是 3 位,分成一组)
// i=4~6,remainingLen=2,1,0 → 不插入
// 最终结果:"1,234,567"
if (remainingLen > 0 && remainingLen % 3 === 0) {
// 插入千位分隔符逗号
result += ',';
}
}
// 拼接最终结果:
// 1. 若原始数字为负数则在最前面加上 '-',否则加空字符串(即不加符号)
// 2. 拼上已插入逗号的整数部分字符串 result
// 3. 拼上小数部分(已包含 '.',例如 ".89");若无小数则 decimalPart 为 ""
return (isNegative ? '-' : '') + result + decimalPart;
}
// --- 测试 ---
console.log(formatForward('1.2345', 2)); // "1.23"
console.log(formatForward('1234567890.12345678905', 10)); // "1,234,567,890.1234567891"
console.log(formatForward('0.999', 2));
// 从同目录下的 prepare-data.js 文件中引入 prepareData 函数
// prepareData 负责:去除负号、四舍五入、拆分整数部分和小数部分
import { prepareData } from './prepare-data.js';
/**
* 将数字字符串格式化为带千位分隔符的字符串
* 算法思路:从整数部分的最后一位(个位)向前遍历,每累计 3 位就插入一个逗号,
* 最终将收集到的字符数组反转,拼上符号和小数部分。
*
* @param {number|string} numStr - 输入数字,例如 "1234567.89" 或 -42
* @param {number} precision - 保留的小数位数,例如 2 表示保留两位小数
* @returns {string} 格式化后的字符串,例如 "1,234,567.89"
*
* 示例:
* formatBackward("1234567.89", 2) => "1,234,567.89"
* formatBackward("-9876543.1", 1) => "-9,876,543.1"
* formatBackward("0.999", 2) => "1.00"
*/
export function formatBackward(numStr, precision) {
// 调用 prepareData 对输入做预处理,返回三个字段:
// integerPart —— 整数部分字符串,例如 "1234567"
// isNegative —— 布尔值,表示原始数字是否为负数
// decimalPart —— 小数部分字符串(含小数点),例如 ".89";无小数时为 ""
const { integerPart, isNegative, decimalPart } = prepareData(
numStr,
precision,
);
// result:用于收集处理后的字符(数字字符和逗号),最终会被反转拼接成字符串
// count:记录已经放入 result 的"数字字符"个数,用于判断何时插入逗号
let result = [],
count = 0;
// 获取整数部分的字符总长度,用于控制循环的起始位置
// 例如 integerPart = "1234567",len = 7
const len = integerPart.length;
// 从整数部分的最后一个字符(个位)向前遍历到第一个字符(最高位)
// 例如 "1234567" 的索引从 6 到 0,依次取到 '7', '6', '5', '4', '3', '2', '1'
for (let i = len - 1; i >= 0; i--) {
// 每当已收集的数字字符数量达到 3 的倍数时(且不是第一个字符),
// 说明需要在这里插入一个千位分隔符逗号。
// 例如:count=3 时(已放入 '7','6','5'),此时准备放第4位,先插入 ','
if (count > 0 && count % 3 === 0) {
result.push(','); // 向数组末尾添加逗号
}
// 将当前位置的数字字符加入数组
// 例如:i=6 时取 integerPart[6] = '7',i=5 时取 '6',以此类推
result.push(integerPart[i]);
// 数字字符计数加一,用于下一次循环判断是否需要插入逗号
count++;
}
// 循环结束后 result 中的字符是"从个位到最高位"的倒序,例如 ['7','6','5',',','4','3','2',',','1']
// reverse() 将数组原地反转,变为正序:['1',',','2','3','4',',','5','6','7']
// join('') 将数组元素拼接成字符串(不加任何分隔符):"1,234,567"
// 最后在开头加上负号(若原始数字为负),末尾拼上小数部分
return (isNegative ? '-' : '') + result.reverse().join('') + decimalPart;
}
// --- 测试 ---
console.log(formatBackward('1.2345', 2)); // "1.23"
console.log(formatBackward('1234567890.12345678905', 10)); // "1,234,567,890.1234567891"
console.log(formatBackward('0.999', 2));
// 从同目录下的 prepare-data.js 文件中引入 prepareData 函数
// prepareData 负责:去除负号、四舍五入、拆分整数部分和小数部分
import { prepareData } from "./prepare-data.js";
/**
* 将数字字符串格式化为带千位分隔符的字符串
* 算法思路:利用正则表达式的"向前查找(lookahead)",找到所有满足
* "后面紧跟着个数为 3 的倍数个数字,直到字符串末尾"的数字字符,
* 然后在该字符后面插入逗号,一步完成格式化,无需手动循环。
*
* @param {number|string} numStr - 输入数字,例如 "1234567.89" 或 -42
* @param {number} precision - 保留的小数位数,例如 2 表示保留两位小数
* @returns {string} 格式化后的字符串,例如 "1,234,567.89"
*
* 示例:
* formatRegex("1234567.89", 2) => "1,234,567.89"
* formatRegex("-9876543.1", 1) => "-9,876,543.1"
* formatRegex("0.999", 2) => "1.00"
*
* ⚠️ 注意:底层使用 toFixed 做四舍五入,存在极少数浮点精度边界问题,
* 例如 formatRegex("1.005", 2) 可能返回 "1.00" 而非 "1.01"。
*/
export function formatRegex(numStr, precision){
// 调用 prepareData 对输入做预处理,返回三个字段:
// integerPart —— 整数部分字符串,例如 "1234567"
// isNegative —— 布尔值,表示原始数字是否为负数
// decimalPart —— 小数部分字符串(含小数点),例如 ".89";无小数时为 ""
const { integerPart, isNegative, decimalPart } = prepareData(numStr, precision);
// 使用正则表达式一次性给整数部分插入千位分隔符
//
// 正则解析:/\d(?=(?:\d{3})+$)/g
//
// \d —— 匹配一个数字字符(即我们想在其后面插入逗号的那个字符)
//
// (?= ... ) —— 向前查找(lookahead),断言当前字符的"后面"满足括号内的条件,
// 但不消耗(不占用)字符串位置,仅用来判断,不会出现在匹配结果里
//
// (?:\d{3})+ —— 非捕获组(?: 开头表示不保存匹配结果),匹配"连续的若干组,
// 每组恰好 3 个数字";+ 表示至少出现一次(即至少 3 位)
// 例如:后面有 3 位 → 一组匹配;有 6 位 → 两组匹配;以此类推
//
// $ —— 字符串末尾锚点,确保这些 3 位组一直延伸到字符串结尾,
// 保证从当前字符到末尾的位数恰好是 3 的倍数
//
// g —— 全局标志,使正则对整个字符串反复匹配,找到所有需要插入逗号的位置
//
// 举例说明 integerPart = "1234567"(长度 7):
// 索引 0,字符 '1',后面有 6 位(6 % 3 === 0)→ 匹配,插入逗号 → "1,"
// 索引 1,字符 '2',后面有 5 位(5 % 3 !== 0)→ 不匹配
// 索引 2,字符 '3',后面有 4 位(4 % 3 !== 0)→ 不匹配
// 索引 3,字符 '4',后面有 3 位(3 % 3 === 0)→ 匹配,插入逗号 → "4,"
// 索引 4~6,字符 '5','6','7',后面分别剩 2,1,0 位 → 均不匹配
// 最终结果:"1,234,567" ✅
//
// replace 的第二个参数 '$&,':
// $& 是正则内置的替换占位符,代表"本次被匹配到的字符串"(即那个 \d 匹配到的数字字符)
// '$&,' 表示:用"原字符 + 逗号"替换原字符,等效于在该字符后面插入一个逗号
// 例如匹配到 '1',替换后变为 '1,'
const formatInteger = integerPart.replace(/\d(?=(?:\d{3})+$)/g, '$&,');
// 拼接最终结果:
// 1. 若原始数字为负数则在最前面加上 '-',否则加空字符串(即不加符号)
// 2. 拼上已插入逗号的整数部分字符串 formatInteger
// 3. 拼上小数部分(已包含 '.',例如 ".89");若无小数则 decimalPart 为 ""
return (isNegative ? '-' : '') + formatInteger + decimalPart;
}
// --- 测试 ---
console.log(formatRegex('1.2345', 2)); // "1.23"
console.log(formatRegex('1234567890.12345678905', 10)); // "1,234,567,890.1234567891"
console.log(formatRegex('0.999', 2));function formatIntl(numStr, precision) {
// 注意:Intl 必须接收数字或 BigInt
// 如果是超大浮点数,此方法会丢失精度
const n = parseFloat(numStr);
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: precision,
maximumFractionDigits: precision
}).format(n);
}/**
* 预处理数据:清洗,大数四舍五入,拆分
* @param {number|string} numStr - 输入的数字或数字字符串,例如 "1234.5678" 或 -42
* @param {number} precision - 保留的小数位数,例如 2 表示保留两位小数
*/
export function prepareData(numStr, precision){
// 将传入的值统一转成字符串,并去掉首尾空格,防止意外的空格导致解析错误
let str = String(numStr).trim();
// 判断字符串是否以 '-' 开头,从而确定该数字是否为负数
const isNegative = str.startsWith('-');
if(isNegative){
// 如果是负数,去掉开头的负号,后续只对纯数字部分进行处理
str = str.slice(1);
}
// 将字符串转为数字,再用 toFixed 按指定精度做四舍五入,结果仍是字符串
// 例如:Number("1234.5678").toFixed(2) => "1234.57"
//
// ⚠️ toFixed 的已知缺陷(浮点精度问题):
// 由于 IEEE 754 浮点数在二进制下无法精确表示某些十进制小数,
// toFixed 在部分环境中对"恰好在中间"的数四舍五入结果不可靠。
// 例如:(1.005).toFixed(2) 在 V8 中返回 "1.00",而非期望的 "1.01"
// (1.255).toFixed(2) 同样可能返回 "1.25" 而非 "1.26"
//
// ✅ 改善方案:
// 方案一(字符串偏移法,轻量):在转换前对原始字符串末尾补一个极小量,
// 规避"恰好在中间"的浮点误差:
// Number(str + "e1").toFixed(precision + 1).slice(0, -1) —— 仅适合简单场景
//
// 方案二(Math.round 乘法,常用):
// (Math.round(Number(str) * 10**precision) / 10**precision).toFixed(precision)
// 原理:先放大到整数再 round,避开小数阶段的浮点误差;但极大数仍可能溢出
//
// 方案三(BigInt 整数法,与方案二思路相同但彻底避免浮点):
// 与方案二的"放大→取整→缩回"思路一致,区别在于全程使用字符串解析 + BigInt
// 运算,不经过浮点数,因此不受 IEEE 754 精度限制,也不会因数字过大而溢出。
// 实现思路:
// 1. 将字符串按小数点拆分为整数部分和小数部分
// 2. 把小数部分填充/截断至 precision+1 位,与整数部分拼接成一个纯整数字符串
// 3. 转为 BigInt,加 5n 后除以 10n(等价于对第 precision+1 位做四舍五入)
// 4. 把结果字符串重新插入小数点
// 例如对 "1.005" precision=2:
// 整数部分 "1",小数部分补齐3位 "005" => BigInt("1005")
// (1005n + 5n) / 10n = 100n => "1.00" ✅ 结果正确,无浮点误差
//
// 方案四(第三方库,生产推荐):
// 使用 decimal.js / big.js 等任意精度库,可完全避免浮点问题:
// import Decimal from 'decimal.js';
// new Decimal(str).toFixed(precision)
const fixed = Number(str).toFixed(precision);
// 以小数点为分隔符拆分字符串,得到整数部分和小数部分
// 例如:"1234.57".split('.') => ["1234", "57"]
// 如果没有小数点(precision 为 0),decimalPart 会是 undefined
const [integerPart, decimalPart] = fixed.split('.');
return {
isNegative, // 是否为负数,布尔值
integerPart, // 整数部分字符串,例如 "1234"
// 如果有小数部分,则在前面拼上 '.',方便后续格式化;否则返回空字符串
decimalPart: decimalPart ? `.${decimalPart}` : "",
};
}