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 位址的位置來提供該地區最常用的語言。此方法存在一些問題,首先是有更準確的方法可以確定語言和地區。此外,此方法不會為使用者提供最佳的使用者體驗。例如,如果您正在前往另一個國家旅行,您將會看到該國語言的內容,而不是您偏好的語言。

Accept-Language 標頭或 Navigator.languages

此方法基於您瀏覽器的語言設定。它比使用 IP 位址的位置更準確。此方法提供使用者偏好的語言資訊,但使用者無法從 UI 切換語言。

URL 中的識別符

此方法基於 URL 中設定的識別符。它是確定語言和地區的最準確方法。它需要更多的工作才能實作,但為使用者提供了最佳的使用者體驗。URL 中識別符的範例包括 https://remix.dev.org.tw/de-athttps://remix.dev.org.tw/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í 創建的 npm 模組,用於使用 Remix 進行 i18n。remix-i18next 建構在 i18n JavaScript 函式庫 i18next 之上。i18next 提供在網路、行動裝置和桌面上本地化您的產品的功能,並隨附許多標準 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 時,您可以享受出色的 DX,同時使用 Remix 和資料夾層級翻譯方法實作 i18n。

我還寫了一篇關於 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=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 應用程式,針對每種語言和地區以程式化的方式設定預覽網址。

也可以混合使用資料夾層級和欄位層級的翻譯方法。這種方法較為複雜,且會處理許多地區,例如 de-atde-chde-de 等。

空間層級翻譯方法是為每種語言和地區建立獨立的空間。這種方法簡化了內容編輯者和開發人員的環境劃分。將其作為簡化環境的一種方式是值得考慮的。

舉例來說,當您在每個空間中確保劃分的環境安全時,可以使用 Storyblok CLI管理 API 在空間之間共用元件、頁面 (Storyblok 中的 story) 和結構描述。這是一種保持內容結構和元件一致性的好方法。

我列出了來自其中一個 CMS 的四種方法,但在某些情況下,即使您提出了很棒的概念驗證來說服您的團隊和決策者,您也不會選擇使用 CMS 的方法。有時,像預算決策之類的事情不在我們的掌控之中,對吧?在這種情況下,您可以考慮以下方法。

3. 可選區段

Remix 提供一個稱為 可選區段的內建功能。可選區段解決了我們上面看到的所有 i18n 的潛在問題,如果您無法採用 CMS,這是一個很好的方法。Remix 的內建功能提供了令人愉悅的 DX,讓您可以

  • 捕捉巢狀網址和版面配置中的所有 slug
  • 只需在您的路由中新增 ($lang) 即可提取可選的 lang 參數

此外,也可以透過建立可重複使用的輔助函式來偵測 params.lang 是否為有效語言值。這是一種為使用者提供最佳 UX 的好方法。

讓我們透過一些範例程式碼更深入地了解我所提到的內容。您可以 fork 並 clone 這個 可選區段範例應用程式 repo,以便在您的本機電腦上進行測試。

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

網址 /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 透過提供有用的參數、彈性的結構和開發人員的最佳 DX 來消除痛點。

請務必遵循 Google SEO 指南,了解網址中非 ASCII 字元的使用。不建議在網址中使用非 ASCII 字元 (即 ja/contacts/マイケル-ジャクソン)。最好在網址中使用 ASCII 字元 (即 ja/contacts/michael-jackson)。

可選區段範例應用程式 repo 還包括一個可重複使用的函式,用於檢查 params.lang 是否為有效的語言代碼。為了提供更好的 UX,偵測使用者何時使用無效的語言代碼存取網址至關重要。同時也很重要地要告訴他們,具有無效語言 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}」。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 元件上使用 getLang 輔助函式,並搭配 Remix 的 useLocationuseParams hook。

useLocation 可用於取得目前的 pathname (物件) 並以所選語言取代 pathname。

useParams 會傳回一個物件,其中包含目前位置中與路由匹配的動態參數的 key 和 value 配對。(例如,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>
  );
}

透過這種方式,我們可以在標頭上建立語言切換器,以切換語言並以所選語言取代 pathname。

更多範例和來源

如果您正在尋找不同的範例和來源來了解如何使用 Remix 執行 i18n,我建議您查看 Dilum Sanjaya 的互動式 Remix 路由範例。Dilum 建置了一個範例應用程式來視覺化 Remix 路由,而他的範例包括了來自 Remix 文件的可選區段範例。

我希望這篇文章能幫助您了解 i18n 的運作方式以及如何使用 Remix 更有效地管理 i18n。如果您有任何問題或意見反應,請隨時在 Twitter 上與我聯絡!


取得最新的 Remix 新聞

搶先了解新的 Remix 功能、社群活動和教學。