nextjs13/14
采用了新的AppRouter
模式,这使原来基于next-i18next
的国际化方案不再适用,官方给出的国际化样例又难以应用。当你决定看我这个文章的时候,想必你一定是遇到了多种多样的困难,废话不多说我们马上开始正题。
准备工作
官方推荐解决方案 如果你看到我的这个文章,想必是你已经看过这个解决方案了。
为什么不使用上面的方案:
- 其中间件拦截了路由的配置影响了public下资源的加载
- 使用了自定义的钩子方案,需要对服务器组件和客户端组件进行区分
参考i18nexus的教程,我给出了如下方案。
设置步骤
1. 安装必要依赖
bash
pnpm add i18next react-i18next i18next-resources-to-backend next-i18n-router
2. 建立i18n配置文件
在你代码的组件结构中建立i18n文件夹,我的i18n文件夹在app文件夹中:
md
-- app
-- i18n
-- locales
-- zh
-- common.json
-- common2.json
-- en
-- common.json
-- common2.json
-- i18nConfig.ts
-- index.ts
-- type.ts
内部代码如下:
json
// common.json
{"about":"关于","title":"国际化示例"}
ts
// i18nConfig.ts
export function getNamespaces() {
return ["common", "common2"];
}
export function getOptions(locale: string, namespaces?: string[]) {
const ns = namespaces !== undefined ? namespaces : getNamespaces();
return {
lng: locale,
fallbackLng: i18nConfig.defaultLocale,
supportedLngs: i18nConfig.locales,
defaultNS: ns[0],
fallbackNS: ns[0],
ns,
preload: typeof window === "undefined" ? i18nConfig.locales : [],
};
}
// 这个属性必须要用默认导出
const i18nConfig = {
defaultLocale: "zh",
locales: ["zh", "en", "fr"],
};
export default i18nConfig;
ts
// index.ts
import { getOptions } from "@/i18n/i18nConfig";
import { InitOptions, createInstance } from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
import { ITran } from "./type";
export default async function initTranslations(
locale: string,
namespaces?: string[],
) {
const i18nInstance = createInstance();
await i18nInstance
.use(initReactI18next)
.use(
resourcesToBackend(
(language: string, namespace: string) =>
import(`app/i18n/locales/${language}/${namespace}.json`),
),
)
.init(getOptions(locale, namespaces));
return i18nInstance as { t: ITran; options: InitOptions }; // 默认没有代码补全,我们自己手动提供类型
}
ts
// type.ts
import common from "./locales/zh/common.json";
interface resource {
common: typeof common;
common2: typeof common2;
}
type ResourceKey = keyof resource;
export type LangKeys<T> = T extends ResourceKey ? keyof resource[T] : never;
export type ITran = (key: LangKeys<ResourceKey>) => string;
3. 提供客户端组件的上下文
tsx
// TranslationsProvider.tsx
"use client";
import initTranslations from "@/i18n";
import { PropsWithChildren, useEffect, useState } from "react";
import { I18nextProvider } from "react-i18next";
let i18n: any;
export default function TranslationsProvider({
children,
locale,
namespaces,
}: PropsWithChildren<{
locale: string;
namespaces: string[];
}>) {
const [instance, setInstance] = useState(i18n);
useEffect(() => {
const init = async () => {
if (!i18n) {
const newInstance = await initTranslations(locale, namespaces);
i18n = newInstance;
setInstance(newInstance);
} else {
if (i18n.language !== locale) {
i18n.changeLanguage(locale);
}
}
};
init();
}, [locale, namespaces]);
if (!instance) {
return null;
}
return (
<I18nextProvider i18n={instance} defaultNS={namespaces[0]}>
{children}
</I18nextProvider>
);
}
4. 移动app中文件
默认情况下我们app目录中会有layout和page文件如下面结构所示
md
--app
--layout.ts
--page.ts
移动为如下结构:
md
--app
--[locale]
--layout.ts
--page.ts
代码设置如下
ts
// layout.ts
import { dir } from "i18next";
import i18nConfig from "../i18n/i18nConfig";
...
// 静态生成路由
export function generateStaticParams() {
return i18nConfig.locales.map((locale) => ({ locale }));
}
export default function RootLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
return (
<html lang={locale} dir={dir(locale)} data-theme='winter'>
<body>{children}</body>
</html>
);
}
ts
// page.ts
import TranslationsProvider from "@/common/TranslationsProvider";
import Header from "@/component/Header";
import initTranslations from "@/i18n";
export default async function Home({
params: { locale },
}: {
params: { locale: string };
}) {
// 服务器组件上下文,如果有其它服务器组件可以再次调用该方法
const { t, options } = await initTranslations(locale);
return (
<TranslationsProvider namespaces={options.ns as string[]} locale={locale}>
<main className=' relative flex max-h-screen min-h-screen flex-row'>
<Header />
</main>
</TranslationsProvider>
);
}
5. middleware.ts
在root目录新建middleware.ts
文件,并在其中写入如下代码:
ts
// moddleware.ts
import { i18nRouter } from "next-i18n-router";
import { NextRequest } from "next/server";
import i18nConfig from "./app/i18n/i18nConfig";
export function middleware(request: NextRequest) {
return i18nRouter(request, i18nConfig);
}
// applies this middleware only to files in the app directory
export const config = {
matcher: "/((?!api|static|.*..*|_next).*)",
};
有了上面中间件后,我们在访问域名时,会根据设置的语言转发到对应语言路由下,例
- 浏览器语言是
zh
,我们当前设置的也是zh
,这个时候请求localhost:3000
可以直接访问,域名此时为localhost:3000
, 等同于localhost:3000/zh
- 浏览器语言是
zh
,我们当前设置是en
,这个时候请求localhost:3000
,域名会转发到localhost:3000/en
6. 提供hooks给客户端组件调用
ts
// hooks.ts
// 其实客户端可以直接调用useTranslation()获取t,但没有类型提示,所以我们封装了一层
export function useClientTranslation() {
const { t, i18n } = useTranslation();
return { t, i18n } as { t: ITran; i18n: i18n };
}
// 可以同样的包装一层hook给服务组件使用
export function async useServiceTranslation() {
return await initTranslations(locale);
}
// 改变语言的hook
export function useChangeLanguage() {
const { i18n } = useTranslation();
const currentLocale = i18n.language;
const router = useRouter();
const currentPathname = usePathname();
const handleChange = (newLocale: string) => {
// set cookie for next-i18n-router
const days = 30;
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = "; expires=" + date.toUTCString();
document.cookie = `NEXT_LOCALE=${newLocale};expires=${expires};path=/`;
router.push(currentPathname.replace(`/${currentLocale}`, `/${newLocale}`));
router.refresh();
};
return { currentLocale, handleChange };
}
// 在非组件中引用
export function getTranslationWithoutReact() {
const { t } = getI18n();
return t as ITran;
}
结尾
至此,你已经拥有了一整套server/client组件国际化的解决方案,你是一个国际化专家了!