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

Remix 中的国际化

福崎有里沙
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-i18next

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

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

有一些配置文件,例如包含本地化值的 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 钩子来翻译内容。

// 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 路由来捕获任何嵌套级别的所有 slug,从而在使用 Remix 和文件夹级翻译方法实现 i18n 时享受极佳的 DX。

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

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

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=ja 更改为 language=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 CLIManagement API 在空间之间共享组件、页面(Storyblok 中的 story)和架构。这是保持内容结构和组件一致的好方法。

我从其中一个 CMS 列出了四种方法,但有些情况下,无论你的概念验证 (POC) 多么出色,也无法采用 CMS 的方法来说服你的团队和决策者。有时,像预算决策这样的事情是我们无法控制的,对吧?在这种情况下,你可以考虑以下方法。

3. 可选片段

Remix 提供了一个名为可选片段的内置功能。可选片段解决了上面我们看到的 i18n 的所有潜在问题,如果你无法采用 CMS,这是一个不错的方法。Remix 的内置功能提供了愉快的开发者体验 (DX),使你能够

  • 捕获嵌套 URL 和布局中的所有片段
  • 通过在路由中简单地添加 ($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 参数将匹配不同嵌套级别中的所有片段,例如,在此示例应用程序中匹配 ja/contactsja/contacts/ryan-florance。它涵盖了在没有 CMS 的情况下实现本地化子目录的情况。

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

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

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

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

// 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,它将抛出一个带有消息“未找到:无效语言 ${lang}”的 404 状态。如何在 Remix 文档 中解释如何抛出 404 响应。

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 功能、社区活动和教程。