前端白屏的前世今生
February 20, 2022
前言
白屏(Blank Screen),它无所不及,摧枯拉朽,令用户体感全失、测试提 P0 相见、研发不寒而栗,胆战心惊,只知匆忙回滚。
对于离用户最近的前端,更是重灾区,浏览器上只要出现白屏,先找前端准没错。
近期工作中频频遇到线上白屏事故,我借这个机遇,介绍为什么会产生白屏,以及应对之道。
兵法著:知彼知己,百战不殆;不知彼知己,一胜一负,不知彼不知己,每战必殆。
只有足够了解白屏,了解自身代码的局限性,才能云淡风轻,编程游刃有余。
白屏从何而来
导致白屏的原因,大概率分为两种:
- 资源访问错误
- 代码执行错误
两者虽然“各有千秋”,但从现代前端视角来看,都和 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 undefinedh1.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 undefinedh1.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 undefinedconst 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 undefinedconst 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,并做好组件熔断及降级。