这篇文章探讨React国际化方案实践与原理,内容涵盖下面一些问题:
- i18next是什么?
- 如何自动识别客户端语言?
- 如何在网页跳转以及下次打开页面时保持同一语言?
- 如何只加载需要的语言文件?
- 如何实现语言切换?需要刷新整个页面吗?
- 如何将语言文件与代码分开维护?
- 服务端渲染与客户端渲染中的多语言实现有什么不同?
zh-CN
与zh
都表示中文,如何统一呢?- 是否存在XSS攻击,如何防范?
i18next
首先介绍下目前最主流的国际化方案,i18next。它是一个使用JavaScript编写的国际化方案,其生态非常丰富,jQuery,React、Vue、Angualar等都有集成库。但它本身是不限于浏览器使用的,它也可以在其他JavaScript运行环境中使用,如Node.js,Deno等。
下面以React项目为例展示下i18next的基本使用方法:
jsx
import i18next from "i18next";
i18next.init({
resources: {
en: {
translation: {
welcome: "Welcome",
},
},
zh: {
translation: {
welcome: "欢迎",
},
},
},
lng: "zh",
});
function App() {
return <h2>{i18next.t("welcome")}</h2>; // 欢迎
}
export default App;
上述示例非常简单,在i18next.init()
中初始化了en
与zh
两种语言的语料,并将zh
作为默认语言,接着就可以直接通过i18next.t("key")
来获取当前语言内容了。
i18next还支持一些稍复杂的方法,如模版插值。示例代码如下:
jsx
i18next.init({
resources: {
en: {
translation: {
welcome: "Welcome to i18next, {{name}}",
},
},
},
lng: "en",
});
function App() {
return <h2>{i18next.t("welcome", { name: "John" })}</h2>; // Welcome to i18next, John
}
更多用法不在本文讨论范围内,读者可前往官方文档查看。
自动识别客户端语言
上述示例中是在初始化时手动指定lng
的值,这不太实用。如果默认为zh
,那国外的朋友打开后得直接一脸懵逼。好在,我们可以通过一些方法自动获取到用户浏览器上的首选语言。
我们可以直接使用i18next的插件,i18next-browser-languageDetector。
jsx
import LanguageDetector from 'i18next-browser-languagedetector';
i18next.use(LanguageDetector).init({
// ...
});
使用了LanguageDetector
后我们就不需要再手动指定lng
了。那它是怎么做的呢?下面的代码来自其README,它会根据order
中规则的顺序来依次识别。以querystring
为例,/products?lng=zh
的路径将识别语言为zh
(这个key是由lookupQuerystring
选项设置的)。cookie
、localStorage
等就同理了。
jsx
{
// order and from where user language should be detected
order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],
// keys or params to lookup language from
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
lookupSessionStorage: 'i18nextLng',
lookupFromPathIndex: 0,
lookupFromSubdomainIndex: 0,
// cache user language on
caches: ['localStorage', 'cookie'],
excludeCacheFor: ['cimode'], // languages to not persist (cookie, localStorage)
// optional expire and domain for set cookie
cookieMinutes: 10,
cookieDomain: 'myDomain',
// optional htmlTag with lang attribute, the default is:
htmlTag: document.documentElement,
// optional set cookie options, reference:[MDN Set-Cookie docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie)
cookieOptions: { path: '/', sameSite: 'strict' },
// optional conversion function to use to modify the detected language code
convertDetectedLanguage: 'Iso15897',
convertDetectedLanguage: (lng) => lng.replace('-', '_')
}
那这些规则都没有显式指定怎么办呢?这时候就会看navigator
。其判断依据就是浏览器的navigator.languages
。
这个值是可以在浏览器设置中修改的,如下所示:
你可能注意到浏览器语言里存在zh-CN
的选项,i18n也会将它匹配到"zh"的语言文件,如果"zh-CN"的语言文件不存在的话。
需要注意的是,i18next在使用时会自动将识别的语言设置到Local storage,这样就能"记住"用户选择的语言了。下次打开页面时,i18next会优先根据Local Storage中的值设置语言。
从文件中加载语言
另外一个问题来了,当我们的应用越来越大,语言内容越来越多,下面的形式就不优雅了。因为它会一次性加载所有语言的语料,即使用户只会用到其中一种。有没有办法只加载用户需要的那份语料内容呢?
jsx
i18next.init({
resources: {
en: {
translation: {
welcome: "Welcome",
},
},
zh: {
translation: {
welcome: "欢迎",
},
},
},
lng: "zh",
});
这时候可以使用i18next-http-backend。它使用XMLHttpRequest或fetch API来动态加载语言文件。使用示例如下:
jsx
import i18next from "i18next";
import Backend from "i18next-http-backend";
i18next
.use(Backend)
.init({
lng: "zh",
})
.then((t) => {
console.log(t("welcome")); // "欢迎"
});
它默认会从/locales/{{lng}}/{{ns}}.json
路径中加载文件,比如locales/zh/translation.json
。需要注意,由于是动态加载,所以需要在回调中获取请求内容。
不过,这样一来,之前那样获取语料值的方法在这里就行不通了:
jsx
function App() {
// 直接使用i18next.t()无法获取到最新的值
return <h2>{i18next.t("welcome")}</h2>;
}
怎么在React组件中获取到最新的值呢?如果交给我们手动实现,显然还要做一些特殊处理,比如维护一个状态来保存语料值,并注册一个文件加载完成的回调,在回调里更新状态,进而页面能够更新。好在,开源已经实现了,后面会讲到用react-i18next来在React应用中更好的集成i18next。
在那之前,再多看两个示例。第一个,将LanguageDetector与Backend结合,如下所示:
jsx
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
i18next
.use(LanguageDetector)
.use(Backend)
.init()
.then((t) => {
console.log(t("welcome")); // "欢迎"
});
容易推断,i18next可以用这种方式集成任意多的插件,这里就不多讨论了。
第二,把翻译文件维护在前端代码中是否合理呢?这样每次更新翻译文件都需要翻译人员把语料发给前端,前端更新代码后再重新发版,而且中间还可能出现人为误差(比如粘贴错了)。能不能搞个服务来专门维护翻译内容呢?前端只需要从远端请求这个翻译文件即可,实现语言文件与代码的解耦。这确实是可行的,并且i18next还推荐了一个服务i18next-locize-backend,专门用来打通翻译与代码开发。
Bridging the gap between translation and development with locize, a modern and affordable localization-management-platform。
react-i18next
react-i18next是基于i18next的,针对React与React Native的国际化框架。其实前面看到,直接在React中用i18next也是可以的,只是在需要远程加载文件的时候遇到了点问题,所以react-18next做了啥呢?还是先看看使用示例:
jsx
import React from "react";
import i18n from "i18next";
import { useTranslation, initReactI18next } from "react-i18next";
// 将i18n传入react-i18next中
i18n.use(initReactI18next).init({
resources: {
en: {
translation: {
welcome: "Welcome",
},
},
zh: {
translation: {
welcome: "欢迎",
},
},
},
lng: "zh",
});
function App() {
// 通过useTranslation这个自定义hook来获取翻译内容
const { t } = useTranslation();
return <h2>{t("welcome")}</h2>; // 欢迎
}
export default App;
这里也是可以结合LanguageDetector与Backend的:
jsx
import React from "react";
import i18n from "i18next";
import { useTranslation, initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
i18n.use(LanguageDetector).use(Backend).use(initReactI18next).init();
function App() {
const { t } = useTranslation();
return <h2>{t("welcome")}</h2>; // 欢迎
}
export default App;
代码很简单清晰,也不会再出现之前翻译内容不存在的情况了。玄机就在这个useTranslation
里。useTranslation
这个hook里维护了 t
的一个状态 const [t, setT] = useState()
,并会在语言文件加载完成时更新它,从而页面上可以渲染出更新后的值。那么问题又来了,如果网络比较慢,语言还没加载完,想展示一个loading状态怎么做呢?官方提供的示例如下,通过Suspense。
jsx
function Component() {
const { t } = useTranslation();
return <h2>{t("welcome")}</h2>;
}
function App() {
return (
<Suspense fallback="loading">
<Component />
</Suspense>
);
}
在useTranslation中是这样实现的:
jsx
// 如果语言文件已加载完成则返回,否则先加载文件,并触发suspense
if (ready) return ret;
// not yet loaded namespaces -> load them -> and trigger suspense
throw new Promise((resolve) => {
if (props.lng) {
loadLanguages(i18n, props.lng, namespaces, () => resolve());
} else {
loadNamespaces(i18n, namespaces, () => resolve());
}
});
有兴趣的读者也可以自己试试,目前使用Suspense是有一定条件的,React官方示例中是通过一个自定义的use来触发,以在组件加载完成前展示一个fallback。
react-i18next中还有一些其他使用语料内容的方式,如高阶组件,这里就不多讨论了。
语言切换
使用react-i18next可以轻松实现语言切换,即通过i18n.changeLanguage
API。在切换语言时,i18next会动态加载新的语言文件。
jsx
function Component() {
const { t, i18n } = useTranslation();
return (
<div>
<select
name="language"
defaultValue={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
<option value="zh">中</option>
<option value="en">En</option>
</select>
<h2>{t("welcome")}</h2>
</div>
);
}
结合LanguageDetector、Backend与语言切换功能,就是一个比较智能的多语言方案了:首先根据客户端偏好动态请求需要的语言文件,如果后续切换语言,则再加载新的语言文件。
到这里讲的内容都是关于客户端渲染的,我们已经可以回答前面的一些问题了:
- 如何自动识别客户端语言?通过querystring、path、localstorage等方式显式标记,或通过
navigator.languages
来自动识别。 - 如何在网页跳转以及下次打开页面时保持同一语言?通过localstorage等方式留痕,如设置
i18nLang=zh
,下次打开页面时优先通过此方式判断语言。 - 如何只加载需要的语言文件?将不同语言的语料维护在不同文件中,通过i18next-http-backend动态获取。
- 如何实现语言切换?需要刷新整个网页吗?结合react-i18next与
i18n.changeLanguage()
可以切换语言。这种方式会动态请求新的语言文件,并通过React机制更新页面内容,不需要刷新整个网页。 - 如何将语言文件与代码分开维护?实现一个专门的语料管理平台(或使用locize),前端只需要从平台中拉取语料文件即可。
接下来我们将继续探讨服务端的实现,上面一些问题的回答或许会有所不同。
Next.js
我们以Next.js的App Router为例,首先看看官方推荐的实现:Internationalization。
识别客户端语言的逻辑放在middleware中。
- 首先判断请求路径中是否存在语言,如
/zh/products
的语言为zh
,如果存在则直接返回。 - 不存在则进一步通过请求头中的
accept-language
获取信息,并匹配到我们支持的语言。 - 重定向到带有语言的路径,如
/products
重定向 到/zh/products
。
jsx
// middleware.ts
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const locales = ['zh', 'en'];
function getLocale(request: NextRequest) {
const headers = {
'accept-language': request.headers.get('accept-language') || ''
};
const languages = new Negotiator({ headers }).languages();
const defaultLocale = locales[0];
return match(languages, locales, defaultLocale);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const pathnameHasLocale = locales.some((locale) =>
pathname.startsWith(`/${locale}`)
);
if (pathnameHasLocale) return;
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
// e.g. /products => /en/products
return Response.redirect(request.nextUrl);
}
这就要求我们所有的页面文件都要放到app/[lang]
目录中,目录下的page或layout文件可以直接通过params
获取到当前的语言lang
。
jsx
export default async function Page({ params: { lang } }) {
return ...
}
语言文件则分开维护到不同的json文件中,如:
json
// dictionaries/en.json
{
"products": {
"cart": "Add to Cart"
}
}
// dictionaries/nl.json
{
"products": {
"cart": "Toevoegen aan Winkelwagen"
}
}
并通过如下逻辑动态加载:
jsx
import 'server-only'
const dictionaries = {
en: () => import('./dictionaries/en.json').then((module) => module.default),
nl: () => import('./dictionaries/nl.json').then((module) => module.default),
}
export const getDictionary = async (locale) => dictionaries[locale]()
这样页面中即可通过getDictionary(lang)
获取到语料的JSON内容。
jsx
import { getDictionary } from './dictionaries'
export default async function Page({ params: { lang } }) {
const dict = await getDictionary(lang) // en
return <button>{dict.products.cart}</button> // Add to Cart
}
整体逻辑还是比较清晰的,流程如下:
原始的语言文件并不会返回到客户端,而是会生成到html里返回 ,所以它不会加载不需要的语言文件。那如何切换语言呢?只需替换路径即可,如将/zh/products
替换为/en/products
。使用Next.js自带的<Link>
或history.push
都行,它会动态请求新的语言文件,并更新页面内容,同样它也是不需要刷新网页的。
但这种基于path的方案存在一些问题:
- 无法保留语言设置 。比如我第一次请求
/products
自动重定向到了/zh/products
,我切换了语言为/en/products
。第二次请求/products
仍然会重定向到/zh/products
。 - 重定向会增加白屏时间 。如
/products
会自动重定向到/zh/products
,这会增加页面的白屏时间。
出现这两个问题的原因是接收的客户端请求是依赖path识别语言的,有没有其他方式让服务端在客户端"留痕",以在下次请求时自动识别语言呢?答案是cookie
(不过这种方法需要用户浏览器设置允许Cookie)。
首先修改middleware,通过种一个名为i18nLang
的cookie来标记当前使用语言。
jsx
export function middleware(request: NextRequest) {
// 如果cookie中存在预先设定的语言,则直接返回
if (request.cookies.has('i18nLang')) {
const lang = request.cookies.get('i18nLang');
if (lang && locales.indexOf(lang.value as Locale) >= 0) {
return;
}
}
// 如果cookie中不存在预先设定的语言,则根据请求头获取默认语言,并设置cookie
const locale = getLocale(request);
const response = NextResponse.next();
response.cookies.set('i18nLang', locale);
return response;
}
相应的,在我们的page与layout中,需要通过cookie()
API来获取语言。
jsx
import { cookies } from 'next/headers';
// ...
cookies().get('i18nLang')?.value
这样前面两个问题就解决了(似乎太顺利了点)。然而还有两个问题,第一,怎么切换语言呢?这里就不是切换路径了,而是在客户端修改cookie,并刷新网页。像这样:
jsx
Cookies.set('i18nLang', locale);
window.location.reload();
这里就真的是需要刷新网页了,不过在实际场景中,切换语言是一个低频操作,而且一般只会切换一次,所以可以忽略这个刷新网页的影响。另外还有一个问题,也可以说是Next.js的一个bug,就是第一次在middleware中种cookie, cookies()
API是拿不到最新的cookie值的。
jsx
// middleware.ts
response.cookies.set('i18nLang', locale);
// layout.ts
cookies().get('i18nLang')?.value // undifined
这个问题可以采用社区中的一种办法来解决,代码如下:
jsx
/**
* Copy cookies from the Set-Cookie header of the response to the Cookie header of the request,
* so that it will appear to SSR/RSC as if the user already has the new cookies.
*/
function applySetCookie(req: NextRequest, res: NextResponse): void {
// parse the outgoing Set-Cookie header
const setCookies = new ResponseCookies(res.headers);
// Build a new Cookie header for the request by adding the setCookies
const newReqHeaders = new Headers(req.headers);
const newReqCookies = new RequestCookies(newReqHeaders);
setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie));
// set "request header overrides" on the outgoing response
NextResponse.next({
request: { headers: newReqHeaders }
}).headers.forEach((value, key) => {
if (
key === 'x-middleware-override-headers' ||
key.startsWith('x-middleware-request-')
) {
res.headers.set(key, value);
}
});
}
export function middleware(request: NextRequest) {
// ...
const response = NextResponse.next();
response.cookies.set('i18nLang', locale);
// https://github.com/vercel/next.js/issues/49442
// 让 cookies.get() 可以获取到最新值
applySetCookie(request, response);
return response;
}
可以看到,这里Next.js中的实现其实与i18next关系不大,并且相比客户端而言,其逻辑还更为简单。再回顾下前面的几个问题,看看是不是有新的答案呢。
- 如何自动识别客户端语言?通过请求头中的
accept-language
匹配语言。 - 如何在网页跳转以及下次打开页面时保持同一语言?服务端种cookie ,比如
i18nLang=zh
,下次请求页面时根据cookie值判断。 - 如何只加载需要的语言文件?结合需要的语言文件生成html返回。
- 如何实现语言切换?需要刷新整个页面吗?如果是依赖path的切换则不需要刷新页面,依赖cookie则需要刷新页面。
- 如何将语言文件与代码分开维护?同之前的回答。
- 服务端渲染与客户端渲染中的多语言实现有什么不同?服务端渲染是在服务端识别语言,并整合生成html后返回;客户端渲染是在客户端识别语言,请求语言文件内容,并渲染页面。
next-i18next
next-i18next是i18next推荐的在Next.js中集成i18n的方案。里面说到,如果使用的是Next.js App Router,则不需要用到next-i18next了,可以直接使用i18next与react-i18next,具体见这篇博客:i18n with Next.js 13/14 and app directory / App Router。里面是如何做的呢?这里截取了一些关键部分。
语言的判断逻辑是相似的:页面放到[lng]
目录下,在中间件中判断,如果path中lng不存在则重定向。
text
.
└── app
└── [lng]
├── second-page
| └── page.js
├── layout.js
└── page.js
jsx
// 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))
}
不同的是,它这里还用到了cookie来记录用户语言。
jsx
let lng
// 获取cookie
if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
// ...
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()
// 设置cookie
if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
return response
}
在服务端页面中通过下面的方式引入语言文件与i18n。
jsx
const initI18next = async (lng, ns) => {
const i18nInstance = createInstance()
await i18nInstance
.use(initReactI18next)
// 加载对应lng的语言文件
.use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
.init(getOptions(lng, ns))
return i18nInstance
}
// 这个useTranslation是在服务端使用的,虽然名称是use开头
export async function useTranslation(lng, ns, options = {}) {
// 初始化i18n的事例,加载lng的语言文件后返回
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
i18n: i18nextInstance
}
}
jsx
import Link from 'next/link'
import { useTranslation } from '../i18n'
export default async function Page({ params: { lng } }) {
// 通过await调用获取到i18n实例
const { t } = await useTranslation(lng)
return (
<>
<h1>{t('title')}</h1>
<Link href={`/${lng}/second-page`}>
{t('to-second-page')}
</Link>
</>
)
}
其实与前面的path方案是基本相同的,只不过多了一步初始化i18n实例 ,从而可以使用i18n的能力。path方案还有一个麻烦的点:每次跳转路径都要带上lng
,这确实是一个开发负担。如果不是页面,而是组件中需要跳转路径,还要将lng从页面中传递下去。
jsx
<Link href={`/${lng}/second-page`}>
{t('to-second-page')}
</Link>
上面的useTranslation
只能在服务端组件中使用,如果客户端组件要使用多语言呢?一个方法是直接从服务端页面传参下来,另一个方案就是在客户端也初始化一个i18n,具体可以见原文内容。如果读者还记得前面的内容,应该可以想到,在这里客户端的i18n中,我们可以用LanguageDetector来根据path获取初始化语言。
XSS攻击的防范
最后讨论一下多语言方案中的XSS攻击。i18n有一个unescape的配置,用来减轻XSS攻击。默认情况下,i18n会对模版插值的变量进行转义。
jsx
{
"keyEscaped": "no danger {{myVar}}",
"keyUnescaped": "dangerous {{- myVar}}"
}
示例:
jsx
i18next.t('keyEscaped',{myVar:'<img />'});
// -> "no danger <img />"
i18next.t('keyUnescaped', { myVar: '<img />' });
// -> "dangerous <img />"
在实际使用中,比如在React中,是可以将escape关闭的,因为React本身已经做了XSS的防范。
jsx
i18n
.init({
interpolation: {
escapeValue: false // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
}
});
比如下面的代码,最终页面上并不会出现<img />
元素,而只会有一个""字符串。
jsx
function App() {
const str = "<img />";
return <h2>{str}</h2>; // 欢迎
}
但要注意,这要是在服务端就不一样了。如果服务端拼接了一个完整的html字符串返回,它是无法区分哪些是标签,哪些是文字的,所以还需要做一定的转义处理来减轻XSS攻击。
总结
实现多语言的方法还是很多的,并且服务端组件与客户端组件的实现还有所不同,读者需要理解其中的差别。但有一个思路是统一的:首先自动识别语言并"留痕",然后加载对应的语言文件,接着渲染页面内容。在客户端页面中,i18n有很多开箱即用的插件来实现这些功能,比如i18next-browser-languagedetector与i18next-http-backend,以及整合了React的react-i18next。但在服务端页面中,比如Next.js,要实现一个简易的多语言功能会更为直接简单,你甚至不需要依赖i18next;如果需要用到i18next的模版插值等功能,则可以再将i18next引入进来。读者可以按需选择自己项目中适合的方案。