专栏首页秋风的笔记性能优化竟白屏,难道真是我的锅?

性能优化竟白屏,难道真是我的锅?

项目日渐“强壮”,性能优化方法之一是采用 React 框架提供的 Reat.lazy() 按需加载的方式,测试过程中,QA说我的优化代码导致了白屏,且看我如何狡辩~

随着项目日渐“强壮”,优化首屏加载渲染速度迫在眉睫,其中就采用了 React 框架提供的 Reat.lazy() 按需加载的方式,测试过程中,在我们的埋点监控平台上,发现了很多网络请求错误的日志,大部分来自分包资源下载失败!难道我的优化变成负优化了???

通过我们的统计平台量化数据可知,用户网络加载失败的概率还是比较大,实验发现,没法儿使用 try{}catch{} 捕获组件渲染错误,查询官方文档,有一个 Error Boundaries 的组件引入眼帘,提供了解决方法,那我们拿到了 demo 应该怎么完善并应用到我们的项目中,以及如何解决按需加载组件失败的场景。

一、背景

某天我在开发了某个功能组件时,发现这个组件引用了一个非常大的三方库,大概100kb,这么大,当然得使用按需加载啦,当我理所当然地觉得这一手“按需加载”的优化很稳,就交给测试同学测试了。

没过多久测试同学反馈,你这个功能咋老白屏?—— 怎么可能?我的代码不可能有BUG!

来到“事故现场”,稍加思索,打开浏览器控制台,发现按需加载的远程文件下载失败了。

emmm~,继续狡辩,这肯定是公司基建不行啊,网络这么不稳,这锅我不背!虽然极力狡辩,可是测试同学就不相信,就认定了是我的问题...

凡事讲证据,冷静下来想一想,万一真的是我的问题,岂不是很尴尬?

为了挽回局面,于是强装镇定说到:“这个问题是网络波动导致,虽然咱们的基建环境不太好,但是为了尽可能提升用户体验,我这尝试下看看如何优化,可通过增加错误监控重试机制,增强用户体验,追求极致!”,赶紧溜回去看看咋解决吧...

一、Error Boundaries

React官方对于“Error Boundaries”的介绍:https://reactjs.org/docs/error-boundaries.html

A JavaScript error in a part of the UI shouldn’t break the whole app. To solve this problem for React users, React 16 introduces a new concept of an “error boundary”.

简单翻译,在 UI 渲染中发生的错误不应该阻塞整个应用的运行,为此,React 16 中提供了一种新的概念“错误边界”。

也就是说,我们可以用“错误边界”来优雅地处理 React 中的 UI 渲染错误问题。

React 中的懒加载使用Suspense包裹,其下的子节点发生了渲染错误,也就是下载组件文件失败,并不会抛出异常,也没法儿捕获错误,那么用 ErrorBoundary 就正好可以决定再子节点发生渲染错误(常见于白屏)时候的处理方式。

注意:Error boundaries 不能捕获如下类型的错误:

  • 事件处理(了解更多)
  • 异步代码 (例如 setTimeout 或 requestAnimationFrame 回调)
  • 服务端渲染
  • 来自ErrorBoundary组件本身的错误 (而不是来自它包裹子节点发生的错误)

二、借鉴

老夫作为“CV工程师”,自然是信手拈来:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // You can also log the error to an error reporting service
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

使用方法:

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
  • static getDerivedStateFromError(error):在 render phase 阶段,子节点发生UI渲染抛出错误时候执行,return 的 {hasError: true} 用于更新 state 中的值,不允许包含副作用的代码,触发重新渲染(渲染fallback UI)内容。
  • componentDidCatch(error, errorInfo):在commit phase 阶段,捕获子节点中发生的错误,因此在该方法中可以执行有副作用的代码,例如用于打印上报错误日志。

官方案例在线演示地址:https://codepen.io/gaearon/pen/wqvxGa?editors=0010

与此同时官方的建议:

In the event of an error, you can render a fallback UI with componentDidCatch() by calling setState, but this will be deprecated in a future release. Use static getDerivedStateFromError() to handle fallback rendering instead.

推荐大家在 getDerivedStateFromError() 中处理 fallback UI,而不是在 componentDidCatch() 方法中,componentDidCatch() 在未来的 React 版本中可能会被废弃,当然只是推荐,仅供参考。

三、修饰

官方的 demo 组件如果要嵌入业务代码中,还是有一些简陋,为了更好地适应业务代码以及更加通用,我们一步步来改造。

3.1 支持自定义fallback以及error callback

目标:满足些场景下,开发者需要自行设置 fallback 的UI,以及自定义错误处理回调

实现也非常简单,基于 TypeScript,再加上一些类型声明,一个支持自定义fallback 和错误回调的 ErrorBoundary 就OK了!

type IProps = {
  fallback?: ReactNode | null;
  onError?: () => void;
  children: ReactNode;
};

type IState = {
  isShowErrorComponent: boolean;
};

class LegoErrorBoundary extends React.Component<IProps, IState> {
  static getDerivedStateFromError(error: Error) {
    return { isShowErrorComponent: true };
  }

  constructor(props: IProps | Readonly<IProps>) {
    super(props);
    this.state = { isShowErrorComponent: false };
  }

  componentDidCatch(error: Error) {
    this.props.onError?.();
  }

  render() {
    const { fallback, children } = this.props;
    if (this.state.isShowErrorComponent) {
      if (fallback) {
        return fallback;
      }
      return <>加载失败,请刷新重试!</>;
    }
    return children;
  }
}

export default LegoErrorBoundary;

3.2 支持错误手动重试

我们的按需加载组件就像局部组件更新一样,当组件按需加载的渲染失败时候,理论上我们应该给用户提供手动/自动重试机制

手动重试机制,简单的做法就是,在 fallback UI 中设置重试按钮

  static getDerivedStateFromError(error: Error) {
    return { isShowErrorComponent: true };
  }

  constructor(props) {
    super(props);
    this.state = { isShowErrorComponent: false };
+   this.handleRetryClick = this.handleRetryClick.bind(this);
  }
    
+ handleRetryClick() {
+  this.setState({
+    isShowErrorComponent: false,
+  });
+ }

 render() {
  const { fallback, children } = this.props;
  if (this.state.isShowErrorComponent) {
    if (fallback) {
      return fallback;
    }
+    return (
+       <div>
+        {/* CSS重置下按钮样式 */}
+        <button className="error-retry-btn" onClick={this.handleRetryClick}>
+          渲染错误,请点击重试!
+        </button>
+      </div>
+    );
   }
   return children;
 }

写一个普通的Counter(计数器)组件:

import React, { useState } from 'react';

const Counter = (props) => {
    const [count, setCount] = useState(0);

    const handleCounterClick = () => {
        setCount(count+1);
    }

    const thr = () => {
        throw new Error('render error')
    }

    return (
        <div>
            {count === 3 ?  thr() : ''}
            计数器:{count}
            <br/>
            <button onClick={handleCounterClick}>点击+1</button>
        </div>
    )
}

export default Counter;

我们使用这个 LegoErrorBoundary 组件包裹 Counter 计数器组件,Counter 组件中在第三次点击时候抛出一个异常,来看看 ErrorBoundary 的捕获处理情况!

表现效果:

如果咱不处理这个错误,就会导致“白屏”,也不利于研发同学排查问题,特别是涉及到一些异步渲染的问题。

3.3 支持发生错误自动重试渲染有限次数

手动重试,会增加用户的一个操作,这会增加用户的操作成本,为了更加便捷用户使用软件,提升用户体验,来瞅瞅采用自动重试有限次数的机制应该如何实现。

实现思路:

重试次数统计变量:retryCount,记录重试渲染次数,超过次数则使用兜底渲染“错误提示”UI。

改造如下:

type IState = {
  isShowErrorComponent: boolean;
+ retryCount: number;
};

class LegoErrorBoundary extends React.Component<IProps, IState> {
-  static getDerivedStateFromError(error: Error) {
-    return { isShowErrorComponent: true };
-  }
  
  constructor(props: IProps | Readonly<IProps>) {
    super(props);
+   this.state = { isShowErrorComponent: false, retryCount: 0 };
+  this.handleErrorRetryClick = this.handleErrorRetryClick.bind(this);
  }

  componentDidCatch(error: Error) {
+  if (this.state.retryCount > 2) {
+      this.setState({
+        isShowErrorComponent: true,
+      })
+    } else {
+      this.setState({
+        retryCount: this.state.retryCount + 1,
+      });
+    }
  }

  render() {
    const { fallback, children } = this.props;
    if (this.state.isShowErrorComponent) {
      if (fallback) {
        return fallback;
      }
+      return <>重试3次后,展示兜底错误提示!</>;
    }
    return children;
  }
}

export default LegoErrorBoundary;

来看看效果:

自动重试3次

改改Counter组件的代码,看看能否处理好异步错误的问题:

import React, { useEffect, useState } from 'react';

const Counter = (props) => {
  const [count, setCount] = useState(0);

  const handleCounterClick = () => {
    setCount(count + 1);
  }

  const thr = () => {
    throw new Error('render error')
  }

  useEffect(() => {
    setTimeout(() => {
      setCount(3)
    }, 1000);
  }, []);

  return (
    <div>
      { count === 3 ? thr() : '' }
      计数器:{ count }
      <br />
      <button onClick={ handleCounterClick }>点击+1</button>
    </div>
  )
}

export default Counter;

表现:

处理异步发生的错误

也是OK的!这说明,属于业务逻辑的代码比如:网络数据请求、异步执行导致渲染出错的情况,“错误边界”组件都是可以拦截并处理。

当前结论:使用 Errorboundary 组件包裹,能够 handle 住子组件发生的渲染 error

四、异步加载组件网络错误

4.1 尝试处理

App.js 中的 Counter 组件引用改为按需加载,然后在浏览器中模拟分包的组件下载失败情况,看看是否能够拦住!

const LazyCounter = React.lazy(() => import('./components/counter/index'));

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={ logo } className="App-logo" alt="logo" />
        <ErrorBoundary>
          <LazyCounter></LazyCounter>
        </ErrorBoundary>
      </header>
    </div>
  );
}

结果白屏了!也可以看到 ErrorBoundary 组件中打印了捕获到的错误信息:

ChunkLoadError: Loading chunk 3 failed.
(error: http://localhost:5000/static/js/3.18a27ea8.chunk.js)
    at Function.a.e ((index):1)
    at App.js:7
    at T (react.production.min.js:18)
    at Hu (react-dom.production.min.js:269)
    at Pi (react-dom.production.min.js:250)
    at xi (react-dom.production.min.js:250)
    at _i (react-dom.production.min.js:250)
    at vi (react-dom.production.min.js:243)
    at fi (react-dom.production.min.js:237)
    at Gi (react-dom.production.min.js:285)

拦截到了,但是没有触发3次重试,componentDidCatch 中的 console.log('发生错误!', error); 只打印了一次错误日志,就挂了,看到大家的推荐做法是,发生一次错误就能够处理到,所以尝试把 retryCount0 的时候就设置 isShowErrorComponent 的值,

this.setState({
    isShowErrorComponent: true,
})

这时能够显示错误的fallback UI:

image

但没法儿实现自动重试有限次数异步组件的渲染,否则如果还按照之前的方案,就会继续向上抛出错误,如果没有后续 catch 处理错误,页面就会白屏!

然后尝试主动触发重新渲染,发现并没有发起二次请求,点击重试只是捕获到了错误~

4.2 定位原因

不生效,于是想到声明引入组件的代码如下:

const LazyCounter = React.lazy(() => import('./components/counter/index'));

经过测试验证,的确打印了错误日志,而只发起了一次网络请求的原因是,该 LazyCounter 组件并没有在组件中声明,重新渲染的时候,LazyCounter 组件作为组件外的全局变量,不受 rerender 影响。

4.3 解决方案

因此,想要解决网络加载错误问题并重试,就得在声明代码 import 的时候处理,因为import 返回的是一个Promise,自然就可以用 .catch 捕获异常。

- const LazyCounter = React.lazy(() => import('./components/counter/index'));

+ const LazyCounter = React.lazy(() => import('./components/counter/index').catch(err => {
+   console.log('dyboy:', err);
+ }));

import() 代码执行的时候才会触发网络请求拉取分包资源文件,所以我们可以在异常捕获中重试,并且可以重试一定次数,所以需要实现一个工具函数,统一处理 import() 动态引入可能失败的问题。

该工具函数如下:

/**
 * 
 * @param {() => Promise} fn 需要重试执行的函数
 * @param {number} retriesLeft 剩余重试次数
 * @param {number} interval 间隔重试请求时间,单位ms
 * @returns Promise<any>
 */
export const retryLoad = (fn, retriesLeft = 5, interval = 1000) => {
  return new Promise((resolve, reject) => {
    fn()
      .then(resolve)
      .catch(err => {
        setTimeout(() => {
          if (retriesLeft === 1) {
            // 远程上报错误日志代码
            reject(err);
            // coding...
            console.log(err)
            return;
          }
          retryLoad(fn, retriesLeft - 1, interval).then(resolve, reject);
        }, interval);
      });
  });
}

使用的时候只需要将 import() 包一下:

const LazyCounter = React.lazy(() => retryLoad(import('./components/counter/index')));

与此同时,为了多次请求下,“错误边界”组件能够捕获到错误,同时能够触发兜底渲染逻辑,把 ErrorBoundary 组件发生错误时候直接处理展示兜底逻辑,不做重复渲染。则将 ErrorBoundary 中的重渲染计数逻辑代码删除即可。

componentDidCatch(error) {
  console.log('发生错误!', error);
  this.setState({
    isShowErrorComponent: true,
  });
}

另外,如果我们既想要渲染出错后的重试,还需要保证多次网络出错后能有错误上报,那么只需要在 retryLoad 工具函数中增加错误日志远程上报逻辑,然后不抛出 reject()

4.4 表现效果

处理如下三种情况的效果:

  1. 正常按需加载组件成功
  2. 网络原因一直下载失败,展示兜底错误
  3. 网络原因,中途恢复,展示正常功能 录制的GIf比较大,微信上无法展示,可点击阅读全文查看效果!⬇️

三种情况下的处理效果

当我把网络加载失败后的处理结果给到QA同学,QA同学赞许地说道:“老哥,稳!”

五、总结

通过针对业务优化场景中遇到的加载失败问题,尝试借助 ErrorBoundary 以及 import() 网络重试加载机制,保证了程序的健壮性,降低前端“白屏率”,换个角度说,一定层次上提升了用户的体验和质量,对于前端工程的收益是较为明显!

在本次的问题处理过程中,其实还有一些值得探究的地方:

  1. ErrorBoundary 捕获错误的原理是啥?为什么不能处理本身错误?
  2. ErrorBoundary 除了接收 JSX,是否可以扩展接收组件等,是否 fallback 可以和函数联动?
  3. ErrorBoundary 是否可以作为前端白屏监控?或更多应用场景?思考&扩展一下?

感谢大家关注笔者的微信公众号:DYBOY,面试内推、技术交流~

Reference

  • static getDerivedStateFromError
  • componentDidCatch
  • Suspense for Data Fetching (Experimental)

本文分享自微信公众号 - 秋风的笔记(qiufengnote)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2021-08-06

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 记一次单机系统的性能优化:最后竟是 TCP 的锅

    这篇文章的主题是记录一次 Python 程序的性能优化,在优化的过程中遇到的问题,以及如何去解决的。为大家提供一个优化的思路,首先要声明的一点是,我的方式不是唯...

    杰哥的IT之旅
  • 【技术创作101训练营】教你一招另辟蹊径抓取美团火锅数据

    今天借腾讯云+社区这个机会,给大家简单分享下美团数据的简易获取教程,希望小小白们也可以学会这招,下次再有人找你要数据的时候,你就可以拍腿板跟她说,让我来!

    前端皮皮
  • 游戏之外的道理

    https://www.sekai.co/trust/ 最近玩到一款讲了大道理的小游戏,『信任的进化』。阅读原文可以进行游戏,不过加载时间有点长,而且微信还屏蔽...

    沙因Sign
  • 测试玩梗--欢迎补充

    客户安装IE反馈不兼容,投诉被移交到测试部门,苦口婆心劝解无效,一小哥用软件把Chrome浏览器安装包图表换成IE,称新款IE浏览器。客户安装之后表示非常好,而...

    FunTester
  • 测试梗--欢迎补充

    客户安装IE反馈不兼容,投诉被移交到测试部门,苦口婆心劝解无效,一小哥用软件把Chrome浏览器安装包图表换成IE,称新款IE浏览器。客户安装之后表示非常好,而...

    FunTester
  • 我在未来餐厅让机器人炒了三道菜,没想到,全糊了

    我坐在舒适柔软的人体工学椅上,面前的悬浮桌面上固定着精致的碗筷,旁边的自动门打开,一位极富东方美的黑直长机器人走进来,端着一碟看上去色泽诱人却不知是什么动物的肉...

    量子位
  • 这才叫积极脱单:为自己搭建相亲网站,一人分饰三十角

    内容提要:霓虹国一直脑洞很大,从动漫到游戏到各种整蛊综艺,总能让我们大跌眼镜,最近有一档节目中就出现了这么一个神奇的程序员小哥......他神奇就神奇在,为自己...

    HyperAI超神经
  • 活动预告 | 第三届上海市青少年人工智能创新大赛即将开幕,优图邀你趣看展

    10月17日到18日,第三届上海市青少年人工智能创新大赛将正式举行。大赛期间,腾讯优图将在AI嘉年华活动环节中展示自主研发的VisionSeed智能视觉模组,以...

    优图实验室
  • 新闻动态|我们把“火锅”搬到了2019ADM现场,优图VisionSeed互动游戏首秀

    五花八门的艺术装置、脑洞大开的设计新作、沉浸式体验空间……10月31日,一场妙趣横生的高颜值展览——ADM亚洲设计管理论坛暨生活创新展在杭州开幕。

    优图实验室
  • 一个女程序员的日常吐槽

    忙了一个下午了,也不知道自己在忙着一些什么,善后一个外包公司的项目,他们开发好之后,也没有测试,也没有过多的优化,我打开小程序,把真个流程跑了一遍,然后再打开后...

    王小婷
  • Vue开发微信H5页面总结

    刚入门前端的时候写过很多的微信H5页面,时隔多年感觉应该是手到擒来,不曾想竟很是费了一些功夫。现在把本次开发过程中遇到的问题以及我是如何解决的,做个记录。防止自...

    前端黑板报
  • 李彦宏:少年,上车吗?可以吃火锅的那种,还是鸳鸯锅底的

    镁客网
  • 一周播报| 测试用例写了,测试却没测,线上发现Bug是谁的锅?

    测试用例确实可以衡量一个项目测试质量。但是测试都没测?这个Bug的锅是谁的,还没点13数吗

    养码场
  • 生活中的几点感想-20210205

    前几天无意中看到这篇文章的时候,是带着一种茫然的心态,但是看完之后内心不由得忧伤起来。

    jeanron100
  • 日本戏精程序员:为自己搭建相亲网站,一人分饰三十角

    霓虹国一直脑洞很大,从动漫到游戏到各种整蛊综艺,总能让我们大跌眼镜,最近有一档节目中就出现了这么一个神奇的程序员小哥......

    HyperAI超神经
  • 同样级别iOS程序员,为啥比我菜的程序员薪资都比我高?

    作为程序员,都有一种相同的焦虑——即当一次又一次的新技术浪潮袭来,总会不由自主的拼命跟随,总是担心如果不紧跟新技术的潮流,将会被时代所抛弃。

    原来是泽镜啊
  • 架构漫谈(三):如何做好架构之识别问题

    按照之前架构的定义,做好架构首先需要做的就是识别出需要解决的问题。一般来说,如果把真正的问题找到,那么问题就已经解决了80%了。这个能力基本上就决定了架构师的水...

    Java高级架构
  • 【转】架构漫谈(三):如何做好架构之识别问题

    按照之前架构的定义,做好架构首先需要做的就是识别出需要解决的问题。一般来说,如果把真正的问题找到,那么问题就已经解决80%了。这个能力基本上就决定了架构师的水平...

    YG
  • 2017—2019 你的朋友圈火了

    2017—2019 在朋友圈里悄悄火了。 为了加入朋友圈狂欢,在昨夜的潮汕牛肉火锅局里,编辑部集体翻自己的 2017 老照片,翻到手软。 ? 不过都 9102...

    腾讯大讲堂

扫码关注云+社区

领取腾讯云代金券