nextjs13/14(App Router)国际化解决方案

3,039 阅读5分钟
有更新!
1. 更新了middleware.ts 中文件匹配的写法
2. 更新了ITran类型定义
3. 更新了initTranslations方法
4. 更新了TranslationsProvider方法
5. 更新了useChangeLanguage方法
6. 更新了在非react组件中使用翻译的方法

nextjs13/14采用了新的AppRouter模式,这使原来基于next-i18next的国际化方案不再适用,官方给出的国际化样例又难以应用。当你决定看我这个文章的时候,想必你一定是遇到了多种多样的困难,废话不多说我们马上开始正题。

准备工作

官方推荐解决方案 如果你看到我的这个文章,想必是你已经看过这个解决方案了。

为什么不使用上面的方案:

  • 其中间件拦截了路由的配置影响了public下资源的加载
  • 使用了自定义的钩子方案,需要对服务器组件和客户端组件进行区分

参考i18nexus的教程,我给出了如下方案。

设置步骤

1. 安装必要依赖

pnpm add i18next react-i18next i18next-resources-to-backend next-i18n-router

2. 建立i18n配置文件

在你代码的组件结构中建立i18n文件夹,我的i18n文件夹在app文件夹中:

-- app
    -- i18n
        -- locales
            -- zh
                -- common.json
                -- common2.json
            -- en
                -- common.json
                -- common2.json
        -- i18nConfig.ts
        -- index.ts
        -- type.ts

内部代码如下:

// common.json
{"about":"关于","title":"国际化示例"}
// i18nConfig.ts
import { Resource } from "i18next";

export function getNamespaces() {
  return ["common", "common2"];
}

export function getOptions(
  locale: string,
  namespaces?: string[],
  resources?: Resource,
) {
  const ns = namespaces !== undefined ? namespaces : getNamespaces();
  return {
    lng: locale,
    resources,
    fallbackLng: i18nConfig.defaultLocale,
    supportedLngs: i18nConfig.locales,
    defaultNS: ns[0],
    fallbackNS: ns[0],
    ns,
    preload: resources ? [] : i18nConfig.locales,
  };
}

// 这个属性必须要用默认导出
const i18nConfig = {
  defaultLocale: "zh",
  locales: ["zh", "en", "fr"],
};
export default i18nConfig;

// 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[],
  instance?: i18n,
  resources?: Resource,
) {
  const i18nInstance = instance || createInstance();

  i18nInstance.use(initReactI18next);
  if (!resources) {
    i18nInstance.use(
      resourcesToBackend((language: string, namespace: string) => {
        return import(`./locales/${language}/${namespace}.json`);
      }),
    );
  }

  await i18nInstance.init(getOptions(locale, namespaces, resources));
  const t: ITran = (key, namespace, occupied) => {
    return (i18nInstance.t as any)(key, { ns: namespace ?? "common", ...occupied });
  };
  return {
    i18n: i18nInstance,
    resources: i18nInstance.services.resourceStore.data,
    t,
  };
}
// type.ts
import common from "./locales/zh/common.json";
interface resource {
  common: typeof common;
  common2: typeof common2;
}
// 定义一个辅助类型,用于获取 resource 中特定键的类型
type ResourceKeyType<T extends keyof resource> = keyof resource[T];

/**
 * 定义函数类型
 * key 为国际化中翻译的key值,该值集合由namespace确定
 * namespace 命名空间,即国际化json文件的名字
 * occupied 是占位值的替换
 *  如:有一个字段写为"after_time_redirect_to": "{{time}}秒后,即将跳转至"
 *  occupied 写作{time: '3'}即可在翻译时进行替换
 */
export type ITran = <T extends keyof resource = "common">(
  key: ResourceKeyType<T>,
  namespace?: T,
  occupied?: Record<string, string>,
) => string;

3. 提供客户端组件的上下文

// TranslationsProvider.tsx
"use client";

import initTranslations from "@/i18n";
import { tranInstanceManager } from '@/hook'
import { Resource, createInstance } from "i18next";
import { PropsWithChildren } from "react";
import { I18nextProvider } from "react-i18next";

export default function TranslationsProvider({
  children,
  locale,
  namespaces,
  resources,
}: PropsWithChildren<{
  locale: string;
  namespaces: string[];
  resources: Resource;
}>) {
  const i18n = createInstance();
  tranInstanceManager.instance = i18n;
  initTranslations(locale, namespaces, i18n, resources);

  return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}

4. 移动app中文件

默认情况下我们app目录中会有layout和page文件如下面结构所示

--app
    --layout.ts
    --page.ts

移动为如下结构:

--app
    --[locale]
        --layout.ts
        --page.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>
  );
}
// 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 ns = getNamespaces();
  const { resources } = await initTranslations(locale, ns);
  return (
    <TranslationsProvider
          namespaces={ns}
          locale={locale}
          resources={resources}
        >
      <main className=' relative flex max-h-screen min-h-screen flex-row'>
        <Header />
      </main>
    </TranslationsProvider>
  );
}

5. middleware.ts

在root目录新建middleware.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给客户端组件调用

// hooks.ts
// 其实客户端可以直接调用useTranslation()获取t,但没有类型提示,所以我们封装了一层
export function useClientTranslation() {
  const { t: tt, i18n } = useTranslation();
  const t: ITran = (key, namespace, occupied) => {
    return (tt as any)(key, { ns: namespace ?? "common", ...occupied });
  };
  return { t, i18n } as { t: ITran; i18n: i18n };
}

// 改变语言的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=/`;

    if (currentLocale === i18nConfig.defaultLocale) {
      router.push("/" + newLocale + currentPathname);
    } else {
      router.push(
        currentPathname.replace(`/${currentLocale}`, `/${newLocale}`),
      );
    }

    router.refresh();
  };
  return { currentLocale, handleChange };
}
// 在非组件中引用
class TranInstanceManager {
  private i18nInstance!: i18n;
  get instance() {
    return this.i18nInstance;
  }
  set instance(value: i18n) {
    this.i18nInstance = value;
  }
}
export const tranInstanceManager = new TranInstanceManager();

export function getTranslationWithoutReact() {
  const i18n = tranInstanceManager.instance;
  return i18n.t as unknown as ITran;
}

结尾

至此,你已经拥有了一整套server/client组件国际化的解决方案,你是一个国际化专家了!