前面几篇写 Parallel Translate,我讲过怎么把网页变成双语阅读视图,也讲过划词面板和查词接口。
这些都属于“把核心链路做出来”。
但真正上线之后,我发现另一个问题更耗时间:真实网页比测试页面复杂得多。
在自己准备的文章页里,正文结构清楚,样式干净,DOM 变化少。扩展开启后扫描正文、插入译文、关闭恢复,看起来都还不错。
可真实网页不是这样。它可能是 SPA,可能无限滚动,可能正文里混着广告、目录、代码块、推荐卡片,可能自己也有复杂浮层和 CSS。用户不是在一个理想 HTML 文件里阅读,而是在整个互联网里阅读。
所以浏览器翻译插件上线后,真正麻烦的不是“还能加什么功能”,而是“它能不能在更多普通网页上别添乱”。
先解释一下 SPA
SPA 是 Single Page Application,单页应用。
简单说,用户在页面里点击跳转时,浏览器不一定真的重新加载整个 HTML。网站可能只是用 JavaScript 改了地址栏,再局部更新页面内容。
对普通用户来说,这是一次页面跳转。对浏览器扩展来说,如果只在页面初次加载时扫描一次,就会出问题。
典型情况是:
打开文章 A -> 扩展扫描并翻译站内跳到文章 B -> 浏览器没有完整刷新 -> 扩展还以为自己在文章 A -> 翻译状态、正文范围、观察器都可能过期
所以后面我补了 URL 变化监听。它大概会覆盖这些情况:
history.pushStatehistory.replaceState- 浏览器前进后退
- hash 变化
这些都是现代网页常见的“地址变了,但页面没有完整刷新”的方式。
监听到 URL 变化后,不能简单继续在旧状态上工作。更稳的做法是先清理当前翻译状态,再延迟一点重新扫描。
URL 变化 -> 判断当前是否正在翻译 -> 刷新正文范围 -> 停掉旧的扫描状态 -> 等页面新内容稳定一点 -> 重新开启翻译
这里的延迟不需要很长。关键是给前端框架一点时间把新页面内容渲染出来。
页面翻译不能假设一次成功
刚开始做全文翻译时,我更关注的是怎么扫描正文、怎么插入译文。
上线后更常见的问题是:某几个段落失败了。
失败原因可能很多:
- 翻译服务临时失败
- 某个段落太长
- 网络波动
- 页面 DOM 在请求过程中被替换
- 用户切换了页面或关闭了翻译
如果一段失败就让整页状态变得很重,阅读体验会很差。用户不一定在意下面某一段暂时没翻出来,他更在意当前可读部分别被破坏。
所以我把失败处理改得更局部。
单个翻译单元失败后,只在这个位置显示错误和重试入口。用户点重试时,也只重试这一段,不重新扫描整页。
一个段落翻译失败 -> 当前段落显示失败状态 -> 提供重试 -> 重试时恢复 loading -> 成功后替换当前段落结果
这比“整页重新翻译”更符合用户预期,也减少了重复请求。
这里还有一个细节:请求回来时,要确认这个结果还适用于当前页面。因为用户可能已经跳转,原来的 DOM 节点也可能被网站替换。如果结果过期,就应该丢弃,而不是硬插回页面。
布局问题通常不是翻译问题
真实网页上的很多 bug,看起来像翻译问题,实际上是布局问题。
比如:
- 译文把正文撑得太宽
- 双语内容和原文行高不协调
- 某些站点的 CSS 把扩展插入的元素样式改掉
- 页面本身有
display: flex或 grid,插入译文后布局错位 - 代码块、表格、按钮被当成普通段落处理
这些问题不是换一个翻译模型能解决的。
前面我已经写过,浏览器双语阅读的关键是不要把网页当成纯文本。上线后我对这点更有体会:页面翻译更像是在别人的排版系统里临时插一层内容。
这要求插入的译文足够克制:
- 不抢宿主页面的全局样式
- 不依赖页面原有 CSS
- 尽量只影响自己的节点
- 行高、间距和颜色要保守
- 失败态和 loading 不能把布局顶得乱跳
有时候最好的修复不是“让译文更明显”,而是让它更不打扰。
站点规则是补丁,不是产品核心
真实网页适配到后面,很容易想维护一份站点规则库。
比如这个站点正文在 article,那个站点要排除侧栏,另一个站点动态加载评论区。为高频站点写规则确实有用,但我不想让产品依赖一大堆站点补丁。
我的理解是:
通用扫描规则 -> 负责大多数页面的基本可用站点规则 -> 只修正高价值页面的明显边界
站点规则可以处理正文范围、排除区域、特殊容器。但它不应该成为主要算法。否则每新增一个网站都要单独维护,最后会变成永远补不完的名单。
更好的方向还是让通用规则足够稳:
- 不处理隐藏元素
- 不处理输入框、按钮、脚本、样式
- 谨慎处理代码块
- 按可读文本单元翻译
- 对动态内容做节流扫描
- 关闭时能恢复原状
站点规则只在通用规则明显不够时再上。
触发器也要适配页面行为
这轮我还改了划词触发器。
以前我更偏向“选中文字后直接翻译”,但真实使用中并不是所有人都喜欢这种方式。有些用户只是想复制,有些用户只想临时查,有些网页自己也会处理 selection。
所以触发方式后来变成几个选项:
- 选中后显示小圆点
- 选中后显示图标
- 按快捷键触发
- 松开鼠标后直接打开
这里看起来是一个 UI 设置,实际也是网页适配问题。
因为不同页面对鼠标事件、选区变化、双击选词的处理不一样。有些页面会吞掉事件,有些页面在你点击扩展浮层时会清空选区。
为了让触发器更稳定,需要处理这些细节:
- 监听
selectionchange - 鼠标事件尽量在 capture 阶段拿到
- 点击扩展浮层时不要误判为用户取消选择
- 页面滚动后重新计算触发器位置
- resize 后避免按钮跑出视口
用户看到的是一个小圆点或小图标,但背后其实是在跟宿主页面抢一点点交互空间。
上线后才会遇到“旧状态”
本地开发时,我经常会清空数据、重新安装、重新加载扩展。这样很容易忽略一个事实:真实用户会带着旧配置升级。
功能迭代多了以后,browser.storage.local 里会留下各种历史字段。比如语言设置、Provider 配置、站点偏好、学习模式、划词触发方式。
如果每个地方都自己读 storage,再自己补默认值,迟早会出错。
所以我后来加了一层配置 schema 迁移。
schema 可以理解成配置结构的版本。扩展启动时,先把旧配置归一化成当前版本稳定结构,再交给 Popup、Options、Content Script 和 Background 使用。
这样做的好处是:
- 新增字段有统一默认值
- 旧字段可以迁移
- 不同入口读到的配置更一致
- 改设置时不容易把别的字段覆盖掉
这类工作不显眼,但对上线后的扩展很重要。因为用户的真实环境永远不会像开发环境那么干净。
别让缓存无限长大
真实使用以后,另一个问题是缓存。
翻译结果、查词结果、AI 句子详解、TTS 音频,这些都可以缓存。缓存能让体验更快,也能减少请求。
但缓存不是越多越好。
特别是 TTS 音频,它比普通文本大很多。如果不清理,IndexedDB 迟早会被撑大。
所以缓存要分两类:
用户资产 -> 生词本、查询历史、用户设置 -> 不能自动随便删可重建缓存 -> 翻译结果、词典结果、AI 解释、音频块 -> 可以按时间或容量清理
这个边界非常重要。
删除一条过期翻译缓存,用户下次最多重新请求。删除用户生词本,那就是数据丢失。
所以后台定期清理只应该碰可重建缓存。对音频这种大对象,还要加容量上限。
结语
浏览器翻译插件最开始做通时,成就感很强:选中页面、扫描正文、翻译、插入译文,一条链路跑起来了。
但上线后真正花时间的,是各种没那么漂亮的问题:
- SPA 路由变了,状态要重建
- 某段翻译失败,只能局部重试
- 某个站点布局特殊,译文不能撑坏页面
- 划词触发器要和宿主页面事件共存
- 用户带着旧配置升级
- 缓存要快,但不能无限长大
这些都不是“再加一个 AI 功能”能解决的。
做到后面我越来越觉得,浏览器扩展的难点不在它能不能控制网页,而在它能不能克制地进入网页,然后稳定地退出。
真实网页不会配合我,用户也不应该为网页差异买单。
所以后续迭代里,我更愿意把时间花在这些看起来琐碎的适配上。它们不一定适合写在功能介绍里,但它们决定了一个阅读工具能不能被长期打开。