给 SaaS 产品加多语言支持,我踩了三个"看起来不可能错"的坑

Mar 22, 2026·1018 tokens·6 min read·
i18nnext.jsreactsaasengineering

一个看似简单的功能——给知识库加多语言——从数据库设计到上线,用了不到两小时。但真正花时间的不是写代码,而是修三个"怎么会错在这里"的 Bug。

这三个 Bug 有一个共同特点:它们在开发环境里完全正常,只在生产环境或真实设备上才暴露。如果你做的是需要多端适配的 Web 产品,这篇文章可能帮你省掉几小时的排查时间。

背景:为什么要做多语言

AgentBooks 是一个 AI Agent 知识库 SaaS。用户通过 API 上传文档,读者在前台浏览。当前所有文档都默认标记为英文,但实际上 80% 的内容是中文。

这带来两个问题:一是读者看到中英文混在一起,没法按语言筛选;二是 API 没有语言字段,后续做搜索优化和 SEO 都没有基础数据。

所以目标很明确:给每篇文档加一个语言标签,让前台能按语言切换显示

第一个决策:数据模型怎么选

多语言有两种常见方案:

方案 A:每种语言一个独立空间(GitBook 模式)。好处是隔离干净,坏处是管理成本翻倍——同一篇文章的中英文版本分散在两个地方,没有关联。

方案 B:同一篇文章存多个语言版本,用一个字段区分(Document360 模式)。好处是文章之间天然关联,坏处是数据模型稍复杂。

选了 B。原因是:知识库场景下,用户需要的是"同一篇文档的不同语言版本",而不是"两个独立的知识库"。用户切换语言时,期望看到同一篇文章的翻译,而不是跳到另一个网站。

具体实现上,没有建翻译关联表。直接用 (space_id, slug, locale) 三元组做唯一约束——同一个 slug 加不同的 locale,就是同一篇文章的不同语言版本。slug 本身就是天然的分组键,多一张表只是增加复杂度。

数据库迁移用手写 SQL 而非 ORM 的 migrate 命令。Prisma 的 migrate 在已有数据的生产库上,行为不总是可预测的(特别是涉及唯一约束变更时)。手写 ALTER TABLE ADD COLUMN IF NOT EXISTSADD CONSTRAINT IF NOT EXISTS,每一步都是幂等的,跑几遍都不会出错。

这个决策后来被验证是对的——迁移前后 10 个 space、17 篇文档零丢失。

第二个决策:语言怎么确定

API 上传文档时,是否强制要求传 locale 参数?

如果强制,会破坏向后兼容——所有现有的 API 调用都会报错。如果不传就默认英文,又会重蹈覆辙(大量中文内容被标记为英文)。

最终方案:不传时自动检测,传了就以传入值为准

语言检测的实现比想象中简单。对于中日韩文,不需要任何 NLP 库——直接数 Unicode 码点就够了:

  • U+4E00–U+9FFF 是 CJK 统一汉字
  • U+3040–U+309F 是平假名(日文特有)
  • U+AC00–U+D7AF 是谚文(韩文特有)

中文和日文都用汉字,但日文会夹杂平假名。所以先数 CJK 字符占比,再看有没有平假名——有就是日文,没有就是中文。

对于拉丁字母的语言(法、德、西、葡),用高频特征词匹配。比如出现 les, dans, est, pour 就是法语,出现 der, und, ist, nicht 就是德语。这不如专业 NLP 库精确,但对于"标题 + 正文前 500 字"的场景,够用了。

关键取舍:这里没有引入外部依赖。一个语言检测库可能更准确,但它会增加包体积、增加一个维护点、增加一个可能过时的依赖。对于知识库场景,Unicode 分析 + 特征词匹配的准确率在 95% 以上,而且用户随时可以手动修正。

然后,三个 Bug 来了

功能开发本身很顺利。API、前台、Dashboard 都在一个多小时内完成。但部署上线后,事情开始变得有趣。

Bug 1:整个页面样式崩了

用户反馈页面样式全坏。排查发现,HTML 里出现了 border-color: #zinc-800 这样的 CSS 声明。

#zinc-800 不是合法的 CSS 颜色值。它是 Tailwind CSS 的色彩 Token 名——zinc-800 对应的实际 hex 值是 #27272a。但不知道是什么时候,模板配置被存进数据库时,存的是 Token 名而不是 hex 值。

为什么之前没发现? 因为大部分页面直接用 Tailwind 类名渲染,浏览器能识别。只有通过模板系统用 style 属性内联渲染时,才会暴露这个问题。而模板系统恰好是最近才上线的功能。

修复:数据库里改正值,代码里加一个 fixColor() 防御函数。这个函数维护了一个 Tailwind Token → hex 的映射表,遇到无效值自动转换。防御性编程的价值在这:你不能假设数据库里的数据总是对的

Bug 2:手机上语言切换按钮点不动

最初用 CSS 的 group-hover 实现下拉菜单——鼠标悬停时展开。桌面端完美运行。手机上?什么也不会发生。

原因很简单:触屏设备没有 hover 事件。这是一个所有人都"知道"但很容易忘记的事实——特别是当你在桌面电脑上开发、用桌面浏览器测试时。

修复也简单:抽出一个 React 客户端组件,用 useState + onClick 实现。点击展开,再次点击或点击外部关闭。

Bug 3:修了 hover 之后,整个页面 500 了

这是三个 Bug 里最隐蔽的。

新的 LangSwitcher 组件是客户端组件('use client'),需要接收一个 URL 构建函数来生成各语言的链接。很自然地写了:

<LangSwitcher buildUrl={(locale) => `/?lang=${locale}`} />

桌面端正常(因为客户端 JS 加载后 hydrate 成功)。但在 SSR 阶段,这个函数需要通过 React Server Components 的序列化协议传递给客户端。

问题是:函数不能被序列化。

RSC 协议只能传递可序列化的数据——字符串、数字、数组、普通对象。传函数会导致序列化错误,但这个错误不会在 next build 时报出来,只在运行时触发。而且错误表现不是组件不渲染,而是整个页面返回 500。

修复:把函数改成字符串模板。

<LangSwitcher urlPattern="/?lang={locale}" />

组件内部自己做 urlPattern.replace('{locale}', loc)。数据可序列化,问题解决。

三个 Bug 的共同模式

回头看,这三个问题有一个共同规律:它们都发生在"边界"上

  • Bug 1:Tailwind 生态和原生 CSS 的边界
  • Bug 2:桌面端和移动端的边界
  • Bug 3:Server Component 和 Client Component 的边界

每当两个系统、两个环境、两种模式在某个点交汇时,就是 Bug 最容易出现的地方。不是因为两边各自有问题,而是关于"另一边"的假设往往是错的

可复用的原则

1. 防御性地处理来自数据库的值。 代码可以信任自己的常量,但不能信任存储层的数据。加一个转换层或验证层,成本很低,收益在意想不到的时候出现。

2. 移动端测试不能只缩小窗口。 resize 浏览器窗口只改变了视口大小,没有改变交互模型。hover、touch、virtual keyboard、safe area——这些都是窗口大小无法模拟的。

3. RSC 边界是新的"前后端分离"。 在传统前后端分离中,大家已经习惯了"API 只传 JSON"。但 Next.js 的 Server Component 和 Client Component 之间的边界同样严格——只是它藏在同一个文件夹里,容易让人忘记。如果你向 Client Component 传 props,问自己:这个值能被 JSON.stringify 吗?

4. 增量 SQL 迁移比 ORM 迁移更可控。 在有数据的生产库上,手写 ALTER TABLE ... IF NOT EXISTS 比依赖 ORM 的迁移引擎更安全。你能精确控制每一步,而且每一步都是幂等的。

5. 功能开发只占 40% 的时间,调试和验证占 60%。 今天多语言的核心逻辑一个多小时写完,但修 Bug + 写自动化测试 + 验证多端表现,花了两倍的时间。如果一开始就假设"写完就能用",会低估真实的交付成本。

最后

多语言不是一个技术难题,而是一个工程质量问题。真正的挑战不是"怎么存 locale 字段",而是"怎么确保这个字段在所有路径上都被正确处理"——API、前台、Dashboard、SSR、CSR、移动端、SEO、fallback。

每多一个维度,边界条件就多一倍。大多数 Bug 不是逻辑错误,而是某条路径上忘了处理某个边界。

如果要给今天的工作做一个判断:功能设计没有问题,数据模型选择也经受住了验证。真正的失误是在第一次部署时没有跑多端测试。 三个 Bug 中的两个(hover 和 RSC 序列化)如果在本地用 Puppeteer 模拟移动端跑一遍,就能提前发现。

这个教训很简单:部署前的自测清单里,"手机端能用吗"应该是第一项,不是最后一项。