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

322次阅读  |  发布于3年以前

前言

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

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

不可能的事

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

1.jpg

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

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

<div />
<Index />

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

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

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

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

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

2.jpg

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

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

 /* 例子2 */
function Index(){
    return new Promise((resolve)=>{
        setTimeout(()=>{
            resolve({ name:'《React进阶实践指南》'  })
        },1000)
    })
}

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

不可能的事变为可能

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

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

export default class App extends React.Component{
    state = {
       isError:false
    }
    componentDidCatch(e){
         this.setState({ isError:true })
    }
    render(){
        return <div>
            hello world , let us learn React!
            {!this.state.isError &&  <Index />}
        </div>
    }
}

3.jpg

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

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

try{
    // 尝试渲染
}catch(e){
     // 渲染失败,执行componentDidCatch(e)
     componentDidCatch(e) 
}

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

function Index(){
    throw {
       name:'《React进阶实践指南》'
    }
}
componentDidCatch(e){
    console.log('error:',e)
    this.setState({ isError:true })
}

5.jpeg

export default class App extends React.Component{
    state = {
       isError:false,
       childThrowMes:{}
    }
    componentDidCatch(e){
          console.log('error:',e)
         this.setState({ isError:true , childThrowMes:e })
    }
    render(){
        return <div>
            hello world , let us learn React!
            {!this.state.isError ?  <Index /> : <div> {this.state.childThrowMes.name} </div>}
        </div>
    }
}

效果:

6.jpg

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

4.gif

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

7.jpg

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

到此为止,可以总结出:

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

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

function Index(){
    throw {
        current:new Promise((resolve)=>{
            setTimeout(()=>{
                resolve({ name:'《React进阶实践指南》'  })
            },1000)
        })
    }
}

8.jpg

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

export default class App extends React.Component{
    state = {
       isError:false,
       childThrowMes:{}
    }
    componentDidCatch(e){
         const errorPromise = e.current
         Promise.resolve(errorPromise).then(res=>{
            this.setState({ isError:true , childThrowMes:res })
         })
    }
    render(){
        return <div>
            hello world , let us learn React!
            {!this.state.isError ?  <Index /> : <div> {this.state.childThrowMes.name} </div>}
        </div>
    }
}

效果:

9.jpg

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

function Index({ isResolve = false , data }){
    const [ likeNumber , setLikeNumber ] = useState(0)
    if(isResolve){
        return <div>
            <p> 名称:{data.name} </p>
            <p> star:{likeNumber} </p>
            <button onClick={()=> setLikeNumber(likeNumber+1)} >点赞</button>
        </div>
    }else{
        throw {
            current:new Promise((resolve)=>{
                setTimeout(()=>{
                    resolve({ name:'《React进阶实践指南》'  })
                },1000)
            })
        }
    }
}
export default class App extends React.Component{
    state = {
       isResolve:false,
       data:{}
    }
    componentDidCatch(e){
         const errorPromise = e.current
         Promise.resolve(errorPromise).then(res=>{
            this.setState({ data:res,isResolve:true  })
         })
    }
    render(){
        const {  isResolve ,data } = this.state
        return <div>
            hello world , let us learn React!
            <Index data={data} isResolve={isResolve} />
        </div>
    }
}

效果:

10.gif

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

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

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

首先解释为什么悬停?

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

<List1 />
<List2 />

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

<Suspense fallback={ <Spin /> }  >
    <List1 />
    <List2 />
</Suspense>

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

接下来解释如何悬停

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

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

悬停后再次render

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

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

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

const fetch = createFetcher(function getData(){
    return new Promise((resolve)=>{
       setTimeout(()=>{
            resolve({
                name:'《React进阶实践指南》',
                author:'alien'
            })
       },1000)
    })
})
function Text(){
    const data = fetch()
    return <div>
        name: {data.name}
        author:{data.author}
    </div>
}

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

function createFetcher(fn){
    const fetcher = {
        status:'pedding',
        result:null,
        p:null
    }
    return function (){
        const getDataPromise = fn()
        fetcher.p = getDataPromise
        getDataPromise.then(result=>{ /* 成功获取数据 */
            fetcher.result = result
            fetcher.status = 'resolve'
        })
        if(fetcher.status === 'pedding'){ /* 第一次执行中断渲染,第二次 */
            throw fetcher
        }
        /* 第二次执行 */
        if(fetcher.status==='resolve')
        return fetcher.result
    }
}

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

class MySusponse extends React.Component{
    state={
        isResolve:true
    }
    componentDidCatch(fetcher){
        const p = fetcher.p
        this.setState({ isResolve:false })
        Promise.resolve(p).then(()=>{
            this.setState({ isResolve:true })
        })
    }
    render(){
        const { fallback, children  } = this.props
        const { isResolve } = this.state
        return isResolve ? children : fallback
    }
}

我们编写的 Susponse 起名字叫 MySusponse

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

export default function Index(){
    return <div>
        hello,world
       <MySusponse fallback={<div>loading...</div>} >
            <Text />
       </MySusponse>
    </div>
}

效果:

11.gif

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

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

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

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

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

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

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

class RenderControlError extends React.Component{
    state={
        isError:false
    }
    componentDidCatch(){
        this.setState({ isError:true })
    }
    render(){
        return !this.state.isError ?
             this.props.children :
             <div style={styles.errorBox} >
                 <img url={require('../../assets/img/error.png')}
                     style={styles.erroImage}
                 />
                 <span style={styles.errorText}  >出现错误</span>
             </div>
    }
}

使用

<RenderControlError>
    <Index />
</RenderControlError>

总结

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

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8