前言
最近在做一个老项目的国际化改造。
项目跑了很多年,页面多、业务多、历史写法也多。我们先用脚本扫了一遍,需要处理的中文大约有几万处。
看到这个数字,第一反应很容易是:多安排几个人,把中文都换成 t(),再补几份语言包不就行了吗?
真动手之后才发现,事情远没有这么简单。
有些中文只是按钮上的字,直接翻译没问题;有些中文却被拿来做判断、传接口、存缓存。还有一些藏在动态菜单、公共组件、异步任务、导出文件名和打印模板里。
如果不先把这些情况分清楚,直接全局替换,页面可能是英文了,业务却悄悄坏了。
这篇文章不讲某个具体项目,也不会放真实业务代码。主要想聊一聊:面对一个约有几万处中文、还在持续迭代的老项目,我们是怎么把国际化从“翻译任务”变成“架构改造”的。
文中的代码都是重新写的通用示例。虽然示例用了常见前端写法,但整体思路不依赖 Vue、React 或其他具体框架。
一、总设计思路
1. 最难的不是翻译,而是把文字和业务拆开
先看一段老项目里很常见的代码:
const orderStatusList = [
{ id: 1, name: '待审核' },
{ id: 2, name: '已通过' }
]
if (currentStatusName === '待审核') {
openReviewDialog()
}
表面上看,待审核 是一段展示文案,
实际上,它还参与了业务判断。
如果只把列表里的 name 翻译成 Pending Review,判断就会失效,而且控制台不会报任何错。
这类问题比漏翻一个按钮严重得多。漏翻一眼就能看到,逻辑失效可能要等到线上用户操作时才会暴露。
所以我们先定了一条最重要的规则:
翻译只负责显示,不能参与传参、缓存和业务判断。
判断要使用稳定的 id、code 或 key:
const orderStatusList = [
{
code: 'pending_review',
name: '待审核',
nameI18nKey: 'orders.status.pendingReview'
},
{
code: 'approved',
name: '已通过',
nameI18nKey: 'orders.status.approved'
}
]
if (currentStatus === 'pending_review') {
openReviewDialog()
}
三个字段各做各的事:
code给业务逻辑使用;nameI18nKey给界面翻译使用;name暂时保留,给老页面和异常情况兜底。
国际化可以改变用户看到的内容,但不能改变系统处理的数据。
2. 5 万处中文不能直接等于 5 万个翻译任务
扫描工具只能告诉我们哪里有中文,不能判断中文是做什么的。
比如:
const title = '订单管理'
const colorName = '中国红'
const fileName = '订单列表'
const userRemark = '用户自己填写的中文'
它们的处理方式完全不同:
title是界面文案,需要翻译;colorName可能是业务值,不能乱改;fileName要先确认导出语言规则;- 用户自己填写的内容通常不翻译。
所以第一步不是安排大家批量替换,而是扫描、分类和定边界。
我们把中文大致分为几类:
普通页面文案
共享枚举和配置
动态菜单与后端消息
Store 和缓存里的展示文字
导出、打印、图片等特殊输出
用户自己创建的内容
只有第一类最适合直接转成 t()。
3. 不追求一次做完
面对 5 万处中文,最危险的计划就是“这个版本全部改完”。
项目还在正常迭代,新需求每天都可能继续增加中文。公共组件、路由和请求层又会被很多页面共用。大分支拖得越久,冲突和回归风险越高。
我们把改造拆成四个阶段。
第一阶段:先搭底座,再跑一个完整试点
先处理:
- 语言初始化;
- 语言包加载;
- 路由标题;
- 组件库语言;
- 公共选择器和筛选组件;
- 请求错误;
- 切换语言和缓存清理;
- 缺少翻译时的兜底。
然后选一个包含列表、详情、筛选、弹窗和消息提示的业务模块跑通。
不要只拿登录页试。
登录页当然简单,但它验证不了动态菜单、共享枚举、表格配置和复杂状态。
第二阶段:按业务模块逐个迁移
语言包先跟前端代码一起发布。
一个模块改完就合并,不等待其他模块。这样可以正常上线,也不会形成一个几个月都合不进去的大分支。
第三阶段:再把语言包搬到 CDN
等本地语言包的目录、key 和加载方式稳定后,再切换到 CDN。
业务页面不应该因为语言包换了存放位置而重新改一遍。
第四阶段:最后建设语言管理后台
规则稳定后,再支持在线编辑、版本发布、灰度和回滚。
如果一开始就做管理后台,前端的 namespace 和 key 规则还在变化,后台大概率会跟着返工。
4. 页面不关心语言包放在哪里
业务代码只依赖两个能力:
loadNamespaces(locale, namespaces)
t(key, defaultMessage)
至于语言包现在放在本地,还是以后从 CDN 拉,页面不需要知道。
整体关系大概是:
页面和公共组件
↓
统一的 i18n 运行时
↓
语言包加载器
├─ 本地 JSON
├─ CDN
├─ IndexedDB 缓存
└─ 本地兜底包
这样做的好处很直接:前两期先把业务迁移跑稳,第三期只换加载器,不再大面积碰业务页面。
二、老项目国际化有哪些特殊难点?
下面这些问题,才是老项目改造里真正费时间的地方。
每个问题都按同一个顺序说:原来怎么写、为什么会坏、最后怎么改。
1. 共享枚举不能直接改成 t()
如果一组选项只在当前页面使用,不导出,也不进缓存,直接翻译没有问题:
const weightOptions = [
{
value: 'actual',
name: t('settings.weight.actual', '使用实际重量')
},
{
value: 'estimated',
name: t('settings.weight.estimated', '使用预估重量')
}
]
但共享枚举不能照搬这种写法:
// 不建议在共享模块里直接这样写
export const orderTypeList = [
{ code: 'normal', name: t('orders.type.normal') }
]
老项目里的共享 name 经常不只负责展示,还可能被用于:
- 接口参数;
- 默认配置;
- 缓存合并;
- Map 的 key;
- 字符串拼接;
- 条件判断。
把 name 改成英文后,这些地方会一起变化。
共享枚举更适合保留原值,再补一个 key:
export const orderTypeList = [
{
code: 'normal',
name: '普通订单',
nameI18nKey: 'orders.type.normal'
}
]
展示时生成一份新数组:
const displayOrderTypes = orderTypeList.map(item => ({
...item,
name: resolveI18nText(item, 'name')
}))
原数组不改,业务逻辑继续使用 code。
2. 中文展示文字可能正在控制业务行为
下面这种写法非常隐蔽:
const noClickColumns = ['操作']
if (!noClickColumns.includes(column.label)) {
selectRow(row)
}
列头翻译成 Actions 后,“操作”列也会触发行选择。
正确做法是看稳定字段:
const noClickColumnKeys = ['actions']
if (!noClickColumnKeys.includes(column.property)) {
selectRow(row)
}
改造前最好专项扫描:
column.label === '中文'
dialog.title === '中文'
route.meta.title === '中文'
name === '中文'
translatedText.includes(...)
凡是翻译后的文本参与 ===、includes、switch,都要重点看。
3. 公共组件一改,可能影响几百个页面
大项目里,很多页面都在使用相同的选择器、筛选栏、表格和日期组件。
如果每个页面都手写一遍:
options.map(item => ({
...item,
name: t(item.nameI18nKey)
}))
几个月后肯定会出现很多不同版本。
更省事的做法是让公共组件认识 nameI18nKey:
const getOptionLabel = (item: Record<string, string>) => {
return resolveI18nText(item, props.labelField)
}
业务页面继续传原来的数据:
<SmartSelect
v-model="status"
:options="orderStatusList"
label-field="name"
value-field="code"
/>
一个公共组件改好,可以覆盖很多页面。
但公共组件的影响面也最大,所以必须先清理上一节提到的“展示文字参与判断”问题,再逐步放量验证。
4. Store 和缓存里存了已经翻译好的文字
下面这种写法很常见:
taskStore.addTask({
title: '批量导出',
columns: [
{ prop: 'operation', label: '执行操作' },
{ prop: 'reason', label: '失败原因' }
]
})
切换语言后,任务还在,里面的中文也还在。
更合理的是保存 key 和参数:
taskStore.addTask({
titleI18nKey: 'tasks.batchExport',
title: '批量导出',
columns: [
{
prop: 'operation',
labelI18nKey: 'tasks.operation',
label: '执行操作'
},
{
prop: 'reason',
labelI18nKey: 'tasks.failureReason',
label: '失败原因'
}
]
})
任务组件渲染时再翻译。
同样的原则也适用于:
- 面包屑;
- 页签;
- 表格列设置;
- 通知中心;
- 跨页面恢复的数据。
状态层保存“它是谁”,展示层决定“现在怎么显示”。
5. 动态菜单不能靠中文名称反查
静态路由比较简单:
meta: {
title: '订单管理',
titleI18nKey: 'route.orders'
}
动态菜单麻烦一些,因为名称通常来自后端。
长期来看,后端最好返回稳定 key:
{
"path": "/orders",
"name": "订单管理",
"nameI18nKey": "menu.orders"
}
前端优先使用 nameI18nKey,没有时回退 name。
如果后端短期改不了,可以先用权限码或菜单 code 做映射:
const menuI18nMap = {
order_manage: 'menu.orders',
inventory_read: 'menu.inventory'
}
但不要用中文菜单名做映射:
// 不建议
const menuMap = {
订单管理: 'Order Management'
}
菜单一改名,映射就断了。同名菜单也没法区分。
6. 后端只返回一句中文,前端真的翻不了
接口如果只返回:
{
"msg": "当前记录已被其他用户修改"
}
前端没有可靠办法知道它对应哪个翻译。
更合适的协议是:
{
"code": 40901,
"messageKey": "errors.resourceChanged",
"params": {
"resource": "订单"
},
"msg": "当前记录已被其他用户修改"
}
前端可以按这个顺序处理:
后端 messageKey
→ 本地 code 对应的 key
→ HTTP 状态码对应的 key
→ 后端 msg
→ 通用错误提示
这样既能逐步改造,也不会因为后端还没改完就让旧接口不能用。
如果项目里有多套请求封装,它们要共用同一个错误解析函数。不然很容易出现 A 接口报错是英文,B 接口报错还是中文。
7. 拼接句子不能只翻译里面的词
中文代码里很常见:
const message = `是否对选中的 ${total} 条记录执行${actionName}?`
如果只翻译 actionName,整句话还是中文语序。
应该把完整句子放进语言包:
t(
'orders.batchActionConfirm',
'是否对选中的 {total} 条记录执行{action}?',
{
total,
action: t(actionI18nKey, actionName)
}
)
英文可以自己调整语序:
{
"batchActionConfirm": "Apply {action} to {total} selected records?"
}
国际化不是词语替换。不同语言的语序、单复数和表达习惯都可能不同。
8. 原生 confirm 和 HTML 弹窗不能机械套 t()
纯文字确认框很好处理:
await showConfirm(
t('orders.deleteConfirm', '确认删除该订单吗?'),
t('common.tip', '提示')
)
复杂情况就不一样了:
showMessage({
useHtml: true,
content: '<strong>账号已存在,<a onclick="goLogin()">立即登录</a></strong>'
})
这里把文案、HTML 和点击事件绑死在一个字符串里。
翻译后语序可能变化,标签位置也可能变化,还有安全问题。更合适的办法是做成正常组件,让文字走语言包,链接走真实事件。
原生 confirm() 也尽量换掉。它的确定、取消按钮通常跟浏览器或操作系统语言走,不一定跟应用内选择的语言一致。
9. 导出语言和界面语言不一定相同
页面切成英文,不代表导出的文件一定要英文。
有些用户用英文界面操作,但文件要发给中文团队;有些打印标签必须遵守固定格式,也不能跟着 UI 随便变化。
所以要先把几个概念分开:
界面语言
导出语言
打印语言
用户自己填写的内容
导出工具可以同时支持 key 和旧文件名:
exportExcel({
fileNameKey: 'exports.orderList.fileName',
fileName: '订单列表',
sheetNameKey: 'exports.orderList.sheetName',
sheetName: '订单明细',
data
})
打印字段也不要只留一个 name:
interface PrintField {
fieldCode: string
name?: string
nameI18nKey?: string
printLabel?: string
}
fieldCode用于内部识别;nameI18nKey用于编辑器界面;printLabel决定纸上真正打印什么。
如果直接把历史 name 全改成 t(),很可能界面翻译好了,打印模板却坏了。
10. 图片里的中文,语言包也救不了
PNG、JPG 里的中文无论怎么调用 t() 都没用,因为文字已经在图片像素里了。
一般有三种处理方式:
- 把文字从图片里拆出来,用普通 DOM 渲染;
- 每种语言准备一张图片;
- 明确这个资源不做多语言,并记录为例外。
能拆就尽量拆。每种语言维护一套图片,时间久了很容易漏更新。
三、具体怎么落地?
1. 语言包按业务模块拆,不按单个页面拆
语言包拆得太细,会出现很多小文件和请求。
全部放在一个大文件里也不行:
- 首次加载大;
- 多人修改容易冲突;
- 改一个小文案也要更新整个包;
- 很难知道某个 key 属于谁。
更合适的边界是:一个二级业务入口对应一个 namespace,它下面的列表、详情、编辑页、弹窗和导出都放在一起。
例如:
locales/
├─ zh-CN/
│ ├─ common.json
│ ├─ route.json
│ ├─ auth.json
│ ├─ admin_orders.json
│ └─ admin_inventory.json
└─ en-US/
├─ common.json
├─ route.json
├─ auth.json
├─ admin_orders.json
└─ admin_inventory.json
common 只放真正通用的词,比如保存、取消、确认。
不要看到“状态”两个字到处都有,就全塞进 common。订单状态和库存状态在不同语言里的表达可能不一样,强行共用反而不好维护。
2. 用统一 loader 加载
路由声明自己需要哪些语言包:
{
path: '/orders',
meta: {
titleI18nKey: 'route.orders',
i18nNamespaces: ['common', 'route', 'admin_orders']
}
}
进入路由前统一加载:
router.beforeEach(async (to) => {
const namespaces =
to.meta.i18nNamespaces ?? ['common', 'route']
await loadNamespaces(currentLocale(), namespaces)
})
加载器还要处理两个小问题:
- 同一个语言包不要重复加载;
- 同一时刻来了多个请求,不要重复发起。
const loaded = new Set<string>()
const inflight = new Map<string, Promise<void>>()
export const loadNamespace = (
locale: string,
namespace: string
) => {
const key = `${locale}:${namespace}`
if (loaded.has(key)) {
return Promise.resolve()
}
if (inflight.has(key)) {
return inflight.get(key)!
}
const task = loadMessage(locale, namespace)
.then(message => {
mergeLocaleMessage(locale, namespace, message)
loaded.add(key)
})
.finally(() => {
inflight.delete(key)
})
inflight.set(key, task)
return task
}
缓存 key 一定要带语言。
如果只记 namespace,先加载中文后再切英文,加载器会误以为这个包已经加载过了。
3. 用适配层兼容新旧数据
老项目不适合一次性删除全部中文字段。
假设几百个页面都在读取:
item.name
如果一次性删掉 name,要求所有页面同时改完,风险太大。
迁移期允许:
{
code: 'pending',
name: '待处理',
nameI18nKey: 'orders.status.pending'
}
已迁移的页面优先读 nameI18nKey,没迁移的页面继续显示中文 name。
可以提供一个统一解析函数:
export const resolveI18nText = (
item: Record<string, string>,
field = 'name'
) => {
const key = item[`${field}I18nKey`]
const fallback = item[field] ?? ''
return key ? t(key, fallback) : fallback
}
展示字段和 key 一一对应:
name → nameI18nKey
label → labelI18nKey
title → titleI18nKey
message → messageI18nKey
placeholder → placeholderI18nKey
这样虽然多写了几个字段,但语义更清楚,也不容易与老项目里已有的 labelKey、nameKey 混在一起。
4. 切换语言时,老项目可以选择刷新
我们最开始也想做无刷新切换。
后来盘了一下现有状态:
- 动态路由缓存里有中文标题;
- 面包屑存了最终文案;
- 已打开页签存了标题;
- 一些 Store 里有任务名称和表头;
- 模块顶层常量已经执行过;
- 组件库也要跟着换语言。
为了一个很低频的切换语言操作,让所有历史状态都支持实时响应,成本很高,也容易漏。
最后选择了更直接的方式:
保存目标语言
→ 清掉含文案的状态
→ 刷新页面
→ 按新语言重新初始化
export const switchLocaleWithReload = (locale: string) => {
localeStorage.set(locale)
systemStore.resetForLocaleChange()
window.location.reload()
}
这里不是把所有缓存都删掉。
Token、租户、用户偏好不能动。只清理动态路由、面包屑、页签标题、表格列标题、任务中心文案等带展示文字的内容。
刷新不是最炫的方案,但对很多老项目更稳。如果产品明确要求无刷新,再增加响应式热切换。
5. CDN 放到业务迁移稳定之后
国际化刚开始时,变动最大的是 key 和模块边界。
今天一个 key 放在 common,明天发现它应该属于订单模块;今天按页面拆包,过几天又决定按业务入口拆。
这时候先上 CDN、版本管理和发布后台,只会让调试链路更长。
先用本地 JSON 把这些事情跑顺:
- namespace 怎么划;
- key 怎么命名;
- 路由怎么声明依赖;
- 公共组件怎么解析;
- 缺 key 怎么兜底。
等这些稳定后,只替换加载器:
export const loadMessage = async (
locale: string,
namespace: string
) => {
if (!enableCdnI18n) {
return loadLocalMessage(locale, namespace)
}
return loadCdnMessageWithFallback(locale, namespace)
}
业务页面不用重新改。
6. CDN 失败时要有多层兜底
CDN 可能超时、缺文件,也可能发生版本不同步。
加载顺序不能只有“请求 CDN,失败显示 key”。
比较稳妥的顺序是:
1. 读取当前版本的 IndexedDB 缓存
2. 请求 CDN 当前版本
3. 使用最近成功的旧版本缓存
4. 使用前端内置的核心语言包
5. 回退到默认语言
6. 使用 t() 调用处的中文默认文案
7. 最后才显示 key,并上报
调用时带默认文案:
t('common.save', '保存')
它不是用来代替语言包,而是最后一道兜底。就算路由漏加载了包,用户看到的也是“保存”,而不是 common.save。
缺 key 还要上报:
reportMissingI18nKey({
locale,
key,
path: window.location.pathname
})
兜底负责用户体验,监控负责让问题最终被修掉。
7. 语言包也要支持灰度和回滚
语言包独立发布之后,它已经和普通线上配置差不多了。
发布前至少要检查:
- JSON 能不能正常解析;
- 目标语言有没有漏 key;
- 插值参数是否一致;
- 有没有删除还在使用的 key;
- namespace 和文件名是否匹配。
比如中文是:
{
"result": "成功 {success} 条,失败 {failed} 条"
}
英文只有 {success},漏了 {failed},发布时就应该拦住。
每个语言包还要有版本号。发现翻译有问题时,只回滚当前模块,不要让整个前端重新发版。
四、5 万处中文怎么分批推进?
1. 先扫描,再分类
扫描范围可以包括:
Vue、JSX 和 HTML 模板里的中文
TypeScript、JavaScript 字符串
表单 placeholder 和校验 message
消息提示和确认框
枚举的 name、label、title
路由标题
导出和打印配置
图片文件名和静态资源
但扫描结果不能直接自动替换。
普通按钮可以自动处理,展示文字参与判断、打印配置和 HTML 弹窗则应该先列出来,由开发者判断。
2. 公共能力和业务模块分开负责
一类人负责公共底座:
- 加载器;
- 路由;
- 请求错误;
- 公共组件;
- 共享枚举;
- 扫描和 CI;
- 缓存策略。
另一类人按业务模块认领:
- 列表;
- 详情;
- 弹窗;
- 表单;
- 模块私有枚举;
- 导入导出。
一个业务入口下的页面尽量由同一个人或同一小组负责。这样语言包边界清楚,也能减少多人同时改一个大 JSON。
3. 不开长期大分支
一个模块改完就合并。
公共组件有变化时单独提 PR,别顺手混进业务页面的几千行改动里。
这样做不是为了追求提交数量,而是为了让每次改动都能单独验证、单独回滚。
4. 插件、扫描脚本和 CI 各自做什么?
这三者不是一回事。
编辑器插件:帮助开发时少写错
编辑器插件适合做:
- key 自动补全;
- 鼠标悬停查看翻译;
- 从代码跳到语言包;
- 提醒当前 key 可能不存在。
它能提高开发效率,但只是个人本地工具。有人没装、没打开,检查就不存在,所以不能把它当成最终质量门禁。
ESLint 或框架插件:检查代码结构
这类插件适合检查:
- 模板里是否新增了裸文本;
t()使用方式是否符合规则;- key 是否使用了不允许的格式;
- 某些静态 key 是否不存在。
它们更懂代码语法,比单纯正则更准确。
但插件通常不知道一个 name 是展示文字还是接口参数,也看不出“翻译后的标题正在参与业务判断”。
自定义扫描脚本:补业务规则
老项目仍然需要自己的脚本,检查项目特有的问题,例如:
展示字段与 *I18nKey 是否配对
翻译结果是否写入 Store 或缓存
中文是否参与 ===、includes、switch
语言包的 key 是否缺失
不同语言的插值参数是否一致
当前模块是否仍有未处理中文
可以把命令统一起来:
{
"scripts": {
"i18n:lint": "eslint src --max-warnings=0",
"i18n:scan": "node scripts/scan-i18n.mjs",
"i18n:check": "pnpm i18n:lint && pnpm i18n:scan"
}
}
上面的名字只是通用示例,重点是给本地和 CI 提供同一个入口。
CI:保证每个人都必须检查
CI 不需要重新实现扫描逻辑,只负责执行统一命令:
- run: pnpm install --frozen-lockfile
- run: pnpm i18n:check
这样不管开发者有没有安装编辑器插件,提交代码后都会经过同一套检查。
简单说:
编辑器插件负责提前提醒
ESLint 插件负责看代码结构
扫描脚本负责项目特殊规则
CI 负责确保谁都不能跳过
5. 规则不要第一天就全部阻断
改造刚开始时,旧中文正在减少,新需求又会继续增加。
治理可以分三步:
第一步:只输出报告
先告诉大家新增了哪些中文,不阻断提交。
这个阶段主要用来调整扫描规则、减少误报。
第二步:只检查改动行
不要求一次清完所有历史问题,只限制新代码继续增加技术债。
第三步:核心模块稳定后再阻断
等公共组件和常见写法都准备好,再把规则收紧。
第一天就让 5 万处历史中文全部阻断,只会逼着大家关闭检查。
五、怎么判断一个模块真的改完了?
不能只打开列表页,看按钮变成英文就算完成。
验收至少分成四部分。
1. 文案是否完整
检查:
- 页面标题和路由标题;
- 侧栏、面包屑和页签;
- 表单 label、placeholder 和校验;
- 表格列和筛选项;
- 消息提示和确认框;
- 接口错误;
- 异步任务;
- 导出文件名;
- 图片里的文字。
2. 静态检查是否通过
至少执行:
pnpm i18n:check
确认:
- 当前模块没有未处理的中文;
- key 没有缺失;
- 插值参数一致;
- 没有新增展示文字判断;
- 没有把翻译结果写进 Store 和缓存。
插件显示“没有错误”还不够,CI 也必须通过。
3. 页面和布局是否正常
英文、葡萄牙语等语言的文案通常比中文长。
需要实际检查:
- 按钮是否被撑开;
- 表头是否换行;
- 筛选项是否遮挡;
- 弹窗是否溢出;
- 空状态和图片是否正确;
- 窄屏下是否还能操作。
静态插件只能看代码,发现不了这些布局问题。
4. 业务行为是否没有变化
这是最容易被忽略的一项。
切换语言后,要重新验证:
- 选择框传给接口的值是否没变;
- 表格行选择和按钮权限是否正常;
- 路由跳转是否仍按稳定 path 或 code;
- 缓存恢复后是否还显示旧语言;
- 导出和打印是否符合各自的语言规则;
- CDN 或语言包缺失时是否能正常使用。
国际化最怕的不是“还有一个地方没翻译”,而是“看起来翻译成功了,操作行为却变了”。
总结
做完这轮设计后,我对老项目国际化最大的感受是:
5 万处中文只是表面上的工作量,真正的难度是这些文字已经和业务代码一起长了很多年。
如果只把它当翻译任务,大家会不停地补 key、改 JSON,最后留下很多新的隐患。
如果把它当架构改造,关注点就会变成:
- 展示文字和业务数据怎么分开;
- 公共组件怎么统一处理;
- Store 和缓存应该保存什么;
- 动态菜单和错误接口需要什么协议;
- 语言包怎么拆、怎么加载、怎么兜底;
- 多人怎么并行,新增问题怎么拦住;
- 改完以后怎么用插件、脚本、CI 和页面回归共同检查。
这些问题想清楚之后,具体用 Vue、React 还是其他框架,反而没有那么重要。
所以,面对一个有 5 万处中文的老项目,别急着全局搜索替换。
先找出哪些只是文字,哪些早已变成了业务的一部分。前者可以翻译,后者要先拆开。
这一步看起来慢,却能少掉很多后面更难排查的坑。