不知从什么时候开始,前端白屏问题成为一个非常普遍的话题,'白屏' 甚至成为了前端 bug 的代名词:_喂,你的页面白了。_而且,'白' 这一现象似乎对于用户体感上来说更加强,回忆起 windows 系统的崩溃 '蓝屏':
可以说是非常相似了,甚至能明白了白屏这个词汇是如何统一出来的。那么,体感如此强烈的现象势必会给用户带来一些不好的影响,如何能尽早监听,快速消除影响就显得很重要了。
不光光是白屏,白屏只是一种现象,我们要做的是精细化的异常监控。异常监控各个公司肯定都有自己的一套体系,集团也不例外,而且也足够成熟。但是通用的方案总归是有缺点的,如果对所有的异常都加以报警和监控,就无法区分异常的严重等级,并做出相应的响应,所以在通用的监控体系下定制精细化的异常监控是非常有必要的。这就是本文讨论白屏这一场景的原因,我把这一场景的边界圈定在了 “白屏” 这一现象。
白屏大概可能的原因有两种:
这两者方向不同,资源错误影响面较多,且视情况而定,故不在下面方案考虑范围内。为此,参考了网上的一些实践加上自己的一些调研,大概总结出了一些方案:
原理很简单,在当前主流的 SPA 框架下,DOM 一般挂载在一个根节点之下(比如 <div id="root"></div>
)发生白屏后通常现象是根节点下所有 DOM 被卸载,该方案就是通过监听全局的 onerror
事件,在异常发生时去检测根节点下是否挂载 DOM,若无则证明白屏。我认为是非常简单暴力且有效的方案。但是也有缺点:其一切建立在白屏 === 根节点下 DOM 被卸载
成立的前提下,实际并非如此比如一些微前端的框架,当然也有我后面要提到的方案,这个方案和我最终方案天然冲突。
不了解的可以看下文档[1]。其本质是监听 DOM 变化,并告诉你每次变化的 DOM 是被增加还是删除。为其考虑了多种方案:
onerror
使用,类似第一个方案,但很快被我否决了,虽然其可以很好的知道 DOM 改变的动向,但无法和具体某个报错联系起来,两个都是事件监听,两者是没有必然联系的。一开始我认为这就是最终答案,经过了漫长的心里斗争,最终还是否定掉了。不过它给了一个比较好的监听时机的选择。
饿了么的白屏监控方案,其原理是记录页面打开 4s 前后 html 长度变化,并将数据上传到饿了么自研的时序数据库。如果一个页面是稳定的,那么页面长度变化的分布应该呈现「幂次分布」曲线的形态,p10、p20 (排在文档前 10%、20%)等数据线应该是平稳的,在一定的区间内波动,如果页面出现异常,那么曲线一定会出现掉底的情况。
其他都大同小样,其实调研了一圈下来发现无非就是两点
几番尝试下来几乎没有我想要的,其主要原因是准确率 -- 这些方案都不能保证我监听到的是白屏,单从理论的推导就说不通。他们都有一个共同点:监听的是'白屏'这个现象,从现象去推导本质虽然能成功,但是不够准确。所以我真正想要监听的是造成白屏的本质。
那么回到最开始,什么是白屏?他是如何造成的?是因为错误导致的浏览器无法渲染?不,在这个 spa 框架盛行的现在实际上的白屏是框架造成的,本质是由于错误导致框架不知道怎么渲染所以干脆就不渲染。由于我们团队 React 技术栈居多,我们来看看 React 官网的一段话[2]:
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>
、 `都会创建一个
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 所有的白屏的场景,比如我要搭配资源的监听来处理资源异常导致的白屏。当然没有一个方案是完美的,我这里也是提供一个思路,欢迎大家一起讨论。
[1]文档: https://link.zhihu.com/?target=https%3A//developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver
[2]一段话: https://link.zhihu.com/?target=https%3A//zh-hans.reactjs.org/docs/error-boundaries.html%23new-behavior-for-uncaught-errors
The End
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/kP2uwtjh3f8n3uMNKyc__A
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。