remix-i18next

翻译 Remix 应用最简单的方式。

为什么选择 remix-i18next?

  • 易于设置,易于使用:设置只需几个步骤,配置也很简单。
  • 无需其他依赖:remix-i18next 简化了 Remix 应用的国际化,无需额外的依赖项。
  • 生产就绪:remix-i18next 支持从加载器中传递翻译和配置选项到路由。
  • 掌控全局:remix-i18next 不会隐藏配置,因此您可以添加任何所需的插件或根据需要进行配置。

设置

[!TIP] 如果你使用的是带有 Vite 的 Remix,请查看 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.cn/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 文件中,如果还没有加载器,请创建以下代码的加载器。

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>
  );
}

最后,在任何你想要翻译的路由中,你可以使用 t() 函数,根据 i18next 文档 并使用默认命名空间中的翻译。

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 知道要使用哪些命名空间和语言,并且它会使用你配置的后端加载正确的翻译文件。

在加载器或操作中翻译文本

如果你需要在加载器或操作函数中获取翻译后的文本,例如翻译稍后在 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 语言环境和配置中设置的 defaultNStranslationi18next 默认命名空间)。
  • 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 路径名中查找语言环境

如果要将用户语言环境保留在路径名中,则有两种选择。

第一种选择是从加载器/操作参数将参数传递给 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