听风是风

学或不学,知识都在那里,只增不减。

导航

react router component与render有什么区别?提升渲染性能,记一个react router component 误用导致请求死循环的有趣bug

壹 ❀ 引

下午前端大佬突然私聊我,说发现了一个很有趣的bug,问我有没有兴趣,因为我平时会记录一些自认为有意思的问题,所以毫不犹豫就答应了,问题表现如下,当我们系统进入到某个页面下时,接口居然无止境的不断请求,跟陷入了死循环一样。

问题简单排查下来其实也不算复杂,算是react router理解不够深刻使用不当造成的问题,处于好奇在项目里搜了下这种不当写法,统计来看应该有不少同学对于这块也不太熟悉,所以这里就做个简单记录。

贰 ❀ 排查思路

因为接口在不断请求,我们自然要排查这个接口是谁发起的,从而定位出发请求的问题组件。点击上图中的data接口,选择Initiator,在这里我们就能看到这个接口从发起到结束整个完整的调用栈,因为我是点击这个页面就出现这个问题,说明这个数据极大可能是在页面初始化的请求,初始化请求一般放在哪?当然是componentDidMout,于是我们查找调用找中的componentDidMout,于是成功定位到了如下文件:

点击文件,可以看到具体的代码确实是在初始化拿数据:

那么问题来了,组件渲染理论上只会执行一次componentDidMount,如果它一直在卸载挂载,那说明出问题的不是组件自身,而是使用了此组件的上游组件,于是我拿这个组件名在项目里搜索了一番,运气还算好,只有一个路由页用到,大致代码如下:

<Route component={() => <A {...this.props} />} />

而这种写法,其实就引发了一个很尴尬的问题,打开react router官方,有如下这段描述:

When you use component (instead of render or children, below) the router uses React.createElement to create a new React element from the given component. That means if you provide an inline function to the component prop, you would create a new component every render. This results in the existing component unmounting and the new component mounting instead of just updating the existing component. When using an inline function for inline rendering, use the render or the children prop (below).

总结来说,如果我们使用了component,路由会使用React.createElement帮你创建一个新的react组件,而且是卸载现有组件以及挂载你设置的新组件,但是上述写法使用了箭头函数,导致只要路由这段代码render执行一次,即便路由地址没发生变化,component都会认定这是一个新组件,从而每次都完整执行生命周期钩子,那写在didMount中的请求自然每次都会请求。

那为啥包含路由相关代码逻辑的父组件一直在render呢?这里需要提一提我们项目中所使用的stamp接口机制,前端每次拿数据,除了告诉后端要拿什么数据之外,都会附带一个时间戳。

比如第一次请求前端时间戳带过去的肯定是0,后端返回了数据以及一个此数据对应的时间戳;

第二次再请求时,前端会带上上次后端给的时间戳与后端做对比,假设数据没变化,后端对于数据层就会返回null以及还是相同的时间戳,前端接到null自然知道数据没变化了,还是走缓存,甚至组件都不会有更新的必要。

但这个问题巧就巧在后端在数据层返回出了问题,带了数据但是没给时间戳,导致前端每次请求的时间戳都是默认的0,从而后端每次都返回新数据,新数据被存入store,数据引用发生变化导致路由所在组件渲染,路由渲染又引发下层组件渲染以及didMout执行,于是请求死循环就诞生了。

虽然后端数据返回有问题是前提,但是前端也不应该发起无意义的请求,说到底就是不应该重复的didMout,怎么修改呢?将component改为render即可:

<Route render={() => <A {...this.props} />} />

叁 ❀ 一个例子加深印象

为了更好的理解上述问题,以及componentrender的使用对比,这里我准备了一个例子,先看component:

class B extends React.Component {

    componentDidMount() {
        // 用于判断子组件didMout是否重复执行
        console.log("componentDidMount")
    }

    render() {
        return (
            <div>B组件,此时num是{this.props.num}</div>
        )
    }
}

class Echo extends React.Component {

    state = { num: 1 }

    componentDidMount() {
        // 定时器,模拟后端不断返回新数据,引发render变化
        setInterval(() => {
            this.setState({ num: this.state.num + 1 });
        }, 1000)
    }

    render() {
        return (
            <div>
                <BrowserRouter>
                    <Route component={() => (<B num={this.state.num} />)} />
                </BrowserRouter>
            </div>
        );
    }
}

点击在线体验这个例子

可以看到组件B不仅render在不断执行,连componentDidMout也在不断执行,若大家有兴趣,可以再给B组件嵌套一个C组件,同时也监听componentDidMout,你会发现component所接收组件下的整个组件树,都在完整的被重新卸载挂载,抛开本文提到的请求死循环,单站在react角度性能也存在一定问题。

现在我们将上述代码中的component改成render,效果如下,可以看到子组件正常渲染,且componentDidMout只会初始化一次,后续不会重复执行。

肆 ❀ 总结

我们在路由写法上,常见写法如下:

<Route component={B} />

但是上述写法并没办法传递props以及其它属性,所以有同学可能就习惯使用箭头函数的做法,如下:

<Route component={() => <B {...this.props} />} />

但是我们通过一个bug分析,以及例子演示得知,假设当前路由未变化但是触发了render,这些用法会导致路由下子组件完整的重复挂载卸载,非常影响性能,解决办法也很简单,改用render即可。

那么componentrender又有什么区别呢?这里我去简单看了下路由源码:

// react-router源码
if (component)
   return match ? React.createElement(component, props) : null

if (render)
   return match ? render(props) : null

源码层面,创建组件的方式不同,component 使用的是 React.createElement,箭头函数情况下由于每次返回的都是一个新组件,所以每次都会触发完整的生命周期;而 render 可以理解执行了一个匿名函数,得到了一个组件,自始至终都是这一个组件,后续更新只是diff比较,就没有额外繁琐的生命周期处理,性能更佳。

对于component,它的调用更像下面代码:

<Route component={B} />
// 你可以理解为
<Route>
  <B />
</Route>

而对于使用render的场景,它更像下方这样:

<Route>
  {B()}
</Route>

那么到这里,本文结束。

posted on 2021-12-15 17:01  听风是风  阅读(803)  评论(0编辑  收藏  举报