CodeGym /课程 /ChatGPT Apps /小部件本地化:Next + React(i18n 架构)

小部件本地化:Next + React(i18n 架构)

ChatGPT Apps
第 9 级 , 课程 2
可用

1. 为什么在 ChatGPT App 中小部件需要独立的 i18n 架构

在常规 Next.js 应用中,你常常依赖 URL(如 /en/.../ru/...)或路由器,把语言与路由绑定。ChatGPT 小部件更有意思:你的 UI 运行在沙盒里的 iframe 中,URL 并不由你控制。语言作为状态由 ChatGPT 提供,比如通过 openai/locale 或像 useOpenAiGlobal('locale') 这样的钩子获得,而不是来自地址栏。

这造成了一个不寻常的情形。对 Next.js 而言,你的小部件可以看作一张页面 /widget,但在内部它必须根据平台给出的任意语言进行渲染。切换语言不应靠导航,而应靠状态。这自然推动我们采用“一个 UI,多份字典”的架构,并再次强调:把文案放在代码里是一条死路。

此外,在同一个 ChatGPT 对话中,你的 App 可能会为来自不同国家的用户启动。你无法“只决定一次这个 App 是俄语”然后就忘掉。小部件必须能在不改变业务逻辑的情况下,根据新的 locale 轻松重新初始化——这正是需要精致 i18n 中间层的原因。

2. 核心原则:代码里不应该有字符串

如果用一句话概括 UI 本地化的理念,那就是:React 组件不需要真实文案,它们需要的是键(key)

而不是:


// 不好:字符串硬编码在组件中
<button>挑选礼物</button>

理想的写法:


// 好:组件只知道键
<button>{t('buttons.pick_gift')}</button>

而真实文案“挑选礼物”和“Pick a gift”则保存在 ru.jsonen.json 两份字典中。

为什么要“自找麻烦”,不直接写 iflocale === 'ru')?

其一,可扩展性。当你要添加第三种语言时,if/else 会迅速变成一锅粥。其二,职责分离。译者或产品可以在 JSON 文件里改文案而不碰代码;开发者可以重构组件而不至于误伤一半的 UI 文案。其三,一致性:单一文案真源能避免“同一按钮一处写‘购买’,另一处写‘支付’”这类随心所欲命名导致的不一致。

在 ChatGPT App 的世界里,这尤其有用:有时你会想用 LLM 生成翻译再加入字典。把所有文案放在 JSON 里比散落在各个组件里方便得多。

3. 为 GiftGenius 小部件组织字典

继续扩展我们的教学应用 GiftGenius——一个礼物推荐小部件。我们至少需要两种语言:ruen。先创建基础结构:

/app
  /widget
    GiftWidget.tsx
/locales
  /en
    widget.json
  /ru
    widget.json

locales/en/widget.json 的最简单内容:

{
  "title": "GiftGenius",
  "forms": {
    "recipient": {
      "label": "Recipient",
      "placeholder": "Who is this gift for?"
    },
    "budget": {
      "label": "Budget",
      "placeholder": "For example, 50"
    }
  },
  "buttons": {
    "pick_gift": "Find gifts",
    "try_again": "Try again"
  },
  "errors": {
    "no_gifts": "No gifts found for your criteria."
  }
}

对应的 locales/ru/widget.json

{
  "title": "GiftGenius",
  "forms": {
    "recipient": {
      "label": "收礼人",
      "placeholder": "这份礼物送给谁?"
    },
    "budget": {
      "label": "预算",
      "placeholder": "例如,50"
    }
  },
  "buttons": {
    "pick_gift": "查找礼物",
    "try_again": "再试一次"
  },
  "errors": {
    "no_gifts": "未找到符合你条件的礼物。"
  }
}

请注意,两种语言的键结构必须一致。这点至关重要:组件依赖的是键而不是具体文案。如果某个语言忘了添加 errors.no_gifts,你会得到清晰的错误,而不是半翻的 UI。

在实际项目中,按领域拆分字典更合理:widgetcheckouterrors 等。在教学应用中,每种语言用一个文件就够了,避免过度复杂。

4. 在 Apps SDK 小部件中从哪里获取 locale

在经典浏览器应用里,你可能会读 navigator.language。在 ChatGPT 小部件里可以这么做,但没必要:ChatGPT 已经替用户算好首选本地化设置,并把它传入 Apps SDK 的上下文。它可能是 window.openai 中的 locale 字段,你可以直接读取,或经由 useOpenAiGlobal('locale') 这样的便捷钩子读取。

在 Apps SDK 的脚手架中,你通常有一个小部件的根组件,可以获取来自 ChatGPT 的全局数据。示意如下:

"use client";

import { useOpenAiGlobal } from "openai-apps-sdk/react";

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  // ...
}

上面的示例是演示性质;具体 API 取决于 SDK 版本,但总体思路正确:locale 是来自 ChatGPT 的外部真实信息,而不是来自用户浏览器。

区域(userLocation)也会经由 _meta["openai/userLocation"] 传入。等到我们格式化价格和处理货币时会用上它。对文本来说,有 locale 就足够了——它通常是 BCP‑47 格式(enen-USru-RU 等)。

5. 编写最小 i18n 层:上下文 + useT 钩子

为避免把小部件变成 react-i18next 的教材,我们实现一个轻量的自定义 i18n 层。对于小型 ChatGPT 小部件绰绰有余,且原理与主流库一致。

先在 app/widget/i18n.tsx 中定义类型并创建上下文:

"use client";

import React, { createContext, useContext } from "react";

type Messages = Record<string, any>;

type I18nContextValue = {
  locale: string;
  messages: Messages;
};

const I18nContext = createContext<I18nContextValue | null>(null);

然后实现一个接收 locale 与字典的 Provider:

type Props = {
  locale: string;
  messages: Messages;
  children: React.ReactNode;
};

export function I18nProvider({ locale, messages, children }: Props) {
  return (
    <I18nContext.Provider value={{ locale, messages }}>
      {children}
    </I18nContext.Provider>
  );
}

重点在于钩子 useT,它按键取文案:

export function useT() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useT must be used within I18nProvider");

  function t(path: string): string {
    return path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages) 
           ?? path;
  }

  return { t, locale: ctx.locale };
}

我们支持 forms.recipient.label 这样的嵌套键;若缺少翻译则直接返回键本身——比静默显示空字符串更有用。

6. 将 i18n Provider 嵌入小部件根组件

前面我们看过只读取 localeGiftWidgetRoot。现在在这个根组件中使用 I18nProvider 并加入字典加载。假设它之前大致如下:

"use client";

export function GiftWidgetRoot() {
  return (
    <div>
      <h1>GiftGenius</h1>
      {/* 表单与结果 */}
    </div>
  );
}

接下来添加字典加载与 Provider。为简单起见,我们根据 locale 使用同步 require/import;在 Next.js 16 中如果字典很大,也可以用(dynamic import)进行异步加载。

"use client";

import { useOpenAiGlobal } from "openai-apps-sdk/react";
import { I18nProvider } from "./i18n";
import { GiftWidget } from "./GiftWidget";

function loadMessages(locale: string) {
  if (locale.startsWith("ru")) {
    return require("/locales/ru/widget.json"); 
  }
  return require("/locales/en/widget.json");
}

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  const messages = loadMessages(locale);

  return (
    <I18nProvider locale={locale} messages={messages}>
      <GiftWidget />
    </I18nProvider>
  );
}

组件 GiftWidget 现在完全不关心语言,只知道有一个 t 函数:

"use client";

import { useT } from "./i18n";

export function GiftWidget() {
  const { t } = useT();

  return (
    <div>
      <h1>{t("title")}</h1>
      <label>{t("forms.recipient.label")}</label>
      {/* 其他 UI */}
    </div>
  );
}

如果明天 ChatGPT 以 locale = "de-DE" 运行小部件,你只需新增 locales/de/widget.json 并在 loadMessages 中加一行,不必改动其余代码——这正是我们要的效果。

7. 可本地化的格式:数字、日期、货币

我们已经把文案放入字典,并用 I18nProvider 包裹了小部件。但文案只是 UX 的一半:美国用户期待看到 12/31/2025,德国用户则期待 31.12.2025。数字与货币也一样。给中国用户显示“1,234.56 USD”不算 bug,却是明确的 UX 反模式——它会让人觉得你的“智能”助手并不细心。

好在浏览器(以及 ChatGPT 沙盒)提供了标准的 Intl API。我们在 i18n.tsx 中添加几个基于当前 locale 的工具函数:

export function useFormatters() {
  const { locale } = useT();

  const formatCurrency = (value: number, currency: string) =>
    new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
      maximumFractionDigits: 2,
    }).format(value);

  const formatDate = (date: Date) =>
    new Intl.DateTimeFormat(locale).format(date);

  return { formatCurrency, formatDate };
}

现在在展示预算或礼物价格的组件里(假设我们从 MCP 服务器拿到了带 currency 的价格):

import { useFormatters } from "./i18n";

type GiftCardProps = {
  name: string;
  price: number;
  currency: string;
};

export function GiftCard({ name, price, currency }: GiftCardProps) {
  const { formatCurrency } = useFormatters();

  return (
    <div>
      <div>{name}</div>
      <div>{formatCurrency(price, currency)}</div>
    </div>
  );
}

如果你想让格式化更“聪明”(例如基于 userLocation 选择默认货币),可以组合使用 locale 与区域。架构上,这与之前你为 MCP‑Gateway 讨论的方向一致:locale 决定文本语言,userLocation 影响业务规则与货币。

8. 语言变更的响应:如果 ChatGPT 动态改变了 locale

在普通 Web 中,用户自己点“EN / RU”,你明确知道何时切换语言。在 ChatGPT App 中,模型理论上可能判断用户换了更合适的语言(或用户在设置里切换了界面语言),于是 openai/locale 发生变化。

如果 SDK 提供了响应式信号(通过钩子或事件),代码模式大致如下:

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  const messages = useMemo(() => loadMessages(locale), [locale]);

  return (
    <I18nProvider locale={locale} messages={messages}>
      <GiftWidget />
    </I18nProvider>
  );
}

此处 loadMessages 会在 locale 改变时重新执行,整个 UI 将自动以新翻译重渲染。多数真实场景中,locale 在会话期间是稳定的,但把反应式模型打好地基依然很有价值。

9. 复杂文案:占位符与复数

我们已经解决了对 locale 的响应。下一个自然问题是:如何处理文本中的动态部分——数量、人名等?在礼物应用中,可能有类似“为 Masha 找到 3 件礼物”的句子。

最简单的做法是在 t() 中支持占位符,并在渲染时替换。为此,把 useT 改造为接收一个值对象作为第二参数:

type Values = Record<string, string | number>;

export function useT() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useT must be used within I18nProvider");

  function t(path: string, values?: Values): string {
    let text =
      path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages) ??
      path;

    if (values) {
      Object.entries(values).forEach(([key, value]) => {
        text = text.replace(`{{${key}}}`, String(value));
      });
    }
    return text;
  }

  return { t, locale: ctx.locale };
}

现在在 widget.json 中加入一条字符串:

"results": {
  "summary": "Found {{count}} gifts for {{name}}"
}

然后这样使用:

const { t } = useT();

<p>{t("results.summary", { count, name: recipientName })}</p>

关于复数有多种处理方式:要么定义多个键(onefewmany)并手动选择;要么接入 react-intl/i18next 这类提供完整复数规则的库。对于教学小部件,基于区间的手动选择(例如当 count === 1,当 count < 5,等等)完全可以接受。

10. 在 Next.js + Apps SDK 模板中把 i18n 放在哪里

在 Next.js 16 与官方 Apps SDK 模板中,你的小部件通常是 app/ 下的一个专用入口(例如 app/widget/page.tsx,或由 Apps SDK 在 ChatGPT 中渲染的独立组件)。

典型模式:

// app/widget/page.tsx
"use client";

import { GiftWidgetRoot } from "./GiftWidgetRoot";

export default function WidgetPage() {
  return <GiftWidgetRoot />;
}

i18n 层完全驻留在客户端——上述内容全是 client components。关键在于,在 ChatGPT 环境中你本来就把一切渲染在 iframe 里的客户端,因此可以临时忽略经典的 SSR‑i18n(在服务器上输出本地化 HTML)。这大大简化了工作:就当开发一个普通的 SPA,只是把 navigator.language 换成 openai/locale

如果你需要在同一个 App 的多个小部件之间共享翻译(比如主向导与“小型内嵌小部件”),可以把 I18nProvider 抽到独立模块并复用。

11. 本地化的微型测试

一旦系统里有了 i18n 层,就值得单独测试它——否则任何键名的笔误都会变成“半翻 UI”。既然架构已经搭好,不测白不测。

首先,建议为 loadMessagesuseT 写简单的单元测试(可用 React Testing Library,也可不依赖 React,直接测 t 函数)。这类测试能捕捉键名笔误,也能在你或译者不小心删掉字典必要分支时给出提醒。

其次,可以提供一个在 ChatGPT 外“本地运行”的模式,让你通过查询参数或 UI 按钮强制指定 locale。这对你和 QA 都很方便:没人需要启动完整的 Dev Mode 和 ChatGPT 仅仅为了看看德语翻译的样子。有了这些基础测试与不同 locale 的本地预览,你会更安心地演进 UI 与文案,并继续推进工具描述的本地化。

这与模型行为有什么关系

我们会在下一讲深入工具 descriptions 的本地化,但现在就要看到这条主线:小部件与工具必须用用户的语言交流。你已经构建了会基于 openai/locale 自适应的 UI。MCP 服务器也会据此选择正确的目录与文本。同理,suggest_gifts 的描述,以及 recipientbudget 字段,也应当使用用户语言来向模型解释——这会减少奇怪的 tool‑call 与不正确的参数。

因此,小部件的 i18n 架构不只是“表面工程”。它是整个体系的第一块砖:UI 层、MCP 层与模型共享同一套本地化上下文。

12. 小部件本地化中的常见错误

错误 №1:直接在 JSX 里硬编码字符串。
这很常见:小部件最初是单语快速原型,突然来了“还要支持英文”。结果 UI 布满了某一种语言的字符串,而尝试加英文就变成全项目的全局查找替换。越早引入字典与 t() 函数,后面的问题就越少。

错误 №2:到处写 iflocale === 'ru')。
这种条件看似“快速解决”,但一旦出现第三种语言或 ru-RUruru-UA 这样的变体就全面崩溃。更好的方式是一次性写好 loadMessages(locale) 并做归一化(locale.split('-')[0]),之后就不用在代码各处散落判断。

错误 №3:把业务逻辑和文案搅在一起。
有时开发者会在组件里写复杂条件,同时处理业务分支与文案选择。比如“如果没有礼物,展示这句话;如果预算太小,展示那句话”。结果是文案难改、逻辑蔓延、翻译跑进 TypeScript。更好的做法是让组件只交付字典键(如 errors.no_giftserrors.budget_too_low),文案单独维护。

错误 №4:忽略按本地化格式化日期/货币。
给德国用户显示 $1,234.56 而不是 1.234,56 $ 不是 bug,却是 UX 反模式。用户会觉得“这个服务不是给我用的”。如果你长期在一个地区开发,很容易忘记 Intl.NumberFormatIntl.DateTimeFormat。因此,把格式化封装在 useFormatters() 这样的钩子中,并坚持用它而不是手写拼接字符串,是很有帮助的。

错误 №5:没有考虑 locale 可能变化。
有些开发者在挂载时读取一次 locale 就把它当常量。多数情况下没问题,但如果 ChatGPT 或平台确实改变了本地化设置(比如用户切换了界面语言),你的组件就会停留在旧语言。更正确的做法是把 locale 视为响应式状态,用 useMemo/useEffect 之类的机制联动它。

错误 №6:不同语言的字典结构不一致。
有时不同语言分别由不同人维护,结果 widget.en.jsonwidget.ru.json 的结构产生分歧。一个里有 forms.budget.placeholder,另一个只有 forms.budget.label。运行时就会出现 undefined 与各种奇怪错误。务必维护一个“规范”文件(通常是英文),其他语言在结构上与它保持一致。甚至可以写脚本校验键一致性来生成新字典。

错误 №7:一上来就上重型 i18n 框架,企图一劳永逸。
react-i18nextnext-intl 这样流行方案确实强大有用,但对小型 ChatGPT 小部件往往过度。通常更简单的做法是从轻量自研层(I18nProvideruseT、JSON 字典)起步;等应用发展到需要复杂复数、ICU 格式等能力时,再迁移到完整库也不迟。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION