我们团队最近把项目从React 15升级到了React 16,本来以为是一次顺利的升级,结果上线后出了一次惊心动魄的线上故障。
React 16是React的一个重大版本更新,2017年9月发布,带来了很多新特性:Fiber架构、错误边界(Error Boundaries)、Portal、支持返回数组和字符串、更好的服务端渲染等。这些新特性,让React的性能更好,功能更强,开发体验也更好。
我们团队很早就关注React 16了,发布之后,我们就开始准备升级。经过一个月的开发和测试,我们觉得没问题了,就上线了。上线后,一切看起来都很正常,性能还有所提升,大家都很高兴。
但是,上线后的第三天,出问题了。
那天下午,用户反馈突然增多,说我们的网站打开是空白的,什么都没有。我们赶紧排查,发现是React 16的一个新特性——错误边界,在特定情况下导致了整个应用崩溃。整个排查和修复过程,惊心动魄,至今想起来还心有余悸。
今天,我想完整复盘这次故障,包括故障现象、排查过程、根本原因、解决方案,以及我们从中学到的经验教训。希望能给正在使用或者准备升级React 16的朋友一些参考,避免踩同样的坑。
一、故障现象
先说说故障现象。
那天下午两点多,我们的客服接到了很多用户反馈,说网站打开是空白的,刷新也没用,换浏览器也没用。客服把问题反馈给我们技术团队,我们赶紧开始排查。
我们自己打开网站,发现确实有问题:
- 网站打开后,页面是空白的,只有一个空的div,没有任何内容
- 控制台报错:"Minified React error #185; visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message"
- 不是所有用户都有问题,大概有30%的用户会遇到
- 问题和浏览器无关,Chrome、Firefox、Safari都有
- 问题和用户的操作路径有关,从首页进入没问题,但是从某些特定页面进入就会出现空白
更奇怪的是,我们在测试环境完全复现不了这个问题,测试环境一切正常。只有线上环境才有问题,而且不是必现,是概率性出现。
这时候,我们的监控系统也报警了,错误率从平时的0.1%飙升到了30%,而且还在上升。用户的反馈也越来越多,情况很紧急。
二、排查过程
发现问题后,我们立刻成立了临时排查小组,前端、后端、运维都有人参与,开始紧张的排查。
第一步:回滚?还是修复?
出了线上故障,第一反应是回滚。但是,我们的React 16升级,不仅仅是升级了React版本,还做了很多代码重构,用了很多React 16的新特性。如果回滚到React 15,需要把这些新特性的代码都改回去,工作量很大,而且回滚本身也有风险。
我们讨论了一下,决定先尝试定位问题,如果能快速定位并修复,就热修复;如果短时间内定位不了,再考虑回滚。
第二步:分析错误信息
我们先分析控制台的错误信息。错误是"Minified React error #185",我们去React的官方文档查了这个错误码,发现是"Maximum update depth exceeded",意思是"超过了最大更新深度",通常是因为在componentDidUpdate或者render里调用了setState,导致无限循环更新。
但是,我们的代码里并没有明显的无限循环更新的地方,而且测试环境也复现不了。这个错误信息,没有直接告诉我们问题出在哪里。
第三步:尝试复现
我们尝试在本地复现这个问题,但是一直复现不了。测试环境、预发布环境,都一切正常。只有线上环境有问题,而且是概率性的。
我们分析了一下,线上和测试环境的区别:
- 线上有真实的用户数据,测试环境是模拟数据
- 线上的代码是压缩过的,测试环境没有压缩
- 线上有CDN缓存,测试环境没有
- 线上的用户量很大,测试环境用户量小
我们怀疑和用户数据有关,就找了几个反馈问题的用户,用他们的账号在测试环境登录,但是还是复现不了。
第四步:线上调试
本地复现不了,我们只能去线上调试。我们在线上环境加了一些调试代码,收集更多的错误信息。
我们用React的错误边界(Error Boundary)功能,在应用的最外层包了一个错误边界,捕获所有的错误,并且把错误信息和组件栈打印出来。
加了错误边界之后,我们发现了一个重要的线索:错误不是发生在应用的最外层,而是发生在一个使用了Portal的弹窗组件里。而且,错误发生的时候,这个弹窗组件的父组件正在更新。
Portal是React 16的新特性,可以把子组件渲染到父组件DOM树之外的地方,比如渲染到document.body里,常用于弹窗、抽屉、提示框等组件。我们项目里的弹窗组件,就是用Portal实现的。
第五步:定位根本原因
有了这个线索,我们开始深入分析这个弹窗组件。
这个弹窗组件,用了Portal,把内容渲染到document.body里。同时,这个组件在componentDidUpdate里,会根据props的变化,调用setState来更新弹窗的位置和大小。
问题出在:当这个弹窗组件的父组件更新的时候,会触发弹窗组件的componentDidUpdate,然后调用setState。但是,因为用了Portal,弹窗的DOM在document.body里,不在父组件的DOM树里,所以React在处理更新的时候,出现了一个边界情况,导致了无限循环更新。
具体来说,是这样的:
- 父组件更新,触发子组件(弹窗)的componentDidUpdate
- componentDidUpdate里调用了setState,触发弹窗组件的更新
- 因为用了Portal,弹窗的DOM在document.body里,React在提交更新的时候,需要把弹窗的DOM从旧的位置移动到新的位置
- 这个移动操作,在特定情况下(比如父组件的DOM结构发生了变化),会触发父组件的重新渲染
- 父组件重新渲染,又触发子组件的componentDidUpdate,然后又调用setState
- 这样就形成了无限循环,直到React检测到超过最大更新深度,抛出错误,整个应用崩溃
为什么测试环境复现不了?因为测试环境的数据比较简单,父组件的DOM结构不会发生那种特定的变化。而线上有真实的用户数据,某些用户的数据会导致父组件的DOM结构发生那种变化,从而触发这个bug。所以,这个问题是概率性的,和用户数据有关。
第六步:临时修复
定位了根本原因之后,我们开始想解决方案。
最快速的临时修复方案,是把这个弹窗组件的Portal去掉,改回普通的渲染方式。这样虽然会有一些样式问题(弹窗的定位可能会受父组件的overflow影响),但是至少不会导致应用崩溃。
我们赶紧改了代码,去掉了Portal,然后热修复上线。上线之后,错误率立刻降了下来,从30%降到了0.1%以下,用户的反馈也少了。
这时候,距离故障发生,已经过去了两个多小时。虽然问题解决了,但是这两个多小时,对用户体验造成了很大的影响,我们也很愧疚。
三、根本原因分析
故障解决之后,我们做了深入的根本原因分析,想搞清楚为什么会出现这个问题,以及以后怎么避免。
根本原因一:对React 16新特性的理解不够深入
我们用了React 16的新特性(错误边界、Portal、Fiber等),但是对这些新特性的理解不够深入,特别是它们的边界情况和潜在问题。
比如Portal,我们只知道它可以把子组件渲染到父组件DOM树之外,觉得这个特性很适合做弹窗,就用了。但是,我们没有深入研究Portal在组件更新、DOM移动、生命周期等方面的行为,也没有考虑到Portal和componentDidUpdate里调用setState组合使用可能会有问题。
如果我们对Portal的理解更深入一些,可能就会发现这个潜在的问题,在上线之前就避免了。
根本原因二:测试覆盖不够充分
我们的测试,主要是功能测试,覆盖了主要的业务流程,但是没有覆盖所有的边界情况,特别是和用户数据相关的边界情况。
这个bug,只有在特定的用户数据下才会触发,而我们的测试数据都是模拟的、简单的数据,没有覆盖到那些复杂的、真实的用户数据。所以,测试环境一直复现不了这个问题。
如果我们的测试数据更丰富一些,或者做了一些基于真实用户数据的测试,可能就会在上线之前发现这个问题。
根本原因三:没有做灰度发布
我们的React 16升级,是全量上线的,没有做灰度发布。上线之后,所有用户都用了新版本,一旦出问题,影响范围就是全部用户。
如果我们做了灰度发布,先让一小部分用户用新版本,观察一段时间,没问题再逐步扩大范围,那么这个故障的影响范围就会小很多,也能更早发现问题。
根本原因四:错误边界的使用不够合理
React 16的错误边界(Error Boundaries),是一个很好的特性,可以捕获子组件树里的错误,避免整个应用崩溃。但是,我们对错误边界的使用不够合理。
我们最开始,只在应用的最外层包了一个错误边界,觉得这样就能捕获所有错误了。但是,实际上,错误边界只能捕获渲染过程中的错误,不能捕获事件处理器、异步代码、服务端渲染等场景的错误。而且,最外层的错误边界,如果捕获到错误,整个应用都会显示错误UI,用户体验也不好。
更合理的做法是,在多个层级使用错误边界,比如每个路由、每个大的模块、每个重要的组件,都包一个错误边界。这样,某个组件出错了,只会影响那个组件,不会影响整个应用。
而且,我们这次的故障,错误边界其实没有起到作用,因为那个无限循环更新的错误,是在React提交更新的时候抛出的,错误边界捕获不到,直接导致了整个应用崩溃。
四、最终解决方案
临时修复之后,我们又做了更彻底的修复和优化。
方案一:修复弹窗组件的Portal问题
我们深入研究了Portal的行为,找到了问题的根源:在componentDidUpdate里调用setState,结合Portal的DOM移动,会导致无限循环。
我们的修复方案是:
- 把弹窗的位置和大小计算,从componentDidUpdate移到render里,或者用CSS来实现,避免在componentDidUpdate里调用setState
- 如果确实需要在componentDidUpdate里调用setState,加上条件判断,只有当props真正发生变化的时候才调用,避免不必要的更新
- 用React的新特性getDerivedStateFromProps替代componentWillReceiveProps,减少生命周期的复杂度
- 对Portal的使用做更严格的code review,确保不会出现类似的问题
修复之后,我们又用了Portal,因为Portal确实是实现弹窗的最佳方式,只要用对了,就不会有问题。
方案二:完善错误边界的使用
我们完善了错误边界的使用,在多个层级使用错误边界:
- 应用最外层:一个全局的错误边界,捕获所有未被捕获的错误,显示友好的错误页面
- 每个路由:一个路由级的错误边界,某个路由出错了,只显示那个路由的错误UI,不影响其他路由
- 重要的模块:比如弹窗、表单、数据列表等,都包一个错误边界,某个模块出错了,只影响那个模块
- 错误边界的UI:友好的错误提示,加上"重试"按钮,用户可以点击重试,而不是只能看到空白页面
这样,即使某个组件出错了,也不会导致整个应用崩溃,用户体验好了很多。
方案三:完善测试覆盖
我们完善了测试覆盖,特别是边界情况的测试:
- 基于真实用户数据的测试:我们导出了一部分真实的用户数据(脱敏后),用于测试,确保测试数据和线上数据一致
- 边界情况的测试:针对每个组件,设计了各种边界情况的测试用例,比如空数据、异常数据、极端数据等
- 集成测试:不仅仅是单元测试,还做了集成测试,测试组件之间的交互和整体的流程
- 自动化测试:把测试用例自动化,每次提交代码都自动运行,确保不会引入回归问题
方案四:引入灰度发布
我们引入了灰度发布机制,以后的大版本升级,都先灰度发布:
- 先让内部员工用新版本,测试一段时间
- 然后让1%的用户用新版本,观察错误率和性能指标
- 没问题的话,逐步扩大到5%、10%、50%
- 最后全量发布
- 灰度过程中,如果发现问题,随时可以回滚
这样,即使新版本有问题,影响范围也很小,而且能早期发现。
方案五:完善监控和告警
我们完善了前端的监控和告警:
- 错误监控:捕获所有的前端错误,包括React组件错误、JS运行时错误、资源加载错误等
- 性能监控:监控页面加载时间、首屏时间、用户交互响应时间等性能指标
- 用户行为监控:记录用户的操作路径,出了问题可以回溯用户的操作
- 告警:错误率超过阈值、性能指标下降等,自动告警,及时发现问题
- 错误分析:对错误进行聚合分析,找出高频错误和影响范围大的错误,优先修复
五、经验教训
这次故障,给我们团队上了深刻的一课。我们总结了以下经验教训:
教训一:新特性要谨慎使用,深入理解后再上线
框架的新特性,往往很诱人,但是也可能有坑。在使用新特性之前,一定要深入理解它的原理、行为、边界情况和潜在问题,不要只看了文档的基本用法就直接用到生产环境。
特别是像React 16这样的大版本更新,底层架构都变了(从Stack Reconciler变成了Fiber),很多行为和React 15不一样,更要谨慎。
建议:
- 新特性先在内部项目或者非核心功能里试用,积累经验
- 深入研究新特性的原理和边界情况,不仅仅是看文档
- 做充分的测试,特别是边界情况的测试
- 上线后密切监控,发现问题及时处理
教训二:测试要覆盖真实场景和边界情况
测试不能只覆盖正常的业务流程,还要覆盖真实的用户数据和各种边界情况。很多bug,只有在特定的数据或者边界情况下才会触发,如果测试数据都是模拟的、简单的,就发现不了这些bug。
建议:
- 用真实的用户数据(脱敏后)做测试
- 针对每个组件,设计各种边界情况的测试用例
- 做集成测试和端到端测试,不仅仅是单元测试
- 自动化测试,每次提交代码都自动运行
教训三:大版本升级要灰度发布
大版本升级,风险很高,一定要灰度发布,不要全量上线。灰度发布可以把影响范围降到最低,也能早期发现问题。
建议:
- 先内部测试,然后小范围灰度,逐步扩大范围
- 灰度过程中密切监控错误率和性能指标
- 发现问题随时回滚
- 全量发布后,继续观察一段时间
教训四:错误处理要完善,不要让整个应用崩溃
前端应用,难免会有错误,但是不能因为一个组件的错误,导致整个应用崩溃。要完善错误处理机制,特别是用了React 16的错误边界之后,要合理使用错误边界,把错误隔离在局部。
建议:
- 在多个层级使用错误边界,而不是只在最外层用一个
- 错误边界的UI要友好,有重试按钮
- 捕获所有类型的错误,不仅仅是React组件错误
- 错误上报和监控,及时发现和修复错误
教训五:线上故障要快速响应,及时沟通
出了线上故障,响应速度很重要。要快速定位问题,快速修复,减少影响时间。同时,要及时和用户沟通,说明情况,道歉,给出解决方案,不要让用户蒙在鼓里。
建议:
- 建立线上故障的应急响应机制,明确分工和流程
- 故障发生后,第一时间成立临时小组,开始排查
- 优先考虑回滚或者临时修复,快速恢复服务
- 及时和用户沟通,通过公告、客服等渠道说明情况
- 故障解决后,做深入的复盘,总结经验教训,避免再犯
六、写在最后
这次React 16新特性的故障,是我们团队经历过的最惊心动魄的线上故障之一。从发现问题到修复,用了两个多小时,影响了30%的用户,教训很深刻。
但是,从另一个角度看,这次故障也让我们成长了很多。我们对React 16的理解更深入了,我们的测试流程更完善了,我们的发布机制更稳健了,我们的错误处理和监控也更健全了。
技术成长的路上,难免会踩坑,会出故障。重要的是,从故障中学习,总结经验教训,不断提升自己和团队的能力,避免再犯同样的错误。
React 16是一个很好的版本,Fiber架构、错误边界、Portal等新特性,确实能提升开发效率和用户体验。只要我们深入理解,合理使用,充分测试,就能发挥它的优势,避免它的坑。
希望我们的这次故障复盘,能给正在使用或者准备升级React 16的朋友一些参考,避免踩同样的坑。也欢迎大家交流讨论,你们在使用React 16的时候,遇到过什么问题?是怎么解决的?
最后,用一句话总结:"技术升级有风险,上线发布需谨慎。深入理解是前提,充分测试是保障,灰度发布是缓冲,完善监控是底线。"
愿每一个前端工程师,都能写出稳定、高效、优雅的代码,都能远离线上故障。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录