Astro 5 i18n 国际化实践指南:多语言动态路由实现

在构建国际化网站时,Astro.js 官方推荐的方法是为每种语言创建单独的文件夹和对应的页面。这种方法虽然直观,但当支持的语言数量增加时,会导致大量重复的页面文件。例如,如果你有 10 个页面需要支持 5 种语言,就需要创建 50 个页面文件!

本文将介绍一种基于动态路由的替代方案,通过组件化和路由参数,大大减少了重复代码,提高了维护效率。

目录

  • 目录
  • 官方推荐的方法及其局限性
  • 动态路由方案
    • 项目结构
    • 核心实现
    • [步骤 1:创建翻译文件](#步骤 1:创建翻译文件 "#%E6%AD%A5%E9%AA%A4-1%E5%88%9B%E5%BB%BA%E7%BF%BB%E8%AF%91%E6%96%87%E4%BB%B6")
    • [步骤 2:创建国际化工具函数](#步骤 2:创建国际化工具函数 "#%E6%AD%A5%E9%AA%A4-2%E5%88%9B%E5%BB%BA%E5%9B%BD%E9%99%85%E5%8C%96%E5%B7%A5%E5%85%B7%E5%87%BD%E6%95%B0")
    • [步骤 3:创建页面组件](#步骤 3:创建页面组件 "#%E6%AD%A5%E9%AA%A4-3%E5%88%9B%E5%BB%BA%E9%A1%B5%E9%9D%A2%E7%BB%84%E4%BB%B6")
    • [步骤 4:实现动态路由](#步骤 4:实现动态路由 "#%E6%AD%A5%E9%AA%A4-4%E5%AE%9E%E7%8E%B0%E5%8A%A8%E6%80%81%E8%B7%AF%E7%94%B1")
    • [步骤 5:创建默认语言页面](#步骤 5:创建默认语言页面 "#%E6%AD%A5%E9%AA%A4-5%E5%88%9B%E5%BB%BA%E9%BB%98%E8%AE%A4%E8%AF%AD%E8%A8%80%E9%A1%B5%E9%9D%A2")
  • 语言切换实现
    • [必要的 astro.config.ts 配置](#必要的 astro.config.ts 配置 "#%E5%BF%85%E8%A6%81%E7%9A%84-astroconfigts-%E9%85%8D%E7%BD%AE")
    • [对 SSG 的影响](#对 SSG 的影响 "#%E5%AF%B9-ssg-%E7%9A%84%E5%BD%B1%E5%93%8D")
    • 隐藏默认语言路径前缀
  • 翻译文件加载优化
    • [1. 动态导入(按需加载)](#1. 动态导入(按需加载) "#1-%E5%8A%A8%E6%80%81%E5%AF%BC%E5%85%A5%E6%8C%89%E9%9C%80%E5%8A%A0%E8%BD%BD")
    • [2. 直接导入(预加载所有语言)](#2. 直接导入(预加载所有语言) "#2-%E7%9B%B4%E6%8E%A5%E5%AF%BC%E5%85%A5%E9%A2%84%E5%8A%A0%E8%BD%BD%E6%89%80%E6%9C%89%E8%AF%AD%E8%A8%80")
    • 选择建议
  • 项目结构示例
  • 最佳实践
  • 故障排除
  • 优化建议
  • 结论
  • 参考资源

官方推荐的方法及其局限性

Astro 官方推荐的国际化方法是基于文件系统的路由结构:

Astro 文档:i18n

bash 复制代码
src/pages/
├── about.astro
├── index.astro
├── fr/
│   ├── about.astro
│   └── index.astro
└── zh/
    ├── about.astro
    └── index.astro

这种方法的主要问题是:

  1. 代码重复:每个语言版本的页面内容基本相同,只是文本不同
  2. 维护困难:添加新页面或修改现有页面时,需要同时更新所有语言版本
  3. 扩展性差:添加新语言需要复制大量文件

动态路由方案

我们可以利用 Astro 的动态路由功能,结合组件化思想,实现更高效的国际化方案。

Astro 文档:动态路由

项目结构

ini 复制代码
src/
├── components/
│   └── pages/
│       ├── HomePage.astro     # 首页组件
│       └── AboutPage.astro    # 关于页面组件
├── i18n/
│   ├── locales/               # 翻译文件
│   │   ├── en.json
│   │   ├── zh.json
│   │   └── ...
│   ├── i18nConfig.ts          # 国际化配置
│   └── utils.ts               # 国际化工具函数
└── pages/
    ├── [locale]/              # 动态语言路由
    │   ├── about.astro
    │   └── index.astro
    ├── about.astro            # 默认语言页面
    └── index.astro            # 默认语言首页

核心实现

  1. 动态路由参数 :使用 [locale] 文件夹捕获语言参数
  2. 页面组件化:将页面内容抽象为组件,接收语言参数
  3. 统一翻译管理:使用 JSON 文件存储所有语言的翻译

步骤 1:创建翻译文件

首先,为每种语言创建 JSON 翻译文件:

src/i18n/locales/en.json

json 复制代码
{
  "Welcome": "Welcome to my website",
  "About": "About us",
  "Description": "This is a multilingual website built with Astro"
}

src/i18n/locales/zh.json

json 复制代码
{
  "Welcome": "欢迎访问我的网站",
  "About": "关于我们",
  "Description": "这是一个使用 Astro 构建的多语言网站"
}

步骤 2:创建国际化工具函数

typescript 复制代码
// src/i18n/i18nConfig.ts
export const languages = {
  en: 'English',
  zh: '中文',
  // 添加更多语言
}

export const defaultLang = 'en'

export type TranslationDict = {
  [key: string]: string
}

// 支持语言的类型
export type SupportedLanguage = keyof typeof translations

// 导入所有语言文件
import en from './locales/en.json'
import zh from './locales/zh.json'
import ja from './locales/ja.json'
import de from './locales/de.json'
import fr from './locales/fr.json'

// Translation dictionary
export const translations: Record<string, TranslationDict> = {
  en,
  zh,
  ja,
  de,
  fr,
}
typescript 复制代码
// src/i18n/utils.ts
import { translations, defaultLang, type SupportedLanguage, type TranslationDict } from './i18nConfig'

// 创建翻译函数
export function useTranslations(lang: SupportedLanguage) {
  return function t(key: string, ...args: string[]): string {
    // Get translation text
    const translation = (translations[lang] as TranslationDict)[key] || (translations[defaultLang] as TranslationDict)[key] || key

    // Replace variables
    if (args.length > 0) {
      return args.reduce((text, arg, index) => {
        return text.replace(`$${index + 1}`, arg)
      }, translation)
    }

    return translation
  }
}

// 计算国际化域名 URL (可选)
export function getLocalizedUrl(lang: string, path: string): string {
  if (lang === defaultLang) {
    return path
  }
  return `/${lang}${path.startsWith('/') ? path : `/${path}`}`
}

步骤 3:创建页面组件

src/components/pages/HomePage.astro

astro 复制代码
---
import { useTranslations } from '@/i18n/utils'

const lang = Astro.currentLocale
const t = useTranslations(lang)
---

<div>
  <h1>{t('Welcome')}</h1>
  <p>{t('Description')}</p>
</div>

步骤 4:实现动态路由

src/pages/[locale]/index.astro

astro 复制代码
---
import Layout from '@/layouts/Layout.astro'
import HomePage from '@/components/pages/HomePage.astro'

// 定义支持的非默认语言路由 (for SSG)
export function getStaticPaths() {
  return [
    { params: { locale: 'zh' } },
    // 添加更多语言
  ]
}
---

<Layout>
  <HomePage />
</Layout>

步骤 5:创建默认语言页面

src/pages/index.astro

astro 复制代码
---
import Layout from '@/layouts/Layout.astro'
import HomePage from '@/components/pages/HomePage.astro'
---

<Layout>
  <HomePage />
</Layout>

语言切换实现

创建一个语言选择器组件:

src/components/LanguagePicker.astro

astro 复制代码
---
import { defaultLang, languages } from '@/i18n/i18nConfig'

const lang = Astro.currentLocale || defaultLang
---

<select id="language-selector" class="appearance-none rounded-md border border-gray-200 bg-white py-1.5 pr-8 pl-3 text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:outline-none">
 {
   Object.entries(languages).map(([langCode, langLabel]) => (
     <option value={langCode} selected={langCode === lang}>
       {langLabel}
     </option>
   ))
 }
</select>

<script define:vars={{ languages, lang }} is:inline>
  document.addEventListener('DOMContentLoaded', () => {
    const languageSelector = document.getElementById('language-selector')

    if (languageSelector) {
      languageSelector.addEventListener('change', (event) => {
        const newLang = event.target.value

        // 设置 cookie 以存储语言偏好
        document.cookie = `preferred_language=${newLang}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`

        // 获取当前 URL 的路径部分
        const currentPath = window.location.pathname

        // 检测当前路径是否包含语言前缀
        const langPathRegex = /^\/([a-z]{2})(\/|$)/
        const langMatch = currentPath.match(langPathRegex)

        let newUrl
        if (langMatch) {
          // 当前 URL 有语言前缀
          const pathWithoutLang = currentPath.replace(langPathRegex, '/')
          if (newLang === 'en') {
            // 英文使用无前缀路径
            newUrl = pathWithoutLang
          } else {
            // 其他语言使用带语言前缀的路径
            newUrl = `/${newLang}${pathWithoutLang === '/' ? '' : pathWithoutLang}`
          }
        } else {
          // 当前 URL 无语言前缀
          if (newLang === 'en') {
            // 保持当前路径不变
            newUrl = currentPath
          } else {
            // 添加语言前缀
            newUrl = `/${newLang}${currentPath === '/' ? '' : currentPath}`
          }
        }

        window.location.href = newUrl
      })
    }
  })
</script>

必要的 astro.config.ts 配置

要使中间件正常工作,必须在 astro.config.ts 中进行以下配置:

typescript 复制代码
// astro.config.ts
import { defineConfig } from 'astro/config'
import node from '@astrojs/node' // 需要安装 @astrojs/node 适配器

export default defineConfig({
  // 其他配置...

  // 国际化配置
  i18n: {
    locales: ['en', 'zh'], // 支持的语言
    defaultLocale: 'en',
    routing: {
      prefixDefaultLocale: false, // 默认语言不添加前缀
    },
  },

  // 中间件需要服务器端渲染(SSR)
  output: 'server',

  // 添加 node 适配器
  adapter: node({
    mode: 'standalone',
  }),
})

对 SSG 的影响

重要提示:使用中间件进行自动语言重定向会牺牲静态站点生成 (SSG) 能力,因为:

  1. 中间件需要服务器端渲染 (SSR) 才能运行,这意味着必须将 output 设置为 'server'
  2. 需要安装和配置服务器适配器(如 @astrojs/node
  3. 网站将不再是纯静态的,无法直接部署到静态托管服务(如 GitHub Pages)
  4. 每个请求都需要在服务器上处理,可能会增加响应时间和服务器负载

如果你的项目必须保持静态生成能力,可以考虑以下替代方案:

  1. 使用客户端 JavaScript 检测语言并重定向(缺点是会有短暂的闪烁)
  2. 使用部署平台的边缘函数或重写规则(如 Netlify/Vercel 的重定向规则)
  3. 放弃自动语言检测,仅提供手动语言切换功能

隐藏默认语言路径前缀

在多语言网站中,通常我们希望默认语言(如英文)不显示语言前缀,例如使用 / 而不是 /en/。这可以通过以下步骤实现:

  1. 配置 Astro 的国际化设置

astro.config.ts 中,设置 prefixDefaultLocale: false

typescript 复制代码
export default defineConfig({
  i18n: {
    locales: ['en', 'zh', 'ja', 'de', 'fr'],
    defaultLocale: 'en',
    routing: {
      prefixDefaultLocale: false, // 默认语言不使用路径前缀
    },
  },
  output: 'server', // 启用服务器端渲染,这是中间件工作的必要条件
})
  1. 实现语言重定向中间件

虽然上面的配置已经能够隐藏默认语言的路径前缀,但我们还需要一个中间件来处理更复杂的重定向逻辑,例如:

  • 处理用户手动输入 /en/* 路径的情况
  • 根据用户浏览器语言或存储的偏好进行自动重定向
  • 在 cookie 中存储用户的语言偏好

以下是我们项目中实际使用的中间件实现:

typescript 复制代码
// src/middleware/languageRedirect.ts
import type { APIContext, MiddlewareNext } from 'astro'
import { defaultLang } from '../i18n/i18nConfig'
import { getLocalizedUrl } from '../i18n/utils'

/**
 * 中间件:处理语言重定向
 * 1. 如果访问 /en/* 路径,重定向到无前缀路径
 * 2. 如果访问根路径或无语言前缀路径,根据浏览器语言或存储的偏好进行重定向
 * 3. 当访问特定语言路径时,在 cookie 中存储当前语言
 */
export async function onRequest(context: APIContext, next: MiddlewareNext) {
  const { url, preferredLocale, cookies, request } = context
  const { pathname } = url

  // 1. 如果访问 /en/* 路径,重定向到无前缀路径
  if (pathname.startsWith('/en/') || pathname === '/en') {
    return new Response('', {
      status: 301,
      headers: {
        Location: pathname === '/en' ? '/' : pathname.replace(/^\/en/, ''),
      },
    })
  }

  // 2. 如果访问根路径或无语言前缀路径
  // 检查是否是无语言前缀的路径(不是 /zh/、/ja/ 等开头)
  const langPathRegex = /^\/([a-z]{2})(\/|$)/
  const langMatch = pathname.match(langPathRegex)

  if (!langMatch) {
    // 这是一个无语言前缀的路径

    // 检查 cookie 中存储的语言偏好
    const storedLang = cookies.get('preferred_language')?.value

    // 尝试从请求头中获取 Cookie(解决新标签页问题)
    let cookieLang = storedLang
    if (!cookieLang) {
      const cookieHeader = request.headers.get('cookie')
      if (cookieHeader) {
        const match = cookieHeader.match(/preferred_language=([a-z]{2})/)
        if (match && match[1]) {
          cookieLang = match[1]
        }
      }
    }

    // 按照存储的语言、浏览器语言偏好、默认语言的顺序选择语言
    const userLang = cookieLang || preferredLocale || defaultLang

    // 只有当语言不是默认语言时才进行重定向
    if (userLang !== defaultLang) {
      // 构建重定向URL
      const redirectUrl = getLocalizedUrl(userLang, pathname)
      return context.redirect(redirectUrl)
    }
  } else {
    // 3. 当访问特定语言路径时,在 cookie 中存储当前语言
    const lang = langMatch[1]

    // 设置 cookie 以存储语言偏好
    cookies.set('preferred_language', lang, {
      path: '/',
      maxAge: 60 * 60 * 24 * 365, // 一年
      secure: url.protocol === 'https:',
      sameSite: 'lax',
    })
  }

  return next()
}
  1. 注册中间件

src/middleware.ts 文件中注册这个中间件:

typescript 复制代码
// src/middleware.ts
export { onRequest } from './middleware/languageRedirect'

这个实现提供了以下功能:

  • /en/* 路径重定向到无前缀路径(如 /about
  • 根据用户的浏览器语言或之前存储的偏好自动重定向到相应的语言版本
  • 在用户访问特定语言版本时,将语言偏好存储在 cookie 中
  • 在后续访问中,根据存储的偏好自动重定向

这种方法比在每个页面中单独实现重定向更加集中和可维护,特别是对于有大量页面的网站。

翻译文件加载优化

在国际化实现中,翻译文件的加载方式对性能和用户体验有重要影响。我们可以采用两种主要的加载策略:

1. 动态导入(按需加载)

typescript 复制代码
// i18nConfig.ts
export const translations = {
  en: enTranslations, // 直接导入默认语言
  zh: () => import('./locales/zh.json').then((module) => module.default),
  ja: () => import('./locales/ja.json').then((module) => module.default),
  // 其他语言...
}

优点

  • 减小初始加载体积,只加载当前需要的语言
  • 适合翻译文件较大或语言较多的项目

缺点

  • 切换语言时可能出现延迟
  • 服务器端渲染时需要额外处理(增加 api)
  • 实现复杂度增加

2. 直接导入(预加载所有语言)

typescript 复制代码
// i18nConfig.ts
import en from './locales/en.json'
import zh from './locales/zh.json'
import ja from './locales/ja.json'
import de from './locales/de.json'
import fr from './locales/fr.json'

export const translations = {
  en,
  zh,
  ja,
  de,
  fr,
}

优点

  • 实现简单直观
  • 服务器端渲染时无需额外处理
  • 语言切换即时生效,无加载延迟
  • 类型安全,编译时可检查翻译键

缺点

  • 增加初始加载体积
  • 如果翻译文件非常大或语言非常多,可能影响性能

选择建议

对于大多数中小型项目,直接导入是更简单有效的方案:

  1. 服务器端渲染友好:在 SSR 环境中,所有翻译在构建时就已加载
  2. 用户体验更好:语言切换无延迟,页面不会显示未翻译内容
  3. 开发体验更好:简化了代码,减少了异步处理的复杂性
  4. 构建优化:现代打包工具可以通过代码分割优化加载

只有当翻译文件非常大(每个语言文件超过 100KB)或支持的语言非常多(超过 10 种)时,才需要考虑动态导入方案。

项目结构示例

一个完整的国际化项目结构示例:

ini 复制代码
src/
├── i18n/
│   ├── i18nConfig.ts          # 国际化配置
│   ├── utils.ts               # i18n 工具函数
│   └── locales/               # 翻译文件
│       ├── en.json           # 英文翻译(默认)
│       ├── zh.json           # 中文翻译
│       └── ...               # 其他语言翻译
├── middleware.ts             # 导出语言重定向中间件
├── middleware/
│   └── languageRedirect.ts   # 语言重定向中间件实现
├── components/
│   ├── LanguagePicker.astro  # 语言选择组件
│   └── pages/                # 共享页面组件
│       ├── HomePage.astro    # 首页组件
│       └── AboutPage.astro   # 关于页面组件
└── pages/
    ├── index.astro           # 默认语言首页
    ├── about.astro           # 默认语言关于页面
    └── [locale]/             # 本地化页面
        ├── index.astro       # 本地化首页
        └── about.astro       # 本地化关于页面

最佳实践

在实现 Astro 国际化时,以下是一些最佳实践:

  1. 组件化设计:将页面内容拆分为可复用的组件,减少重复代码。

  2. 类型安全:使用 TypeScript 接口定义翻译键,确保类型安全。

  3. 动态导入:对于大型项目,使用动态导入翻译文件以提高性能。

  4. 路径处理 :确保 getLocalizedUrl 函数正确处理各种路径情况,包括带查询参数的 URL。

  5. 使用 Astro 内置的 i18n :尽可能利用 Astro 的内置国际化功能,如 Astro.currentLocale,而不是自定义函数。

  6. 在每个组件中获取语言 :虽然可以通过 props 传递语言参数,但更好的做法是在每个组件中直接使用 Astro.currentLocale。这样做有几个好处:

    • 简化组件接口:不需要通过 props 传递语言参数
    • 一致性:所有组件使用相同的方式获取当前语言
    • 自动更新:当用户切换语言时,组件会自动使用新的语言
    • 避免全局状态问题:由于 Astro 是服务器渲染的,全局翻译函数可能会在不同请求之间共享状态
    • 组件隔离:保持了 Astro 组件的隔离性原则
    • 便于测试:组件内部获取语言更容易进行单元测试

    示例:

    astro 复制代码
    ---
    import { useTranslations } from '@/i18n/utils'
    // 直接使用 Astro.currentLocale 而不是通过 props 传递
    const lang = Astro.currentLocale || 'en'
    const t = useTranslations(lang)
    ---
    
    <div>
      <h1>{t('Welcome')}</h1>
    </div>
  7. 测试:在不同场景下测试语言切换:

    • 从默认语言到其他语言
    • 在非默认语言之间
    • 带路径段和不带路径段的情况

故障排除

在实现国际化过程中,可能会遇到以下常见问题:

重定向不正确

如果语言重定向不正常,请检查:

  • astro.config.ts 中的中间件注册是否正确
  • 是否正确启用了服务器端渲染(output: 'server'
  • Cookie 设置和权限是否正确
  • 浏览器语言检测逻辑是否有误

翻译缺失

如果翻译没有正确显示:

  • 验证翻译文件格式是否正确(JSON 语法)
  • 检查各语言文件之间的键是否完全匹配
  • 确保正确的语言代码传递给 useTranslations
  • 检查动态导入是否正确处理

语言偏好持久化问题

如果语言偏好没有正确保存:

  • 检查 Cookie 设置(SameSite、Secure、path 等属性)
  • 验证浏览器是否启用了 JavaScript
  • 在不同浏览器中测试以隔离问题
  • 检查是否有阻止 Cookie 的浏览器扩展

优化建议

  1. 预编译翻译:在构建时预编译翻译文件,避免运行时动态导入
  2. 自动化工具:开发脚本自动同步新页面到所有语言路由
  3. 集成翻译管理系统:使用专业的翻译管理工具简化翻译流程

结论

通过动态路由和组件化,我们可以在 Astro 中实现更高效的国际化方案,大大减少代码重复和维护成本。虽然这种方法仍有一些局限性,但对于大多数多语言网站来说,它提供了一个很好的平衡点。

如果你有更好的 Astro.js 国际化实现方案,欢迎在评论区分享!

参考资源

  1. Astro 官方国际化文档
  2. Astro 动态路由文档
  3. Paul Pietzko - Internationalization (i18n) in Astro
相关推荐
martian6651 小时前
分布式并发控制实战手册:从Redis锁到ZK选主的架构之道
java·开发语言·redis·分布式·架构
Thomas游戏开发1 小时前
Unity3D状态管理器实现指南
前端框架·unity3d·游戏开发
niusir1 小时前
深入理解 React 自定义 Hook
前端·react.js·前端框架
SimonKing1 小时前
JDK 24 新特性解析:更安全、更高效、更易用
java·后端·架构
陈珙2 小时前
后端思维之高并发处理方案
架构·技术
laopeng3012 小时前
Spring AI MCP 架构详解
人工智能·spring·架构
DemonAvenger2 小时前
Go sync 包详解:Mutex、RWMutex 与使用陷阱
分布式·架构·go
百锦再3 小时前
React编程的核心概念:数据流与观察者模式
前端·javascript·vue.js·观察者模式·react.js·前端框架·ecmascript
Moonbit3 小时前
双周报Vol.68: Bytes模式匹配增强、函数别名上线、IDE体验优化...核心技术迎来多项更新升级!
架构
Moonbit3 小时前
双周报Vol.67: 模式匹配支持守卫、LLVM 后端发布、支持 Attribute 语法...多项核心技术更新!
架构