前端多语言

本页在已启用前端的全栈项目中增加中英文页面,通过服务端明确选择 locale,保证 SSR 与首次 hydration 一致。下面的两种语言是应用示例数据,不涉及站点英文文档的翻译。

目录导航

Locale 来源

后端 config.locale 控制请求侧语言协商并填充 req.locale,frontend.i18n 控制前端消息扫描与加载。启用前端 I18n 后,HTML、页面 envelope 与浏览器导航共用具体的有效语言;"inherit" 不会成为字典键或 html lang。

当前端不需要扫描或打包 src/frontend/locales/** 时,设置 frontend.i18n.enabled=false。一次性的页面文案仍可以通过 res.render() 的 messages 显式传入。

关闭扫描时,除非 route 显式传入 messages,useVextI18n() 会返回空对象。Vext 生成的脚手架首页会使用中性的英文 fallback 保持可用;业务组件也应在访问嵌套文案前提供自己的 fallback:

// 组件内部片段,useVextI18n 从 vextjs/frontend 导入
const i18n = useVextI18n<{ dashboard?: { title: string } }>();
const dashboard = i18n.dashboard ?? { title: "Dashboard" };

语言优先级如下:

  1. res.render(..., { locale }) 的具体语言。
  2. defaultLocale: "inherit" 时,与前端字典匹配的 req.locale。
  3. 按 detect 顺序探测;无匹配时回退到具体 defaultLocale、首个可用字典或 en-US。

detect 支持 query(query.locale)、header / x-vext-locale(X-Vext-Locale)、cookie(locale cookie)和 accept-language。Accept-Language 按质量权重协商,支持大小写和语言前缀匹配;未知来源会在配置校验时拒绝。URL 路径前缀、用户偏好与鉴权逻辑由 route 显式选择语言。

要继承请求侧协商,配置 locale.supported: ["en-US", "zh-CN"];请求侧缺少 supported 时使用其默认 en-US,匹配成功的继承语言优先于前端 detect。要独立按前端探测列表选择,可把前端 defaultLocale 设为具体语言。

res.render() 把最终 locale 与显式 messages 写入页面 payload,导航响应也携带它们。启用 htmlLang 时,SSR 与后续导航同步 <html lang>;htmlLang: false 移除 Vext 生成的 lang marker。

inject: "used" 尚未按组件裁剪消息,构建会给出诊断;当前选定语言仍完整加载,clientLoad: "current" 可限制语言数量。clientSwitch: "reload" 也不会自动创建选择器,切换步骤仍由应用实现。

前端 Locale 文件

页面文案放在 src/frontend/locales/**。

默认建议按功能模块组织,可继续细分二级目录;这是建议布局,扫描位置可由 frontend.i18n.source 覆盖:

src/frontend/locales/
  dashboard/
    en-US.ts
    zh-CN.ts
  order/
    payment/
      en-US.json
      zh-CN.json

例如 order/payment/zh-CN.json 导出 { "title": "支付" },组件通过 i18n.order.payment.title 读取。目录会形成对象命名空间,原有单文件模式及嵌套对象访问继续有效。文件支持 .ts/.mts/.cts/.js/.mjs/.cjs/.json;同一语言可来自多个模块,但同一规范化语言与目录不能重复定义来源,最终键也不能重复。

文件内顶层已全限定的点号键和数字业务码保持原键,用 i18n["order.payment.title"] 等方括号访问。不要同时用目录键和全限定键定义同一最终消息。前端保留对象结构;后端校验器的消息表采用扁平键,不能将两者的访问方式混用。

也可继续使用根目录单文件布局。下面采用独立 demo 命名空间,避免覆盖模板已有消息;创建以下两个文件:

// src/frontend/locales/demo/en-US.ts
export default {
  title: "Dashboard",
  welcome: "Welcome back",
};
// src/frontend/locales/demo/zh-CN.ts
export default {
  title: "仪表盘",
  welcome: "欢迎回来",
};

同一模块的各 locale 文件应保持相同对象结构。缺 key 应视为内容错误,框架不会因为泛型声明就补齐实际消息。

组件中使用文案

Vext 默认前端 API 是对象访问,而不是 t("a.b.c")。

// src/frontend/pages/localized-dashboard.tsx
import { useVextI18n } from "vextjs/frontend";

type Messages = { demo: { title: string; welcome: string } };

export default function DashboardPage() {
  const i18n = useVextI18n<Messages>();
  return (
    <main>
      <h1>{i18n.demo.title}</h1>
      <p>{i18n.demo.welcome}</p>
      <a href="/language/en-US">English</a>
      <a href="/language/zh-CN">中文</a>
    </main>
  );
}

对应路由明确校验并传入语言:

// src/routes/language.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.get(
    "/:locale",
    { validate: { param: { locale: "string" } } },
    (req, res) => {
      const locale = req.valid("param").locale;
      if (locale !== "en-US" && locale !== "zh-CN")
        app.throw(404, "Unknown locale");
      res.render("localized-dashboard", {}, { locale });
    },
  );
});

组件明确知道该语言在服务端与浏览器均已加载时,也可以指定(沿用上面的 Messages 类型):

const english = useVextI18n<Messages>("en-US");

hook 只读取 context,不下载消息也不切换全局语言;指定语言不在 allMessages 时返回当前 messages。current 模式下不要在另一语言页面强行读取未加载语言:SSR 可见的语言集合与浏览器可能不同,造成 mismatch。手工传 messages 会替换当前消息对象,并非深合并补丁。

SSR 与 Hydration

将下面配置合并到 src/config/default.ts。解析器的 i18n 默认关闭,全栈模板已显式开启;这里同时给出明确 fallback 和默认加载模式:

export default {
  frontend: {
    enabled: true,
    i18n: {
      enabled: true,
      defaultLocale: "en-US",
      clientLoad: "current",
    },
  },
};

"current" 表示浏览器 hydration 只加载 SSR 当前 locale,减少首屏 JS。

确实需要在浏览器读取其他已构建语言时,可将上述 clientLoad 改为 "all";下面是对应字段片段,沿用已启用的配置:

frontend: {
  i18n: {
    clientLoad: "all",
  },
}

切换语言

all 会加载所有扫描到的语言,但不自动创建语言选择器、保存用户偏好、改变 html lang 或触发页面重渲染。应用仍需实现这些动作;最简单的完整切换流程是 reload-based:

  1. 用户选择语言。
  2. 写入 cookie、用户偏好 API、URL prefix 或其他服务端可见位置。
  3. 导航或刷新页面。
  4. SSR 和 hydration 使用同一个 locale。

避免服务端先渲染一种语言,而浏览器 hydration 立刻渲染另一种语言。

缓存与 Vary

如果 HTML 会因语言不同而变化,缓存 key 必须包含语言。

常见策略:

  • 保留 Vary: Accept-Language
  • CDN key 包含 locale
  • 使用 /en、/zh 这样的语言路径前缀
  • 反向代理 key 包含语言偏好 cookie

本页的 /language/en-US 和 /language/zh-CN 直接以 URL 区分语言。i18n.vary: true 合并实际探测涉及的 Accept-Language、X-Vext-Locale 或 Cookie 与既有 Vary;仅由 URL 选择语言时无需追加语言头。设为 false 时由应用负责外部缓存的变体隔离。

框架 freshness 使用同一有效语言构造 key。Cookie、认证/session、自定义 req.locale 及无法由已声明请求来源或 URL 区分的 render locale 覆盖绕过公共缓存。static/revalidate 路由须把影响语言的请求输入纳入 detect 或 URL;依赖未声明的 header/身份变化时使用 dynamic。显式 render locale 与查找语言不同的结果不写入该 key,防止错误回放。缓存层区别见Render Data 与缓存。

生成的 JS/CSS 与 locale chunk 通常通过内容 hash URL 区分;缓存这些具体文件不等同于缓存按语言变化的 HTML。

验证示例

执行 npm run build,成功后运行 npm start -- --port 3000,分别访问两个语言 URL。原始 HTML、h1、html lang 与 hydration 后文案都应对应语言,Console 无 mismatch;普通链接应完整导航到另一语言。请求 /language/unknown 应返回 404。当前显式路径示例不应因更换 Accept-Language 而覆盖路由已选择的语言。结束后停止服务。