多语言第一步:独立产品如何做 i18n 与本地化

0阅读9分钟

从一个真实的 Bug 说起

我接手过一个出海 SaaS 的 Bug:用户从日本访问价格页,看到的价格是 $9.90,但日区定价应该是 ¥1,500。排查下来发现代码里把美元符号和数字直接拼在了模板字符串里,没有走格式化管道。这个问题在英语区完全看不出来,只在非英语语言环境下暴露。

这类问题不是「翻译没做好」,而是「国际化基础没打好」。很多人把 i18n 和 l10n 混为一谈,实际上它们是两个不同阶段的工作:

概念含义时机典型产出
国际化 (i18n)让代码结构能够适应不同语言和地区开发阶段翻译 Key 提取、Intl 格式化、路由设计
本地化 (l10n)针对具体语言做翻译和文化适配上线前后翻译文本、本地日期/货币、适配文化习惯
全球化 (g11n)i18n + l10n 的总称贯穿产品生命周期多语言产品完整落地

多数独立产品的第一版出海,往往两件事混在一起做,结果改一处翻译就破坏一处布局,加一种语言就要跑一遍全站回归。

我在过去一年把两个独立产品从零做到支持 5 种语言,踩了不少坑。这篇文章把我摸索出来的路径整理出来,重点讲「第一步怎么做」——选什么框架、文案怎么组织、路由怎么设计、SEO 怎么配合。

选框架:为什么我最终选了 next-intl

React 生态里的 i18n 框架很多,主流选择有 react-i18nextreact-intl(FormatJS)、next-intl 和较新的 Intlayer。它们的设计目标和适用场景不同,直接决定了后续维护成本。

维度react-i18nextreact-intl (FormatJS)next-intlIntlayer
定位框架无关的通用方案框架无关,ICU 消息语法专为 Next.js App Router 设计类型优先的声明式方案
Next.js App Router 兼容需手动适配,无官方路由集成需额外配置原生支持,官方推荐原生支持
翻译格式JSON / YAML / 自定义后端JSON (ICU MessageFormat)JSON (ICU 子集)TypeScript 声明式
TypeScript 支持需配置类型推导中等开箱即用的类型安全类型推导极强
插件生态丰富(后端、缓存、检测)一般精简够用较少
周下载量(2026.6)~3.5M~2.8M~800K~50K
适合场景大型团队、已有 i18n 基础设施需要完整 ICU 语法(复数、性别)Next.js 项目快速上手追求类型安全的中小项目

我做选择时主要考虑三点:

  1. App Router 原生支持——next-intl 从第一天就为 App Router 设计,react-i18next 则需要自行处理 Middleware 和路由前缀,配置量大。
  2. 类型安全——独立产品迭代快,翻译 Key 拼错如果只能运行时发现,修复成本太高。next-intlIntlayer 都能在编译期捕获。
  3. 心智负担——一个人维护多语言,没有精力搞复杂的插件链。next-intl 的设计哲学是「一个 Provider、一个文件夹、一个 Hook」,够用了。

如果你已经用 react-i18next 并且跑得稳,没必要迁移。但如果你从零开始,且技术栈是 Next.js App Router,我推荐先看 next-intl

案例一:文案散落各处导致翻译遗漏

这是我犯的第一个错误。产品初期赶进度,UI 文案直接写在组件里:

// ❌ 坏做法:文案硬编码在组件中
function PricingCard({ plan }: { plan: Plan }) {
  return (
    <div className="card">
      <h3>{plan.name}</h3>
      <p>每月 {plan.price} 元</p>
      <button>立即订阅</button>
      <small>随时取消,无隐藏费用</small>
    </div>
  )
}

这种做法的问题不只是「没法翻译」,更麻烦的是:当你想加日语时,根本不知道全站有多少地方写了中文。我当时用 grep 搜了一遍,漏掉了表单 placeholder、Toast 提示和邮件模板里的文本。

修复方式是把所有用户可见文本提取到翻译文件:

// ✅ 好做法:使用翻译函数,文案集中管理
import { useTranslations } from 'next-intl'
 
function PricingCard({ plan }: { plan: Plan }) {
  const t = useTranslations('pricing')
  return (
    <div className="card">
      <h3>{plan.name}</h3>
      <p>{t('monthlyPrice', { price: plan.price })}</p>
      <button>{t('subscribe')}</button>
      <small>{t('cancelAnytime')}</small>
    </div>
  )
}

对应的翻译文件结构:

// messages/zh.json
{
  "pricing": {
    "monthlyPrice": "每月 {price} 元",
    "subscribe": "立即订阅",
    "cancelAnytime": "随时取消,无隐藏费用"
  }
}
 
// messages/en.json
{
  "pricing": {
    "monthlyPrice": "${price}/month",
    "subscribe": "Subscribe",
    "cancelAnytime": "Cancel anytime, no hidden fees"
  }
}

关键差异:翻译文件里不只有静态文本,还有带占位符的动态内容(&#123;price&#125;)。占位符的顺序在不同语言中可以不同——英语把价格放前面($9.90/month),中文放后面(每月 9.90 元),翻译者自己调整语序,开发不用改代码。

案例二:日期和数字格式没走本地化管道

前面提到的日本价格 Bug 就属于这类问题。根源是我在模板里直接拼接了字符串,没有使用 Intl API:

// ❌ 坏做法:手动拼接日期和货币
function OrderSummary({ date, amount }: { date: Date; amount: number }) {
  return (
    <div>
      <p>订单日期:{date.getFullYear()}/{date.getMonth() + 1}/{date.getDate()}</p>
      <p>金额:${amount.toFixed(2)}</p>
    </div>
  )
}

这段代码在英语环境勉强能用(虽然日期格式也不是英语习惯),但到了德语区会出大问题——德国的日期格式是 DD.MM.YYYY,数字小数点用逗号(9,90 而不是 9.90)。

修复方式是使用 next-intl 内置的格式化函数,底层调用 Intl.DateTimeFormatIntl.NumberFormat

// ✅ 好做法:使用 next-intl 的格式化 Hook
import { useFormatter, useTranslations } from 'next-intl'
 
function OrderSummary({ date, amount }: { date: Date; amount: number }) {
  const format = useFormatter()
  const t = useTranslations('order')
  return (
    <div>
      <p>{t('orderDate', { date: format.dateTime(date, { dateStyle: 'long' }) })}</p>
      <p>{t('amount', { amount: format.number(amount, { style: 'currency', currency: 'USD' }) })}</p>
    </div>
  )
}
 
// messages/de.json
// {
//   "order": {
//     "orderDate": "Bestelldatum: {date}",
//     "amount": "Betrag: {amount}"
//   }
// }
// 德语输出:Bestelldatum: 26. Juni 2026 / Betrag: $9,90

这里的核心原则是:格式化规则跟着 locale 走,不跟着开发者写死Intl.NumberFormat 会根据当前 locale 自动选择小数点符号、千分位分隔符和货币符号位置,你只需要传入数值和货币代码。

案例三:SEO 元信息没有按语言独立生成

多语言上线后一个月,我发现 Google 收录的日文页面里,&lt;title&gt;&lt;meta description&gt; 还是英文的。原因是我在 layout.tsx 里统一写了 metadata,没有按 locale 切换:

// ❌ 坏做法:所有语言共用同一套 metadata
export async function generateMetadata() {
  return {
    title: 'My SaaS - The Best Tool for Teams',
    description: 'Boost your team productivity with our AI-powered tool.',
    alternates: { canonical: 'https://mysaas.com' },
  }
}

修复后每个 locale 有独立的 metadata,并且加上了 hreflang 标签告诉搜索引擎不同语言版本的对应关系:

// ✅ 好做法:按 locale 生成独立 metadata + hreflang
import { getTranslations } from 'next-intl/server'
import type { Metadata } from 'next'
 
const LOCALES = ['en', 'zh', 'ja', 'de'] as const
 
export async function generateMetadata({
  params,
}: {
  params: { locale: string }
}): Promise<Metadata> {
  const t = await getTranslations({ locale: params.locale, namespace: 'seo' })
  const alternates = Object.fromEntries(
    LOCALES.map((loc) => [loc, `https://mysaas.com/${loc}`])
  )
 
  return {
    title: t('homeTitle'),
    description: t('homeDescription'),
    alternates: {
      canonical: `https://mysaas.com/${params.locale}`,
      languages: alternates,
    },
  }
}

hreflang 的作用不只是 SEO——它还能让搜索引擎把用户导向正确的语言版本。如果一个德语用户在 Google 搜索到你的产品,hreflang="de" 会帮助 Google 直接展示德语 URL 而不是英语。

从翻译到上线的完整流程

把三个案例的经验汇总,我把多语言落地的技术结构画成了流程图:

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程的关键控制点在 CI 检查。我在 package.json 里加了一个脚本,在每次提交前检查翻译文件的 Key 是否对齐:

{
  "scripts": {
    "i18n:check": "node scripts/check-i18n-keys.mjs --strict --fail-on-missing"
  }
}

脚本逻辑很简单:以默认语言(英语)的 JSON 为基准,递归遍历所有 Key,检查其他语言文件是否都有对应条目。缺失的 Key 会在 CI 里报错,阻止合并。这比上线后让用户发现缺失翻译要好得多。

路由策略选择

Next.js App Router 没有内置的 i18n 路由支持(Pages Router 有),需要自己做。常见三种方案:

方案URL 形式优点缺点适合场景
子路径路由/en/about/zh/about实现简单,SEO 友好,hreflang 直观URL 较长多数独立产品首选
子域名路由en.mysaas.comzh.mysaas.com各语言完全隔离,可独立部署DNS 配置复杂,SSL 证书多份多团队协作的大产品
域名路由mysaas.commysaas.jp本地化感最强每个域名独立维护,成本高有明确本地公司实体的产品

对独立产品来说,子路径路由是性价比最高的选择。next-intl 通过 Middleware 实现语言检测和重定向,配合 [locale] 动态路由段,不需要额外的域名或子域名配置。

// middleware.ts - 语言检测与重定向
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
 
export default createMiddleware(routing)
 
export const config = {
  // 只匹配需要国际化的路由,跳过 API 和静态资源
  matcher: ['/', '/(zh|en|ja|de)/:path*'],
}

内容优先级:先翻译什么

资源有限的独立产品不可能第一天就翻译全部内容。我按用户路径和转化率影响做了分级:

优先级内容范围影响建议时间点
P0 必须翻译首页、价格页、注册/登录、支付页、核心按钮、错误提示、欢迎邮件直接影响转化率和用户信任首版上线前
P1 尽快翻译帮助文档、FAQ、Onboarding 引导、密码重置邮件、隐私条款影响留存和合规上线后 2 周内
P2 按需翻译博客、高级设置、后台管理、API 文档低频使用,影响较小目标市场确认后

判断标准:用户在付费路径上能遇到的所有文本都属于 P0。用户如果在支付页面看到半中半英的界面,信任感会断崖式下降。

翻译维护的工作流

翻译不是一次性任务,而是持续过程。我的做法:

  1. 开发时写英文 Key——每次新增功能同步更新翻译文件,不积攒。
  2. 用 Crowdin 或 Localazy 做翻译管理——把 JSON 文件同步上去,翻译者(可能是外包、可能是社区志愿者)在平台上操作,完成后自动同步回仓库。
  3. AI 辅助初稿——用 GPT-4 或 DeepL API 生成翻译初稿,但必须有人审校。机器翻译在产品名、术语一致性和语气上经常出错。
  4. CI 做质量卡点——检查 Key 完整性、占位符是否匹配、翻译长度是否合理(某些语言的翻译会比英语长 30%-50%,可能撑破 UI)。

Localazy 对独立开发者比较友好,有免费计划,支持 GitHub Action 自动同步。Crowdin 也有开源项目免费计划。两者都支持翻译记忆(Translation Memory),翻译过的内容可以复用到新 Key 上,减少重复劳动。

检查清单

按落地阶段分组,每项可直接执行:

阶段一:基础设施搭建

  • 选定 i18n 框架,配置翻译目录结构(如 messages/&#123;locale&#125;.json
  • 在 Middleware 中实现语言检测和重定向逻辑
  • 配置 [locale] 动态路由段,确保所有页面在每种语言下可访问
  • 设置默认语言的回退策略(翻译缺失时不白屏)

阶段二:文案提取与格式化

  • 全站搜索硬编码文案,迁移到翻译文件
  • 所有日期使用 Intl.DateTimeFormat 或框架封装函数格式化
  • 所有数字/货币使用 Intl.NumberFormat 或框架封装函数格式化
  • 动态文案使用占位符,不手动拼接字符串

阶段三:SEO 与元信息

  • 每种语言有独立的 &lt;title&gt;&lt;meta description&gt; 和 canonical URL
  • 配置 hreflang 标签,声明语言版本对应关系
  • 生成多语言 sitemap.xml,每种语言独立 URL
  • Open Graph 和 Twitter Card 标签按语言适配

阶段四:CI 与持续维护

  • 添加翻译 Key 完整性检查脚本,CI 不通过则阻止合并
  • 配置翻译管理平台(Crowdin / Localazy)与仓库自动同步
  • 新增功能时同步更新翻译文件,不积攒
  • 定期审查翻译长度对 UI 布局的影响(尤其是德语、芬兰语)

阶段五:用户体验

  • 提供语言切换入口,切换后保持当前页面语义
  • 首次访问时根据浏览器语言自动选择,但允许手动覆盖
  • 表单错误提示、Toast 通知、邮件模板全部覆盖目标语言

参考资料

  1. Next.js Official Documentation: Internationalization (i18n) — Next.js App Router 国际化官方指南,涵盖路由策略、Middleware 配置和渲染行为。
  2. next-intl Official Documentation — next-intl 库的官方文档,包括 App Router 集成、翻译管理、格式化函数和类型安全配置。
  3. W3C: 本地化与国际化有什么关系? — W3C 对 i18n 和 l10n 概念的基础定义,以及全球化开发模式的核心要求。
  4. Crowdin: SaaS Localization: How to Translate Software in 2026 — SaaS 产品本地化的完整指南,涵盖字符串外部化、UI 弹性设计和翻译工作流。
  5. LocaFlow: SaaS Localization in 2026: The Complete Founder's Guide — 面向独立创始人的本地化实战指南,讨论如何用有限预算进入多个市场。
  6. i18next Official: Comparison to Others — i18next 官方对各主流框架的对比分析,客观说明各方案的取舍。
  7. Intlayer: next-i18next vs next-intl vs Intlayer: 2026 Comparison — 2026 年 Next.js i18n 框架横评,包含 App Router 兼容性、TypeScript 支持和社区反馈。
  8. MDN: Intl - JavaScript 国际化 API — JavaScript 原生 Intl API 文档,Intl.DateTimeFormatIntl.NumberFormat 等格式化器的权威参考。

评论 0

0 / 1000