remix-i18next

翻譯您的 React Router 框架模式應用程式最簡單的方式。

[!IMPORTANT] 如果您仍在使用 Remix v2,請繼續使用 remix-i18next v6,因為 v7 僅與 React Router v7 相容。

為什麼選擇 remix-i18next?

  • 易於設定,易於使用:設定僅需幾個步驟,且組態簡單。
  • 沒有其他要求:remix-i18next 簡化了您的 React Router 應用程式的國際化,而無需額外的依賴項。
  • 生產就緒:remix-i18next 支援從 loader 將翻譯和組態選項傳遞到路由中。
  • 掌握控制權:remix-i18next 不會隱藏組態,因此您可以新增任何想要的插件或隨意組態。

設定

[!TIP] 如果您正在使用 Remix with Vite,請查看 https://github.com/sergiodxa/remix-vite-i18next 以取得範例應用程式,如果您有問題,請將您的設定與範例比較。

安裝

第一步是在您的專案中安裝它,使用

npm install remix-i18next i18next react-i18next i18next-browser-languagedetector

您需要組態一個 i18next 後端和語言偵測器,在這種情況下,您也可以安裝它們,對於其餘的設定指南,我們將使用 http 和 fs 後端。

npm install i18next-http-backend i18next-fs-backend

組態

首先,讓我們建立一些翻譯檔案

public/locales/en/common.json:

{
  "greeting": "Hello"
}

public/locales/es/common.json:

{
  "greeting": "Hola"
}

接下來,設定您的 i18next 組態

這兩個檔案可以放在您應用程式資料夾中的某個位置。

在此範例中,我們將建立 app/i18n.ts

export default {
  // This is the list of languages your application supports
  supportedLngs: ["en", "es"],
  // This is the language you want to use in case
  // if the user language is not in the supportedLngs
  fallbackLng: "en",
  // The default namespace of i18next is "translation", but you can customize it here
  defaultNS: "common",
};

然後建立一個名為 i18next.server.ts 的檔案,其中包含以下程式碼

import Backend from "i18next-fs-backend";
import { resolve } from "node:path";
import { RemixI18Next } from "remix-i18next/server";
import i18n from "~/i18n"; // your i18n configuration file

let i18next = new RemixI18Next({
  detection: {
    supportedLanguages: i18n.supportedLngs,
    fallbackLanguage: i18n.fallbackLng,
  },
  // This is the configuration for i18next used
  // when translating messages server-side only
  i18next: {
    ...i18n,
    backend: {
      loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
    },
  },
  // The i18next plugins you want RemixI18next to use for `i18n.getFixedT` inside loaders and actions.
  // E.g. The Backend plugin for loading translations from the file system
  // Tip: You could pass `resources` to the `i18next` configuration and avoid a backend here
  plugins: [Backend],
});

export default i18next;

客戶端組態

現在在您的 entry.client.tsx 中,將預設程式碼替換為此程式碼

import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import i18n from "./i18n";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
import { getInitialNamespaces } from "remix-i18next/client";

async function hydrate() {
  await i18next
    .use(initReactI18next) // Tell i18next to use the react-i18next plugin
    .use(LanguageDetector) // Setup a client-side language detector
    .use(Backend) // Setup your backend
    .init({
      ...i18n, // spread the configuration
      // This function detects the namespaces your routes rendered while SSR use
      ns: getInitialNamespaces(),
      backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" },
      detection: {
        // Here only enable htmlTag detection, we'll detect the language only
        // server-side with remix-i18next, by using the `<html lang>` attribute
        // we can communicate to the client the language detected server-side
        order: ["htmlTag"],
        // Because we only use htmlTag, there's no reason to cache the language
        // on the browser, so we disable it
        caches: [],
      },
    });

  startTransition(() => {
    hydrateRoot(
      document,
      <I18nextProvider i18n={i18next}>
        <StrictMode>
          <RemixBrowser />
        </StrictMode>
      </I18nextProvider>
    );
  });
}

if (window.requestIdleCallback) {
  window.requestIdleCallback(hydrate);
} else {
  // Safari doesn't support requestIdleCallback
  // https://caniuse.dev.org.tw/requestidlecallback
  window.setTimeout(hydrate, 1);
}

伺服器端組態

在您的 entry.server.tsx 中,將程式碼替換為此程式碼

import { PassThrough } from "stream";
import {
  createReadableStreamFromReadable,
  type EntryContext,
} from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { createInstance } from "i18next";
import i18next from "./i18next.server";
import { I18nextProvider, initReactI18next } from "react-i18next";
import Backend from "i18next-fs-backend";
import i18n from "./i18n"; // your i18n configuration file
import { resolve } from "node:path";

const ABORT_DELAY = 5000;

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  let callbackName = isbot(request.headers.get("user-agent"))
    ? "onAllReady"
    : "onShellReady";

  let instance = createInstance();
  let lng = await i18next.getLocale(request);
  let ns = i18next.getRouteNamespaces(remixContext);

  await instance
    .use(initReactI18next) // Tell our instance to use react-i18next
    .use(Backend) // Setup our backend
    .init({
      ...i18n, // spread the configuration
      lng, // The locale we detected above
      ns, // The namespaces the routes about to render wants to use
      backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json") },
    });

  return new Promise((resolve, reject) => {
    let didError = false;

    let { pipe, abort } = renderToPipeableStream(
      <I18nextProvider i18n={instance}>
        <RemixServer context={remixContext} url={request.url} />
      </I18nextProvider>,
      {
        [callbackName]: () => {
          let body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);
          responseHeaders.set("Content-Type", "text/html");

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: didError ? 500 : responseStatusCode,
            })
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          didError = true;

          console.error(error);
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

用法

現在,在您的 app/root.tsxapp/root.jsx 檔案中,如果您沒有 loader,請使用以下程式碼建立一個。

import { useChangeLanguage } from "remix-i18next/react";
import { useTranslation } from "react-i18next";
import i18next from "~/i18next.server";

export async function loader({ request }: LoaderArgs) {
  let locale = await i18next.getLocale(request);
  return json({ locale });
}

export let handle = {
  // In the handle export, we can add a i18n key with namespaces our route
  // will need to load. This key can be a single string or an array of strings.
  // TIP: In most cases, you should set this to your defaultNS from your i18n config
  // or if you did not set one, set it to the i18next default namespace "translation"
  i18n: "common",
};

export default function Root() {
  // Get the locale from the loader
  let { locale } = useLoaderData<typeof loader>();

  let { i18n } = useTranslation();

  // This hook will change the i18n instance language to the current locale
  // detected by the loader, this way, when we do something to change the
  // language, this locale will change and i18next will load the correct
  // translation files
  useChangeLanguage(locale);

  return (
    <html lang={locale} dir={i18n.dir()}>
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

最後,在任何您想要翻譯的路由中,您可以按照 i18next 文件使用 t() 函式,並使用預設命名空間的翻譯。

import { useTranslation } from "react-i18next";

export default function Component() {
  let { t } = useTranslation();
  return <h1>{t("greeting")}</h1>;
}

如果您希望分割您的翻譯檔案,您可以建立新的翻譯檔案,例如

public/locales/en/home.json

{
  "title": "remix-i18n is awesome"
}

public/locales/es/home.json

{
  "title": "remix-i18n es increíble"
}

並在您的路由中使用它們

import { useTranslation } from "react-i18next";

// This tells remix to load the "home" namespace
export let handle = { i18n: "home" };

export default function Component() {
  let { t } = useTranslation("home");
  return <h1>{t("title")}</h1>;
}

就是這樣,針對每個您想要翻譯的路由重複最後一個步驟,remix-i18next 會自動讓 i18next 知道要使用哪個命名空間和語言,並且這個會使用您組態的後端載入正確的翻譯檔案。

翻譯 loader 或 action 內的文字

如果您需要在 loader 或 action 函式中取得翻譯的文字,例如翻譯稍後在 MetaFunction 中使用的頁面標題,您可以使用 i18n.getFixedT 方法來取得 t 函式。

export async function loader({ request }: LoaderArgs) {
  let t = await i18n.getFixedT(request);
  let title = t("My page title");
  return json({ title });
}

export let meta: MetaFunction = ({ data }) => {
  return { title: data.title };
};

可以使用參數組合呼叫 getFixedT 函式

  • getFixedT(request):將使用請求來取得語言環境和組態中設定的 defaultNStranslationi18next 預設命名空間
  • getFixedT("es"):將使用指定的 es 語言環境和組態中設定的 defaultNS,或 translationi18next 預設命名空間
  • getFixedT(request, "common") 將使用請求來取得語言環境和指定的 common 命名空間來取得翻譯。
  • getFixedT("es", "common") 將使用指定的 es 語言環境和指定的 common 命名空間來取得翻譯。
  • getFixedT(request, "common", { keySeparator: false }) 將使用請求來取得語言環境和 common 命名空間來取得翻譯,並使用第三個引數的選項來初始化 i18next 執行個體。
  • getFixedT("es", "common", { keySeparator: false }) 將使用指定的 es 語言環境和 common 命名空間來取得翻譯,並使用第三個引數的選項來初始化 i18next 執行個體。

如果您總是需要設定相同的 i18next 選項,您可以在建立新執行個體時將它們傳遞給 RemixI18Next。

export let i18n = new RemixI18Next({
  detection: { supportedLanguages: ["es", "en"], fallbackLanguage: "en" },
  // The config here will be used for getFixedT
  i18next: {
    backend: { loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json") },
  },
  // This backend will be used by getFixedT
  backend: Backend,
});

此選項將被提供給 getFixedT 的選項覆蓋。

使用 keyPrefix 選項搭配 getFixedT

getFixedT 函式現在支援 keyPrefix 選項,允許您在翻譯鍵前面加上前綴。當您想要命名翻譯,而無需每次都指定完整的鍵路徑時,這特別有用。

以下是如何使用它

export async function loader({ request }: LoaderArgs) {
  // Assuming "greetings" namespace and "welcome" keyPrefix
  let t = await i18n.getFixedT(request, "greetings", { keyPrefix: "welcome" });
  let message = t("user"); // This will look for the "welcome.user" key in your "greetings" namespace
  return json({ message });
}

此功能簡化了處理深度巢狀翻譯鍵,並增強了翻譯檔案的組織性。

從請求 URL 路徑名稱中尋找語言環境

如果您想將使用者語言環境保留在路徑名稱中,您有兩種可能的選項。

第一個選項是將 loader/action 參數中的參數傳遞給 getFixedT。這樣您將停止使用 remix-i18next 的語言偵測功能。

第二個選項是將 findLocale 函式傳遞給 RemixI18Next 中的偵測選項。

export let i18n = new RemixI18Next({
  detection: {
    supportedLanguages: ["es", "en"],
    fallbackLanguage: "en",
    async findLocale(request) {
      let locale = request.url.pathname.split("/").at(1);
      return locale;
    },
  },
});

findLocale 傳回的語言環境將根據支援的語言環境清單進行驗證,如果無效,將使用回退語言環境。

從資料庫查詢語言環境

如果您的應用程式將使用者語言環境儲存在資料庫中,您可以使用 findLocale 函式來查詢資料庫並傳回語言環境。

export let i18n = new RemixI18Next({
  detection: {
    supportedLanguages: ["es", "en"],
    fallbackLanguage: "en",
    async findLocale(request) {
      let user = await db.getUser(request);
      return user.locale;
    },
  },
});

請注意,每次呼叫 getLocalegetFixedT 都會呼叫 findLocale,因此盡可能保持快速非常重要。

如果您需要語言環境和 t 函式,您可以呼叫 getLocale,並將結果傳遞給 getFixedT