Plasmo 浏览器扩展国际化实践:基于 chrome.i18n 实现中英文多语言支持

前言 最近我在开发一个基于 Plasmo 的 Chrome MV3 浏览器扩展,项目主要用于统一管理 ChatGPT、Claude 等 AI 平台的对话记录,支持账号同步、搜索、收藏、标签、批量操作等功能。 随着功能逐渐完善,扩展里出现了大量 UI 文案、提示信息、错误信息。如果这些文本全部硬编码在...
Plasmo 浏览器扩展国际化实践:基于 chrome.i18n 实现中英文多语言支持
Plasmo 浏览器扩展国际化实践:基于 chrome.i18n 实现中英文多语言支持

前言

最近我在开发一个基于 Plasmo 的 Chrome MV3 浏览器扩展,项目主要用于统一管理 ChatGPT、Claude 等 AI 平台的对话记录,支持账号同步、搜索、收藏、标签、批量操作等功能。

随着功能逐渐完善,扩展里出现了大量 UI 文案、提示信息、错误信息。如果这些文本全部硬编码在组件里,后续维护会非常麻烦:

  • 想支持英文、中文等多语言时,需要到处改代码;
  • popup、content script、background 里都有文案,容易遗漏;
  • Chrome 扩展本身也有名称、描述等 manifest 文案,也需要国际化;
  • 文案带变量时,比如“已选中 5 项”,如果手动拼接,会不利于多语言适配。

因此,我最终使用 Chrome 扩展原生提供的 chrome.i18n API,再结合 Plasmo 的目录约定,给项目做了一套轻量、简单、够用的国际化方案。

本文就结合我的项目实践,介绍如何在 Plasmo 浏览器扩展中实现 i18n。


一、项目技术栈

我的项目是一个 Chrome MV3 扩展,主要技术栈如下:

  • Plasmo
  • React
  • TypeScript
  • Tailwind CSS
  • Chrome Extension Manifest V3

项目结构比较简单,所有源文件都放在项目根目录,没有额外的 src 目录。

核心文件大致如下:

my-first-extension/
├── popup.tsx
├── content.ts
├── background.ts
├── logic.ts
├── db.ts
├── utils/
│   └── i18n.ts
├── locales/
│   ├── en/
│   │   └── messages.json
│   └── zh_CN/
│       └── messages.json
├── package.json
└── ...

其中和国际化相关的主要是:

utils/i18n.ts
locales/en/messages.json
locales/zh_CN/messages.json
package.json

二、Plasmo 项目中的语言包目录

在 Chrome 扩展规范中,最终构建产物里通常会有 _locales 目录,例如:

_locales/
├── en/
│   └── messages.json
└── zh_CN/
    └── messages.json

而在 Plasmo 项目开发阶段,我们可以直接在项目中创建:

locales/
├── en/
│   └── messages.json
└── zh_CN/
    └── messages.json

Plasmo 构建时会处理这些语言资源,让它们符合 Chrome Extension 的国际化规范。

我的项目里目前支持两种语言:

locales/en/messages.json
locales/zh_CN/messages.json

其中:

  • en 是英文;
  • zh_CN 是简体中文。

三、在 manifest 中配置默认语言

Chrome 扩展要启用国际化,必须在 manifest 中声明 default_locale

在 Plasmo 项目里,可以直接在 package.jsonmanifest 字段里配置:

{
  "manifest": {
    "default_locale": "en",
    "permissions": [
      "storage",
      "alarms"
    ],
    "host_permissions": [
      "https://chatgpt.com/*",
      "https://claude.ai/*"
    ]
  }
}

这里我把默认语言设置为英文:

"default_locale": "en"

这意味着如果用户当前浏览器语言没有对应的语言包,Chrome 会回退使用英文语言包。


四、扩展名称和描述的国际化

浏览器扩展的名称、描述也可以使用 Chrome i18n 的占位符语法。

我的 package.json 中这样写:

{
  "name": "multi-ai-conversation-manager",
  "displayName": "__MSG_extName__",
  "description": "__MSG_extDescription__"
}

这里的重点是:

"displayName": "__MSG_extName__",
"description": "__MSG_extDescription__"

__MSG_xxx__ 是 Chrome 扩展国际化的特殊语法。

例如:

__MSG_extName__

会去当前语言包的 messages.json 中查找:

{
  "extName": {
    "message": "ChatGPT & Claude AI Conversation Manager"
  }
}

中文语言包中对应的是:

{
  "extName": {
    "message": "ChatGPT、Claude AI 对话管理器"
  }
}

这样一来,扩展在 Chrome 扩展管理页、商店信息或者 manifest 中展示时,就能根据用户浏览器语言自动切换名称和描述。


五、messages.json 的基本格式

Chrome 扩展的语言文件必须叫做 messages.json

基本格式如下:

{
  "key": {
    "message": "具体文案"
  }
}

比如英文语言包:

{
  "account_management": {
    "message": "Account Management"
  },
  "account_add": {
    "message": "Add Account"
  },
  "btn_confirm": {
    "message": "Confirm"
  },
  "btn_cancel": {
    "message": "Cancel"
  }
}

中文语言包:

{
  "account_management": {
    "message": "账号管理"
  },
  "account_add": {
    "message": "添加账号"
  },
  "btn_confirm": {
    "message": "确认"
  },
  "btn_cancel": {
    "message": "取消"
  }
}

这里需要注意:

不同语言包里的 key 要保持一致。

也就是说,英文里有:

"account_add"

中文里也必须有:

"account_add"

否则运行时调用这个 key 时,某些语言环境下就可能取不到翻译。


六、封装一个统一的 t 函数

虽然可以在代码里直接写:

chrome.i18n.getMessage("btn_confirm")

但如果项目中大量使用这个 API,会有几个问题:

  1. 写起来比较长;
  2. 后续如果想增加 fallback 逻辑,需要到处改;
  3. popup、content script、background 都会重复使用;
  4. 不方便统一处理参数替换。

所以我在项目中封装了一个简单的工具函数。

文件位置:

utils/i18n.ts

代码如下:

/**
 * Chrome 扩展国际化工具函数
 * 封装 chrome.i18n API,提供类型安全的文本获取方法
 */

/**
 * 获取国际化文本
 * @param key - messages.json 中定义的 key
 * @param substitutions - 可选的替换参数,字符串或字符串数组
 * @returns 翻译后的文本,如果 key 不存在则返回 key 本身
 */
export function t(key: string, substitutions?: string | string[]): string {
  return chrome.i18n.getMessage(key, substitutions) || key
}

这个函数做了两件事:

第一,简化调用:

t("btn_confirm")

比下面这样更简洁:

chrome.i18n.getMessage("btn_confirm")

第二,增加 fallback:

return chrome.i18n.getMessage(key, substitutions) || key

如果某个 key 没有找到,直接返回 key 本身。这样至少页面不会显示空字符串,调试时也很容易发现哪个 key 没配置。


七、在 React Popup 页面中使用 i18n

我的扩展主要 UI 在 popup.tsx 中,它是一个 React 页面。

首先引入工具函数:

import { t } from "./utils/i18n"

然后就可以在 JSX 中使用:

<button>
  {t("account_add")}
</button>

或者用于错误提示:

return (
  <div className="p-4 text-red-500 text-sm">
    {t("errorBoundaryMessage")}
  </div>
)

对应的英文语言包:

{
  "errorBoundaryMessage": {
    "message": "An error occurred. Please close and reopen the extension"
  }
}

对应的中文语言包:

{
  "errorBoundaryMessage": {
    "message": "出现错误,请关闭后重新打开扩展"
  }
}

这样,当用户浏览器语言是中文时,会显示中文;如果是英文环境,则显示英文。


八、在 Content Script 中使用 i18n

浏览器扩展不仅 popup 页面需要国际化,content script 也经常需要显示文案。

我的项目中,content.ts 会注入到 ChatGPT 或 Claude 页面中,用于检测当前登录账号。如果发现新账号,会在页面右下角显示一个提示卡片,引导用户同步账号。

同样先引入:

import { t } from "./utils/i18n"

然后在创建 DOM 时使用:

card.innerHTML = `
  <button class="close-btn" aria-label="${t('content_closeAriaLabel')}">&times;</button>
  <div class="header">
    <div class="text-content">
      <h3>${t('content_newAccountTitle')}</h3>
      <p>${t('content_newAccountDesc', platformName)}</p>
    </div>
  </div>
  <div class="actions">
    <button class="btn-ignore">${t('content_btnIgnore')}</button>
    <button class="btn-add">${t('content_btnSync')}</button>
  </div>
`

这里有一个带变量的文案:

t("content_newAccountDesc", platformName)

比如英文里可能是:

{
  "content_newAccountDesc": {
    "message": "A new $PLATFORM$ account was detected. Do you want to sync it now?",
    "placeholders": {
      "platform": {
        "content": "$1",
        "example": "ChatGPT"
      }
    }
  }
}

中文里可以是:

{
  "content_newAccountDesc": {
    "message": "检测到新的 $PLATFORM$ 账号,是否立即同步?",
    "placeholders": {
      "platform": {
        "content": "$1",
        "example": "ChatGPT"
      }
    }
  }
}

这样在代码里只需要传入平台名称:

t("content_newAccountDesc", "ChatGPT")

最终 Chrome 会自动把 $1 对应的内容替换进去。


九、在 Background 和业务逻辑中使用 i18n

除了 UI,后台逻辑里也会产生提示信息或错误信息。

比如消息处理器、同步逻辑、错误封装中,也可以统一使用:

import { t } from "~utils/i18n"

或:

import { t } from "./utils/i18n"

然后:

throw new Error(t("error_accountMismatch"))

或者:

return {
  success: false,
  error: t("error_syncFailed")
}

这样有一个好处:

前端展示的错误信息和后台返回的错误信息,都可以使用同一套语言包管理。

对于浏览器扩展来说,popup、content script、background 是不同运行环境,如果不统一管理文案,后期维护会很痛苦。


十、带参数文案的写法

国际化里最常见的问题之一,就是文案里带变量。

比如:

已选中 5 项

不能简单写成:

"已选中 " + count + " 项"

因为不同语言的语序可能不一样。

正确做法是放到语言包中:

英文:

{
  "batch_selectedCount": {
    "message": "$COUNT$ items selected",
    "placeholders": {
      "count": {
        "content": "$1",
        "example": "5"
      }
    }
  }
}

中文:

{
  "batch_selectedCount": {
    "message": "已选中 $COUNT$ 项",
    "placeholders": {
      "count": {
        "content": "$1",
        "example": "5"
      }
    }
  }
}

代码中调用:

t("batch_selectedCount", String(selectedCount))

如果有多个参数,也可以传数组:

t("error_accountMismatch", [oldEmail, newEmail])

语言包中可以使用:

{
  "error_accountMismatch": {
    "message": "账号不匹配:当前账号是 $CURRENT$,目标账号是 $TARGET$",
    "placeholders": {
      "current": {
        "content": "$1",
        "example": "user1@example.com"
      },
      "target": {
        "content": "$2",
        "example": "user2@example.com"
      }
    }
  }
}

十一、推荐的 key 命名方式

随着项目变大,语言包会越来越长。如果 key 命名不规范,很容易混乱。

我在项目中采用了按业务模块前缀命名的方式,例如:

theme_light
theme_dark
theme_system

account_management
account_add
account_delete
account_sync

batch_manage
batch_addTag
batch_removeTag
batch_selectedCount

tag_management
tag_create
tag_rename
tag_delete

conversation_notFound
conversation_total
conversation_copyLink

content_newAccountTitle
content_newAccountDesc
content_btnIgnore
content_btnSync

errorBoundaryMessage
error_syncFailed

这种命名方式有几个优点:

  1. 一眼能看出文案属于哪个模块;
  2. 搜索方便;
  3. 不容易和其他模块冲突;
  4. 语言包比较容易维护。

我个人不太建议全部写成非常短的 key,例如:

title
desc
button
message

因为项目一大,这些 key 很快就会失去语义。


十二、Chrome i18n 的语言选择机制

使用 chrome.i18n 后,不需要我们自己判断用户语言。

Chrome 会根据用户浏览器语言自动选择语言包。

比如项目里有:

locales/en/messages.json
locales/zh_CN/messages.json

如果用户浏览器语言是中文简体,Chrome 会优先使用:

zh_CN

如果用户浏览器语言是英文,则使用:

en

如果没有找到对应语言,会回退到 manifest 中配置的:

"default_locale": "en"

所以在大多数情况下,我们不需要自己写:

navigator.language

也不需要自己维护语言切换逻辑。


十三、Plasmo i18n 实现流程总结

整体流程可以总结为五步。

1. 创建语言包目录

locales/
├── en/
│   └── messages.json
└── zh_CN/
    └── messages.json

2. 在 package.json 中配置默认语言

{
  "manifest": {
    "default_locale": "en"
  }
}

3. 使用 __MSG_xxx__ 国际化扩展名称和描述

{
  "displayName": "__MSG_extName__",
  "description": "__MSG_extDescription__"
}

4. 封装 i18n 工具函数

export function t(key: string, substitutions?: string | string[]): string {
  return chrome.i18n.getMessage(key, substitutions) || key
}

5. 在 popup、content script、background 中统一调用

t("btn_confirm")
t("batch_selectedCount", String(count))
t("content_newAccountDesc", platformName)

十四、这种方案的优点

我目前这个项目采用的是原生 chrome.i18n 方案,而不是引入 i18nextreact-intl 之类的第三方库。

原因很简单:对于浏览器扩展来说,原生方案已经足够好用。

它的优点包括:

1. 不需要额外依赖

不需要安装额外 npm 包,减少 bundle 体积。

2. 和 Chrome 扩展天然集成

manifest 中的名称、描述都可以直接使用 __MSG_xxx__

3. popup、content script、background 都能用

只要在扩展环境中,都可以调用:

chrome.i18n.getMessage()

4. 构建简单

Plasmo 会处理扩展构建,不需要自己手动生成 _locales

5. 维护成本低

对于中小型浏览器扩展项目,这种方案非常轻量。


十五、需要注意的坑

1. 必须配置 default_locale

如果使用了语言包,但是 manifest 没有配置:

"default_locale": "en"

扩展可能无法正确加载国际化资源。

2. key 必须在所有语言包中保持一致

比如英文有:

"btn_confirm"

中文也应该有:

"btn_confirm"

否则在某些语言下会取不到翻译。

3. messages.json 格式不能随便写

Chrome i18n 不是普通 JSON 字典,不能写成:

{
  "btn_confirm": "Confirm"
}

必须写成:

{
  "btn_confirm": {
    "message": "Confirm"
  }
}

4. 占位符要用 $1$2

比如:

{
  "conversation_total": {
    "message": "$COUNT$ conversations",
    "placeholders": {
      "count": {
        "content": "$1",
        "example": "10"
      }
    }
  }
}

代码中:

t("conversation_total", "10")

5. 不建议在代码里拼接多语言句子

不推荐:

t("selected") + count + t("items")

推荐:

t("batch_selectedCount", String(count))

因为不同语言的语序可能不一样。

6. content script 中使用 innerHTML 时要注意安全

如果语言包内容完全由开发者自己维护,风险较小。但如果文案来自用户输入,就不要直接拼进 innerHTML

我的项目中的语言包是静态文件,由开发者维护,所以可以用于构造提示卡片。但如果要插入用户输入,最好使用 textContent 或做好转义。


十六、适合继续优化的方向

目前这个方案已经可以满足我的项目需求,不过后续还可以继续优化。

1. 给 key 增加 TypeScript 类型约束

现在的 t 函数是:

export function t(key: string, substitutions?: string | string[]): string

也就是说 key 是普通字符串。

如果写错了:

t("btn_confim")

TypeScript 不会报错,只有运行时才会发现。

后续可以自动从 messages.json 生成类型,例如:

type I18nKey =
  | "btn_confirm"
  | "btn_cancel"
  | "account_add"
  | "account_delete"

然后改成:

export function t(key: I18nKey, substitutions?: string | string[]): string {
  return chrome.i18n.getMessage(key, substitutions) || key
}

这样可以在开发阶段提前发现 key 拼写错误。

2. 编写脚本检查多语言 key 是否一致

可以写一个 Node.js 脚本,对比:

locales/en/messages.json
locales/zh_CN/messages.json

检查两个文件的 key 是否完全一致。

比如:

en 中有但 zh_CN 中没有
zh_CN 中有但 en 中没有

这对项目变大后非常有用。

3. 拆分语言包

messages.json 变得非常大时,可以按模块维护源文件,例如:

i18n-source/
├── account.json
├── batch.json
├── tag.json
├── content.json
└── error.json

然后通过脚本合并生成最终的:

locales/en/messages.json
locales/zh_CN/messages.json

不过对于大多数浏览器扩展项目,一个 messages.json 也完全够用。


十七、完整示例

下面给一个简化版完整示例。

package.json

{
  "name": "multi-ai-conversation-manager",
  "displayName": "__MSG_extName__",
  "description": "__MSG_extDescription__",
  "manifest": {
    "default_locale": "en",
    "permissions": [
      "storage",
      "alarms"
    ]
  }
}

locales/en/messages.json

{
  "extName": {
    "message": "ChatGPT & Claude AI Conversation Manager"
  },
  "extDescription": {
    "message": "Unified management for ChatGPT and Claude conversations"
  },
  "btn_confirm": {
    "message": "Confirm"
  },
  "btn_cancel": {
    "message": "Cancel"
  },
  "batch_selectedCount": {
    "message": "$COUNT$ items selected",
    "placeholders": {
      "count": {
        "content": "$1",
        "example": "5"
      }
    }
  }
}

locales/zh_CN/messages.json

{
  "extName": {
    "message": "ChatGPT、Claude AI 对话管理器"
  },
  "extDescription": {
    "message": "统一管理 ChatGPT、Claude 平台的 AI 对话"
  },
  "btn_confirm": {
    "message": "确认"
  },
  "btn_cancel": {
    "message": "取消"
  },
  "batch_selectedCount": {
    "message": "已选中 $COUNT$ 项",
    "placeholders": {
      "count": {
        "content": "$1",
        "example": "5"
      }
    }
  }
}

utils/i18n.ts

export function t(key: string, substitutions?: string | string[]): string {
  return chrome.i18n.getMessage(key, substitutions) || key
}

popup.tsx

import { t } from "./utils/i18n"

export default function Popup() {
  const selectedCount = 5

  return (
    <div>
      <button>{t("btn_confirm")}</button>
      <button>{t("btn_cancel")}</button>

      <p>{t("batch_selectedCount", String(selectedCount))}</p>
    </div>
  )
}

结语

在 Plasmo 浏览器扩展项目中实现 i18n,并不一定要引入复杂的国际化框架。

对于大多数 Chrome 扩展来说,直接使用浏览器原生的 chrome.i18n API 就已经足够:

  • manifest 文案使用 __MSG_xxx__
  • 页面和脚本中使用 chrome.i18n.getMessage()
  • 封装一个简单的 t() 函数;
  • 使用 locales/en/messages.jsonlocales/zh_CN/messages.json 管理语言包;
  • 对变量文案使用 placeholders。

这种方式简单、轻量、和浏览器扩展生态天然兼容,非常适合 Plasmo 架构下的扩展开发。

我目前的项目就是采用这种方案,同时覆盖了 popup 页面、content script、background 消息处理和错误提示。整体使用下来,维护成本低,也方便后续继续增加更多语言支持。

1 个帖子 - 1 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文