生产环境页面白屏分析复盘
自己负责开发的一个H5页面在生产环境遇到了一个奇怪的现象,每天有少量的 iPhone 客户端的用户反馈打开页面后是白屏,在测试环境无法复现,且当时的前端工程还没有接入错误监控系统,下面复盘下定位问题的过程和自己的一点思考。
问题用户群体特征
- 反馈问题的群体均是iOS高版本用户,不太像页面兼容性问题,且询问了客户端的同事,近期都没有发版,可以排除是原生APP的问题。
- 用户卸载重装即可恢复,有点像是缓存的问题。
- 反馈的白屏页面均在store域名下。
根据以上线索还无法判定具体出现了什么问题,先分析下白屏从何而来?什么情况下会导致页面白屏?
白屏从何而来
项目用的是 Taro 框架开发,遵循 react 语法规范,白屏基本是以下3个原因导致的
-
资源访问错误
由SPA框架构建的web应用,一旦某个关键路径资源文件([bundle|app].js)因为网络原因访问失败,便会引发页面白屏。
-
在入口文件(根组件渲染前)就遇到错误
-
组件 render 过程中,代码执行报错
自 React 16 起,任何未被错误边界捕获的错误将会导致整个 React 组件树被卸载。
资源访问错误
前端编译后的一些静态资源往往会放在 CDN 上,如果 CDN 出现了故障(概率很小),那么就极有可能出现白屏现象。解决办法就是 CDN 容灾。
html 中的资源加载问题
对于 html 中静态资源的问题,主流解决方案是配合 onerror
事件监听,来及时捕获 <script>
标签上发生的错误并重试。
<script
src="//your.cdn/js/app.js"
onerror="reload(this)"
></script>
<script>
window.reload = function() {
var scriptDom = document.createElement('script');
scriptDom.src = "//your.domain/js/app.js";
document.write(scriptDom.outHTML);
}
</script>
document.write()
在文档流未关闭前,可以对文档流追加字符串,即在触发 load 或 DOMContentLoaded 事件之前时追加 html 字符串,追加的 html 字符串紧挨着调用 document.write() 的 Dom 之后,如果是在文档流关闭之后调用,则会默认先调用 document.open() 关闭文档,重新将 document.write() 的内容加到 body 中。
当 CDN 出现故障后,紧接着会请求 “//your.domain/js/app.js” 获取 JS 资源。也可以结合 script-ext-html-webpack-plugin 等插件自动给 script 标签插入 onerror 属性。
webpack动态导入的chunk问题
上述基于 <script>
等 HTML 标签的方案看起来很完美,但只能解决写在 html 中的资源加载问题。自从代码拆分以及动态导入出现后,页面里就经常出现另一种资源——由 webpack 动态导入的 chunk。
import('./dynamic-module')
.then((dynamicModule) => { /* 加载成功的业务逻辑 */ })
当代码被拆分为更小的 chunk 之后,webpack 会通过动态创建 <script>
标签来加载它们,其关键原理如下:
function loadScript(src) {
return new Promise((resolve, reject) => {
var $script = document.createElement('script');
$script.src = src;
$script.onload = resolve;
$script.onerror = reject;
document.head.appendChild('script');
})
}
一旦从 CDN 资源加载失败,其内部则会通过 onerror 回调进入 Promise 的 reject 流程,而 resolve 回调中 "加载成功的业务逻辑" 永远不会执行。
目前普遍的方案是使用一些插件,来对 webpack 内部的 requireEnsure 函数进行一些魔改,比如 webpack-retry-chunk-load-plugin 以及 webpack-plugin-import-retry
入口文件(根组件渲染前)错误
根组件渲染前遇到未捕获的错误也会导致页面白屏。
这种情况下异步chunk不会发起请求。
render过程中代码执行报错
react 组件渲染过程中遇到未被错误边界捕获的错误也会导致白屏
具体原因和解决措施请参考React官方文档
借助于 ErrorBoundary,它能捕获任意子组件在渲染期间发生的 Uncaught Errors,从而避免整体组件树的卸载,把白屏扼杀在摇篮中。
错误监控
给前端工程加错误监控首先考虑接入 sentry
,但是我们无法确保在sentry
初始化前是否已经遇到了错误。所以自己做了一个简易的前端监控。
window.onerror
当 JS 运行时错误发生时,window 会触发一个 ErrorEvent
接口的 error 事件,
/**
* @param {String} message 错误信息
* @param {String} source 出错文件
* @param {Number} lineno 行号
* @param {Number} colno 列号
* @param {Object} error Error对象
*/
window.onerror = function(message, source, lineno, colno, error) {
console.log('捕获到异常:', {message, source, lineno, colno, error});
}
但是 window.onerror 有一个缺陷,它无法捕获资源错误,当一项资源(如图片或脚本)加载失败,加载资源的元素会触发一个 Event 接口的 error 事件,这些 error 事件不会向上冒泡到 window,而 window.onerror 只能捕获到冒泡到 window 的错误
。
window.addEventListener
加载资源错误虽然无法冒泡,但是可以被捕获,可以通过 window.addEventListener 监测捕获
// 图片、script、css加载错误,都能被捕获
<script>
window.addEventListener('error', (error) => {
console.log('捕获到异常:', error);
}, true)
</script>
有一点需要注意的是 window.addEventListener
和window.onerror
都不能捕获promise错误,可通过 unhandledrejection
用来全局监听 Uncaught Promise Error
// 全局统一处理 Promise 错误
window.addEventListener("unhandledrejection", function(e){
console.log('捕获到异常:', e);
});
Script error
一般情况,如果出现 Script error 这样的错误,基本上可以确定是出现了跨域问题。同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互,对于跨域脚本捕获的错误,只会输出 Script error。这是一个用于隔离潜在恶意文件的重要安全机制。
后端配置Access-Control-Allow-Origin
、前端script加crossorigin
来解决。
错误上报
捕获到了错误,就要开始往服务端发送(可以生成日志或者异常文档,一般监控工具会自动完成)。这其实就是一次请求的过程,错误上报采用了图片的方式来上报
。
里面有几个需要注意的地方:
-
为什么不能直接用ajax - GET/POST/HEAD请求接口进行上报?
一般而言,打点域名都不是当前域名,所以所有的接口请求都会构成跨域。
-
为什么不能用请求其他的文件资源(js/css/ttf)的方式进行上报?
一般来说创建资源节点后只有将对象注入到浏览器DOM树后,浏览器才会实际发送资源请求。而且载入js/css资源还会阻塞页面渲染,影响用户体验。
构造图片打点不仅不用插入DOM,只要在js中new出Image对象就能发起请求,而且还没有阻塞问题,在没有js的浏览器环境中也能通过img标签正常打点。
发现错误
在错误上报后终于发现了可疑线索
QuotaExceededError
就是 localStorage
存储超出限制,当通过 localStorage.setItem
接口写数据,超出限制后就会报这个错误,看了下代码,在入口处果然有 localStorage.setItem
的调用,且没有通过 try...catch... 处理,导致在根组件渲染前就报错了,导致页面白屏。
同一浏览器的相同域名和端口的不同页面间可以共享相同的 localStorage,看了下我们多个前端工程生产环境都部署在 store 域名下,如果这些前端工程往 localStorage
域名下写数据且没有清理就有可能超出存储限制。
安卓和ios的存储量如下:
这也解释了为什么都是ios用户出现了问题,因为 android 存储容量大。
避免问题再次发生
如果要避免这个问题,那么在所有 localStorage.setItem 的地方都加try...catch...处理,最好的办法是写一个 babel plugin,可以参考我的另一篇文章:babel 插件开发实战
还有一种办法是使用 localForage,会按照 IndexedDB,WebSQL,localStorage
的顺序选择驱动,这也算是一个扩大存储的机制。
转载自:https://juejin.cn/post/7250646924117819447