只有当读者翻译你的页面时才会出现的 React 漏洞
Chrome页面翻译功能会分离React文本节点,从而导致removeChild方法崩溃或程序无声卡住。在不同浏览器上的测试表明,何时通过修复视口问题能解决问题,以及何时在翻译器封装结构中进行操作更为安全。
第一个问题看似很简单。React计数器显示的数值已经过时,而周围的控件依然能正常响应。应用状态保存的是正确值,但显示的字符串却不是。针对那位访问者,Chrome启用了内置的翻译器。
一周后,同一个产品开始出现NotFoundError: Failed to execute 'removeChild' on 'Node'的错误,并自行拆解其根节点。机制相同,只是错误表现更为严重。
翻译器的运作方式
Chrome的翻译器从不直接修改原有文本节点。它会创建一个替代节点,将该替代节点包裹在<font>元素中,将其插入到原位置,同时将原有节点从文档的实时结构中移除。
原始节点仍然存在,React仍保留着对该节点的引用,只是该节点在文档中已不可见。
这种结构上的替换可以解释这两种故障模式。removeChild会抛出异常,因为React想要移除的节点已没有父节点。而赋值nodeValue虽然不会抛出异常,但却会改变那些已不可见的文本。
崩溃时会留下堆栈跟踪信息,而冻结的UI则不会留下任何有用的线索。计数器停止递增时看起来像是状态错误,这也是团队通常开始排查的地方。
大家都会复制的解决方案
2018年,shuhei在React问题追踪系统中描述了DOM冲突的问题。Dan Abramov将此问题标记为无法修复,但那次讨论中提出的临时解决方案至今仍是人们常用的复制粘贴式答案。该补丁会绕过removeChild和insertBefore,使得当节点并非目标父节点的子节点时它们能直接返回。
崩溃现象随之消失。
实时更新功能也随之消失了。
在某次翻译测试中,同一 React 示例在三种不同配置下进行了对比。未对组件结构进行保护时出现了两个未被捕获的异常,导致包括按钮在内的整个界面结构崩溃;而安装了相应的保护机制后,所有错误都消失了,但每次刷新时计数器都会停止更新,被删除的文字仍会显示在屏幕上,且用于切换状态的三元运算符会同时渲染出两种状态。
可见的故障被不可见的功能停滞所取代。
用测量代替猜测
我们放弃了论坛上的答案,转而采用直接观察的方法。对比了 Chrome、Edge、Firefox、Yandex 以及 Google 的独立翻译插件,同时使用了一个记录页面来抓取每个文本节点的快照,在人工启动翻译操作后暂停,随后进行了十六次检测和五项实验。计时测试则是通过 Playwright 在带有预设配置的真实 Chrome 实例上完成的。
第一个重要结论:该漏洞并非普遍存在。
在 Edge 和 Firefox 中,渲染引擎会在不分离文本节点的情况下重写它,因此后续的修改会一直保留。这两种浏览器中每秒触发一次的计数器最终上升到了 6 次。使用这些引擎的测试用户既没有遇到崩溃情况,也没有出现程序卡住的情况。这一事实驳斥了那些声称存在通用解决方案的人的观点,但事实确实如此,这也是为什么说明文档和演示页面都会提及这一点。
改变问题性质的发现
Chrome 的渲染引擎只处理当前显示的内容,而非整个页面。
针对处于空闲状态的渲染引擎,我们发送了十次唤醒尝试,同时还进行了隐蔽的控制操作:强制读取布局信息;模拟调整大小、获取焦点、改变可见性以及鼠标移动事件;滚动窗口来回移动;再加上浏览器自身发出的真实滚轮和指针活动信号。
只有 element.scrollIntoView() 能触发翻译,且需要在168毫秒后才能实现。其余九次尝试在整整十秒钟内都毫无反应。
两次失败的尝试实际上是真实的浏览器事件,这排除了“可信输入”是唯一触发因素的可能性。窗口级滚动同样失败了。被翻译的元素必须自行变为可见状态。
首个结论的错误之处
草案中有一个大胆的论断:一旦分离的文本节点被恢复,Chrome就再不会对其进行翻译。后来有一个测试似乎证实了这一点。测试运行后,该节点仍保持英文状态,于是这条结论就被写入了文档。
但那个测试其实并未被充分展示出来。
直到将视口规则写下来后,这一矛盾才显现出来:这两种说法无法同时成立。将探针移入可视范围并重复测试后,发现Chrome在210毫秒内就修复了被恢复的节点;而在屏幕外则无论等待多久都不会进行修复。
在一个以“测量优于假设”为论点的文档中,这一说法在两天内就已被证明是错误的。
随后又出现了两处遵循相同模式的错误。由于相关库从未加载,首次对比时与现有库的直接测试毫无意义。该库的代码末尾有//# sourceMappingURL=注释,而用于全局加载该库的代码行恰好被添加到了这个注释内部。现在每次测试都会在测量开始前明确显示每组代码实际上修复了哪些DOM方法。
Firefox的评估也被高估了。三次反向测试均显示正确数值,但其中两次以法语呈现,一次以英语呈现。在持续更新的情况下,原有引擎可能会出现延迟,从而短暂显示原始语言。
三次更正内容均保留在报告中,同时标明了替代内容。若隐藏修改痕迹,则要求读者信任未经核查的部分。
修正措施会给阅读者带来何种成本
一旦节点从树结构中消失,恢复方式有两种:要么恢复原始节点并等待译者发现后重新翻译;要么将新值插入译者已添加的包装结构中。
大多数现有库选择前者。虽然功能上可行,但阅读者仍需承担可见的成本。在四次更新、共五次重复测试中,每50毫秒抽取一次可见文本进行检测:
恢复操作在每次更新时需要100–150毫秒,而四步流程总共需500–600毫秒。向封装结构写入数据时,二十次更新中的每一次都无需0毫秒即可显示源语言文本。
单次闪光很容易被忽略,而实时计数器则会在每次跳动时都显示该闪光。
之前的草案称每次更新需要150–200毫秒,整个流程需700毫秒。这些数据仅来自一次测试,并未通过五次重复实验的验证,因此正式报告保留了全部五次测试的结果——单一的计时样本不能作为事实依据。
更优方法失效的情况
在已翻译的句子中插入新数字在荷兰语中可行,但在俄语中则可能破坏语法结构。
Intl.PluralRules('ru')将4归类为few,将7归类为many,名词词尾会随之变化。用俄语表达时,若四个灯泡使用few对应的词尾,那么当数量变为七个时就不能再保留该词尾。早期版本中就出现了这种在实现者无法理解的语言里发生的隐性错误。
当前的逻辑会在复数类别、数字长度或句子结构发生变化,或是语言环境未被识别时拒绝处理。当这种启发式方法失效时,界面会以未翻译的语言显示准确的数字。这种降级处理是刻意为之:用英语给出正确的数字,总比用团队中没人能校对的语言写出错误的词形要安全。
在荷兰语和德语中,Intl.PluralRules 对于所有整数都会返回 other,因此仅针对这些语言环境进行测试时不会出现复数规则相关的问题。
尚未测量的内容
Safari那一行是故意留空的。Playwright中的WebKit不包含翻译功能,而且自2012年以来就再也没有发布过Windows版本的Safari,因此无法实现自动化测试。要收集数据就需要一台实体Mac电脑以及能够手动忽略翻译提示的人。猜测结果反而比让该单元格保持空白更糟糕。
当录制过程从未检测到正在进行的翻译时,对应的分数会存储为 null 而不是 false。这种区别使得后续查看数据的人无法将没有翻译活动的测试结果视为该引擎无害的证据。
该库
配套的包是 npm 上的 translate-shield。当 Chrome 用 <font> 标签包裹文本节点时,该库会记录这一关系,并将后续 React 的写入操作重定向到该标签中,从而使页面始终显示访问者选择的语言。它没有运行时依赖,打包后的大小约为 15 kB。Edge 和 Firefox 的行为为无操作,因为这些浏览器从一开始就不会分离该节点。
该包既不负责字符串翻译,也不能替代 i18n 库。
交互式演示会将经过保护的文档与未受保护的对应文档放在一起,由访问者的浏览器同时对两者进行翻译。必须使用独立的文档:该补丁适用于整个文档,因此单个页面无法作为对照组。
此处引用的所有统计数据均有仓库中可重复运行的测试生成的 JSON 文件作为支撑,包括因先前错误而经过修正的测量值。
延伸阅读
交互式对比工具:https://google-translate-simulation.netlify.app/
包含原始探测记录的仓库:https://github.com/alievdavlat/translate-shield
已发布的包页面:https://www.npmjs.com/package/translate-shield