A globe close-up photo zooming in to the North American continent.
2024年3月6日

使用 Remix 进行国际化

Arisa Fukuzaki
Storyblok 高级开发者关系工程师

专家们不断讨论如何使网络变得更好。可访问性、UI/UX、网络性能,等等。您可能不会像其他主题那样经常听到国际化(i18n),但它对于使网络变得更好仍然至关重要。在本文中,我们将了解 i18n 的影响,探索其基本逻辑,并学习如何在 Remix 应用程序中实现 i18n。

我还在我的 Remix Conf 2023 演讲中谈到了使用 Remix 的 i18n。如果您想观看视频录像,可以在此处找到我的 i18n 演讲

什么是 i18n?

i18n 代表国际化:第一个字符“i”和最后一个字符“n”之间有 18 个字符。简而言之,i18n 是关于在您的应用程序中实现结构和功能,为您的每个用户提供本地化的内容版本。

我们应该关心 i18n 的原因有很多。最重要的原因是它可以让您的应用程序更容易被说不同语言的人访问。有一些有趣的数据和统计数据证明了这一点。例如,2020 年有 50.7 亿人使用互联网。这超过了世界人口的一半。在超过 50 亿的用户中,74.1% 的人访问的内容使用英语以外的其他语言。

您可以在 Statista 上找到并浏览以上声明的统计数据。

i18n 基础知识

在我们深入研究 i18n 如何与 Remix 一起工作之前,让我们先看看 i18n 的基础知识。在 i18n 中,有三种方法可以确定语言和地区:IP 地址的位置、Accept-Language 标头Navigator.languages,以及 URL 中的标识符。

IP 地址的位置

此方法使用请求的 IP 地址的位置来提供该地区最流行的语言。此方法存在一些问题,首先是有更准确的方法来确定语言和地区。此外,此方法不能为用户提供最佳 UX。例如,如果您去其他国家/地区旅行,您将看到该国家/地区的语言的内容,而不是您首选的语言。

Accept-Language 标头或 Navigator.languages

此方法基于浏览器的语言设置。它比使用 IP 地址的位置更准确。此方法提供用户的首选语言信息,但用户无法从 UI 切换语言。

URL 中的标识符

此方法基于 URL 中设置的标识符。它是确定语言和地区的最准确方法。它需要更多的工作来实现,但为用户提供了最佳 UX。URL 中的标识符示例有 https://remix.org.cn/de-athttps://remix.org.cn/fr-ca 等。这种方法称为本地化子目录。

或者,如果您不关心 SEO 和同源策略,您可以使用不同的域和 URL 参数来为其他语言和地区创建 URL 标识符。但一般来说,我们关心这些事情,因为我们希望使网络变得更好,所以我们在本文中将重点介绍本地化子目录。

i18n 如何与 Remix 一起工作

当您要使用任何框架实现 i18n 时,您应该考虑它们是否提供多种实用且灵活的选项

我在 我的 i18n 演讲中多次强调这一点,否则您的 DX 将会很痛苦,并且您很可能会由于框架的技术限制而牺牲 UX。我不是说其他框架不足,但是当我在 i18n 项目上工作时,我曾经在其他框架中遇到过噩梦,例如无法以编程方式修改 slug,需要额外的 npm 包等。

i18n 是一个复杂的主题,并且不止一种直接的方法来实现它。这就是为什么我们需要几个实用且灵活的选项来为每个项目找到实现 i18n 的最佳方法。

幸运的是,Remix 为实现 i18n 提供了几个实用且灵活的选项!让我们看看 i18n 如何与 Remix 一起工作。

1. remix-18next

remix-i18next 是由 Sergio Xalambrí 创建的用于使用 Remix 进行 i18n 的 npm 模块。remix-i18next 构建在 i18n JavaScript 库 i18next 的基础上。i18next 提供了在 Web、移动和桌面设备上本地化产品的功能,并具有许多标准的 i18n 功能。

此方法需要几个步骤来实现使用 Remix 的 i18n,例如安装多个 npm 模块,在源代码级别维护翻译 JSON 文件,并使用 useTranslation hook 来翻译内容。

有一些配置文件,例如具有本地化值的 JSON 文件和用于服务器端和客户端配置的 i18n 配置文件。

// common.json
{
  "intro": "こんにちは!"
}
// i18n.js -> i18n config file
export default {
  supportedLngs: ["en", "ja"],
  fallbackLng: "en",
  defaultNS: "common", // common.json namespace
  react: { useSuspense: false }, // Disabling suspense is recommended
};
// i18next.server.js -> contains the logic to be used in entry.server.jsx
import Backend from "i18next-fs-backend";
import { resolve } from "node:path";
import { RemixI18Next } from "remix-i18next";
import i18n from "~/i18n"; // i18n config file

let i18next = new RemixI18Next({
  detection: {
    supportedLanguages: i18n.supportedLngs,
    fallbackLanguage: i18n.fallbackLng,
  },
  i18next: {
    ...i18n,
    backend: {
      loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
    },
  },
  backend: Backend,
});

export default i18next;

有服务器端和客户端配置文件,其中包含 i18n 初始化,用于检测每个请求中的特定语言环境并加载相应的翻译 JSON 文件。有关这些文件的更多详细信息,您可以查看我的关于 remix-i18next 的单独文章

设置好配置文件后,您可以使用 useTranslation hook 来翻译内容。

// root.jsx
import { json } from "@remix-run/node";
import { useChangeLanguage } from "remix-i18next";
import { useTranslation } from "react-i18next";
import i18next from "~/i18next.server";
//...

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

export let handle = {
  i18n: "common",
};

export default function App() {
  // Get the locale from the loader
  let { locale } = useLoaderData();
  let { i18n } = useTranslation();

  // change the language of the instance to the locale detected by the loader
  useChangeLanguage(locale);

  return (
    <html lang={locale} dir={i18n.dir()}>
      ...
    </html>
  );
}
// any route
import { useTranslation } from "react-i18next";

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

如果您有兴趣了解如何使用代码片段来使用 remix-i18next,我写了一篇关于 remix-i18next 的单独文章

请注意,remix-i18next 方法需要安装多个额外的 npm 模块,并在源代码级别维护翻译 JSON 文件。它需要一些工作才能实现本地化子目录。如果您希望内容编辑器承担更多责任来帮助您处理内容任务,请考虑下一种方法。

2. Remix 和 CMS 的组合

正如我提到的拥有多种实用且灵活的选项的重要性,调查使用哪个 CMS 将是拥有更多选项以找到适合您网站的最佳方法的关键过程。CMS 提供了各种方法来帮助构建本地化内容,并与源代码分开管理该内容。

根据每个 CMS 的不同,选项的数量和实现 i18n 的方式也会不同。在本文中,我将使用 Storyblok 作为示例之一。

Storyblok 有四种方法可供您选择来构建内容,从而有足够的空间来使 i18n 实现灵活高效。

  1. 文件夹级翻译
  2. 字段级翻译
  3. 文件夹级和字段级翻译的组合
  4. 空间级翻译

文件夹级翻译 方法允许您为每种语言和地区创建一个文件夹,并在每个文件夹中管理内容。在某种程度上,您可以为内容编辑器创建不同的环境。同时,文件夹名称充当本地化子目录。内容编辑器可以帮助您可视化他们希望如何在每个语言和地区从 CMS UI 构建本地化子目录。

A screenshot of Storyblok UI displaying Japanese, German and English folders to separate different localized content as well as creating localized sub-directory structures

这使我们更容易实现本地化子目录,因为我们可以专注于实现。在使用 Remix 的 Splat Routes 来捕获任何嵌套级别的所有 slug 时,您可以享受使用 Remix 和文件夹级翻译方法实现 i18n 的出色 DX。

我还写了一篇关于使用文件夹级翻译方法的 Remix 和 CMS 组合的单独文章,包括如何使用 Splat Routes。

字段级翻译 方法创建一个内容树。无需为每种语言和地区创建独立的文件夹。可翻译字段将作为单独的独立属性存储在内容树中,并带有每种语言的后缀。简而言之,如果您想为每种语言和地区应用相同的页面布局,则可以使用此方法来防止在几个本地化内容文件夹中复制常用页面。相反,创建一个页面并在一个内容树中本地化内容。

A screenshot of Storyblok UI displaying default, Italian, Hong Kong, English, German and Japanese language options to switch different localized home page in one content tree of home page

要可视化一个内容树如何准确地将可翻译字段作为单独的独立属性存储,并带有所有语言的后缀,您可以查看此主页的 JSON 文件的屏幕截图。

当您更改 URL 上的 language 参数时(即,language=jalanguage=de 或 Storyblok UI 上面屏幕截图中任何其他语言选项),您可以看到每种语言和地区的 JSON,其中包含相应的 full_slugslang

A screenshot of JSON with ja language parameter on the URL with corresponding full_slug and lang with language parameter value

Storyblok 还提供 Links API 来检索包含所有链接的链接对象。

要在 Storyblok UI 上启用相应的实时预览,您可以安装 Advanced Paths 应用程序来以编程方式为每种语言和地区配置预览 URL。

也可以混合使用文件夹级和字段级翻译方法。此方法更复杂,可以处理许多区域,例如 de-atde-chde-de 等。

空间级翻译方法是为每种语言和地区创建一个单独的空间。这种方法简化了内容编辑和开发人员的环境划分。记住这一点可以简化环境。

例如,在确保每个空间中划分环境的同时,您可以使用 Storyblok CLI管理 API 在空间之间共享组件、页面(在 Storyblok 中称为 story)和模式。这是保持内容结构和组件一致性的好方法。

我列出了来自一个 CMS 的四种方法,但在某些情况下,无论您的 POC 有多么出色,都无法说服您的团队和决策者而采用 CMS 方法。有时,预算决策等事情是我们无法控制的,对吧?在这种情况下,您可以考虑以下方法。

3. 可选段

Remix 提供了一个名为 可选段的内置功能。可选段解决了我们在上面看到的 i18n 的所有潜在问题,如果您无法采用 CMS,这是一种好方法。Remix 的内置功能提供了愉快的 DX,使您可以:

  • 捕获嵌套 URL 和布局中的所有 slug
  • 通过简单地在您的路由中添加 ($lang) 来提取一个可选的 lang 参数

此外,可以通过创建一个可重用的辅助函数来检测 params.lang 是否不是有效的语言值。这是为用户提供最佳 UX 的好方法。

让我们通过一些示例代码更深入地了解我所提到的内容。您可以 fork 并克隆此 可选段示例应用程序仓库,以便在本地计算机上进行测试。

A GIF of Remix Optional Segments example app

此示例应用程序部分基于 Remix 联系人应用程序教程。它有一个 contacts 路由,contacts 路由有一个 contactId 参数。

app/
├── components/
│   └── Header.tsx // language switcher
├── routes/
│   ├── _index.tsx
│   ├── ($lang).contacts.$contactId.tsx
│   └── ($lang).contacts.tsx
├── root.tsx
├── data.tsx // contacts data
└── utils.ts // reusable getLang function to check valid params.lang

URL /ja/contacts/ryan-florence/contacts/ryan-florence 都将匹配 app/routes/($lang).contacts.$contactId 路由,因为 ($lang) 是可选的。在此示例中,如果没有提供 lang 参数,我们将默认为英语(en)。

$lang 参数将匹配不同嵌套级别的所有 slug,例如本示例应用程序中的 ja/contactsja/contacts/ryan-florance。它涵盖了您想要在没有 CMS 的情况下实现本地化子目录的情况。

params.lang 这样的内置参数可以节省您实现支持 i18n 的路由的时间。要启用可选段,您可以在路由中添加 ($lang),如 app/routes/($lang).contacts.$contactId,以捕获路由中的 lang 参数。

配置具有功能性路由的本地化子目录是 i18n 实现过程中最重要但又耗时的部分,具体取决于框架的内置功能。Remix 通过提供有用的参数、灵活的结构和最佳的开发人员体验来消除这种痛苦。

请确保您遵循 Google SEO 指南,了解 URL 中非 ASCII 字符的使用。不建议在 URL 中使用非 ASCII 字符(例如,ja/contacts/マイケル-ジャクソン)。最好在 URL 中使用 ASCII 字符(例如,ja/contacts/michael-jackson)。

可选段示例应用程序仓库还包括一个可重用的函数,用于检查 params.lang 是否不是有效的语言代码。为了获得更好的用户体验,检测用户何时使用无效的语言代码访问 URL 至关重要。同样重要的是要向他们显示使用无效语言 slug 的页面不存在。

// utils.ts
import { Params } from "@remix-run/react";

export function getLang(params: Params<string>) {
  const lang = params.lang ?? "en";
  if (lang !== "ja" && lang !== "en") {
    throw new Response(null, {
      status: 404,
      statusText: `Not Found: Invalid language ${lang}`,
    });
  }
  return lang;
}

对于无效的 params.lang,它会抛出一个 404 状态,并显示消息“未找到:无效语言 ${lang}”。有关如何抛出 404 响应的说明,请参见 Remix 文档

getLang 函数返回选定的有效 params.lang 值,这意味着它可用于仅获取所选语言的必要数据。data.tsx 中的带有类型的联系人数据对象如下所示:

// data.tsx
type ContactMutation = {
  id?: string;
  avatar?: string;
  twitter?: string;
  notes?: string;
  favorite?: boolean;
  details?: {
    en?: {
      first?: string;
      last?: string;
    },
    ja?: {
      first?: string;
      last?: string;
    }
  }
};

我们可以使用 getLang 函数来精简数据,仅返回内容的正确翻译

// ($lang).contacts.$contactId.tsx
// ...
import { getLang } from "~/utils";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const lang = getLang(params);
  const singleContact = await getContact(params.contactId);

  if (!singleContact) {
    throw new Response("Not Found", { status: 404 });
  }

  const { avatar, twitter, notes, details } = singleContact;
  // 1. getLang func checks if params.lang is "en" or "ja"
  // 2. get either ja.first & ja.last or en.first & en.last
  const name = `${details?.[lang]?.first} ${details?.[lang]?.last}`;
  // return only necessary data for the selected language
  return json({ avatar, twitter, notes, name });
};

export default function Contact() {
  const { avatar, twitter, notes, name } = useLoaderData<typeof loader>();
  return (
    <div id="contact">
      <div>
        <img alt={`${name} avatar`} key={avatar} src={avatar} />
      </div>

      <div>
        <h1>{name}</h1>

        {twitter ? (
          <p>
            <a href={`https://twitter.com/${twitter}`}>{twitter}</a>
          </p>
        ) : null}

        {notes ? <p>{notes}</p> : null}
      </div>
    </div>
  );
}

这样,我们可以只获取所选语言所需的数据,而不是获取单个联系人的所有语言的所有数据。

// 😩 NOT what we want
{
  "avatar":
    "https://sessionize.com/image/124e-400o400o2-wHVdAuNaxi8KJrgtN3ZKci.jpg",
  "twitter": "@shrutikapoor08",
  "details": {
    "en": {
      "first": "Shruti",
      "last": "Kapoor",
    },
    "ja": {
      "first": "シュルティー",
      "last": "カプアー",
    }
  },
}

// 😁 What we want
{
  "avatar": "https://sessionize.com/image/124e-400o400o2-wHVdAuNaxi8KJrgtN3ZKci.jpg",
  "twitter": "@shrutikapoor08",
  "name": "シュルティー カプアー"
}

获取侧边栏的联系人列表与我们在 ($lang).contacts.$contactId.tsx 中看到的获取单个联系人的方式非常相似。只有我们想要获取的属性不同。

// ($lang).contacts.tsx
// ...
import Header from "~/components/Header";
import { getLang } from "~/utils";

export const loader = async ({ params }: LoaderFunctionArgs) => {
  const fullContact = await getContacts();
  const lang = getLang(params);

  const contacts = fullContact.map((contact) => ({
    // different properties to get compared to a single contact
    id: contact.id,
    name: `${contact.details?.[lang]?.first} ${contact.details?.[lang]?.last}`,
  }));

  return json({ contacts, lang });
};

export default function ContactsLayout() {
  const { contacts, lang } = useLoaderData<typeof loader>();

  return (
    <>
      <Header />
      <div id="wrapper">
        <div id="sidebar">
          <h1>{lang === "ja" ? `Remix コンタクト` : `Remix Contacts`}</h1>
          <nav>
            {contacts.length ? (
              <ul>
                {contacts.map(({ id, name }) => {
                  return (
                    <li key={id}>
                      <Link to={`${id}`}>{name}</Link>
                    </li>
                  );
                })}
              </ul>
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </nav>
        </div>
        {/* ... */}
      </div>
    </>
  );
}

要在标头中创建语言切换器,我们可以在 Header 组件上使用带有来自 Remix 的 useLocationuseParams 钩子的 getLang 辅助函数。

useLocation 可用于获取当前路径名(对象),并用所选语言替换路径名。

useParams 返回当前位置与路由匹配的动态参数的键值对对象。(例如,routes/($lang).contacts.$contactId.tsxja/contacts/glenn-reyes 匹配,将返回 params.contactId,其值为 glenn-reyes)。

// components/Header.tsx
import { Link, useLocation, useParams } from "@remix-run/react";
import { getLang } from "~/utils";

export default function Header() {
  const { pathname } = useLocation();
  const params = useParams();
  const lang = getLang(params);

  return (
    <div id="header">
      <h1>
        {lang === "ja" ? `Optional Segments デモ` : `Optional Segments Example`}
      </h1>
      <nav>
        {lang === "ja" ? (
          <Link to={pathname.replace(/^\/ja/, "")}>🇺🇸</Link>
        ) : (
          <Link to={`/ja${pathname}`}>🇯🇵</Link>
        )}
      </nav>
    </div>
  );
}

通过这种方式,我们可以在标头中创建一个语言切换器,以切换语言并用所选语言替换路径名。

更多示例和资源

如果您正在寻找不同的示例和资源来了解 Remix 的 i18n 工作原理,我建议您查看 Dilum Sanjaya 的交互式 Remix 路由示例。Dilum 构建了一个示例应用程序来可视化 Remix 路由,他的示例包括来自 Remix 文档的可选段示例。

我希望这篇文章能帮助您了解 i18n 的工作原理以及如何使用 Remix 更有效地管理 i18n。如果您有任何问题或反馈,请随时在 Twitter 上联系我!


获取有关最新 Remix 新闻的更新

率先了解新的 Remix 功能、社区活动和教程。