安装插件
npm install i18next react-i18next i18next-resources-to-backend
1. 目录结构
.
└── app
└── [lng]
├── second-page
| └── page.js
├── layout.js
└── page.js
app/[lng]/page.js
文件:
import Link from 'next/link'
export default function Page({ params: { lng } }) {
return (
<>
<h1>Hi there!</h1>
<Link href={`/${lng}/second-page`}>
second page
</Link>
</>
)
}
app/[lng]/second-page/page.js
文件:
import Link from 'next/link'
export default function Page({ params: { lng } }) {
return (
<>
<h1>Hi from second page!</h1>
<Link href={`/${lng}`}>
back
</Link>
</>
)
}
app/[lng]/layout.js
文件:
import { dir } from 'i18next'
const languages = ['en', 'de']
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export default function RootLayout({
children,
params: {
lng
}
}) {
return (
<html lang={lng} dir={dir(lng)}>
<head />
<body>
{children}
</body>
</html>
)
}
2. 语言识别
现在导航到http://localhost:3000/en
或http://localhost:3000/de
应该显示一些东西,并且到第二页和返回的链接也应该生效,但是导航到http://localhost:3000
将返回404错误。
为了解决这个问题,我们将创建一个Next.js中间件并重构一些代码:
创建app/i18n/settings.js
文件:
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'de']
修改app/[lng]/layout.js
文件:
import { dir } from 'i18next'
import { languages } from '../i18n/settings'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export default function RootLayout({
children,
params: {
lng
}
}) {
return (
<html lang={lng} dir={dir(lng)}>
<head />
<body>
{children}
</body>
</html>
)
}
创建middleware.js
文件:
npm install accept-language
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages } from './app/i18n/settings'
acceptLanguage.languages(languages)
export const config = {
// matcher: '/:lng*'
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)']
}
const cookieName = 'i18next'
export function middleware(req) {
let lng
if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
if (!lng) lng = fallbackLng
// Redirect if lng in path is not supported
if (
!languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
!req.nextUrl.pathname.startsWith('/_next')
) {
return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, req.url))
}
if (req.headers.has('referer')) {
const refererUrl = new URL(req.headers.get('referer'))
const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
const response = NextResponse.next()
if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
return response
}
return NextResponse.next()
}
现在导航到根路径/
首先会检查是否已经有上次选择的语言的cookie,若没有,作为回退将检查Accept-Language
header ,若仍然没有,最后回退是自定义的回退语言。
检测到的语言将用于重定向到适当的页面。
3. i18n instrumentation
在app/i18n/index.js
文件中准备i18next:
这里没有使用i18next单例,而是在每个useTranslation
调用上创建一个新实例,因为在编译期间,一切似乎都是并行执行的。拥有单独的实例将保持翻译的一致性。
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'
const initI18next = async (lng, ns) => {
const i18nInstance = createInstance()
await i18nInstance
.use(initReactI18next)
.use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
.init(getOptions(lng, ns))
return i18nInstance
}
export async function useTranslation(lng, ns, options = {}) {
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
i18n: i18nextInstance
}
}
在app/i18n/settings.js
文件中,我们将添加i18next选项:
...
export const defaultNS = 'translation'
export function getOptions (lng = fallbackLng, ns = defaultNS) {
return {
// debug: true,
supportedLngs: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns
}
}
准备一些翻译文件:
.
└── app
└── i18n
└── locales
├── en
| ├── translation.json
| └── second-page.json
└── de
├── translation.json
└── second-page.json
app/i18n/locales/en/translation.json
:
{
"title": "Hi there!",
"to-second-page": "To second page"
}
app/i18n/locales/de/translation.json
:
{
"title": "Hallo Leute!",
"to-second-page": "Zur zweiten Seite"
}
app/i18n/locales/en/second-page.json
:
{
"title": "Hi from second page!",
"back-to-home": "Back to home"
}
app/i18n/locales/de/second-page.json
:
{
"title": "Hallo von der zweiten Seite!",
"back-to-home": "Zurück zur Hauptseite"
}
现在准备在页面中使用它…
服务器页面可以通过async
方式等待useTranslation
响应。
app/[lng]/page.js
:
import Link from 'next/link'
import { useTranslation } from '../i18n'
export default async function Page({ params: { lng } }) {
const { t } = await useTranslation(lng)
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}/second-page`}>
{t('to-second-page')}
</Link>
</>
)
}
app/[lng]/second-page/page.js
:
import Link from 'next/link'
import { useTranslation } from '../../i18n'
export default async function Page({ params: { lng } }) {
const { t } = await useTranslation(lng, 'second-page')
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}`}>
{t('back-to-home')}
</Link>
</>
)
}
4. 语言切换器
在Footer组件中定义一个语言切换器:
app/[lng]/components/Footer/index.js
:
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
import { useTranslation } from '../../../i18n'
export const Footer = async ({ lng }) => {
const { t } = await useTranslation(lng, 'footer')
return (
<footer style={{ marginTop: 50 }}>
<Trans i18nKey="languageSwitcher" t={t}>
Switch from <strong>{{lng}}</strong> to:{' '}
</Trans>
{languages.filter((l) => lng !== l).map((l, index) => {
return (
<span key={l}>
{index > 0 && (' or ')}
<Link href={`/${l}`}>
{l}
</Link>
</span>
)
})}
</footer>
)
}
上述代码中使用了react-i18next Trans组件和新的命名空间:
app/i18n/locales/en/footer.json
:
{
"languageSwitcher": "Switch from <1>{{lng}}</1> to: "
}
app/i18n/locales/de/footer.json
:
{
"languageSwitcher": "Wechseln von <1>{{lng}}</1> nach: "
}
将Footer组件添加到页面:
app/[lng]/page.js
:
...
import { Footer } from './components/Footer'
export default async function Page({ params: { lng } }) {
...
return (
<>
...
<Footer lng={lng}/>
</>
)
}
app/[lng]/second-page/page.js
:
...
import { Footer } from '../components/Footer'
export default async function Page({ params: { lng } }) {
...
return (
<>
...
<Footer lng={lng}/>
</>
)
}
5. 客户端
到目前为止,我们只创建了服务器端页面。
那么客户端页面是什么样的呢?
由于客户端react组件不能async
,我们需要做一些调整。
介绍一下app/i18n/client.js
文件:
'use client'
import { useEffect, useState } from 'react'
import i18next from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { getOptions, languages } from './settings'
const runsOnServerSide = typeof window === 'undefined'
// 在客户端,正常的单例模式是可以的
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
.init({
...getOptions(),
lng: undefined, // 在客户端检测语言
detection: {
order: ['path', 'htmlTag', 'cookie', 'navigator'],
},
preload: runsOnServerSide ? languages : []
})
export function useTranslation(lng, ns, options) {
const ret = useTranslationOrg(ns, options)
const { i18n } = ret
if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng)
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (activeLng === i18n.resolvedLanguage) return
setActiveLng(i18n.resolvedLanguage)
}, [activeLng, i18n.resolvedLanguage])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!lng || i18n.resolvedLanguage === lng) return
i18n.changeLanguage(lng)
}, [lng, i18n])
}
return ret
}
在客户端,正常的i18next单例就可以了。它将只初始化一次。我们可以使用“普通的”useTranslation钩子。我们只是包装它,以便有可能传递语言。
为了与服务器端语言检测保持一致,我们使用i18next-browser-languagedetector并相应地配置它。
我们还需要创建2个版本的Footer组件。
.
└── app
└── [lng]
└── components
└── Footer
├── client.js
├── FooterBase.js
└── index.js
app/[lng]/components/Footer/FooterBase.js
:
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
export const FooterBase = ({ t, lng }) => {
return (
<footer style={{ marginTop: 50 }}>
<Trans i18nKey="languageSwitcher" t={t}>
Switch from <strong>{{lng}}</strong> to:{' '}
</Trans>
{languages.filter((l) => lng !== l).map((l, index) => {
return (
<span key={l}>
{index > 0 && (' or ')}
<Link href={`/${l}`}>
{l}
</Link>
</span>
)
})}
</footer>
)
}
服务器端部分继续使用async
版本,app/[lng]/components/Footer/index.js
:
import { useTranslation } from '../../../i18n'
import { FooterBase } from './FooterBase'
export const Footer = async ({ lng }) => {
const { t } = await useTranslation(lng, 'footer')
return <FooterBase t={t} lng={lng} />
}
客户端部分将使用新的i18n/client
版本,app/[lng]/components/Footer/client.js
:
'use client'
import { useTranslation } from '../../../i18n/client'
import { FooterBase } from './FooterBase'
export const Footer = ({ lng }) => {
const { t } = useTranslation(lng, 'footer')
return <FooterBase t={t} lng={lng} />
}
客户端页面看起来像这样- app/[lng]/client-page/page.js
:
'use client'
import Link from 'next/link'
import { useTranslation } from '../../i18n/client'
import { Footer } from '../components/Footer/client'
import { useState } from 'react'
export default function Page({ params: { lng } }) {
const { t } = useTranslation(lng, 'client-page')
const [counter, setCounter] = useState(0)
return (
<>
<h1>{t('title')}</h1>
<p>{t('counter', { count: counter })}</p>
<div>
<button onClick={() => setCounter(Math.max(0, counter - 1))}>-</button>
<button onClick={() => setCounter(Math.min(10, counter + 1))}>+</button>
</div>
<Link href={`/${lng}`}>
<button type="button">
{t('back-to-home')}
</button>
</Link>
<Footer lng={lng} />
</>
)
}
翻译源:
app/i18n/locales/en/client-page.json
:
{
"title": "Client page",
"counter_one": "one selected",
"counter_other": "{{count}} selected",
"counter_zero": "none selected",
"back-to-home": "Back to home"
}
app/i18n/locales/de/client-page.json
:
{
"title": "Client Seite",
"counter_one": "eines ausgewählt",
"counter_other": "{{count}} ausgewählt",
"counter_zero": "keines ausgewählt",
"back-to-home": "Zurück zur Hauptseite"
}
在初始页面app/[lng]/page.js
新增一个链接:
...
export default async function Page({ params: { lng } }) {
...
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}/second-page`}>
{t('to-second-page')}
</Link>
<br />
<Link href={`/${lng}/client-page`}>
{t('to-client-page')}
</Link>
<Footer lng={lng}/>
</>
)
}
翻译源:
app/i18n/locales/en/translation.json
:
{
"title": "Hi there!",
"to-second-page": "To second page",
"to-client-page": "To client page"
}
app/i18n/locales/de/translation.json
:
{
"title": "Hallo Leute!",
"to-second-page": "Zur zweiten Seite",
"to-client-page": "Zur clientseitigen Seite"
}
完整代码