跪拜 Guibai
← Back to the summary

Internationalizing a Legacy Codebase Isn't a Translation Job — It's an Architecture Overhaul


theme: devui-blue

Foreword

Recently, I've been working on the internationalization (i18n) refactoring of an old project.

The project has been running for many years, with many pages, many business modules, and many historical coding styles. We first scanned it with a script and found that there are roughly tens of thousands of Chinese strings that need to be handled.

Seeing this number, the first reaction is often: just assign a few more people, replace all the Chinese with t(), and add a few language packs, right?

After actually starting, I realized things are far from that simple.

Some Chinese strings are just text on buttons, which can be translated directly without issue; but other Chinese strings are used for making judgments, passing to APIs, or storing in caches. Some are hidden in dynamic menus, shared components, asynchronous tasks, exported file names, and print templates.

If you don't first categorize these situations clearly and just do a global replacement, the pages might appear in English, but the business logic will quietly break.

This article won't talk about a specific project, nor will it show real business code. I mainly want to discuss: facing an old project with tens of thousands of Chinese strings that is still under continuous iteration, how we transformed internationalization from a "translation task" into an "architectural refactoring."

The code in the article is all rewritten as generic examples. Although the examples use common frontend patterns, the overall approach does not depend on Vue, React, or any specific framework.


1. Overall Design Approach

1. The hardest part isn't translation, but separating text from business logic

Let's look at a very common piece of code in an old project:

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

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

On the surface, 待审核 is a piece of display text. In reality, it also participates in business logic.

If you only translate the name in the list to Pending Review, the judgment will fail, and the console won't throw any errors.

This type of problem is much more serious than missing a button translation. A missed translation is visible at a glance, but a logic failure might only be exposed when an online user performs an operation.

So we first established the most important rule:

Translation is only responsible for display; it must not participate in parameter passing, caching, or business logic.

Judgments should use stable id, code, or key:

const orderStatusList = [
    {
        code: 'pending_review',
        name: '待审核',
        nameI18nKey: 'orders.status.pendingReview'
    },
    {
        code: 'approved',
        name: '已通过',
        nameI18nKey: 'orders.status.approved'
    }
]

if (currentStatus === 'pending_review') {
    openReviewDialog()
}

Three fields each do their own job:

Internationalization can change what the user sees, but it cannot change the data the system processes.

2. 50,000 Chinese strings do not directly equal 50,000 translation tasks

Scanning tools can only tell us where Chinese strings are, not what the Chinese strings are used for.

For example:

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

Their handling methods are completely different:

So the first step is not to arrange for everyone to do batch replacements, but to scan, categorize, and define boundaries.

We roughly divided the Chinese strings into several categories:

Regular page copy
Shared enums and configurations
Dynamic menus and backend messages
Display text in Store and cache
Special outputs like exports, prints, images
User-generated content

Only the first category is most suitable for direct conversion to t().

3. Don't aim to finish everything at once

Facing 50,000 Chinese strings, the most dangerous plan is "finish all changes in this version."

The project is still iterating normally, and new requirements may continue to add Chinese strings every day. Shared components, routes, and request layers are used by many pages. The longer a large branch drags on, the higher the risk of conflicts and regressions.

We broke the refactoring into four phases.

Phase 1: Build the foundation first, then run a complete pilot

First handle:

Then choose a business module that includes lists, details, filters, dialogs, and message notifications to run through completely.

Don't just test with the login page.

The login page is certainly simple, but it cannot validate dynamic menus, shared enums, table configurations, and complex states.

Phase 2: Migrate module by module

Language packs are first released together with the frontend code.

Merge a module as soon as it's done, without waiting for other modules. This allows normal releases and avoids creating a huge branch that can't be merged for months.

Phase 3: Move language packs to CDN

After the local language pack directory structure, keys, and loading methods are stable, switch to CDN.

Business pages should not need to be re-modified just because the storage location of language packs changed.

Phase 4: Build the language management backend last

After the rules are stable, support online editing, version releases, canary releases, and rollbacks.

If you build the management backend at the very beginning, the frontend namespace and key rules are still changing, and the backend will likely need rework.

4. Pages don't care where the language pack is stored

Business code only depends on two capabilities:

loadNamespaces(locale, namespaces)
t(key, defaultMessage)

Whether the language pack is currently stored locally or pulled from a CDN later, the page doesn't need to know.

The overall relationship is roughly:

Pages and shared components
    ↓
Unified i18n runtime
    ↓
Language pack loader
    ├─ Local JSON
    ├─ CDN
    ├─ IndexedDB cache
    └─ Local fallback pack

The benefit of this is straightforward: in the first two phases, stabilize the business migration first; in the third phase, only swap the loader, without touching business pages extensively again.


2. What are the special difficulties of internationalizing old projects?

The following issues are what truly consume time in old project refactoring.

Each issue is described in the same order: how it was originally written, why it breaks, and how it was finally fixed.

1. Shared enums cannot be directly changed to t()

If a set of options is only used on the current page, not exported, and not stored in cache, direct translation is fine:

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

But shared enums cannot copy this pattern:

// Not recommended to write directly in a shared module like this
export const orderTypeList = [
    { code: 'normal', name: t('orders.type.normal') }
]

In old projects, a shared name is often not just responsible for display, but may also be used for:

After changing name to English, these places will change together.

Shared enums are more suitable for retaining the original value and adding a key:

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

Generate a new array for display:

const displayOrderTypes = orderTypeList.map(item => ({
    ...item,
    name: resolveI18nText(item, 'name')
}))

The original array remains unchanged, and business logic continues to use code.

2. Chinese display text might be controlling business behavior

This kind of code is very subtle:

const noClickColumns = ['操作']

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

After the column header is translated to Actions, the "操作" column will also trigger row selection.

The correct approach is to look at stable fields:

const noClickColumnKeys = ['actions']

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

Before refactoring, it's best to do a targeted scan:

column.label === 'Chinese'
dialog.title === 'Chinese'
route.meta.title === 'Chinese'
name === 'Chinese'
translatedText.includes(...)

Anywhere translated text participates in ===, includes, switch, it must be carefully reviewed.

3. Changing a shared component can affect hundreds of pages

In large projects, many pages use the same selectors, filter bars, tables, and date components.

If every page manually writes:

options.map(item => ({
    ...item,
    name: t(item.nameI18nKey)
}))

After a few months, many different versions will inevitably appear.

A more efficient approach is to make shared components aware of nameI18nKey:

const getOptionLabel = (item: Record<string, string>) => {
    return resolveI18nText(item, props.labelField)
}

Business pages continue to pass the original data:

<SmartSelect
    v-model="status"
    :options="orderStatusList"
    label-field="name"
    value-field="code"
/>

Fixing one shared component can cover many pages.

But shared components also have the largest impact, so you must first clean up the "display text participating in judgments" issue mentioned in the previous section, then gradually roll out and verify.

4. Store and cache store already-translated text

This kind of code is very common:

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

After switching languages, the task is still there, and the Chinese text inside is also still there.

It's more reasonable to save the key and parameters:

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

Translate when the task component renders.

The same principle applies to:

The state layer saves "what it is," and the display layer decides "how to display it now."

5. Dynamic menus cannot rely on Chinese names for reverse lookup

Static routes are relatively simple:

meta: {
    title: '订单管理',
    titleI18nKey: 'route.orders'
}

Dynamic menus are more troublesome because the names usually come from the backend.

In the long term, the backend should ideally return a stable key:

{
  "path": "/orders",
  "name": "订单管理",
  "nameI18nKey": "menu.orders"
}

The frontend prioritizes using nameI18nKey, falling back to name if absent.

If the backend cannot be changed in the short term, you can first use permission codes or menu codes for mapping:

const menuI18nMap = {
    order_manage: 'menu.orders',
    inventory_read: 'menu.inventory'
}

But do not use Chinese menu names for mapping:

// Not recommended
const menuMap = {
    订单管理: 'Order Management'
}

If the menu name changes, the mapping breaks. Menus with the same name also cannot be distinguished.

6. If the backend only returns a Chinese sentence, the frontend truly cannot translate it

If the API only returns:

{
  "msg": "当前记录已被其他用户修改"
}

The frontend has no reliable way to know which translation it corresponds to.

A more suitable protocol is:

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

The frontend can process in this order:

Backend messageKey
    → Local key corresponding to code
    → Key corresponding to HTTP status code
    → Backend msg
    → Generic error message

This allows gradual refactoring without breaking old APIs before the backend is fully updated.

If the project has multiple request wrappers, they must share the same error parsing function. Otherwise, it's easy to end up with API A reporting errors in English while API B reports errors in Chinese.

7. Concatenated sentences cannot just translate the words inside

Very common in Chinese code:

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

If you only translate actionName, the whole sentence remains in Chinese word order.

The complete sentence should be placed in the language pack:

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

English can adjust its own word order:

{
  "batchActionConfirm": "Apply {action} to {total} selected records?"
}

Internationalization is not word replacement. Different languages may have different word orders, singular/plural forms, and expression habits.

8. Native confirm and HTML dialogs cannot mechanically apply t()

Plain text confirmation dialogs are easy to handle:

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

Complex situations are different:

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

Here, copy, HTML, and click events are bound together in one string.

After translation, the word order might change, tag positions might change, and there are security concerns. A more suitable approach is to make it a normal component, letting text go through the language pack and links use real events.

Native confirm() should also be replaced as much as possible. Its OK and Cancel buttons usually follow the browser or OS language, which may not match the language selected within the application.

9. Export language and UI language are not necessarily the same

Switching the page to English does not mean the exported file must be in English.

Some users operate in an English UI, but the files need to be sent to a Chinese team; some printed labels must adhere to a fixed format and cannot change arbitrarily with the UI.

So first, separate these concepts:

UI language
Export language
Print language
User-generated content

Export tools can support both keys and old file names simultaneously:

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

Print fields should also not only keep a name:

interface PrintField {
    fieldCode: string
    name?: string
    nameI18nKey?: string
    printLabel?: string
}

If you directly change all historical name to t(), the UI translation might be fine, but the print template will likely break.

10. Chinese in images cannot be saved by language packs

No matter how you call t(), Chinese in PNGs and JPGs cannot be changed because the text is already in the image pixels.

There are generally three ways to handle this:

  1. Extract the text from the image and render it with regular DOM;
  2. Prepare one image for each language;
  3. Explicitly decide this resource will not be multilingual and record it as an exception.

Extract whenever possible. Maintaining a set of images for each language makes it easy to miss updates over time.


3. How to implement specifically?

1. Split language packs by business module, not by individual page

Splitting language packs too finely results in many small files and requests.

Putting everything in one large file is also problematic:

A more suitable boundary is: one secondary business entry corresponds to one namespace, and its lists, details, edit pages, dialogs, and exports are all placed together.

For example:

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 only holds truly universal words, like save, cancel, confirm.

Don't see the word "status" everywhere and stuff it all into common. Order status and inventory status might be expressed differently in different languages; forcing them to share is actually harder to maintain.

2. Load using a unified loader

Routes declare which language packs they need:

{
    path: '/orders',
    meta: {
        titleI18nKey: 'route.orders',
        i18nNamespaces: ['common', 'route', 'admin_orders']
    }
}

Load uniformly before entering the route:

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

    await loadNamespaces(currentLocale(), namespaces)
})

The loader also needs to handle two minor issues:

  1. Don't load the same language pack repeatedly;
  2. If multiple requests come at the same time, don't initiate duplicates.
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
}

The cache key must include the language.

If you only record the namespace, after loading Chinese first and then switching to English, the loader will mistakenly think this pack has already been loaded.

3. Use an adaptation layer to be compatible with old and new data

Old projects are not suitable for deleting all Chinese fields at once.

Suppose hundreds of pages are reading:

item.name

If you delete name all at once and require all pages to be changed simultaneously, the risk is too high.

During the migration period, allow:

{
    code: 'pending',
    name: '待处理',
    nameI18nKey: 'orders.status.pending'
}

Migrated pages prioritize reading nameI18nKey, while unmigrated pages continue to display the Chinese name.

A unified resolution function can be provided:

export const resolveI18nText = (
    item: Record<string, string>,
    field = 'name'
) => {
    const key = item[`${field}I18nKey`]
    const fallback = item[field] ?? ''

    return key ? t(key, fallback) : fallback
}

Display fields and keys correspond one-to-one:

name        → nameI18nKey
label       → labelI18nKey
title       → titleI18nKey
message     → messageI18nKey
placeholder → placeholderI18nKey

Although this adds a few more fields, the semantics are clearer and less likely to be confused with existing labelKey, nameKey in the old project.

4. When switching languages, old projects can choose to refresh

We initially also wanted to implement a refresh-free switch.

Later, we took stock of the existing state:

For a very low-frequency language switch operation, making all historical states support real-time response is costly and easy to miss things.

Finally, we chose a more direct approach:

Save target language
    → Clear state containing text
    → Refresh the page
    → Re-initialize with the new language
export const switchLocaleWithReload = (locale: string) => {
    localeStorage.set(locale)
    systemStore.resetForLocaleChange()
    window.location.reload()
}

This doesn't mean deleting all caches.

Token, tenant, user preferences cannot be touched. Only clean up content containing display text, such as dynamic routes, breadcrumbs, tab titles, table column headers, and task center copy.

Refresh is not the flashiest solution, but it's more stable for many old projects. If the product explicitly requires no refresh, then add reactive hot-switching.

5. Move CDN after business migration is stable

At the beginning of internationalization, what changes most are keys and module boundaries.

Today a key is in common, tomorrow you find it should belong to the orders module; today you split packs by page, a few days later you decide to split by business entry.

If you rush to set up CDN, version management, and a publishing backend at this point, it only makes the debugging chain longer.

First, use local JSON to stabilize these things:

Once these are stable, only swap the loader:

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

    return loadCdnMessageWithFallback(locale, namespace)
}

Business pages don't need to be re-modified.

6. Multiple fallback layers when CDN fails

CDN might timeout, miss files, or have version mismatches.

The loading order cannot just be "request CDN, show key on failure."

A more reliable order is:

1. Read IndexedDB cache for the current version
2. Request CDN for the current version
3. Use the last successful old version cache
4. Use the frontend's built-in core language pack
5. Fallback to the default language
6. Use the Chinese default text at the `t()` call site
7. Finally, show the key and report it

Provide default text when calling:

t('common.save', '保存')

It's not a substitute for the language pack, but the last line of defense. Even if a route misses loading the pack, the user sees "保存" instead of common.save.

Missing keys should also be reported:

reportMissingI18nKey({
    locale,
    key,
    path: window.location.pathname
})

Fallbacks handle user experience; monitoring ensures problems are eventually fixed.

7. Language packs also need canary releases and rollbacks

After language packs are released independently, they are essentially like regular online configurations.

Before release, at least check:

For example, Chinese is:

{
  "result": "成功 {success} 条,失败 {failed} 条"
}

If English only has {success} and misses {failed}, it should be blocked at release time.

Each language pack should also have a version number. When a translation issue is found, only rollback the current module, without requiring the entire frontend to be re-released.


4. How to advance in batches with 50,000 Chinese strings?

1. Scan first, then categorize

The scan scope can include:

Chinese in Vue, JSX, and HTML templates
TypeScript, JavaScript strings
Form placeholders and validation messages
Message notifications and confirmation dialogs
Enum name, label, title
Route titles
Export and print configurations
Image file names and static resources

But scan results cannot be directly auto-replaced.

Regular buttons can be auto-processed, but display text participating in judgments, print configurations, and HTML dialogs should be listed first for developers to judge.

2. Separate responsibilities for shared capabilities and business modules

One group is responsible for the shared foundation:

Another group takes ownership by business module:

Pages under one business entry should ideally be handled by the same person or the same small team. This keeps language pack boundaries clear and reduces conflicts when multiple people modify a large JSON simultaneously.

3. No long-lived large branches

Merge as soon as a module is done.

When shared components change, submit a separate PR; don't casually mix it into thousands of lines of business page changes.

This isn't about pursuing a high commit count, but about ensuring each change can be independently verified and rolled back.

4. What do plugins, scan scripts, and CI each do?

These three are not the same thing.

Editor Plugin: Helps write less wrong code during development

Editor plugins are suitable for:

It can improve development efficiency, but it's only a personal local tool. If someone doesn't install it or doesn't turn it on, the check doesn't exist, so it cannot be treated as the final quality gate.

ESLint or Framework Plugin: Checks code structure

These plugins are suitable for checking:

They understand code syntax better and are more accurate than simple regex.

But plugins usually don't know if a name is display text or an API parameter, nor can they see that "a translated title is participating in business logic."

Custom Scan Script: Supplements business rules

Old projects still need their own scripts to check project-specific issues, for example:

Whether display fields are paired with *I18nKey
Whether translation results are written into Store or cache
Whether Chinese participates in ===, includes, switch
Whether language pack keys are missing
Whether interpolation parameters are consistent across languages
Whether the current module still has unhandled Chinese

Commands can be unified:

{
  "scripts": {
    "i18n:lint": "eslint src --max-warnings=0",
    "i18n:scan": "node scripts/scan-i18n.mjs",
    "i18n:check": "pnpm i18n:lint && pnpm i18n:scan"
  }
}

The names above are just generic examples; the point is to provide a single entry point for both local and CI.

CI: Ensures everyone must check

CI doesn't need to re-implement scan logic; it just executes the unified command:

- run: pnpm install --frozen-lockfile
- run: pnpm i18n:check

This way, regardless of whether the developer has installed an editor plugin, the code will go through the same set of checks after submission.

Simply put:

Editor plugin is responsible for early warnings
ESLint plugin is responsible for checking code structure
Scan script is responsible for project-specific rules
CI is responsible for ensuring no one can skip

5. Don't block everything on day one

At the beginning of the refactoring, old Chinese is decreasing, but new requirements will continue to add more.

Governance can be done in three steps:

Step 1: Only output reports

First, tell everyone what new Chinese has been added, without blocking commits.

This phase is mainly used to adjust scan rules and reduce false positives.

Step 2: Only check changed lines

Don't require clearing all historical issues at once; only restrict new code from continuing to add technical debt.

Step 3: Block after core modules are stable

Once shared components and common patterns are ready, tighten the rules.

Blocking all 50,000 historical Chinese strings on day one will only force everyone to disable the checks.


5. How to judge if a module is truly done?

You can't just open the list page, see the buttons are in English, and consider it complete.

Acceptance is at least divided into four parts.

1. Is the copy complete?

Check:

2. Do static checks pass?

At least execute:

pnpm i18n:check

Confirm:

It's not enough that the plugin shows "no errors"; CI must also pass.

3. Are pages and layouts normal?

Text in languages like English and Portuguese is usually longer than Chinese.

Need to actually check:

Static plugins can only look at code; they cannot find these layout issues.

4. Has business behavior not changed?

This is the most easily overlooked item.

After switching languages, re-verify:

The scariest thing about internationalization is not "there's still one place not translated," but "it looks like the translation succeeded, but the operational behavior has changed."


Summary

After completing this round of design, my biggest takeaway on internationalizing old projects is:

50,000 Chinese strings are just the surface workload; the real difficulty is that these texts have grown together with the business code for many years.

If you only treat it as a translation task, people will endlessly patch keys and modify JSONs, ultimately leaving many new hidden dangers.

If you treat it as an architectural refactoring, the focus shifts to:

Once these issues are thought through, whether you specifically use Vue, React, or another framework becomes much less important.

So, facing an old project with 50,000 Chinese strings, don't rush to global search and replace.

First, find out which are just text and which have long become part of the business. The former can be translated; the latter must be separated first.

This step seems slow, but it can save you from many pitfalls that are much harder to troubleshoot later.

Comments

Top 2 of 4 from juejin.cn, machine-translated. The original thread is authoritative.

用户4012709947342

OP's article is both hardcore and solid

天天鸭

?

小小程序元

Old project = mountain of shit? [look]

天天鸭

[facepalm]