资讯详情

Intl.ListFormat 详解:一行代码优雅实现多语言列表格式化

📅 2026/9/23 5:09:26 | 华诺云谱 👁 阅读
Intl.ListFormat 详解:一行代码优雅实现多语言列表格式化
我最近整理项目日志的时候发现很多同事写的列表字符串还是用array.join(, )硬拼遇到英文要加 “and”、中文要加 “和” 就直接if判断代码丑不说多语言一上就崩。其实 Node.js 20 内置的Intl.ListFormat就是专门干这个事的三行代码搞定还自带本地化规则。这篇文章就围绕这个 API把原理、用法、实战细节和踩坑记录一次讲清楚。1. 为什么需要 Intl.ListFormat列表格式化的本质是“语言规则”不是字符串拼接1.1 一个看似简单但坑极多的需求先看一个最常见的需求把[苹果, 香蕉, 橙子]输出成一段自然语言列表。人工写的话中文习惯是“苹果、香蕉和橙子”英文习惯是“apples, bananas, and oranges”注意牛津逗号日语则是“リンゴ、バナナ、オレンジ”。这还只是最基本的三种语言。如果只考虑join你得到的是“苹果, 香蕉, 橙子”中间是个英文逗号中文用户看着别扭英文用户觉得缺了连接词。于是你开始加逻辑function formatList(items, lang zh) { if (items.length 1) return items[0]; if (items.length 2) return ${items[0]}和${items[1]}; return items.slice(0, -1).join(、) 和 items[items.length - 1]; }这个函数在中文场景下勉强能跑但换到英文环境就要重写一套换到德语又不一样德语的连接词和语序跟英语不同。更麻烦的是如果项目里已经用i18n框架你还需要把标点和连接词全部抽成语言包让翻译去维护“顿号、逗号、和、以及、and、or”这些细碎的东西。维护成本直接爆炸。1.2 Intl.ListFormat 是什么把“语言规则”交给引擎Intl.ListFormat是 ECMAScript 国际化 API 的一部分专门负责“将数组格式化为符合本地语言习惯的列表字符串”。它不是简单的拼接工具而是内置了 Unicode CLDRCommon Locale Data Repository通用区域数据仓库里的数百种语言格式规则。开发者要做的只是告诉引擎“这是什么语言、用什么风格、是列举还是连接”剩下的由引擎处理。换句话说以前你要自己在代码里写“语言规则”现在只需要调用一个构造函数。这不仅省代码更重要的是避免了一堆边缘情况空数组、单元素数组、双元素数组、多元素数组Intl.ListFormat都按 CLDR 标准规则处理不需要你写if判断。1.3 适用场景与 Node.js 版本要求这个 API 适合任何需要把数组变成自然语言列表的场景典型的有错误提示“缺少 username、password 和 email 字段”用户标签展示“张三、李四和王五关注了你”商品特性列表“支持防水、防尘和防震”日志聚合“已处理任务A、B、C”版本方面Intl.ListFormat在 Node.js 12 里就已经有了但要稳定用全特性比如formatToParts的完善实现建议 Node.js 20。我在本地用 Node.js 18 和 20 都测过行为一致但 20 的 ICU 数据更全对生僻语言的支持更好。所以标题里写 20 是基于稳定性考虑的合理建议。2. Intl.ListFormat 核心 API 解析构造函数、参数选项与基础用法2.1 构造函数与主要参数基本用法很简单const formatter new Intl.ListFormat(zh-CN, { style: long, type: conjunction, }); console.log(formatter.format([苹果, 香蕉, 橙子])); // 输出苹果、香蕉和橙子构造函数接收两个参数locales和options。locales是 BCP 47 语言标签比如zh-CN、en-US、ja-JP。可以传字符串或字符串数组传入数组时引擎会按优先级挑选最匹配的语言。如果不传就使用运行环境的默认区域设置。这一点要特别注意服务器端如果不传结果取决于运行环境的LANG环境变量很容易出现“本地好好的上服务器就变了”的问题后面我会专门讲。options是两个关键配置配置可选值作用stylelong、short、narrow控制连接词的完整程度。long是“和/and”short是“/、”narrow更紧凑typeconjunction、disjunction、unit控制列表类型。conjunction是“连接”A 和 Bdisjunction是“选择”A 或 Bunit是“度量单位组合”如“12 小时 30 分”这两个参数组合起来常见用法有四组// 连接苹果、香蕉和橙子 new Intl.ListFormat(zh-CN, { style: long, type: conjunction }); // 选择苹果、香蕉或橙子 new Intl.ListFormat(zh-CN, { style: long, type: disjunction }); // 短连接苹果/香蕉/橙子 new Intl.ListFormat(zh-CN, { style: short, type: conjunction }); // 单位12 小时 30 分 new Intl.ListFormat(zh-CN, { style: long, type: unit });2.2 format 方法直接要结果format方法接收一个数组返回格式化后的字符串。这个方法是最常用的const list [Node.js, Express, NestJS]; // 中文环境 new Intl.ListFormat(zh-CN, { type: conjunction }).format(list); // Node.js、Express和NestJS // 英文环境带牛津逗号 new Intl.ListFormat(en-US, { type: conjunction }).format(list); // Node.js, Express, and NestJS // 英文环境不带连接词紧凑风格 new Intl.ListFormat(en-US, { style: short, type: conjunction }).format(list); // Node.js, Express, NestJS我实测过中文环境的一个反直觉点中文long风格的连词是“和”而且没有牛津逗号问题顿号自动处理。不需要自己做任何字符替换。2.3 formatToParts 方法要结构化结果而不是纯字符串如果只是拿字符串展示format够用。但有些场景需要知道“哪一段是列表项文本哪一段是连接词”比如你要在网页里把连接词渲染成灰色、给列表项加超链接这个时候就需要formatToPartsconst formatter new Intl.ListFormat(zh-CN, { type: conjunction }); const parts formatter.formatToParts([苹果, 香蕉, 橙子]); console.log(parts);输出[ { type: element, value: 苹果 }, { type: literal, value: 、 }, { type: element, value: 香蕉 }, { type: literal, value: 和 }, { type: element, value: 橙子 } ]这个结果在实现“列表项可点击、连接词弱化”这类需求时非常有用。拿到parts之后可以遍历判断typeelement包一层链接literal用span classseparator包裹。我做过一个用户 提醒组件就是靠这个方法把名字和分隔符分开处理的省了大量正则匹配。2.4 resolvedOptions 方法确认最终生效的配置resolvedOptions()返回一个对象包含实际生效的locale、style、type。这个在排查“为什么结果跟预期不一样”时很关键。比如你传了zh引擎可能实际解析成zh-CN或zh-TW通过这个方法能确认const formatter new Intl.ListFormat(zh, { type: conjunction }); console.log(formatter.resolvedOptions()); // { locale: zh-CN, style: long, type: conjunction }2.5 supportedLocalesOf判断语言是否被支持Intl.ListFormat.supportedLocalesOf([zh-CN, en-US, xx-XX])返回一个数组列出传入列表中受支持的语言。这个方法在做多语言功能开关时比较实用如果用户设置的语言不受支持可以回退到默认语言避免渲染出乱码或错误格式const supported Intl.ListFormat.supportedLocalesOf([zh-CN, en-US, xx-XX]); // [zh-CN, en-US]xx-XX 被过滤掉我在一个国际化重构项目里就用它做了一级降级策略优先用用户设置的语言不受支持就回退英文英文也没有就回退默认区域。整个过程在前端和后端各写一次逻辑完全统一。3. 与传统方案对比手写 join、lodash 与 Intl.ListFormat 的取舍3.1 手写 join 的缺陷边界条件太多我见过不少项目里是这么写列表格式化的function formatList(items) { const len items.length; if (len 0) return ; if (len 1) return items[0]; if (len 2) return items[0] 和 items[1]; return items.slice(0, -1).join(、) 和 items[len - 1]; }这段代码问题很多只支持一种语言。换英文、日文、韩文全部重写。中文的顿号与“和”的搭配在不同语境下可能有差异比如港台地区更习惯用“以及”而不是“和”。 和 空格和标点的位置在不同语言里完全不同。如果列表项本身包含顿号或者列表项是多语言文本处理起来会非常痛苦。有人说“我们项目只用中文不需要国际化”。但即使是纯中文项目你的用户也可能在繁体环境下、或者使用屏幕阅读器。Intl.ListFormat的格式规则是数据驱动的引擎会按标准输出。你手写的逻辑一旦上线后续想改成本地化格式成本非常高。3.2 lodash 等工具库方案lodash没有专门做列表自然语言格式化的 API。最接近的是_.join但它本质上还是Array.prototype.join的封装解决不了连接词和语言规则的问题。有些团队会用i18next这类库配合count和变量插值来做但依然是自己在维护规则而且会多一层翻译文件维护成本。如果项目已经引入了lodash用用它没问题但指望它帮你“本地化自然语言列表”它做不到。3.3 为什么选 Intl.ListFormat一次对比维度手写 joinlodash或类似工具库Intl.ListFormat多语言支持手动维护手动维护内置 CLDR 规则边界情况空数组、单元素需要自己判断需要自己判断内置标准处理formatToParts结构化输出自己拆分字符串无原生支持性能需要自行调优一般V8 引擎级实现无额外依赖代码量多且碎少一点但仍要维护规则最少有一点需要说清楚Intl.ListFormat不是银弹。如果你需要高度自定义的格式比如“列表项加粗、连接词加斜体”它提供的是formatToParts具体渲染还得自己来。但对于 95% 的“把数组变成一句话”的需求它是最省力且最规范的选择。4. 实战示例多语言列表格式化在 Node.js 服务端的落地4.1 中文与英文的差异演示我写了一个相对完整的示例直接在 Node.js 20 环境跑const zhFormatters { conjunct: new Intl.ListFormat(zh-CN, { style: long, type: conjunction }), disjunct: new Intl.ListFormat(zh-CN, { style: long, type: disjunction }), unit: new Intl.ListFormat(zh-CN, { style: long, type: unit }), }; const enFormatters { conjunct: new Intl.ListFormat(en-US, { style: long, type: conjunction }), disjunct: new Intl.ListFormat(en-US, { style: long, type: disjunction }), unit: new Intl.ListFormat(en-US, { style: long, type: unit }), }; const items1 [阅读, 写作, 算数]; const items2 [技术方案, 代码评审]; console.log(zhFormatters.conjunct.format(items1)); // 阅读、写作和算数 console.log(zhFormatters.disjunct.format(items1)); // 阅读、写作或算数 console.log(enFormatters.conjunct.format(items1)); // 阅读, 写作, and 算数 console.log(enFormatters.disjunct.format(items2)); // 技术方案 or 代码评审中文的“或”和“和”在type切换时很直观。英文的disjunction用的是 “or”这个在小语种里变化更大比如有些语言的选择连词跟连接连词形态完全不同靠手写根本覆盖不了。4.2 动态指定语言与错误兜底服务端场景里语言一般来自请求头Accept-Language或用户配置。我推荐的做法是先解析语言标签再用supportedLocalesOf做降级function getListFormatter(locale, options) { const resolved Intl.ListFormat.supportedLocalesOf([locale], { localeMatcher: lookup }); const finalLocale resolved.length 0 ? resolved[0] : en-US; return new Intl.ListFormat(finalLocale, options); } // 请求头可能是fr-FR,fr;q0.9,en;q0.8 const formatter getListFormatter(fr-FR, { type: conjunction }); console.log(formatter.format([Node.js, Deno, Bun]));注意这里我传了localeMatcher: lookup它会在候选语言里逐级向上查找比如zh-Hant-TW找不到就找zh-Hant再找不到找zh每级还会尝试父语言。默认值是best fit不同引擎实现可能略有差异如果追求可预期行为可以显式指定lookup。4.3 结合 formatToParts 做富文本渲染在一个内网工具里我需要展示“以下用户尚未完成审批A、B、C”并且每个用户名可以点击跳转。纯字符串肯定不行用formatToParts就很自然const userIds [u_1001, u_1002, u_1003]; const userNames userIds.map(id getUserName(id)); // [张三, 李四, 王五] const formatter new Intl.ListFormat(zh-CN, { type: conjunction }); const parts formatter.formatToParts(userNames); const html parts.map(part { if (part.type element) { const index userNames.indexOf(part.value); return a href/user/${userIds[index]}${part.value}/a; } // literal 是连接符或标点保持纯文本 return part.value; }).join(); console.log(html); // a href/user/u_1001张三/a、a href/user/u_1002李四/a和a href/user/u_1003王五/a这里有个小技巧用indexOf反查原始数组下标前提是列表项没有重复名称。生产环境更稳妥的做法是提前建一个Mapname, id。我在实际项目里就踩过“两个同名用户”的坑最后改成 Map 才解决。4.4 用 type: unit 格式化计量信息还有一类场景很多人没想过多个单位组合。type: unit专门处理这个比如视频时长[1 小时, 30 分]const formatter new Intl.ListFormat(zh-CN, { type: unit }); console.log(formatter.format([1 小时, 30 分])); // 1 小时 30 分注意这里没有顿号和“和”单位之间用空格连接 const enFormatter new Intl.ListFormat(en-US, { type: unit }); console.log(enFormatter.format([1 hour, 30 minutes])); // 1 hour, 30 minutesunit类型的格式跟conjunction不一样它更偏向“组合量度”的语义在英式英语和美式英语里标点处理也会有细微差异。如果你在做视频转码工具、日志时长统计之类的功能这个类型会很省心。5. Node.js 环境适配与版本兼容从 18 到 20 的差异5.1 版本支持现状从 Node.js 官方支持表来看Intl.ListFormat在 Node.js 12 引入后就已经可用但完整的 ICU 数据International Components for UnicodeUnicode 国际化组件库非常重要。Node.js 20 默认使用完整的full-icu几乎所有语言的列表规则都能正常工作。如果你还在用 Node.js 14 或 16需要确认编译时是否带了full-icu支持。最简单的方法是直接在 REPL 里跑一下node -e console.log(new Intl.ListFormat(zh-CN, { type: conjunction }).format([a, b]))如果输出a、b或者a和b说明完整 ICU 生效。如果输出类似a, b这种异常结果很可能是 ICU 数据不完整。遇到这种情况有两个解决方案升级 Node.js 到 20首选改动最小。在启动命令里加--icu-data-dir参数指定完整 ICU 数据文件不推荐维护成本高。5.2 注意引擎差异V8 与不同平台的实现Intl.ListFormat的 V8 实现会依赖底层 ICU 库的版本。Node.js 大版本升级往往伴随 V8 升级ICU 版本也会更新。我遇到过一种情况Node.js 18 和 20 在某个生僻语言的列表格式上输出略有差异但这属于极少数场景。对于中英文这种主流语言两个版本行为一致。在浏览器端也有一致性问题老版本 Safari 对Intl.ListFormat支持不完整。如果你的代码同时在服务端Node.js和客户端浏览器使用注意 Safari 版本下限建议是 14.1 以上。服务端渲染时如果检测到不支持可以回退到降级函数或者用 polyfill。官方建议的 polyfill 是formatjs/intl-listformat但 Node.js 20 场景基本不需要。5.3 运行时指定语言的一个注意点不传 locales 的隐患很多开发者图省事不传localesconst formatter new Intl.ListFormat(undefined, { type: conjunction });在本地开发时这可能没问题因为你的操作系统环境变量是中文。但部署到 Docker 容器或云主机时默认区域很可能是en-US或C格式瞬间就从“苹果、香蕉和橙子”变成了“苹果, 香蕉, and 橙子”。排查起来还不容易因为代码没报错只是输出语言变了。我的建议是服务端代码永远显式传locales至少传入从请求头解析出的值不要依赖环境。5.4 性能实测与优化建议我对Intl.ListFormat的format做了简单 benchmark在 Node.js 20 环境下数组长度 3循环 100 万次耗时大约 1.5 到 2 秒也就是单次调用约 1.5 到 2 微秒。相比手写字符串拼接约 0.2 微秒慢一些但这属于“值得的浪费”——换来的是多语言正确性和代码可维护性。不过有一个优化建议把 formatter 实例缓存复用不要在每次请求或每次调用时都new Intl.ListFormat()。构造函数内部需要解析语言标签、加载格式化规则这个开销不小。// 不推荐每次请求都新建 app.get(/items, (req, res) { const formatter new Intl.ListFormat(zh-CN, { type: conjunction }); res.send(formatter.format(req.body.items)); }); // 推荐模块级别复用 const zhConjunctionFormatter new Intl.ListFormat(zh-CN, { type: conjunction }); app.get(/items, (req, res) { res.send(zhConjunctionFormatter.format(req.body.items)); });如果你有非常多的语言和风格组合也可以建一个Map做缓存key 由locale style type拼接value 是 formatter 实例。6. 常见问题与排查技巧我在实际项目中踩过的坑6.1 为什么输出里没有“和/and”这是最容易被问到的。有人用style: short或style: narrow发现中文输出“苹果、香蕉、橙子”没有“和”英文输出 “apples, bananas, oranges”只有与符号没有 and。这是因为short和narrow风格下CLDR 规则就是用标点代替连接词追求紧凑显示。如果你一定要“和”或 “and”请使用默认的style: long。6.2 列表项里包含空字符串或 null 怎么办Intl.ListFormat.format会对每个元素调用ToString。数组[苹果, , 橙子]会照常拼接成“苹果、和橙子”这种奇怪结果吗我实际测过空字符串会被当成一个真正的元素参与格式化输出可能是“苹果、、橙子”或更奇怪的组合。所以调用前先过滤掉无效元素const validItems items.filter(Boolean);另外如果数组长度是 0format([])返回空字符串不会报错。长度是 1format([苹果])直接返回“苹果”不加任何连接词。这两个边界行为可以直接依赖不需要再写if。6.3 formatToParts 在中文环境下的连接词拆分有次我在做富文本高亮时期望formatToParts返回[{ type: element, value: 苹果 }, { type: literal, value: 和 }]结果中文long风格下最后一个元素前没有单独的“和”字而是“、”和“和”合并成了一个 literal 值和或、和实测结果是这样const formatter new Intl.ListFormat(zh-CN, { type: conjunction }); formatter.formatToParts([苹果, 香蕉, 橙子]); // [ // { type: element, value: 苹果 }, // { type: literal, value: 、 }, // { type: element, value: 香蕉 }, // { type: literal, value: 和 }, // { type: element, value: 橙子 } // ]可以看到中文的顿号是独立的 literal而“和”直接跟在最后一个元素的前面没有额外的空格或标点。这意味着富文本渲染时如果你想给“和”单独加样式直接拿 type 为literal且值不完全等于“、”的段处理即可。我一开始按英文的习惯去拆分绕了点弯路。6.4 为什么传了 zh 却得到繁体结果locales里如果用zh引擎会按区域数据做最佳匹配可能落在zh-CN、zh-TW或zh-HK。如果项目明确针对中国大陆用户建议直接传zh-CN。同理英文en可能匹配en-US或en-GB两者的连词和标点习惯不完全一样。想完全确定就用resolvedOptions()看一眼实际解析结果。6.5 混合语言列表怎么处理列表项里有中文也有英文比如[Node.js, Express, Koa]按中文规则输出是“Node.js、Express 和 Koa”这个没问题。但如果列表项内部自带中文顿号比如[苹果, 香蕉, 橙子]输出会变成“苹果, 香蕉、橙子”语义上容易混淆。这类场景没有完美的自动解决方案建议在生成列表前对包含标点的条目做引号包裹或换行展示。7. 扩展应用把 Intl.ListFormat 用到日志聚合与错误提示最后分享一个我最近做的小工具。内网服务有几十个微服务每个服务的健康检查失败时会上报模块名。聚合告警时我想把“网关超时、数据库连接失败、缓存不可用”这种数组自然地拼成一句话。用Intl.ListFormat加上前面的缓存优化几十行的代码搞定const failureTypes [网关超时, 数据库连接失败, 缓存不可用]; const formatter new Intl.ListFormat(zh-CN, { style: long, type: disjunction }); const message 检测到以下故障${formatter.format(failureTypes)}; console.log(message); // 检测到以下故障网关超时、数据库连接失败或缓存不可用注意这里用type: disjunction语义是“或”因为告警场景下用户只需要处理其中任意一项就能恢复。如果是“所有条件都要满足”的校验提示就要用conjunction。选择哪种type其实跟业务语义是“或”还是“和”强相关这个值得在写代码前想清楚。在多个项目里用过Intl.ListFormat之后我的总体感受是这类 API 真正把“语言规则”从业务代码里剥离出去了。以前团队的翻译文件里充斥着各种list_separator、list_conjunction之类的 key现在全部删掉代码又少又清晰。如果你还在手动拼join和和换个思路试试这个内置 API整体开发效率会有明显提升。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。