AKR

TanStack 国际化的实践

TanStack Start?

TanStack Start 是 TanStack Router 的 meta framework 版本,或者说是 TanStack 团队对全栈框架的一次尝试。

相比 Next.js 的约定大于配置,TanStack Start 更偏向明确性和灵活性:没有 App Router 那么重的心智负担,也没有那么多隐式的魔法。

问题在哪?

传统的纯客户端应用中,国际化只需要考虑浏览器环境:

  • 检测用户语言(navigator.language
  • 加载对应的翻译文件
  • 渲染界面

但是 SSR 让事情变得复杂了。

服务端需要在渲染前就知道用户的语言偏好,否则会出现这些问题:

  • 首屏闪烁(服务端渲染英文,客户端 hydrate 后切换成中文)
  • SEO 不友好(搜索引擎看到的语言可能不对)
  • 水合不匹配(服务端和客户端渲染结果不一致会报错)

而服务端检测语言的方式和浏览器完全不同,这就需要一套同构的方案。在 Tanstack Start 里,我看到了一种更漂亮的可能性。

怎么做?

核心思路:在服务端和客户端使用不同的逻辑,但保证结果一致。

用同构函数检测语言

使用 createIsomorphicFn 创建同构函数:

import { createIsomorphicFn } from "@tanstack/react-start";
import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
import acceptLanguage from "accept-language";

const getLocale = createIsomorphicFn()
  .client(() => {
    return i18n.language;
  })
  .server(() => {
    acceptLanguage.languages(["zh-CN", "en-US"]);
    const acceptLanguageHeader = getRequestHeader("accept-language");
    const localeFromCookie = getCookie("i18next");

    if (localeFromCookie) {
      return localeFromCookie;
    }

    const parsedLocaleFromHeader = acceptLanguage.get(acceptLanguageHeader);
    const locale = parsedLocaleFromHeader || "en";
    i18n.changeLanguage(locale);
    return locale;
  });

逻辑很清晰:

  • 客户端:直接从 i18next 实例获取当前语言
  • 服务端:优先读 Cookie,其次解析 Accept-Language 请求头

无需 hacky 的 typeof window === 'undefined',只有非常明确的 client 和 server 分支,分支后的两个函数会被分别 tree shake,无需担心 server 的 bundle(比如数据库连接件)会被带到前端。

i18next 配置

标准的 i18next 配置,没什么特别的:

import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";

const resources = {
  en: {
    common: enCommon,
    landing: enLanding,
    // ...
  },
  zh: {
    common: zhCommon,
    landing: zhLanding,
    // ...
  },
};

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    fallbackLng: "en",
    ns: ["common", "landing", "auth"],
    defaultNS: "common",
    detection: {
      order: ["cookie", "localStorage", "navigator"],
      caches: ["cookie"],
      cookieMinutes: 10080, // 7 days
    },
  });

值得注意的是 detection.order:先检查 Cookie,再检查 localStorage,最后才是浏览器语言。这和服务端的优先级保持一致。

初始化时机

在创建 Router 之前调用 getLocale()

export const getRouter = () => {
  getLocale(); // 确保语言在 Router 创建前就确定了

  const router = createTanStackRouter({
    routeTree,
    context: { orpc, queryClient },
    Wrap: ({ children }) => (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    ),
  });

  return router;
};

这样可以保证所有路由组件在渲染时,i18n 已经初始化完成。

思考

这套方案能用,但还不够优雅。

i18next 本身是为客户端设计的,在 SSR 场景下需要很多额外处理。TanStack 的同构函数倒是提供了一个不错的抽象,但感觉还是有点 hacky。

理想的方案应该是框架级别的支持,像 Next.js 的 next-intl 那样,开箱即用。也许 TanStack 会在未来加入这方面的功能?

不过话说回来,灵活性和便利性本来就是权衡。选择了 TanStack,就要接受这种自己动手的风格。

反正能用就行了。