
1. 为什么我突然想搞一个“偷懒版”的 i18n先说点实在的。我手上有个项目业务模块拆得非常散前后端分离前端是多个独立部署的子应用人员流动也大代码风格属于那种“能跑就行”的路子。早期做国际化的时候我踩过很多坑尤其是那种老牌 i18n 方案的集成成本。你要引入 vue-i18n 或 react-intl首先得改入口文件、封装公共方法、设计语言包目录结构、配构建插件、处理异步加载语言包的问题东西还没写两行光搭建环境就花掉半天。更要命的是团队里新来的同学不太懂这些约定经常出现“文案写死在模板里没人记得加 key”的情况最后产品上线一看界面上中英混杂。后来我反思了一下问题根源其实是常规 i18n 方案把“代码结构”和“语言包管理”搅在了一起对团队来说太“重”了。很多业务场景根本没有那么复杂我们需要的只是一套能把界面上的文字快速抽出来、替换成对应语言的机制最好接入成本低改动面小不绑架技术栈还能让不熟悉 i18n 的同事也敢上手。所以我给自己项目写了一个“偷懒版”国际化组件不需要大改架构不需要依赖特定的框架版本只需要给要翻译的 DOM 节点打上标记再提供一个语言包映射它就能把页面上该翻译的地方都翻译完。这篇文章就是来聊聊这个组件的设计思路、完整实现和我在实际项目中踩过的坑。这套思路适合下面这些场景老项目要做国际化改造但不想推倒重来团队技术栈不统一Vue、React、原生 JS 混着用产品急着上线需要快速把所有硬编码文案先翻一遍以及那些“从零开始但不想维护一整套 i18n 全家桶”的小项目。它不是要取代正规的 i18n 方案而是给你一个更轻的切入路径。2. 整体设计思路所有翻译都围绕“DOM 节点”展开2.1 传统 i18n 方案到底重在哪我拿过去最常用的 vue-i18n 举例。你要用它的完整能力通常得做这么几件事先装依赖再建locales/zh.js、locales/en.js里面每个文案都要定义一个 key然后在组件里通过$t(menu.dashboard)这类 API 去取文案模板里也要把“用户中心”改成{{ $t(user.center) }}。这个流程对老项目很要命因为老项目里模板中的中文是直接写死的改成$t调用需要逐行做替换工作量巨大而且容易改错。另外维护语言包本身也是个麻烦事。开发过程中新增一个按钮就要去两个以上的语言包里各加一个 key漏加了一个界面上就会出现 key 名或者直接空着。我见过很多项目最后 zh.js 和 en.js 的 key 已经对不齐了谁都不敢动因为一动就可能崩。核心矛盾在于传统方案要求你在“开发时”就为多语言做设计而改造老项目时你却希望“事后”能把已存在的文本统一管理起来。所以“偷懒版”组件的第一步就是彻底改变交互方式——我们不去装饰代码里的每个字符串而是直接在 DOM 上做文章。2.2 “偷懒”的核心标记节点而不是改代码逻辑我的思路很简单英文文案是独立的语言包但页面里需要翻译的位置不通过 JS 函数调用去绑定而是由 HTML 结构上的约定来驱动。举个例子改造前你的模板可能长这样button classsave-btn保存/button span请输入用户名/span改造后我只在这些节点上加一个自定义属性button classsave-btn>class I18nLite { constructor(options {}) { this.locale options.locale || zh-CN; this.messages options.messages || {}; this.root options.root || document.documentElement; this.cache new Map(); this.isInitialized false; } // 注册语言包支持对象或异步加载函数 async registerMessages(locale, messages) { if (typeof messages function) { const data await messages(); this.messages[locale] data; } else { this.messages[locale] messages; } } // 切换语言 async setLocale(locale) { if (!this.messages[locale]) { // 生产环境可以改成静默失败开发环境给个警告 console.warn([i18n-lite] 未找到语言包: ${locale}); return; } this.locale locale; this.apply(); } }这里我把messages设计成多层嵌套对象和 vue-i18n 的 key 结构保持一致。比如语言包长这样const messages { zh-CN: { common: { save: 保存, cancel: 取消 }, login: { title: 用户登录, usernamePlaceholder: 请输入用户名 } }, en-US: { common: { save: Save, cancel: Cancel }, login: { title: Login, usernamePlaceholder: Please enter username } } };registerMessages方法支持直接传对象也支持传一个函数这样就能在切换语言时才去加载对应的语言包文件实现按需加载首屏负担会更小。实际项目里我强烈建议用函数的方式把语言包拆成独立文件通过动态import()加载这样不需要构建工具的额外配置原生支持代码分割。3.2 核心翻译逻辑如何优雅地替换文本而不破坏节点这是我这个组件最关键的方法也是“偷懒版”的灵魂。扫描节点之后我们需要根据>// 获取嵌套属性对应的翻译值 getMessage(key, locale this.locale) { const keys key.split(.); let result this.messages[locale]; for (const k of keys) { if (result undefined || result null) { return null; } result result[k]; } return result; } // 扫描并缓存所有待翻译节点 collectNodes(root this.root) { const nodes root.querySelectorAll([data-i18n], [data-i18n-placeholder], [data-i18n-html]); const list []; nodes.forEach((node) { const key node.getAttribute(data-i18n); const placeholderKey node.getAttribute(data-i18n-placeholder); const htmlKey node.getAttribute(data-i18n-html); if (key || placeholderKey || htmlKey) { // 保存原始文本后续切换语言时需要 const originalText node.textContent; const originalPlaceholder node.getAttribute(placeholder); const originalHTML node.innerHTML; list.push({ node, key, placeholderKey, htmlKey, originalText, originalPlaceholder, originalHTML }); } }); return list; } // 执行翻译 apply() { const nodes this.collectNodes(this.root); nodes.forEach((item) { if (item.key) { const text this.getMessage(item.key); if (text ! null text ! item.node.textContent) { item.node.textContent text; } } if (item.placeholderKey) { const placeholderText this.getMessage(item.placeholderKey); if (placeholderText ! null) { item.node.setAttribute(placeholder, placeholderText); } } if (item.htmlKey) { const htmlText this.getMessage(item.htmlKey); if (htmlText ! null) { item.node.innerHTML htmlText; } } }); }收集节点时我会把原始文本、原始 placeholder、原始 HTML 都存进缓存里。这个缓存有两个作用第一切换语言时可以还原原始内容避免叠加替换导致文案错乱第二如果某个 key 缺失我可以用原始内容兜底界面不会露出 key 名。关于嵌套节点的问题我再补充一下。假设有一个容器节点同时带>div>observe() { const observer new MutationObserver((mutationsList) { for (const mutation of mutationsList) { if (mutation.type childList) { mutation.addedNodes.forEach((node) { if (node.nodeType ! 1) return; const targets node.querySelectorAll ? node.querySelectorAll([data-i18n], [data-i18n-placeholder], [data-i18n-html]) : []; this.apply(); }); } // 属性变化场景比如外层组件给节点动态加了>img srclogo.png>const ATTRIBUTE_MAP { data-i18n: textContent, data-i18n-placeholder: placeholder, data-i18n-html: innerHTML, data-i18n-title: title, data-i18n-alt: alt };collectNodes改成遍历所有规则只要匹配到其中一个属性就把节点加入待翻译列表。apply里根据属性名选择翻译后的写入方式。这个设计的核心思想是让 HTML 节点自带“翻译声明”而非在 JS 业务代码里到处找翻译点。3.5 语言包按需加载与本地存储记忆切换语言的一个隐藏需求是用户选过之后下次进入页面还想保持他的选择。“偷懒版”组件不强求后端配合存配置直接用localStorage做本地记忆就能满足大多数场景。初始化的时候组件会按这个优先级确定当前语言用户显式传参 localStorage 中存储的语言 浏览器默认语言 组件内置默认语言。constructor(options {}) { this.locale options.locale || localStorage.getItem(i18n-lite-locale) || (navigator.language.startsWith(zh) ? zh-CN : en-US) || zh-CN; } setLocale(locale) { this.locale locale; localStorage.setItem(i18n-lite-locale, locale); this.apply(); }这段代码看起来简单但能在实际项目中省很多事。产品不用自己记用户选择技术也不用写额外的 cookie 逻辑。如果将来要升级成账号级语言偏好只需要在setLocale里增加一层接口调用把本地存储和远程存储同步起来就好。4. 实操过程我如何一步步给老项目接上这个组件4.1 老项目的真实改造场景我这里拿一个我实际改造过的老管理后台来举例。这个项目是 Vue 2 Element UI 技术栈页面结构复杂每个模块下都有大量表格、表单、弹窗代码里全部是硬编码的中文文案。问题在于Element UI 组件自身的文案可以通过引入语言包解决但业务代码里的“保存”“删除”“确认删除这条数据吗”“请输入用户名”等等就是纯纯的硬编码。我的目标是尽量不改动现有页面结构只添加标记属性切入成本压到最低。第一步我先把组件文件写好放在项目的utils/i18n-lite.js里然后在入口文件初始化import I18nLite from /utils/i18n-lite; import zhCN from /locales/zh-CN; import enUS from /locales/en-US; const i18n new I18nLite({ locale: zh-CN, messages: { zh-CN: zhCN, en-US: enUS } }); i18n.observe(); window.$i18n i18n;第二步语言包文件目录如下src/ locales/ zh-CN.js en-US.js每个文件导出嵌套对象层级按模块划分比如common、dashboard、system.user、order.list这种。第三步开始改造模板。这一步是整个改造中工作量最大的部分。我的优先级顺序是先改公共组件和公共页面比如顶栏、侧边栏、布局组件再改高频业务页面比如列表页、表单页最后再处理一些低频的营销页和错误页。每改一个页面我只需要在标签上增加>el-button typedanger clickhandleDelete删除/el-button改造后el-button typedanger clickhandleDelete>common: { delete: 删除 }英文语言包common: { delete: Delete }这类组件改造对业务功能是零侵入的。Element UI 组件会透传所有自定义属性到最终渲染的 DOM 上所以>{ html: div本月业绩目标已完成 strong80%/strong请继续保持/div, translations: { en-US: { banner.monthlyTarget: Monthly target completed strong80%/strong, keep going } } }前端脚本const response await fetch(/api/announcement); const data await response.json(); const wrapper document.getElementById(announcement); wrapper.innerHTML div>tips: { welcome: 欢迎回来strong{{name}}/strong记得查看新的通知 }而他在模板上写的是div>unread: 你还有 {count} 条未读消息然后业务代码里通过formatMessage方法来插值。但“偷懒版”不支持在>span>message: { unread: 你还有 {count} 条未读消息 }组件apply时读取节点上的>if (this.debug) { console.warn([i18n-lite] 未找到 key: ${item.key}当前语言: ${this.locale}); }生产环境把 debug 关掉就不会有性能损耗也不会有控制台噪音。5.2 动态生成的弹窗和下拉选项没有自动翻译MutationObserver 虽然能捕获动态节点但如果业务代码是通过第三方弹窗库渲染的弹窗内容可能渲染在body的某个固定容器内并不在我监听的root内。这时候就需要把监听范围扩大或者对第三方弹窗的方法做一次透明代理。我在项目中遇到最多的是 MessageBox 确认框。Element UI 的this.$confirm(确定删除吗, 提示)会动态生成一个弹窗里面的文案是业务代码传进去的。由于它生成的节点没有>// utils/dialogConfirm.js import i18n from ./i18n-lite; export function confirmDelete() { return this.$confirm( i18n.getMessage(common.deleteConfirm), i18n.getMessage(common.tip), { confirmButtonText: i18n.getMessage(common.confirm), cancelButtonText: i18n.getMessage(common.cancel) } ); }这就触及了“偷懒版”的一个边界它对业务代码中的逻辑字符串比如接口返回的提示、组件库方法参数无能为力必须靠 JS 层辅助。我的应对策略是把所有这类逻辑字符串统一放在一个jsMessages.js文件里并封装对应的调用函数避免团队成员直接在代码里写this.$confirm(确定删除吗)。5.3 富文本替换后节点上的事件绑定失效这个问题是在一次重构中发现的。页面上某段富文本后接了一个“查看详情”按钮按钮上有click事件用>div>message: { unread: { one: You have {count} new message, other: You have {count} new messages } }组件根据count值选择对应形式这个设计借鉴了 ICU MessageFormat 的思想但只做最基础的 one/other 区分够用又不重。还有一点我把语言包的默认值抽成了一个与语言无关的 key>