Skip to content

12React的渲染异常会造成什么后果?

面试中,在问到 React 的渲染流程后,通常还会问到"React 的渲染异常会造成什么后果",大部分应聘的同学,可能会说"页面会崩掉",但崩掉后是什么样子,又因为什么而崩掉就说不清楚了。这一讲我们就来讲解这个问题应该如何回答。

破题

第 03 讲"如何避免生命周期中的坑"中,有讲到"错误边界"的相关内容:如果渲染异常,在没有任何降级保护措施的情况下,页面会直接显示白屏。但问题到这里并没有结束,在类似"会产生什么影响"或者"会造成什么后果"的问题后,紧接着会询问你"怎么做",这就需要你给出一个方案来解决问题。所以这类问题的中心会发生偏移,从"是什么"到"怎么做",即从"解释原理"到"提供方案"。

那如何评判方案的优劣呢?通常有这样三种层次。

解决问题

在表达方式上,倾向于展示自己有解决该问题的能力,如"我在项目 x 中遇到了一个 y 问题,通过排查 a、b、c 三个点找到了问题所在,最后改动了项目 x 中的 d、e、f 点完成了上线修复"这样的回答。

预防问题

在表达方式上倾向于暗示自己是一个老手,在问题处理中以预防为主。通常会讲自己在初始化项目后,会做这样几条措施以预防同类的低级错误。

工程化方案

上面两种表达还停留在个人层次的基本面,缺乏全盘考虑。你要知道,我们做前端实际上也是在做工程,那我们就需要从工程化的角度去思考方案该怎么做。那什么是工程化呢?工程化这个概念并没有统一的标准说法,但我们仍然可以从项目经验中进行总结提炼。

工程化通常以标准化为抓手完成降本增效,它涉及的方向有:

  • 模块标准化

  • 流程标准化

  • 代码风格标准化

  • ......

通过标准化的方案解决同类型的问题。即在解决的过程中需要考虑减少人为因素,引入自动化过程,比如在统一团队内部代码风格时,我们并不会用人人互相 Review 代码的方式去检查大家的符号与缩进,而是使用 ESLint 自动化处理。所以在解决同类型问题时,沉淀的标准化方案,才能用于更多的业务方向,在团队内部才更具有通用价值。

只解决自己所遇到的问题,贡献度是很低的,如果能够产出通用的方案供团队使用,就非常有价值了。同时是否具备团队视野会影响面试官对个人能力的评定。所以在面试中,不少面试官更偏好这类问题。他们希望从这类问题中了解应聘者在原团队所处的位置,以及所做的贡献是否能匹配更高的职级。这就又涉及了另外一个问题,即如何量化成果?项目价值也好,业绩贡献也好,最后都需要数据支撑,如果只是空口一句修复了 Bug,而没有提供任何参考性的指标,那就没有任何意义。工程是一门重视过程与结果的科学,不仅需要注重实施方案,也需要注重结果的量化。

审题

从上面的分析中可以整理出两个答题方向:

  • "是什么",即阐述现象与原理,在描述原理时,也要注意提供相符的案例;

  • "怎么解决",即从工程化的角度展开,拿出通用化方案,并能量化结果,给出数据与指标。

入手

是什么

首先我们需要了解渲染异常的现象是怎样的。下面的案例就能够很好地说明:

js
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,再在回调函数中取需要的值。

java
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 函数的话,可以写成这样:

java
  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 插件保障浏览器兼容性。

java
{
  "plugins": ["@babel/plugin-proposal-optional-chaining"]
}

可选链操作符是一个遵循 ES 标准、侵入性比较低的方案。那有没有无须配置的方案呢?也是有的,如果是使用 TypeScript 的话,在 3.7 版本中可以直接使用该特性。

兜底

虽然空安全的预防方案都足够完善,但我们不可能在每行代码中都使用,而且旧项目的老代码,逐行修改,也费时费力。所以以防万一,肯定是需要兜底方案的。

兜底应该限制崩溃的层级。错误边界加到哪里,崩溃就止步于哪里,其他组件还可以正常使用,所以只需要给关键的 UI 组件添加错误边界,那就可以应用到我们在第 05 讲"如何设计 React 组件?"中提到的高阶组件:

java
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。使用起来也很简单,直接在原函数上挂上注解就可以了。

java
@errorBoundary
export default class UserProfile {
}

这个方案可以在团队内部抽取为一个 npm 包,提供能力复用。

量化结果

我们做了这么多事情,那如何评估做得好不好呢?就需要用数据说话,来评估做的事到底有没有价值。

  • 在预防层面,需要看空安全方案在项目中的覆盖量,从而保证团队内项目都将空安全用了起来。

  • 在兜底层面,同样需要保障方案在项目中的覆盖量,其次需要统计兜底页面成功兜底的次数,最后兜底页面展示时,能够及时完成线上报警。

所以量化结果的工作主要是从覆盖统计两个方向展开。

覆盖量怎么计算呢?最简单的方法就是直接查看项目的 package.json 文件是否引入相关的库。排查的方案因不同的公司而异。比如有的公司代码检测使用统一的工具,比如 sonar,那你就只需要去写配置文件就可以了。那如果公司什么都没接入呢?就需要结合公司的实际情况了,你甚至可以用一个最原始简单的方案去实现,比如写一个 node 脚本,去拉取相关仓库的代码自行分析,然后每周产出一个 dashboard 查看使用情况。

统计相对容易一些,因为每个公司至少会接入一种统计工具,比如用百度统计、Google 统计或者 mixpanel 来完成业务分析。所以只需要在代码中,添加一行统计代码,就像下面这段代码一样直接上报相关信息就可以。如果有条件的话,最好也接入一下报警系统,这样能够及时发现问题并上报。

java
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 次报警。

总结

本讲中的问题与方案都不复杂,但着重强调了工程思维。你会发现在有基本数据支撑下,去描述一件事,会更立体,让人更容易理解这件事的价值,也更加让人信服。

当然工程思维不是只能回答该讲的问题,它还可以解决很多其他问题,所以在日常工作和生活中,你可以思考下哪些问题是可以用这种思维来回答的,欢迎在留言区留下你的答案。

在下一讲中,我将会介绍如何通过描绘基线来完成性能优化。


[

](https://shenceyun.lagou.com/t/mka)

《大前端高薪训练营》

对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!