国际化
启用语言在 config/i18n.ts,文案在 locales JSON。按产品需求增加语言并翻译后即可切换,无需另接 i18n 框架。
Saavo 模板已经内置多语言支持,默认提供英语、简体中文和繁体中文。不同语言通过 URL 前缀进行区分,页面文案则统一从对应的 Locale 文件中读取。
开发自己的产品时,通常不需要再引入额外的 i18n 库。只需要在配置中启用需要支持的语言,并补充对应的翻译内容;在页面和服务端代码中,则可以直接使用项目提供的 Localized、localized 和 serverLocalized 输出多语言文案。
已有功能
模板初始化后,以下国际化功能开箱即用:
| 功能 | 默认值 | 说明 |
|---|---|---|
| 启用语言 | en、zh-Hans、zh-Hant | config/i18n.ts |
| 默认语言 | en | 无前缀路由 |
| 其他语言前缀 | /{code}/... | 例如 /zh-Hans/docs |
| 文案文件 | locales/en.json 等 | en.json 是键和类型的权威来源 |
| 语言切换 | 页面顶部的语言切换器 | 在已启用语言之间切换 |
页面语言根据 URL 路径中的语言前缀确定,而 API 则通过 Accept-Language 请求头识别语言。虽然适配器中保留了 Locale Cookie 的配置名称,但当前语言检测流程并不会读取 Cookie,换句话说,切换语言仍应以 URL 和请求头为准,Cookie 不会影响语言切换。
除了页面文案,站点名称、套餐名称等配置内容也可以参与多语言处理。需要翻译的配置项可以使用 { value, key } 形式,其中 value 作为默认内容,key 则指向 Locale 文件中的对应翻译。
除此之外,浏览器端需要使用的配置统一从 @/libs/config/client 读取。deploy.ts、权益配置以及各类密钥等配置属于服务端信息,不应直接导入前端代码,以免被打包到浏览器中。
先体验语言切换
打开首页,使用语言切换器在英语和中文之间切换。默认语言的地址没有前缀:
/ 英语首页
/zh-Hans 简体中文首页
/zh-Hant 繁体中文首页
/docs 英语文档
/zh-Hans/docs 简体中文文档
模板注释里还预留了其他语种,包括 RTL 的 ar。它们默认未启用,不会出现在语言列表中。
提示
修改品牌名称时,需要同步更新所有已启用语言中的对应文案。如果只修改英语版本,中文页面仍然可能继续显示默认的 “Saavo Starter”。
配置多语言
语言列表在 config/i18n.ts 中配置,文案则统一保存在 locales/ 目录下,每个语言对应一个独立的 JSON 文件。
添加新语言
- 在
languages中取消注释或新增{ code, name, direction }。 - 增加
locales/{code}.json,键与locales/en.json对齐。 - 如有文档或博客,补齐
content/docs/{code}/和对应的博客目录。 - 翻译法律页、邮件以及产品配置中使用的所有 key。
- 对于 RTL 语言,设置
direction: 'rtl'。
defaultLanguage 决定无语言前缀路由使用的默认语言,因此不要轻易修改。更改后,原本默认指向英语的无前缀页面会切换为新的默认语言,现有英文访问路径也可能因此发生变化。
完整步骤见:
修改品牌和界面文案
如果只需要修改某一种语言的文案,直接编辑对应的 Locale JSON 文件即可。如果需要同步调整品牌名称等通用内容,则要同时更新所有已启用语言对应的翻译文件。例如,如果需要将产品名称从 “Saavo Starter” 改为 “My Product”,不仅需要更新 locales/en.json,还要同步修改 locales/zh-Hans.json 和 locales/zh-Hant.json。
配置中的 LocalizedText 可以同时包含 value 和 key。当配置了 key 时,系统会优先读取 Locale 文件中的翻译;如果当前语言缺少对应内容,则回退到 value 中的默认英文文案。
组件中需要展示给用户的固定文案,也应该统一放到 Locale 文件中管理,不要直接在 JSX 中硬编写中英文长句。例如,如果需要展示 “购买失败,请重试” 的错误提示,应该在 Locale 文件中定义一个 components.billing.purchaseFailed 键,而不是直接在组件中写死 “Purchase failed, please try again.”。
在业务代码中使用多语言
业务代码只用项目提供的三个 API,都从 @/components/server/Localized 导入,不要直接读 Locale JSON。
路径里的 server 只表示 <Localized /> 输出纯字符串、不需要额外交互,页面两端都能用;不是只能在服务端调用。localized() 同样可以在客户端组件和点击、提交等回调里用。只有 serverLocalized() 必须已经拿到 Ctx 或 WorkerCtx。
按文案出现的位置选 API:
| 位置 | API |
|---|---|
| JSX 里的可见文本 | <Localized /> |
aria-label、placeholder、alt、Toast、函数返回值 | localized() |
| 页面 SEO、API 响应、邮件等已有请求上下文的地方 | serverLocalized() |
JSX 文本:
import { Localized } from '@/components/server/Localized';
<h2>
<Localized
id="components.profileSettings.title"
default="Profile Settings"
/>
</h2>属性、Toast 等必须是字符串的位置:
import { localized } from '@/components/server/Localized';
import { toast } from 'react-toastify';
<img
alt={localized({
id: 'components.profileSettings.avatar.alt',
default: 'User avatar',
})}
/>
<input
placeholder={localized({
id: 'components.profileSettings.fullName.placeholder',
default: 'Enter your full name',
})}
/>
toast.error(localized({
id: 'components.billing.purchaseFailed',
default: 'Purchase failed, please refresh the page and try again.',
}));服务端页面、API 和邮件已经持有 c 时:
import { serverLocalized } from '@/components/server/Localized';
const pageTitle = serverLocalized({
c,
id: 'pages.home.title',
default: `${siteName} - A complete SaaS starter for Cloudflare`,
values: { siteName },
});id 必须是 locales/en.json 里已有的键,default 是缺翻译时的英文回退。邮件模板同样走 serverLocalized,例如 email.welcome.text。
需要插入姓名、数量等动态内容时,在 Locale 里保留完整句子,用 {name} 这类占位符,调用时通过 values 传入。不要把一句话拆成多段翻译再拼接。
localized({
id: 'components.account.products.cadence.everyMonths',
default: 'Every {count} months',
values: { count },
});日期、时间和金额不要写进翻译字符串里排版。先按当前语言格式化,再把结果放进 values。服务端用 c.var.getLocale();客户端组件一般由页面把 locale 传下来。金额用 formatMinorAmount。
import { formatMinorAmount } from '@/libs/utils/currency';
const date = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(paidAt);
const amount = formatMinorAmount(totalMinor, currency, locale);底层虽然基于 @intlify/core,但业务侧不要直接调它的 numberFormat / datetimeFormat,项目也没有为这两种格式单独配表。命名插值走上面的 values,数字和日期走 Intl。
翻译文案中的竖线要转义
目前 Saavo 的国际化功能内部使用 @intlify/core 处理翻译文本。这个库除了普通的变量插值,还支持复数等消息语法,其中 | 就有特殊含义。
在翻译文本中,未转义的 | 会被当作复数分支的分隔符,用来根据数量选择不同的文案。例如:
{
"itemCount": "One item | {count} items"
}传入不同数量时,会自动选择对应的内容:
// 输出 One item
localized({
id: 'itemCount',
values: { count: 1 },
});
// 输出 5 items
localized({
id: 'itemCount',
values: { count: 5 },
});常见的写法有两种:
| 文案形式 | 分支含义 |
|---|---|
单数文案 | 复数文案 | 数量为 1 时使用前者,其他情况使用后者 |
零个文案 | 单数文案 | 复数文案 | 分别处理 0、1 和其他数量 |
例如:
{
"itemCount": "No items | One item | {count} items"
}因此,如果你希望页面中真正显示 | 字符,就不能直接写在翻译文本中。
这个问题在普通文案里不太常见,但标题中很容易遇到,因为很多人习惯使用 | 分隔站点名称和页面标题。例如下面这种写法:
{
"pages": {
"home": {
"title": "{siteName} | Product Overview"
}
}
}这里的 | 会被翻译引擎识别为复数分支分隔符,最终可能只显示其中一部分内容。
如果需要显示真正的 |,应改为:
{
"pages": {
"home": {
"title": "{siteName} {'|'} Product Overview"
}
}
}这样 | 就会作为普通字符输出,不会再参与复数语法解析。
这个问题很容易在修改页面标题时遇到,所以使用 | 时需要特别注意。
上线检查
语言会直接出现在用户面前,上线前建议至少确认:
- 已启用语言都能通过语言切换器访问,URL 前缀正确。
-
locales/en.json、zh-Hans.json、zh-Hant.json的品牌名和关键页面文案已经换成自己的产品。 - 新增语言的 JSON 键与英语对齐,没有缺 key。
- 文档和博客在该语言下有内容,或你能接受缺页。
- 法律页、邮件主题和正文已翻译。
- 没有改动
defaultLanguage,除非已经确认无前缀路由的新语义。
建议每种启用语言都打开首页、登录、定价和一篇文档,而不是只看英语。
常见问题
接下来
根据接下来要开发的功能,可以继续阅读:
大多数产品走完本章后,只需要维护 locale 文件和内容目录。
新页面从一开始就用 Localized 输出文案,避免事后再抽字符串。