突然有一个海外用户反馈问题,说有一个页面点击新增按钮就白屏。对方不会说中文,所以全程英文交流,用上了我抠脚的6级哑巴英语,沟通过程稍微麻烦一点。一开始听到白屏,心里还是毫无波动的,这种问题呢,无非就是某个接口数据返回不太科学,然后前端没有容错。只需要看见报错信息必然可以秒解决
让用户打开控制台
先让用户刷新再复现一遍,保持一直打开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"
于是我拿到了用户整个页面所有的标签字符串集合,在我打开的页面的控制台下,和我的对比一下:
代码语言:javascript复制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标签是这样
代码语言:javascript复制<html class="client-js" lang="en" dir="ltr">
复制代码
点了两下下一步的时候,html标签发生了变化,核心特征:有translated-ltr类
代码语言:javascript复制<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玩坏
代码语言:javascript复制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包一下组件即可
代码语言:javascript复制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元素最外层存在文本节点
话不多说,看?
代码语言:javascript复制<div className="App">
<h1>Hello CodeSandbox</h1>
{show && (
<>
123123
<h2>Start editing to see some magic happen!</h2>
</>
)}
</div>
复制代码
这一块,有最外层的123123文本节点,所以翻译了会报错:
代码语言:javascript复制{show && (
<>
123123
<h2>Start editing to see some magic happen!</h2>
</>
)}
复制代码
为什么呢?先看看翻译后结果,发现原本想删的节点是"123123",而他父节点却再也找不到它了
代码语言:javascript复制{show && (
<>
<font><font>123123</font></font>
<h2><font><font>Start editing to see some magic happen!</font></font></h2>
</>
)}
复制代码
改正措施: 加上span标签,不要让123123裸露
代码语言:javascript复制{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,都不会出问题
再看一个?
代码语言:javascript复制 <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