为什么单独监控白屏
不光光是白屏,白屏只是一种现象,我们要做的是精细化的异常监控。异常监控各个公司肯定都有自己的一套体系,集团也不例外,而且也足够成熟。但是通用的方案总归是有缺点的,如果对所有的异常都加以报警和监控,就无法区分异常的严重等级,并做出相应的响应,所以在通用的监控体系下定制精细化的异常监控是非常有必要的。这就是本文讨论白屏这一场景的原因,我把这一场景的边界圈定在了 “白屏” 这一现象。
方案调研
白屏大概可能的原因有两种:
-
js 执行过程中的错误
-
资源错误
这两者方向不同,资源错误影响面较多,且视情况而定,故不在下面方案考虑范围内。为此,参考了网上的一些实践加上自己的一些调研,大概总结出了一些方案:
一、onerror + DOM 检测
原理很简单,在当前主流的 SPA 框架下,DOM 一般挂载在一个根节点之下(比如 <div id="root"></div>
)发生白屏后通常现象是根节点下所有 DOM 被卸载,该方案就是通过监听全局的 onerror
事件,在异常发生时去检测根节点下是否挂载 DOM,若无则证明白屏。我认为是非常简单暴力且有效的方案。但是也有缺点:其一切建立在 白屏 === 根节点下 DOM 被卸载
成立的前提下,实际并非如此比如一些微前端的框架,当然也有我后面要提到的方案,这个方案和我最终方案天然冲突。
二、Mutation Observer Api
不了解的可以看下文档[1]。其本质是监听 DOM 变化,并告诉你每次变化的 DOM 是被增加还是删除。为其考虑了多种方案:
-
搭配
onerror
使用,类似第一个方案,但很快被我否决了,虽然其可以很好的知道 DOM 改变的动向,但无法和具体某个报错联系起来,两个都是事件监听,两者是没有必然联系的。 -
单独使用判断是否有大量 DOM 被卸载,缺点:白屏不一定是 DOM 被卸载,也有可能是压根没渲染,且正常情况也有可能大量 DOM 被卸载。完全走不通。
-
单独使用其监听时机配合 DOM 检测,其缺点和方案一一样,而且我觉得不如方案一。因为它没法和具体错误联系起来,也就是没法定位。当然我和其他团队同学交流的时候他们给出了其他方向:通过追踪用户行为数据来定位问题,我觉得也是一种方法。
一开始我认为这就是最终答案,经过了漫长的心里斗争,最终还是否定掉了。不过它给了一个比较好的监听时机的选择。
三、饿了么-Emonitor 白屏监控方案
饿了么的白屏监控方案,其原理是记录页面打开 4s 前后 html 长度变化,并将数据上传到饿了么自研的时序数据库。如果一个页面是稳定的,那么页面长度变化的分布应该呈现「幂次分布」曲线的形态,p10、p20 (排在文档前 10%、20%)等数据线应该是平稳的,在一定的区间内波动,如果页面出现异常,那么曲线一定会出现掉底的情况。
其他
其他都大同小样,其实调研了一圈下来发现无非就是两点
-
监控时机:调研下来常见的就三种:
-
onerror
-
mutation observer api
-
轮训
-
DOM 检测:这个方案就很多了,除了上述的还可以:
-
elementsFromPoint api 采样
-
图像识别
-
基于 DOM 的各种数据的各种算法识别
-
…
改变方向
几番尝试下来几乎没有我想要的,其主要原因是准确率 – 这些方案都不能保证我监听到的是白屏,单从理论的推导就说不通。他们都有一个共同点:监听的是’白屏’这个现象,从现象去推导本质虽然能成功,但是不够准确。所以我真正想要监听的是造成白屏的本质。
那么回到最开始,什么是白屏?他是如何造成的?是因为错误导致的浏览器无法渲染?不,在这个 spa 框架盛行的现在实际上的白屏是框架造成的,本质是由于错误导致框架不知道怎么渲染所以干脆就不渲染。由于我们团队 React 技术栈居多,我们来看看 React 官网的一段话[2]:
React 认为把一个错误的 UI 保留比完全移除它更糟糕。我们不讨论这个看法的正确与否,至少我们知道了白屏的原因:渲染过程的异常且我们没有捕获异常并处理。
反观目前的主流框架:我们把 DOM 的操作托管给了框架,所以渲染的异常处理不同框架方法肯定不一样,这大概就是白屏监控难统一化产品化的原因。但大致方向肯定是一样的。
那么关于白屏我认为可以这么定义:异常导致的渲染失败。
那么白屏的监控方案即:监控渲染异常。那么对于 React 而言,答案就是:Error Boundaries
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
Something went wrong.
;}
return this.props.children;
}
}
一个有责任心的开发一定不会放任错误的发生。错误边界可以包在任何位置并提供降级 UI,也就是说,一旦开发者’有责任心’ 页面就不会全白,这也是我之前说的方案一与之天然冲突且其他方案不稳定的情况。那么,在这同时我们上报异常信息,这里上报的异常一定会导致我们定义的白屏,这一推导是 100% 正确的。
100% 这个词或许不够负责,接下来我们来看看为什么我说这一推导是 100% 准确的:
React 渲染流程
我们来简单回顾下从代码到展现页面上 React 做了什么。我大致将其分为几个阶段:render => 任务调度 => 任务循环 => 提交 => 展示 我们举一个简单的例子来展示其整个过程(任务调度不再本次讨论范围故不展示):
const App = ({ children }) => (
<>
hello
{ children }
</>
);
const Child = () =>
I’m child
const a = ReactDOM.render(
,
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
树。
我只保留了一些渲染过程中重要的字段,其他还有很多用于调度、判断的字段我这边就不放出来了,有兴趣自行了解
render
现在我们要开始渲染页面,是我们刚才的例子,执行 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)
方法,这里有两种情况 -
该组件 return 一个单一节点,也就是返回一个
ReactElement
对象,重复 3 - 4 的步骤。并将当前 节点的 child 指向子节点CurrentFiberNode.child = ChildFiberNode
并将子节点的 return 指向当前节点ChildFiberNode.return = CurrentFiberNode
-
该组件 return 多个节点(数组或者
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,
sibling: null,
flags: Placement, // 待布局状态
}
FiberNode {
elementType: f App(),
type: FunctionComponent,
return: FiberNodeRoot,
child: FiberNode
,
sibling: null,
flags: Placement // 待布局状态
}
FiberNode
{
elementType: ‘p’,
type: HostComponent,
return: FiberNode,
sibling: FiberNode,
child: null,
flags: Placement // 待布局状态
}
FiberNode {
elementType: f Child(),
type: FunctionComponent,
return: FiberNode,
child: null,
flags: Placement // 待布局状态
}
提交阶段
提交阶段简单来讲就是拿着这棵树进行深度优先遍历 child => sibling,放置 DOM 节点并调用生命周期。
那么整个正常的渲染流程简单来讲就是这样。接下来看看异常处理
错误边界流程
刚刚我们了解了正常的流程现在我们制造一些错误并捕获他:
const App = ({ children }) => (
<>
hello
{ children }
</>
Vue 面试题
1.Vue 双向绑定原理
2.描述下 vue 从初始化页面–修改数据–刷新页面 UI 的过程?
3.你是如何理解 Vue 的响应式系统的?
4.虚拟 DOM 实现原理
5.既然 Vue 通过数据劫持可以精准探测数据变化,为什么还需要虚拟 DOM 进行 diff 检测差异?
6.Vue 中 key 值的作用?
7.Vue 的生命周期
8.Vue 组件间通信有哪些方式?
9.watch、methods 和 computed 的区别?
10.vue 中怎么重置 data?
11.组件中写 name 选项有什么作用?
12.vue-router 有哪些钩子函数?
13.route 和 router 的区别是什么?
14.说一下 Vue 和 React 的认识,做一个简单的对比
15.Vue 的 nextTick 的原理是什么?
16.Vuex 有哪几种属性?
17.vue 首屏加载优化
18.Vue 3.0 有没有过了解?
19.vue-cli 替我们做了哪些工作?
:
const App = ({ children }) => (
<>
hello
{ children }
</>
Vue 面试题
1.Vue 双向绑定原理
2.描述下 vue 从初始化页面–修改数据–刷新页面 UI 的过程?
3.你是如何理解 Vue 的响应式系统的?
4.虚拟 DOM 实现原理
5.既然 Vue 通过数据劫持可以精准探测数据变化,为什么还需要虚拟 DOM 进行 diff 检测差异?
6.Vue 中 key 值的作用?
7.Vue 的生命周期
8.Vue 组件间通信有哪些方式?
9.watch、methods 和 computed 的区别?
10.vue 中怎么重置 data?
11.组件中写 name 选项有什么作用?
12.vue-router 有哪些钩子函数?
13.route 和 router 的区别是什么?
14.说一下 Vue 和 React 的认识,做一个简单的对比
15.Vue 的 nextTick 的原理是什么?
16.Vuex 有哪几种属性?
17.vue 首屏加载优化
18.Vue 3.0 有没有过了解?
19.vue-cli 替我们做了哪些工作?
[外链图片转存中…(img-Q9M9CiLJ-1714230794470)]