Dify之i18n

半兽人 发表于: 2025-07-23   最后更新时间: 2025-07-23 18:15:55  
{{totalSubscript}} 订阅, 54 游览

Dify实现了一个完整的国际化解决方案,支持多语言切换,包括服务端渲染(SSR)和客户端渲染(CSR)的完整支持。基于 i18nextreact-i18next 构建,提供了类型安全的国际化体验。

项目结构

i18n/
├── README.md                 # 本文档
├── index.ts                  # 主要导出文件,客户端语言管理
├── i18next-config.ts         # i18next 配置和资源加载
├── language.ts               # 语言相关工具函数
├── server.ts                 # 服务端语言检测和翻译
├── languages.json            # 支持的语言配置
├── en-US/                    # 英文翻译文件
│   ├── common.ts            # 通用翻译
│   └── apps.ts              # 应用相关翻译
├── zh-Hans/                  # 简体中文翻译文件
│   ├── common.ts
│   └── apps.ts
└── zh-Hant/                  # 繁体中文翻译文件
    ├── common.ts
    └── apps.ts

核心文件详解

1. i18n/index.ts - 主要导出文件

作用: 提供客户端语言管理的核心功能

import Cookies from 'js-cookie'
import { changeLanguage } from '@/i18n/i18next-config'
import { LOCALE_COOKIE_NAME } from '@/config'
import { LanguagesSupported } from '@/i18n/language'

export const i18n = {
    defaultLocale: 'en-US',
    locales: LanguagesSupported,
} as const

export type Locale = typeof i18n['locales'][number]

// 设置客户端语言
export const setLocaleOnClient = (locale: Locale, reloadPage = true) => {
    Cookies.set(LOCALE_COOKIE_NAME, locale)
    changeLanguage(locale)
    reloadPage && location.reload()
}

// 获取客户端语言
export const getLocaleOnClient = (): Locale => {
    return Cookies.get(LOCALE_COOKIE_NAME) as Locale || i18n.defaultLocale
}

// 渲染国际化对象
export const renderI18nObject = (obj: Record<string, string>, language: string) => {
    if (!obj) return ''
    if (obj?.[language]) return obj[language]
    if (obj?.en_US) return obj.en_US
    return Object.values(obj)[0]
}

2. i18n/i18next-config.ts - i18next 配置

作用: 配置 i18next 实例,加载语言资源

'use client'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { LanguagesSupported } from '@/i18n/language'

// 静默加载语言资源
const requireSilent = (lang: string) => {
  let res
  try {
    res = require(`./${lang}/common`).default
  }
  catch {
    res = require('./en-US/common').default
  }
  return res
}

// 加载语言资源
const loadLangResources = (lang: string) => ({
  translation: {
    app: require(`./${lang}/apps`).default,
    common: require(`./${lang}/common`).default,
  },
})

// 自动生成资源对象
export const resources = LanguagesSupported.reduce<Resource>((acc, lang) => {
  acc[lang] = loadLangResources(lang)
  return acc
}, {})

// 初始化 i18next
i18n.use(initReactI18next)
  .init({
    lng: undefined,
    fallbackLng: 'en-US',
    resources,
  })

export const changeLanguage = i18n.changeLanguage
export default i18n

3. i18n/language.ts - 语言工具函数

作用: 提供语言相关的工具函数和配置

import data from './languages.json'

export type Item = {
  value: number | string
  name: string
  example: string
}

export type I18nText = {
  'en-US': string
  'zh-Hans': string
}

export const languages = data.languages

// 获取支持的语言列表
export const LanguagesSupported = languages.filter(item => item.supported).map(item => item.value)

// 获取语言代码
export const getLanguage = (locale: string) => {
  if (['zh-Hans', 'ja-JP'].includes(locale))
    return locale.replace('-', '_')
  return LanguagesSupported[0].replace('-', '_')
}

// 获取文档语言
export const getDocLanguage = (locale: string) => {
  const DOC_LANGUAGE: Record<string, string> = {
    'zh-Hans': 'zh-hans',
    'ja-JP': 'ja-jp',
    'en-US': 'en',
  }
  return DOC_LANGUAGE[locale] || 'en'
}

// 获取定价页面语言
export const getPricingPageLanguage = (locale: string) => {
  const PRICING_PAGE_LANGUAGE: Record<string, string> = {
    'ja-JP': 'jp',
  }
  return PRICING_PAGE_LANGUAGE[locale] || ''
}

4. i18n/server.ts - 服务端语言处理

作用: 处理服务端语言检测和翻译

import { cookies, headers } from 'next/headers'
import Negotiator from 'negotiator'
import { match } from '@formatjs/intl-localematcher'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { i18n } from '.'
import type { Locale } from '.'

// 初始化 i18next 实例
const initI18next = async (lng: Locale, ns: string) => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language: string, namespace: string) => 
      import(`./${language}/${namespace}.ts`)))
    .init({
      lng: lng === 'zh-Hans' ? 'zh-Hans' : lng,
      ns,
      fallbackLng: 'en-US',
    })
  return i18nInstance
}

// 使用翻译
export async function useTranslation(lng: Locale, ns = '', options: Record<string, any> = {}) {
  const i18nextInstance = await initI18next(lng, ns)
  return {
    t: i18nextInstance.getFixedT(lng, ns, options.keyPrefix),
    i18n: i18nextInstance,
  }
}

// 获取服务端语言
export const getLocaleOnServer = async (): Promise<Locale> => {
  const locales: string[] = [...i18n.locales]

  let languages: string[] | undefined

  // 1. 从 cookie 获取语言
  const localeCookie = (await cookies()).get('locale')
  languages = localeCookie?.value ? [localeCookie.value] : []

  if (!languages.length) {
    // 2. 从 HTTP 头获取语言
    const negotiatorHeaders: Record<string, string> = {};
    (await headers()).forEach((value, key) => (negotiatorHeaders[key] = value))
    languages = new Negotiator({ headers: negotiatorHeaders }).languages()
  }

  // 验证语言
  if (!Array.isArray(languages) || languages.length === 0 || 
      !languages.every(lang => typeof lang === 'string' && /^[\w-]+$/.test(lang)))
    languages = [i18n.defaultLocale]

  // 匹配语言
  const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale
  return matchedLocale
}

5. i18n/languages.json - 语言配置

作用: 定义支持的语言列表

{
  "languages": [
    {
      "value": "en-US",
      "name": "English (United States)",
      "prompt_name": "English",
      "example": "Hello, Dify!",
      "supported": true
    },
    {
      "value": "zh-Hans",
      "name": "简体中文",
      "prompt_name": "Chinese Simplified",
      "example": "你好,Dify!",
      "supported": true
    },
    {
      "value": "zh-Hant",
      "name": "繁體中文",
      "prompt_name": "Chinese Traditional",
      "example": "你好,Dify!",
      "supported": true
    }
  ]
}

React 组件集成

1. app/components/i18n-server.tsx - 服务端组件

作用: 服务端语言提供者组件

import React from 'react'
import I18N from './i18n'
import { ToastProvider } from './base/toast'
import { getLocaleOnServer } from '@/i18n/server'

export type II18NServerProps = {
  children: React.ReactNode
}

const I18NServer = async ({
  children,
}: II18NServerProps) => {
  const locale = await getLocaleOnServer()

  return (
    <I18N {...{ locale }}>
      <ToastProvider>{children}</ToastProvider>
    </I18N>
  )
}

export default I18NServer

2. app/components/i18n.tsx - 客户端组件

作用: 客户端语言提供者组件

'use client'

import type { FC } from 'react'
import React, { useEffect } from 'react'
import I18NContext from '@/context/i18n'
import type { Locale } from '@/i18n'
import { setLocaleOnClient } from '@/i18n'

export type II18nProps = {
  locale: Locale
  children: React.ReactNode
}

const I18n: FC<II18nProps> = ({
  locale,
  children,
}) => {
  useEffect(() => {
    setLocaleOnClient(locale, false)
  }, [locale])

  return (
    <I18NContext.Provider value={{
      locale,
      i18n: {},
      setLocaleOnClient,
    }}>
      {children}
    </I18NContext.Provider>
  )
}

export default React.memo(I18n)

3. context/i18n.ts - React Context

作用: 提供全局语言上下文

import {
  createContext,
  useContext,
} from 'use-context-selector'
import type { Locale } from '@/i18n'
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n/language'
import { noop } from 'lodash-es'

type II18NContext = {
  locale: Locale
  i18n: Record<string, any>
  setLocaleOnClient: (_lang: Locale, _reloadPage?: boolean) => void
}

const I18NContext = createContext<II18NContext>({
  locale: 'en-US',
  i18n: {},
  setLocaleOnClient: noop,
})

export const useI18N = () => useContext(I18NContext)

export const useGetLanguage = () => {
  const { locale } = useI18N()
  return getLanguage(locale)
}

export const useGetDocLanguage = () => {
  const { locale } = useI18N()
  return getDocLanguage(locale)
}

export const useGetPricingPageLanguage = () => {
  const { locale } = useI18N()
  return getPricingPageLanguage(locale)
}

export default I18NContext

翻译文件结构

通用翻译 (common.ts)

export default {
    welcome: 'Welcome',
    apps: 'Apps',
    home: 'Home',
    about: 'About',
    contact: 'Contact',
    language: 'Language',
    english: 'English',
    chinese: 'Chinese',
} as const

应用翻译 (apps.ts)

export default {
    title: 'Applications',
    description: 'Browse and manage your applications',
    features: {
        title: 'Features',
        list: [
            'Easy to use interface',
            'Fast performance',
            'Secure and reliable',
            '24/7 support'
        ]
    },
    stats: {
        total: 'Total Applications',
        active: 'Active',
        inactive: 'Inactive'
    },
    actions: {
        create: 'Create New App',
        edit: 'Edit',
        delete: 'Delete',
        view: 'View Details'
    }
} as const

使用方法

1. 在页面中使用翻译

// 服务端组件
import { useTranslation } from 'react-i18next'

export default function AppsPage() {
    const { t } = useTranslation()

    return (
        <>
            hello {t('app.title')}
        </>
    )
}

2. 在客户端组件中使用

'use client'
import { useI18N } from '@/context/i18n'

export default function ClientComponent() {
  const { locale, setLocaleOnClient } = useI18N()

  const handleLanguageChange = (newLocale: string) => {
    setLocaleOnClient(newLocale as any)
  }

  return (
    <div>
      <p>Current language: {locale}</p>
      <button onClick={() => handleLanguageChange('zh-Hans')}>
        Switch to Chinese
      </button>
    </div>
  )
}

3. 在布局中集成

// app/layout.tsx
import I18nServer from './components/i18n-server'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <I18nServer>
          {children}
        </I18nServer>
      </body>
    </html>
  )
}

测试

以下是测试语言的代码,测试切换功能是否生效:

// app/apps/page.tsx
import { LanguageSwitcher } from '@/app/components/language-switcher'
import { useTranslation } from '@/i18n/server'
import { getLocaleOnServer } from '@/i18n/server'
export default async function AppsPage() {
    const locale = await getLocaleOnServer()
    const { t: tCommon } = await useTranslation(locale, 'common')
    const { t: tApps } = await useTranslation(locale, 'apps')

    return (
        <div>
            <LanguageSwitcher />
            {tCommon('welcome')} == {tApps('title')}
        </div>
    )
}

切换语言实现:

// app/components/language-switcher/language-switcher.ts
export { default as LanguageSwitcher } from './language-switcher'
// app/components/language-switcher/index.ts
'use client'

import React, { useState, useRef, useEffect } from 'react'
import { useI18N } from '@/context/i18n'
import { LanguagesSupported, languages } from '@/i18n/language'
import ActionButton from '@/app/components/base/action-button'
import classNames from '@/utils/classnames'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { setLocaleOnClient } from '@/i18n'
import type { Locale } from '@/i18n'

// 国旗emoji映射
const flagEmojis: Record<string, string> = {
    'en-US': '🇺🇸',
    'zh-Hans': '🇨🇳',
    'zh-Hant': '🇹🇼'
}

interface LanguageSwitcherProps {
    className?: string
    showFlag?: boolean
    showNativeName?: boolean
}

const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
    className,
    showFlag = true,
    showNativeName = true
}) => {
    const { locale } = useI18N()
    const [isOpen, setIsOpen] = useState(false)
    const dropdownRef = useRef<HTMLDivElement>(null)
    const router = useRouter()
    const pathname = usePathname()
    const searchParams = useSearchParams()

    // 点击外部关闭下拉菜单
    useEffect(() => {
        const handleClickOutside = (event: MouseEvent) => {
            if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
                setIsOpen(false)
            }
        }

        document.addEventListener('mousedown', handleClickOutside)
        return () => {
            document.removeEventListener('mousedown', handleClickOutside)
        }
    }, [])

    const switchLanguage = async (locale: string) => {
        // 更新 URL 参数
        const newSearchParams = new URLSearchParams(searchParams)

        if (locale === 'en-US') {
            // 英文版:移除hi参数
            newSearchParams.delete('hi')
        } else if (locale === 'zh-Hans') {
            // 简体中文版:设置hi=zh参数
            newSearchParams.set('hi', 'zh')
        } else if (locale === 'zh-Hant') {
            // 繁体中文版:设置hi=zh-tw参数
            newSearchParams.set('hi', 'zh-tw')
        }

        const newUrl = newSearchParams.toString()
            ? `${pathname}?${newSearchParams.toString()}`
            : pathname

        // 先更新URL,然后让setLocaleOnClient处理刷新
        window.history.replaceState({}, '', newUrl)

        // 设置语言并刷新页面
        setLocaleOnClient(locale as Locale, true)
        setIsOpen(false)
    }

    // 从languages数据中获取当前语言信息
    const currentLanguage = languages.find(lang => lang.value === locale)

    return (
        <div className={classNames('relative', className)} ref={dropdownRef}>
            {/* 当前语言显示 */}
            <ActionButton
                onClick={() => setIsOpen(!isOpen)}
                className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
            >
                {showFlag && <span className="text-lg">{flagEmojis[locale]}</span>}
                <span>{showNativeName ? currentLanguage?.name : currentLanguage?.prompt_name}</span>
                <svg
                    className={classNames(
                        'w-4 h-4 transition-transform duration-200',
                        isOpen ? 'rotate-180' : ''
                    )}
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                >
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
                </svg>
            </ActionButton>

            {/* 下拉菜单 */}
            {isOpen && (
                <div className="absolute left-0 z-50 w-48 mt-1 bg-white border border-gray-200 rounded-md shadow-lg">
                    <div className="py-1">
                        {LanguagesSupported.map((lang) => {
                            const langData = languages.find(l => l.value === lang)
                            const isActive = locale === lang

                            return (
                                <button
                                    key={lang}
                                    onClick={() => switchLanguage(lang)}
                                    className={classNames(
                                        'flex items-center w-full px-4 py-2 text-sm text-left transition-colors',
                                        'hover:bg-gray-100 focus:bg-gray-100 focus:outline-none',
                                        isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
                                    )}
                                >
                                    {showFlag && <span className="mr-3 text-lg">{flagEmojis[lang]}</span>}
                                    <div className="flex flex-col">
                                        <span className="font-medium">
                                            {showNativeName ? langData?.name : langData?.prompt_name}
                                        </span>
                                        {showNativeName && (
                                            <span className="text-xs text-gray-500">
                                                {langData?.prompt_name}
                                            </span>
                                        )}
                                    </div>
                                    {isActive && (
                                        <svg
                                            className="w-4 h-4 ml-auto text-blue-600"
                                            fill="currentColor"
                                            viewBox="0 0 20 20"
                                        >
                                            <path
                                                fillRule="evenodd"
                                                d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                                                clipRule="evenodd"
                                            />
                                        </svg>
                                    )}
                                </button>
                            )
                        })}
                    </div>
                </div>
            )}
        </div>
    )
}

export default LanguageSwitcher

语言检测优先级

  1. Cookie: 优先从 locale cookie 获取用户选择的语言
  2. HTTP Headers: 从浏览器 Accept-Language 头获取语言偏好
  3. 默认语言: 如果以上都不可用,使用 en-US 作为默认语言

添加新语言

  1. i18n/languages.json 中添加新语言配置
  2. 创建对应的语言文件夹 (如 i18n/ja-JP/)
  3. 添加翻译文件 (common.ts, apps.ts 等)
  4. 更新类型定义

注意事项

  • 所有翻译文件都应该使用 as const 确保类型安全
  • 服务端和客户端组件需要分别处理
  • 语言切换会触发页面刷新以确保一致性
  • 支持的语言列表在 languages.json 中统一管理
更新于 2025-07-23

查看NextJS更多相关的文章或提一个关于NextJS的问题,也可以与我们一起分享文章