企业级官网全栈(React·Next.js·Tailwind·Axios·Headless UI·RHF·i18n)实战教程-第三篇:国际化多语言系统设计(i18n)

第三篇:国际化多语言系统设计(i18n)

在企业级官网中,国际化不是“加个翻译文件”这么简单

本篇目标:
        构建一套 可扩展、可维护、对 SEO 友好 的国际化体系。

我们将实现:

  • 完整 i18n 架构设计

  • URL / Cookie / LocalStorage 三端同步

  • Header / Footer / 表单文案国际化

  • 与 App Router 深度结合


一、i18n 技术选型

在 Next.js App Router 下,推荐方案只有一个:

next-intl

原因:

  • 官方支持 App Router

  • 支持 Server / Client Components

  • SEO 友好(基于 URL)

  • 类型安全

安装依赖:

pnpm add next-intl


二、国际化整体架构设计

1.语言维度的三层同步

层级用途
URLSEO / 首屏渲染
CookieServer 侧读取
LocalStorageClient 侧记忆

URL 是唯一真源(Single Source of Truth)

正确顺序永远是:

URL(SEO / SSR)
 ↓
Cookie(Server 读取)
 ↓
LocalStorage(Client 体验)

2.目录结构规划

将app/layout.tsx和app/page.tsx移动到app/[locale]/下

src/
 ├─ app/
 │   └─ [locale]/
 │       ├─ layout.tsx
 │       ├─ page.tsx      ← 真首页
 │       ├─ products
 │       └─ ...
 │
 ├─ i18n/
 │   ├─ config.ts
 │   ├─ request.ts
 │   └─ messages/
 │       ├─ zh.json
 │       └─ en.json
 │  
 ├─ proxy.ts    ← 关键


三、i18n 基础配置

i18n/config.ts

export const locales = ['zh', 'en'] as const
export const defaultLocale = 'zh'

export type Locale = (typeof locales)[number]

i18n/messages/zh.json

{
  "nav": {
    "products": "产品",
    "news": "新闻",
    "careers": "招聘",
    "docs": "文档"
  },
  "user_menu": {
    "profile": "个人资料",
    "user_center": "用户中心",
    "login_setting": "登录设置",
    "payment_method": "支付方式"
  },
  "auth": {
    "login": "登录",
    "logout": "退出登录"
  },
  "footer": {
    "address": "北京市朝阳区某某科技园",
    "phone": "电话",
    "copyright": "版权所有"
  }
}

i18n/messages/en.json

{
  "nav": {
    "products": "Products",
    "news": "News",
    "careers": "Careers",
    "docs": "Docs"
  },
  "user_menu": {
    "profile": "Profile",
    "user_center": "User Center",
    "login_setting": "Login Setting",
    "payment_method": "Payment Method",
  },
  "auth": {
    "login": "Login",
    "logout": "Logout"
  },
  "footer": {
    "address": "Beijing Chaoyang Tech Park",
    "phone": "phone",
    "copyright": "All rights reserved"
  }
}

四、Server 端国际化加载

i18n/request.ts

import { getRequestConfig } from 'next-intl/server'
import { locales, defaultLocale, Locale } from './config'

export default getRequestConfig(async ({ locale }) => {
    if (!locales.includes(locale as Locale)) {
        locale = defaultLocale
    }

    return {
        locale: locale as Locale,
        messages: (await import(`./messages/${locale}.json`)).default
    }
})

五、App Router 中的 Locale Layout

app/[locale]/layout.tsx

import '@/app/globals.css'

import { NextIntlClientProvider } from 'next-intl'
import { notFound } from 'next/navigation'
import { Locale, locales } from '@/i18n/config'
import { Header } from '@/components/layout/Header'
import { Footer } from '@/components/layout/Footer'

export default async function LocaleLayout({
  children,
  params
}: {
  children: React.ReactNode
  params: Promise<{ locale: Locale }>
}) {
  const { locale } = await params

  if (!locales.includes(locale as Locale)) {
    notFound()
  }

  const messages = (await import(`@/i18n/messages/${locale}.json`)).default

  return (
    <html lang={locale}>
      <body className="min-h-screen bg-background text-foreground">
        <NextIntlClientProvider locale={locale} messages={messages}>
          <Header />
          {children}
          <Footer />
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

六、Header 文案国际化改造

Header.tsx

'use client'

import Link from 'next/link'
import { MainNav } from './MainNav'
import { UserMenu } from './UserMenu'
import { useTranslations } from 'next-intl'

// 临时模拟登录态(后续会替换为真实 auth)
const mockUser = {
  email: 'user@example.com',
}

export function Header() {
  const isLoggedIn = Boolean(mockUser)
  const t = useTranslations()

  return (
    <header className="border-b">
      <div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6">
        <div className="flex items-center space-x-8">
          <Link href="/" className="text-lg font-bold">
            Enterprise
          </Link>
          <MainNav />
        </div>

        <div>
          {!isLoggedIn ? (
            <Link href="/login" className="text-sm font-medium">
              {t('auth.login')}
            </Link>
          ) : (
            <div className="flex items-center space-x-3">
              <Link href="/user" className="text-sm">
                {t('user_menu.user_center')}
              </Link>
              <span className="text-muted-foreground">|</span>
              <UserMenu email={mockUser.email} />
            </div>
          )}
        </div>
      </div>
    </header>
  )
}

MainNav.tsx

import { useTranslations } from 'next-intl'
import Link from 'next/link'

export function MainNav() {
  const t = useTranslations('nav')

  return (
    <nav className="flex space-x-6">
      <Link href="/products">{t('products')}</Link>
      <Link href="/news">{t('news')}</Link>
      <Link href="/careers">{t('careers')}</Link>
      <Link href="/docs">{t('docs')}</Link>
    </nav>
  )
}

UserMenu.tsx

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import Link from 'next/link'
import { maskEmail } from '@/lib/utils/mask-email'
import { useTranslations } from 'next-intl'

interface UserMenuProps {
  email: string
}

export function UserMenu({ email }: UserMenuProps) {
  const t = useTranslations()
  
  return (
    <Menu as="div" className="relative">
      <MenuButton className="flex items-center space-x-2 text-sm">
        <span>{maskEmail(email)}</span>
        <span>▼</span>
      </MenuButton>

      <MenuItems className="absolute right-0 mt-2 w-40 rounded-md border bg-background shadow">
        <MenuItem>
          {({ focus }) => (
            <Link
              href="/user"
              className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
            >
              {t('user_menu.user_center')}
            </Link>
          )}
        </MenuItem>

        <MenuItem>
          {({ focus }) => (
            <Link
              href="/user/settings"
              className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
            >
              {t('user_menu.login_setting')}
            </Link>
          )}
        </MenuItem>

        <MenuItem>
          {({ focus }) => (
            <Link
              href="/user/payment"
              className={`block px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
            >
              {t('user_menu.payment_method')}
            </Link>
          )}
        </MenuItem>

        <MenuItem>
          {({ focus }) => (
            <button
              className={`w-full text-left px-3 py-2 text-sm ${focus ? 'bg-secondary' : ''}`}
            >
              {t('auth.logout')}
            </button>
          )}
        </MenuItem>
      </MenuItems>
    </Menu>
  )
}

七、Footer 文案国际化

'use client'
import { useTranslations } from 'next-intl'
import { LanguageSwitcher } from './LanguageSwitcher'

export function Footer() {
  const t = useTranslations('footer')

  return (
    <footer className="border-t">
      <div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6 text-sm">
        <div className="space-y-1">
          <p>{t('address')}</p>      
          <p>{t('phone')}: 010-12345678</p>
          <p>© 2025 Enterprise · {t('copyright')}</p>
        </div>

        <LanguageSwitcher />
      </div>
    </footer>
    
  )
}

八、语言切换组件实现

LanguageSwitcher.tsx

'use client'

import { usePathname, useRouter } from 'next/navigation'
import { locales } from '@/i18n/config'

export function LanguageSwitcher() {
  const router = useRouter()
  const pathname = usePathname()

  const switchLocale = (locale: string) => {
    const segments = pathname.split('/')
    segments[1] = locale
    const newPath = segments.join('/')

    const setCookie = (name: string, value: string, path: string = '/') => {
      document.cookie = `${name}=${value}; path=${path}`;
    };
    
    setCookie('locale', locale);
    localStorage.setItem('locale', locale);

    router.push(newPath)
  }

  return (
    <div className="flex space-x-2">
      {locales.map(locale => (
        <button
          key={locale}
          onClick={() => switchLocale(locale)}
          className="text-sm underline"
        >
          {locale.toUpperCase()}
        </button>
      ))}
    </div>
  )
}

九、重点说明

  1. useTranslationsuseLocale 等 next-intl 客户端钩子必须在客户端组件(带 'use client')中使用。
  2. 根布局(layout.tsx)是服务端组件,需通过 NextIntlClientProvider 为客户端组件提供国际化上下文。
  3. 客户端组件必须被 NextIntlClientProvider 包裹。

十、表单文案国际化示例

'use client'

const t = useTranslations('auth')

<button>{t('login')}</button>

后续所有表单 + 校验提示 全部从 i18n 读取

十一、写 proxy:自动决定语言

src/proxy.ts

import createMiddleware from 'next-intl/middleware'
import { locales, defaultLocale } from '@/i18n/config'
import { NextRequest } from 'next/server';

const intlMiddleware = createMiddleware({
    locales,
    defaultLocale,
    localePrefix: 'always'
})

// Next.js 15+ proxy 约定:导出默认函数处理请求
export default function proxy(request: NextRequest) {
    // 用 next-intl 中间件处理所有请求
    return intlMiddleware(request);
}

export const config = {
    matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}

效果

访问实际行为
/重定向到 /zh
/en保持 /en
/zh保持 /zh
/products/zh/products

localePrefix: 'always' 是关键点

模式行为
'always'所有 URL 都必须带 locale(推荐)
'as-needed'默认语言不带 locale(⚠️复杂)
'never'不使用 locale 路由

十二、扩展

新建 src/i18n/navigation.ts

import { createNavigation } from 'next-intl/navigation'
import { locales, defaultLocale } from './config'

export const {
    Link,
    redirect,
    usePathname,
    useRouter
} = createNavigation({
    locales,
    defaultLocale
})

替换 Link 来源

// 原来
import Link from 'next/link'

// 改成
import { Link } from '@/lib/i18n/navigation'

MainNav 改造示例

import { useTranslations } from 'next-intl'
import { Link } from '@/i18n/navigation'

export function MainNav() {
  const t = useTranslations('nav')

  return (
    <nav className="flex space-x-6">
      <Link href="/products">{t('products')}</Link>
      <Link href="/news">{t('news')}</Link>
      <Link href="/careers">{t('careers')}</Link>
      <Link href="/docs">{t('docs')}</Link>
    </nav>
  )
}

自动生成:

  • /en/products

  • /zh/products

locale 永远正确
不需要手拼路径


十三、本篇总结

你已经完成:

  • 企业级 i18n 架构设计

  • URL / Cookie / LocalStorage 三端同步

  • Header / Footer 文案国际化

  • 表单文案国际化基础能力

这套方案:

  • SEO 友好

  • Server / Client 兼容

  • 易扩展


下一篇预告

第四篇:登录 / 注册系统(React Hook Form + Zod)

  • 表单抽象设计

  • Headless UI + Shadcn 风格

  • 多语言校验错误提示

  • 图形验证码架构

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值