1. 项目背景与核心价值
最近在重构一个多语言电商项目时,遇到了一个看似简单却令人头疼的问题:如何根据不同语言习惯优雅地格式化商品列表?比如英语中习惯用"and"连接最后两项(A, B and C),而中文则用"、"分隔(A、B和C)。传统的手动拼接字符串方式不仅代码臃肿,还容易遗漏语言差异。直到发现了ECMAScript Internationalization API中的Intl.ListFormat,这个困扰我多年的问题终于有了优雅解法。
Node.js 20+版本对Intl API的支持已经相当完善,特别是ListFormat功能可以自动处理不同语言环境下的列表格式化规则。这个看似小众的API实际上在以下场景中非常实用:
- 多语言网站的列表展示(如商品标签、用户权限说明)
- 日志信息的友好输出(如"删除的文件:A、B和C")
- CLI工具的多结果展示
- 动态生成的提示信息拼接
2. Intl.ListFormat技术解析
2.1 基本用法与参数说明
ListFormat构造函数接受两个参数:
new Intl.ListFormat([locales[, options]])locales参数:
- 字符串或数组形式,如'en-US'或['zh-CN', 'en']
- 决定列表格式化的语言规则
- 遵循BCP 47语言标签标准
options对象:
{ type: 'conjunction' | 'disjunction' | 'unit', style: 'long' | 'short' | 'narrow' }type参数详解:
conjunction:默认值,表示"和"关系(A、B和C)disjunction:表示"或"关系(A、B或C)unit:适用于单位列表,如长度单位(A B C)
style参数示例:
- 'long':"A、B和C"(中文),"A, B, and C"(英文)
- 'short':"A、B等"(中文),"A, B, etc."(英文)
- 'narrow':更紧凑的格式,视语言而定
2.2 实际应用示例
基础用法:
const list = ['React', 'Vue', 'Angular']; // 英文格式 const enFormatter = new Intl.ListFormat('en'); enFormatter.format(list); // "React, Vue, and Angular" // 中文格式 const zhFormatter = new Intl.ListFormat('zh'); zhFormatter.format(list); // "React、Vue和Angular"带选项的高级用法:
// 或关系+短格式 const orFormatter = new Intl.ListFormat('zh', { type: 'disjunction', style: 'short' }); orFormatter.format(['登录', '注册']); // "登录或注册" // 单位列表 const unitFormatter = new Intl.ListFormat('en', { type: 'unit', style: 'narrow' }); unitFormatter.format(['1kg', '2m', '3L']); // "1kg 2m 3L"3. 性能优化与实战技巧
3.1 性能关键点
在性能敏感场景下,需要注意:
实例复用:避免在循环中重复创建ListFormat实例
// 错误示范 items.forEach(item => { const formatter = new Intl.ListFormat('en'); // 每次循环都新建实例 console.log(formatter.format(item)); }); // 正确做法 const formatter = new Intl.ListFormat('en'); items.forEach(item => { console.log(formatter.format(item)); });语言检测优化:
// 根据请求头自动选择格式化器 function getFormatter(req) { const lang = req.headers['accept-language']?.split(',')[0] || 'en-US'; return new Intl.ListFormat(lang); }缓存策略:
const formatterCache = new Map(); function getCachedFormatter(locale, options = {}) { const key = `${locale}-${JSON.stringify(options)}`; if (!formatterCache.has(key)) { formatterCache.set(key, new Intl.ListFormat(locale, options)); } return formatterCache.get(key); }
3.2 特殊场景处理
空数组处理:
function safeFormat(list, locale = 'en', options = {}) { if (!list?.length) return ''; if (list.length === 1) return list[0]; return new Intl.ListFormat(locale, options).format(list); }混合语言内容: 当列表项本身包含多种语言时,建议统一使用一种语言环境格式化:
const mixedList = ['東京', 'Seoul', '新加坡']; const formatter = new Intl.ListFormat('ja'); // 使用日语规则格式化 formatter.format(mixedList); // "東京、Seoul、新加坡"自定义分隔符: 对于某些语言可能没有内置支持的情况,可以fallback到自定义实现:
function universalFormat(list, locale = 'en', options = {}) { try { return new Intl.ListFormat(locale, options).format(list); } catch (e) { // Fallback方案 const { type = 'conjunction' } = options; const sep = locale.startsWith('zh') ? '、' : ', '; const conj = { 'zh': type === 'disjunction' ? '或' : '和', 'en': type === 'disjunction' ? ' or ' : ' and ' }[locale.split('-')[0]] || ' and '; if (list.length <= 1) return list.join(''); return list.slice(0, -1).join(sep) + conj + list[list.length - 1]; } }4. 与其他国际化API的协同使用
4.1 与DateTimeFormat结合
在显示带日期列表时,可以组合使用:
const dates = [ new Intl.DateTimeFormat('zh').format(new Date('2023-01-01')), new Intl.DateTimeFormat('zh').format(new Date('2023-02-01')), new Intl.DateTimeFormat('zh').format(new Date('2023-03-01')) ]; const formatter = new Intl.ListFormat('zh'); console.log(`重要日期:${formatter.format(dates)}`); // 输出:重要日期:2023/1/1、2023/2/1和2023/3/14.2 与NumberFormat结合
格式化货币列表:
const prices = [1234.56, 789.01, 4567.89].map( num => new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(num) ); const formatter = new Intl.ListFormat('zh-CN'); console.log(`总价包含:${formatter.format(prices)}`); // 输出:总价包含:¥1,234.56、¥789.01和¥4,567.894.3 与PluralRules结合
处理多语言复数形式:
function formatItems(counts, locale) { const rules = new Intl.PluralRules(locale); return counts.map(count => { const category = rules.select(count); // 根据语言和数量类别返回不同表述 if (locale === 'ru') { // 俄语有更复杂的复数规则 return `${count} элемент${getRussianSuffix(count)}`; } return `${count} item${category === 'one' ? '' : 's'}`; }); } const counts = [1, 2, 5]; const formatted = formatItems(counts, 'en'); new Intl.ListFormat('en').format(formatted); // "1 item, 2 items, and 5 items"5. 浏览器兼容性与Node.js支持
5.1 兼容性现状
浏览器支持:
- Chrome 72+
- Firefox 78+
- Safari 14+
- Edge 79+
Node.js支持:
- 完整支持需要Node.js 12+(需full-icu版本)
- Node.js 20+默认包含完整ICU数据
检查是否支持:
function isListFormatSupported() { try { new Intl.ListFormat('en'); return true; } catch (e) { return false; } }5.2 Node.js环境配置
对于Node.js 12-19版本,可能需要额外安装ICU数据:
# 查看当前ICU版本 node -p 'process.versions.icu' # 安装full-icu包 npm install full-icu然后在启动时加载:
node --icu-data-dir=./node_modules/full-icu your-app.js或者在代码中动态加载:
if (process.versions.icu === 'undefined') { require('full-icu'); }6. 测试策略与最佳实践
6.1 单元测试方案
使用Jest测试不同语言场景:
describe('ListFormat', () => { const testCases = [ { locale: 'en', input: ['A', 'B', 'C'], expected: 'A, B, and C' }, { locale: 'zh', input: ['A', 'B', 'C'], expected: 'A、B和C' }, { locale: 'ja', input: ['A', 'B'], options: { type: 'disjunction' }, expected: 'AまたはB' } ]; testCases.forEach(({ locale, input, expected, options }) => { test(`should format ${locale} correctly`, () => { const formatter = new Intl.ListFormat(locale, options); expect(formatter.format(input)).toBe(expected); }); }); });6.2 实际项目集成建议
抽象格式化层:
// lib/formatters.js const listFormatters = new Map(); export function formatList(list, locale = 'en', options = {}) { const key = `${locale}-${JSON.stringify(options)}`; if (!listFormatters.has(key)) { listFormatters.set(key, new Intl.ListFormat(locale, options)); } return listFormatters.get(key).format(list); }与i18n框架集成:
// 以i18next为例 i18next.services.interpolator.interpolate = (value, data, lng) => { if (Array.isArray(data.__list__)) { const formatter = new Intl.ListFormat(lng); return value.replace('{{list}}', formatter.format(data.__list__)); } // 原有插值逻辑... };SSR场景处理:
// Next.js示例 export async function getServerSideProps({ locale }) { const products = await fetchProducts(); return { props: { products, formattedFeatures: new Intl.ListFormat(locale).format(products[0].features) } }; }
7. 高级应用场景
7.1 动态语言切换
在SPA应用中实现实时语言切换:
// React示例 function ProductList({ products, locale }) { const [formatter, setFormatter] = useState(() => new Intl.ListFormat(locale) ); useEffect(() => { setFormatter(new Intl.ListFormat(locale)); }, [locale]); return ( <ul> {products.map(product => ( <li key={product.id}> {product.name} - {formatter.format(product.tags)} </li> ))} </ul> ); }7.2 无限列表的分批格式化
处理长列表的分页显示:
function formatPaginatedList(items, locale, chunkSize = 5) { const formatter = new Intl.ListFormat(locale, { style: 'short' }); const chunks = []; for (let i = 0; i < items.length; i += chunkSize) { const chunk = items.slice(i, i + chunkSize); chunks.push(chunk.length === chunkSize ? formatter.format(chunk) : new Intl.ListFormat(locale).format(chunk) ); } return chunks; } // 输出:["A, B, C, D, etc.", "F、G和H"](中文环境下)7.3 与GraphQL的结合
在GraphQL解析器中直接应用:
const resolvers = { Product: { formattedTags: (product, _, context) => { const formatter = new Intl.ListFormat(context.locale); return formatter.format(product.tags); } } };8. 常见问题与解决方案
8.1 问题排查清单
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 报错"Intl.ListFormat is not a constructor" | Node.js版本过低或未包含完整ICU | 升级到Node.js 20+或安装full-icu |
| 格式化结果不符合预期 | 语言标签不正确 | 使用标准的BCP 47语言标签 |
| 性能低下 | 频繁创建新实例 | 复用ListFormat实例 |
| 某些语言不支持 | ICU数据不完整 | 确保使用full-icu版本 |
8.2 调试技巧
检查可用语言:
Intl.ListFormat.supportedLocalesOf(['zh', 'en', 'ja']);查看实际使用的语言:
const formatter = new Intl.ListFormat('zh-CN'); console.log(formatter.resolvedOptions().locale);性能分析:
console.time('ListFormat'); new Intl.ListFormat('en').format(['A', 'B', 'C']); console.timeEnd('ListFormat');
8.3 备选方案
当ListFormat不可用时,可以考虑以下替代方案:
硬编码方案:
function fallbackFormat(list, locale = 'en') { const { separator, lastSeparator } = { 'zh': { separator: '、', lastSeparator: '和' }, 'en': { separator: ', ', lastSeparator: ' and ' } }[locale.split('-')[0]] || { separator: ', ', lastSeparator: ' and ' }; if (list.length <= 1) return list.join(''); return list.slice(0, -1).join(separator) + lastSeparator + list[list.length - 1]; }使用i18n库的列表功能:
- i18next: 通过
interpolation选项自定义列表格式 - FormatJS: 使用
formatList方法
- i18next: 通过
服务端预处理: 对于静态内容,可以在构建时预先格式化好各种语言版本
在实际项目中,我通常会先尝试使用Intl.ListFormat,然后通过能力检测fallback到备选方案。这种渐进增强的策略既能利用现代浏览器的能力,又能保证旧环境的兼容性。