🚀 Remix web框架搭建,React-Router v7 实现 i18n 方案

2025/02/11

1. Remix 与 React-Router v7

目前 Remix 是基于 V2 版本(2025.02.08),官方正在对 V2 进行底层迁移,基于 React-Router v7 版本作为基础框架,https://reactrouter.com.cn/ ,开发文档及API在 Remix 上也适用。

2. 项目初始化

最新版本初始化项目

npx create-react-router@latest fe-site

3. 支持 i18n 的项目结构

🎯 目标:支持 URL 路径为 /zh/ 、/方式实现i18n,识别用户语言(优先级)、返回对应语言页面,并在任何页面上能够获取当前语言 locale。


🔧 使用文件路由方式:

routes.ts 文件用了官方社区兼容库:remix-flat-routes。/app/routes 支持文件路由以及文件夹目录下嵌套路由 https://github.com/kiliman/remix-flat-routes

// app/routes.ts
import { remixRoutesOptionAdapter } from '@react-router/remix-routes-option-adapter'
import { flatRoutes } from 'remix-flat-routes'

export default remixRoutesOptionAdapter((defineRoutes) => {
  return flatRoutes('routes', defineRoutes, {
    ignoredRouteFiles: ['**/.*'], // Ignore dot files (like .DS_Store)
    //appDir: 'app',
    //routeDir: 'routes',
    //basePath: '/',
    // paramPrefixChar: '$',
    // nestedDirectoryChar: '+',
    //routeRegex: /((\${nestedDirectoryChar}[\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
  })
})

📦 目录层级如下:

  • $(lang)+ 这里加一层目录作用是支持 /* 和 /$lang/* 的路由;
  • $(lang)+/_.tsx 判断语言是否支持、 404/500 报错处理、全局 Meta 等;
  • $(lang)+/$.tsx 这个页面用于路由入口,兼容 /lang,/* 的路由,以及抛出404由 _.tsx文件进行处理;

文件路由的符号,可以在文档上进行查阅 https://github.com/kiliman/remix-flat-routes/

📃 核心文件: /app/routes/($lang)+/$.jsx

import { Outlet } from 'react-router'

// 这个页面用于路由入口,兼容 /lang,/* 的路由
// 不在这里添加任何业务逻辑
export default function Route() {
  return <Outlet />
}

export const loader = () => {
  throw Response.json('NotFound', {
    status: 404,
  })
}

/app/routes/($lang)+/_.tsx

import { isRouteErrorResponse, Outlet } from 'react-router'
import type { LoaderFunction, MetaArgs, MetaFunction } from 'react-router'

import type { Route } from './+types/_'

import PageNotFound from '@app/components/PageNotFound'
import PageServerError from '@app/components/PageServerError'

import i18nOptions from '@app/locale/options'

export const meta: MetaFunction = (metaArgs: MetaArgs) => {
  // 可根据错误类型,定义meta
  if (isRouteErrorResponse(metaArgs.error)) {
    return [{ title: '404' }]
  }

  // 定义全局的 meta 信息
  return [{ title: `fe.site title` }]
}

export const loader: LoaderFunction = async ({ params, request }) => {
  console.log('_ loader ', params.lang)
  // @ts-ignore
  if (params.lang && i18nOptions.supportedLngs.indexOf(params.lang) === -1) {
    // 不支持的语言,抛出 404
    throw Response.json('NotFound', {
      status: 404,
    })
  }

  return null
}

export default function Route() {
  return <Outlet />
}

// :全局的错误处理
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  let message = 'Oops!'
  let details = 'An unexpected error occurred.'
  let stack: string | undefined

  // 开发环境下,显示错误信息
  if (import.meta.env.DEV && error && error instanceof Error) {
    details = error.message
    stack = error.stack

    return (
      <main>
        <h1>{message}</h1>
        <p>{details}</p>
        {stack && (
          <pre>
            <code>{stack}</code>
          </pre>
        )}
      </main>
    )
  }

  if (isRouteErrorResponse(error)) {
    // 404 页面
    if (error.status == 404) {
      return <PageNotFound />
    }

    // 500 错误
    if (error.status === 500) {
      return <PageServerError />
    }
  }

  return <PageNotFound />
}

root.tsx

import {
  isRouteErrorResponse,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  redirect,
  type LoaderFunctionArgs,
} from "react-router";

import type { Route } from "./+types/root";

import { useTranslation } from "react-i18next";
import i18next, { localeCookie } from "@app/locale/i18n.server";
import { useChangeLanguage } from "remix-i18next/react";
import i18nOptions from "@app/locale/options";
import { UAParser } from "ua-parser-js";

export async function loader({ request, params }: LoaderFunctionArgs) {
  const locale = await i18next.getLocale(request);

  // 如果请求的是根/* 路径,并且locale不是us,则重定向到带有locale的路径
  if (!params.locale && i18nOptions.supportedLngs.includes(locale) && locale !== i18nOptions.fallbackLng) {
    const url = new URL(request.url);
    const newPath = `/${locale}${url.pathname}${url.search}`;
    let newUrl = new URL(newPath, request.url).toString();

    return redirect(newUrl, {
      headers: { "Set-Cookie": await localeCookie.serialize(locale) },
    });
  }

  // 解析是否为移动端
  const ua = request.headers.get("user-agent");
  const { device } = UAParser(ua);
  const isMobile = device.is("mobile");

  return Response.json(
    { locale, isMobile, isDev },
    {
      headers: { "Set-Cookie": await localeCookie.serialize(locale) },
    },
  );
}

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

export function Layout({ children }: { children: React.ReactNode }) {
  const { locale, isMobile } = useLoaderData<typeof loader>();
  const { i18n } = useTranslation();

  useChangeLanguage(locale);

  return (
    <html lang={locale} dir={i18n.dir()}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {/* 注入全局变量 */}
        <CommonProvider injectParams={{ isMobile, locale, isDev }}>{children}</CommonProvider>

        <ScrollRestoration />
        <Scripts />

        {!isDev && <GoogleAnalytics4 />}
      </body>
    </html>
  );
}

删除 root.tsx 的报错,即 function ErrorBoundary 部分。


4. i18n 框架选型

目前最流行(截止v7版本最稳定的i18n方案),基于 i18-next 的 remix-i18next(👈文档) 方案。 此方案需要使用 npx react-router reveal 命令解包出 entry.server.ts 和 entry.client.ts,用于词条植入、语言检查等操作。

踩坑点:import Backend from "i18next-http-backend"; 的导入需要改成 import Backend from "i18next-http-backend/cjs",原因该库默认包导出的实现在 vite 打包不兼容报错。

核心文件: /app/locale/i18n.server.ts 这个文件主要定义 i18next 配置。

import Backend from 'i18next-fs-backend'
import { resolve } from 'node:path'
import { RemixI18Next } from 'remix-i18next/server'
import i18nOptions from '@app/locale/options'

let i18next = new RemixI18Next({
  detection: {
    supportedLanguages: i18nOptions.supportedLngs,
    fallbackLanguage: i18nOptions.fallbackLng,
  },
  // This is the configuration for i18next used
  // when translating messages server-side only
  i18next: {
    ...i18nOptions, // i18n初始化配置
    
    backend: {
      loadPath: resolve('./messages/{{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

/app/locale/options.ts

export default {
  fallbackLng: 'en',
  supportedLngs: [
    'en', // English
    'zh', // Chinese (Simplified)
    'ja', // Japanese
    'ko', // Korean
    'es', // Spanish
    'fr', // French
    'de', // German
    'it', // Italian
    'pt', // Portuguese
    'ru', // Russian
    'ar', // Arabic
    'hi', // Hindi
    'vi', // Vietnamese
    'th', // Thai
    'id', // Indonesian
  ],
  defaultNS: 'common', // 默认的命名空间,即 common.json
}

/app/entry.client.tsx 这个文件是 client 入口,需要配置 i18n。

import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from 'react-router/dom'

import i18n from '@app/locale/options'
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() {
  console.log('entry point client file')
  await i18next
    .use(initReactI18next)
    .use(LanguageDetector)
    .use(Backend)
    .init({
      ...i18n, // Extending our default config file with client only fields
      // detects the namespaces your routes rendered while SSR use
      ns: getInitialNamespaces(),
      backend: {
        loadPath: `/messages/{{lng}}/{{ns}}.json?v=${__APP_VERSION__}`,
      },
      detection: {
        order: ['htmlTag'],
        caches: [],
      },
    })

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

console.log('hello????')

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

/app/entry.server.ts 对应服务端相关

import { PassThrough } from 'node:stream'

import type { AppLoadContext, EntryContext } from 'react-router'
import { createReadableStreamFromReadable } from '@react-router/node'
import { ServerRouter } from 'react-router'
import { isbot } from 'isbot'
import type { RenderToPipeableStreamOptions } from 'react-dom/server'
import { renderToPipeableStream } from 'react-dom/server'

import { createInstance } from 'i18next'
import i18next from '@app/locale/i18n.server'
import { I18nextProvider, initReactI18next } from 'react-i18next'
// BUG: if you see an error about a `Top Level await` import form /cjs instead
// see: https://github.com/i18next/i18next-fs-backend/issues/57
// import Backend from 'i18next-fs-backend';

import Backend from 'i18next-fs-backend/cjs'
import i18n from '@app/locale/options' // your i18n configuration file
import { resolve } from 'node:path'

export const streamTimeout = 5_000

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  loadContext: AppLoadContext
) {
  const instance = createInstance()
  const lng = await i18next.getLocale(request)
  const ns = i18next.getRouteNamespaces(routerContext)

  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 shellRendered = false
    let userAgent = request.headers.get('user-agent')

    // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
    // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
    let readyOption: keyof RenderToPipeableStreamOptions =
      (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady'

    const { pipe, abort } = renderToPipeableStream(
      <I18nextProvider i18n={instance}>
        <ServerRouter context={routerContext} url={request.url} />
      </I18nextProvider>,
      {
        [readyOption]() {
          shellRendered = true
          const body = new PassThrough()
          const stream = createReadableStreamFromReadable(body)

          responseHeaders.set('Content-Type', 'text/html')

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          )

          pipe(body)
        },
        onShellError(error: unknown) {
          reject(error)
        },
        onError(error: unknown) {
          responseStatusCode = 500
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error)
          }
        },
      }
    )

    // Abort the rendering stream after the `streamTimeout` so it has time to
    // flush down the rejected boundaries
    setTimeout(abort, streamTimeout + 1000)
  })
}

参考配置的项目:https://github.com/edmondso006/shopify-remix-i18n-blog-post/blob/main

语言检测需要自定义识别,在 i18n.server.ts 文件编辑语言检测,注入在 RemixI18Next 实例上。 i18n.server.ts 代码参考:

import Backend from "i18next-fs-backend/cjs";
import { resolve } from "node:path";
import { RemixI18Next } from "remix-i18next/server";
import i18nOptions, { localeMap } from "@app/locale/options";
import acceptLanguageParser from "accept-language-parser";
import { createCookie } from "react-router";

export const localeCookie = createCookie("locale", {
  path: "/",
  sameSite: "lax",
  secure: process.env.NODE_ENV === "production",
  httpOnly: true,
});

// 语言检测
export const localeDetector = async (request: Request): Promise<string> => {
  // 1. 从 /$locale 中获取
  const url = new URL(request.url);
  const pathSegments = url.pathname.split("/");
  const potentialLocale = pathSegments[1];

  if (potentialLocale && i18nOptions.supportedLngs.includes(potentialLocale)) {
    return potentialLocale;
  }

  // 2. 从 cookie 中获取
  const cookieHeader = request.headers.get("Cookie");
  const locale = (await localeCookie.parse(cookieHeader)) || null;
  if (locale && i18nOptions.supportedLngs.includes(locale)) {
    return locale;
  }

  // 3. 从请求头中获取
  const acceptLanguageHeader = request.headers.get("Accept-Language");
  const localeHeader = detectLanguage(acceptLanguageHeader, Array.from(localeMap.keys()));

  if (localeHeader && i18nOptions.supportedLngs.includes(localeHeader)) {
    return localeHeader;
  }

  return i18nOptions.fallbackLng;
};

/**
 * 根据 Accept-Language header 检测匹配的语言
 * @param acceptLanguage Accept-Language header 字符串
 * @param supportedLangs 支持的语言代码数组
 * @returns 匹配到的语言代码,如果没有匹配则返回 null
 */
export function detectLanguage(acceptLanguage: string | null | undefined, supportedLangs: string[]): string | null {
  if (!acceptLanguage) return null;

  const languageLocale = acceptLanguageParser.pick(supportedLangs, acceptLanguage);
  if (!languageLocale) return null;

  return localeMap.get(languageLocale) || null;
}

let i18next = new RemixI18Next({
  detection: {
    supportedLanguages: i18nOptions.supportedLngs,
    fallbackLanguage: i18nOptions.fallbackLng,
    findLocale: localeDetector,
  },
  // This is the configuration for i18next used
  // when translating messages server-side only
  i18next: {
    ...i18nOptions,
    backend: {
      loadPath: resolve("./public/messages/{{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;

i18n.options.ts

import type { InitOptions } from "i18next";

// 语言代码映射,value 作为 locale
export const localeMap = new Map<string, string>([
  ["en-US", "us"], // 英语
  ["zh-CN", "cn"], // 简体中文
  ["zh-HK", "hk"], // 繁体中文
  ["zh-TW", "hk"], // 繁体中文
  ["zh-MO", "hk"], // 繁体中文
  ["ko-KR", "kr"], // 韩语
  ["ja-JP", "jp"], // 日语
  // ["it-IT", "it"],
  // ["de-DE", "de"],
  // ["fr-FR", "fr"],
  // ["es-ES", "es"],
  // ["ru-RU", "ru"],
]);

interface ILocaleFullLanguage {
  [key: string]: string;
}

/** eg: zh-cn、us-en */
export const localeLabelMap: ILocaleFullLanguage = {
  us: "English",
  cn: "简体中文",
  hk: "繁體中文",
  jp: "日本語",
  kr: "한국어",
};

const options = {
  supportedLngs: Array.from(localeMap.values()),
  fallbackLng: "us",
  defaultNS: "common",
  load: "languageOnly",
  react: { useSuspense: false },
} satisfies InitOptions;

export default options;