遇到问题
前段时间,我接手负责的某项目的产品同事,反馈了某用户遇到的2个页面问题
- 页面文字内容和其他人的不一样
- 审批流程处理后,提醒的待办数据没有刷新
第一个问题很奇怪,我一时想不到可能原因;第二个问题估计是代码存在bug,似乎更好解决一点。于是先着手在测试环境去复现第二个问题,可怎么也复现不了,看了代码,也确实在流程处理后刷新动作。一时陷入了困境的我又想着先试试第一个问题。
看着用户给的截图里不一样的页面文案,我突然意识到,这会不会是翻译造成的,于是赶紧在鼠标右键选择翻译成中文,果然,页面上不仅非中文的文字被翻译了,连原本是中文的一部分文字也被改了
翻译前
翻译后
可以看到部分中文被替换,而替换后的文案可能是乱的。
继续查看页面元素内容,发现原来dom根部的html标签的lang属性被设置为'en'(当初项目应该是用框架脚手架生成的,这种情况默认lang='en'),浏览器将页面当作了英文页面,而浏览器可设置自动翻译外文。后来询问得知,用户确实是开启了自动翻译英文页面,导致页面文字错乱。
找到原因就好办了。将html标签的lang属性改为'zh-CN',这样浏览器就不会自动翻译了。但如果用户手动强行翻译,页面上还是会有小部分文字被替换。考虑到目前这个系统并没有外文用户,在和产品商量后,又给html标签添加了translate="no",从根本上禁止了页面被翻译的情况。
接下来是第二个问题。细想一下,待办数据更新是我接手之前就有的功能,上线已经蛮久了,如果有问题,应该一早就有用户反馈。联想到翻译会更改文字,那会不会是更改了文字导致数据不刷新的?我赶紧撤回改动,开启翻译,第二个问题果然复现了。前端页面是用react写的,初步排查应该是和react相关。因为加了translate="no"已经避免了触发翻译的情况,时间关系当时先用这个办法一起解决了,后续经过一番研究,终于理清楚了内部的原因。
原因探究
chrome浏览器在翻译时都做了什么
已经知道问题是由chrome翻译引起,就得知道在翻译时,都对页面做了什么改动
使用vite+react创建一个最简单的项目,查看dom内容如下
html
<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>
启用翻译后,dom变为
html
<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这个改动。
复现错误
给页面添加一个再简单不过的计数器
jsx
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
通过下面的录屏可以发现,在翻译前都是正常的,在启动翻译后,第一个计数值显示仍然正常,第二个却不更新了
(双击放大重播git动画)
后来甚至发现,如果涉及到元素移除,页面还会报错。通过报错信息,可知是react-dom在试图通过父元素的removeChild移除指定node时发现指定node不是父元素的子元素。
jsx
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
(双击放大重播git动画)
源码分析
这里以最新的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,即使翻译后被包裹front改变改变了结构,也有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,页面也不会更新。
如何避免
- 避免在有多个子节点的节点内使用文本节点,应该在外包裹一层标签,变成标签节点。历史代码太多的话,可以考虑写插件来实现自动转换
less
<div>
<h1>Hello World !</h1>
{name} // bad
</div>
<div>
<h1>Hello World !</h1>
<span>{name}<span> // good
</div>
- 设置合适的lang值避免自动翻译,必要的时候还可以添加translate="no"属性
扩展
1.其他浏览器是否有同样的情况
目前发现edge浏览器也自带翻译,开启翻译后,更新文本节点正常,但移除文本节点同样会报错。edge翻译对dom的改变少得多,只会对文本节点包裹一层font标签。这里猜测更新能正常是因为edge翻译是在原文本节点基础上进行修改,这保留了dom和文本节点对象的映射关系。
2.vue3是否有同样的情况
vue3同样有翻译后页面不更新的情况,并且template写法比render写法问题少,但不存在删除文本节点报错的情况。和react的方式应该有差异,这里就不再分析源码了,各位可以自行研究。有趣的是,vue3结合edge浏览器,正好可以同时避免更新和删除这2个问题。