
壹 ❀ 引
下午前端大佬突然私聊我,說發現了一個很有趣的bug
,問我有沒有興趣,因為我平時會記錄一些自認為有意思的問題,所以毫不猶豫就答應了,問題表現如下,當我們系統進入到某個頁面下時,接口居然無止境的不斷請求,跟陷入了死循環一樣。

問題簡單排查下來其實也不算復雜,算是react router
理解不夠深刻使用不當造成的問題,處於好奇在項目里搜了下這種不當寫法,統計來看應該有不少同學對於這塊也不太熟悉,所以這里就做個簡單記錄。
貳 ❀ 排查思路
因為接口在不斷請求,我們自然要排查這個接口是誰發起的,從而定位出發請求的問題組件。點擊上圖中的data
接口,選擇Initiator
,在這里我們就能看到這個接口從發起到結束整個完整的調用棧,因為我是點擊這個頁面就出現這個問題,說明這個數據極大可能是在頁面初始化的請求,初始化請求一般放在哪?當然是componentDidMout
,於是我們查找調用找中的componentDidMout
,於是成功定位到了如下文件:

點擊文件,可以看到具體的代碼確實是在初始化拿數據:

那么問題來了,組件渲染理論上只會執行一次componentDidMount
,如果它一直在卸載掛載,那說明出問題的不是組件自身,而是使用了此組件的上游組件,於是我拿這個組件名在項目里搜索了一番,運氣還算好,只有一個路由頁用到,大致代碼如下:
<Route component={() => <A {...this.props} />} />
而這種寫法,其實就引發了一個很尷尬的問題,打開react router
官方,有如下這段描述:
When you use
component
(instead ofrender
orchildren
, below) the router usesReact.createElement
to create a new React element from the given component. That means if you provide an inline function to thecomponent
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 therender
or thechildren
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} />} />
叄 ❀ 一個例子加深印象
為了更好的理解上述問題,以及component
與render
的使用對比,這里我准備了一個例子,先看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
即可。
那么component
與render
又有什么區別呢?這里我去簡單看了下路由源碼:
// 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>
那么到這里,本文結束。