出处:掘金
原作者:桂之風
遇到问题
开启浏览器翻译插件,页面可能:
- 响应式数据不更新
- 页面白屏报错
浏览器在翻译时做了什么
创建一个最简单的项目,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>
对比可以发现,翻译的改动有:
- 将
<html>
的属性lang
改为翻译后的语言代码zh-CN
,同时添加类名translated-ltr
- 加载了一个 CSS 文件
- 翻译的文字被包裹了 2 层
- 最后面多了一块
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
,页面也不会更新
如何避免
- 避免在有多个子节点的节点内使用文本节点,应该在外包裹一层标签,变成标签节点。历史代码太多的话,可以考虑写插件来实现自动转换
<div>
<h1>Hello World !</h1>
{name} // bad
</div>
<div>
<h1>Hello World !</h1>
<span>{name}<span> // good
</div>
- 设置合适的
lang
值避免自动翻译,必要的时候还可以添加translate="no"
属性和class="notranslate"
特殊类名
扩展
其他浏览器是否有同样的情况
目前发现 Edge 浏览器也自带翻译,开启翻译后,更新文本节点正常,但移除文本节点同样会报错。Edge 翻译对 DOM 的改变少得多,只会对文本节点包裹一层 <font>
标签。这里猜测更新能正常是因为 Edge 翻译是在原文本节点基础上进行修改,这保留了 DOM 和文本节点对象的映射关系
Vue3 是否有同样的情况
Vue3 同样有翻译后页面不更新的情况,并且 template
写法比 render
写法问题少,但不存在删除文本节点报错的情况。和 React 的方式应该有差异,这里就不再分析源码了,各位可以自行研究。有趣的是,Vue3 结合 Edge 浏览器,正好可以同时避免更新和删除这 2 个问题