前言
最近我在开发一个基于 Plasmo 的 Chrome MV3 浏览器扩展,项目主要用于统一管理 ChatGPT、Claude 等 AI 平台的对话记录,支持账号同步、搜索、收藏、标签、批量操作等功能。
随着功能逐渐完善,扩展里出现了大量 UI 文案、提示信息、错误信息。如果这些文本全部硬编码在组件里,后续维护会非常麻烦:
- 想支持英文、中文等多语言时,需要到处改代码;
- popup、content script、background 里都有文案,容易遗漏;
- Chrome 扩展本身也有名称、描述等 manifest 文案,也需要国际化;
- 文案带变量时,比如“已选中 5 项”,如果手动拼接,会不利于多语言适配。
因此,我最终使用 Chrome 扩展原生提供的 chrome.i18n API,再结合 Plasmo 的目录约定,给项目做了一套轻量、简单、够用的国际化方案。
希望未来关于AI降智相关的法律能更加完善
[问与答] 我配齐了 AI 编程全家桶:提示词、Antigravity、OpenCode、Gemini 套餐+小米套餐,结果还是要疯狂改代码……
本文就结合我的项目实践,介绍如何在 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.json 的 manifest 字段里配置:
{
"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,会有几个问题:
- 写起来比较长;
- 后续如果想增加 fallback 逻辑,需要到处改;
- popup、content script、background 都会重复使用;
- 不方便统一处理参数替换。
所以我在项目中封装了一个简单的工具函数。
文件位置:
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')}">×</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
这种命名方式有几个优点:
- 一眼能看出文案属于哪个模块;
- 搜索方便;
- 不容易和其他模块冲突;
- 语言包比较容易维护。
我个人不太建议全部写成非常短的 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 方案,而不是引入 i18next、react-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.json、locales/zh_CN/messages.json管理语言包; - 对变量文案使用 placeholders。
这种方式简单、轻量、和浏览器扩展生态天然兼容,非常适合 Plasmo 架构下的扩展开发。
我目前的项目就是采用这种方案,同时覆盖了 popup 页面、content script、background 消息处理和错误提示。整体使用下来,维护成本低,也方便后续继续增加更多语言支持。
1 个帖子 - 1 位参与者