@nuxtjs/i18n 前缀策略踩坑

greatpi.dev 是中英双语的,用的是 @nuxtjs/i18n 的 prefix 策略:每个页面都带语言前缀,/zh/blog、/en/blog,访问根路径 / 时按浏览器语言跳到其中一个。这篇记录这套配置在实际项目里踩到的坑。它们有个共同点:都不报错,只是结果悄悄不对。

先说四种策略

@nuxtjs/i18n 一共有四种路由策略,以默认语言 zh 为例:

策略 中文 英文 说明
no_prefix /blog /blog URL 里没有语言,靠 cookie 或请求头区分。同一个地址对应两种内容,搜索引擎只能收录其中一种
prefix_except_default /blog /en/blog 模块的默认值。默认语言不带前缀,其余语言带
prefix /zh/blog /en/blog 所有语言都带前缀,根路径 / 只负责跳转
prefix_and_default /blog 和 /zh/blog /en/blog 默认语言两种地址都能访问,重复页面要自己处理

我没用默认的 prefix_except_default,选了 prefix,主要是想让路径规整:中英文的地址结构完全对称,任何一页在两种语言下只差一个前缀;以后要是调整默认语言,已有的地址也不用变。MDN(/en-US/docs/...)和微软的文档站也是这样,所有语言都带前缀。代价是根路径 / 没有自己的内容,只负责跳转。

detectBrowserLanguage 默认开着 cookie(useCookie: true),用来记住访客的语言。问题是它不只在跳转时写:一个没带这个 cookie 的请求,不管访问哪一页,响应里都会有:

set-cookie: i18n_redirected=zh; Path=/; ...

第一次来的访客、搜索引擎的爬虫、CDN 回源的请求,都不带这个 cookie。而 Cloudflare 不缓存带 Set-Cookie 的响应。我给首页配的 s-maxage 缓存头从上线起就没生效过,线上 cf-cache-status 一直是 DYNAMIC;后来在控制台配了 Cache Rule,也只是从 DYNAMIC 变成了 BYPASS。

在 prefix 策略下,这个 cookie 能做的事很少:URL 里本来就带着语言,它只在访问裸的 / 时决定跳到哪边。所以我直接关掉了:

// nuxt.config.ts
i18n: {
  strategy: 'prefix',
  detectBrowserLanguage: {
    useCookie: false,
    redirectOn: 'root',
    alwaysRedirect: false
  }
}

代价是:手动切到英文之后再访问 /,会按浏览器语言回到中文。站内链接都带前缀,实际影响很小。

关掉之后重新部署,页面第一次请求是 MISS,第二次起就是 HIT 了。

关掉 cookie 只是第一步。Cloudflare Pages 上,Functions 返回的页面默认不进缓存,还得在控制台加一条 Cache Rule,这部分写在了缓存那篇里。

坑 2:不带前缀的路由规则都不生效

prefix 策略下,/blog/admin 这种不带前缀的路径根本不存在,真实的路由是 /zh/blog/admin 和 /en/blog/admin。所以 routeRules 里这样写,不会报错,也不会生效:

routeRules: {
  '/blog/admin': { ssr: false },     // 没有这个路由,永远匹配不到
  '/*/blog/admin': { ssr: false }    // 这样才对
}

更麻烦的是 prerender。我给 NavX 起始页配过 '/navx': { prerender: true },Nitro 会真的去抓这个不存在的路由,拿到 404,然后整个 build 失败。要写成带前缀的两条:

'/zh/navx': { prerender: true },
'/en/navx': { prerender: true }

坑 3:<html lang> 不会自己出现

装好 i18n、页面也切得了语言,但每一页的 <html> 标签上都没有 lang,也没有 hreflang 互链。这些要三样东西配齐才有:配置里的 baseUrl、每种语言的 language 代码,再在 app.vue 里调用 useLocaleHead:

// nuxt.config.ts
i18n: {
  baseUrl: 'https://greatpi.dev',
  locales: [
    { code: 'zh', language: 'zh-CN', ... },
    { code: 'en', language: 'en-US', ... }
  ]
}

// app.vue
const i18nHead = useLocaleHead({ lang: true, seo: true })
useHead(() => ({
  htmlAttrs: { lang: i18nHead.value.htmlAttrs?.lang },
  link: i18nHead.value.link,
  meta: i18nHead.value.meta
}))

还有一个容易漏的地方:错误页 error.vue 渲染时取代的是整个 app.vue,那里设置的 lang 在 404 页上不生效,要在 error.vue 里再设一次。

坑 4:只有一种语言的内容

我的文章只写中文。/en/blog/133 能打开,界面是英文,正文还是那篇中文。useLocaleHead 并不知道这一点,它照样生成 hreflang,宣称这篇有英文版,canonical 也各自指向自己。结果是两个一模一样的页面在搜索引擎那里互相竞争。

我的处理是三件事:

  1. 文章详情页的 canonical 一律指向 /zh/ 版本
  2. 这类页面不输出 hreflang 互链(在 app.vue 里按路由过滤掉 rel="alternate")
  3. 英文界面下,把正文区域单独标成 lang="zh-CN",读屏软件才会用中文念
<article lang="zh-CN">
  <!-- 页面是 en-US,这一块内容是中文 -->
</article>

坑 5:加了 language 之后,sitemap 的文件名变了

这个是配完坑 3 才冒出来的。@nuxtjs/sitemap 会按语言拆出几份 sitemap,文件名原来是 zh.xml、en.xml;给 locale 加上 language 之后,变成了 zh-CN.xml、en-US.xml。

我的 sitemap 有一个动态源,用来把文章加进去,里面用 _sitemap: 'zh' 指定放进哪一份。文件名一变,这个值就匹配不到了,结果中文 sitemap 里一个 URL 都没有,也没有任何报错。要改成 language 代码:

return articles.map(post => ({
  loc: `/zh/blog/${post.id}`,
  _sitemap: 'zh-CN'   // 不是 'zh'
}))

如果之前在 Search Console 里单独提交过 zh.xml,也要重新提交。

坑 6:模板里用 t(),不用 $t()

legacy: false 下,模板里的 $t 有类型缺口,vue-tsc 检查模板时会报 TS2339,加本地类型补丁也没用。useI18n() 返回的 t 反而带着完整的 locale 类型,模板里统一用它就好。


这几个坑都不报错,只是结果不对。所以我现在的习惯是,改完 i18n 相关的配置,不看代码看结果:curl 看响应头里有没有 cookie,打开 sitemap 数一数 URL,查看网页源代码找 <html lang>。

更多