前端多语言国际化方案探讨

2022-08-30 | 88分钟 | yrobot | 前端,多语言,切换,国际化,探讨,international

国际化需求的基本场景

我目前运营着一个 figma 的插件 Figma-Nocode,他主要是帮助使用者将设计稿转化为前端页面文件。

但是通过埋点,我发现目前主要的使用用户都是国外的用户,中文使用者比例很少。

而目前的插件使用的主要语言是中文,这对大部分国外用户的使用体验是有很大的打击的,为了提升这部分用户的使用体验,页面就需要进行英文版的支持。

且由于后期运营主要市场可能还是国内,所以中文也要予以保留,页面最好可以根据用户的语言设置来自动切换展示语言。

这就是我面临的最基本的一个国际化需求。

一个实现国际化的基本框架设想

其实 国际化的本质和主题没有太大的区别,都是基于一个唯一的 key 进行配置内容的分发,将内容通过状态管理等手段通知到各个组件,进行更新。

此想法的核心伪代码

locales.ts

export default const locales = {
  en: {
    "home.title": "Hello World!",
    "home.form": { title: "Fill In Your Info", button: "Submit" },
  },
  zh: {
    "home.title": "你好 世界!",
    "home.form": { title: "填写你的信息", button: "提交" },
  },
};

Translate.tsx

import React, { useState, useContext } from "react";
import locales from "locales.ts";

const I18NContext = React.createContext();

const LANGUAGE_KEY = "_local";
export const LanguageProvider = ({ children }) => {
  const [locale, setLocale] = useState(
    localStorage.getItem(LANGUAGE_KEY) || "zh"
  );

  const set = (locale) => {
    setLocale(locale);
    localStorage.setItem(LANGUAGE_KEY, locale);
  };

  return (
    <I18NContext.Provider value={[locales[locale], set, locale]}>
      {children}
    </I18NContext.Provider>
  );
};

export function useLocal(key) {
  const [locale, setLocale, locale] = useContext(I18NContext);
  return [locale[key], setLocale, locale];
}

export const T = ({ children }: { children: string }) => useLocal(children);

Home.tsx

import React from "react";
import { T, LanguageProvider, useLocal } from "Translate.tsx";

const Title = () => <T>home.title</T>;

const Form = () => {
  const [lan, updateLan] = useLocal("home.form");
  return (
    <>
      <form>
        <h1>{lan.title}</h1>
        <button>{lan.button}</button>
      </form>

      <button
        onClick={() => {
          updateLan("en");
        }}
      >
        Change Language
      </button>
    </>
  );
};

const Home = () => (
  <>
    <Title />
    <Form />
  </>
);

export default () => (
  <LanguageProvider>
    <Home />
  </LanguageProvider>
);

这其实就是目前 Yrobot‘s Blog 的国际化解决方案

这其实也是 react-i18next 核心逻辑

此方案存在的问题

SSR 和 SSG 场景会出现闪屏的情况

SSR 和 SSG 是在服务端生成 HTML,加载完 html 和 css 文件后即可展示首屏。

而语言的切换逻辑是在 client 端进行的,本地检测逻辑检测到当前浏览器语言环境是 另一种语言,或者发现用户之前设置过三方语言,那么运行语言切换逻辑对页面进行 重渲染。从而出现闪屏的现象

SEO 不友好

SEO 能探测到的永远是默认语言环境的页面信息

SSR 和 SSG 场景怎么更好的处理国际化

由于 SSR 的方案最大的区别是在服务端生成 html,为了规避闪屏现象,那么就应该针对不同的语言生成对应的 html,如:

  • ch -> index.ch.html
  • en -> index.en.html

下一步就是针对用户页面请求分发对应语言的 html。

这一步遇到的一个大问题就是,怎么根据请求获取当前用户的语言设置。

  • 直接根据请求 http 协议
    在 http 协议下,可以获取用户语言配置信息主要是 Accept-Language,所以服务端可以直接利用 Accept-Language 对返回 html 内容进行分发。

  • 根据 client 设置请求的信息,其中包含语言数据

    • 域名
    • 路由
    • cookie

从对业务和工程化影响来看,我们可以从 客户端修改能力 和 部署要求 来分析这几个方案:

客户端修改 部署要求 其他
利用 Accept-Language 分发 客户端 js 无法修改 需要服务端进行逻辑分发
利用 域名 分发 利用重定向设置 需要服务端进行逻辑分发 不同域名的资源无法缓存(流量浪费)
利用 路由 分发 利用重定向设置 无需服务端支持
利用 cookie 分发 通过更新 cookie 修改 需要服务端进行逻辑分发

从最小成本实现国际化的角度考虑,使用 路由进行分发的方案 是更合适的。

因为他可以实现纯前端处理国际化,无需后端逻辑服务,从而减少了运维的成本。

当然,这并不是是银弹,单纯的 路由分发的国际化方案 还是有弊端存在。我们从用户访问流程走一遍看,

  1. 用户用浏览器打开 yrobot.top
  2. https 请求经过域名解析,发送到静态资源服务
  3. 由于没有路由参数,服务器返回 /index.html
  4. 页面加载后运行监测 js,拿到当前用户的语言,如果默认的英文刚好是当前用户的主要语言,那么用户无需手动切换路由就可以正常访问了,流程直接结束
  5. 但是如果当前用户不是英文用户
    • 本地没有配置过语言,且客户端环境语言不是英文。那么客户端 js 提示是否选择三方语言进行访问,根据用户选择的语言分发路由,更新 url 发起请求 yrobot.top/cn/
    • 如果本地设置过语言,那么直接重定向到 yrobot.top/cn/
  6. 静态资源服务器 拿到 /cn/ 路由后,返回对应的 中文语言的 html
  7. 浏览器重新加载中文页面

可以看到,即使是那些二次用户(之前设置过语言的用户),浏览体验还是经历了一次重定向(不过相对于域名重定向,大部分资源已经在第一次请求中缓存,二次请求只需要请求部分资源,但体验会比无缓存的要好很多)。在后续的浏览中也无需再体验闪屏的情况(纯 js 逻辑的国际化无法规避每个页面的闪烁)。

如果不考虑成本,那么可以将几个方案进行结合,达到更好的浏览体验:

  • 访问 / 时,服务端利用 Accept-Language 预先探测用户的语言环境,然后利用服务端代理直接将对应语言的 html 进行返回(如需修改 url,可以利用 304 方案更新浏览器 url)。从而可能可以规避第一次的闪屏情况
  • 搭配 cookie + token 缓存用户信息,服务端中间件获取用户信息,根据用户设置分发页面。实现跨端的语言环境设置。

一些 Next.js 的 国际化方案分析

Next.js 的 国际化路由

参看文档 《Internationalized Routing》

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.

Next.js 从 v10.0.0 开始 内置支持 国际化配置。你可以通过配置 语言列表 和 默认语言 来使 Next.js 自动处理路由逻辑

方案不足

获取当前语言数据

Next.js 自带的国际化能力只支持到语言探测,不包括数据填充层面逻辑。如需要开箱即用的 国际化方案,请跳转至 #next-i18next

  • getStaticProps
export async function getStaticProps({ locale }) {
  return {
    props: {
      locale,
    },
  };
}
  • useRouter
import { useRouter } from "next/router";
const { locale, locales, defaultLocale, domainLocales } = useRouter();

获取到 locale 后,就可以利用当前语言和语言配置数据实现多语言页面内容。

基本的国际化策略

Next.js 主要是两种语言处理策略,Sub-path RoutingDomain Routing,和上方 利用域名分发 和 利用路由分发 两种方案对应。以下是 Sub-path Routing 的示例:

// next.config.js
module.exports = {
  i18n: {
    locales: ["en-US", "cn", "nl-NL"],
    defaultLocale: "en-US",
  },
};

pages/blog.js 便可以通过以下 3 个路由访问:

  • /blog
  • /cn/blog
  • /nl-nl/blog

defaultLocale 的路由不带语言前置。

自动探测用户语言环境

When a user visits the application root (generally /), Next.js will try to automatically detect which locale the user prefers based on the Accept-Language header and the current domain.

当用户访问 app 跟路径时,Next.js 会尝试根据 Accept-Language 和 当前域名 自动探测 用户语言环境

这个策略和上方分析中利用 Accept-Language 分发的逻辑一致

利用 Cookie 更新语言探测策略,实现客户端设置语言的能力

Next.js supports overriding the accept-language header with a NEXT_LOCALE=the-locale cookie. This cookie can be set using a language switcher and then when a user comes back to the site it will leverage the locale specified in the cookie when redirecting from / to the correct locale location.

Next.js 支持利用 NEXT_LOCALE=the-locale cookie 覆盖 accept-language 策略。

这个策略和上方分析中利用 cookie 分发的逻辑一致。结合此能力,便可实现客户端设置语言环境的能力

next-i18next

Although Next.js provides internationalised routing directly, it does not handle any management of translation content, or the actual translation functionality itself. All Next.js does is keep your locales and URLs in sync.

To complement this, next-i18next provides the remaining functionality – management of translation content, and components/hooks to translate your React components – while fully supporting SSG/SSR, multiple namespaces, codesplitting, etc.

next-i18next 提供了开箱即用的 next.js 国际化方案,它在 next.js 的基础上 添加了 翻译内容管理、通用逻辑封装 的能力

1. 安装 next-i18next

yarn add next-i18next

2. 添加语言配置文件

.
└── public
    └── locales
        ├── en
        |   └── common.json
        └── cn
            └── common.json

3. 配置 next-i8next

next-i18next.config.js

module.exports = {
  i18n: {
    defaultLocale: "en",
    locales: ["en", "cn"],
  },
};

next.config.js

const { i18n } = require("./next-i18next.config");

module.exports = {
  i18n,
};

4. 让 next-i18next 接管页面

appWithTranslation

appWithTranslation 处理了类似 I18nextProvider 的事物,将 i18n 实例在页面内自由穿梭

pages/_app.js

import { appWithTranslation } from "next-i18next";

const MyApp = ({ Component, pageProps }) => <Component {...pageProps} />;

export default appWithTranslation(MyApp);
serverSideTranslations

serverSideTranslations 的指责主要是:生成 initialI18nStore,读取多语言配置数据并存入 i18n,将 i18n 实例暴露给页面

所以 serverSideTranslations 需要纯服务端环境运行

pages/$name.js

import { serverSideTranslations } from "next-i18next/serverSideTranslations";

export async function getStaticProps({ locale }) {
  return {
    props: {
      ...(await serverSideTranslations(locale, ["common", "footer"])),
    },
  };
}
useTranslation
import { useTranslation } from "next-i18next";

export const Footer = () => {
  const { t } = useTranslation("footer");

  return (
    <footer>
      <p>{t("description")}</p>
    </footer>
  );
};

next-language-detector

仓库对 i18next-browser-languageDetector 做了一层分装,来更好的配合 Next.js。

主要作用:在客户端对语言环境进行探测

探测的依据主要包括:

  • cookie (set cookie i18next=LANGUAGE)
  • sessionStorage (set key i18nextLng=LANGUAGE)
  • localStorage (set key i18nextLng=LANGUAGE)
  • navigator (set browser language)
  • querystring (append ?lng=LANGUAGE to URL)
  • htmlTag (add html language tag <html lang="LANGUAGE" ...)
  • path (http://my.site.com/LANGUAGE/...)
  • subdomain (http://LANGUAGE.site.com/...)
// lngDetector.ts
import languageDetector from "next-language-detector";
import i18nextConfig from "../next-i18next.config";

export default languageDetector({
  supportedLngs: i18nextConfig.i18n.locales,
  fallbackLng: i18nextConfig.i18n.defaultLocale,
});
// page.ts
import { useEffect } from "react";
import { useRouter } from "next/router";
import lngDetector from "./lngDetector";

export const Page = () => {
  const { locale } = useRouter();
  useEffect(() => {
    const detectedLng = lngDetector.detect();
    if (locale !== detectedLng) {
      // 提示用户切换语言
    }
  }, []);
  return <div />;
};

参考文献