出处:掘金

原作者:桂之風


遇到问题

开启浏览器翻译插件,页面可能:

  • 响应式数据不更新
  • 页面白屏报错

浏览器在翻译时做了什么

创建一个最简单的项目,DOM 内容如下:

<html lang="en">
  <head>
    <script type="module" src="/@vite/client"></script>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  </head>
  <body>
    <div id="root"><h1>Hello World !</h1></div>
    <script type="module" src="/src/main.jsx?t=1699602638956"></script>
  </body>
</html>

启用 Chrome 翻译后,DOM 变为:

<html lang="zh-CN" class="translated-ltr">
  <head>
    <script type="module" src="/@vite/client"></script>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link
      type="text/css"
      rel="stylesheet"
      charset="UTF-8"
      href="https://www.gstatic.com/_/translate_http/_/ss/k=translate_http.tr.qhDXWpKopYk.L.W.O/am=CAM/d=0/rs=AN8SPfqeKn8wA30q4viup18yaci8udUjKQ/m=el_main_css"
    />
  </head>
  <body>
    <div id="root">
      <h1><font style="vertical-align: inherit;"><font style="vertical-align: inherit;">你好世界 !</font></font></h1>
    </div>
    <script type="module" src="/src/main.jsx?t=1699602638956"></script>
    <div
      id="goog-gt-tt"
      class="VIpgJd-yAWNEb-L7lbkb skiptranslate"
      style="
        border-radius: 12px;
        margin: 0 0 0 -23px;
        padding: 0;
        font-family: 'Google Sans', Arial, sans-serif;
      "
      data-id=""
    >
      <div id="goog-gt-tt" class="VIpgJd-yAWNEb-L7lbkb skiptranslate" style="border-radius: 12px; margin: 0 0 0 -23px; padding: 0; font-family: 'Google Sans', Arial, sans-serif;" data-id=""><div id="goog-gt-vt" class="VIpgJd-yAWNEb-hvhgNd"><div class=" VIpgJd-yAWNEb-hvhgNd-l4eHX-i3jM8c"><img src="https://fonts.gstatic.com/s/i/productlogos/translate/v14/24px.svg" width="24" height="24" alt=""></div><div class=" VIpgJd-yAWNEb-hvhgNd-k77Iif-i3jM8c"><div class="VIpgJd-yAWNEb-hvhgNd-IuizWc" dir="ltr">原文</div><div id="goog-gt-original-text" class="VIpgJd-yAWNEb-nVMfcd-fmcmS VIpgJd-yAWNEb-hvhgNd-axAV1"></div></div><div class="VIpgJd-yAWNEb-hvhgNd-N7Eqid ltr"><div class="VIpgJd-yAWNEb-hvhgNd-N7Eqid-B7I4Od ltr" dir="ltr"><div class="VIpgJd-yAWNEb-hvhgNd-UTujCb">请对此翻译评分</div><div class="VIpgJd-yAWNEb-hvhgNd-eO9mKe">您的反馈将用于改进谷歌翻译</div></div><div class="VIpgJd-yAWNEb-hvhgNd-xgov5 ltr"><button id="goog-gt-thumbUpButton" type="button" class="VIpgJd-yAWNEb-hvhgNd-bgm6sf" title="翻译质量很棒" aria-label="翻译质量很棒" aria-pressed="false"><span id="goog-gt-thumbUpIcon"><svg width="24" height="24" viewBox="0 0 24 24" focusable="false" class="VIpgJd-yAWNEb-hvhgNd-THI6Vb NMm5M"><path d="M21 7h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 0S7.08 6.85 7 7H2v13h16c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73V9c0-1.1-.9-2-2-2zM7 18H4V9h3v9zm14-7l-3 7H9V8l4.34-4.34L12 9h9v2z"></path></svg></span><span id="goog-gt-thumbUpIconFilled"><svg width="24" height="24" viewBox="0 0 24 24" focusable="false" class="VIpgJd-yAWNEb-hvhgNd-THI6Vb NMm5M"><path d="M21 7h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 0S7.08 6.85 7 7v13h11c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73V9c0-1.1-.9-2-2-2zM5 7H1v13h4V7z"></path></svg></span></button><button id="goog-gt-thumbDownButton" type="button" class="VIpgJd-yAWNEb-hvhgNd-bgm6sf" title="翻译质量很差" aria-label="翻译质量很差" aria-pressed="false"><span id="goog-gt-thumbDownIcon"><svg width="24" height="24" viewBox="0 0 24 24" focusable="false" class="VIpgJd-yAWNEb-hvhgNd-THI6Vb NMm5M"><path d="M3 17h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 24s7.09-6.85 7.17-7h5V4H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2zM17 6h3v9h-3V6zM3 13l3-7h9v10l-4.34 4.34L12 15H3v-2z"></path></svg></span><span id="goog-gt-thumbDownIconFilled"><svg width="24" height="24" viewBox="0 0 24 24" focusable="false" class="VIpgJd-yAWNEb-hvhgNd-THI6Vb NMm5M"><path d="M3 17h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 24s7.09-6.85 7.17-7V4H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2zm16 0h4V4h-4v13z"></path></svg></span></button></div></div><div id="goog-gt-votingHiddenPane" class="VIpgJd-yAWNEb-hvhgNd-aXYTce"><form id="goog-gt-votingForm" action="//translate.googleapis.com/translate_voting?client=te_lib" method="post" target="votingFrame" class="VIpgJd-yAWNEb-hvhgNd-aXYTce"><input type="text" name="sl" id="goog-gt-votingInputSrcLang"><input type="text" name="tl" id="goog-gt-votingInputTrgLang"><input type="text" name="query" id="goog-gt-votingInputSrcText"><input type="text" name="gtrans" id="goog-gt-votingInputTrgText"><input type="text" name="vote" id="goog-gt-votingInputVote"></form><iframe name="votingFrame" frameborder="0"></iframe></div></div></div>
    </div>
  </body>
</html>

对比可以发现,翻译的改动有:

  1. <html> 的属性 lang 改为翻译后的语言代码 zh-CN,同时添加类名 translated-ltr
  2. 加载了一个 CSS 文件
  3. 翻译的文字被包裹了 2 层
  4. 最后面多了一块 id=goog-gt-tt 的 DOM 结构

简单研究后发现:

  • 4 是一段被隐藏的的让用户给翻译评分的结构
  • 而 2 的 CSS 仅用于设置 4 这段 DOM 的样式
  • 记得最早的时候,Chorme 的翻译是可以按段落显示原文的,这段 DOM 应该是那个功能的遗留产物
  • 真正对页面元素造成破坏的是 3 这个改动

复现错误

给页面添加一个再简单不过的计数器:

import { useState } from 'react'
function App() {
  const [count, setCount] = useState(1)
  return (
    <>
      <h1>Hello World !</h1>
      <p>{count}</p>
      {count}
      <div>
        <button onClick={() => setCount(count + 1)}>计数</button>
      </div>
    </>
  )
}
export default App

通过下面的录屏可以发现,在翻译前都是正常的,在启动翻译后,第一个计数值显示仍然正常,第二个却不更新了

如果涉及到元素移除和插入,页面还会报错。通过报错信息,可知是 react-dom 在试图通过父元素的 removeChild 移除指定 node 时发现指定 node 不是父元素的子元素

function App() {
  const [count, setCount] = useState(1)
  return (
    <div>
      <h1>Hello World !</h1>
      <p>{count}</p>
      {count}
      <br />
      {count < 3 && '不能超过3'}
      <div>
        <button onClick={() => setCount(count + 1)}>计数</button>
      </div>
    </div>
  )
}
export default App

源码分析

这里以最新的 react-dom@18 为例,通过查看其源码可以发现,react 在作 diff 时,会将某节点中被移除的节点放到其 fiber 对象的 deletions 属性中,然后在后续循环 deletions,最终调用 parentInstance.removeChild(child) 来移除节点

上面例子中,被移除的文字被包裹添加了 <font> 标签,导致 '不能超过3' 这个文本节点(text node)不再是原上层 <div> 的子节点,这时调用 parentInstance.removeChild(child) 也就因为找不到这子节点而报错了。如果给这个文本节点外包裹 <p> 标签,翻译添加的 <font> 标签在 <p> 标签内,<p> 标签依然是原父节点的子节点,DOM 更新就不会有问题

那么一开始的更新无效也是找不到子节点吗?并不相同。在更新操作中,react-dom 对标签节点和文本节点采用了 2 种不同的更新方式

对于标签节点是更其 children,即使翻译后被包裹 <font> 改变改变了结构,也有 node.textContent = text 兜底,直接把整个节点内容替换掉

所以如果查看 DOM 结构,可以发现翻译后的:

<p><font style="vertical-align: inherit;"><font style="vertical-align: inherit;">1</font></font></p>

在更新后先变成了 <p>2</p>,然后又很快被浏览器翻译转换为:

<p><font style="vertical-align: inherit;"><font style="vertical-align: inherit;">2</font></font></p>

而如果是文本节点,则是直接修改其 nodeValue

虽然此时文本节点的引用还在,但 DOM 结构被翻译改变,其对应的 DOM 已不再存在,即使改了 nodeValue,页面也不会更新

如何避免

  1. 避免在有多个子节点的节点内使用文本节点,应该在外包裹一层标签,变成标签节点。历史代码太多的话,可以考虑写插件来实现自动转换
<div>
  <h1>Hello World !</h1>
  {name}  // bad
</div>
 
 
<div>
  <h1>Hello World !</h1>
  <span>{name}<span>  // good
</div>
  1. 设置合适的 lang 值避免自动翻译,必要的时候还可以添加 translate="no" 属性和 class="notranslate" 特殊类名

扩展

其他浏览器是否有同样的情况

目前发现 Edge 浏览器也自带翻译,开启翻译后,更新文本节点正常,但移除文本节点同样会报错。Edge 翻译对 DOM 的改变少得多,只会对文本节点包裹一层 <font> 标签。这里猜测更新能正常是因为 Edge 翻译是在原文本节点基础上进行修改,这保留了 DOM 和文本节点对象的映射关系

Vue3 是否有同样的情况

Vue3 同样有翻译后页面不更新的情况,并且 template 写法比 render 写法问题少,但不存在删除文本节点报错的情况。和 React 的方式应该有差异,这里就不再分析源码了,各位可以自行研究。有趣的是,Vue3 结合 Edge 浏览器,正好可以同时避免更新和删除这 2 个问题