格式化工具 (format.ts)
格式化工具函数集合,提供全面的数据格式化功能,包含数值、时间、隐私数据、文本、集合、状态等各种类型的格式化处理。
📖 概述
格式化工具库包含以下功能类别:
- 数值格式化:处理单位、数字、金额、百分比和单位
- 时间与持续时间:处理时长的格式化
- 隐私和敏感数据处理:处理敏感信息的格式化和脱敏
- 文本和URL处理:处理字符串截断和格式化
- 集合和枚举处理:处理列表、枚举和布尔值的格式化
- 状态与颜色:根据状态值返回对应的颜色类名
- 表格数据格式化:为表格单元格提供通用的格式化功能
🔢 数值格式化
formatUnit
格式化CSS单位值,如果值是数字,自动添加px单位。
formatUnit(
val: number | string,
defaultUnit: string = 'px',
options?: FormatOptions
): string参数:
val- 要格式化的值defaultUnit- 默认单位,默认为 'px'options- 格式化选项
返回值:
string- 格式化后的CSS单位值
示例:
formatUnit(100) // "100px"
formatUnit('100') // "100px"
formatUnit('100%') // "100%" (已有单位,保持不变)
formatUnit('auto') // "auto" (非数字,保持不变)
formatUnit(100, 'rem') // "100rem"
formatUnit(0) // "0px"formatNumber
格式化数字,支持小数位数控制和千分位分隔符。
formatNumber(
value: number,
decimals: number = 0,
useThousandsSeparator: boolean = false,
thousandsSeparator: string = ',',
options?: FormatOptions
): string参数:
value- 数值decimals- 小数位数,默认为0useThousandsSeparator- 是否使用千分位分隔符,默认为falsethousandsSeparator- 千分位分隔符,默认为 ','options- 格式化选项
返回值:
string- 格式化后的数字
示例:
formatNumber(1234.56) // "1235" (四舍五入)
formatNumber(1234.56, 2) // "1234.56"
formatNumber(1234.56, 2, true) // "1,234.56"
formatNumber(1234567.89, 2, true) // "1,234,567.89"
formatNumber(-1234.56, 2, true) // "-1,234.56"formatPercent
格式化百分比,将小数转换为百分比格式。
formatPercent(
value: number,
decimals: number = 2,
withSymbol: boolean = true,
options?: FormatOptions
): string参数:
value- 小数值(如 0.1234 表示 12.34%)decimals- 小数位数,默认为2withSymbol- 是否包含百分号,默认为trueoptions- 格式化选项
返回值:
string- 格式化后的百分比
示例:
formatPercent(0.1234) // "12.34%"
formatPercent(0.1234, 1) // "12.3%"
formatPercent(0.1234, 1, false) // "12.3"
formatPercent(1.5) // "150.00%"
formatPercent(0) // "0.00%"formatAmount
格式化金额,支持自定义货币符号和分隔符。
formatAmount(
amount: number,
decimals: number = 2,
decimalSeparator: string = '.',
thousandsSeparator: string = ',',
prefix: string = '',
suffix: string = '',
options?: FormatOptions
): string参数:
amount- 金额数值decimals- 小数位数,默认为2decimalSeparator- 小数点分隔符,默认为 '.'thousandsSeparator- 千分位分隔符,默认为 ','prefix- 前缀(如货币符号),默认为空suffix- 后缀,默认为空options- 格式化选项
返回值:
string- 格式化后的金额字符串
示例:
formatAmount(1234.56) // "1,234.56"
formatAmount(1234.56, 2, '.', ',', '¥') // "¥1,234.56"
formatAmount(1234.56, 0, '.', ',', '$') // "$1,235"
formatAmount(-1234.56, 2, '.', ',', '¥') // "¥-1,234.56"
formatAmount(1234.56, 2, ',', '.', '€', ' EUR') // "€1.234,56 EUR"formatCurrency
通用货币格式化,支持不同区域的货币格式。
formatCurrency(
amount: number,
currencyCode: string = 'CNY',
options?: CurrencyFormatOptions
): string参数:
amount- 金额currencyCode- 货币代码(ISO 4217),默认为 'CNY'options- 货币格式化选项
货币格式化选项:
interface CurrencyFormatOptions {
decimals?: number // 小数位数,默认为2
currencySymbol?: string // 货币符号
symbolPosition?: 'prefix' | 'suffix' // 符号位置,默认为 'prefix'
decimalSeparator?: string // 小数点分隔符,默认为 '.'
thousandsSeparator?: string // 千分位分隔符,默认为 ','
}支持的货币代码:
CNY- 人民币 (¥)USD- 美元 ($)EUR- 欧元 (€)GBP- 英镑 (£)JPY- 日元 (¥)KRW- 韩元 (₩)RUB- 卢布 (₽)
示例:
formatCurrency(1234.56) // "¥1,234.56" (默认人民币)
formatCurrency(1234.56, 'USD') // "$1,234.56"
formatCurrency(1234.56, 'EUR') // "€1,234.56"
formatCurrency(1234.56, 'GBP') // "£1,234.56"
// 自定义选项
formatCurrency(1234.56, 'CNY', {
decimals: 0,
symbolPosition: 'suffix'
}) // "1,235¥"formatFileSize
格式化文件大小,将字节数转换为易读的文件大小格式。
formatFileSize(bytes: number, decimals: number = 2, options?: FormatOptions): string参数:
bytes- 文件大小(字节)decimals- 小数位数,默认为2options- 格式化选项
返回值:
string- 格式化后的文件大小
示例:
formatFileSize(0) // "0 Bytes"
formatFileSize(1024) // "1.00 KB"
formatFileSize(1234567) // "1.18 MB"
formatFileSize(1073741824) // "1.00 GB"
formatFileSize(1099511627776, 1) // "1.0 TB"formatDistance
格式化距离单位,根据米数自动选择合适的单位。
formatDistance(meters: number, decimals: number = 1, options?: FormatOptions): string参数:
meters- 米数decimals- 公里小数位数,默认为1options- 格式化选项
返回值:
string- 格式化后的距离
示例:
formatDistance(500) // "500米"
formatDistance(999) // "999米"
formatDistance(1000) // "1.0公里"
formatDistance(1500) // "1.5公里"
formatDistance(1234) // "1.2公里"
formatDistance(1234, 2) // "1.23公里"⏱️ 时间与持续时间
formatDuration
格式化时长,将秒数转换为时分秒格式。
formatDuration(
seconds: number,
showZeroHours: boolean = false,
options?: FormatOptions
): string参数:
seconds- 秒数showZeroHours- 是否显示为0的小时,默认为falseoptions- 格式化选项
返回值:
string- 格式化后的时长
示例:
formatDuration(61) // "1分1秒"
formatDuration(3661) // "1小时1分1秒"
formatDuration(7320) // "2小时2分0秒"
formatDuration(61, true) // "0小时1分1秒" (显示零小时)
formatDuration(3600) // "1小时0分0秒"🔒 隐私和敏感数据处理
formatPrivacy
格式化敏感数据,提供隐私保护。
formatPrivacy(
data: string,
options?: PrivacyOptions,
formatOptions?: FormatOptions
): string隐私选项:
interface PrivacyOptions {
showStart?: number // 显示开头字符数,默认为0
showEnd?: number // 显示结尾字符数,默认为0
maskChar?: string // 掩码字符,默认为'*'
maskLength?: number // 掩码字符重复次数,0表示根据原始长度自动计算
}示例:
formatPrivacy('13812345678', { showStart: 3, showEnd: 4 }) // "138****5678"
formatPrivacy('张三丰', { showStart: 1, showEnd: 0 }) // "张**"
formatPrivacy('admin@example.com', {
showStart: 2,
showEnd: 10,
maskChar: '-'
}) // "ad----ample.com"formatIDCard
格式化身份证号,提供隐私保护。
formatIDCard(
idNumber: string,
options?: PrivacyOptions,
formatOptions?: FormatOptions
): string示例:
formatIDCard('110101199001011234') // "1101**********1234" (默认显示前4位后4位)
formatIDCard('110101199001011234', { showStart: 6, showEnd: 4 }) // "110101********1234"formatPhone
格式化手机号,支持多种格式化方式。
formatPhone(
phone: string,
format: string = 'xxx-xxxx-xxxx',
mask: string = '*',
privacy: boolean = false,
options?: FormatOptions
): string参数:
phone- 手机号码format- 格式化模式,x将被替换为数字,默认为 'xxx-xxxx-xxxx'mask- 掩码字符,默认为 '*'privacy- 是否启用隐私保护(中间四位使用掩码),默认为falseoptions- 格式化选项
示例:
formatPhone('13812345678') // "138-1234-5678"
formatPhone('13812345678', 'xxx xxxx xxxx') // "138 1234 5678"
formatPhone('13812345678', undefined, undefined, true) // "138****5678"
formatPhone('13812345678', '(xxx) xxxx-xxxx') // "(138) 1234-5678"formatBankCard
格式化银行卡号,每4位插入一个空格。
formatBankCard(
cardNumber: string,
separator: string = ' ',
privacy: boolean = false,
options?: FormatOptions
): string参数:
cardNumber- 银行卡号separator- 分隔符,默认为 ' 'privacy- 是否启用隐私保护(只显示后四位),默认为falseoptions- 格式化选项
示例:
formatBankCard('6225365271562822') // "6225 3652 7156 2822"
formatBankCard('6225365271562822', '-') // "6225-3652-7156-2822"
formatBankCard('6225365271562822', ' ', true) // "**** **** **** 2822"formatIP
格式化IP地址,支持隐私保护。
formatIP(
ip: string,
options?: IPFormatOptions,
formatOptions?: FormatOptions
): stringIP格式化选项:
interface IPFormatOptions {
privacy?: boolean // 是否隐藏部分IP,默认为false
maskChar?: string // 掩码字符,默认为'*'
}示例:
formatIP('192.168.1.1') // "192.168.1.1"
formatIP('192.168.1.1', { privacy: true }) // "192.168.*.*"
formatIP('192.168.1.1', { privacy: true, maskChar: 'x' }) // "192.168.x.x"📝 文本和URL处理
formatStringLength
格式化字符串长度,超出指定长度的部分用省略号代替。
formatStringLength(
str: string,
options?: StringLengthOptions,
formatOptions?: FormatOptions
): string字符串长度选项:
interface StringLengthOptions {
maxLength?: number // 最大长度,默认为20
ellipsis?: string // 省略号字符,默认为'...'
position?: 'start' | 'middle' | 'end' // 截取位置,默认为'end'
}示例:
formatStringLength('这是一个很长的字符串', { maxLength: 10 })
// "这是一个很长..."
formatStringLength('这是一个很长的字符串', {
maxLength: 10,
position: 'middle'
})
// "这是一个...字符串"
formatStringLength('这是一个很长的字符串', {
maxLength: 10,
position: 'start'
})
// "...很长的字符串"formatFileName
格式化文件名,保留扩展名。
formatFileName(
filename: string,
maxLength: number = 20,
ellipsis: string = '...',
options?: FormatOptions
): string参数:
filename- 文件名maxLength- 最大长度,默认为20ellipsis- 省略号字符,默认为 '...'options- 格式化选项
示例:
formatFileName('very_long_filename_example.txt', 20) // "very_long_f...ple.txt"
formatFileName('document.pdf', 20) // "document.pdf"
formatFileName('超长的文件名称示例.docx', 15) // "超长的文...例.docx"formatURL
格式化URL,可选择显示或隐藏参数。
formatURL(url: string, showParams: boolean = true, options?: FormatOptions): string参数:
url- URL地址showParams- 是否显示参数,默认为trueoptions- 格式化选项
示例:
formatURL('https://example.com/path?query=123') // "https://example.com/path?query=123"
formatURL('https://example.com/path?query=123', false) // "https://example.com/path"📋 集合和枚举处理
formatList
格式化列表数据为分隔符连接的字符串。
formatList(
list: any[],
field?: string | ((item: any) => any),
options?: ListFormatOptions,
formatOptions?: FormatOptions
): string列表格式化选项:
interface ListFormatOptions {
separator?: string // 分隔符,默认为','
maxItems?: number // 最大显示项数,默认为0(不限制)
ellipsis?: string // 省略提示文本,默认为'...'
emptyText?: string // 空列表显示文本,默认为''
}示例:
// 基本用法
formatList([1, 2, 3]) // "1,2,3"
// 提取对象属性
formatList([{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}], 'name') // "Alice,Bob"
// 限制显示数量
formatList([1, 2, 3, 4, 5], null, { maxItems: 3, ellipsis: '等' }) // "1,2,3等"
// 使用函数转换
formatList([1, 2, 3], item => `项目${item}`) // "项目1,项目2,项目3"
// 自定义分隔符
formatList(['A', 'B', 'C'], null, { separator: ' | ' }) // "A | B | C"formatEnum
格式化枚举值为显示文本。
formatEnum(
value: any,
enumMap: Record<string | number, string>,
defaultText: string = ''
): string参数:
value- 枚举值enumMap- 枚举映射对象defaultText- 默认文本,当映射不存在时返回
示例:
const statusMap = { 0: '禁用', 1: '启用', 2: '待审核' }
formatEnum(1, statusMap) // "启用"
formatEnum('PENDING', { PENDING: '待处理', SUCCESS: '成功' }) // "待处理"
formatEnum(99, statusMap, '未知') // "未知"formatBoolean
格式化布尔值。
formatBoolean(
value: any,
trueText: string = '是',
falseText: string = '否'
): string参数:
value- 布尔值trueText- 布尔值为true时的显示文本,默认为 '是'falseText- 布尔值为false时的显示文本,默认为 '否'
示例:
formatBoolean(true) // "是"
formatBoolean(false) // "否"
formatBoolean(false, '启用', '禁用') // "禁用"
formatBoolean('true') // "是" (字符串也支持)
formatBoolean('false') // "否"
formatBoolean(1, '开', '关') // "开"
formatBoolean(0, '开', '关') // "关"🎨 状态与颜色
formatStatusColor
根据状态值返回对应的颜色类名。
formatStatusColor(
status: number | string,
customColorMap?: StatusColorMap
): StatusColorType状态颜色类型:
type StatusColorType = 'primary' | 'success' | 'info' | 'warning' | 'danger' | 'default'默认状态颜色映射:
'0'- info(默认或待处理)'1'- success(成功或正常)'2'- warning(警告或异常)'3'- danger(错误或禁用)
示例:
formatStatusColor(1) // "success"
formatStatusColor('2') // "warning"
formatStatusColor(3) // "danger"
// 自定义颜色映射
const customMap = { 'error': 'danger', 'ok': 'success' }
formatStatusColor('error', customMap) // "danger"📊 表格数据格式化
formatTableCell
为表格单元格提供格式化函数,支持多种数据类型格式化。
formatTableCell(
value: any,
type: 'text' | 'number' | 'amount' | 'percent' | 'date' | 'datetime' | 'boolean' | 'enum',
options?: Record<string, any>
): string参数:
value- 单元格值type- 数据类型options- 格式化选项,根据type不同而不同
示例:
// 金额格式化
formatTableCell(1234.56, 'amount', { decimals: 2, prefix: '¥' }) // "¥1,234.56"
// 百分比格式化
formatTableCell(0.1234, 'percent', { decimals: 1 }) // "12.3%"
// 日期格式化
formatTableCell('2023-01-01', 'date') // "2023-01-01"
// 枚举格式化
formatTableCell(1, 'enum', { enumMap: { 0: '禁用', 1: '启用' } }) // "启用"
// 布尔值格式化
formatTableCell(true, 'boolean', { trueText: '是', falseText: '否' }) // "是"💡 使用技巧
1. 表格列配置
在Vue表格中统一使用格式化函数:
const columns = [
{
prop: 'amount',
label: '金额',
formatter: (row) => formatTableCell(row.amount, 'amount', { prefix: '¥', decimals: 2 })
},
{
prop: 'status',
label: '状态',
formatter: (row) => formatTableCell(row.status, 'enum', { enumMap: statusMap })
},
{
prop: 'progress',
label: '进度',
formatter: (row) => formatTableCell(row.progress, 'percent', { decimals: 1 })
}
]2. 敏感信息展示
在用户信息展示中保护隐私:
// 用户信息组件
const userInfo = {
phone: '13812345678',
email: 'user@example.com',
idCard: '110101199001011234'
}
// 显示处理
const displayInfo = {
phone: formatPhone(userInfo.phone, undefined, undefined, true), // "138****5678"
email: formatPrivacy(userInfo.email, { showStart: 3, showEnd: 8 }), // "use****mple.com"
idCard: formatIDCard(userInfo.idCard) // "1101**********1234"
}3. 金额显示统一
在财务系统中统一金额格式:
// 配置货币格式
const formatMoney = (amount, currency = 'CNY') => {
return formatCurrency(amount, currency, { decimals: 2 })
}
// 使用
formatMoney(1234.56) // "¥1,234.56"
formatMoney(1234.56, 'USD') // "$1,234.56"4. 文件大小显示
在文件管理系统中:
const files = [
{ name: 'document.pdf', size: 1024000 },
{ name: 'image.jpg', size: 2048000 },
{ name: 'video.mp4', size: 104857600 }
]
files.forEach(file => {
console.log(`${file.name}: ${formatFileSize(file.size)}`)
})
// document.pdf: 1000.00 KB
// image.jpg: 1.95 MB
// video.mp4: 100.00 MB5. 数据列表格式化
在数据展示中优雅处理列表:
const tags = ['JavaScript', 'Vue', 'TypeScript', 'Node.js', 'React']
// 限制显示数量
formatList(tags, null, { maxItems: 3, separator: ' · ', ellipsis: '...' })
// "JavaScript · Vue · TypeScript..."
// 提取对象字段
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
]
formatList(users, 'name', { separator: ', ' }) // "Alice, Bob, Charlie"⚠️ 注意事项
- 数值精度:浮点数计算可能有精度问题,重要场景请使用专门的数值库
- 格式化选项:
strict模式会在无效输入时返回默认值,非严格模式保持原值 - 性能考虑:频繁格式化大量数据时考虑缓存结果
- 国际化:部分格式化函数使用中文文本,国际化项目需要适配
- 类型安全:在TypeScript中注意类型匹配,避免运行时错误
❓ 常见问题
1. 金额格式化出现浮点数精度问题
问题描述:
使用 formatAmount 或 formatCurrency 格式化金额时,出现精度丢失问题,导致显示的金额与预期不符。
// 期望结果 "¥1,234.56",实际可能出现 "¥1,234.5599999999999"
formatAmount(1234.56, 2)
// 更明显的例子
const result = 0.1 + 0.2 // 0.30000000000000004
formatAmount(result, 2) // "0.30" 还是 "0.30000000000000"?问题原因:
- JavaScript 浮点数使用 IEEE 754 双精度格式,存在固有精度问题
- 金融计算中的乘法、除法、累加操作会放大精度误差
toFixed()方法在某些边界情况下的舍入行为不一致- 后端返回的金额可能已经包含精度问题
解决方案:
// ❌ 错误:直接使用浮点数计算
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
const formatted = formatAmount(total, 2) // 可能出现精度问题
// ✅ 正确:使用整数计算(分为单位)避免精度问题
class MoneyFormatter {
/**
* 安全的金额格式化(内部使用分为单位)
*/
static formatSafe(
amountInCents: number,
options?: {
decimals?: number
prefix?: string
suffix?: string
}
): string {
const { decimals = 2, prefix = '¥', suffix = '' } = options || {}
// 确保是整数
const cents = Math.round(amountInCents)
const yuan = cents / 100
// 使用 Intl.NumberFormat 确保正确的格式化
const formatter = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
})
return `${prefix}${formatter.format(yuan)}${suffix}`
}
/**
* 元转分(安全转换)
*/
static yuanToCents(yuan: number): number {
// 先转换为字符串再处理,避免浮点数精度问题
const str = String(yuan)
const parts = str.split('.')
if (parts.length === 1) {
return parseInt(parts[0]) * 100
}
const integerPart = parseInt(parts[0]) * 100
const decimalPart = parts[1].padEnd(2, '0').substring(0, 2)
const sign = yuan < 0 ? -1 : 1
return sign * (Math.abs(integerPart) + parseInt(decimalPart))
}
/**
* 安全的金额计算(避免精度问题)
*/
static safeAdd(...amounts: number[]): number {
// 先全部转换为分,计算后再转回元
const totalCents = amounts.reduce((sum, amount) => {
return sum + this.yuanToCents(amount)
}, 0)
return totalCents / 100
}
}
// 使用示例
const items = [
{ price: 10.01, quantity: 3 },
{ price: 20.02, quantity: 2 }
]
// 安全计算总价
const totalCents = items.reduce((sum, item) => {
const priceCents = MoneyFormatter.yuanToCents(item.price)
return sum + priceCents * item.quantity
}, 0)
// 格式化输出
const formatted = MoneyFormatter.formatSafe(totalCents)
console.log(formatted) // "¥70.07"
// 使用 decimal.js 库处理高精度计算
import Decimal from 'decimal.js'
function formatAmountPrecise(amount: string | number, decimals: number = 2): string {
const decimal = new Decimal(amount)
const formatted = decimal.toFixed(decimals)
const parts = formatted.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
formatAmountPrecise('1234.56789', 2) // "¥1,234.57"
formatAmountPrecise(0.1 + 0.2, 2) // "¥0.30"2. 不同国家手机号格式化规则不一致
问题描述:
使用 formatPhone 格式化手机号时,默认格式只适用于中国手机号,对于其他国家的手机号格式化结果不正确。
// 中国手机号格式化正常
formatPhone('13812345678') // "138-1234-5678"
// 美国手机号格式化结果不符合当地习惯
formatPhone('12025551234') // "120-2555-1234"(错误)
// 应该是 "(202) 555-1234"问题原因:
- 不同国家手机号长度不同(美国10位,中国11位,日本11位等)
- 分隔符位置和符号各国规范不同
- 国际号码需要处理国家代码前缀
- 部分号码可能包含空格或特殊字符
解决方案:
// ❌ 错误:使用固定格式处理所有国家手机号
const formatted = formatPhone(phone)
// ✅ 正确:支持多国手机号格式化
interface PhoneFormatRule {
pattern: RegExp
format: string
example: string
}
const phoneFormatRules: Record<string, PhoneFormatRule> = {
CN: {
pattern: /^(\d{3})(\d{4})(\d{4})$/,
format: '$1-$2-$3',
example: '138-1234-5678'
},
US: {
pattern: /^1?(\d{3})(\d{3})(\d{4})$/,
format: '($1) $2-$3',
example: '(202) 555-1234'
},
UK: {
pattern: /^(\d{4})(\d{3})(\d{4})$/,
format: '$1 $2 $3',
example: '0123 456 7890'
},
JP: {
pattern: /^(\d{3})(\d{4})(\d{4})$/,
format: '$1-$2-$3',
example: '090-1234-5678'
},
HK: {
pattern: /^(\d{4})(\d{4})$/,
format: '$1 $2',
example: '9123 4567'
}
}
class InternationalPhoneFormatter {
/**
* 清理手机号中的非数字字符
*/
private static cleanPhone(phone: string): string {
return phone.replace(/\D/g, '')
}
/**
* 检测手机号所属国家
*/
private static detectCountry(phone: string): string {
const cleaned = this.cleanPhone(phone)
// 根据长度和前缀判断
if (cleaned.startsWith('86') && cleaned.length === 13) {
return 'CN'
}
if (cleaned.length === 11 && cleaned.startsWith('1')) {
return 'CN' // 中国手机号以1开头
}
if (cleaned.startsWith('1') && cleaned.length === 11) {
return 'US' // 美国手机号(含国家代码)
}
if (cleaned.length === 10) {
return 'US' // 美国手机号(不含国家代码)
}
if (cleaned.startsWith('44') && cleaned.length === 12) {
return 'UK'
}
if (cleaned.startsWith('81') && cleaned.length === 13) {
return 'JP'
}
if (cleaned.startsWith('852') && cleaned.length === 11) {
return 'HK'
}
return 'CN' // 默认中国
}
/**
* 移除国家代码前缀
*/
private static removeCountryCode(phone: string, country: string): string {
const cleaned = this.cleanPhone(phone)
const countryCodes: Record<string, string> = {
CN: '86',
US: '1',
UK: '44',
JP: '81',
HK: '852'
}
const code = countryCodes[country]
if (code && cleaned.startsWith(code)) {
return cleaned.substring(code.length)
}
return cleaned
}
/**
* 格式化国际手机号
*/
static format(
phone: string,
options?: {
country?: string
privacy?: boolean
includeCountryCode?: boolean
}
): string {
const { country, privacy = false, includeCountryCode = false } = options || {}
const detectedCountry = country || this.detectCountry(phone)
const localPhone = this.removeCountryCode(phone, detectedCountry)
const rule = phoneFormatRules[detectedCountry]
if (!rule) {
return phone // 未知格式,返回原值
}
const match = localPhone.match(rule.pattern)
if (!match) {
return phone // 格式不匹配,返回原值
}
let formatted = localPhone.replace(rule.pattern, rule.format)
// 隐私保护
if (privacy) {
formatted = this.applyPrivacy(formatted, detectedCountry)
}
// 添加国家代码
if (includeCountryCode) {
const countryCodes: Record<string, string> = {
CN: '+86',
US: '+1',
UK: '+44',
JP: '+81',
HK: '+852'
}
formatted = `${countryCodes[detectedCountry]} ${formatted}`
}
return formatted
}
/**
* 应用隐私保护
*/
private static applyPrivacy(formatted: string, country: string): string {
// 根据不同国家的格式,隐藏中间部分
const privacyRules: Record<string, (s: string) => string> = {
CN: (s) => s.replace(/(\d{3})-(\d{4})-(\d{4})/, '$1-****-$3'),
US: (s) => s.replace(/\((\d{3})\) (\d{3})-(\d{4})/, '($1) ***-$3'),
UK: (s) => s.replace(/(\d{4}) (\d{3}) (\d{4})/, '$1 *** $3'),
JP: (s) => s.replace(/(\d{3})-(\d{4})-(\d{4})/, '$1-****-$3'),
HK: (s) => s.replace(/(\d{4}) (\d{4})/, '$1 ****')
}
const rule = privacyRules[country]
return rule ? rule(formatted) : formatted
}
}
// 使用示例
console.log(InternationalPhoneFormatter.format('13812345678'))
// "138-1234-5678"
console.log(InternationalPhoneFormatter.format('2025551234', { country: 'US' }))
// "(202) 555-1234"
console.log(InternationalPhoneFormatter.format('13812345678', { privacy: true }))
// "138-****-5678"
console.log(InternationalPhoneFormatter.format('13812345678', { includeCountryCode: true }))
// "+86 138-1234-5678"3. 大数据量表格格式化导致性能问题
问题描述:
在表格中对大量数据进行格式化时,页面出现明显卡顿,滚动不流畅,用户体验变差。
// 对10000行数据进行格式化
const formattedData = data.map(row => ({
...row,
amount: formatAmount(row.amount, 2, '.', ',', '¥'),
phone: formatPhone(row.phone, undefined, undefined, true),
status: formatEnum(row.status, statusMap),
createTime: formatDate(row.createTime)
}))问题原因:
- 同步格式化大量数据阻塞主线程
- 每次渲染都重新执行格式化计算
- 格式化函数内部创建临时对象增加 GC 压力
- 复杂的正则表达式匹配消耗 CPU 时间
- 虚拟滚动未正确实现导致全量渲染
解决方案:
// ❌ 错误:在渲染时同步格式化所有数据
const TableComponent = () => {
const formatted = data.map(row => ({
amount: formatAmount(row.amount)
}))
return <Table data={formatted} />
}
// ✅ 正确:使用多种优化策略
import { ref, computed, shallowRef } from 'vue'
// 1. 格式化结果缓存
class FormatCache {
private cache: Map<string, string> = new Map()
private maxSize: number
constructor(maxSize: number = 10000) {
this.maxSize = maxSize
}
private generateKey(type: string, value: unknown, options: unknown): string {
return `${type}:${JSON.stringify(value)}:${JSON.stringify(options)}`
}
get(type: string, value: unknown, options: unknown): string | undefined {
const key = this.generateKey(type, value, options)
return this.cache.get(key)
}
set(type: string, value: unknown, options: unknown, result: string): void {
const key = this.generateKey(type, value, options)
// LRU 淘汰策略
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value
if (firstKey) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, result)
}
clear(): void {
this.cache.clear()
}
}
const formatCache = new FormatCache()
// 2. 带缓存的格式化函数
function cachedFormatAmount(amount: number, options?: Record<string, unknown>): string {
const cached = formatCache.get('amount', amount, options)
if (cached) return cached
const result = formatAmount(
amount,
(options?.decimals as number) ?? 2,
(options?.decimalSeparator as string) ?? '.',
(options?.thousandsSeparator as string) ?? ',',
(options?.prefix as string) ?? ''
)
formatCache.set('amount', amount, options, result)
return result
}
// 3. 虚拟滚动 + 懒格式化
export function useVirtualTableFormatter<T extends Record<string, unknown>>(
rawData: Ref<T[]>,
formatConfig: Record<string, (value: unknown) => string>
) {
// 只格式化可见区域的数据
const visibleRange = ref({ start: 0, end: 50 })
const formattedCache = shallowRef<Map<number, Record<string, string>>>(new Map())
const visibleData = computed(() => {
const result: Array<T & { _formatted: Record<string, string> }> = []
const { start, end } = visibleRange.value
for (let i = start; i < end && i < rawData.value.length; i++) {
const row = rawData.value[i]
let formatted = formattedCache.value.get(i)
if (!formatted) {
formatted = {}
for (const [field, formatter] of Object.entries(formatConfig)) {
if (field in row) {
formatted[field] = formatter(row[field])
}
}
formattedCache.value.set(i, formatted)
}
result.push({ ...row, _formatted: formatted })
}
return result
})
const updateVisibleRange = (start: number, end: number) => {
visibleRange.value = { start, end }
}
const clearCache = () => {
formattedCache.value.clear()
}
return {
visibleData,
updateVisibleRange,
clearCache
}
}
// 4. Web Worker 后台格式化
const formatWorker = new Worker(new URL('./format.worker.ts', import.meta.url))
interface FormatTask {
id: string
data: unknown[]
config: Record<string, string>
}
interface FormatResult {
id: string
formatted: unknown[]
}
class BackgroundFormatter {
private pending: Map<string, (result: unknown[]) => void> = new Map()
constructor() {
formatWorker.onmessage = (event: MessageEvent<FormatResult>) => {
const { id, formatted } = event.data
const resolve = this.pending.get(id)
if (resolve) {
resolve(formatted)
this.pending.delete(id)
}
}
}
format(data: unknown[], config: Record<string, string>): Promise<unknown[]> {
return new Promise(resolve => {
const id = Math.random().toString(36).substring(7)
this.pending.set(id, resolve)
const task: FormatTask = { id, data, config }
formatWorker.postMessage(task)
})
}
}
// format.worker.ts
// self.onmessage = (event: MessageEvent<FormatTask>) => {
// const { id, data, config } = event.data
// const formatted = data.map(row => {
// const result = { ...row }
// for (const [field, type] of Object.entries(config)) {
// result[`${field}_formatted`] = formatByType(row[field], type)
// }
// return result
// })
// self.postMessage({ id, formatted })
// }
// 5. 使用 requestIdleCallback 分批处理
function formatInBatches<T>(
data: T[],
formatter: (item: T) => T,
batchSize: number = 100
): Promise<T[]> {
return new Promise(resolve => {
const result: T[] = []
let index = 0
const processBatch = (deadline: IdleDeadline) => {
while (index < data.length && deadline.timeRemaining() > 0) {
const batchEnd = Math.min(index + batchSize, data.length)
for (let i = index; i < batchEnd; i++) {
result.push(formatter(data[i]))
}
index = batchEnd
}
if (index < data.length) {
requestIdleCallback(processBatch)
} else {
resolve(result)
}
}
requestIdleCallback(processBatch)
})
}
// 使用示例
const { visibleData, updateVisibleRange } = useVirtualTableFormatter(
tableData,
{
amount: (v) => cachedFormatAmount(v as number, { prefix: '¥' }),
phone: (v) => formatPhone(v as string, undefined, undefined, true),
status: (v) => formatEnum(v, statusMap)
}
)4. 枚举格式化在多语言环境下失效
问题描述:
使用 formatEnum 格式化枚举值时,在切换语言后枚举显示文本没有更新,或者不同语言下显示混乱。
const statusMap = { 0: '禁用', 1: '启用' }
// 切换到英文后,依然显示中文
formatEnum(1, statusMap) // "启用"(期望显示 "Enabled")问题原因:
- 枚举映射表是静态定义的,不支持动态切换
- 未与 i18n 国际化系统集成
- 缓存的格式化结果未在语言切换时清除
- 枚举值来源分散,难以统一管理
解决方案:
// ❌ 错误:静态定义枚举映射
const statusMap = { 0: '禁用', 1: '启用' }
formatEnum(status, statusMap)
// ✅ 正确:与 i18n 集成的动态枚举格式化
import { useI18n } from 'vue-i18n'
import { computed, watch, ref } from 'vue'
// 枚举定义(使用 i18n key)
interface EnumDefinition {
value: string | number
labelKey: string
color?: string
icon?: string
}
const statusEnumDef: EnumDefinition[] = [
{ value: 0, labelKey: 'enum.status.disabled', color: 'danger' },
{ value: 1, labelKey: 'enum.status.enabled', color: 'success' },
{ value: 2, labelKey: 'enum.status.pending', color: 'warning' }
]
// i18n 消息
// zh-CN.json
// {
// "enum": {
// "status": {
// "disabled": "禁用",
// "enabled": "启用",
// "pending": "待审核"
// }
// }
// }
// en-US.json
// {
// "enum": {
// "status": {
// "disabled": "Disabled",
// "enabled": "Enabled",
// "pending": "Pending"
// }
// }
// }
// 动态枚举格式化 Composable
export function useEnumFormatter() {
const { t, locale } = useI18n()
// 缓存,按语言区分
const cache = ref<Map<string, Map<string, Record<string | number, string>>>>(new Map())
// 语言切换时清除缓存
watch(locale, () => {
cache.value.clear()
})
/**
* 构建枚举映射表
*/
const buildEnumMap = (
enumDef: EnumDefinition[],
cacheKey: string
): Record<string | number, string> => {
const currentLocale = locale.value
let localeCache = cache.value.get(currentLocale)
if (!localeCache) {
localeCache = new Map()
cache.value.set(currentLocale, localeCache)
}
let enumMap = localeCache.get(cacheKey)
if (enumMap) {
return enumMap
}
enumMap = {}
for (const item of enumDef) {
enumMap[item.value] = t(item.labelKey)
}
localeCache.set(cacheKey, enumMap)
return enumMap
}
/**
* 格式化枚举值
*/
const formatEnumI18n = (
value: string | number,
enumDef: EnumDefinition[],
cacheKey: string,
defaultText?: string
): string => {
const enumMap = buildEnumMap(enumDef, cacheKey)
return enumMap[value] ?? defaultText ?? String(value)
}
/**
* 获取枚举选项列表(用于下拉框)
*/
const getEnumOptions = (
enumDef: EnumDefinition[],
cacheKey: string
): Array<{ value: string | number; label: string; color?: string }> => {
const enumMap = buildEnumMap(enumDef, cacheKey)
return enumDef.map(item => ({
value: item.value,
label: enumMap[item.value],
color: item.color
}))
}
/**
* 获取枚举项的颜色
*/
const getEnumColor = (
value: string | number,
enumDef: EnumDefinition[]
): string | undefined => {
const item = enumDef.find(e => e.value === value)
return item?.color
}
return {
formatEnumI18n,
getEnumOptions,
getEnumColor,
buildEnumMap
}
}
// 使用示例
const { formatEnumI18n, getEnumOptions, getEnumColor } = useEnumFormatter()
// 格式化单个值
const statusText = formatEnumI18n(1, statusEnumDef, 'status')
// 中文环境: "启用"
// 英文环境: "Enabled"
// 获取下拉选项
const statusOptions = getEnumOptions(statusEnumDef, 'status')
// [
// { value: 0, label: '禁用', color: 'danger' },
// { value: 1, label: '启用', color: 'success' },
// { value: 2, label: '待审核', color: 'warning' }
// ]
// 在表格中使用
const columns = [
{
prop: 'status',
label: t('table.status'),
formatter: (row) => formatEnumI18n(row.status, statusEnumDef, 'status')
}
]5. 文件大小格式化对负数和特殊值处理不当
问题描述:
使用 formatFileSize 处理非常规输入时,返回不合理的结果或抛出错误。
formatFileSize(-1024) // 返回 "-1.00 KB"?还是错误?
formatFileSize(NaN) // 返回什么?
formatFileSize(Infinity) // 返回什么?
formatFileSize(null) // 类型错误?问题原因:
- 函数未对边界情况进行防御性处理
- 负数在文件大小场景下无意义但可能出现
- NaN 和 Infinity 会导致计算异常
- null 和 undefined 未进行类型检查
解决方案:
// ❌ 错误:不处理边界情况
function formatFileSize(bytes: number): string {
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB']
let index = 0
while (bytes >= 1024 && index < units.length - 1) {
bytes /= 1024
index++
}
return `${bytes.toFixed(2)} ${units[index]}`
}
// ✅ 正确:完善的边界处理和错误恢复
interface FileSizeFormatOptions {
decimals?: number
base?: 1024 | 1000
units?: string[]
fallback?: string
allowNegative?: boolean
locale?: string
}
function formatFileSizeSafe(
bytes: unknown,
options?: FileSizeFormatOptions
): string {
const {
decimals = 2,
base = 1024,
units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'],
fallback = '0 Bytes',
allowNegative = false,
locale = 'zh-CN'
} = options || {}
// 类型检查和转换
let numericValue: number
if (bytes === null || bytes === undefined) {
return fallback
}
if (typeof bytes === 'string') {
numericValue = parseFloat(bytes)
} else if (typeof bytes === 'number') {
numericValue = bytes
} else {
return fallback
}
// 处理特殊数值
if (Number.isNaN(numericValue)) {
console.warn('formatFileSize: NaN input, returning fallback')
return fallback
}
if (!Number.isFinite(numericValue)) {
return numericValue > 0 ? '∞' : '-∞'
}
// 处理负数
const isNegative = numericValue < 0
if (isNegative && !allowNegative) {
console.warn('formatFileSize: Negative value not allowed, using absolute value')
}
const absoluteValue = Math.abs(numericValue)
// 处理 0
if (absoluteValue === 0) {
return fallback
}
// 计算单位
let unitIndex = 0
let value = absoluteValue
while (value >= base && unitIndex < units.length - 1) {
value /= base
unitIndex++
}
// 格式化数值
const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: unitIndex === 0 ? 0 : decimals,
maximumFractionDigits: unitIndex === 0 ? 0 : decimals
})
const formattedValue = formatter.format(value)
const sign = isNegative && allowNegative ? '-' : ''
return `${sign}${formattedValue} ${units[unitIndex]}`
}
// 扩展:支持从文件大小字符串解析回字节数
function parseFileSize(sizeStr: string): number | null {
const match = sizeStr.match(/^(-?)(\d+(?:\.\d+)?)\s*(Bytes?|KB|MB|GB|TB|PB|EB)$/i)
if (!match) {
return null
}
const [, sign, valueStr, unit] = match
const value = parseFloat(valueStr)
const unitMultipliers: Record<string, number> = {
'byte': 1,
'bytes': 1,
'kb': 1024,
'mb': 1024 ** 2,
'gb': 1024 ** 3,
'tb': 1024 ** 4,
'pb': 1024 ** 5,
'eb': 1024 ** 6
}
const multiplier = unitMultipliers[unit.toLowerCase()] ?? 1
const bytes = value * multiplier
return sign === '-' ? -bytes : bytes
}
// 使用示例
console.log(formatFileSizeSafe(1024)) // "1.00 KB"
console.log(formatFileSizeSafe(-1024)) // "1.00 KB"(默认取绝对值)
console.log(formatFileSizeSafe(-1024, { allowNegative: true })) // "-1.00 KB"
console.log(formatFileSizeSafe(NaN)) // "0 Bytes"
console.log(formatFileSizeSafe(Infinity)) // "∞"
console.log(formatFileSizeSafe(null)) // "0 Bytes"
console.log(formatFileSizeSafe('2048')) // "2.00 KB"
console.log(parseFileSize('1.5 GB')) // 1610612736
console.log(parseFileSize('invalid')) // null6. 银行卡格式化时卡号验证失败
问题描述:
使用 formatBankCard 格式化银行卡号时,对于格式不正确或长度异常的卡号没有进行校验,导致格式化结果不正确或产生误导。
// 长度不正确的卡号也被格式化了
formatBankCard('123456') // "1234 56"
formatBankCard('622536527156282') // "6225 3652 7156 282"(15位,缺一位)
// 包含字母的"卡号"
formatBankCard('622536527156ABCD') // "6225 3652 7156 ABCD"问题原因:
- 格式化函数只负责格式化,未进行验证
- 银行卡号长度可能是15-19位不等
- 用户输入可能包含非数字字符
- 不同银行卡类型有不同的验证规则
解决方案:
// ❌ 错误:只格式化不验证
function formatBankCard(cardNumber: string): string {
return cardNumber.replace(/(\d{4})/g, '$1 ').trim()
}
// ✅ 正确:带验证的银行卡格式化
interface BankCardFormatResult {
formatted: string
isValid: boolean
cardType?: string
bankName?: string
errors: string[]
}
// 常见银行卡 BIN(前6位)对应的银行和卡类型
const bankBinMap: Record<string, { bank: string; type: string }> = {
'622202': { bank: '中国工商银行', type: '借记卡' },
'622848': { bank: '中国工商银行', type: '信用卡' },
'621226': { bank: '中国建设银行', type: '借记卡' },
'622280': { bank: '中国农业银行', type: '借记卡' },
'621660': { bank: '中国银行', type: '借记卡' },
'622150': { bank: '招商银行', type: '借记卡' },
'622588': { bank: '招商银行', type: '信用卡' },
'622700': { bank: '中国银行', type: '信用卡' }
}
class BankCardFormatter {
/**
* Luhn 算法验证卡号
*/
private static luhnCheck(cardNumber: string): boolean {
const digits = cardNumber.replace(/\D/g, '')
if (digits.length < 13 || digits.length > 19) {
return false
}
let sum = 0
let isEven = false
for (let i = digits.length - 1; i >= 0; i--) {
let digit = parseInt(digits[i], 10)
if (isEven) {
digit *= 2
if (digit > 9) {
digit -= 9
}
}
sum += digit
isEven = !isEven
}
return sum % 10 === 0
}
/**
* 识别银行和卡类型
*/
private static identifyCard(cardNumber: string): { bank?: string; type?: string } {
const cleaned = cardNumber.replace(/\D/g, '')
const bin = cleaned.substring(0, 6)
return bankBinMap[bin] || {}
}
/**
* 验证并格式化银行卡号
*/
static formatWithValidation(
cardNumber: string,
options?: {
separator?: string
privacy?: boolean
strictValidation?: boolean
}
): BankCardFormatResult {
const {
separator = ' ',
privacy = false,
strictValidation = true
} = options || {}
const errors: string[] = []
const cleaned = cardNumber.replace(/\D/g, '')
// 检查是否包含非数字字符
if (cardNumber !== cleaned && cardNumber.replace(/\s/g, '') !== cleaned) {
errors.push('卡号包含非数字字符')
}
// 检查长度
if (cleaned.length < 13) {
errors.push('卡号长度不足(最少13位)')
} else if (cleaned.length > 19) {
errors.push('卡号长度过长(最多19位)')
}
// Luhn 校验
const isLuhnValid = this.luhnCheck(cleaned)
if (strictValidation && !isLuhnValid && cleaned.length >= 13) {
errors.push('卡号校验位不正确')
}
// 识别银行
const { bank, type } = this.identifyCard(cleaned)
// 格式化
let formatted: string
if (privacy) {
// 隐私模式:只显示后四位
const visible = cleaned.slice(-4)
const hidden = '*'.repeat(cleaned.length - 4)
const combined = hidden + visible
formatted = combined.replace(/(.{4})/g, `$1${separator}`).trim()
} else {
formatted = cleaned.replace(/(.{4})/g, `$1${separator}`).trim()
}
return {
formatted,
isValid: errors.length === 0 && (isLuhnValid || !strictValidation),
cardType: type,
bankName: bank,
errors
}
}
/**
* 简单格式化(带基本验证)
*/
static format(cardNumber: string, separator: string = ' '): string {
const result = this.formatWithValidation(cardNumber, { separator, strictValidation: false })
return result.formatted
}
/**
* 实时输入格式化(用于输入框)
*/
static formatInput(input: string, maxLength: number = 19): string {
// 移除非数字
let cleaned = input.replace(/\D/g, '')
// 限制长度
if (cleaned.length > maxLength) {
cleaned = cleaned.substring(0, maxLength)
}
// 每4位添加空格
return cleaned.replace(/(.{4})/g, '$1 ').trim()
}
}
// 使用示例
const result = BankCardFormatter.formatWithValidation('6225365271562822')
console.log(result)
// {
// formatted: "6225 3652 7156 2822",
// isValid: true,
// cardType: undefined,
// bankName: undefined,
// errors: []
// }
const invalidResult = BankCardFormatter.formatWithValidation('123456')
console.log(invalidResult)
// {
// formatted: "1234 56",
// isValid: false,
// cardType: undefined,
// bankName: undefined,
// errors: ["卡号长度不足(最少13位)"]
// }
// 在 Vue 输入框中使用
// <input
// v-model="cardNumber"
// @input="cardNumber = BankCardFormatter.formatInput($event.target.value)"
// placeholder="请输入银行卡号"
// />7. 列表格式化对嵌套对象和复杂数据结构处理失败
问题描述:
使用 formatList 处理复杂对象数组时,无法正确提取嵌套属性或处理数组属性。
const users = [
{ id: 1, profile: { name: '张三' }, roles: ['admin', 'user'] },
{ id: 2, profile: { name: '李四' }, roles: ['user'] }
]
// 无法提取嵌套属性
formatList(users, 'profile.name') // 返回 "undefined,undefined"
// 无法处理数组属性
formatList(users, 'roles') // 返回 "[object Array],[object Array]"问题原因:
formatList只支持单层属性访问- 未处理数组类型的属性值
- 缺少对 null 和 undefined 的处理
- 不支持复杂的取值逻辑
解决方案:
// ❌ 错误:简单的属性访问
function formatList(list: any[], field?: string): string {
return list.map(item => field ? item[field] : item).join(',')
}
// ✅ 正确:支持深度访问和复杂数据结构
type PathAccessor<T> = string | ((item: T) => unknown)
interface AdvancedListFormatOptions {
separator?: string
maxItems?: number
ellipsis?: string
emptyText?: string
nullText?: string
arrayJoinSeparator?: string
transform?: (value: unknown) => string
}
class AdvancedListFormatter {
/**
* 根据路径获取嵌套属性值
*/
private static getNestedValue(obj: unknown, path: string): unknown {
if (obj === null || obj === undefined) {
return undefined
}
const parts = path.split('.')
let current: unknown = obj
for (const part of parts) {
if (current === null || current === undefined) {
return undefined
}
// 处理数组索引 (如 "items[0]")
const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/)
if (arrayMatch) {
const [, prop, index] = arrayMatch
current = (current as Record<string, unknown>)[prop]
if (Array.isArray(current)) {
current = current[parseInt(index)]
} else {
return undefined
}
} else {
current = (current as Record<string, unknown>)[part]
}
}
return current
}
/**
* 将值转换为字符串
*/
private static valueToString(
value: unknown,
options: AdvancedListFormatOptions
): string {
const { nullText = '', arrayJoinSeparator = '、', transform } = options
if (value === null || value === undefined) {
return nullText
}
if (transform) {
return transform(value)
}
if (Array.isArray(value)) {
return value
.map(v => this.valueToString(v, { ...options, transform: undefined }))
.filter(Boolean)
.join(arrayJoinSeparator)
}
if (typeof value === 'object') {
// 尝试使用 toString 或 name/label 属性
const obj = value as Record<string, unknown>
if (typeof obj.toString === 'function' && obj.toString !== Object.prototype.toString) {
return obj.toString()
}
if (typeof obj.name === 'string') {
return obj.name
}
if (typeof obj.label === 'string') {
return obj.label
}
return JSON.stringify(value)
}
return String(value)
}
/**
* 格式化列表
*/
static format<T>(
list: T[],
accessor?: PathAccessor<T>,
options?: AdvancedListFormatOptions
): string {
const {
separator = ',',
maxItems = 0,
ellipsis = '...',
emptyText = '',
nullText = '',
arrayJoinSeparator = '、',
transform
} = options || {}
if (!list || list.length === 0) {
return emptyText
}
// 处理 maxItems
const itemsToProcess = maxItems > 0 ? list.slice(0, maxItems) : list
const hasMore = maxItems > 0 && list.length > maxItems
const values = itemsToProcess.map(item => {
let value: unknown
if (accessor === undefined) {
value = item
} else if (typeof accessor === 'function') {
try {
value = accessor(item)
} catch {
value = undefined
}
} else if (typeof accessor === 'string') {
value = this.getNestedValue(item, accessor)
} else {
value = item
}
return this.valueToString(value, { nullText, arrayJoinSeparator, transform })
})
const result = values.filter(v => v !== '').join(separator)
return hasMore ? `${result}${ellipsis}` : result
}
/**
* 便捷方法:格式化对象列表的指定字段
*/
static formatField<T extends Record<string, unknown>>(
list: T[],
field: string,
options?: AdvancedListFormatOptions
): string {
return this.format(list, field, options)
}
/**
* 便捷方法:使用函数提取值
*/
static formatWith<T>(
list: T[],
getter: (item: T) => unknown,
options?: AdvancedListFormatOptions
): string {
return this.format(list, getter, options)
}
}
// 使用示例
const users = [
{ id: 1, profile: { name: '张三' }, roles: ['admin', 'user'] },
{ id: 2, profile: { name: '李四' }, roles: ['user'] }
]
// 提取嵌套属性
console.log(AdvancedListFormatter.format(users, 'profile.name'))
// "张三,李四"
// 处理数组属性
console.log(AdvancedListFormatter.format(users, 'roles'))
// "admin、user,user"
// 使用函数提取
console.log(AdvancedListFormatter.formatWith(users, u => `${u.profile.name}(${u.roles.length}个角色)`))
// "张三(2个角色),李四(1个角色)"
// 自定义分隔符和限制数量
const tags = ['JavaScript', 'Vue', 'TypeScript', 'React', 'Node.js']
console.log(AdvancedListFormatter.format(tags, undefined, {
separator: ' | ',
maxItems: 3,
ellipsis: ' +2'
}))
// "JavaScript | Vue | TypeScript +2"
// 处理空值
const mixedList = [{ name: '张三' }, { name: null }, { name: '王五' }]
console.log(AdvancedListFormatter.format(mixedList, 'name', { nullText: '未知' }))
// "张三,未知,王五"8. 百分比格式化在不同场景下精度要求不一致
问题描述:
使用 formatPercent 时,不同业务场景对百分比的精度和显示格式有不同要求,统一的格式化参数无法满足需求。
// 进度条需要整数百分比
formatPercent(0.756, 2) // "75.60%"(期望 "76%")
// 财务报表需要保留4位小数
formatPercent(0.12345678, 2) // "12.35%"(期望 "12.3457%")
// 科学数据需要使用指数形式
formatPercent(0.00000123, 2) // "0.00%"(期望 "1.23e-4%")问题原因:
- 不同场景对精度有不同要求
- 极小值或极大值需要特殊处理
- 部分场景需要显示正负号
- 数值接近边界时的舍入规则不同
解决方案:
// ❌ 错误:简单的百分比格式化
function formatPercent(value: number, decimals: number = 2): string {
return `${(value * 100).toFixed(decimals)}%`
}
// ✅ 正确:支持多种场景的百分比格式化
type RoundingMode = 'round' | 'floor' | 'ceil' | 'trunc'
interface PercentFormatOptions {
decimals?: number
minDecimals?: number
maxDecimals?: number
showSign?: boolean
withSymbol?: boolean
roundingMode?: RoundingMode
useExponential?: boolean
exponentialThreshold?: number
colorCode?: boolean
locale?: string
}
interface PercentFormatResult {
text: string
value: number
color?: 'positive' | 'negative' | 'neutral'
}
class PercentFormatter {
private static round(value: number, decimals: number, mode: RoundingMode): number {
const factor = Math.pow(10, decimals)
switch (mode) {
case 'floor':
return Math.floor(value * factor) / factor
case 'ceil':
return Math.ceil(value * factor) / factor
case 'trunc':
return Math.trunc(value * factor) / factor
case 'round':
default:
return Math.round(value * factor) / factor
}
}
/**
* 格式化百分比
*/
static format(value: number, options?: PercentFormatOptions): string {
const result = this.formatDetailed(value, options)
return result.text
}
/**
* 格式化百分比(返回详细结果)
*/
static formatDetailed(value: number, options?: PercentFormatOptions): PercentFormatResult {
const {
decimals,
minDecimals = 0,
maxDecimals = 2,
showSign = false,
withSymbol = true,
roundingMode = 'round',
useExponential = false,
exponentialThreshold = 0.0001,
colorCode = false,
locale = 'zh-CN'
} = options || {}
// 处理特殊值
if (!Number.isFinite(value)) {
return {
text: Number.isNaN(value) ? 'N/A' : (value > 0 ? '+∞%' : '-∞%'),
value,
color: 'neutral'
}
}
const percentValue = value * 100
const absPercent = Math.abs(percentValue)
// 确定小数位数
let effectiveDecimals: number
if (decimals !== undefined) {
effectiveDecimals = decimals
} else {
// 根据数值大小自动调整精度
if (absPercent >= 10) {
effectiveDecimals = minDecimals
} else if (absPercent >= 1) {
effectiveDecimals = Math.min(1, maxDecimals)
} else if (absPercent >= 0.1) {
effectiveDecimals = Math.min(2, maxDecimals)
} else {
effectiveDecimals = maxDecimals
}
}
// 使用指数形式(极小值)
if (useExponential && absPercent > 0 && absPercent < exponentialThreshold * 100) {
const exponential = percentValue.toExponential(effectiveDecimals)
const sign = showSign && percentValue > 0 ? '+' : ''
const symbol = withSymbol ? '%' : ''
return {
text: `${sign}${exponential}${symbol}`,
value,
color: this.getColor(percentValue, colorCode)
}
}
// 应用舍入
const rounded = this.round(percentValue, effectiveDecimals, roundingMode)
// 格式化数值
const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: effectiveDecimals,
maximumFractionDigits: effectiveDecimals,
signDisplay: showSign ? 'exceptZero' : 'auto'
})
const formattedNumber = formatter.format(rounded)
const symbol = withSymbol ? '%' : ''
return {
text: `${formattedNumber}${symbol}`,
value,
color: this.getColor(percentValue, colorCode)
}
}
private static getColor(value: number, colorCode: boolean): 'positive' | 'negative' | 'neutral' | undefined {
if (!colorCode) return undefined
if (value > 0) return 'positive'
if (value < 0) return 'negative'
return 'neutral'
}
/**
* 进度百分比(整数,限制0-100)
*/
static formatProgress(value: number): string {
const clamped = Math.max(0, Math.min(1, value))
const percent = Math.round(clamped * 100)
return `${percent}%`
}
/**
* 财务百分比(高精度,带符号)
*/
static formatFinance(value: number, decimals: number = 4): string {
return this.format(value, {
decimals,
showSign: true,
colorCode: true
})
}
/**
* 科学百分比(支持指数形式)
*/
static formatScientific(value: number, decimals: number = 4): string {
return this.format(value, {
decimals,
useExponential: true,
exponentialThreshold: 0.0001
})
}
/**
* 变化率格式化(强调正负)
*/
static formatChange(value: number, options?: PercentFormatOptions): PercentFormatResult {
return this.formatDetailed(value, {
showSign: true,
colorCode: true,
...options
})
}
}
// 预设场景的便捷函数
export const percentPresets = {
// 进度条
progress: (value: number) => PercentFormatter.formatProgress(value),
// "76%"
// 财务报表
finance: (value: number) => PercentFormatter.formatFinance(value),
// "+12.3457%" 或 "-5.2341%"
// 科学数据
scientific: (value: number) => PercentFormatter.formatScientific(value),
// "1.2300e-4%"
// 变化率
change: (value: number) => PercentFormatter.formatChange(value),
// { text: "+15.23%", value: 0.1523, color: "positive" }
// 默认格式
default: (value: number, decimals?: number) => PercentFormatter.format(value, { decimals })
// "12.34%"
}
// 使用示例
console.log(percentPresets.progress(0.756)) // "76%"
console.log(percentPresets.finance(0.12345678)) // "+12.3457%"
console.log(percentPresets.scientific(0.00000123)) // "1.2300e-4%"
const changeResult = percentPresets.change(0.1523)
console.log(changeResult)
// { text: "+15.23%", value: 0.1523, color: "positive" }
// 在 Vue 模板中使用
// <span :class="percentPresets.change(item.rate).color">
// {{ percentPresets.change(item.rate).text }}
// </span>