引用下集团监控的 slogan:关注业务稳定性的人,运气都不会太差~
不知从何时开始,前端白屏问题成为一个很是广泛的话题,'白屏' 甚至成为了前端 bug 的代名词:_喂,你的页面白了。_并且,'白' 这一现象彷佛对于用户体感上来讲更增强,回忆起 windows 系统的崩溃 '蓝屏':
能够说是很是类似了,甚至能明白了白屏这个词汇是如何统一出来的。那么,体感如此强烈的现象势必会给用户带来一些很差的影响,如何能尽早监听,快速消除影响就显得很重要了。javascript
不光光是白屏,白屏只是一种现象,咱们要作的是精细化的异常监控。异常监控各个公司确定都有本身的一套体系,集团也不例外,并且也足够成熟。可是通用的方案总归是有缺点的,若是对全部的异常都加以报警和监控,就没法区分异常的严重等级,并作出相应的响应,因此在通用的监控体系下定制精细化的异常监控是很是有必要的。这就是本文讨论白屏这一场景的缘由,我把这一场景的边界圈定在了 “白屏” 这一现象。html
白屏大概可能的缘由有两种:前端
这二者方向不一样,资源错误影响面较多,且视状况而定,故不在下面方案考虑范围内。为此,参考了网上的一些实践加上本身的一些调研,大概总结出了一些方案:java
原理很简单,在当前主流的 SPA 框架下,DOM 通常挂载在一个根节点之下(好比 <div id="root"></div>
)发生白屏后一般现象是根节点下全部 DOM 被卸载,该方案就是经过监听全局的 onerror
事件,在异常发生时去检测根节点下是否挂载 DOM,若无则证实白屏。
我认为是很是简单暴力且有效的方案。可是也有缺点:其一切创建在 **白屏 === 根节点下 DOM 被卸载**
成立的前提下,实际并不是如此好比一些微前端的框架,固然也有我后面要提到的方案,这个方案和我最终方案自然冲突。react
不了解的能够看下文档。
其本质是监听 DOM 变化,并告诉你每次变化的 DOM 是被增长仍是删除。为其考虑了多种方案:算法
onerror
使用,相似第一个方案,但很快被我否决了,虽然其能够很好的知道 DOM 改变的动向,但没法和具体某个报错联系起来,两个都是事件监听,二者是没有必然联系的。一开始我认为这就是最终答案,通过了漫长的内心斗争,最终仍是否认掉了。不过它给了一个比较好的监听时机的选择。数据库
饿了么的白屏监控方案,其原理是记录页面打开 4s 先后 html 长度变化,并将数据上传到饿了么自研的时序数据库。若是一个页面是稳定的,那么页面长度变化的分布应该呈现「幂次分布」曲线的形态,p十、p20 (排在文档前 10%、20%)等数据线应该是平稳的,在必定的区间内波动,若是页面出现异常,那么曲线必定会出现掉底的状况。windows
其余都大同小样,其实调研了一圈下来发现无非就是两点api
监控时机:调研下来常见的就三种:数组
DOM 检测:这个方案就不少了,除了上述的还能够:
几番尝试下来几乎没有我想要的,其主要缘由是准确率 -- 这些方案都不能保证我监听到的是白屏,单从理论的推导就说不通。他们都有一个共同点:监听的是'白屏'这个现象,从现象去推导本质虽然能成功,可是不够准确。因此我真正想要监听的是形成白屏的本质。
那么回到最开始,什么是白屏?他是如何形成的?是由于错误致使的浏览器没法渲染?不,在这个 spa 框架盛行的如今实际上的白屏是框架形成的,本质是因为错误致使框架不知道怎么渲染因此干脆就不渲染。因为咱们团队 React 技术栈居多,咱们来看看 React 官网的一段话:
React 认为把一个错误的 UI 保留比彻底移除它更糟糕。咱们不讨论这个见解的正确与否,至少咱们知道了白屏的缘由:渲染过程的异常且咱们没有捕获异常并处理。
反观目前的主流框架:咱们把 DOM 的操做托管给了框架,因此渲染的异常处理不一样框架方法确定不同,这大概就是白屏监控难统一化产品化的缘由。但大体方向确定是同样的。
那么关于白屏我认为能够这么定义:异常致使的渲染失败。
那么白屏的监控方案即:监控渲染异常。那么对于 React 而言,答案就是: Error Boundaries
咱们能够称之为错误边界,错误边界是什么?它其实就是一个生命周期,用来监听当前组件的 children 渲染过程当中的错误,并能够返回一个 降级的 UI 来渲染:
class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error) { // 更新 state 使下一次渲染可以显示降级后的 UI return { hasError: true }; } componentDidCatch(error, errorInfo) { // 咱们能够将错误日志上报给服务器 logErrorToMyService(error, errorInfo); } render() { if (this.state.hasError) { // 咱们能够自定义降级后的 UI 并渲染 return <h1>Something went wrong.</h1>; } return this.props.children; } }
一个有责任心的开发必定不会听任错误的发生。错误边界能够包在任何位置并提供降级 UI,也就是说,一旦开发者'有责任心' 页面就不会全白,这也是我以前说的方案一与之自然冲突且其余方案不稳定的状况。
那么,在这同时咱们上报异常信息,这里上报的异常必定会致使咱们定义的白屏,这一推导是 100% 正确的。
100% 这个词或许不够负责,接下来咱们来看看为何我说这一推导是 100% 准确的:
咱们来简单回顾下从代码到展示页面上 React 作了什么。
我大体将其分为几个阶段:render => 任务调度 => 任务循环 => 提交 => 展现
咱们举一个简单的例子来展现其整个过程(任务调度再也不本次讨论范围故不展现):
const App = ({ children }) => ( <> <p>hello</p> { children } </> ); const Child = () => <p>I'm child</p> const a = ReactDOM.render( <App><Child/></App>, document.getElementById('root') );
首先浏览器是不认识咱们的 jsx 语法的,因此咱们经过 babel 编译大概能获得下面的代码:
var App = function App(_ref2) { var children = _ref2.children; return React.createElement("p", null, "hello"), children); }; var Child = function Child() { return React.createElement("p", null, "I'm child"); }; ReactDOM.render(React.createElement(App, null, React.createElement(Child, null)), document.getElementById('root'));
babel 插件将全部的 jsx 都转成了 createElement
方法,执行它会获得一个描述对象 ReactElement
大概长这样子:
{ $$typeof: Symbol(react.element), key: null, props: {}, // createElement 第二个参数 注意 children 也在这里,children 也会是一个 ReactElement 或 数组 type: 'h1' // createElement 的第一个参数,多是原生的节点字符串,也多是一个组件对象(Function、Class...) }
全部的节点包括原生的 <a></a>
、 <p></p>
都会建立一个 FiberNode
,他的结构大概长这样:
FiberNode = { elementType: null, // 传入 createElement 的第一个参数 key: null, type: HostRoot, // 节点类型(根节点、函数组件、类组件等等) return: null, // 父 FiberNode child: null, // 第一个子 FiberNode sibling: null, // 下一个兄弟 FiberNode flag: null, // 状态标记 }
你能够把它理解为 Virtual Dom 只不过多了许多调度的东西。最开始咱们会为根节点建立一个 FiberNodeRoot
若是有且仅有一个 ReactDOM.render
那么他就是惟一的根,当前有且仅有一个 FiberNode
树。
我只保留了一些渲染过程当中重要的字段,其余还有不少用于调度、判断的字段我这边就不放出来了,有兴趣自行了解
如今咱们要开始渲染页面,是咱们刚才的例子,执行 ReactDOM.render
。这里咱们有个全局 workInProgress
对象标志当前处理的 FiberNode
FiberNodeRoot
,他的结构就如上面所示,并将 workInProgress= FiberNodeRoot
。ReactDOM.render
方法的第一个参数,咱们获得一个 ReactElement
:ReactElement = { $$typeof: Symbol(react.element), key: null, props: { children: { $$typeof: Symbol(react.element), key: null, props: {}, ref: null, type: ƒ Child(), } } ref: null, type: f App() }
该结构描述了 <App><Child /></App>
ReactElement
生成一个 FiberNode
并把 return 指向父 FiberNode
,最开始是咱们的根节点,并将 workInProgress = FiberNode
{ elementType: f App(), // type 就是 App 函数 key: null, type: FunctionComponent, // 函数组件类型 return: FiberNodeRoot, // 咱们的根节点 child: null, sibling: null, flags: null }
只要workInProgress
存在咱们就要处理其指向的 FiberNode
。节点类型有不少,处理方法也不太同样,不过总体流程是相同的,咱们以当前函数式组件为例子,直接执行 App(props)
方法,这里有两种状况
ReactElement
对象,重复 3 - 4 的步骤。并将当前 节点的 child 指向子节点 CurrentFiberNode.child = ChildFiberNode
并将子节点的 return 指向当前节点 ChildFiberNode.return = CurrentFiberNode
Fragment
),此时咱们会获得一个 ChildiFberNode
的数组。咱们循环他,每个节点执行 3 - 4 步骤。将当前节点的 child 指向第一个子节点 CurrentFiberNode.child = ChildFiberNodeList[0]
,同时每一个子节点的 sibling 指向其下一个子节点(若是有) ChildFiberNode[i].sibling = ChildFiberNode[i + 1]
,每一个子节点的 return 都指向当前节点 ChildFiberNode[i].return = CurrentFiberNode
若是无异常每一个节点都会被标记为待布局 FiberNode.flags = Placement
workInProgress
为空。最终咱们能大概获得这样一个 FiberNode
树:
FiberNodeRoot = { elementType: null, type: HostRoot, return: null, child: FiberNode<App>, sibling: null, flags: Placement, // 待布局状态 } FiberNode<App> { elementType: f App(), type: FunctionComponent, return: FiberNodeRoot, child: FiberNode<p>, sibling: null, flags: Placement // 待布局状态 } FiberNode<p> { elementType: 'p', type: HostComponent, return: FiberNode<App>, sibling: FiberNode<Child>, child: null, flags: Placement // 待布局状态 } FiberNode<Child> { elementType: f Child(), type: FunctionComponent, return: FiberNode<App>, child: null, flags: Placement // 待布局状态 }
提交阶段简单来说就是拿着这棵树进行深度优先遍历 child => sibling,放置 DOM 节点并调用生命周期。
那么整个正常的渲染流程简单来说就是这样。接下来看看异常处理
刚刚咱们了解了正常的流程如今咱们制造一些错误并捕获他:
const App = ({ children }) => ( <> <p>hello</p> { children } </> ); const Child = () => <p>I'm child {a.a}</p> const a = ReactDOM.render( <App> <ErrorBoundary><Child/></ErrorBoundary> </App>, document.getElementById('root') );
执行步骤 4 的函数体是包裹在 try...catch
内的若是捕获到了异常则会走异常的流程:
do { try { workLoopSync(); // 上述 步骤 4 break; } catch (thrownValue) { handleError(root, thrownValue); } } while (true);
执行步骤 4 时咱们调用 Child
方法因为咱们加了个不存在的表达式 {a.a}
此时会抛出异常进入咱们的 handleError
流程此时咱们处理的目标是 FiberNode<Child>
,咱们来看看 handleError
:
function handleError(root, thrownValue): void { let erroredWork = workInProgress; // 当前处理的 FiberNode 也就是异常的 节点 throwException( root, // 咱们的根 FiberNode erroredWork.return, // 父节点 erroredWork, thrownValue, // 异常内容 ); completeUnitOfWork(erroredWork); } function throwException( root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, ) { // The source fiber did not complete. sourceFiber.flags |= Incomplete; let workInProgress = returnFiber; do { switch (workInProgress.tag) { case HostRoot: { workInProgress.flags |= ShouldCapture; return; } case ClassComponent: // Capture and retry const ctor = workInProgress.type; const instance = workInProgress.stateNode; if ( (workInProgress.flags & DidCapture) === NoFlags && (typeof ctor.getDerivedStateFromError === 'function' || (instance !== null && typeof instance.componentDidCatch === 'function' && !isAlreadyFailedLegacyErrorBoundary(instance))) ) { workInProgress.flags |= ShouldCapture; return; } break; default: break; } workInProgress = workInProgress.return; } while (workInProgress !== null); }
代码过长截取一部分
先看 throwException
方法,核心两件事:
FiberNode.flags = Incomplete
ClassComponent
)且的确处理了异常的(声明了 getDerivedStateFromError
或 componentDidCatch
生命周期)节点,若是有,则将那个节点标志为待捕获 workInProgress.flags |= ShouldCapture
,若是没有则是根节点。completeUnitOfWork
方法也相似,从父节点开始冒泡,找到 ShouldCapture
标记的节点,若是有就标记为已捕获 DidCapture
,若是没找到,则一路把全部的节点都标记为 Incomplete
直到根节点,并把 workInProgress
指向当前捕获的节点。
以后从当前捕获的节点(也有可能没捕获是根节点)开始从新走流程,因为其状态 react 只会渲染其降级 UI,若是有 sibling 节点则会继续走下面的流程。咱们看看上述例子最终获得的 FiberNode
树:
FiberNodeRoot = { elementType: null, type: HostRoot, return: null, child: FiberNode<App>, sibling: null, flags: Placement, // 待布局状态 } FiberNode<App> { elementType: f App(), type: FunctionComponent, return: FiberNodeRoot, child: FiberNode<p>, sibling: null, flags: Placement // 待布局状态 } FiberNode<p> { elementType: 'p', type: HostComponent, return: FiberNode<App>, sibling: FiberNode<ErrorBoundary>, child: null, flags: Placement // 待布局状态 } FiberNode<ErrorBoundary> { elementType: f ErrorBoundary(), type: ClassComponent, return: FiberNode<App>, child: null, flags: DidCapture // 已捕获状态 } FiberNode<h1> { elementType: f ErrorBoundary(), type: ClassComponent, return: FiberNode<ErrorBoundary>, child: null, flags: Placement // 待布局状态 }
若是没有配置错误边界那么根节点下就没有任何节点,天然没法渲染出任何内容。
ok,相信到这里你们应该清楚错误边界的处理流程了,也应该能理解为何我以前说由 ErrorBoundry
推导白屏是 100% 正确的。固然这个 100% 指的是由 ErrorBoundry
捕捉的异常基本上会致使白屏,并非指它能捕获所有的白屏异常。如下场景也是他没法捕获的:
React SSR 设计使用流式传输,这意味着服务端在发送已经处理好的元素的同时,剩下的仍然在生成 HTML,也就是其父元素没法捕获子组件的错误并隐藏错误的组件。这种状况彷佛只能将全部的 render 函数包裹 try...catch
,固然咱们能够借助 babel
或 TypeScript
来帮咱们简单实现这一过程,其最终获得的效果是和 ErrorBoundry
相似的。
而事件和异步则很巧,虽然说 ErrorBoundry
没法捕获他们之中的异常,不过其产生的异常也刚好不会形成白屏(若是是错误的设置状态,间接致使了白屏,恰好仍是会被捕获到)。这就在白屏监控的职责边界以外了,须要别的精细化监控能力来处理它。
那么最后总结下本文的出的几个结论:
我对白屏的定义:异常致使的渲染失败。
对应方案是:资源监听 + 渲染流程监听。
在目前 SPA 框架下白屏的监控须要针对场景作精细化的处理,这里以 React 为例子,经过监听渲染过程异常可以很好的得到白屏的信息,同时能加强开发者对异常处理的重视。而其余框架也会有相应的方法来处理这一现象。
固然这个方案也有弱点,因为是从本质推导现象其实没法 cover 全部的白屏的场景,好比我要搭配资源的监听来处理资源异常致使的白屏。固然没有一个方案是完美的,我这里也是提供一个思路,欢迎你们一块儿讨论。
做者:ES2049 / 金城武
文章可随意转载,但请保留此原文连接。
很是欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com 。