5月28日 02:00

如何在 DApp 前端中实现多语言支持?

DApp 面向全球用户,多语言支持不是可选项,而是基本要求。一个只支持英语的 DApp,直接放弃了非英语地区的潜在用户。实际开发中,多语言的实现并不复杂,但有几个 DApp 特有的坑需要提前避开——比如钱包地址格式化、链上动态数据的翻译、以及 RTL 语言的布局适配。

技术选型:i18next 为什么是首选

React 生态中,react-i18next 是最成熟的国际化方案;Vue 生态对应的是 vue-i18n(注意不是 vue-i18next)。两者底层都基于 i18next 核心协议,API 思路一致。

选 i18next 的理由很直接:

  • 插件体系完整,支持按需加载语言包、语言检测、缓存等
  • 与 Web3.js/Ethers.js 无冲突,翻译函数和合约调用互不干扰
  • 社区维护超过 10 年,遇到问题基本都能找到解决方案

不推荐自研轻量方案。DApp 的国际化场景比普通应用复杂——钱包连接状态、交易确认、合约错误码都需要翻译,自研方案容易在边缘场景上翻车。

语言文件的组织方式

推荐按功能模块拆分语言文件,而不是把所有翻译塞进一个 JSON:

shell
/locales ├── en/ │ ├── common.json # 通用按钮、提示 │ ├── wallet.json # 钱包相关 │ └── transaction.json # 交易相关 └── zh-CN/ ├── common.json ├── wallet.json └── transaction.json

翻译文件示例(wallet.json):

json
{ "connected": "钱包已连接", "disconnected": "钱包未连接", "address": "地址: {{address}}", "balance": "余额: {{balance}} {{symbol}}", "network": "当前网络: {{network}}" }

几个关键点:

  • {{}} 做插值占位,不用 {},这是 i18next 的默认语法
  • 动态内容(地址、余额、网络名)必须走插值,不能拼字符串
  • 每个语言文件都要有完整的 key,缺失 key 会显示 fallback 语言或 key 本身

在组件中集成翻译

React 组件集成

javascript
import { useTranslation } from "react-i18next"; function WalletStatus({ account, balance, chainName }) { const { t } = useTranslation("wallet"); return ( <div> <p>{t("connected")}</p> <p>{t("address", { address: formatAddress(account) })}</p> <p>{t("balance", { balance: formatBalance(balance), symbol: "ETH" })}</p> <p>{t("network", { network: chainName })}</p> </div> ); }

formatAddress 做地址截断显示,比如 0x1234...abcd。这个截断逻辑要放在翻译函数外面,不要在插值里做字符串操作。

Vue 组件集成

vue
<template> <div> <p>{{ $t("wallet.connected") }}</p> <p>{{ $t("wallet.address", { address: formattedAddress }) }}</p> </div> </template> <script> export default { computed: { formattedAddress() { return this.account ? this.account.slice(0, 6) + "..." + this.account.slice(-4) : ""; }, }, }; </script>

DApp 特有的国际化问题

链上动态数据的翻译

交易哈希、合约返回值这些数据是链上生成的,不能预翻译。处理方式是翻译模板字符串,把动态数据当参数传进去:

javascript
// 交易确认 const receipt = await contract.transfer(to, amount); notify(t("transaction.confirmed", { hash: receipt.hash.slice(0, 10) + "..." })); // 合约错误 try { await contract.transfer(to, amount); } catch (err) { const reason = err.reason || err.message; notify(t("transaction.failed", { reason: translateContractError(reason) })); }

合约错误码的翻译建议做一层映射:

javascript
const ERROR_MAP = { "ERC20: insufficient allowance": "error.insufficientAllowance", "execution reverted": "error.executionReverted", }; function translateContractError(reason) { const key = ERROR_MAP[reason] || "error.unknown"; return t(key); }

RTL 语言布局适配

阿拉伯语、希伯来语是从右到左书写,布局需要翻转。i18next 本身不管布局,但可以监听语言切换来动态调整:

javascript
const RTL_LANGUAGES = ["ar", "he", "fa"]; i18n.on("languageChanged", (lng) => { const dir = RTL_LANGUAGES.includes(lng) ? "rtl" : "ltr"; document.documentElement.dir = dir; document.documentElement.lang = lng; });

CSS 中用逻辑属性替代物理方向,这样切换语言时布局自动适配:

css
/* 不要用 left/right */ .wallet-card { padding-inline-start: 16px; /* 替代 padding-left */ margin-inline-end: 8px; /* 替代 margin-right */ }

语言切换与路由联动

如果用 Next.js,语言切换要和路由同步,URL 中带语言前缀(如 /en/dashboard/zh/dashboard),这对 SEO 有直接帮助:

javascript
// Next.js 中间件处理语言路由 import { NextResponse } from "next/server"; export function middleware(request) { const lng = request.cookies.get("i18next")?.value || "en"; const { pathname } = request.nextUrl; if (!pathname.startsWith(`/${lng}`)) { return NextResponse.redirect(new URL(`/${lng}${pathname}`, request.url)); } }

性能优化

按需加载语言包

不要把所有语言打包进主 bundle。用 i18next-http-backend 按需加载:

javascript
import i18n from "i18next"; import HttpBackend from "i18next-http-backend"; i18n.use(HttpBackend).init({ backend: { loadPath: "/locales/{{lng}}/{{ns}}.json", }, fallbackLng: "en", });

用户切换语言时才下载对应的语言包,首屏只加载当前语言。

本地缓存

加载过的语言包缓存到 localStorage,避免重复请求:

javascript
import Cache from "i18next-localstorage-cache"; i18n.use(Cache).init({ cache: { enabled: true, expiration: 60 * 60 * 24, // 24小时 }, });

首屏加载优化

用 React Suspense 包裹根组件,语言包加载完成前显示 loading:

javascript
import { Suspense } from "react"; function App() { return ( <Suspense fallback={<LoadingSpinner />}> <DApp /> </Suspense> ); }

测试要点

多语言场景的测试容易被忽略,以下是需要覆盖的关键用例:

  • 语言切换后,所有文案是否正确切换(包括合约错误信息)
  • 动态插值是否正确渲染(地址截断、余额格式化)
  • RTL 语言布局是否翻转
  • 缺失 key 时是否正确 fallback 到默认语言
  • 钱包连接/断开状态的文案是否随语言切换

Jest 测试示例:

javascript
import { render } from "@testing-library/react"; import i18n from "../i18n"; test("wallet status displays in Chinese", async () => { await i18n.changeLanguage("zh-CN"); render(<WalletStatus account="0x1234" balance="1.5" chainName="Ethereum" />); expect(screen.getByText(/钱包已连接/)).toBeInTheDocument(); }); it("falls back to English for missing Chinese keys", async () => { await i18n.changeLanguage("zh-CN"); // 假设某个 key 在中文包中缺失 expect(screen.getByText("Wallet connected")).toBeInTheDocument(); });

面试常见追问

问:i18next 的命名空间和语言文件拆分有什么关系?

命名空间是逻辑分组,语言文件是物理存储。一个命名空间可以对应一个 JSON 文件,也可以多个命名空间合并到一个文件。推荐一对一映射,方便按需加载——用户切到交易页才加载 transaction.json

问:DApp 的多语言和普通 Web 应用有什么区别?

核心区别在动态数据来源不同。普通应用的动态数据来自后端 API,后端可以返回对应语言的内容。DApp 的动态数据来自链上,链上不关心语言,所以所有本地化都要在前端完成。合约错误码、代币名称、事件日志这些都需要前端做映射和翻译。

问:如何处理用户自定义代币的多语言显示?

用户导入的自定义代币,名称和符号来自合约的 name()symbol() 方法,这些值是链上的,无法预翻译。处理方式是直接显示链上原始值,不做翻译。如果代币在已知列表中(如通过 CoinGecko API 获取),可以维护一份代币名称的翻译映射表。

问:多语言对 DApp 的 Gas 费有影响吗?

没有。前端国际化只影响 UI 展示层,不涉及任何链上交互。翻译逻辑完全在客户端执行,不会触发额外的合约调用或交易。

标签:Web3