前端面试宝典之 React 篇 - 某一线大厂资深前端工程师 - 拉勾教育
面试中,在问到 React 的渲染流程后,通常还会问到 “React 的渲染异常会造成什么后果”,大部分应聘的同学,可能会说 “页面会崩掉”,但崩掉后是什么样子,又因为什么而崩掉就说不清楚了。这一讲我们就来讲解这个问题应该如何回答。
破题
在第 03 讲 “如何避免生命周期中的坑”中,有讲到 “错误边界” 的相关内容:如果渲染异常,在没有任何降级保护措施的情况下,页面会直接显示白屏。但问题到这里并没有结束,在类似 “会产生什么影响” 或者 “会造成什么后果” 的问题后,紧接着会询问你 “怎么做”,这就需要你给出一个方案来解决问题。所以这类问题的中心会发生偏移,从“是什么” 到“怎么做”,即从 “解释原理” 到“提供方案”。
那如何评判方案的优劣呢?通常有这样三种层次。
解决问题
在表达方式上,倾向于展示自己有解决该问题的能力,如 “我在项目 x 中遇到了一个 y 问题,通过排查 a、b、c 三个点找到了问题所在,最后改动了项目 x 中的 d、e、f 点完成了上线修复” 这样的回答。
预防问题
在表达方式上倾向于暗示自己是一个老手,在问题处理中以预防为主。通常会讲自己在初始化项目后,会做这样几条措施以预防同类的低级错误。
工程化方案
上面两种表达还停留在个人层次的基本面,缺乏全盘考虑。你要知道,我们做前端实际上也是在做工程,那我们就需要从工程化的角度去思考方案该怎么做。那什么是工程化呢?工程化这个概念并没有统一的标准说法,但我们仍然可以从项目经验中进行总结提炼。
工程化通常以标准化为抓手完成降本增效,它涉及的方向有:
- 模块标准化
- 流程标准化
- 代码风格标准化
- ……
通过标准化的方案解决同类型的问题。即在解决的过程中需要考虑减少人为因素,引入自动化过程,比如在统一团队内部代码风格时,我们并不会用人人互相 Review 代码的方式去检查大家的符号与缩进,而是使用 ESLint 自动化处理。所以在解决同类型问题时,沉淀的标准化方案,才能用于更多的业务方向,在团队内部才更具有通用价值。
只解决自己所遇到的问题,贡献度是很低的,如果能够产出通用的方案供团队使用,就非常有价值了。同时是否具备团队视野会影响面试官对个人能力的评定。所以在面试中,不少面试官更偏好这类问题。他们希望从这类问题中了解应聘者在原团队所处的位置,以及所做的贡献是否能匹配更高的职级。这就又涉及了另外一个问题,即如何量化成果?项目价值也好,业绩贡献也好,最后都需要数据支撑,如果只是空口一句修复了 Bug,而没有提供任何参考性的指标,那就没有任何意义。工程是一门重视过程与结果的科学,不仅需要注重实施方案,也需要注重结果的量化。
审题
从上面的分析中可以整理出两个答题方向:
- “是什么”,即阐述现象与原理,在描述原理时,也要注意提供相符的案例;
- “怎么解决”,即从工程化的角度展开,拿出通用化方案,并能量化结果,给出数据与指标。
入手
是什么
首先我们需要了解渲染异常的现象是怎样的。下面的案例就能够很好地说明:
import React from "react";
export default class App extends React.Component {
state = {
fruits: ["apple", "banana", "pear", "orange"]
};
handleClick = () => {
this.setState({
fruits: undefined
});
};
render() {
return (
<div className="App">
<ul>
{this.state.fruits.map((fruit) => (
<li>{fruit}</li>
))}
</ul>
<button onClick={this.handleClick}>Make a chaos</button>
</div>
);
}
}
这段代码包含了两个部分:
- 渲染 this.state.fruits 的常规操作;
- 按钮点击事件,通过点击按钮将 fruits 重置为 undefined 。
整段代码运行之后,展示的内容如下图所示。
这时如果点击按钮,就会看到熟悉的 React 报错。这是因为在开发模式下 react-error-overlay,所以在代码抛出异常后,能够在页面上直接看见。
如果是生产模式呢?即直接把代码打包上线,运行在线上会怎么样呢?就像下面动图所展示的一样,你能看到整个界面会直接消失。
那为什么会产生这样的现象呢?React 官方团队是这样回答的。
组件内的 JavaScript 错误会导致 React 的内部状态被破坏,并且在下一次渲染时产生可能无法追踪的 错误。这些错误基本上是由较早的其他代码(非 React 组件代码)错误引起的,但 React 并没有提供一种在组件中优雅处理这些错误的方式,也无法从错误中恢复。
简而言之,如果有 JavaScript 的错误出现在 React 内部渲染层,就会导致整个应用的崩溃。从现象上来看,就是整个界面从页面中被移除掉,也就展现出了所谓的白屏。
怎么做
通用方案
那该怎么办呢?第 03 讲 “如何避免生命周期中的坑”中有提到错误边界的概念,其中应用到了 getDerivedStateFromError 与 componentDidCatch 两个函数。但这并不是完整的方案,正如前面所说,需要考虑整体性。而完整方案通常是从预防与兜底两个角度下手解决问题。
预防
预防就需要先知道病灶在哪里。既然是渲染异常,就需要分析异常会出现在哪里。我们可以知道,在渲染层,也就是 render 中 return 后的 JSX,都是在进行数据的拼装与转换。
- 如果在拼装的过程中出现错误,那直接会导致编译的失败;
- 但如果在转换的过程中出现错误,就很不容易被发现。
比如上面案例中的 this.state.fruits 为 undefine 就很难被发现,尤其是前端的渲染数据基本上都是通过后端业务接口获取,那么数据是否可靠就成了一个至关重要的问题。
这个问题被称为 null-safety,也就是空安全。它并没有最优解,不仅困扰着我们,也困扰着像 Facebook 这样的大公司,目前他们对于这个问题的解决方案是使用 idx。
idx 在使用时需要配置 Babel 插件,再引入 idx 库,然后通过 idx 函数包裹需要使用的 object,再在回调函数中取需要的值。
import idx from 'idx';
function getFriends() {
return idx(props, _ => _.user.friends[0].friends)
};
function getFriends() {
return props.user == null ? props.user :
props.user.friends == null ? props.user.friends :
props.user.friends[0] == null ? props.user.friends[0] :
props.user.friends[0].friends
}
idx 的代码既不优雅,也不简洁,还需要引入 Babel 插件,所以在社区中使用者寥寥无几,还不如使用 Lodash 的 get 函数。那有没有优雅的解决方案呢?有,就是 ES2020 中的 Optional chaining,中文叫作可选链操作符。如果用可选链操作符重新 render 函数的话,可以写成这样:
render() {
return (
<div className="App">
<ul>
{this.state?.fruits.map((fruit) => (
<li>{fruit}</li>
))}
</ul>
<button onClick={this.handleClick}>Make a chaos</button>
</div>
);
并不是所有浏览器都支持该特性,但可以通过引入 Babel 插件保障浏览器兼容性。
{
"plugins": ["@babel/plugin-proposal-optional-chaining"]
}
可选链操作符是一个遵循 ES 标准、侵入性比较低的方案。那有没有无须配置的方案呢?也是有的,如果是使用 TypeScript 的话,在 3.7 版本中可以直接使用该特性。
兜底
虽然空安全的预防方案都足够完善,但我们不可能在每行代码中都使用,而且旧项目的老代码,逐行修改,也费时费力。所以以防万一,肯定是需要兜底方案的。
兜底应该限制崩溃的层级。错误边界加到哪里,崩溃就止步于哪里,其他组件还可以正常使用,所以只需要给关键的 UI 组件添加错误边界,那就可以应用到我们在第 05 讲 “如何设计 React 组件?”中提到的高阶组件:
export const errorBoundary = (WrappedComponent) => {
return class Wrap extends Component {
state = {
hasError: false,
};
static getDerivedStateFromError(err) {
return {
hasError: true,
err
};
}
componentDidCatch(err: Error, info: React.ErrorInfo) {
console.log(err, info);
}
render() {
return (
this.state.hasError ? <ErrorDefaultUI error={this.state.error} /> : <WrappedComponent {...this.props} />
);
}
}
}
用这个高阶组件拦截报错信息,展示统一的错误页面,也就是 ErrorDefaultUI。使用起来也很简单,直接在原函数上挂上注解就可以了。
@errorBoundary
export default class UserProfile {
}
这个方案可以在团队内部抽取为一个 npm 包,提供能力复用。
量化结果
我们做了这么多事情,那如何评估做得好不好呢?就需要用数据说话,来评估做的事到底有没有价值。
- 在预防层面,需要看空安全方案在项目中的覆盖量,从而保证团队内项目都将空安全用了起来。
- 在兜底层面,同样需要保障方案在项目中的覆盖量,其次需要统计兜底页面成功兜底的次数,最后兜底页面展示时,能够及时完成线上报警。
所以量化结果的工作主要是从覆盖与统计两个方向展开。
覆盖量怎么计算呢?最简单的方法就是直接查看项目的 package.json 文件是否引入相关的库。排查的方案因不同的公司而异。比如有的公司代码检测使用统一的工具,比如 sonar,那你就只需要去写配置文件就可以了。那如果公司什么都没接入呢?就需要结合公司的实际情况了,你甚至可以用一个最原始简单的方案去实现,比如写一个 node 脚本,去拉取相关仓库的代码自行分析,然后每周产出一个 dashboard 查看使用情况。
统计相对容易一些,因为每个公司至少会接入一种统计工具,比如用百度统计、Google 统计或者 mixpanel 来完成业务分析。所以只需要在代码中,添加一行统计代码,就像下面这段代码一样直接上报相关信息就可以。如果有条件的话,最好也接入一下报警系统,这样能够及时发现问题并上报。
componentDidCatch(err: Error, info: React.ErrorInfo) {
trackEvent('error boundary', { err, info });
reportError(error, info)
}
到这里数据就有了,就可以支撑你的汇报工作了。
答题
React 渲染异常的时候,在没有做任何拦截的情况下,会出现整个页面白屏的现象。它的成型原因是在渲染层出现了 JavaScript 的错误,导致整个应用崩溃。这种错误通常是在 render 中没有控制好空安全,使值取到了空值。
所以在治理上,我的方案是这样的,从预防与兜底两个角度去处理。
在预防策略上,引入空安全相关的方案,在做技术选型时,我主要考虑了三个方案:第一个是引入外部函数,比如 Facebook 的 idx 或者 Lodash.get;第二个是引入 Babel 插件,使用 ES 2020 的标准——可选链操作符;第三个是 TypeScript,它在 3.7 版本以后可以直接使用可选链操作符。最后我选择了引入 Babel 插件的方案,因为这个方案外部依赖少,侵入性小,而且团队内没有 TS 的项目。
在兜底策略上,因为考虑到团队内部和我存在一样的问题,就抽取了兜底的公共高阶组件,封装成了 NPM 包供团队内部使用。
从最终的数据来看,预防与治理方案覆盖了团队内 100% 的 React 项目,头三个月兜底组件统计到了日均 10 次的报警信息,其中有 10% 是公司关键业务。那么经过分析与统计,首先是为关键的 UI 组件添加兜底组件进行拦截,然后就是做内部培训,对易错点的代码进行指导,加强 Code Review。后续到现在,线上只收到过 1 次报警。
总结
本讲中的问题与方案都不复杂,但着重强调了工程思维。你会发现在有基本数据支撑下,去描述一件事,会更立体,让人更容易理解这件事的价值,也更加让人信服。
当然工程思维不是只能回答该讲的问题,它还可以解决很多其他问题,所以在日常工作和生活中,你可以思考下哪些问题是可以用这种思维来回答的,欢迎在留言区留下你的答案。
在下一讲中,我将会介绍如何通过描绘基线来完成性能优化。
《大前端高薪训练营》
对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!