前端白屏的前世今生

February 20, 2022

前言

白屏(Blank Screen),它无所不及,摧枯拉朽,令用户体感全失、测试提 P0 相见、研发不寒而栗,胆战心惊,只知匆忙回滚。

对于离用户最近的前端,更是重灾区,浏览器上只要出现白屏,先找前端准没错。

近期工作中频频遇到线上白屏事故,我借这个机遇,介绍为什么会产生白屏,以及应对之道。

兵法著:知彼知己,百战不殆;不知彼知己,一胜一负,不知彼不知己,每战必殆。

只有足够了解白屏,了解自身代码的局限性,才能云淡风轻,编程游刃有余。

白屏从何而来

导致白屏的原因,大概率分为两种:

  1. 资源访问错误
  2. 代码执行错误

两者虽然“各有千秋”,但从现代前端视角来看,都和 SPA 框架的广泛应用逃不了干系。

资源访问错误

这里的资源特指 JavaScript 脚本、样式表、图片等静态资源,不包括服务调用等动态资源。

最典型的例子莫过于 React、Vue 等 SPA 框架构建的 Web 应用,一旦 [bundle|app].js 因为网络原因访问失败,便会引发页面白屏。

你可以访问 https://vue-ebgcbmiy3-b2d1.vercel.app/,按照如下步骤复现:打开 DevTools > Network,找到 app.3b315b6b.js,右键并选中 Block request URL,随后刷新页面。

代码执行出错

如果说资源访问错误还有回旋的余地,可能用户的网络不稳定,重试几次便能恢复正常。

那么在 render 过程中,代码执行出错无异于被宣判死刑,包括但不限于:

  • 读取 undefined null 的属性,null.a;
  • 对普通对象进行函数调用,const o = {}; o();
  • 将 null undefined 传递给 Object.keys,Object.keys(null);
  • JSON 反序列化接受到非法值,JSON.parse({});

你必须经历本地调试,CI、CD,构建部署等一系列措施、或者直接 rollback.

为什么 read properties of undefined 就白屏了?

先请教一个问题,试问以下代码的执行是否会导致页面白屏?

为了摆脱框架的约束,我特意使用原生 JavaScript、以命令式的编程范式动态渲染一个网页。

html
<body>
<div id="root"></div>
<script>
const arr = ["webpack", "rollup", "parcel"];
const root = document.getElementById("root");
const ul = document.createElement("ul");
for (let i = 0; i <= arr.length - 1; i++) {
const li = document.createElement("li");
li.innerHTML = arr[i];
ul.appendChild(li);
}
root.appendChild(ul);
const h1 = document.createElement("h1");
// trigger read properties of undefined
h1.textContent = document.createTextNode({}.a.b);
root.appendChild(h1);
</script>
</body>
html
<body>
<div id="root"></div>
<script>
const arr = ["webpack", "rollup", "parcel"];
const root = document.getElementById("root");
const ul = document.createElement("ul");
for (let i = 0; i <= arr.length - 1; i++) {
const li = document.createElement("li");
li.innerHTML = arr[i];
ul.appendChild(li);
}
root.appendChild(ul);
const h1 = document.createElement("h1");
// trigger read properties of undefined
h1.textContent = document.createTextNode({}.a.b);
root.appendChild(h1);
</script>
</body>

浏览器的真实表现是 ul 被正常渲染,而 h1 直接不渲染,两者互不影响,更不会导致白屏。

把视角切回 React,我们将渲染 ul h1 的过程类比为渲染 <Ul /> 组件 和 <H1 /> 组件,看看会发生什么?

js
const Ul = () => (
<ul>
{["webpack", "rollup", "parcel"].map((v) => (
<li>{v}</li>
))}
</ul>
);
// trigger read properties of undefined
const H1 = () => <h1>{{}.a.b}</h1>;
const App = () => {
return (
<>
<Ul />
<H1 />
</>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
js
const Ul = () => (
<ul>
{["webpack", "rollup", "parcel"].map((v) => (
<li>{v}</li>
))}
</ul>
);
// trigger read properties of undefined
const H1 = () => <h1>{{}.a.b}</h1>;
const App = () => {
return (
<>
<Ul />
<H1 />
</>
);
};
ReactDOM.render(<App />, document.getElementById("root"));

毫无意外,页面呈现白屏状态,<H1 /> 的渲染错误致使整个 <App /> 都崩溃了。

根本原因是自 React 16 起,任何未被错误边界捕获的错误将会导致整个 React 组件树被卸载

翻译一下就是如果在组件的渲染期间内,发生了 Uncaught Errors,而又未被 Error Boundaries 捕获,整个 <App /> 所表示的 DOM 结构都被会移除,如下所示:

js
ReactDOM.render(null, document.getElementById("root"));
js
ReactDOM.render(null, document.getElementById("root"));

React 用白屏真正诠释了什么叫唇寒齿亡,牵一发而动全身,这也验证了我之前的说法,现代 Web 应用频繁白屏和 SPA 框架逃不了干系。

但你能说这个机制是负向优化的吗?官方说法是:

我们对这一决定有过一些争论,但根据我们的经验,把一个错误的 UI 留在那比完全移除它要更糟糕。例如,在类似 Messenger 的产品中,把一个异常的 UI 展示给用户可能会导致用户将信息错发给别人。同样,对于支付类应用而言,显示错误的金额也比不呈现任何内容更糟糕。

我越来越相信,前端层出不穷的框架或是新技术,虽然它的 leverage 足够大,但背后隐含着 trade-off,在绝大多数场景下表现优异,在另一些场景下你也必须要接受它的“规则”。

为什么不能是 ⬛ 屏、🟦 屏?

既然 DOM 都被移除了,只剩下个光秃秃的 div#app 节点,加上 body 的默认背景颜色是 #FFF,理所应当白屏。

html
<body>
<div id="app"></div>
<script src="/js/chunk-vendors.61a12961.js"></script>
<script src="/js/app.3b315b6b.js"></script>
</body>
html
<body>
<div id="app"></div>
<script src="/js/chunk-vendors.61a12961.js"></script>
<script src="/js/app.3b315b6b.js"></script>
</body>

因此,不仅黑屏、蓝屏可以实现,只要将 body 的背景颜色稍作调整,彩虹屏也可以实现,彼时复盘文档的标题名为 「XXX 引发彩虹屏」,活成了前端喜剧人的样子。

我认为白屏只是一种代号,引申的含义是页面无内容渲染。

我还想强调,白屏只是一种外在表现形式,内在错误已经发生,不可挽回,肯定会给用户带来功能上的影响,只不过白屏的视觉冲击力最强,大脑直觉反馈十分严重。

如何降低白屏的“破坏力”

不再赘述如何避免白屏,因为错误时时刻刻会发生,我们能做的是尽人事,遵循以下原则:

  • 依赖不可信,npm 的 Breaking Change
  • 调用不可信,HTTP/RPC 等 API 调用不仅会失败,还会返回约定之外的数据,不兼容过时版本
  • 输入不可信,用户常常会输入一些边界值、非法值 能尽可能避免异常。

我们关注的是错误已经发生的窘境下,如何及时补救,把外在的不良表现弱化成用户可以接受,或者无感知的状态。

借助于 ErrorBoundary,它能捕获任意子组件在渲染期间发生的 Uncaught Errors,从而避免整体组件树的卸载,把白屏扼杀在摇篮中。

除此之外,它还能对渲染错误的组件做兜底,具体的处理措施有两种:熔断和降级。

组件 “熔断”

熔断机制指的是在股票市场的交易时间中,当价格波动的幅度达到某一个限定的目标(熔断点)时,对其暂停交易一段时间的机制。 此机制如同保险丝在电流过大时候熔断,避免引发更大的事故,因此得名。

它被大量应用于容灾体系,对应 React 体系中,熔断点等同于渲染错误发生,暂定交易等同于卸载组件,直接不渲染,舍车保帅。

直接看例子:

js
import { ErrorBoundary } from "react-error-boundary";
const Other = () => <h1>I AM OTHER</h1>;
const Bug = () => {
const [val, setVal] = useState({});
const triggerError = () => {
setVal(undefined);
};
return (
<>
<button onClick={triggerError}>trigger render error</button>
<h1>I HAVE BUG, DO NOT CLICK ME</h1>
{Object.keys(val)}
</>
);
};
const App = () => (
<>
<Other />
<ErrorBoundary fallbackRender={() => null}>
<Bug />
</ErrorBoundary>
</>
);
js
import { ErrorBoundary } from "react-error-boundary";
const Other = () => <h1>I AM OTHER</h1>;
const Bug = () => {
const [val, setVal] = useState({});
const triggerError = () => {
setVal(undefined);
};
return (
<>
<button onClick={triggerError}>trigger render error</button>
<h1>I HAVE BUG, DO NOT CLICK ME</h1>
{Object.keys(val)}
</>
);
};
const App = () => (
<>
<Other />
<ErrorBoundary fallbackRender={() => null}>
<Bug />
</ErrorBoundary>
</>
);

组件优雅降级

优雅降级指使用 替代渲染出错的组件,并做符合功能场景,用户心智的提示。

js
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
const App = () => (
<>
<Other />
<ErrorBoundary fallbackRender={ErrorFallback}>
<Bug />
</ErrorBoundary>
</>
);
js
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
const App = () => (
<>
<Other />
<ErrorBoundary fallbackRender={ErrorFallback}>
<Bug />
</ErrorBoundary>
</>
);

以上 demo 所选择的错误边界库为 https://github.com/bvaughn/react-error-boundary,可在生产环境中投入使用。

前提是大家都要有对每个组件加上错误边界的共识,配合团队内部的监控上报和 Lint 检测,才能最大限度降低白屏的“破坏力”,打造一个稳定性更强的线上环境。

题外话:主动 throw error 导致白屏

我宁愿犯错,也不愿什么也不做。

这一点我和 React Team 的观点相同,与其展示错误的 UI,不如不展示。

错误的 UI,随时是个定时炸弹,在特定情况下就会爆炸,试想用户在错误的界面进行操作,小则造成 BUG,大则造成经济损失、安全泄露,会带来不可损失的影响,所以遇到对于非预期的行为,一定要主动 throw error,并做好组件熔断及降级。


B2D1 (包邦东)

Written by B2D1 (包邦东)