小伙伴遇到这个问题说不想干前端了——一次Chrome翻译造成的玄学bug

13,288 阅读12分钟

突然有一个海外用户反馈问题,说有一个页面点击新增按钮就白屏。对方不会说中文,所以全程英文交流,用上了我抠脚的6级哑巴英语,沟通过程稍微麻烦一点。一开始听到白屏,心里还是毫无波动的,这种问题呢,无非就是某个接口数据返回不太科学,然后前端没有容错。只需要看见报错信息必然可以秒解决

前排提示,现在掘金发文的时候有违禁词会发不出去。所以花了半小时发文章,反复使用二分法排除定位违禁词语,能发出去说明前面内容没问题,然后再加一点内容继续试。我还在发文章的时候,就看见200浏览了,给200个小伙伴道歉,那时候还在试敏感词中,文章内容不完整,现在已经好了,可以回头看了。例如"报错"有时候需要改成"错误"才能过、页面不能有emoji、"jiechi"也是违禁的(以后叫hijack吧)。这里强烈建议,给出违禁词清单、或者监测到违禁词的时候弹出来提示一下

让用户打开控制台

先让用户刷新再复现一遍,保持一直打开console的状态下操作。再手把手截图指导,如何打开console切到哪个面板,再让对方截图,结果是这样的报错:

这个就突然让我有点懵逼了,竟然不是 cannot read property xx of undefined 这种报错。细看一下,是react源码的报错:dispatch后setstate、触发批量更新、执行调度。估计是中途有其他操作把dom节点改了,react瞬间懵逼。即使知道大概是这样,但怎么排查呢?那就先直接来捞接口数据,放本地跑一下看看能不能复现吧

引导用户发response过来

经过一番抠脚英语交流和步骤截图,终于让用户把相关接口的返回数据都发过来了。拿到了数据,那就到我表演了。我本地开始跑dev,再把这些接口全部代理到刚刚拿到的数据上

结果,居然正常运行,一切问题都没有发生

接着我尝试看看对方的录屏,结果发现也没什么错误操作,唯独就是点一下按钮,就报错了,而且还是同样的react源码内部的报错,接口都正常。最后,决定让用户扫我电脑的码,在我电脑登录账号

在我电脑登上了别人的号,开始一顿操作,来到同样的页面,点一下按钮,结果又正常,什么都没有发生......小朋友,你是否有很多问号

远程桌面

实在没办法了,我直接视频通话打过去并要求屏幕分享。打通了,开始全程口语交流,抠脚的英语口语水平只能慢慢的讲,估计对方勉强听得懂吧。我重复了之前的操作,果然又出现了,来到同样的页面,点了按钮,马上报错了。还是一样的问题

于是开始打断点,随便操作了几下,居然自己好了!??

后面刷新页面,全都自然好了........

心累,暂时不管那么多了,没事就好了吧,事情就此为止。

"looks fine for now. Thank you so much!"

事情再次出现

过了几天,在愉快地写需求的时候,突然被机器人拉群,还是同样的人,还是同样的问题,只是不同的页面链接了。先别急着动手,捋一下思路:

  • react源码错误,必然是有react之外的原生dom操作
  • 确认过代码,没有任何其他原生dom操作
  • 对方在控制台做了dom操作?不可能,无技术背景
  • 那只能是浏览器插件、中间人注入(基本不可能优先级调最低)、翻译
  • 忘了上次打断点的事情吧,不能投机取巧

上次的经验告诉我,直接远程控制是最好的方法。于是马上连上了远程控制。检查了一下浏览器插件,没有什么插件有影响——浏览器插件pass。确认一下是否翻译,问了对方说有没有开了翻译,对方说没有(远程桌面看不见弹出菜单的,所以需要人家告诉我)

ok,人家说没有翻译,那我就假设这是实话。既然问题发生的根本原因就是有react之外的原生dom操作,那就是dom节点数很有可能不一样。于是我在控制台输入了一下?('*'),发现对方电脑上是2400个节点。在我电脑上输一下,只有2000个节点。让同事帮忙看看,一样也是2000个节点。于是我决定对比一下第一个不一样的节点是怎样的,在对方的电脑控制台上输了一段简单的脚本:

?('*').reduce((acc, { tagName }) => `${acc}${tagName},`, '')

我:"could you please copy the txt and send me"

于是我拿到了用户整个页面所有的标签字符串集合,在我打开的页面的控制台下,和我的对比一下:

var arr = otherHtml.split(',')
?('*').findIndex(({ tagName }, i) => tagName !== arr[i])

发现index为103,找到第103个节点,发现是一个link标签,引入了translate.googleapis.com下的一个css,而且html这个标签多了一个叫做translated-ltr的class。顾名思义,翻译实锤了

于是,再继续展开主内容,发现对方的页面上多了很多font标签!!

果然,还是开了翻译,只是人家“觉得没有开”。其实,很有可能是之前设置了一律翻译,所以后面就一直不用管,所有的网站都会自动翻译。接着让用户按照我的要求,将翻译关掉。最后,多次重复的操作,问题也没有出现了

其实,估计之前大家都是脚手架一把刷,并没有注意到html的lang的值,而且我们这个系统都是英文的。于是出现了一个所有的内容都是英文的“中文”页面,到了海外Chrome翻译的逻辑就是,这是“中文”页面,需要自动翻译,然后就“英文翻译成英文”,视觉上无变化,实际上dom节点已经多了很多font

<html lang="zh-cn">

为什么上次打断点就没事

于是我还是想看看为什么上次打断点就没事了,打开维基百科试一下,在开启了翻译的条件下打断点会发生什么。打开source面板,勾选了load事件

自动翻译也开启

刷新页面,发现一进来的时候,一切安好,html标签是这样

<html class="client-js" lang="en" dir="ltr">

点了两下下一步的时候,html标签发生了变化,核心特征:有translated-ltr类

<html class="client-js translated-ltr ve-not-available" lang="zh-CN" dir="ltr">

再看看element面板,很多font包裹

实际上这就是一个页面load成功后,Chrome的翻译功能去拉css和js回来、修改页面内容的过程。复盘一下上次能解决问题的断点操作:

  • 我在报错的发生前最后一个接口的返回打了断点,勾选了error事件的断点
  • 页面进来,有一个cors报错,error卡住。此时已经有请求出去了,断点卡一下争取到了时间(你看起来是pending,实际上response已经到你家门口了)
  • 再点下一步,前面的数据秒出,一瞬间又卡了,因为最后一个接口也回来了
  • 此时还没到拉翻译资源的时候,但页面已经展示完整。我点一下按钮,成功越过翻译导致的页面元素错乱。这是一个创建按钮,创建成功了后面就是用户自己操作了
  • 因为创建是频率稍微低一些的行为,所以几天内再无收到反馈
  • 出现问题通常是setstate后删掉某个元素,那个元素追溯不到报错了。这里点了按钮的确是会删掉按钮并切换页面内容

看看react具体怎样才会报错

继续来作死,一起看看怎么样才能把react玩坏


const { useState, useLayoutEffect } = React;
export default function App() {
  useLayoutEffect(() => {
    const font = document.createElement("font");
    const app = document.querySelector(".App");
    // 制造font包裹的效果,模拟翻译的效果,破坏原有结构
    while (app.firstChild) {
      font.appendChild(app.firstChild);
    }
    app.appendChild(font);
    setTimeout(() => {
    // set个state看看
      setShow(false);
    }, 1000);
  }, []);
  const [show, setShow] = useState(true);
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      {show && (
        <>
          123123
          <h2>Start editing to see some magic happen!</h2>
        </>
      )}
    </div>
  );
}

预期效果出现了:

其实也不需要手动改,你只需要右键开启翻译为中文就可以复现了。问题根源在于react提前把parentNode存起来了,所以操作的时候找不到子节点

解决方法

错误边界组件

利用react的两个生命周期来感知翻译错误,然后展示兜底ui,提示用户关掉翻译。并给出操作文档链接。使用的时候只需要用TranslateErrorBoundary包一下组件即可

class TranslateErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { translateError: false };
  }

  static getDerivedStateFromError() {
    if (document.documentElement.classList.contains("translated-ltr")) {
      return { translateError: true };
    }
  }

  componentDidCatch(e, info) {
  // 上报翻译错误
    report(e, info);
  }

  render() {
    if (this.state.translateError) {
      return (
        <>
          <strong>
            translate error! you' d better to turn your google-translate off and reload. see
          </strong>
          <a
            target="_blank"
            rel="noopener noreferrer"
            href="文档链接"
          >
            文档
          </a>
        </>
      );
    }
    return this.props.children;
  }
}

// usage
<TranslateErrorBoundary>
    <Cpn />
</TranslateErrorBoundary>

不要让一块可删改的react元素最外层存在文本节点

话不多说,看🌰

<div className="App">
  <h1>Hello CodeSandbox</h1>
  {show && (
    <>
      123123
      <h2>Start editing to see some magic happen!</h2>
    </>
  )}
</div>

这一块,有最外层的123123文本节点,所以翻译了会报错:

{show && (
  <>
    123123
    <h2>Start editing to see some magic happen!</h2>
  </>
)}

为什么呢?先看看翻译后结果,发现原本想删的节点是"123123",而他父节点却再也找不到它了

{show && (
  <>
    <font><font>123123</font></font>
    <h2><font><font>Start editing to see some magic happen!</font></font></h2>
  </>
)}

改正措施: 加上span标签,不要让123123裸露

{show && (
  <>
    <span>123123</span>
    <h2>Start editing to see some magic happen!</h2>
  </>
  )
}
// 翻译后

{show && (
  <>
    <span><font><font>123123</font></font></span>
    <h2><font><font>Start editing to see some magic happen!</font></font></h2>
  </>
  )
}

因为最外层的是span,所以即使加了font,也是在span内部加了,删除元素的时候找的是span,都不会出问题

再看一个🌰

  <div>
    {label !== undefined ? (
      <div>
        {label}
      </div>
    ) : null}
    {children}
  </div>

这里的话,label就是纯文本。经过上面的例子,相信大家都知道{label}那里要套一个span了。但是这还是有风险:如果这个组件对外部使用,外部靠children传进来,意味着children的内容是多变的,比如传一个字符串进来,setstate后是一个其他节点,那么问题再次出现

错误条件再次重复一遍:一块可删改的react元素最外层存在文本节点。此时children是一块元素,而且是可变的,最外层就是children这个对象的最外层所有节点,其中存在一个文本节点是字符串,因此满足出错条件

例如children是文本节点textNode1,那么正常情况下setstate后如果children发生变化,删掉textNode1的方式就是textNode1ParentNode.removeChild(textNode1)。如果翻译了,文本节点包了两层font,那么textNode1再也不是textNode1ParentNode的子节点了。此外,即使把外层div换成span、section、article同理,都会出错

推论:不要在任何元素下直接裸露可变文本节点

代码都是自己写的,像props.children这种那么灵活的,尤其是要注意一下,如果是可能有文本节点的最好包一个span,确认没有的就可以不用包,防止外国用户翻译后源码出错。其实可以写一个工具,扫一下ast,发现有裸露文本节点的自动包一层span

要不,提个issue问问react那边可不可以不把parent节点先存起来,删元素的时候直接node.parentNode.removeChild?

总结

  • 使用数据驱动视图的框架如react、vue,如果遇到源码错误,考虑一下是不是有原生dom操作打乱了
  • 如果确认不是原生dom操作导致,考虑一下浏览器插件、翻译
  • 确实需要在react、vue中使用原生操作,需要考虑到这个隐患
  • 国际化的业务,如果出现这种问题,建议首先从浏览器翻译开始排查
  • 不要让一块可删改的react元素最外层存在文本节点,确认会有可变文本节点,需要套一层span

纯写需求写业务无聊?那一起搞事情鸭。关注公众号《不一样的前端》,以不一样的视角学习前端,快速成长,一起把玩最新的技术、探索各种黑科技