Next.js 13 appDir 实战 i18n

背景

官方目前未打算支持 i18n 国际化路由支持,且尚未提供解决方案,但是我们可以通过实验特性 appDir 来实现。

Not Planned Features

We are currently not planning to include the following features in app:

  • Internationalization (i18n) - we will be providing a guide on how to implement internationalization in app.
  • AMP Support

If you need any of these features, we will continue to support pages, including bug fixes and feature additions, for multiple major versions.

文档地址: https://beta.nextjs.org/docs/app-directory-roadmap#not-planned-features

首先需要了解一下 Server and Client Components 服务器组件和客户端组件。

What do you need to do?Server ComponentClient Component
Fetch data. Learn more.⚠️
Access backend resources (directly)
Keep sensitive information on the server (access tokens, API keys, etc)
Keep large dependencies on the server / Reduce client-side JavaScript
Add interactivity and event listeners (onClick(), onChange(), etc)
Use State and Lifecycle Effects (useState(), useReducer(), useEffect(), etc)
Use browser-only APIs
Use custom hooks that depend on state, effects, or browser-only APIs
Use React Class components

简单来说,服务器端组件不支持事件侦听、不支持生命周期状态,这导致了原有的好多组件不可以直接拿来就用了。比如 next-i18next, next-themes 等等好多,直接引入使用会报各种莫名其妙的错误。

废话不多说,直接进入正题。

实现

i18n 方法

首先你需要一个 i18n 的实现,可以用 i18next,也可以用 rosetta 之类的。我这里实现了一个简化版的。

// 参考示例项目的 /i18n/next-i18n.ts
import dlv from 'dlv';
import tmpl from 'templite';

// eslint-disable-next-line no-unused-vars
type Fn = (...args: any[]) => string;
export interface I18nDict {
  [key: string]: string | number | Fn | I18nDict;
}

export interface NextI18nOptions {
  /**
   * Define the list of supported languages, this is used to determine if one of
   * the languages requested by the user is supported by the application.
   * This should be be same as the supportedLngs in the i18next options.
   */
  supportedLanguages: string[];
  /**
   * Define the fallback language that it's going to be used in the case user
   * expected language is not supported.
   * This should be be same as the fallbackLng in the i18next options.
   */
  fallbackLng: string;
}

export class NextI18n {
  private currentLocale: string;

  public fallbackLng: string;

  public supportedLanguages: string[];

  private dict: I18nDict = {};

  constructor(options: NextI18nOptions) {
    this.currentLocale = options.fallbackLng;
    this.supportedLanguages = options.supportedLanguages;
    this.fallbackLng = options.fallbackLng;
  }

  public locale = (lang?: string) => {
    if (lang !== undefined && this.currentLocale !== lang) {
      this.currentLocale = lang;
      this.onChangeLanguage?.(lang);
    }
    return this.currentLocale;
  };

  public set = (lang: string, dict: I18nDict) => {
    this.dict[lang] = Object.assign(this.dict[lang] || {}, dict);
  };

  public t = (key: string, params?: any, lang?: string): string => {
    // eslint-disable-next-line
    const val = dlv(this.dict[lang || this.currentLocale] as any, key, key);
    // eslint-disable-next-line
    if (typeof val === 'function') return val(params) as string;
    // eslint-disable-next-line
    if (typeof val === 'string') return tmpl(val, params);
    return val as string;
  };

  /* PROTECTED */
  // eslint-disable-next-line no-unused-vars
  private onChangeLanguage?: (locale: string) => void;

  // eslint-disable-next-line no-unused-vars
  public setOnChange = (fn: (locale: string) => void) => {
    this.onChangeLanguage = fn;
  };
}

然后是初始化这个 i18n 实例:

// 参考示例项目的 /i18n/index.ts
import { NextI18n } from './next-i18n';

export const languages = {
  'zh-CN': { name: '简体中文', flag: '🇨🇳', unicode: '1f1e8-1f1f3' },
  'zh-TW': { name: '正體中文', flag: '🇹🇼', unicode: '1f1f9-1f1fc' },
  en: { name: 'English', flag: '🇺🇸', unicode: '1f1fa-1f1f8' },
  ko: { name: '한국어', flag: '🇰🇷', unicode: '1f1f0-1f1f7' },
  ja: { name: '日本語', flag: '🇯🇵', unicode: '1f1ef-1f1f5' }
};

export const supportedLanguages = Object.keys(languages);
export const fallbackLng = 'zh-CN';

const i18n = new NextI18n({
  supportedLanguages,
  fallbackLng
});

supportedLanguages.forEach((locale) => {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
  i18n.set(locale, require(`./${locale}/common.json`));
});

export default i18n;

Provider

注意 Provider 中用到了 useState 来强制刷新,所以必须是个 Client Component

// 参考示例项目的 /i18n/provider.ts
'use client';

import {
  createContext,
  createElement,
  ReactNode,
  useMemo,
  useState
} from 'react';
import { NextI18n } from './next-i18n';

export const context = createContext<{ i18n: NextI18n } | null>(null);

interface I18nProviderProps {
  children: ReactNode;
  i18n: NextI18n;
}

export function I18nProvider({ i18n, children }: I18nProviderProps) {
  const [, setTick] = useState(0);

  const value = useMemo(() => {
    // eslint-disable-next-line
    i18n.setOnChange(() => {
      setTick((s) => s + 1);
    });
    return { i18n };
  }, [i18n]);

  // eslint-disable-next-line react/no-children-prop
  return createElement(context.Provider, {
    value: { ...value },
    children
  });
}

Hook

不用说,也是个 Client Component

// 参考示例项目的 /i18n/hook.ts
'use client';

import { useContext } from 'react';
import { context } from './provider';

export function useI18n() {
  const content = useContext(context);
  if (!content) {
    throw new Error('Unable to get instance of i18n');
  }
  return content.i18n;
}

app 中创建

不要直接在 layout、page 中使用 Client Component,所以我又在 Provider 上套了一层。

// laoyout.tsx
// 手动套一层 provider
import { I18nClientProvider } from './providers';

export default function RootLayout({
  children,
  params
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  const { locale = 'zh-CN' } = params || {};

  return (
    <I18nClientProvider locale={locale}>
      <html lang={locale}>
        <head />
        <body>
          <div className='pt-20' style={{ minHeight: 'calc(100vh - 75px)' }}>
            {children}
          </div>
        </body>
      </html>
    </I18nClientProvider>
  );
}

这个 Provider 的代码为:

'use client';
import { I18nProvider, i18n } from '@/i18n';
import { useEffect } from 'react';

export function I18nClientProvider({
  children,
  locale
}: {
  children: React.ReactNode;
  locale: string;
}) {
  useEffect(() => {
    if (i18n.locale() !== locale) {
      i18n.locale(locale);
    }
  }, [locale]);

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

最后

<think>好的,用户问的是如何将现有的路由结构转换为Next.js App Router的规范结构。首先,我需要回顾之前的对话,用户之前提供了项目的路由表,结构比较杂乱,没有从属关系,现在需要整理成Next.js的规范结构。 首先,我需要确认用户对Next.js App Router的了解程度。可能他们已经有一定的基础,但遇到了结构混乱的问题。用户的需求不仅仅是转换,而是如何有序地组织路由,提升可维护性。 接下来,我得分析用户提供的路由结构。之前的回复中,我建议按功能模块划分,比如管理后台、API、认证等,并给出了优化步骤。现在用户需要具体的转换步骤,可能需要更详细的指导。 我需要考虑Next.js App Router的特性,比如基于文件系统的路由,动态路由的使用,以及布局和页面的组织方式。用户可能需要知道如何将现有目录结构映射到Next.js的约定结构中,比如app目录下的page.tsx和layout.tsx的使用。 用户可能还关心如何处理现有的API路由,是否需要将它们移到app/api目录下,以及如何组织这些API路由以符合Next.js的最佳实践。此外,动态路由的命名和结构也需要符合Next.js的规范,比如使用[参数名]的方式。 另外,用户提到的重复目录example和examples需要合并,这可能涉及到代码的迁移和重命名,需要确保在转换过程中不遗漏任何功能。 可能还需要考虑页面组件和公共组件的分离,将components目录中的组件合理组织,可能引入Component Provider或设计系统来管理。 用户可能没有明确提到的深层需求包括代码的可维护性、团队协作的便利性以及性能优化。因此,在回答中需要涵盖这些方面,比如通过动态导入优化性能,使用Error Boundary处理错误,以及配置ESLint和Prettier保持代码风格统一。 最后,需要确保回答的结构清晰,分步骤说明,并给出具体的示例代码,比如如何重构目录结构,如何创建布局组件,如何处理动态路由等。同时,相关问题需要引导用户进一步细化需求,比如动态路由的最佳实践、API路由的分离方法、组件库的构建等。</think>以下是针对Next.js App Router规范的路由结构转换指南: 1. 标准目录结构重构 ```bash app/ ├── admin/ │ ├── cms/ │ │ ├── [id]/ │ │ │ ├── edit/ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── create/ │ │ │ └── page.tsx │ │ └── page.tsx │ ├── users/ │ │ ├── [id]/ │ │ │ ├── make-admin/ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ └── layout.tsx │ ├── api/ │ ├── admin/ │ │ ├── cms/ │ │ │ └── route.ts │ │ └── users/ │ │ └── route.ts │ └── auth/ │ ├── signin/ │ │ └── route.ts │ └── signup/ │ └── route.ts │ ├── auth/ │ ├── signin/ │ │ └── page.tsx │ └── signup/ │ └── page.tsx │ ├── content/ │ └── page.tsx │ ├── dashboard/ │ └── page.tsx │ ├── (public)/ │ ├── home/ │ │ └── page.tsx │ └── sitemap/ │ └── page.tsx │ ├── layout.tsx └── page.tsx ``` 2. 路由转换核心步骤 (1) 动态路由规范化 ```tsx // 旧结构: app/test-cms/page.tsx // 新结构: app/cms/[id]/page.tsx // 使用动态参数: export default function CMSPage({ params }: { params: { id: string } }) { // 页面逻辑 } ``` (2) 嵌套路由处理 ```tsx // 创建布局组件处理嵌套结构 // app/admin/layout.tsx export default function AdminLayout({ children }: { children: React.ReactNode }) { return ( <div className="admin-layout"> <AdminSidebar /> <main>{children}</main> </div> ) } ``` (3) API路由迁移 ```ts // 旧结构: app/api/admin/files/route.ts // 新结构: app/api/admin/files/route.ts import { NextRequest } from 'next/server' export async function GET(request: NextRequest) { // 处理逻辑 } ``` (4) 公共组件优化 ```tsx // 创建组件Provider统一管理 // components/ComponentProvider.tsx export function Button({ children }: { children: React.ReactNode }) { return <button className="custom-button">{children}</button> } // 使用方式: // app/page.tsx import { Button } from '@/components/ComponentProvider' ``` 3. 配置优化建议 ```ts // next.config.js const nextConfig = { reactStrictMode: true, swcMinify: true, experimental: { appDir: true, }, eslint: { dirs: ['app', 'components', 'lib'], }, } module.exports = nextConfig ``` 4. 性能优化技巧 ```tsx // 动态导入优化 const LazyComponent = dynamic(() => import('@/components/LazyComponent')) // 预加载策略 <Link href="/dashboard" prefetch={true}>Dashboard</Link> ```
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Willin 老王躺平不摆烂

感谢你这么好看还这么慷慨

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值