react&next项目的国际化

85 阅读4分钟

插件

i18n Ally 插件id: lokalise.i18n-ally

支持react-i18next和next-intl 会自动扫描语言包 给出代码提示

对语言包的格式有要求 必须把同一个语言的翻译写在同一个json里 而不能分成不同的json 并且翻译函数必须是t

react项目

使用react-i18next进行国际化

依赖

react-i18next i18next i18next-browser-languagedetector

配置i18next

image.png

resource/

语言包

i18n-types

import locales from './resource/zh.json'

declare module 'i18next' {
  interface CustomTypeOptions {
    resources: typeof locales
  }
}

拓展模块类型 在useTranslation的时候有类型提示

index

import { initReactI18next } from 'react-i18next'
import i18n, { ReadCallback } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { pick } from 'lodash-es'

/** 支持的语言 */
export const supportedLngs = ['zh', 'en'] as const

/** 支持的语言 */
export type SupportedLng = (typeof supportedLngs)[number]

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  // 自定义backend插件 不需要手动将语言包插入
  .use({
    type: 'backend',
    // lng已经被i18n转化过
    // ns==='translation'表示全部命名空间
    read: async (lng: SupportedLng, ns: string, cb: ReadCallback) => {
      import(`./resource/${lng}.json`)
        .then((res) => res.default)
        .then((data) => (ns === 'translation' ? data : pick(data, ns)))
        .then((resource) => cb(null, resource))
        .catch((e) => cb(e, null))
    },
  })
  .init({
    // 会将字符串自动转化到supportedLngs
    supportedLngs,
    nonExplicitSupportedLngs: false,
    fallbackLng: 'en',
    interpolation: {
      escapeValue: false,
      skipOnVariables: false,
    },
    detection: {
      // 缓存相关 从navigator获取语言 然后存在lcoalStorage里
      order: ['localStorage', 'navigator'],
      caches: ['localStorage'],
      lookupLocalStorage: 'i18n_lang',
    },
  })

antd和dayjs的locale

import { FC, PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { App, ConfigProvider } from 'antd'
import type { Locale } from 'antd/es/locale'
import antd_en_US from 'antd/es/locale/en_US'
import antd_zh_CN from 'antd/es/locale/zh_CN'
import { StyleProvider } from '@ant-design/cssinjs'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import { Subject } from 'rxjs'
import { match } from 'ts-pattern'
import { SupportedLng } from '@/locales'

/** antd 首屏样式 样式兼容 本地化 主题等 */
export const AntdProvider: FC<PropsWithChildren> = (props) => {
  const { i18n } = useTranslation()
  const [local, setLocale] = useState<Locale>()
  const lng$ = useMemo(() => {
    const _lng$ = new Subject<SupportedLng>()
    _lng$.subscribe((lng) => {
      setLocale(
        match(lng)
          .with('zh', () => antd_zh_CN)
          .with('en', () => antd_en_US)
          .exhaustive(),
      )
      dayjs.locale(
        match(lng)
          .with('zh', () => 'zh-cn')
          .with('en', () => 'en')
          .exhaustive(),
      )
    })
    return _lng$
  }, [])
  useEffect(() => {
    lng$.next(i18n.language as SupportedLng)
    const onLanguageChanged = (lng: string) => {
      lng$.next(lng as SupportedLng)
    }
    i18n.on('languageChanged', onLanguageChanged)
    return () => {
      i18n.off('languageChanged', onLanguageChanged)
    }
  }, [i18n, lng$])
  return (
    <StyleProvider layer>
      <ConfigProvider locale={local}>
        <App className='app'> {props.children}</App>
      </ConfigProvider>
    </StyleProvider>
  )
}

export default AntdProvider

使用

const t = useTranslation()

注意事项

在语言包未加载好时 react-i18next默认进入suspense
在根路由最好加入一个Suspense

next项目

比较通用的国际化策略是为每个语言提供不同的url前缀 例如/zh/xxx /en/xxx 参考mdn

这也是next-intl默认的方式

配置

基础配置

next.config.ts
import {NextConfig} from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
 
const nextConfig: NextConfig = {};
 
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);
@/i18n/

按照文档 进行配置

注意 只执行Initial setup这一节的内容

修改@/i18n/request.ts

import { Formats, hasLocale } from 'next-intl'
import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'

// 日期时间货币等
export const formats: Formats = {}

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
  // 获取语言包
  const { default: messages } = await import(`@/../messages/${locale}.json`)
  return {
    locale,
    messages,
    formats,
  }
})

类型配置

创建@/i18n/type.ts

import locales from '@/../messages/zh.json'
import { formats } from './request'
import { routing } from './routing'

/** 具有locale属性的路径参数 */
export type LocaleParams = {
  params: Promise<{ locale: Locale }>
}

declare module 'next-intl' {
  interface AppConfig {
    Locale: (typeof routing.locales)[number]
    Messages: typeof locales
    Formats: typeof formats
  }
}

客户端组件的上下文

app/[loacle]/layout里添加 NextIntlClientProvider 为客户端提供国际化信息

参考文档

国际化的404页面

app/[loacle]/[...rest]/page这是实际上的404页面 这个页面可以使用国际化相关内容

无法国际化的情形

proxy.ts会把页面重定向到/[locale] 但这是有条件的. 无法重定向的情况需要app/下的文件进行处理

not-found

如果url像/xxx.txt 则不会被重定向 因此需要全局的not-found页面 即app/not-found.这个页面无法国际化

page

如果打包为静态html(即,next配置中设置output为export) proxy.ts就不会生效 此时/对应的是 /page 这个组件需要对页面重定向

'use client'

import { redirect } from 'next/navigation'
import { FC, useEffect } from 'react'
import { Skeleton } from 'antd'
import { routing } from '@/i18n/routing'

const Page: FC = () => {
  useEffect(() => {
    const lng =
      navigator.languages.find((l) => routing.locales.includes(l as any)) ?? routing.defaultLocale
    redirect(`/${lng}`)
  }, [])

  return (
    <html lang='en'>
      <body>
        <Skeleton className='p-4' active />
      </body>
    </html>
  )
}

export default Page

layout

这个页面无法国际化 所以不能设置html的lang属性 因此直接导出React.Fragment即可

使用next-intl

通用

以下客户端和服务端都可以用

  • @/i18n/routing 可以获取国际化配置 例如所有支持语言等
  • @/i18n/navigation 具有国际化能力的路由组件和函数
  • next-intl
import { type Locale, hasLocale } from 'next-intl'

支持语言的类型 判断语言是否合法

服务端

包括但不限于服务端组件、metadata的生成函数、server action 都可以使用同一套api

// 列举常见函数
import { getFormatter, getMessages, getLocale, getTranslations } from 'next-intl/server'

这些函数全都是异步函数

const locale = await getLocale() // 获取当前的语言
const t = await getTranslations() // 获取翻译函数

t('common.test')

可以生成国际化的元数据(例如标签页title) 也可以用于服务端组件的翻译

客户端

客户端是与服务端很相似的一系列hook

import { useLocale, useMessages, useFormatter } from 'next-intl'

dayjs的国际化

dayjs.lcoale与渲染完全无关 可以在组件内部直接调用 不需要在useEffect里面调用

打包为静态html

  • next.config.js 将output设置为export
  • 确保app/page具有重定向能力
  • 删除/[locale]/[...rest]/page. 这种收集全部的动态路由无法被打包为静态html
  • /[locale]/layout添加以下导出
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }))
}

这个函数用于配置SSG 打包为静态html需要指明动态路由的所有可能情况

  • 改造/[locale]/layout 假设有一个环境变量EXPORT控制是否静态导出
import { setRequestLocale } from 'next-intl/server'

const RootLayout: FC<PropsWithChildren> = async (props) => {
  // 不能放在FC的类型里面 过不了类型检查
  const { children, params } = props as PropsWithChildren & {
    params: Promise<{ locale: Locale }>
  }
  const locale = await match(process.env.EXPORT === 'true')
    .with(true, async () => {
      // 静态html
      const { locale } = await params
      return locale
    })
    .with(false, getLocale)
    .exhaustive()

  if (!hasLocale(routing.locales, locale)) {
    notFound()
  }

  if (process.env.EXPORT === 'true') {
    setRequestLocale(locale)
  }

  return (
    <html lang={locale}>xxx</html>
 )
}

原理:
服务端获取国际化信息时 都是默认从headers里获取的 但是静态导出是不能调用header的 因此要从路径参数locale里获取
而setRequestLocale可以强制设置locale
只有在执行setRequestLocale后 服务端调用getXXX才不会出错

  • 元数据
    生产环境 元数据可能和页面不是同一个上下文 需要从路径中获取国际化信息
import { type LocaleParams } from '@/i18n/type'

export async function generateMetadata(props: LocaleParams): Promise<Metadata> {
  const { params } = props
  const { locale } = await params
  const t = await getTranslations({ locale })
  return {
    title: t('metadata.title'),
  }
}