SSG 站点下 i18n 的轻量支持方案
技术i18n, frontend-dev, UX

SSG 站点下 i18n 的轻量支持方案

Published On
| 更新于
Updated On
16分钟阅读概述:本文简要对比了国际化支持的不同方案,以及提供了一种 i18n 支持/同时较规范的“轻量方案”

背景

  • SSG (Site Static Generation) 释义不在本篇内容中,阅读其定义解释可跳转

  • i18n(Internationalization | WIKI ),网页的国际化支持(或者就直白说是“多语言切换”的支持)能够做到对其他母语使用者——在网页的阅读和交互层面友好。

多语言支持的实现方案对比

!声明:以下总结并不全面和充分正确性。

  1. 多语言 - 多站点

以富有设计质感的花椿官网为例。

其日文官网:

其英文官网:

显然的,布局、内容等各方面的不同,说明了[lang]语言路由跳转下,实际是到了不同前端部署的网页。而非共享同一个站点。

  1. 多语言 - 语言包

这需要开发者为自己的站点提供一份语言包,文件格式上没有强制要求,但多采用 JSON 格式。 那么项目代码下和语言包关联的目录结构多可见:

src
├─locales
│  ├─en
│  ├───common.json
│  ├─zh-Hans
│  ├───common.json

代码根据地址栏的 [lang]语言路由来决定读取哪一份语言包的 json 文件,将对应语言的内容填充到代码里。 SSG / SSR 模式下,由服务端根据预先确定的[lang]语言路由来读取关联的语言包 json 文件,提前生成静态网页,同时因为是服务端预先确定了关联语言唯一特定的 JSON 文件的读取,所以无需担心客户端请求所有语言包 json 文件,从而增大客户端请求负担。

!这里应同时注意,语言包命名有其规范可参考链接

  1. 多语言 - 维持在(页面)组件内部(简陋版本)

如下的代码片段为例

some-comp.jsx
// import ...

function getLabel(labels, locale) {
  const lang = locale === undefined ? "zh-CH" : locale;
  return labels[lang];
}

function SomeComp() {
  const { locale } = useRouter();
  const label = getLabel(labels, locale);
  return (
    <div>
      <Link href="/articles/1">{label.article}</Link>
      <Link href="/albums/1">{label.album}</Link>
      <Link href="/videos/1">{label.video}</Link>
    </div>
  );
}
labels.js
// 文案信息
export const labels = {
  "zh-CN": {
    article: "文章",
    album: "摄影",
    video: "视频",
    search: "搜索",
  },
  en: {
    article: "Article",
    album: "Album",
    video: "Video",
    search: "Search",
  },
};

严格来说上述代码实现并不推荐,显著的两个弊端:

a. 客户端请求 JS 体积增大,在已经有 url [lang]页面路由支持下(const { locale } = useRouter();),上述支持并不是可取的实现方案,而应该使用 JSON 这样的语言包配置文件来减少客户端 JS 体积;

b. 翻译文案离散的分布于各个独立的组件代码中,不便于集中整理编辑。

需求及痛点

吾辈之前在公司项目有处理过支持某产品多语言的需求,体会到其逼格便利之后,也在自己的一个基于 NextJS 的个人独立项目:SaaS 应用自用模板中配置了国际化,该项目基于先前官方采用的 Pages Router 路由模式,i18n 支持实现主要依赖以下核心库:

npm install i18next next-i18next

配置细节不多赘述,有详实的官方配置流程文档作参考,当然这之中也遇到取舍痛点,吾辈使用 NextJS 搭建该 SaaS 应用时,主要看中了其 SSG + SSR 的友好支持特性、开发时无需过多配置。

但是多语言支持下,对于部分 SSG 页面,因为是构建时即生成静态网页,那么对于多语言支持下,如下的

(以 Url 地址栏中的体现为例:如 ://site.com/home(默认简中,也见 ://site.com/zh-Hans/home) 和 ://site.com/en/home(英语支持) 和 ://site.com/zh-Hant/home (繁体支持))

那么原本的单语言支持下只需要生成一个静态网页,而现在就需要生成 3 个相互独立的静态网页。采用 SSR 的网页也同样如此。

吾辈有考虑,仅对于个人的项目而言,真的需要这些“重复的”静态网页吗,以增大项目的打包体积为代价。(当然最终还是在该个人项目中配置了基于 NextJS Page Router 模式下的 i18n 支持,实现全局的语言切换,体验了下该国际化支持必要的开发流程)

落地取舍

而在后续开发本站基于的博客项目中,复用原本的 SaaS 项目代码基础上,升级使用 NextJS 13 新支持的 App Router 路由模式,该路由模式下,能更好的支持共享布局、嵌套布局的定义和使用。(对比传统的 Page Router ,在 DX 上提升明显,之后会考虑单独一篇简短博客对比两者在页面布局定义上的开发区别)

但同时,原本的 i18n 配置实现不能复用在 App Router 模式下,当然官方也提供了该模式下的配置文档说明,代码目录有显著变化:原本的 app/ -> page.tsx ,现在需变为app/ -> [lang]/ -> page.tsx。其中, [lang]/ 就对应页面访问时 url 中的语言路由部分。

正打算上手配置时,吾辈几经思考决定放弃全局的语言支持,因为审视了该博客服务场景,以及并不打算对同一份博文内容本身作多个语言版本的翻译支持,(毕竟现在依赖浏览器插件就能一定程度保证翻译内容的准确性和可读性),同时也不会显著增加打包构建时 SSG 静态网页的数量。

充分需要 i18n 配置的场景

所以什么场景下这样的多语言切换是充分且必要的呢? 个人理解来说,站点内容除去导航栏、菜单、页脚这些公共区域的文本内容,项目/产品说明页/博文内容本身也提供了多语言版本。

以一个例子说明,有这样一个产品页,其内容显示来源于指定 markdown 文件的处理。 ://site.com/time-machine,对于该访问路径,开发人员会处理 posts/ 目录下的 time-machine.md 文档得到该路由下的静态网页。

而对于 ://site.com/zh-Hans/time-machine 访问路径,开发人员会处理 posts/ 目录下的或许命名为 time-machine-zh.md 的文档得到该路由下的静态网页。

正题

取消了通过官方库如 next-i18next / react-i18next 来配置 [lang] 路由下的全局语言切换,但吾辈还是期望对页面的一些核心区域作 i18n 支持(如导航栏、菜单、页脚这些公共区域的文本内容),但并不想引入 app/ -> [lang]/ -> page.tsx 的结构增大静态文件构建数量。

所以如下的(也就是本博客站点的语言切换方案的轻量实现)。实现原理简单,就是依赖一个关联当前偏好语言的全局状态。

以 React + TypeScript 项目开发为例:

初始化 JSON 语言包

locales/en/common.json
{
  "main_nav.home": "Home",
  "main_nav.blog": "Blog",
  "main_nav.tags": "TagCloud",
  "main_nav.preject": "Project",
  "main_nav.blog_light": "Article",
  "main_nav.about": "About",
}
locales/zh-Hans/common.json
{
  "main_nav.home": "首页",
  "main_nav.blog": "博客",
  "main_nav.tags": "标签云",
  "main_nav.preject": "项目",
  "main_nav.blog_light": "文章",
  "main_nav.about": "关于",
}

初始化当前使用语言的全局状态

这里,我们假定有这样一个定义全局状态的功能函数 #createContextState调用其获得该全局状态的 Provider / Getter / Setter,形如:

const initState = 42;
const [Provider, useGetter, useSetter] = createContextState(initState);

我们开始定义一个关联当前使用语言包的全局状态

LightLocaleProvider.tsx
export type LangAliasType = "en" | "zh-Hans" | "zh-Hant" | "ja" | "ko";

const [LocaleAppliedProvider, useLocaleApplied, useSetLocaleApplied] =
  createContextState<LangAliasType>("zh-Hans");

export {
  LocaleAppliedProvider as default,
  useLocaleApplied,
  useSetLocaleApplied,
}

再在需要提供语言切换的页面顶层组件提供该 Provider。

some-partial-app.tsx
// import ...;

<LocaleAppliedProvider key="LocaleAppliedProvider">
  <SomePartialApp />
</LocaleAppliedProvider>

Web API - Intl#DisplayNames | 非必要

API 说明: Intl.DisplayNames

usage-demo
new Intl.DisplayNames(["zh-Hant"], {
  type: "language",
}).of("zh-Hans"); // -> 簡體中文

该代码导出并非必须,只是为了 UX 层面友好 —— 再给用户的选择菜单中显示对应语言区域对该语言规范命名

直观例子 🌰,不使用这个 API 之前,我们的语言选择列表大概长这样(文本由我们自己确定)

[请选择]
├─简体中文
├─英语
├─繁体中文

而应用了该 API 导出实例下提供的相关字段后:

[请选择]
├─简体中文
├─English
├─繁體中文

如下的定义一个导出的 mapper:

languageNamesHumanReadMap.ts
const FixedLangAliasList: LangAliasType[] = [
  "zh-Hans",
  "en",
  "zh-Hant",
  "ja",
  "ko",
];

const languageNamesHumanReadMap = new Map();

FixedLangAliasList?.forEach((localeOne) => {
  const intlEntity = new Intl.DisplayNames([localeOne], {
    type: "language",
  });
  languageNamesHumanReadMap.set(localeOne, intlEntity.of(localeOne)!);
});

语言切换的 React Hook 定义

我们有必要抽离出公共 hook ,方便在页面组件中调用。

同时,因为导入的是 json 文件,并不能很好的为我们提供字段联想支持,我们必要根据当前的 en/common.json 的字段结构手动维护一份 type.d.ts文件:

type.d.ts
export type PredefineLocaleType = {
  "main_nav.home": string;
  "main_nav.blog": string;
  "main_nav.tags": string;
  "main_nav.preject": string;
  "main_nav.blog_light": string;
  "main_nav.about": string;
  ...
}

核心 React Hook useLocalTrans 实现:

useLocalTrans.ts
// tsconfig.json 中配置支持直接导入 json 文件
import en from "~/src/locales/en/common.json";
import zhHans from "~/src/locales/zh-Hans/common.json";

// import ...;

const mapL: Record<LangAliasType, PredefineLocaleType> = {
  en: en,
  ja: ja,
  ko: ko,
  "zh-Hans": zhHans,
  "zh-Hant": zhHant,
};

export function useLocalTrans() {
  const lang = useLocaleApplied();

  const t = React.useCallback(
    (key: keyof PredefineLocaleType) => {
      let lngPackage = zhHans;

      if (Reflect.has(mapL, lang)) {
        lngPackage = mapL[lang];
      }

      return lngPackage[key];
    },
    [lang],
  );

  return { t, lang }; /* 说明:这里约定导出函数名为 t,也是靠拢 next-i18n 的导出调用风格 */
}

🕊️ 组件中应用

some-page.tsx
// import ...

function SomePage() {
  const setLocaleApplied = useSetLocaleApplied();
  const { t } = useLocalTrans();

  return (
    <div>
      {FixedLangAliasList.map((item) => {
        return (
          <button
            onClick={() => {
              setLocaleApplied(localeOne);
            }}
          >
            // !对应语言区域对该语言规范命名显示
            {languageNamesHumanReadMap.get(localeOne)}
          </button>
        );
      })}
      <div>
        <span>{t("main_nav.home")}</span>
        <span>{t("main_nav.blog")}</span>
        <span>{t("main_nav.about")}</span>
      </div>
    </div>
  );
}

补充/延伸

上面 i18n 轻量支持方案的弊端

自己在本博客项目中采用支持语言切换的“轻量方案”,某种程度来说也是“清水房”版本。并且不可忽视的弊端在于,客户端总会在请求文件中拿到所有语言包的所有内容。(不过毕竟是独立站点,内容也是确定的,总的语言包的体积不会过大)

记忆用户对偏好语言的选择

规范的 i18n 支持实现中,用户的语言偏好显式的出现在 url 中:site.com/en/contact,所以下次用户打开同网页时,服务端可以确定的返回对应语言下的静态网页。但是如上的依托于本地全局状态的 i18n 支持实现中,我们不能依赖 url 中的语言路由来判断用户的偏好语言。(当然我们也不期望使用 url#lang 来判断,前面提到过,对 url 引入 [lang] 语言部分,SSG 模式下会生成倍数于本身的静态网页数量,这背离了吾辈的搭建站点的预期。)

但是我们还是有手段缓存记忆用户的语言偏好,在下次用户再次打开页面时,直接切换到对应语言(当然就需要客户端来处理了)—— 借助 localStorage 或其他浏览器本身提供的缓存。简要叙述实现就是:再利用 localeSetter 对全局状态操作赋值时,额外的 localStorage.setItem([youKey], value),并在赋初始值时 createContextState(localStorage.getItem([youKey]) ?? [initState])

水合不一致问题

上面缓存了用户的语言偏好,并且下次打开时,各处就是对应语言包提供的文本。但是如果在 SSG 、 SSR 开发模式下,比如 NextJS 就可能为开发者报错如:

❌ Warning: Text content did not match. Server: "首页" Client: "Home" ...
❌ ERROR: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.

出现这样报错的原因,也能很快想到,在基于 SSR 、 SSG 模式下,服务端生成网页的“初始模板”时,显然是不知道用户语言偏好的,所以生成也是按照兜底的语言包(这里是 zh-Hant/common.json)生成,而在客户端处理时,拿到了缓存的用户语言偏好,直接切换到该语言包下文本,此时,就出现了水合不一致问题(an error while hydrating),解决办法大致有两个:

  • 其一,保留服务端生成网页的初始文本,而是在 useEffet 中更改文本。这样可以规避这类水合不一致报错;
  • 其二,借助 React#API#suppressHydrationWarning

(以上方案,参考自:stackoverflow

SSG 站点下 i18n 的轻量支持方案

https://blog.ninoh.cc/blog/a4-light-i18n[Copy]
本文作者
ninohx96
创建/发布于
Published On
更新/发布于
Updated On
许可协议
CC BY-NC-SA 4.0

转载或引用本文时请遵守“署名-非商业性使用-相同方式共享 4.0 国际”许可协议,注明出处、不得用于商业用途!分发衍生作品时必须采用相同的许可协议。