@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/...)和微软的文档站也是这样,所有语言都带前缀。代价是根路径 / 没有自己的内容,只负责跳转。
坑 1:语言 cookie 让整站页面进不了 CDN 缓存
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 也各自指向自己。结果是两个一模一样的页面在搜索引擎那里互相竞争。
我的处理是三件事:
- 文章详情页的 canonical 一律指向
/zh/版本 - 这类页面不输出 hreflang 互链(在
app.vue里按路由过滤掉rel="alternate") - 英文界面下,把正文区域单独标成
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>。