别让海外访客看到一屏中文Next.js 多语言站点用 next-intl 搭起来【免费下载链接】YimMenuV2Experimental menu for GTA 5: Enhanced项目地址: https://gitcode.com/GitHub_Trending/yi/YimMenuV2一个法国访客带着法语的 Accept-Language 打开了你的首页看到的却是一整屏中文——或者反过来他手动刷新了三次页面死活不肯换语言。多语言站点的麻烦往往不在这页文案怎么翻而在一堆没人提前想清楚的细节语言从哪来、URL 里到底要不要挂 /en 前缀、切换语言时页面会不会白屏闪一下、博客正文这种动态内容跟着谁走。nelsonlai.dev 站点把英语、中文简繁、日语、西班牙语、葡萄牙语共 6 种语言跑在同一个 Next.js 项目里用的是 next-intl。它的方案值得拆开看因为它几乎在每个容易翻车的地方都做了明确的取舍而每个取舍背后都有具体的工程理由。浏览器带了一串语言偏好页面该听谁的用户请求进站点时浏览器基本都会带上 Accept-Language 头里面是一串带权重的语言列表比如fr-FR,fr;q0.9,en;q0.8。说白了就是访客在告诉服务器我最想看法语其次能接受英语。问题是这个头并不总是可靠有人改过浏览器语言却没改操作系统语言有人挂着代理有人就是偏好和你提供的不匹配。所以听谁的要先定规则而不是让某个组件临时发挥。用一个 routing 对象集中声明语言采用 next-intl 而不是 react-i18next主要原因是它和 App Router 的路由、中间件、metadata 是同构的——语言不是挂在某个 Context 上的附加品而是路由本身的一部分。所有语言相关的决策因此可以收敛到一个配置对象里在 i18n/routing.ts 声明export const routing defineRouting({ locales: [en, zh-TW, zh-CN, ja, es, pt-BR] as const, defaultLocale: en, localePrefix: as-needed, localeDetection: true, })这四个字段就决定了整站的语言行为后面所有问题都能从它们推出来。检测只在入口做一次middleware 里创建一次 i18n 中间件让按 Accept-Language 选语言这件事只发生在请求入口而不是散落在每个组件里import createMiddleware from next-intl/middleware import { routing } from ./routing export const i18nMiddleware createMiddleware(routing)检测逻辑放在 i18n/middleware.ts 这一层还有个好处它跑在 Node 侧读请求头没有浏览器兼容问题而且检测结果是路由决策天然可测试——给定某个 Accept-Language能确定性地推出应该落到哪个路径。URL 里的 /en 前缀到底要不要localePrefix有三个常见取值对应三种完全不同的 URL 策略这个选择会一直影响你。always所有页面都带前缀/en/about、/ja/about最直观但默认语言用户也要忍受一个多余的前缀。neverURL 里完全没有语言只靠 Accept-Language 和 Cookie 区分。看起来干净但链接不可移植——别人把/about发给你他看到的可能是日版你看到的是英版。as-needed本项目采用只对非默认语言加前缀。英语用户直接访问/about日语用户访问/ja/about。为什么前缀 URL 值得多占几个字符这里有个坑只靠 Accept-Language 区分语言对搜索引擎是灾难。hreflang 标签要求每种语言有一个可独立索引的规范 URL带前缀的 URL 才能稳定地承载它也才能在邮件里被分享而不失真。前缀不是冗余它是这个页面是这个语言版本的自证。localePrefix: as-needed同时保住了两边默认语言 URL 最干净非默认语言依然有独立可分享的地址。对以英语为默认、其他语言为增量的站点这是最省事的折中。换语言时怎么不闪一下切换语言最反直觉的地方在于用户期待的是同一页换种语言而不是被扔回首页。实现上就两件事——记住当前路径切换 locale 后回到同样的路径。路径与 locale 分离切换器组件 locale-switcher.tsx 用usePathname()拿到当前已去前缀的路径再交给 routerfunction handleLocaleChange(value: Locale | null) { if (!value) return router.replace(pathname, { locale: value }) }replace而不是push是为了不在浏览器历史里留下一堆语言切换记录——用户按后退键时不会在英版和日版之间来回弹跳。触发器别偷懒选择器套了一层带aria-label的 SelectTrigger让屏幕阅读器用户也能换语言。这类细节平时不起眼但在多语言服务更多人群的语境下无障碍不是锦上添花而是多语言体验的一部分。博客正文这种动态内容跟着谁走翻译文件解决的是界面文案但站点真正的内容是博客正文。把所有正文逐句翻进 messages 是不可维护的正文会持续增长翻译产能跟不上最后一定出现半英半中的页面。按语言过滤而不是按语言翻译正确姿势是把属于哪种语言作为内容的一个属性而不是内容的副本。文章元数据带上 locale 字段列表页按当前 locale 过滤export function getAllPostsByLocale(locale: string) { return allPosts.filter((post) post.locale locale) }这带来一个自然的推论一篇法语读者看不到的文章不会出现在法语文档列表里也不会贡献一个 404 的 /fr 链接。内容缺席是显式的而不是翻译缺失造成的隐性空洞。文案与内容的分界线一个实用的判据界面里所有按钮、标签、提示走 messages 的翻译键所有用户或作者产出的正文走 locale 字段过滤。这条线一划翻译维护者只需要盯前者内容作者只管写后者。日期、复数和元数据容易被忽略的细节多语言真正出丑的地方往往不是主文案而是这些边角。日期别自己拼字符串July 4, 2026、2026年7月4日、4 juillet 2026——月份顺序、分隔符、日字位置全都不一样。让useFormatter的dateTime去处理它内部走的是语言环境对应的格式化规则不要手写const format useFormatter() format.dateTime(new Date(), { dateStyle: medium })复数规则写在哪一层英语有单复数俄语有六种日语没有。把复数写进模板字符串1 like/n likes等于把语言学规则硬编码进 UI。next-intl 的翻译键本身带 ICU 复数处理t(common.likes, { count: 5 })在不同语言下自动落到正确的形态维护者只需要在每种语言的 messages 里各写一次复数分支。每种语言都要有规范元数据SEO 层面多语言站的 metadata 要为每种语言生成独立的alternatescanonical 指向自身语言版本languages里列出所有兄弟语言x-default指回默认语言。这套标签是告诉搜索引擎这几条 URL 是同一页的不同语言的唯一方式缺了它多语言就只是多了一份内容而不是多了一种被正确索引的语言。语言列表怎么克制结构怎么留这套方案里最容易被低估的反而是两处不做什么。语言数量是成本问题不是配置问题每加一种语言成本不只是翻一遍 messages所有复数分支、所有带 HTML 的翻译、所有动态插值的键都要有人核对而且这个成本随功能增长持续累积。6 种语言能维持得下去很大程度上是因为加语言前算过这笔账。语言列表是产品决策不是往 locales 数组里塞字符串。结构镜像内容messages 的组织刻意贴合站点信息架构homepage.hero、common.save这样的键基本能照着页面结构读出来。新增一个页面时翻它对应的键段就能判断还缺什么而不是全局搜一遍。反过来一旦有人为了复用把键抽到离页面很远的通用命名空间里维护成本会指数上升——宁可重复也别让翻译者猜键在哪。想把它落下去最实际的第一步只有一个先确定 defaultLocale 和 localePrefix 这两个值再用 Accept-Language 实测一次自动检测是否符合预期。这两个决定会决定你之后所有语言相关代码的形状值得在写任何页面之前就先钉死。【免费下载链接】YimMenuV2Experimental menu for GTA 5: Enhanced项目地址: https://gitcode.com/GitHub_Trending/yi/YimMenuV2创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?