打开网易新闻 查看精彩图片

随着互联网的全球化进程不断推进,多语言支持成为了现代 Web 应用不可或缺的一部分。然而,在实际应用中,诸如 Google 翻译这样的机器翻译工具却带来了意想不到的问题。特别是对于基于 React 框架构建的应用程序而言,Google 翻译的实时翻译功能常常会导致 DOM 结构的变化,进而引发一系列崩溃和其他不可预见的问题。本文旨在全面剖析这一现象,揭示 Google 翻译与 React 应用之间的冲突本质,并探讨可能的解决方案。

原文链接:https://martijnhols.nl/gists/everything-about-google-translate-crashing-react

作者 | Martijn Hols

翻译 | 郑丽媛

出品 | CSDN(ID:CSDNnews)

作为 Google Chrome 的一项内置功能,Google 翻译为用户提供了一个便捷的网页翻译途径,用户可以在浏览器标签页中直接进行翻译。这样做的好处在于,无论用户的母语是什么,他们都可以无障碍地阅读网页。

然而,这种便利是有代价的,它会干扰许多现代网站的工作机制。问题的根源在于 Google 翻译会以某种方式操作 DOM(文档对象模型),从而破坏基础的应用程序。这种干扰通常表现为由 DOM 元素的原生 removeChild 方法引发的崩溃,产生诸如 “NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.” 之类的错误。尽管这类问题看起来很明显,但实际上还有更多不易察觉的影响。

本文将重点讨论 Google 翻译对 React 的干扰,但需要注意的是,这些问题并不仅限于 React。事实上,大多数机器翻译工具都会遇到类似情况,并且可能会干扰所有规模较大、结构复杂的 Web 应用。

在本文中,我们将探讨以下内容:

● Google 翻译的工作原理

● Google 翻译的干扰机制

● 浏览器扩展的一般干扰情况

● 对常规 JavaScript 代码的影响

● 可能的解决方法和替代方案

首先,让我们先了解一下 Google 翻译是如何工作的。

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

Google 翻译的工作原理

要了解 Google 翻译的工作方式,我们需要仔细比较翻译前后 DOM 结构的变化。

所有在浏览器中渲染的 HTML 都会通过 JavaScript 表现为 DOM(文档对象模型)。DOM 是一种类似树的结构,其中每个元素都是一个节点。HTML 元素被表示为 Element 节点,文本则表示为 TextNode。

让我们来看一段简单的 HTML 代码:

There are 4 lights! p>

在 JavaScript 中,这段 HTML 会以如下结构表示在 DOM 中:

打开网易新闻 查看精彩图片

当 Google 翻译启动时,它会寻找要翻译的 TextNode。然后,这些节点会被替换为包含翻译后文本的 FontElement 元素。例如,假设我们将其翻译成荷兰语,结果会变成以下 HTML:

Er zijn 4 lampen! font> p> 

更重要的是,DOM 结构也会随之改变:

打开网易新闻 查看精彩图片

这表明原始的 TextNode 被卸载,并被包含翻译后文本的新 FontElement 元素所取代。

这就是 Google 翻译对 DOM 产生影响的核心机制,也是为什么它会导致 JavaScript 应用在操作 DOM 时出现问题或干扰的重要原因。

模拟 Google 翻译

现在我们已经了解了 Google 翻译的工作原理,可以通过模拟它在页面某个部分的应用来复现其引发的问题,以此更轻松地观察到 Google 翻译造成的干扰。

下面的代码片段将查找一个 id 为“translateme”的元素,并将其所有直接的 TextNode 子节点替换为 FontElements,类似于 Google 翻译的操作方式。为了更直观地看出哪些文本受到了 Google 翻译模拟的影响,我们会将受影响的文本用方括号标记(例如,“There are 4 lights!” 会变成 “[There are 4 lights!]”)。

useEffect(() => {
document.getElementById('translateme').childNodes.forEach((child) => {
if (child.nodeType === Node.TEXT_NODE) {
const fontElem = document.createElement('font')
fontElem.textContent = `[${child.textContent}]`

child.parentElement.insertBefore(fontElem, child)
child.parentElement.removeChild(child)
}
})
})

下面的复现示例都使用了这种方法来模拟 Google 翻译的效果。

手动测试 Google 翻译

如果你想亲自验证 Google 翻译引起的问题,可以通过手动测试来实现。这将帮助你更好地理解 Google 翻译对应用程序的影响。

我发现测试 Google 翻译最简单的方法是将英语翻译成另一种语言。要让 Google Chrome 执行翻译,你需要在设置中更改首选语言,步骤如下所示。

打开网易新闻 查看精彩图片

接下来,访问你想要测试的网页。如果该网页设置正确(并且内容为英文),那么其 HTML 标签中应该包含 lang="en"。这使 Google 翻译能够可靠地检测语言并进行翻译。如果浏览器没有自动提示翻译,可以点击地址栏中的翻译图标手动触发翻译。

打开网易新闻 查看精彩图片

打开网易新闻 查看精彩图片

Google 翻译的干扰问题

现在我们已经了解了 Google 翻译是如何操作 DOM 的,接下来就可以深入探讨它所引发的干

问题 1:翻译后的文本未更新

当 Google 翻译卸载 DOM 节点并将其替换为新节点时,原始的 DOM 节点依然存在于内存中。此时,任何对原始 DOM 节点的更改都不会显示在用户的浏览器中,这些更改将仅保留在内存中。

对于像 React 这样使用虚拟 DOM(Virtual DOM)的系统来说,这绝对是个问题。React 使用虚拟 DOM 的主要原因之一是为了提高性能,其中一个关键点在于尽量只更新 DOM 节点的值,而不是直接替换它们,因为替换 DOM 节点的计算成本更高。

因此在 React 中,任何可能与其他字符串一起改变的文本或数字都会受到影响。一旦应用了 Google 翻译,页面上显示的值可能就再也无法更新。

对于显示重要数据的应用来说,这是一个严重的问题,特别是大型 React 应用。显示错误的数据可能会误导用户,甚至带来危险。例如,仪表盘显示错误的数字可能导致用户做出错误决策;应用程序显示无效价格可能引发法律问题;显示错误的药物剂量更可能会带来严重后果……这个问题的风险有多大,取决于你的应用类型和业务性质。

但这个问题很难被发现,因为它不会导致崩溃或任何明显的错误提示。

问题复现

在下面的复现示例中,我们有一个简单的计数器,它会跟踪灯光的数量(这个数字存储在 useState 中)。每次按下按钮时,灯光的数量都会加一。紧挨着按钮的标记标签显示内容只显示 "There are {lights} lights!" —— 没有任何额外的条件或逻辑。

我们使用前文描述的方法来模拟 Google 翻译,模拟翻译时会在文本周围添加方括号以表明它正在被翻译。按钮下方显示的绿色值是真实的状态值,这个值不受 Google 翻译的影响。

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

当你多次点击按钮时,你会发现状态确实在更新,组件也在重新渲染,但翻译后的文本始终没有更新,无法反映新的数值。

问题 2:崩溃

如果你正在使用如 Sentry 这样的错误监控工具,或者尝试过手动测试 Google 翻译,那么你可能已经见过这类问题。在 React 中,由于 Google 翻译的干扰,以下错误较为常见:

● NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

● Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.

当这些错误发生时,React 将会把你的组件树卸载到最近的错误边界。但如果网站上没有设置错误边界(这是很常见的情况),那么你的整个应用将会崩溃。

这些错误通常发生在你的应用尝试从 DOM 中删除一个条件渲染的节点,而实际上 Google 翻译已经卸载了该节点。另一个错误发生的频率较低,通常是因为某个条件渲染的内容试图出现在已被 Google 翻译卸载的节点之前。

我认为在很多情况下,这些崩溃可能都不如翻译文本未更新来得重要。文本未更新比什么都不显示更加不可预测,这可能会误导用户,结果可能比什么都不显示更糟糕。

问题复现

下面的按钮通过翻转 useState 中的布尔值来控制灯是否打开。当灯关闭时,文本 “There are 4 lights!” 将不再通过条件表达式 {lightsOn && 'There are 4 lights!'} 渲染。React 会尝试通过从父节点移除之前添加的 TextNode 来重新整合渲染。而当 Google 翻译启动时,TextNode 不再是父节点的子节点,从而导致崩溃。

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

要复现这个问题,条件渲染的 TextNode 需要有一个兄弟节点。在 React 中,几乎每个条件渲染的节点都会有兄弟节点,因此这是一个常见情况。

另一种复现该崩溃的方法是,通过三元表达式渲染不同数量的 TextNode。下面的复现示例同样是切换灯的状态,但当灯关闭时,会通过三元表达式渲染文本 “The lights are off”:{lightsOn ? <>There are {lights} lights! : <>The lights are off}。

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

复现的关键在于三元表达式两边渲染的 TextNode 数量不同。虽然看起来不明显,但在 <>There are {lights} lights! 表达式中,React 实际上生成了三个 TextNode。

当然了,这个复现只是你应用程序中可能出现的一个简化版本。在示例代码中,我们可以用单个模板字符串来处理三元表达式的两侧,但在现实情况下,这些表达式往往更为复杂,难以转化为模板字符串。

渲染不同数量 TextNode 的方式很多,我相信还有更多的方式可以复现这种崩溃,因此想要找到一个适用于所有情况的解决方法十分困难。

解决方案

关于 React 的崩溃问题,在 GitHub 上已有反馈。开发者们对此提出了一些解决方案,但很可惜,这些方案并不能快速修复问题,有些甚至可能会让问题变得更糟。

下面列出的解决方案仅针对崩溃问题,但对翻译文本未更新的问题毫无作用。

(1)猴子补丁(Monkey Patching)removeChild 和 insertBefore

React 核心团队成员 Gaearon 提出了一个解决方案,通过猴子补丁的方法对 removeChild 和 insertBefore 进行修改,使其在调用无效参数时静默失败。

虽然这个补丁成功阻止了崩溃,但并没有解决根本问题。当 React 试图通过 removeChild 删除一个 TextNode 时,虽然不会再崩溃,但什么也不会发生,翻译后的文本将继续保留在 DOM 中,直到其父节点被移除。而当 insertBefore 错误被触发时,新渲染的文本将不会出现在用户界面中。

除非用户自己意识到这种行为,否则这两个问题仍会使应用程序几乎无法正常使用,就像崩溃时一样。你可以观察这个猴子补丁的实际效果:

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

如果点击关闭 Google 翻译模拟,就能发现组件在没有干扰的情况下可正常开关灯:

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

(2)用 元素包裹 TextNode

GitHub 用户 Shuhei 也提出了一个解决方案,即将所有条件渲染的文本和相邻的文本都包裹在 元素中。通过确保 React 不直接尝试删除或插入 TextNode,这个方法可以避免某些崩溃。

这种方法可以修复一些最常见的崩溃问题,尤其是第一种复现场景中的条件渲染问题,比如 {lightsOn && 'There are 4 lights!'} 表达式引发的崩溃。然而,它并不能解决所有问题,特别是由三元表达式中的条件渲染 TextNode 引起的崩溃将依旧存在。

实施这个解决方案需要对现有的大量常规代码进行重构。如果没有 ESLint 规则来强制执行这一点,那么想要在整个团队中一致地应用这一解决方案将非常困难。坦白讲,对许多开发者来说,为此付出的努力和牺牲代码质量并不值得。

额外提示:ESLint 插件 eslint-plugin-sayari 中有一条规则,要求与其他元素共享同一父节点的 TextNode 必须包裹在 标签内。虽然这条规则可能会捕捉到一些问题表达式,但其误报率极高,几乎要求你把应用中的所有 TextNode 都包裹起来。此外,这条规则也无法解决三元表达式导致的崩溃问题。

(3)自我重新渲染的错误边界

GitHub 用户 Sorahn 提出了一个自我重新渲染的错误边界解决方案,即当遇到错误时,它会重新渲染相同的子组件。不过在这个过程中,子树中的任何组件都会丢失其状态。虽然这个方法可以解决某些场景中的问题,但也并不是通用解决方案。如果需要调整代码的话,将 TextNode 包裹在 标签中可能是更好的选择。

问题 3:不一致的 event.target

当 Google 翻译启动时,event.target 的值变得不可预测。用户可能会点击 Google 翻译的 font 元素,而不是开发者创建的、原本期望被点击的底层元素。在某些情况下,例如在覆盖层内部,这可能会导致点击事件无法正常工作。

虽然这个问题非常具体,且相对容易解决,但很少有开发者意识到这个问题,也不会主动测试它。

问题复现

在以下的复现示例中,按钮的文本会被 Google 翻译模拟器翻译。当你在复现区域点击时,event.target 的元素类型会显示在按钮下方的文本中。正常情况下,点击按钮时,event.target 应该指向按钮本身,但在 Google 翻译启动时,它会指向 font 元素:

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

若关闭 Google 翻译模拟后再点击按钮:

打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片
打开网易新闻 查看精彩图片

不仅仅是 React 的问题

Google 翻译的干扰不仅仅影响 React 应用程序,任何以类似方式操作 DOM 的 JavaScript 代码都会受到影响。这包括更新 TextNode 的值、添加或移除子节点,或使用 event.target 等操作,而这些操作并不仅限于 React。

只是这些问题在 React 应用中更为常见,因为 React 广泛使用“虚拟 DOM”。虚拟 DOM 会保留所有 DOM 节点的引用,这样只需更新实际发生变化的部分。这种方式比替换 DOM 节点更高效,故而使应用能够高效运行。因此,React 使用虚拟 DOM 来重用和更新节点,而不是频繁替换它们,这也是框架发展的自然趋势。

打开网易新闻 查看精彩图片

也不仅仅是 Google 翻译的问题

大多数机器翻译工具的工作方式与 Google 翻译类似,因此问题也并不仅限于它——事实上,任何操作 DOM 的浏览器扩展程序都可能引发干扰。以下是一些其他示例:

  • 密码管理器操作表单以显示预填充下拉菜单;

  • 显示竞争商店价格的扩展程序;

  • 广告拦截器移除某个页面元素;

  • AutocardAnywhere:为卡牌游戏显示卡片图片弹窗。

打开网易新闻 查看精彩图片

我想强调的是,Google 翻译的开发团队不应为此类问题受到责备。总体来说,这是一个非常优秀的工具,帮助了全球许多人,让更多人能够轻松使用网络。Google 翻译的架构设计,是在网络环境与今天大不相同的时代完成的,而这些问题的出现,实际上是随着网络技术的进步而产生的:如今许多热门网站已不再是单纯的静态网站,而是复杂的大型 Web 应用。

要彻底解决这些问题并非易事。为了实现高质量的翻译,Google 翻译通常需要对句子进行重构,以适应目标语言的语法——但这在不干扰 DOM 的情况下,几乎是不可能实现的。

打开网易新闻 查看精彩图片

目前还没有真正的解决方案

截至本文撰写时,还没有一个解决方案能让 Google 翻译在大型 React 应用中很好地协同工作。正如前面提到的,尽管有一些防止崩溃的临时解决办法,但这些方法也引入了新问题,使得任何复杂的应用在 Google 翻译后几乎都无法正常使用。

尽管如此,你还是可以采取一些措施,虽然你可能不会喜欢这些方法。

无奈的“修复”

当我在 2017 年第一次遇到这个问题时,曾在 React 的问题追踪器上发布了一个帖子,说我通过完全阻止翻译来“修复”了我的应用程序。如今,7 年过去了,我还是要遗憾地告诉大家,这似乎仍然是唯一可以快速避免所有 Google 翻译引发问题的方法。

我不喜欢这样解决问题,这会让应用对全球用户的可访问性降低。但对于某些复杂应用来说,这总比给 Google 翻译用户提供一个几乎无法使用的应用要好。

如果你愿意投入时间和精力,将条件渲染的 TextNode 包裹在 标签中,确实可以解决大部分崩溃问题(但并不能解决其他问题)。对于简单的网站而言,这通常已经足够,因为这些网站并不太依赖动态渲染,代码库较小,开发人员也少,并不会显示那些至关重要的计算数据。

你需要仔细考虑哪种解决方案最适合你的应用。保留 Google 翻译对一些用户来说将非常有帮助,但为了让它能正常工作并确保不会向用户展示错误数据,你还需要做一些调试工作。

替代方案

目前,最具可行性、能避免 Google 翻译干扰的替代方案是自行在应用中实现本地化(即国际化)。虽然这能使机器翻译变得不再必要,并为国际用户提供最优体验,但确实存在几个显著的缺点:

  • 高成本:实现国际化需要大量的工作量。

  • 复杂性:正确实现国际化并不容易。

  • 减缓开发速度:需要额外的时间来维护和支持多语言版本。

  • 高昂的翻译费用:好的翻译人员或专业翻译服务价格昂贵。

  • 语言覆盖范围有限:难以覆盖像 Google 翻译那样多的语言。

综上所述,对于大多数应用程序来说,这并不是最实用的解决方案。

打开网易新闻 查看精彩图片

结论

这就是 Google 翻译导致 React 应用(以及其他 Web 应用)崩溃的主要原因。或者更准确地说,这是第三方浏览器扩展对 DOM 的操作干扰了复杂 JavaScript 应用的响应机制,进而引发崩溃和其他问题。

我希望这篇文章能帮助你理解这些问题,并帮助你在自己的应用中选择合适的解决方案。