背景
官方目前未打算支持 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 Component | Client 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>;
}