「React进阶」我在函数组件中可以随便写-通俗异步组件原理

开发 前端
本文通过一些脑洞大开,奇葩的操作,让大家明白了 Susponse ,componentDidCatch 等原理。我相信不久之后,随着 React 18 发布,Susponse 将崭露头角,未来可期。

[[428609]]

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

前言

接下来的几篇文章将围绕一些‘猎奇’场景,从原理颠覆对 React 的认识。每一个场景下背后都透漏出 React 原理,

我可以认真的说,看完这篇文章,你将掌握:

1 componentDidCatch 原理

2 susponse 原理

3 异步组件原理。

不可能的事

我的函数组件中里可以随便写,很多同学看到这句话的时候,脑海里应该浮现的四个字是:怎么可能?因为我们印象中的函数组件,是不能直接使用异步的,而且必须返回一段 Jsx 代码。

那么今天我将打破这个规定,在我们认为是组件的函数里做一些意想不到的事情。接下来跟着我的思路往下看吧。

首先先来看一下 jsx ,在 React JSX 中 <div /> 代表 DOM 元素,而 <Index> 代表组件, Index 本质是函数组件或类组件。

  1. <div /> 
  2. <Index /> 

透过现象看本质,JSX 为 React element 的表象,JSX 语法糖会被 babel 编译成 React element 对象 ,那么上述中:

  • <div /> 不是真正的 DOM 元素,是 type 属性为 div 的 element 对象。
  • 组件 Index 是 type 属性为类或者组件本身的 element 对象。

言归正传,那么以函数组件为参考,Index 已经约定俗成为这个样子:

  1. function Index(){ 
  2.     /* 不能直接的进行异步操作 */ 
  3.     /* return 一段 jsx 代码 */ 
  4.     return <div></div> 

如果不严格按照这个格式写,通过 jsx 形式挂载,就会报错。看如下的例子??:

  1. /* Index  不是严格的组件形式 */ 
  2. function Index(){ 
  3.     return { 
  4.        name:'《React进阶实践指南》' 
  5.     } 
  6. /* 正常挂载 Index 组件 */ 
  7. export default class App extends React.Component{ 
  8.     render(){ 
  9.         return <div> 
  10.             hello world , let us learn React!  
  11.             <Index /> 
  12.         </div> 
  13.     } 

我们通过报错信息,不难发现原因,children 类型错误,children 应该是一个 React element 对象,但是 Index 返回的却是一个普通的对象。

既然不能是普通的对象,那么如果 Index 里面更不可能有异步操作了,比如如下这种情况:

  1. /* 例子2 */ 
  2. unction Index(){ 
  3.    return new Promise((resolve)=>{ 
  4.        setTimeout(()=>{ 
  5.            resolve({ name:'《React进阶实践指南》'  }) 
  6.        },1000) 
  7.    }) 

同样也会报上面的错误,所以在一个标准的 React 组件规范下:

  • 必须返回 jsx 对象结构,不能返回普通对象。
  • render 执行过程中,不能出现异步操作。

不可能的事变为可能

那么如何破局,将不可能的事情变得可能。首先要解决的问题是 报错问题 ,只要不报错,App 就能正常渲染。不难发现产生的错误时机都是在 render 过程中。那么就可以用 React 提供的两个渲染错误边界的生命周期 componentDidCatch 和 getDerivedStateFromError。

因为我们要在捕获渲染错误之后做一些骚操作,所以这里选 componentDidCatch。接下来我们用 componentDidCatch 改造一下 App。

  1. export default class App extends React.Component{ 
  2.     state = { 
  3.        isError:false 
  4.     } 
  5.     componentDidCatch(e){ 
  6.          this.setState({ isError:true }) 
  7.     } 
  8.     render(){ 
  9.         return <div> 
  10.             hello world , let us learn React! 
  11.             {!this.state.isError &&  <Index />} 
  12.         </div> 
  13.     } 

用 componentDidCatch 捕获异常,渲染异常

可以看到,虽然还是报错,但是至少页面可以正常渲染了。现在做的事情还不够,以第一 Index 返回一个正常对象为例,我们想要挂载这个组件,还要获取 Index 返回的数据,那么怎么办呢?

突然想到 componentDidCatch 能够捕获到渲染异常,那么它的内部就应该像 try{}catch(){} 一样,通过 catch 捕获异常。类似下面这种:

  1. try{ 
  2.     // 尝试渲染 
  3. }catch(e){ 
  4.      // 渲染失败,执行componentDidCatch(e) 
  5.      componentDidCatch(e)  

那么如果在 Index 中抛出的错误,是不是也可以在 componentDidCatch 接收到。于是说干就干。我们把 Index 改变由 return 变成 throw ,然后在 componentDidCatch 打印错误 error。

  1. function Index(){ 
  2.     throw { 
  3.        name:'《React进阶实践指南》' 
  4.     } 

将 throw 对象返回。

  1. componentDidCatch(e){ 
  2.     console.log('error:',e) 
  3.     this.setState({ isError:true }) 

通过 componentDidCatch 捕获错误。此时的 e 就是 Index throw 的对象。接下来用子组件抛出的对象渲染。

  1. export default class App extends React.Component{ 
  2.     state = { 
  3.        isError:false
  4.        childThrowMes:{} 
  5.     } 
  6.     componentDidCatch(e){ 
  7.           console.log('error:',e) 
  8.          this.setState({ isError:true , childThrowMes:e }) 
  9.     } 
  10.     render(){ 
  11.         return <div> 
  12.             hello world , let us learn React! 
  13.             {!this.state.isError ?  <Index /> : <div> {this.state.childThrowMes.name} </div>} 
  14.         </div> 
  15.     } 

捕获到 Index 抛出的异常对象,用对象里面的数据重新渲染。

效果:

大功告成,子组件 throw 错误,父组件 componentDidCatch 接受并渲染,这波操作是不是有点...

但是 throw 的所有对象,都会被正常捕获吗?于是我们把第二个 Index 抛出的 Promise 对象用 componentDidCatch 捕获。看看会是什么吧?

如上所示,Promise 对象没有被正常捕获,捕获的是异常的提示信息。在异常提示中,可以找到 Suspense 的字样。那么 throw Promise 和 Suspense 之间肯定存在着关联,换句话说就是 Suspense 能够捕获到 Promise 对象。而这个错误警告,就是 React 内部发出找不到上层的 Suspense 组件的错误。

到此为止,可以总结出:

  • componentDidCatch 通过 try{}catch(e){} 捕获到异常,如果我们在渲染过程中,throw 出来的普通对象,也会被捕获到。但是 Promise 对象,会被 React 底层第 2 次抛出异常。
  • Suspense 内部可以接受 throw 出来的 Promise 对象,那么内部有一个 componentDidCatch 专门负责异常捕获。

鬼畜版——我的组件可以写异步

即然直接 throw Promise 会在 React 底层被拦截,那么如何在组件内部实现正常编写异步操作的功能呢?既然 React 会拦截组件抛出的 Promise 对象,那么如果把 Promise 对象包装一层呢? 于是我们把 Index 内容做修改。

  1. function Index(){ 
  2.     throw { 
  3.         current:new Promise((resolve)=>{ 
  4.             setTimeout(()=>{ 
  5.                 resolve({ name:'《React进阶实践指南》'  }) 
  6.             },1000) 
  7.         }) 
  8.     } 

如上,这回不在直接抛出 Promise,而是在 Promise 的外面在包裹一层对象。接下来打印错误看一下。

可以看到,能够直接接收到 Promise 啦,接下来我们执行 Promise 对象,模拟异步请求,用请求之后的数据进行渲染。于是修改 App 组件。

  1. export default class App extends React.Component{ 
  2.     state = { 
  3.        isError:false
  4.        childThrowMes:{} 
  5.     } 
  6.     componentDidCatch(e){ 
  7.          const errorPromise = e.current 
  8.          Promise.resolve(errorPromise).then(res=>{ 
  9.             this.setState({ isError:true , childThrowMes:res }) 
  10.          }) 
  11.     } 
  12.     render(){ 
  13.         return <div> 
  14.             hello world , let us learn React! 
  15.             {!this.state.isError ?  <Index /> : <div> {this.state.childThrowMes.name} </div>} 
  16.         </div> 
  17.     } 

在 componentDidCatch 的参数 e 中获取 Promise ,Promise.resolve 执行 Promise 获取数据并渲染。

效果:

可以看到数据正常渲染了,但是面临一个新的问题:目前的 Index 不是一个真正意义上的组件,而是一个函数,所以接下来,改造 Index 使其变成正常的组件,通过获取异步的数据。

  1. function Index({ isResolve = false , data }){ 
  2.     const [ likeNumber , setLikeNumber ] = useState(0) 
  3.     if(isResolve){ 
  4.         return <div> 
  5.             <p> 名称:{data.name} </p> 
  6.             <p> star:{likeNumber} </p> 
  7.             <button onClick={()=> setLikeNumber(likeNumber+1)} >点赞</button> 
  8.         </div> 
  9.     }else
  10.         throw { 
  11.             current:new Promise((resolve)=>{ 
  12.                 setTimeout(()=>{ 
  13.                     resolve({ name:'《React进阶实践指南》'  }) 
  14.                 },1000) 
  15.             }) 
  16.         } 
  17.     } 
  • Index 中通过 isResolve 判断组件是否加在完成,第一次的时候 isResolve = false 所以 throw Promise。
  • 父组件 App 中接受 Promise ,得到数据,改变状态 isResolve ,二次渲染,那么第二次 Index 就会正常渲染了。看一下 App 如何写:
  1. export default class App extends React.Component{ 
  2.     state = { 
  3.        isResolve:false
  4.        data:{} 
  5.     } 
  6.     componentDidCatch(e){ 
  7.          const errorPromise = e.current 
  8.          Promise.resolve(errorPromise).then(res=>{ 
  9.             this.setState({ data:res,isResolve:true  }) 
  10.          }) 
  11.     } 
  12.     render(){ 
  13.         const {  isResolve ,data } = this.state 
  14.         return <div> 
  15.             hello world , let us learn React! 
  16.             <Index data={data} isResolve={isResolve} /> 
  17.         </div> 
  18.     } 

通过 componentDidCatch 捕获错误,然后进行第二次渲染。

效果:

达到了目的。这里就简单介绍了一下异步组件的原理。上述引入了一个 Susponse 的概念,接下来研究一下 Susponse。

飞翔版——实现一个简单 Suspense

Susponse 是什么?Susponse 英文翻译 悬停。在 React 中 Susponse 是什么呢?那么正常情况下组件染是一气呵成的,在 Susponse 模式下的组件渲染就变成了可以先悬停下来。

首先解释为什么悬停?

Susponse 在 React 生态中的位置,重点体现在以下方面。

  • code splitting(代码分割) :哪个组件加载,就加载哪个组件的代码,听上去挺拗口,可确实打实的解决了主文件体积过大的问题,间接优化了项目的首屏加载时间,我们知道过浏览器加载资源也是耗时的,这些时间给用户造成的影响就是白屏效果。
  • spinner 解耦:正常情况下,页面展示是需要前后端交互的,数据加载过程不期望看到 无数据状态->闪现数据的场景,更期望的是一种spinner数据加载状态->加载完成展示页面状态。比如如下结构:
  1. <List1 /> 
  2. <List2 /> 

List1 和 List2 都使用服务端请求数据,那么在加载数据过程中,需要 Spin 效果去优雅的展示 UI,所以需要一个 Spin 组件,但是 Spin 组件需要放入 List1 和 List2 的内部,就造成耦合关系。现在通过 Susponse 来接耦 Spin,在业务代码中这么写道:

  1. <Suspense fallback={ <Spin /> }  > 
  2.     <List1 /> 
  3.     <List2 /> 
  4. </Suspense> 

 

当 List1 和 List2 数据加载过程中,用 Spin 来 loading 。把 Spin 解耦出来,就像看电影,如果电影加载视频流卡住,不期望给用户展示黑屏幕,取而代之的是用海报来填充屏幕,而海报就是这个 Spin 。

  • render data:整个 render 过程都是同步执行一气呵成的,那样就会 组件 Render => 请求数据 => 组件 reRender ,但是在 Suspense 异步组件情况下允许调用 Render => 发现异步请求 => 悬停,等待异步请求完毕 => 再次渲染展示数据。这样无疑减少了一次渲染。

接下来解释如何悬停

上面理解了 Suspense 初衷,接下来分析一波原理,首先通过上文中,已经交代了 Suspense 原理,如何悬停,很简单粗暴,直接抛出一个异常;

异常是什么,一个 Promise ,这个 Promise 也分为二种情况:

  • 第一种就是异步请求数据,这个 Promise 内部封装了请求方法。请求数据用于渲染。
  • 第二种就是异步加载组件,配合 webpack 提供的 require() api,实现代码分割。

悬停后再次render

在 Suspense 悬停后,如果想要恢复渲染,那么 rerender 一下就可以了。

如上详细介绍了 Suspense 。接下来到了实践环节,我们去尝试实现一个 Suspense ,首先声明一下这个 Suspense 并不是 React 提供的 Suspense ,这里只是模拟了一下它的大致实现细节。

本质上 Suspense 落地瓶颈也是对请求函数的的封装,Suspense 主要接受 Promise,并 resolve 它,那么对于成功的状态回传到异步组件中,对于开发者来说是未知的,对于 Promise 和状态传递的函数 createFetcher,应该满足如下的条件。

  1. const fetch = createFetcher(function getData(){ 
  2.     return new Promise((resolve)=>{ 
  3.        setTimeout(()=>{ 
  4.             resolve({ 
  5.                 name:'《React进阶实践指南》'
  6.                 author:'alien' 
  7.             }) 
  8.        },1000) 
  9.     }) 
  10. }) 
  11. function Text(){ 
  12.     const data = fetch() 
  13.     return <div> 
  14.         name: {data.name
  15.         author:{data.author} 
  16.     </div> 
  • 通过 createFetcher 封装请求函数。请求函数 getData 返回一个 Promise ,这个 Promise 的使命就是完成数据交互。
  • 一个模拟的异步组件,内部使用 createFetcher 创建的请求函数,请求数据。

接下来就是 createFetcher 函数的编写。

  1. function createFetcher(fn){ 
  2.     const fetcher = { 
  3.         status:'pedding'
  4.         result:null
  5.         p:null 
  6.     } 
  7.     return function (){ 
  8.         const getDataPromise = fn() 
  9.         fetcher.p = getDataPromise 
  10.         getDataPromise.then(result=>{ /* 成功获取数据 */ 
  11.             fetcher.result = result 
  12.             fetcher.status = 'resolve' 
  13.         }) 
  14.         if(fetcher.status === 'pedding'){ /* 第一次执行中断渲染,第二次 */ 
  15.             throw fetcher 
  16.         } 
  17.         /* 第二次执行 */ 
  18.         if(fetcher.status==='resolve'
  19.         return fetcher.result 
  20.     } 
  • 这里要注意的是 fn 就是 getData, getDataPromise 就是 getData返回的 Promise。
  • 返回一个函数 fetch ,在 Text 内部执行,第一次组件渲染,由于 status = pedding 所以抛出异常 fetcher 给 Susponse,渲染中止。
  • Susponse 会在内部 componentDidCatch 处理这个fetcher,执行 getDataPromise.then, 这个时候status已经是resolve状态,数据也能正常返回了。
  • 接下来Susponse再次渲染组件,此时就能正常的获取数据了。

既然有了 createFetcher 函数,接下来就要模拟上游组件 Susponse 。

  1. class MySusponse extends React.Component{ 
  2.     state={ 
  3.         isResolve:true 
  4.     } 
  5.     componentDidCatch(fetcher){ 
  6.         const p = fetcher.p 
  7.         this.setState({ isResolve:false }) 
  8.         Promise.resolve(p).then(()=>{ 
  9.             this.setState({ isResolve:true }) 
  10.         }) 
  11.     } 
  12.     render(){ 
  13.         const { fallback, children  } = this.props 
  14.         const { isResolve } = this.state 
  15.         return isResolve ? children : fallback 
  16.     } 

我们编写的 Susponse 起名字叫 MySusponse 。

  • MySusponse 内部 componentDidCatch 通过 Promise.resolve 捕获 Promise 成功的状态。成功后,取缔 fallback UI 效果。

大功告成,接下来就是体验环节了。我们尝试一下 MySusponse 效果。

  1. export default function Index(){ 
  2.     return <div> 
  3.         hello,world 
  4.        <MySusponse fallback={<div>loading...</div>} > 
  5.             <Text /> 
  6.        </MySusponse> 
  7.     </div> 

效果:

虽然实现了效果,但是和真正的 Susponse 还差的很远,首先暴露出的问题就是数据可变的问题。上述编写的 MySusponse 数据只加载一次,但是通常情况下,数据交互是存在变数的,数据也是可变的。

衍生版——实现一个错误异常处理组件

言归正传,我们不会在函数组件中做如上的骚操作,也不会自己去编写 createFetcher 和 Susponse。但是有一个场景还是蛮实用的,那就是对渲染错误的处理,以及 UI 的降级,这种情况通常出现在服务端数据的不确定的场景下,比如我们通过服务端的数据 data 进行渲染,像如下场景:

  1. <div>{ data.name }</div> 

 

如果 data 是一个对象,那么会正常渲染,但是如果 data 是 null,那么就会报错,如果不加渲染错误边界,那么一个小问题会导致整个页面都渲染不出来。

那么对于如上情况,如果每一个页面组件,都加上 componentDidCatch 这样捕获错误,降级 UI 的方式,那么代码过于冗余,难以复用,无法把降级的 UI 从业务组件中解耦出来。

所以可以统一写一个 RenderControlError 组件,目的就是在组件的出现异常的情况,统一展示降级的 UI ,也确保了整个前端应用不会奔溃,同样也让服务端的数据格式容错率大大提升。接下来看一下具体实现。

  1. class RenderControlError extends React.Component{ 
  2.     state={ 
  3.         isError:false 
  4.     } 
  5.     componentDidCatch(){ 
  6.         this.setState({ isError:true }) 
  7.     } 
  8.     render(){ 
  9.         return !this.state.isError ? 
  10.              this.props.children : 
  11.              <div style={styles.errorBox} > 
  12.                  <img url={require('../../assets/img/error.png')} 
  13.                      style={styles.erroImage} 
  14.                  /> 
  15.                  <span style={styles.errorText}  >出现错误</span> 
  16.              </div> 
  17.     } 

如果 children 出错,那么降级 UI。

使用

 

总结

本文通过一些脑洞大开,奇葩的操作,让大家明白了 Susponse ,componentDidCatch 等原理。我相信不久之后,随着 React 18 发布,Susponse 将崭露头角,未来可期。

 

责任编辑:武晓燕 来源: 前端Sharing
相关推荐

2021-06-07 08:41:59

React异步组件

2021-07-08 06:51:29

React函数组件

2021-09-05 07:35:58

lifecycleAndroid组件原理

2022-07-06 08:29:12

antdInput 组件

2022-02-16 08:11:52

组件渲染前端

2022-04-26 05:55:06

Vue.js异步组件

2020-10-21 08:38:47

React源码

2019-07-22 10:42:11

React组件前端

2021-02-26 15:10:00

前端React组件交互

2021-10-15 14:28:30

React 组件渲染

2021-07-15 07:23:25

React动画页面

2020-05-20 14:25:45

Reactreact.js前端

2021-03-18 08:00:55

组件Hooks React

2023-11-21 07:17:36

Reac函数组件

2017-05-17 15:50:34

开发前端react

2017-07-06 20:27:38

React.jsHtml5Javascript

2009-04-03 13:09:12

Windows Emb

2017-02-28 21:57:05

React组件

2017-03-21 21:37:06

组件UI测试架构

2019-03-13 10:10:26

React组件前端
点赞
收藏

51CTO技术栈公众号