5 万处中文的老项目实现国际化,如何用架构思维完成改造?

前言

最近在做一个老项目的国际化改造。

项目跑了很多年,页面多、业务多、历史写法也多。我们先用脚本扫了一遍,需要处理的中文大约有 5 万处。

看到这个数字,第一反应很容易是:多安排几个人,把中文都换成 t(),再补几份语言包不就行了吗?

真动手之后才发现,事情远没有这么简单。

有些中文只是按钮上的字,直接翻译没问题;有些中文却被拿来做判断、传接口、存缓存。还有一些藏在动态菜单、公共组件、异步任务、导出文件名和打印模板里。

如果不先把这些情况分清楚,直接全局替换,页面可能是英文了,业务却悄悄坏了。

这篇文章不讲某个具体项目,也不会放真实业务代码。主要想聊一聊:面对一个约有 5 万处中文、还在持续迭代的老项目,我们是怎么把国际化从"翻译任务"变成"架构改造"的。

文中的代码都是重新写的通用示例。虽然示例用了常见前端写法,但整体思路不依赖 Vue、React 或其他具体框架。


一、总设计思路

1. 最难的不是翻译,而是把文字和业务拆开

先看一段老项目里很常见的代码:

ts 复制代码
const orderStatusList = [
    { id: 1, name: '待审核' },
    { id: 2, name: '已通过' }
]

if (currentStatusName === '待审核') {
    openReviewDialog()
}

表面上看,待审核 是一段展示文案, 实际上,它还参与了业务判断。

如果只把列表里的 name 翻译成 Pending Review,判断就会失效,而且控制台不会报任何错。

这类问题比漏翻一个按钮严重得多。漏翻一眼就能看到,逻辑失效可能要等到线上用户操作时才会暴露。

所以我们先定了一条最重要的规则:

翻译只负责显示,不能参与传参、缓存和业务判断。

判断要使用稳定的 idcodekey

ts 复制代码
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 万个翻译任务

扫描工具只能告诉我们哪里有中文,不能判断中文是做什么的。

比如:

ts 复制代码
const title = '订单管理'
const colorName = '中国红'
const fileName = '订单列表'
const userRemark = '用户自己填写的中文'

它们的处理方式完全不同:

  • title 是界面文案,需要翻译;
  • colorName 可能是业务值,不能乱改;
  • fileName 要先确认导出语言规则;
  • 用户自己填写的内容通常不翻译。

所以第一步不是安排大家批量替换,而是扫描、分类和定边界。

我们把中文大致分为几类:

text 复制代码
普通页面文案
共享枚举和配置
动态菜单与后端消息
Store 和缓存里的展示文字
导出、打印、图片等特殊输出
用户自己创建的内容

只有第一类最适合直接转成 t()

3. 不追求一次做完

面对 5 万处中文,最危险的计划就是"这个版本全部改完"。

项目还在正常迭代,新需求每天都可能继续增加中文。公共组件、路由和请求层又会被很多页面共用。大分支拖得越久,冲突和回归风险越高。

我们把改造拆成四个阶段。

第一阶段:先搭底座,再跑一个完整试点

先处理:

  • 语言初始化;
  • 语言包加载;
  • 路由标题;
  • 组件库语言;
  • 公共选择器和筛选组件;
  • 请求错误;
  • 切换语言和缓存清理;
  • 缺少翻译时的兜底。

然后选一个包含列表、详情、筛选、弹窗和消息提示的业务模块跑通。

不要只拿登录页试。

登录页当然简单,但它验证不了动态菜单、共享枚举、表格配置和复杂状态。

第二阶段:按业务模块逐个迁移

语言包先跟前端代码一起发布。

一个模块改完就合并,不等待其他模块。这样可以正常上线,也不会形成一个几个月都合不进去的大分支。

第三阶段:再把语言包搬到 CDN

等本地语言包的目录、key 和加载方式稳定后,再切换到 CDN。

业务页面不应该因为语言包换了存放位置而重新改一遍。

第四阶段:最后建设语言管理后台

规则稳定后,再支持在线编辑、版本发布、灰度和回滚。

如果一开始就做管理后台,前端的 namespace 和 key 规则还在变化,后台大概率会跟着返工。

4. 页面不关心语言包放在哪里

业务代码只依赖两个能力:

ts 复制代码
loadNamespaces(locale, namespaces)
t(key, defaultMessage)

至于语言包现在放在本地,还是以后从 CDN 拉,页面不需要知道。

整体关系大概是:

text 复制代码
页面和公共组件
    ↓
统一的 i18n 运行时
    ↓
语言包加载器
    ├─ 本地 JSON
    ├─ CDN
    ├─ IndexedDB 缓存
    └─ 本地兜底包

这样做的好处很直接:前两期先把业务迁移跑稳,第三期只换加载器,不再大面积碰业务页面。


二、老项目国际化有哪些特殊难点?

下面这些问题,才是老项目改造里真正费时间的地方。

每个问题都按同一个顺序说:原来怎么写、为什么会坏、最后怎么改。

1. 共享枚举不能直接改成 t()

如果一组选项只在当前页面使用,不导出,也不进缓存,直接翻译没有问题:

ts 复制代码
const weightOptions = [
    {
        value: 'actual',
        name: t('settings.weight.actual', '使用实际重量')
    },
    {
        value: 'estimated',
        name: t('settings.weight.estimated', '使用预估重量')
    }
]

但共享枚举不能照搬这种写法:

ts 复制代码
// 不建议在共享模块里直接这样写
export const orderTypeList = [
    { code: 'normal', name: t('orders.type.normal') }
]

老项目里的共享 name 经常不只负责展示,还可能被用于:

  • 接口参数;
  • 默认配置;
  • 缓存合并;
  • Map 的 key;
  • 字符串拼接;
  • 条件判断。

name 改成英文后,这些地方会一起变化。

共享枚举更适合保留原值,再补一个 key:

ts 复制代码
export const orderTypeList = [
    {
        code: 'normal',
        name: '普通订单',
        nameI18nKey: 'orders.type.normal'
    }
]

展示时生成一份新数组:

ts 复制代码
const displayOrderTypes = orderTypeList.map(item => ({
    ...item,
    name: resolveI18nText(item, 'name')
}))

原数组不改,业务逻辑继续使用 code

2. 中文展示文字可能正在控制业务行为

下面这种写法非常隐蔽:

ts 复制代码
const noClickColumns = ['操作']

if (!noClickColumns.includes(column.label)) {
    selectRow(row)
}

列头翻译成 Actions 后,"操作"列也会触发行选择。

正确做法是看稳定字段:

ts 复制代码
const noClickColumnKeys = ['actions']

if (!noClickColumnKeys.includes(column.property)) {
    selectRow(row)
}

改造前最好专项扫描:

text 复制代码
column.label === '中文'
dialog.title === '中文'
route.meta.title === '中文'
name === '中文'
translatedText.includes(...)

凡是翻译后的文本参与 ===includesswitch,都要重点看。

3. 公共组件一改,可能影响几百个页面

大项目里,很多页面都在使用相同的选择器、筛选栏、表格和日期组件。

如果每个页面都手写一遍:

ts 复制代码
options.map(item => ({
    ...item,
    name: t(item.nameI18nKey)
}))

几个月后肯定会出现很多不同版本。

更省事的做法是让公共组件认识 nameI18nKey

ts 复制代码
const getOptionLabel = (item: Record<string, string>) => {
    return resolveI18nText(item, props.labelField)
}

业务页面继续传原来的数据:

vue 复制代码
<SmartSelect
    v-model="status"
    :options="orderStatusList"
    label-field="name"
    value-field="code"
/>

一个公共组件改好,可以覆盖很多页面。

但公共组件的影响面也最大,所以必须先清理上一节提到的"展示文字参与判断"问题,再逐步放量验证。

4. Store 和缓存里存了已经翻译好的文字

下面这种写法很常见:

ts 复制代码
taskStore.addTask({
    title: '批量导出',
    columns: [
        { prop: 'operation', label: '执行操作' },
        { prop: 'reason', label: '失败原因' }
    ]
})

切换语言后,任务还在,里面的中文也还在。

更合理的是保存 key 和参数:

ts 复制代码
taskStore.addTask({
    titleI18nKey: 'tasks.batchExport',
    title: '批量导出',
    columns: [
        {
            prop: 'operation',
            labelI18nKey: 'tasks.operation',
            label: '执行操作'
        },
        {
            prop: 'reason',
            labelI18nKey: 'tasks.failureReason',
            label: '失败原因'
        }
    ]
})

任务组件渲染时再翻译。

同样的原则也适用于:

  • 面包屑;
  • 页签;
  • 表格列设置;
  • 通知中心;
  • 跨页面恢复的数据。

状态层保存"它是谁",展示层决定"现在怎么显示"。

5. 动态菜单不能靠中文名称反查

静态路由比较简单:

ts 复制代码
meta: {
    title: '订单管理',
    titleI18nKey: 'route.orders'
}

动态菜单麻烦一些,因为名称通常来自后端。

长期来看,后端最好返回稳定 key:

json 复制代码
{
  "path": "/orders",
  "name": "订单管理",
  "nameI18nKey": "menu.orders"
}

前端优先使用 nameI18nKey,没有时回退 name

如果后端短期改不了,可以先用权限码或菜单 code 做映射:

ts 复制代码
const menuI18nMap = {
    order_manage: 'menu.orders',
    inventory_read: 'menu.inventory'
}

但不要用中文菜单名做映射:

ts 复制代码
// 不建议
const menuMap = {
    订单管理: 'Order Management'
}

菜单一改名,映射就断了。同名菜单也没法区分。

6. 后端只返回一句中文,前端真的翻不了

接口如果只返回:

json 复制代码
{
  "msg": "当前记录已被其他用户修改"
}

前端没有可靠办法知道它对应哪个翻译。

更合适的协议是:

json 复制代码
{
  "code": 40901,
  "messageKey": "errors.resourceChanged",
  "params": {
    "resource": "订单"
  },
  "msg": "当前记录已被其他用户修改"
}

前端可以按这个顺序处理:

text 复制代码
后端 messageKey
    → 本地 code 对应的 key
    → HTTP 状态码对应的 key
    → 后端 msg
    → 通用错误提示

这样既能逐步改造,也不会因为后端还没改完就让旧接口不能用。

如果项目里有多套请求封装,它们要共用同一个错误解析函数。不然很容易出现 A 接口报错是英文,B 接口报错还是中文。

7. 拼接句子不能只翻译里面的词

中文代码里很常见:

ts 复制代码
const message = `是否对选中的 ${total} 条记录执行${actionName}?`

如果只翻译 actionName,整句话还是中文语序。

应该把完整句子放进语言包:

ts 复制代码
t(
    'orders.batchActionConfirm',
    '是否对选中的 {total} 条记录执行{action}?',
    {
        total,
        action: t(actionI18nKey, actionName)
    }
)

英文可以自己调整语序:

json 复制代码
{
  "batchActionConfirm": "Apply {action} to {total} selected records?"
}

国际化不是词语替换。不同语言的语序、单复数和表达习惯都可能不同。

8. 原生 confirm 和 HTML 弹窗不能机械套 t()

纯文字确认框很好处理:

ts 复制代码
await showConfirm(
    t('orders.deleteConfirm', '确认删除该订单吗?'),
    t('common.tip', '提示')
)

复杂情况就不一样了:

ts 复制代码
showMessage({
    useHtml: true,
    content: '<strong>账号已存在,<a onclick="goLogin()">立即登录</a></strong>'
})

这里把文案、HTML 和点击事件绑死在一个字符串里。

翻译后语序可能变化,标签位置也可能变化,还有安全问题。更合适的办法是做成正常组件,让文字走语言包,链接走真实事件。

原生 confirm() 也尽量换掉。它的确定、取消按钮通常跟浏览器或操作系统语言走,不一定跟应用内选择的语言一致。

9. 导出语言和界面语言不一定相同

页面切成英文,不代表导出的文件一定要英文。

有些用户用英文界面操作,但文件要发给中文团队;有些打印标签必须遵守固定格式,也不能跟着 UI 随便变化。

所以要先把几个概念分开:

text 复制代码
界面语言
导出语言
打印语言
用户自己填写的内容

导出工具可以同时支持 key 和旧文件名:

ts 复制代码
exportExcel({
    fileNameKey: 'exports.orderList.fileName',
    fileName: '订单列表',
    sheetNameKey: 'exports.orderList.sheetName',
    sheetName: '订单明细',
    data
})

打印字段也不要只留一个 name

ts 复制代码
interface PrintField {
    fieldCode: string
    name?: string
    nameI18nKey?: string
    printLabel?: string
}
  • fieldCode 用于内部识别;
  • nameI18nKey 用于编辑器界面;
  • printLabel 决定纸上真正打印什么。

如果直接把历史 name 全改成 t(),很可能界面翻译好了,打印模板却坏了。

10. 图片里的中文,语言包也救不了

PNG、JPG 里的中文无论怎么调用 t() 都没用,因为文字已经在图片像素里了。

一般有三种处理方式:

  1. 把文字从图片里拆出来,用普通 DOM 渲染;
  2. 每种语言准备一张图片;
  3. 明确这个资源不做多语言,并记录为例外。

能拆就尽量拆。每种语言维护一套图片,时间久了很容易漏更新。


三、具体怎么落地?

1. 语言包按业务模块拆,不按单个页面拆

语言包拆得太细,会出现很多小文件和请求。

全部放在一个大文件里也不行:

  • 首次加载大;
  • 多人修改容易冲突;
  • 改一个小文案也要更新整个包;
  • 很难知道某个 key 属于谁。

更合适的边界是:一个二级业务入口对应一个 namespace,它下面的列表、详情、编辑页、弹窗和导出都放在一起。

例如:

text 复制代码
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 加载

路由声明自己需要哪些语言包:

ts 复制代码
{
    path: '/orders',
    meta: {
        titleI18nKey: 'route.orders',
        i18nNamespaces: ['common', 'route', 'admin_orders']
    }
}

进入路由前统一加载:

ts 复制代码
router.beforeEach(async (to) => {
    const namespaces =
        to.meta.i18nNamespaces ?? ['common', 'route']

    await loadNamespaces(currentLocale(), namespaces)
})

加载器还要处理两个小问题:

  1. 同一个语言包不要重复加载;
  2. 同一时刻来了多个请求,不要重复发起。
ts 复制代码
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. 用适配层兼容新旧数据

老项目不适合一次性删除全部中文字段。

假设几百个页面都在读取:

ts 复制代码
item.name

如果一次性删掉 name,要求所有页面同时改完,风险太大。

迁移期允许:

ts 复制代码
{
    code: 'pending',
    name: '待处理',
    nameI18nKey: 'orders.status.pending'
}

已迁移的页面优先读 nameI18nKey,没迁移的页面继续显示中文 name

可以提供一个统一解析函数:

ts 复制代码
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 一一对应:

text 复制代码
name        → nameI18nKey
label       → labelI18nKey
title       → titleI18nKey
message     → messageI18nKey
placeholder → placeholderI18nKey

这样虽然多写了几个字段,但语义更清楚,也不容易与老项目里已有的 labelKeynameKey 混在一起。

4. 切换语言时,老项目可以选择刷新

我们最开始也想做无刷新切换。

后来盘了一下现有状态:

  • 动态路由缓存里有中文标题;
  • 面包屑存了最终文案;
  • 已打开页签存了标题;
  • 一些 Store 里有任务名称和表头;
  • 模块顶层常量已经执行过;
  • 组件库也要跟着换语言。

为了一个很低频的切换语言操作,让所有历史状态都支持实时响应,成本很高,也容易漏。

最后选择了更直接的方式:

text 复制代码
保存目标语言
    → 清掉含文案的状态
    → 刷新页面
    → 按新语言重新初始化
ts 复制代码
export const switchLocaleWithReload = (locale: string) => {
    localeStorage.set(locale)
    systemStore.resetForLocaleChange()
    window.location.reload()
}

这里不是把所有缓存都删掉。

Token、租户、用户偏好不能动。只清理动态路由、面包屑、页签标题、表格列标题、任务中心文案等带展示文字的内容。

刷新不是最炫的方案,但对很多老项目更稳。如果产品明确要求无刷新,再增加响应式热切换。

5. CDN 放到业务迁移稳定之后

国际化刚开始时,变动最大的是 key 和模块边界。

今天一个 key 放在 common,明天发现它应该属于订单模块;今天按页面拆包,过几天又决定按业务入口拆。

这时候先上 CDN、版本管理和发布后台,只会让调试链路更长。

先用本地 JSON 把这些事情跑顺:

  • namespace 怎么划;
  • key 怎么命名;
  • 路由怎么声明依赖;
  • 公共组件怎么解析;
  • 缺 key 怎么兜底。

等这些稳定后,只替换加载器:

ts 复制代码
export const loadMessage = async (
    locale: string,
    namespace: string
) => {
    if (!enableCdnI18n) {
        return loadLocalMessage(locale, namespace)
    }

    return loadCdnMessageWithFallback(locale, namespace)
}

业务页面不用重新改。

6. CDN 失败时要有多层兜底

CDN 可能超时、缺文件,也可能发生版本不同步。

加载顺序不能只有"请求 CDN,失败显示 key"。

比较稳妥的顺序是:

text 复制代码
1. 读取当前版本的 IndexedDB 缓存
2. 请求 CDN 当前版本
3. 使用最近成功的旧版本缓存
4. 使用前端内置的核心语言包
5. 回退到默认语言
6. 使用 t() 调用处的中文默认文案
7. 最后才显示 key,并上报

调用时带默认文案:

ts 复制代码
t('common.save', '保存')

它不是用来代替语言包,而是最后一道兜底。就算路由漏加载了包,用户看到的也是"保存",而不是 common.save

缺 key 还要上报:

ts 复制代码
reportMissingI18nKey({
    locale,
    key,
    path: window.location.pathname
})

兜底负责用户体验,监控负责让问题最终被修掉。

7. 语言包也要支持灰度和回滚

语言包独立发布之后,它已经和普通线上配置差不多了。

发布前至少要检查:

  • JSON 能不能正常解析;
  • 目标语言有没有漏 key;
  • 插值参数是否一致;
  • 有没有删除还在使用的 key;
  • namespace 和文件名是否匹配。

比如中文是:

json 复制代码
{
  "result": "成功 {success} 条,失败 {failed} 条"
}

英文只有 {success},漏了 {failed},发布时就应该拦住。

每个语言包还要有版本号。发现翻译有问题时,只回滚当前模块,不要让整个前端重新发版。


四、5 万处中文怎么分批推进?

1. 先扫描,再分类

扫描范围可以包括:

text 复制代码
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 是展示文字还是接口参数,也看不出"翻译后的标题正在参与业务判断"。

自定义扫描脚本:补业务规则

老项目仍然需要自己的脚本,检查项目特有的问题,例如:

text 复制代码
展示字段与 *I18nKey 是否配对
翻译结果是否写入 Store 或缓存
中文是否参与 ===、includes、switch
语言包的 key 是否缺失
不同语言的插值参数是否一致
当前模块是否仍有未处理中文

可以把命令统一起来:

json 复制代码
{
  "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 不需要重新实现扫描逻辑,只负责执行统一命令:

yaml 复制代码
- run: pnpm install --frozen-lockfile
- run: pnpm i18n:check

这样不管开发者有没有安装编辑器插件,提交代码后都会经过同一套检查。

简单说:

text 复制代码
编辑器插件负责提前提醒
ESLint 插件负责看代码结构
扫描脚本负责项目特殊规则
CI 负责确保谁都不能跳过

5. 规则不要第一天就全部阻断

改造刚开始时,旧中文正在减少,新需求又会继续增加。

治理可以分三步:

第一步:只输出报告

先告诉大家新增了哪些中文,不阻断提交。

这个阶段主要用来调整扫描规则、减少误报。

第二步:只检查改动行

不要求一次清完所有历史问题,只限制新代码继续增加技术债。

第三步:核心模块稳定后再阻断

等公共组件和常见写法都准备好,再把规则收紧。

第一天就让 5 万处历史中文全部阻断,只会逼着大家关闭检查。


五、怎么判断一个模块真的改完了?

不能只打开列表页,看按钮变成英文就算完成。

验收至少分成四部分。

1. 文案是否完整

检查:

  • 页面标题和路由标题;
  • 侧栏、面包屑和页签;
  • 表单 label、placeholder 和校验;
  • 表格列和筛选项;
  • 消息提示和确认框;
  • 接口错误;
  • 异步任务;
  • 导出文件名;
  • 图片里的文字。

2. 静态检查是否通过

至少执行:

bash 复制代码
pnpm i18n:check

确认:

  • 当前模块没有未处理的中文;
  • key 没有缺失;
  • 插值参数一致;
  • 没有新增展示文字判断;
  • 没有把翻译结果写进 Store 和缓存。

插件显示"没有错误"还不够,CI 也必须通过。

3. 页面和布局是否正常

英文、葡萄牙语等语言的文案通常比中文长。

需要实际检查:

  • 按钮是否被撑开;
  • 表头是否换行;
  • 筛选项是否遮挡;
  • 弹窗是否溢出;
  • 空状态和图片是否正确;
  • 窄屏下是否还能操作。

静态插件只能看代码,发现不了这些布局问题。

4. 业务行为是否没有变化

这是最容易被忽略的一项。

切换语言后,要重新验证:

  • 选择框传给接口的值是否没变;
  • 表格行选择和按钮权限是否正常;
  • 路由跳转是否仍按稳定 path 或 code;
  • 缓存恢复后是否还显示旧语言;
  • 导出和打印是否符合各自的语言规则;
  • CDN 或语言包缺失时是否能正常使用。

国际化最怕的不是"还有一个地方没翻译",而是"看起来翻译成功了,操作行为却变了"。


总结

做完这轮设计后,我对老项目国际化最大的感受是:

5 万处中文只是表面上的工作量,真正的难度是这些文字已经和业务代码一起长了很多年。

如果只把它当翻译任务,大家会不停地补 key、改 JSON,最后留下很多新的隐患。

如果把它当架构改造,关注点就会变成:

  • 展示文字和业务数据怎么分开;
  • 公共组件怎么统一处理;
  • Store 和缓存应该保存什么;
  • 动态菜单和错误接口需要什么协议;
  • 语言包怎么拆、怎么加载、怎么兜底;
  • 多人怎么并行,新增问题怎么拦住;
  • 改完以后怎么用插件、脚本、CI 和页面回归共同检查。

这些问题想清楚之后,具体用 Vue、React 还是其他框架,反而没有那么重要。

所以,面对一个有 5 万处中文的老项目,别急着全局搜索替换。

先找出哪些只是文字,哪些早已变成了业务的一部分。前者可以翻译,后者要先拆开。

这一步看起来慢,却能少掉很多后面更难排查的坑。

相关推荐
程序员黑豆1 小时前
鸿蒙应用开发之持久化存储解析:PersistentStorage / PersistenceV2 / preferences 选型与实战
前端·harmonyos
上海安当技术1 小时前
统一身份认证平台怎么落地?11 个异构业务系统接入 ASP 的完整实施路径
数据库·servlet·架构·kubernetes·jenkins
明月_清风1 小时前
🚀 OpenAI 数据代理架构全解析:从 600 PB 到自然语言的六层上下文工程
前端·后端·架构
明月_清风1 小时前
🚀 从 Foundry 到 AIP:Palantir 发生了什么变化?一篇文章全搞懂
前端·后端
西门啐血1 小时前
Vue 缓存之坑,变量赋值方式和响应式数据
前端·vue.js·缓存
涛涛ing2 小时前
2026 上半年,前端圈已经炸了五次
前端
hunterandroid2 小时前
Android 后台任务可靠性排查:从 WorkManager 观测到失败重试闭环
android·前端
skiyee2 小时前
🔥 oiyo & unibest = 又新又好的 uniapp 模板
前端·uni-app
xingren2 小时前
「眨眼」UI 特效 - 在 Winform/WPF/WinUI3/Avalonia/Web 的实现
前端