Next.js DApp 国际化路由:多语言链上术语库与动态内容切换
Next.js DApp 国际化路由:多语言链上术语库与动态内容切换
一、链上术语的一致性问题:当"滑点"不是 Slippage 时
DApp 的多语言支持比传统 Web 应用多了一层复杂性:链上术语在不同语言中的标准化程度差异巨大。在中文社区,DeFi 用户说"滑点"、"无常损失"、"收割";英文社区用 "Slippage"、"Impermanent Loss"、"Yield Farming"。这些术语不是简单的字面翻译问题 —— 它们背后映射到同一套链上逻辑,但不同语言用户对术语的理解边界和应用习惯完全不同。
更大的挑战来自内容层面。DApp 的 UI 里充斥着链上实时数据:TVL 数字、APY 比率、代币名称。这些内容在切换语言时不仅要翻译标签文本,还要保持数字格式、日期格式、甚至货币符号的本地化一致。比如中文用户更习惯看"万/亿"为单位的大数,英文用户习惯 "K/M/B" 缩写。
Next.js 的 App Router 在 v13.4+ 提供了内置的 i18n 路由支持,结合next-intl可以在路由层面实现语言切换,同时保持服务端渲染能力。这对 DApp 特别重要 —— 链上数据在服务端渲染时可以一次性完成多语言格式化,不需要客户端二次 hydration 导致的闪烁。
flowchart TD A[用户请求 /zh-CN/swap] --> B{Next.js 中间件} B --> C[解析 locale: zh-CN] C --> D[加载 zh-CN 术语库] D --> E[SSR 渲染页面] E --> F[注入链上数据 + 本地化格式] F --> G[返回 HTML] H[用户切换语言] --> I[路由跳转 /en/swap] I --> B subgraph 术语库缓存 J[(链上术语 JSON)] K[(UI 标签 JSON)] end D -.-> J D -.-> K二、路由架构与术语库设计原理
Next.js App Router 的 i18n 实现依赖两个核心机制:中间件路由重写和服务端 locale 上下文传播。当用户访问/zh-CN/swap时,中间件解析 locale 参数,将其注入到x-next-intl-localecookie 中,然后通过 React Context 在服务端组件树中传递。
对于链上术语库,不建议使用运行时翻译 API(会影响首屏渲染速度),而是在构建时生成多语言 JSON 文件。每套语言文件包含两个维度:UI 标签翻译和链上术语映射。UI 标签是常规的键值翻译,链上术语则采用三层结构:术语英文原名 → 中文约定译名 → 使用场景说明。
sequenceDiagram participant User as 用户浏览器 participant MW as Next.js 中间件 participant SSR as 服务端组件 participant RPC as 链上 RPC participant Dict as 术语库 User->>MW: GET /zh-CN/pool/0x1234 MW->>MW: 解析 locale = zh-CN MW->>SSR: 传递 locale context SSR->>Dict: 加载 zh-CN 术语库 SSR->>RPC: 查询池子数据 RPC-->>SSR: 返回原始数据 SSR->>SSR: 格式化数字、翻译术语 SSR-->>USer: 渲染 HTML User->>MW: 语言切换 → /en/pool/0x1234 MW->>SSR: locale = en Note over SSR: 重新加载 en 术语库三、完整工程实践
3.1 项目结构与依赖配置
# 项目结构 ├── messages/ │ ├── zh-CN.json # 中文术语库 │ ├── en.json # 英文术语库 │ └── common.json # 共享配置 ├── src/ │ ├── middleware.ts │ ├── i18n/ │ │ ├── request.ts # locale 解析 │ │ ├── routing.ts # 路由配置 │ │ └── navigation.ts │ ├── lib/ │ │ └── terminology.ts # 链上术语工具函数 │ └── app/ │ └── [locale]/ │ ├── layout.tsx │ └── page.tsxnpm install next-intl3.2 术语库设计
// messages/zh-CN.json — 链上术语定义 { "HomePage": { "title": "去中心化交易协议" }, "Swap": { "title": "兑换", "from": "支付", "to": "获得", "slippage": "滑点容忍度", "priceImpact": "价格影响" }, "Terminology": { "slippage": { "display": "滑点", "description": "交易执行价格与报价价格之间的偏差比例", "usageContext": "交易确认页面的参数设置区域" }, "impermanentLoss": { "display": "无常损失", "description": "做市商在池子价格变化时相对于直接持有的价值损失", "usageContext": "流动性池的收益分析页面" }, "totalValueLocked": { "display": "总锁仓量", "shortDisplay": "锁仓", "description": "协议中锁定的所有资产的总美元价值", "formatRule": "locale_format_large_number" }, "annualPercentageYield": { "display": "年化收益率", "shortDisplay": "年化", "description": "按年化计算的收益率,含复利效应", "formatRule": "locale_format_percentage" } } }3.3 中间件配置
// src/middleware.ts import createMiddleware from 'next-intl/middleware'; import { routing } from './i18n/routing'; /** * 国际化中间件 * 设计考量: * - 使用 next-intl 内置中间件而非手动实现:避免 Cookie/Session 同步问题 * - 默认 locale 使用 zh-CN:中文为主要用户群 * - localePrefix 使用 'always':确保 SEO 友好,每个语言版本有独立 URL */ export default createMiddleware(routing); export const config = { // 匹配所有非静态资源和非 API 路由 matcher: ['/', '/(zh-CN|en)/:path*', '/((?!api|_next|_vercel|.*\\..*).*)'], };3.4 路由配置
// src/i18n/routing.ts import { defineRouting } from 'next-intl/routing'; import { createNavigation } from 'next-intl/navigation'; /** * 路由配置 * 设计考量: * - 仅保留 zh-CN 和 en 两个语言:减少术语库维护成本,聚焦核心用户群 * - defaultLocale 设为 zh-CN:未指定语言时自动重定向 * - localePrefix 使用 'always':避免无前缀路由的 SEO 重复问题 */ export const routing = defineRouting({ locales: ['zh-CN', 'en'], defaultLocale: 'zh-CN', localePrefix: 'always', }); export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);3.5 请求级别的 locale 解析
// src/i18n/request.ts import { getRequestConfig } from 'next-intl/server'; import { routing } from './routing'; /** * 按请求加载对应语言的术语库 * 设计考量: * - 使用 getRequestConfig 而非静态导入:每个请求独立加载对应语言文件 * - 提供 locale 验证兜底:如果 URL 中的 locale 不在支持列表,降级到默认 language */ export default getRequestConfig(async ({ requestLocale }) => { let locale = await requestLocale; // 兜底:不在支持语言列表中的 locale,降级为默认语言 if (!locale || !routing.locales.includes(locale as any)) { locale = routing.defaultLocale; } return { locale, messages: (await import(`../../messages/${locale}.json`)).default, }; });3.6 布局组件与语言切换
// src/app/[locale]/layout.tsx import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; import { notFound } from 'next/navigation'; import { routing } from '@/i18n/routing'; import { LocaleSwitcher } from '@/components/LocaleSwitcher'; /** * 根布局组件 * 设计考量: * - 使用 NextIntlClientProvider 包裹整个应用:确保客户端组件也能访问翻译 * - getMessages 在服务端执行:消息数据直接注入到服务端渲染的 HTML 中 */ export default async function LocaleLayout({ children, params, }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) { const { locale } = await params; if (!routing.locales.includes(locale as any)) { notFound(); } const messages = await getMessages(); return ( <html lang={locale}> <body> <NextIntlClientProvider messages={messages}> <LocaleSwitcher currentLocale={locale} /> {children} </NextIntlClientProvider> </body> </html> ); }3.7 链上术语工具函数
// src/lib/terminology.ts import { useFormatter } from 'next-intl'; /** * 链上数据本地化工具 * 设计考量: * - 数字格式化与语言绑定:中文用"万/亿",英文用"K/M/B" * - 百分比统一使用千分位分隔符的 locale 适配 * - 函数纯客户端调用:服务端组件使用 getFormatter() 而非 hook */ const LOCALE_NUMBER_CONFIG: Record<string, { divisors: { value: number; suffix: string }[]; precision: number; }> = { 'zh-CN': { divisors: [ { value: 1e8, suffix: '亿' }, { value: 1e4, suffix: '万' }, ], precision: 2, }, 'en': { divisors: [ { value: 1e9, suffix: 'B' }, { value: 1e6, suffix: 'M' }, { value: 1e3, suffix: 'K' }, ], precision: 2, }, }; /** * 格式化链上大数 * 设计原因:不同语言用户对大数的认知模式不同,中文用户习惯"万/亿", * 英文用户习惯"K/M/B"。直接显示原始数字会影响可读性。 */ export function formatLargeNumber(value: number | bigint, locale: string): string { const num = typeof value === 'bigint' ? Number(value) : value; const config = LOCALE_NUMBER_CONFIG[locale] ?? LOCALE_NUMBER_CONFIG['zh-CN']; for (const { value: divisor, suffix } of config.divisors) { if (Math.abs(num) >= divisor) { return `${(num / divisor).toFixed(config.precision)}${suffix}`; } } return num.toLocaleString(locale); } /** * 格式化链上百分比 * 例如:0.1234 → 12.34%(中文)/ 12.34% (英文) */ export function formatPercentage(value: number, locale: string): string { const pct = (value * 100).toFixed(2); return `${pct}%`; }四、边界分析
术语库维护是一个持续负担。每新增一个功能页面,就需要在两套语言文件中添加对应的翻译条目。如果一个 20 人的团队只有 1 个人负责 i18n,术语库很快会变成信息孤岛。解决思路是在 Issue 模板中强制要求 feature 提案附带中英文术语对照。
SEO 的多语言挑战。localePrefix: 'always'意味着每个语言版本独立索引,Google 会将它们识别为不同页面。需要确保所有hreflang标签正确配置,否则可能被判定为重复内容。
动态内容不适合提前翻译。用户在链上创建的代币名称、DAO 提案标题等 UGC 内容,无法预先录入术语库。这类内容只能做技术性标记(如添加lang属性),不能强求翻译。
数字格式的精度取舍。中文"亿/万"的除法格式在数值较小时精度损失可以接受(显示 0.87 万 vs 8,700),但对于精确到分的金融数据必须回退到全精度显示。
不适用场景:
- 单语言中文社区 DApp:i18n 框架的复杂度不值得引入
- 需要 10+ 语言的全球化产品:next-intl 的消息加载机制在语言过多时会产生显著的包体积膨胀
- 大量实时渲染的链上数据仪表盘:服务端渲染的多语言处理会显著增加 TTFB
五、总结
| 维度 | 要点 |
|---|---|
| 路由策略 | Next.js App Router + next-intl 中间件,localePrefix: 'always'保证 SEO |
| 术语库设计 | 三层结构(原名→译名→使用场景)+ 构建时静态 JSON,避免运行时翻译 API |
| 数字格式 | locale 感知的格式化函数,中文"万/亿"缩写 vs 英文"K/M/B"缩写 |
| 渲染策略 | SSR 优先,术语库在服务端直接注入 HTML,避免客户端 hydration 闪烁 |
| 维护成本 | 两套语言文件的持续维护是最大负担,需要流程约束和 Review 机制 |
| 禁用场景 | 单语言社区 DApp、10+ 语言全球化产品、高实时性数据仪表盘 |
