昨天线上出了个奇怪的Bug页面偶尔会出现组件状态错乱的问题,而且只在特定的操作路径下才会复现本地怎么都复现不了排查了整整一夜才找到根因,竟然是React 16的一个新特性导致的。
今天想把这次排查的过程和根因记录下来分享给大家也提醒一下用React 16新特性的朋友有,些坑不踩过真的不知道希望这篇文章能帮大家避免类似的问题。
一、故障发生:奇怪的状态错乱
事情是这样的昨天下午我们刚上线了一个新版本主要是重构了商品详情页的代码用了一些React 16的新特性,比如FragmentContext API还有新的生命周期函数getDerivedStateFromProps替代了以前的componentWillReceiveProps因为React 16已经把componentWillReceiveProps标记为unsafe了以后会废弃,所以我们趁这次重构就一起改了。
上线后一开始没什么问题监控指标都正常我们也没太在意到了晚上八九点用户高峰的时候,客服开始收到用户反馈说商品详情页有时候会显示错误的商品信息,比如点了商品A进去显示的却是商品B的图片和价格,但是刷新一下又好了,而且不是必现是偶尔出现概率不高大概几十分之一,但是,因为用户量大还是有不少用户遇到了影响了体验。
我们收到反馈后赶紧开始排查首先,怀疑是后端接口的问题是不是接口返回了错误的数据,但是查了后端的日志和监控接口返回的数据都是对的没有问题,而且用户刷新一下就好了说明后端数据没问题应该是前端的问题组件状态错乱了。
然后我们就开始在本地复现这个问题,但是奇怪的是本地怎么都复现不了不管怎么操作都是正常的线上却偶尔会出现这就很头疼了不能稳定复现的Bug最难排查我们试了各种操作路径快速切换商品来回跳转清缓存用不同浏览器等等都复现不了时间一点点过去已经晚上十点多了问题还没找到用户反馈还在持续我们都有点着急了。
二、排查过程:一步步缩小范围
既然本地复现不了我们就换了思路先从代码层面分析看看这次重构改了哪些地方可能会导致状态错乱我们把这次重构的代码diff拉出来一行一行看重点看商品详情页的组件代码。
商品详情页的结构大概是这样的最外层是一个ProductDetail组件接收商品ID作为参数,然后里面有几个子组件ProductImage(商品图片)ProductInfo(商品信息价格标题等)ProductSpec(商品规格选择)等等之前,的代码是用componentWillReceiveProps来监听商品ID的变化当商品ID变了的时候,重新拉取商品数据更新组件状态这次重构的时候,我们把componentWillReceiveProps改成了getDerivedStateFromProps因为React 16推荐用这个新的生命周期函数替代componentWillReceiveProps。
getDerivedStateFromProps这个生命周期函数是React 16.3引入的用来替代componentWillReceiveProps和componentWillMount它是一个静态方法接收nextProps和prevState两个参数返回一个对象来更新state或者返回null表示不需要更新它的调用时机是每次渲染之前,不管props有没有变化都会调用这一点和componentWillReceiveProps不同componentWillReceiveProps只有props变化的时候,才会调用而getDerivedStateFromProps每次渲染前都会调用这是一个很重要的区别也是后来我们发现的问题的关键,但是当时我们还没意识到这一点。
我们先看了getDerivedStateFromProps的代码大概是这样的(简化版):
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.productId !== prevState.productId) {
return {
productId: nextProps.productId,
product: null,
loading: true,
};
}
return null;
}看起来逻辑是对的判断nextProps的productId和prevState的productId是否相同不同的话,就更新state重置商品数据设置loading状态,然后在componentDidUpdate里监听productId变化重新拉取数据看起来没什么问题啊和官方文档的示例也差不多为什么会出问题呢?
我们又看了其他改动的地方,比如用了Fragment替代了外层的div这个应该不会导致状态错乱用了新的Context API替代了旧的context这个也不太可能导致状态错乱我们还看了其他子组件的代码也没发现什么明显的问题。
这时候已经凌晨一点多了我们还是没找到问题大家都有点疲惫了,但是线上问题还没解决不能休息我们决定换个思路,既然本地复现不了那就在线上加日志看看能不能抓到问题发生时的状态我们在商品详情页的几个关键地方加了console.log打印props和state的变化,然后部署到线上开始抓日志。
等了大概半个多小时终于有用户复现了这个问题我们赶紧看日志这一看就发现了不对劲的地方日志显示有一次渲染的时候getDerivedStateFromProps返回了新的state把product重置成了null但是那一次props的productId其实没有变化和prevState的productId是一样的按道理应该返回null不更新state才对,但是它却返回了新的state这就奇怪了为什么判断nextProps.productId !== prevState.productId会成立呢?它们明明是一样的啊?
我们仔细看了日志打印的值发现nextProps.productId是字符串类型的"12345"而prevState.productId是数字类型的12345它们的值看起来一样,但是类型不同一个是string一个是number而我们的判断用的是!==严格相等,所以"12345" !== 12345是true所以判断成立返回了新的state把product重置了这就是问题的直接原因!
但是为什么props的productId会变成字符串而state里的是数字呢?而且为什么本地复现不了线上偶尔才出现?我们继续往下查发现productId是从URL参数里取的用的是react-router的match.params.productId而URL参数默认都是字符串类型的,所以props的productId本来就是字符串那为什么state里的会变成数字呢?
哦我们想起来了在componentDidMount里第一次拉取商品数据的时候,我们做了一个操作把productId转成了数字类型存到了state里,因为后端接口要求传数字类型的商品ID所以我们在调用接口的时候,用Number(productId)转了一下,然后把转后的数字存到了state里大概是这样的:
componentDidMount() {
const productId = Number(this.props.productId);
this.setState({ productId });
this.fetchProduct(productId);
}这样state里的productId就变成了数字类型而props里的productId还是字符串类型,所以在getDerivedStateFromProps里比较的时候,用严格相等!==就会认为它们不相等,然后错误地重置state但是等等那为什么不是每次都出问题而是偶尔才出现呢?按道理每次渲染getDerivedStateFromProps都会调用每次都会比较都会发现类型不同都会重置state啊应该每次都出问题才对为什么只是偶尔?
这就涉及到getDerivedStateFromProps的调用时机和React 16的Fiber架构了我们后来才搞明白这其中的细节下面详细说。
三、根因:getDerivedStateFromProps + Fiber + 类型不一致
找到直接原因后我们继续深挖为什么这个问题只是偶尔出现而不是必现这就和React 16的Fiber架构以及,getDerivedStateFromProps的调用时机有关了。
首先,回顾一下getDerivedStateFromProps的调用时机它是在每次渲染之前,调用的包括首次渲染和后续的更新渲染,但是有一个重要的点getDerivedStateFromProps返回的state更新是合并到当前的state里的,而且它不会触发额外的渲染它是在当前渲染过程中同步处理的。
那为什么我们的问题只是偶尔出现呢?关键在于React 16的Fiber架构的渲染机制Fiber是React 16引入的新的核心架构它把渲染过程拆成了可中断的小任务支持异步渲染和优先级调度在Fiber架构下组件的渲染过程可能会被中断,然后重新开始,也就是说一个组件的render函数可能会被调用多次在一次更新过程中,因为任务被中断后重新执行这在React 15的同步渲染架构下是不会发生的,但是在React 16的Fiber架构下是可能的特别是在线上用户操作频繁系统负载高的时候,渲染任务更容易被中断和重新执行这就是为什么本地复现不了(本地负载低渲染很少被中断)而线上偶尔会出现(线上负载高渲染偶尔被中断重新执行)。
那渲染被中断重新执行和我们的Bug有什么关系呢?关系很大我们来梳理一下整个过程:
- 用户进入商品详情页productId是"12345"(字符串)。
- 首次渲染getDerivedStateFromProps调用这时候prevState.productId是undefined(初始state)所以nextProps.productId !== prevState.productId成立返回新state把productId设为"12345"(字符串)product设为nullloading设为true。
- render执行显示loading状态。
- componentDidMount调用我们在里面做了Number(this.props.productId)把productId转成数字12345然后setState({ productId: 12345 })同时拉取商品数据。
- 这时候触发一次更新渲染getDerivedStateFromProps调用nextProps.productId是"12345"(字符串)prevState.productId是12345(数字,因为componentDidMount里setState改成数字了)"12345" !== 12345成立,所以返回新state把productId设为"12345"(字符串)product重置为nullloading设为true。
- 但是这时候componentDidMount里的fetchProduct可能已经拉取完数据了返回了商品A的数据,然后调用setState({ product: productA, loading: false })。
- 这时候就有两个state更新在排队一个是getDerivedStateFromProps返回的重置state(product=null, loading=true, productId="12345")另一个是fetchProduct完成后的setState(product=productA, loading=false)。
- 在React 15的同步渲染下这两个更新会按顺序合并处理先处理getDerivedStateFromProps的重置再处理fetchProduct的更新最终state是product=productA, loading=false没问题,而且componentDidUpdate里会检测到productId变化重新拉取数据最终也会正确。
- 但是在React 16的Fiber架构下渲染过程可能被中断重新执行这时候getDerivedStateFromProps可能会被调用多次,而且state的合并顺序可能会出现意想不到的情况特别是当getDerivedStateFromProps错误地返回了重置state而fetchProduct的更新已经在队列里的时候,可能会出现state被错误重置后没有被正确的数据覆盖的情况导致product是null或者是上一个商品的数据这就是用户看到的状态错乱显示错误的商品信息。
而且,因为Fiber的渲染中断和重新执行是和系统负载任务优先级等因素相关的不是每次都会发生,所以这个Bug只是偶尔出现本地负载低很少触发渲染中断,所以复现不了线上负载高偶尔触发渲染中断就会出现问题这就是整个Bug的根因总结一下就是三个因素叠加导致的:
- 类型不一致:props.productId是字符串state.productId被我们改成了数字用严格相等比较导致误判。
- getDerivedStateFromProps的特性:每次渲染前都调用,而且在Fiber下可能多次调用错误的判断会导致state被错误重置。
- React 16 Fiber架构:渲染可中断可重新执行导致state更新顺序出现意外情况放大了前面两个问题的影响。
这三个因素缺一个都不会导致这个Bug如果类型一致就不会误判,如果用的是componentWillReceiveProps(只有props变化才调用)也不会每次都比较,如果是React 15同步渲染也不会出现渲染中断导致的state顺序问题,但是三个因素刚好凑到一起了就产生了这个诡异的偶尔复现的Bug排查了一夜才找到根因真的是不容易。
四、修复方案
找到根因后修复就简单了我们用了几个措施来修复这个问题,并且避免以后再出类似的问题。
修复1:统一类型props和state的productId都用字符串
最直接的修复就是把state里的productId也改成字符串和props保持一致不要在componentDidMount里转成数字存到state调用后端接口的时候,再临时转一下就好了这样getDerivedStateFromProps里比较的时候,类型一致就不会误判了。
componentDidMount() {
const productId = this.props.productId; // 保持字符串不转数字
this.setState({ productId });
this.fetchProduct(Number(productId)); // 调用接口时临时转
}这样state.productId和props.productId都是字符串比较的时候,就不会,因为类型不同而误判了。
修复2:getDerivedStateFromProps里用宽松比较,或者转成同一类型再比较
即使类型统一了为了保险我们在getDerivedStateFromProps里比较的时候,也做了类型转换确保比较的是同一类型的值,比如都转成字符串,或者都转成数字再比较避免,因为类型问题导致误判。
static getDerivedStateFromProps(nextProps, prevState) {
if (String(nextProps.productId) !== String(prevState.productId)) {
return {
productId: nextProps.productId,
product: null,
loading: true,
};
}
return null;
}这样,不管两边是什么类型都转成字符串再比较就不会,因为类型不同而误判了更安全。
修复3:避免在getDerivedStateFromProps里做复杂逻辑保持它的纯粹性
这次问题也给我们提了个醒getDerivedStateFromProps应该保持简单和纯粹不要在里面做复杂的逻辑它的职责就是根据props更新state而且,因为它每次渲染前都会调用在Fiber下可能多次调用,所以一定要保证它是纯函数没有副作用,而且判断条件要严谨不能有误判,否则就会导致各种诡异的问题我们也重新review了项目里所有用getDerivedStateFromProps的地方确保判断条件都严谨没有类型不一致的问题。
修复4:关键数据加key强制组件重新挂载
对于商品详情页这种根据ID展示不同内容的组件我们还用了一个更保险的方案就是给组件加key属性值为productId这样当productId变化的时候React会认为是不同的组件会卸载旧组件挂载新组件而不是在同一个组件上更新props这样就完全避免了组件状态错乱的问题,因为每次商品ID变了都是一个全新的组件实例state都是初始的不会有旧状态残留的问题。
<ProductDetail key={productId} productId={productId} />这个方案,虽然会有一点性能损失(每次切换商品都要重新挂载组件)但是对于商品详情页这种场景来说性能影响不大,而且能彻底避免状态错乱的问题更安全我们最后也采用了这个方案作为双保险。
修复后我们部署到线上观察了一天没有再收到用户反馈状态错乱的问题监控指标也正常这个Bug终于解决了我们也松了一口气一夜的排查没有白费。
五、经验和教训
这次Bug排查了一夜,虽然辛苦,但是也学到了很多总结了一些经验和教训分享给大家希望能帮大家避免类似的问题。
1. 升级React版本和使用新特性时要充分了解变化和坑
React 16引入了很多新特性Fiber架构新生命周期函数Context APIFragment等等这些新特性带来了很多好处,但是也有一些变化和坑和React 15的行为不完全一样升级和使用的时候,一定要充分了解这些变化不能想,当然地以为和以前一样,比如getDerivedStateFromProps和componentWillReceiveProps的调用时机就不一样Fiber的渲染机制也和以前的同步渲染不同这些都可能导致意想不到的问题一定要仔细看官方文档和升级指南充分测试再上线。
2. 类型一致很重要特别是比较的时候
这次Bug的直接原因就是类型不一致字符串和数字用严格相等比较导致误判在JavaScript里类型是动态的很容易出现类型不一致的情况特别是从URL接口存储等地方取的数据类型可能和预期不一样,所以在比较的时候,一定要注意类型问题要么统一类型要么用宽松比较(==)要么转成同一类型再比较不要想,当然地以为值一样就相等特别是用严格相等(===!==)的时候,更要注意类型我们后来也在项目里加了ESLint规则检查可能的类型不一致比较尽量在编译阶段就发现问题。
3. getDerivedStateFromProps要慎用保持简单纯粹
getDerivedStateFromProps这个生命周期函数,虽然是React推荐的替代componentWillReceiveProps的方案,但是它也不是银弹有自己的特点和坑它每次渲染前都调用在Fiber下可能多次调用,所以一定要保持简单纯粹是纯函数没有副作用判断条件要严谨不能有误判,而且很多时候其实不需要用getDerivedStateFromProps可以用其他更简单的方案替代,比如完全受控组件,或者用key强制重新挂载等等不要为了用新特性而用新特性要根据场景选择最合适的方案。
4. 不能稳定复现的Bug要善用日志和线上抓包
这次Bug本地不能稳定复现排查起来很困难最后是靠在线上加日志抓到了问题发生时的状态才找到了线索,所以对于不能稳定复现的Bug不要死磕本地复现要善用日志和线上抓包在关键地方加日志打印关键变量和状态等问题发生时看日志找线索这往往比盲目地试更有效,当然加日志的时候,要注意不要打印敏感信息也不要影响性能加了之后,记得问题解决后去掉,或者改成debug级别。
5. 线上问题要及时止损再慢慢排查根因
这次问题发生后我们一开始就应该先止损,比如先回滚到上一个版本,或者先加个临时修复保证用户体验,然后再慢慢排查根因而不是一直在线上排查让用户持续受影响我们这次在这方面做得不够好排查了几个小时才想到先回滚止损导致用户受影响的时间比较长这是一个教训线上问题第一要务是止损恢复服务保证用户体验,然后再排查根因彻底修复不要为了排查根因而让用户持续受影响这是做线上服务的基本原则。
六、写在最后
以上就是这次线上React 16新特性Bug的排查过程和经验总结从下午收到用户反馈到凌晨找到根因再到修复验证整整搞了一夜,虽然辛苦,但是也收获很大对React 16的Fiber架构和新生命周期函数有了更深的理解也积累了排查诡异线上Bug的经验。
React 16是一个很大的版本升级Fiber架构带来了很多好处,比如更好的性能更流畅的用户体验支持异步渲染等等,但是也带来了一些行为的变化和潜在的坑特别是对于习惯了React 15同步渲染的开发者来说有些之前,没问题的代码在React 16下可能就会出问题,所以升级和使用新特性的时候,一定要谨慎充分了解变化做好测试不要想,当然。
最后用一句话结束这篇文章:"新特性带来新能力也带来新坑升级需谨慎测试要充分线上无小事每一个细节都可能导致大问题。"
愿大家的线上服务都能稳定运行少出Bug用户体验越来越好。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录