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,就要接受这种自己动手的风格。
反正能用就行了。