React i18n 结合React router 路由切换解决方案

1,098 阅读2分钟

i18n

国际化,Internationalization,简写为i18n,

需要注意的点

i18n需要注意很多,不仅仅是语言翻译,还需要从书写习惯、文化、各种不同数据表式、甚至宗教文化等。

比如:

  • 货币单位: $、¥、€
  • 日期格式:中国2024/5/30、英国05/30/24
  • 数字:中10000、英美10,000
  • 书写顺序:中国从左到右、阿拉伯国家从右到左

还有其他很多

前后端语言偏好对齐

前端可以将user agent的语言传给后端:

  1. 通过请求的header的方式传给后端(accept-language或者自定义):
    Accept-Language: "zh-CN"
    
  2. 通过cookie的方式

各个语言偏好的值,建议进行标准化 - 参考《全球各国语言代码缩写与国家对照表》(自己搜一下)

Accept-Language头

Accept-Language 请求 HTTP 标头表示客户端所偏好的自然语言和区域设置。服务器利用内容协商机制从这些提议中选出一项,并通过 Content-Language 响应标头将这一选择告知客户端。浏览器会根据其当前活跃的用户界面语言为该标头设定所需的值。

当服务器无法通过其他方式(比如使用依赖于用户明确决定的特定 URL)确定目标内容语言时,这个标头可作为提示使用。服务器绝不应覆盖用户的明确语言选择。Accept-Language 的内容常常超出用户的控制范围(例如在旅行时)。用户也可能希望访问使用的语言与用户界面并不相同的页面。

但是目前一般不用Accept-Language来做前端的国际化处理,前端一般会更具域名或者子路径的方式来做国际化处理

// 使用质量价值语法对多个类型进行加权:权重->也就是优先级
Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5

前端处理国际化

前端处理国际化需要注意两个东西

  1. 需要进行国际化的资源(代码、图片、数据等)
  2. 前端路由

现代网站基本都把网站的国际化进行分开了,所谓分开,指的是在地址上做标识,而不是通过什么storage、cookie去做标识,比如:

  • 力扣:美国(总站)leetcode.com,中国leetcode.cn
  • React文档:总站react.dev,中文zh-hans.react.dev

举力扣的例子其实并不太好,因为这是相当于一个公司(业务)针对不同环境做出的不同平台,希望懂得鉴别。

而通过在URL上做国际化标识,正是前端路由需要注意的地方,两种模式:

  • 一种是不同语言不同的域名,比如react
  • 还有一种是放在url的path里,比如xxx.com/zh/...

Nextjs整合locale

Next.js has built-in support for internationalized (i18n) routing since v10.0.0. You can provide a list of locales, the default locale, and domain-specific locales and Next.js will automatically handle the routing.

官网摘录,nextjs已经支持i18n,直接在next.config.js里面配置就好了,同时支持domainsub-path两种模式:

// url path模式
module.exports = {
  i18n: { '
    locales: ['en-US', 'fr', 'nl-NL'], 
    defaultLocale: 'en-US' 
  }
}
//   `/blog`
//  `/fr/blog`
//  `/nl-nl/blog`

nextjs的就不写了,看文档即可nextjs-i18n

关于nextj如何整合常见的国际化方案比如react-i18next,其实和其他架子搭起来的项目一样,后面章节会用Vite起的项目做一个方案。

普通项目整合i18n

目前有两种方案:react-i18nextreact-intl

react-i18nexti18nreact实现封装,在下载量和用户量以及资历来说,i18n都属于老牌。

结合项目fe-demo做国际化

安装

本体:i18next react-i18next

三个插件:

  1. i18next-http-backend:从服务端拉取翻译配置文件
  2. i18next-browser-languagedetector:浏览器的语言偏好加载相应的国际化资源

pnpm install react-i18next i18next --save 

# if you'd like to detect user language and load translation
pnpm install i18next-http-backend i18next-browser-languagedetector --save

还有个插件可以用一下,直接工程本地文件进行打包加载:

pnpm install i18next-resources-to-backend --save

用法:

import i18next from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'

i18next
  .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
  .init({ /* other options */ })

http-backend的区别就是,http-backend是作为请求,作为静态资源访问,你可以用http-backend配置到cdn。而resourcesToBackend是从打包文件里面读出来的,"当做内存的内容读出来的"。

基础配置写好

// i18n.ts
import i18n from 'i18next'
import languageDetector from 'i18next-browser-languagedetector'
import i18nextHttpBackend from 'i18next-http-backend'
import { initReactI18next } from 'react-i18next'

import { I18N_LANGUAGES } from '@/constants/i18n'

i18n
  .use(i18nextHttpBackend)
  .use(languageDetector)
  .use(initReactI18next) // passes i18n down to react-i18next
  .init({
    debug: true,
    supportedLngs: Object.values(I18N_LANGUAGES),
    fallbackLng: I18N_LANGUAGES.ZH_CN,
    interpolation: { escapeValue: false },
    // i18next-http-backend translation file path
    // https://github.com/i18next/i18next-http-backend
    backend: {
      path: '/public/locales/{{lng}}/{{ns}}.json',
    },
  })

export default i18n

注意这里官方文档写漏了,需要用I18nextProvider去包裹提供上下文:

// main.tsx

import App from './App'
import i18n from './i18n'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <I18nextProvider i18n={i18n}>
      <BrowserRouter basename={import.meta.env.VITE_PUBLIC_PATH as string}>
        <App />
      </BrowserRouter>
    </I18nextProvider>
  </React.StrictMode>,
)

image.png

import { FC } from 'react'
import { useTranslation } from 'react-i18next'

import { I18N_LANGUAGES } from '@/constants/i18n'
const Welcome: FC = () => {
  const { t, i18n } = useTranslation('welcome')
  return (
    <div>
      国际化的你好 : {t('nihao')}
      <br />
      当前语言:{i18n.language}, 切换语言:{' '}
      <select value={i18n.language} onChange={(e) => i18n.changeLanguage(e.target.value)}>
        <option value={I18N_LANGUAGES.ZH_CN}>中文</option>
        <option value={I18N_LANGUAGES.ZH_TW}>繁体</option>
        <option value={I18N_LANGUAGES.EN_US}>英文</option>
      </select>
    </div>
  )
}

export default Welcome

material.gif

路由整合i18n

语言符号的优先级:路由带的locale > i18n存在localestorage的locale > 浏览器语言检测

目前i18n自动完成了后两部分,路由的locale还需要我们自己去写。

reactrouter.com/en/main/rou…

统一规定:locale标识path统一放在路由最前面,即/:locale?/xxxxx

方案:会做一个全局处理,当进入页面的时候执行这个全局处理,这个全局处理需要从路由里面检测locale,再进行切换语言的操作;注意一个点react-routerpath params只有在这个pathchildren下才可以请求到,所以我们得在每一个页面的element下去检测,为了方便我们直接写一个wrapper

// i18NRouterWrapper.tsx

import { FC, PropsWithChildren, useEffect } from 'react'
import { useParams } from 'react-router-dom'

import { I18N_DEFAULT_LOCALE, I18N_LANGUAGES } from '@/constants/i18n'

import i18n from '.'
const I18NRouterWrapper: FC<PropsWithChildren> = ({ children }) => {
  const { locale = I18N_DEFAULT_LOCALE } = useParams()

  useEffect(() => {
    const languageTags = Object.values(I18N_LANGUAGES)
    if (languageTags.includes(locale as I18N_LANGUAGES)) i18n.changeLanguage(locale)
  }, [locale])

  return <>{children}</>
}

export default I18NRouterWrapper

useRoutes传入路由配置的时候,处理下:

const addLocaleConfig = config.map((item) => {
  item.path = item.path?.startsWith('/:locale?') ? item.path : `/:locale?${item.path}`
  item.element = <I18NRouterWrapper>{item.element}</I18NRouterWrapper>
  return item
})
export default addLocaleConfig

这样路由的整合就可以了

手动切换

结合路由的整合做一个手动切换,考虑路由为最大优先级

import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate, useParams } from 'react-router-dom'

import { I18N_DEFAULT_LOCALE, I18N_LANGUAGES } from '@/constants/i18n'
const Welcome: FC = () => {
  const { t, i18n } = useTranslation('welcome')
  const { locale: routeLocale } = useParams()
  const { pathname } = useLocation()
  const navigate = useNavigate()
  // 手动change
  const changeLanguage = (changeLocale: I18N_LANGUAGES) => {
    if (changeLocale === i18n.language) return

    i18n.changeLanguage(changeLocale)

    const pathExcludeLocale = routeLocale ? pathname.replace(`/${routeLocale}`, '') : pathname
    if (changeLocale !== I18N_DEFAULT_LOCALE) {
      navigate(`/${changeLocale}${pathExcludeLocale}`, { replace: true })
    } else {
      navigate(pathExcludeLocale, { replace: true })
    }
  }
  return (
    <div>
      国际化的你好 : {t('nihao')}
      <br />
      当前语言:{i18n.language}, 切换语言:{' '}
      <select value={i18n.language} onChange={(e) => changeLanguage(e.target.value as I18N_LANGUAGES)}>
        <option value={I18N_LANGUAGES.ZH_CN}>中文</option>
        <option value={I18N_LANGUAGES.ZH_TW}>繁体</option>
        <option value={I18N_LANGUAGES.EN_US}>英文</option>
      </select>
    </div>
  )
}

export default Welcome

material.gif

现有问题:进阶AST

在代码内容中的国际化配置做完,剩下的就是写,有很多种场景:

  • 纯文本字段,带默认值
  • 含有一定的变量需要替换
  • 货币单位
  • 文字方向
  • 日期格式等

这些场景在i18n-next官网或者查一下就知道了。

现在的问题是,可能在某一次开发忘了某些地方需要国际化,或者每次开发的时候翻译文件还没准备好,需要怎么处理?

忘了某些地方需要国际化?

思路:结合AST,开发打包工具插件,在构建打包的时候,对代码做TextNode文本检测,在重新注入i18n国际化的格式。

难点:检测TextNode和代码中需要进行格式化的地方,场景比较多,比如图片alt、元素title等。

翻译文件还没准备好

思路:同样的,开发打包构建工具的插件,在构建打包的时候,在第一个问题能解决的前提下,肯定能知道需要翻译的地方,对上述列举的场景结合大模型AI,自动翻译和处理好翻译文件,传到远端覆盖翻译文件配置。

难点:自动对齐翻译文件