🚀 Remix web框架搭建,React-Router v7 实现 i18n 方案
2025/02/111. 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;