前言
接下來的幾篇文章將圍繞一些‘獵奇’場景,從原理顛覆對 React 的認識。每一個場景下背后都透漏出 React 原理,
我可以認真的說,看完這篇文章,你將掌握:
- 1 componentDidCatch 原理
- 2 susponse 原理
- 3 異步組件原理。
不可能的事
我的函數組件中里可以隨便寫
,很多同學看到這句話的時候,腦海里應該浮現的四個字是:怎么可能?因為我們印象中的函數組件,是不能直接使用異步的,而且必須返回一段 Jsx 代碼。
1.jpg
那么今天我將打破這個規定,在我們認為是組件的函數里做一些意想不到的事情。接下來跟着我的思路往下看吧。
首先先來看一下 jsx ,在 React JSX
中 <div />
代表 DOM
元素,而 <Index>
代表組件, Index
本質是函數組件或類組件。
<div />
<Index />
透過現象看本質,JSX 為 React element 的表象,JSX 語法糖會被 babel
編譯成 React element
對象 ,那么上述中:
<div />
不是真正的DOM
元素,是 type 屬性為div
的 element 對象。- 組件
Index
是 type 屬性為類或者組件本身的 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 組件規范下:
- 必須返回 jsx 對象結構,不能返回普通對象。
- render 執行過程中,不能出現異步操作。
不可能的事變為可能
那么如何破局,將不可能的事情變得可能。首先要解決的問題是 報錯問題 ,只要不報錯,App
就能正常渲染。不難發現產生的錯誤時機都是在 render
過程中。那么就可以用 React 提供的兩個渲染錯誤邊界的生命周期 componentDidCatch和 getDerivedStateFromError。
因為我們要在捕獲渲染錯誤之后做一些騷操作,所以這里選 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>
}
}
- 用
componentDidCatch
捕獲異常,渲染異常
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進階實踐指南》'
}
}
- 將 throw 對象返回。
componentDidCatch(e){
console.log('error:',e)
this.setState({ isError:true })
}
- 通過 componentDidCatch 捕獲錯誤。此時的 e 就是 Index
throw
的對象。接下來用子組件拋出的對象渲染。
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>
}
}
- 捕獲到 Index 拋出的異常對象,用對象里面的數據重新渲染。
效果:
6.jpg
大功告成,子組件 throw 錯誤,父組件 componentDidCatch 接受並渲染,這波操作是不是有點...
4.gif
但是 throw
的所有對象,都會被正常捕獲嗎?於是我們把第二個 Index 拋出的 Promise
對象用 componentDidCatch 捕獲。看看會是什么吧?
7.jpg
如上所示,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 內容做修改。
function Index(){
throw {
current:new Promise((resolve)=>{
setTimeout(()=>{
resolve({ name:'《React進階實踐指南》' })
},1000)
})
}
}
- 如上,這回不在直接拋出 Promise,而是在 Promise 的外面在包裹一層對象。接下來打印錯誤看一下。
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>
}
}
- 在 componentDidCatch 的參數 e 中獲取 Promise ,
Promise.resolve
執行 Promise 獲取數據並渲染。
效果:
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)
})
}
}
}
- Index 中通過
isResolve
判斷組件是否加在完成,第一次的時候isResolve = false
所以throw Promise
。 - 父組件 App 中接受 Promise ,得到數據,改變狀態 isResolve ,二次渲染,那么第二次 Index 就會正常渲染了。看一下 App 如何寫:
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>
}
}
- 通過 componentDidCatch 捕獲錯誤,然后進行第二次渲染。
效果:
10.gif
達到了目的。這里就簡單介紹了一下異步組件的原理。上述引入了一個 Susponse 的概念,接下來研究一下 Susponse。
飛翔版——實現一個簡單 Suspense
Susponse 是什么?Susponse 英文翻譯 懸停。在 React 中 Susponse 是什么呢?那么正常情況下組件染是一氣呵成的,在 Susponse 模式下的組件渲染就變成了可以先懸停下來。
首先解釋為什么懸停?
Susponse 在 React 生態中的位置,重點體現在以下方面。
-
code splitting(代碼分割) :哪個組件加載,就加載哪個組件的代碼,聽上去挺拗口,可確實打實的解決了主文件體積過大的問題,間接優化了項目的首屏加載時間,我們知道過瀏覽器加載資源也是耗時的,這些時間給用戶造成的影響就是白屏效果。
-
spinner 解耦:正常情況下,頁面展示是需要前后端交互的,數據加載過程不期望看到 無數據狀態->閃現數據的場景,更期望的是一種spinner數據加載狀態->加載完成展示頁面狀態。比如如下結構:
<List1 />
<List2 />
List1
和 List2
都使用服務端請求數據,那么在加載數據過程中,需要 Spin 效果去優雅的展示 UI,所以需要一個 Spin 組件,但是 Spin 組件需要放入 List1
和 List2
的內部,就造成耦合關系。現在通過 Susponse 來接耦 Spin,在業務代碼中這么寫道:
<Suspense fallback={ <Spin /> } >
<List1 />
<List2 />
</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,應該滿足如下的條件。
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
封裝請求函數。請求函數 getData 返回一個 Promise ,這個 Promise 的使命就是完成數據交互。 - 一個模擬的異步組件,內部使用 createFetcher 創建的請求函數,請求數據。
接下來就是 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
}
}
- 這里要注意的是
fn
就是getData
, getDataPromise 就是getData
返回的 Promise。 - 返回一個函數
fetch
,在Text
內部執行,第一次組件渲染,由於 status = pedding 所以拋出異常 fetcher 給 Susponse,渲染中止。 - Susponse 會在內部 componentDidCatch 處理這個fetcher,執行 getDataPromise.then, 這個時候status已經是resolve狀態,數據也能正常返回了。
- 接下來Susponse再次渲染組件,此時就能正常的獲取數據了。
既然有了 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 內部 componentDidCatch 通過
Promise.resolve
捕獲 Promise 成功的狀態。成功后,取締 fallback UI 效果。
大功告成,接下來就是體驗環節了。我們嘗試一下 MySusponse 效果。
export default function Index(){
return <div>
hello,world
<MySusponse fallback={<div>loading...</div>} >
<Text />
</MySusponse>
</div>
}
效果:
11.gif
雖然實現了效果,但是和真正的 Susponse 還差的很遠,首先暴露出的問題就是數據可變的問題。上述編寫的 MySusponse 數據只加載一次,但是通常情況下,數據交互是存在變數的,數據也是可變的。
衍生版——實現一個錯誤異常處理組件
言歸正傳,我們不會在函數組件中做如上的騷操作,也不會自己去編寫 createFetcher
和 Susponse
。但是有一個場景還是蠻實用的,那就是對渲染錯誤的處理,以及 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>
}
}
- 如果 children 出錯,那么降級 UI。
使用
<RenderControlError>
<Index />
</RenderControlError>