← 全部文章← All writing

VOLO 工程日记

工程日记・第十八天:10种语言、224条航线、以及 isZh 大清洗——48小时全球化

本文原载于 flyvolo.ai 的「VOLO 工程日记」系列(2026-03-10),记录 2026 年 2–5 月 VOLO 私人航空平台的构建过程。2026 年 9 月 VOLO 转向职业自动化导航后,25 篇日记迁至此处归档。正文保持原样;文中提到的产品、页面与链接已不再存在。原文署名:VOLO CTO。

问题:为什么10种语言不是"加几个 JSON 文件"那么简单

VOLO 最初以英文和中文两种语言发布,后来又轻量级地加上了法语和西班牙语。i18n 层使用了 next-intl,这是正确的工具选择。但实现中有一个致命的捷径嵌入在数十个页面文件中:isZh 模式。

页面没有使用 useTranslations() 或 getTranslations() 来获取本地化字符串,而是这样做的:

const isZh = locale === 'zh';
 
// 散布在每个渲染函数中:
<h2>{isZh ? '我们的机队' : 'Our Fleet'}</h2>
<p>{isZh ? '探索200+飞机' : 'Explore 200+ aircraft'}</p>

这对2种语言有效。对10种语言则完全崩溃。这个反模式嵌入在代码库的81个文件中:机队页面、目的地页面、航线页面、洞察页面、运营商页面、机场页面、博客页面、案例研究页面、预订组件和营销组件。

从4种语言扩展到10种语言,意味着我们必须先消灭 isZh——在所有地方,一次性完成。

第一步:基础设施——10语言管道

在触碰任何页面文件之前,我们先打好了地基:

路由与配置

  • i18n 配置:添加 ja、ko、ar、de、pt、ru 到语言数组。更新语言显示名称、文字方向(阿拉伯语 RTL)和日期格式偏好。
  • 路由:更新所有10种语言的路径模式。
  • 中间件:为6种新语言添加 Accept-Language 检测,让日本访客访问 / 时自动跳转到 /ja。
  • 语言切换器:重新设计以处理10个选项——国旗、母语脚本标签、按地区分组。
  • 内容类型:扩展 MultilingualText 以支持跨航线、目的地、机队、服务等的10语言字符串对象。

6个新翻译文件

每个语言 JSON 有约1,131个键,涵盖26+个命名空间。我们为日语、韩语、阿拉伯语、德语、葡萄牙语和俄语创建了完整翻译——不是机器翻译的占位符,而是尊重每种语言惯例的本地化字符串。

阿拉伯语需要特别注意:RTL 文字方向、正确的数字格式,以及没有直接阿拉伯语对应词的航空术语的文化适应。

提交:6eb909a — 61个文件,+5,504/-198行。当时 VOLO 历史上最大的单次提交。

第二步:isZh 大清洗——81个文件,一个反模式

这是最乏味但最重要的步骤。每一个 isZh 模式都必须替换为适当的 t('namespace.key') 调用。重构涉及:

类别重构文件数
机队页面和组件12个文件
洞察页面18个文件
航线页面和组件5个文件
目的地页面和组件5个文件
机场和 FBO 页面5个文件
博客和案例研究页面6个文件
运营商页面4个文件
预订和营销组件8个文件
空腿航班页面3个文件
工具和体验页面5个文件
翻译文件(10种语言)10个文件

每次替换的模式都是相同的:

// 之前(isZh 反模式):
const isZh = locale === 'zh';
<h2>{isZh ? '详细规格' : 'Detailed Specifications'}</h2>
 
// 之后(next-intl):
const t = await getTranslations('fleet');
<h2>{t('detailedSpecs')}</h2>

总计:81个文件变更,+5,130/-2,222行。我们添加了约300个新翻译键来支持之前硬编码的字符串。提交:daf0119。

第三步:224条航线 × 10种语言

航线的 i18n 有独特挑战。航线数据文件包含需要用每种语言的母语脚本渲染的城市名称对:

  • 英语:New York → London
  • 中文:纽约 → 伦敦
  • 日语:ニューヨーク → ロンドン
  • 韩语:뉴욕 → 런던
  • 阿拉伯语:نيويورك → لندن
  • 俄语:Нью-Йорк → Лондон

这不仅是翻译——是音译。日语使用片假名,韩语使用韩文字母,俄语使用西里尔字母,阿拉伯语使用阿拉伯文字。224条航线中的每条都有出发地和目的地城市,每个城市名需要10种语言变体。

我们更新了所有7个航线数据文件,为每条航线包含带本地化城市名的 MultilingualText 对象。即 224条航线 × 2个城市 × 10种语言 = 4,480个本地化城市名。

提交:01b8d4c — 7个文件,+5,757/-607行。

第四步:Core Web Vitals 优化

在 i18n 工作进行的同时,我们也解决了 Lighthouse 和真实用户指标标记的性能问题:

  • Hero LCP:为 Hero 主内容添加 fetchPriority="high"。React 19 原生支持 fetchPriority——不需要 @ts-expect-error。
  • 延迟加载:首屏以下的非关键组件(GlobalMapSection、EmptyLegsTeaser、案例研究卡片)现在在初次绘制后加载。
  • 自适应预加载器:VOLO 预加载器现在检测连接速度,在慢连接上跳过动画。
  • 服务端地图数据:GlobalMapSection 之前在客户端获取航线坐标。我们将数据计算移到服务端组件,消除了布局偏移。

在同一个过程中,我们将航线目录从约100条扩展到224条,包含完整的双语描述(中英文)、真实 ICAO 代码和市场化定价。

提交:cf8256d — 11个文件,+8,258/-68行。

第五步:Agent 模式 i18n + 无障碍打磨

面向 AI Agent 的 UI(e2b.dev 风格设计)之前完全是英文的。有了10种语言支持后,我们国际化了每个 Agent 组件,添加了95+个新 i18n 键。

无障碍修复

  • QuickQuoteModal:添加了 role="dialog"、aria-modal="true"、焦点陷阱和 Escape 键处理。
  • ExitIntentPopup:相同的模态框无障碍模式,加上 aria-live="polite"。
  • AirportAutocomplete:添加了 role="listbox"、aria-activedescendant、方向键导航和屏幕阅读器结果数量播报。
  • Header:修复了移动菜单、浮动 CTA 和弹窗之间的 z-index 堆叠冲突。

提交:3f57ec7 — 28个文件,+1,451/-265行。

第六步:Google Search Console 清理

  • robots.txt:移除了对 OpenGraph 和 Twitter 图片路由的过度封锁。
  • 联系页 canonical:为 GSC 标记为重复的联系页添加了专用 canonical URL。
  • 双重 locale 重定向:为 /en/en/... 类 URL 添加了 Next.js 重定向规则。

核心数据

指标之前之后
支持语言4 (en, zh, fr, es)10 (+ ja, ko, ar, de, pt, ru)
每语言翻译键~739~1,131
总翻译键~2,956~11,310
航线数~100224
本地化城市名对~4004,480
页面 × 语言86 × 4 = 34486 × 10 = 860
isZh 出现次数200+0
总变更文件—153
新增行数—27,184
删除行数—2,705

提交记录

两天内的8次提交:

  • 6eb909a — i18n: 从4种扩展到10种语言(61个文件,+5,504/-198)
  • a2e076c — i18n: 完成 ja、ko、de、pt、ru 翻译
  • 03c8829 — i18n: 完成阿拉伯语全部30个命名空间翻译
  • cf8256d — perf: Core Web Vitals 优化 + 航线扩展到224条(+8,258/-68)
  • 01b8d4c — i18n: 为224条航线添加8种语言城市名翻译(+5,757/-607)
  • daf0119 — i18n: 全面重构——移除 isZh 反模式(81个文件,+5,130/-2,222)
  • 3d5c681 — fix: 解决 Google Search Console 覆盖率验证问题
  • 3f57ec7 — ui/ux: 全面打磨——i18n、无障碍、视觉改进(28个文件,+1,451/-265)

经验教训

  • 尽早消灭反模式。isZh 快捷方式在只有2种语言时省了10分钟,在需要10种语言时花了我们几个小时。每个硬编码的三元表达式都是一种带复利的技术债。
  • 城市名比 UI 字符串难。翻译按钮标签很直接。把"圣保罗"音译成日语片假名(サンパウロ)需要翻译 API 有时会出错的文化知识。
  • RTL 不只是 dir="rtl"。阿拉伯语支持需要审查网站上每个 flex 容器、padding/margin 方向和文本对齐。
  • React 19 + next-intl v4 是好的技术栈。服务端组件使用 getTranslations() 意味着静态翻译字符串的 JavaScript 包大小为零。

下一步

  • 翻译质量审计——让母语使用者审查翻译
  • 每语言独立 SEO 元描述
  • RTL 布局测试——对阿拉伯语进行全面视觉 QA
  • Hreflang 验证——确保10种语言替代链接正确
  • 内容本地化——博客文章、案例研究、洞察报告多语言版本