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

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

[[408650]]

本文转载自微信公众号「DYBOY」,作者DYBOY。转载本文请联系DYBOY公众号。

随着项目日渐“强壮”,优化首屏加载渲染速度迫在眉睫,其中就采用了 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工程师”,自然是信手拈来:

  1. class ErrorBoundary extends React.Component { 
  2.   constructor(props) { 
  3.     super(props); 
  4.     this.state = { hasError: false }; 
  5.   } 
  6.  
  7.   static getDerivedStateFromError(error) { 
  8.     // Update state so the next render will show the fallback UI. 
  9.     return { hasError: true }; 
  10.   } 
  11.  
  12.   componentDidCatch(error, errorInfo) { 
  13.     // You can also log the error to an error reporting service 
  14.     logErrorToMyService(error, errorInfo); 
  15.   } 
  16.  
  17.   render() { 
  18.     if (this.state.hasError) { 
  19.       // You can render any custom fallback UI 
  20.       return <h1>Something went wrong.</h1>; 
  21.     } 
  22.  
  23.     return this.props.children;  
  24.   } 

使用方法:

  1. <ErrorBoundary> 
  2.   <MyWidget /> 
  3. </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了!

  1. type IProps = { 
  2.   fallback?: ReactNode | null
  3.   onError?: () => void; 
  4.   children: ReactNode; 
  5. }; 
  6.  
  7. type IState = { 
  8.   isShowErrorComponent: boolean; 
  9. }; 
  10.  
  11. class LegoErrorBoundary extends React.Component<IProps, IState> { 
  12.   static getDerivedStateFromError(error: Error) { 
  13.     return { isShowErrorComponent: true }; 
  14.   } 
  15.  
  16.   constructor(props: IProps | Readonly<IProps>) { 
  17.     super(props); 
  18.     this.state = { isShowErrorComponent: false }; 
  19.   } 
  20.  
  21.   componentDidCatch(error: Error) { 
  22.     this.props.onError?.(); 
  23.   } 
  24.  
  25.   render() { 
  26.     const { fallback, children } = this.props; 
  27.     if (this.state.isShowErrorComponent) { 
  28.       if (fallback) { 
  29.         return fallback; 
  30.       } 
  31.       return <>加载失败,请刷新重试!</>; 
  32.     } 
  33.     return children; 
  34.   } 
  35.  
  36. export default LegoErrorBoundary; 

3.2 支持错误手动重试

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

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

  1.   static getDerivedStateFromError(error: Error) { 
  2.     return { isShowErrorComponent: true }; 
  3.   } 
  4.  
  5.   constructor(props) { 
  6.     super(props); 
  7.     this.state = { isShowErrorComponent: false }; 
  8. +   this.handleRetryClick = this.handleRetryClick.bind(this); 
  9.   } 
  10.      
  11. + handleRetryClick() { 
  12. +  this.setState({ 
  13. +    isShowErrorComponent: false
  14. +  }); 
  15. + } 
  16.  
  17.  render() { 
  18.   const { fallback, children } = this.props; 
  19.   if (this.state.isShowErrorComponent) { 
  20.     if (fallback) { 
  21.       return fallback; 
  22.     } 
  23. +    return ( 
  24. +       <div> 
  25. +        {/* CSS重置下按钮样式 */} 
  26. +        <button className="error-retry-btn" onClick={this.handleRetryClick}> 
  27. +          渲染错误,请点击重试! 
  28. +        </button> 
  29. +      </div> 
  30. +    ); 
  31.    } 
  32.    return children; 
  33.  } 

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

  1. import React, { useState } from 'react'
  2.  
  3. const Counter = (props) => { 
  4.     const [count, setCount] = useState(0); 
  5.  
  6.     const handleCounterClick = () => { 
  7.         setCount(count+1); 
  8.     } 
  9.  
  10.     const thr = () => { 
  11.         throw new Error('render error'
  12.     } 
  13.  
  14.     return ( 
  15.         <div> 
  16.             {count === 3 ?  thr() : ''
  17.             计数器:{count
  18.             <br/> 
  19.             <button onClick={handleCounterClick}>点击+1</button> 
  20.         </div> 
  21.     ) 
  22.  
  23. export default Counter; 

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

表现效果:

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

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

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

实现思路:

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

改造如下:

  1. type IState = { 
  2.   isShowErrorComponent: boolean; 
  3. + retryCount: number; 
  4. }; 
  5.  
  6. class LegoErrorBoundary extends React.Component<IProps, IState> { 
  7. -  static getDerivedStateFromError(error: Error) { 
  8. -    return { isShowErrorComponent: true }; 
  9. -  } 
  10.    
  11.   constructor(props: IProps | Readonly<IProps>) { 
  12.     super(props); 
  13. +   this.state = { isShowErrorComponent: false, retryCount: 0 }; 
  14. +  this.handleErrorRetryClick = this.handleErrorRetryClick.bind(this); 
  15.   } 
  16.  
  17.   componentDidCatch(error: Error) { 
  18. +  if (this.state.retryCount > 2) { 
  19. +      this.setState({ 
  20. +        isShowErrorComponent: true
  21. +      }) 
  22. +    } else { 
  23. +      this.setState({ 
  24. +        retryCount: this.state.retryCount + 1, 
  25. +      }); 
  26. +    } 
  27.   } 
  28.  
  29.   render() { 
  30.     const { fallback, children } = this.props; 
  31.     if (this.state.isShowErrorComponent) { 
  32.       if (fallback) { 
  33.         return fallback; 
  34.       } 
  35. +      return <>重试3次后,展示兜底错误提示!</>; 
  36.     } 
  37.     return children; 
  38.   } 
  39.  
  40. export default LegoErrorBoundary; 

来看看效果:

自动重试3次

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

  1. import React, { useEffect, useState } from 'react'
  2.  
  3. const Counter = (props) => { 
  4.   const [count, setCount] = useState(0); 
  5.  
  6.   const handleCounterClick = () => { 
  7.     setCount(count + 1); 
  8.   } 
  9.  
  10.   const thr = () => { 
  11.     throw new Error('render error'
  12.   } 
  13.  
  14.   useEffect(() => { 
  15.     setTimeout(() => { 
  16.       setCount(3) 
  17.     }, 1000); 
  18.   }, []); 
  19.  
  20.   return ( 
  21.     <div> 
  22.       { count === 3 ? thr() : '' } 
  23.       计数器:{ count } 
  24.       <br /> 
  25.       <button onClick={ handleCounterClick }>点击+1</button> 
  26.     </div> 
  27.   ) 
  28.  
  29. export default Counter; 

表现:

处理异步发生的错误

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

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

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

4.1 尝试处理

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

  1. const LazyCounter = React.lazy(() => import('./components/counter/index')); 
  2.  
  3. function App() { 
  4.   return ( 
  5.     <div className="App"
  6.       <header className="App-header"
  7.         <img src={ logo } className="App-logo" alt="logo" /> 
  8.         <ErrorBoundary> 
  9.           <LazyCounter></LazyCounter> 
  10.         </ErrorBoundary> 
  11.       </header> 
  12.     </div> 
  13.   ); 

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

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

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

  1. this.setState({ 
  2.     isShowErrorComponent: true
  3. }) 

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

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

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

4.2 定位原因

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

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

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

4.3 解决方案

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

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

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

该工具函数如下:

  1. /** 
  2.  *  
  3.  * @param {() => Promise} fn 需要重试执行的函数 
  4.  * @param {number} retriesLeft 剩余重试次数 
  5.  * @param {number} interval 间隔重试请求时间,单位ms 
  6.  * @returns Promise<any
  7.  */ 
  8. export const retryLoad = (fn, retriesLeft = 5, interval = 1000) => { 
  9.   return new Promise((resolve, reject) => { 
  10.     fn() 
  11.       .then(resolve) 
  12.       .catch(err => { 
  13.         setTimeout(() => { 
  14.           if (retriesLeft === 1) { 
  15.             // 远程上报错误日志代码 
  16.             reject(err); 
  17.             // coding... 
  18.             console.log(err) 
  19.             return
  20.           } 
  21.           retryLoad(fn, retriesLeft - 1, interval).then(resolve, reject); 
  22.         }, interval); 
  23.       }); 
  24.   }); 

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

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

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

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

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

4.4 表现效果

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

 

  1. 正常按需加载组件成功
  2. 网络原因一直下载失败,展示兜底错误
  3. 网络原因,中途恢复,展示正常功能

 

责任编辑:武晓燕 来源: DYBOY
相关推荐

2010-09-01 09:48:14

UbuntuLinux

2015-05-22 10:03:05

NAS云计算共享存储

2010-04-28 13:31:52

IT技术人员

2021-07-28 14:35:09

代码进度条前端

2022-04-11 09:58:07

数据库SQL

2023-05-26 14:02:29

AI智能

2020-11-04 17:53:49

Windows 10Windows系统

2022-02-22 11:50:16

Python字典代码

2020-12-21 08:32:07

内存性能优化

2020-11-16 08:37:16

MariaDB性能优化

2024-09-03 16:09:59

2018-09-03 00:01:51

华为程序员技术

2016-06-12 10:22:31

云计算

2021-09-29 08:23:56

项目css

2021-06-09 10:15:26

优化性能颗粒度

2022-01-07 13:36:00

MySQL数据库分页

2023-11-19 23:24:21

Golang开发

2009-06-06 15:37:22

Hibernate性能

2023-03-07 07:14:59

电源性价比品牌

2024-08-13 15:26:44

点赞
收藏

51CTO技术栈公众号