# General Translation Integrations: 使用 React Router 本地化 Shopify Hydrogen
URL: https://generaltranslation.com/zh/docs/integrations/shopify/guides/localizing-react-router.mdx
Docs index: https://generaltranslation.com/llms.txt
Description: 如何在基于 React Router 的 Hydrogen 店面中添加 gt-react，并让 Shopify 的语言上下文与其保持同步。

在 React Router 店面中，`gt-react` 负责翻译代码中的文案，Hydrogen 负责与 Shopify 通信。Shopify 方面需要做的，就是让 Hydrogen 的请求上下文使用与页面相同的区域设置。

*备注：本指南适用于运行在 React Router 8 上的 `@shopify/hydrogen` 2026.10，或运行在已于 `react-router.config.ts` 中设置 `future.v8_middleware: true` 的 React Router 7 上的同一版本。*

<Callout type="info">
  **Hydrogen 2026.10 变更：** `createHydrogenContext` 和仅适用于 React Router 的模板已被与框架无关的 `createShopifyRequestContext` 取代。它接收同样的 `i18n` 语言和国家/地区参数，另外还支持一个可选的 `pathPrefix`。可使用 Hydrogen 软件包中自带的 `migrate-to-hydrogen-2026-10` 技能完成升级。
</Callout>

## 开始之前 [#before-start]

* 使用 `npx @shopify/hydrogen setup` 在你的 React Router 应用中设置 Hydrogen。你的根中间件会创建 Shopify 请求上下文和 Storefront 客户端，并运行 `handleShopifyRoutes`。
* 完成 [React Router 快速入门](/docs/react/react-router-quickstart)。你的根加载器会返回区域设置和翻译快照，根 `Layout` 会渲染 [`GTProvider`](/docs/react/reference/components/gt-provider) 和 `<html lang>`。本指南会将快速入门中的 [`<LocaleSelector>`](/docs/react/reference/components/locale-selector) 替换为通过更改 URL 来切换语言的语言切换器。

## 从 URL 解析区域设置 [#request-locale]

从请求路径中读取区域设置，让每个区域设置都拥有独立的 URL。`gt-react` 和 Hydrogen 共用同一个模块：

```ts title="app/lib/i18n.ts"
import gtConfig from '../../gt.config.json';

// "/fr/products/x" -> "fr"；无前缀的路径使用默认区域设置。
export function getRequestLocale(request: Request): string {
  const [, segment] = new URL(request.url).pathname.split('/');
  return gtConfig.locales.includes(segment) ? segment : gtConfig.defaultLocale;
}

// "/products/x" -> "/fr/products/x"；默认区域设置沿用无前缀的路径。
export function localizePath(pathname: string, locale: string): string {
  if (locale === gtConfig.defaultLocale) return pathname;
  return pathname === '/' ? `/${locale}` : `/${locale}${pathname}`;
}

// "/fr/products/x" -> "/products/x"；无前缀的路径原样返回。
export function stripLocale(pathname: string): string {
  const [, segment, ...rest] = pathname.split('/');
  return gtConfig.locales.includes(segment) ? `/${rest.join('/')}` : pathname;
}
```

在根加载器中，调用 `getRequestLocale(request)` 而非 `parseLocale(request)`，并将结果传给 [`getTranslationsSnapshot`](/docs/react/reference/functions/get-translations-snapshot)。

*注意：`parseLocale` 会先读取区域设置 cookie，再读取 `Accept-Language`。如果单独使用它，所有语言都会通过同一个 URL 提供，导致搜索引擎只会索引其中一种语言。*

要提供带前缀的路径，请将页面嵌套在可选的 `:locale?` 布局路由下。Hydrogen 的兜底路由需保持在最后，以确保未匹配的路径仍能进入其重定向逻辑：

```ts title="app/routes.ts"
import {type RouteConfig, index, route} from '@react-router/dev/routes';

export default [
  // 页面既可以不带前缀渲染，也可以带目标区域设置前缀（如 /fr）渲染。
  route(':locale?', 'routes/locale.tsx', [
    index('routes/home.tsx'),
    route('products/:handle', 'routes/product.tsx'),
  ]),
  route('*', 'routes/catchall.tsx'),
] satisfies RouteConfig;
```

在布局路由中，如果该路径段不是已配置的目标区域设置，则返回 404，以免 `/en/...` 和未知前缀渲染出重复的页面：

```tsx title="app/routes/locale.tsx"
import {Outlet} from 'react-router';
import gtConfig from '../../gt.config.json';

// 只有目标语言区域可作为有效前缀；默认区域设置不加前缀。
export async function loader({params}: {params: {locale?: string}}) {
  if (params.locale && !gtConfig.locales.includes(params.locale)) {
    throw new Response(null, {status: 404});
  }
  return null;
}

export default function LocaleLayout() {
  return <Outlet />;
}
```

如果使用基于文件的路由，请改为将布局命名为 `($locale).tsx`，并将页面文件嵌套在其下。

Hydrogen 自带的处理函数 (例如 `/api/cart`、`/api/predictive-search`、`/checkout` 以及 Customer Account 路由) 会在任何页面渲染之前就由根中间件响应，因此它们不带前缀。

### 让 Hydrogen 的处理函数与页面语言保持一致 [#handler-locale]

发往 `/api/cart` 和 `/api/predictive-search` 的请求不带区域设置前缀，因此 `getRequestLocale` 会将其解析为默认区域设置。这样一来，在法语页面上，购物车和搜索结果显示的仍是英文目录文本。

将页面的区域设置保存在 cookie 中，且仅在这些不带前缀的 `/api` 请求中读取。对于页面，URL 仍是区域设置的唯一来源。替换 `app/lib/i18n.ts` 中的 `getRequestLocale`：

```ts title="app/lib/i18n.ts"
export const LOCALE_COOKIE_NAME = 'shop_locale';

const allLocales = [gtConfig.defaultLocale, ...gtConfig.locales];

export function readCookie(request: Request, name: string): string | undefined {
  const cookie = request.headers.get('Cookie')?.split(/;\s*/).find((part) => part.startsWith(`${name}=`));
  return cookie?.slice(name.length + 1);
}

/** 页面使用 URL 前缀中的区域设置；Hydrogen 的 `/api` 处理函数则沿用发起调用的页面的区域设置。 */
export function getRequestLocale(request: Request): string {
  const {pathname} = new URL(request.url);
  const [, segment] = pathname.split('/');
  if (gtConfig.locales.includes(segment)) return segment;
  if (!pathname.startsWith('/api/')) return gtConfig.defaultLocale;
  const remembered = readCookie(request, LOCALE_COOKIE_NAME);
  // 只信任已配置的区域设置；其他值一律回退到默认区域设置。
  return remembered && allLocales.includes(remembered) ? remembered : gtConfig.defaultLocale;
}

/** 仅匹配页面加载和客户端导航，不包括 `/api` 调用和推测性预取。 */
export function isPageNavigation(request: Request): boolean {
  const purpose = `${request.headers.get('Purpose') ?? ''} ${request.headers.get('Sec-Purpose') ?? ''}`;
  return (
    request.method === 'GET' &&
    !new URL(request.url).pathname.startsWith('/api/') &&
    !purpose.includes('prefetch')
  );
}
```

下一节中的根中间件会在页面导航的区域设置与已记住的区域设置不一致时设置该 cookie。预取不会设置该 cookie，因此即使预取了指向其他语言的链接，在购物者实际访问该链接之前，购物车的语言也不会改变。

## 将语言传递给 Shopify [#storefront-language]

在根中间件中，将每个区域设置映射到对应的 Shopify `LanguageCode`，并传给 `createShopifyRequestContext`。这样，对于每个声明了 `@inContext(country: $country, language: $language)` 的查询，Storefront 客户端都会自动填充 `$language` 和 `$country`。

```ts title="app/root.tsx"
import {
  createShopifyRequestContext,
  createStorefrontClient,
  handleShopifyRedirects,
  handleShopifyRoutes,
} from '@shopify/hydrogen';
import gtConfig from '../gt.config.json';
import {getRequestLocale, isPageNavigation, LOCALE_COOKIE_NAME, readCookie} from '~/lib/i18n';
import type {Route} from './+types/root';

// gt.config.json 中的每个区域设置各对应一个条目。
const SHOPIFY_LANGUAGES = {en: 'EN', fr: 'FR', ja: 'JA'} as const;

export const middleware: Route.MiddlewareFunction[] = [
  async ({context, request}, next) => {
    const locale = getRequestLocale(request) as keyof typeof SHOPIFY_LANGUAGES;
    const requestContext = createShopifyRequestContext({
      request,
      // 语言随页面变化；国家/地区则固定为你销售所在的市场。
      // pathPrefix 确保 Hydrogen 的变体重定向和标准路由重定向仍指向该区域设置的 URL。
      i18n: {
        language: SHOPIFY_LANGUAGES[locale],
        country: 'US',
        pathPrefix: locale === gtConfig.defaultLocale ? '' : `/${locale}`,
      },
    });
    const storefrontClient = createStorefrontClient({type: 'public', requestContext, config});

    const shopifyRoute = handleShopifyRoutes({request, requestContext, storefrontClient, routeTemplates, handlers});
    if (shopifyRoute) return shopifyRoute;

    context.set(storefrontClientContext, storefrontClient);

    const response = await next();
    if (response.status === 404) {
      const redirect = await handleShopifyRedirects({request, routeTemplates, storefrontClient});
      if (redirect) return redirect;
    }
    // 为 Hydrogen 的 `/api` 处理函数记录页面的区域设置。请在调用
    // applyResponseHeaders 之前设置 cookie，因为它会将任何设置了 cookie 的响应标记为私有且不可缓存。
    if (isPageNavigation(request) && readCookie(request, LOCALE_COOKIE_NAME) !== locale) {
      response.headers.append('Set-Cookie', `${LOCALE_COOKIE_NAME}=${locale}; Path=/; SameSite=Lax`);
    }
    storefrontClient.requestContext.applyResponseHeaders(response.headers);
    return response;
  },
];
```

`config`、`routeTemplates`、`handlers` 和 `storefrontClientContext` 均来自你现有的 Hydrogen 初始化设置，新增的只有 `i18n` 和区域设置 cookie。设置该 cookie 的页面响应不会被缓存，因此只有在购物者首次浏览页面或切换语言时才会出现这种情况。

本指南对所有语言都沿用美国市场，因此切换到法语只会改变页面文案，不会改变购物车的买家国家/地区或货币。如需支持多个市场，可以让购物者单独选择国家/地区，或者将每个区域设置与一个市场对应，并在切换语言时迁移购物车。 (参见[在多个市场销售](/docs/integrations/shopify/guides/selling-in-markets)) 。

## 格式化价格 [#prices]

使用 Shopify 返回的货币代码来格式化 Shopify 提供的金额。[`<Currency>`](/docs/react/reference/components/currency) 会按当前区域设置进行格式化，但不会对金额做任何换算。

```tsx title="app/components/LocalizedMoney.tsx"
import {Currency} from 'gt-react';

/** 按当前区域设置格式化 Shopify 的价格，不进行货币换算。 */
export function LocalizedMoney({data}: {data: {amount?: string; currencyCode?: string}}) {
  // 乐观更新返回的购物车数据可能暂时缺少金额相关字段。
  if (data.amount == null || !data.currencyCode) return null;
  return <Currency currency={data.currencyCode}>{data.amount}</Currency>;
}
```

## 在当前区域设置内链接 [#links]

代码中的路由路径不带前缀，在渲染链接时再加上当前区域设置的前缀。通过 [`useLocale`](/docs/react/reference/hooks/use-locale) 读取当前区域设置，并将 `localizePath(to, locale)` 传给 React Router 的 `Link`。

要切换语言，只需更改当前 URL 的前缀。根加载器随即会读取新的区域设置，而购物车保持不变。此处不要使用 [`<LocaleSelector>`](/docs/react/reference/components/locale-selector)：它会将区域设置保存到 cookie 中并重新加载同一 URL，而该 URL 仍会解析为旧的区域设置。

```tsx title="app/components/LanguageSwitcher.tsx"
import {useLocale, useLocales} from 'gt-react';
import {useLocation, useNavigate} from 'react-router';
import {localizePath, stripLocale} from '~/lib/i18n';

/** 通过更改 URL 前缀来切换语言；根加载器会从 URL 中读取新的区域设置。 */
export function LanguageSwitcher() {
  const locale = useLocale();
  const locales = useLocales();
  const {pathname, search} = useLocation();
  const navigate = useNavigate();
  return (
    <select
      aria-label="Language"
      value={locale}
      onChange={(event) => navigate(localizePath(stripLocale(pathname), event.target.value) + search)}
    >
      {locales.map((option) => (
        // 用各语言的自称来显示其名称，例如 “français” 或 “日本語”。
        <option key={option} value={option} lang={option}>
          {new Intl.DisplayNames([option], {type: 'language'}).of(option)}
        </option>
      ))}
    </select>
  );
}
```

链接到 Customer Account 路由 (例如 `/account/login`) 时，请使用普通的 `<a>` 或 `<form>`，而不是 `Link`。这些路由的处理函数会重定向到 Shopify，而客户端导航无法处理这类重定向。

## 生成译文 [#generate]

用 [`<T>`](/docs/react/reference/components/t) 包裹文案，对 `aria-label` 值等字符串使用 [`useGT`](/docs/react/reference/hooks/use-gt)，并在 `react-router build` 之前运行 [`npx gt translate`](/docs/cli/reference/commands/translate)。由加载器打包的译文在首次服务端渲染时即可用，因此缓存的响应中也会包含译文。

为了在店面内容更新时，文案始终保持包裹并得到翻译，请将代码仓库连接到 [Locadex](/docs/platform/locadex/quickstart)。它会提交包含新译文的拉取请求，供你审校。

## Next steps

- /docs/integrations/shopify/guides/localizing-nextjs
- /docs/integrations/shopify/guides/translating-products
- /docs/integrations/shopify/guides/selling-in-markets

## Sitemap

See the full [sitemap](https://generaltranslation.com/sitemap.md) for all pages.
