震驚!用 Suspense 解決請求依賴的復雜場景居然這么簡單!
有一種復雜場景 React 新手經(jīng)常處理不好。
那就是一個頁面有多個模塊,每個模塊都有自己的數(shù)據(jù)需要請求。與此同時,可能部分模塊的數(shù)據(jù)還要依賴父級的異步數(shù)據(jù)才能正常請求自己的數(shù)據(jù)。如下圖所示,當我們直接訪問該頁面時,頁面請求的數(shù)據(jù)就非常多。而且這些數(shù)據(jù)還有一定的先后依賴關系。
大概數(shù)據(jù)請求的順序依次如下:
1. 自動登錄 -> 個人用戶信息,權限信息
2. 左側路由信息
3. 頁面頂層數(shù)據(jù)
4. 頁面五個模塊各自的數(shù)據(jù)
這些接口數(shù)據(jù)依賴關系比較明確,前面的接口請求完成之后,后續(xù)的接口才能正確請求。
如果頁面四個模塊的接口數(shù)據(jù)相互之間沒有關系,其實整個頁面還會簡單一些,但是很多時候復雜度往往來自于后端的不配合。前端與后端的溝通在一些團隊經(jīng)常出現(xiàn)問題。
有的后端不愿意配合前端頁面結構修改接口,前端也溝通不下來,只能自己咬牙在混亂的接口情況下寫頁面,就導致了無論是組件的劃分也好還是頁面的復雜度也好都變得雜亂無章。從而增加了開發(fā)成本。
因此,只有在一些比較規(guī)范的團隊里,頁面五個模塊的數(shù)據(jù)解耦做得比較好。模塊之間干凈簡潔的依賴關系能有效降低開發(fā)難度。
因此許多前端比較依賴把所有接口都放在父級組件中去請求的方案,這樣不管你的接口是否混亂,在前端總能處理。但是這樣的結果就是頁面組件的耦合變得更加嚴重。
在 React 19 中,我們可以使用 Suspense 嵌套來解決這種請求之間前后依賴的方案。我們在項目中模擬了這種場景的實現(xiàn)。具體的演示圖如下。
一、重新考慮初始化
和之前的方案一樣,我們先定義父組件的請求接口。
const getMessage = async () => {
const res = await fetch('https://api.chucknorris.io/jokes/random')
return res.json()
}
然后在父組件中,將 getMessage() 執(zhí)行之后返回的 promise 作為狀態(tài)存儲在 useState 中。這樣,當我點擊時,只需要重新執(zhí)行依次 getMessage() 就可以更新整個組件。
const [
messagePromise,
setMessagePromise
] = useState(null)
但是此時我們發(fā)現(xiàn),messagePromise 并沒有初始值,因此初始化時,接口并不會請求。這種情況下,有兩種交互我們需要探討。一種是通過點擊按鈕來初始化接口。另外一種就是組件首次渲染就要初始化接口。
我們之前的案例中,使用了取巧的方式,在函數(shù)組件之外提前獲取了數(shù)據(jù),這會導致訪問任何頁面該數(shù)據(jù)都會加載,因此并非合適的手段。
// 我們之前的案例這樣做是一種取巧的方式
const api = getMessage()
function Message() {
...
但是如果我們直接把 getMessage() 放在組件內(nèi)部執(zhí)行,也存在不小的問題。因為當組件因為其他的狀態(tài)發(fā)生變化需要重新執(zhí)行時,此時 getMessage() 也會冗余的多次執(zhí)行。
// 此時會冗余多次執(zhí)行
const [
messagePromise,
setMessagePromise
] = useState(getMessage())
理想的情況是 getMessage() 只在組件首次渲染時執(zhí)行依次,后續(xù)狀態(tài)的改變就不在執(zhí)行。而不需要多次執(zhí)行。
我們先來考慮通過點擊事件初始化接口的交互。此時我們可以先設置 messagePrmoise 的初始值為 null。
const [
messagePromise,
setMessagePromise
] = useState(null)
不過這樣做有一個小問題就是如果我將 messagePromise 值為 null 時傳遞給了子組件。那么子組件就會報錯,因此我們需要特殊處理。一種方式就是在子組件內(nèi)部判斷。
const MessageOutput = ({messagePromise}) => {
if (!messagePromise) return
const messageContent = use(messagePromise)
或者:
// 這種寫法是在需要默認顯示狀態(tài)時的方案
const MessageOutput = ({messagePromise}) => {
const messageContent = messagePromise ? use(messagePromise) : {value: '默認值'}
另外一種思路就是設置一個狀態(tài),子組件基于該狀態(tài)的值來是否顯示。然后在點擊時將其設置為 true。
const [show, setShow] = useState(false)
function __clickHandler() {
setMessagePromise(getMessage())
setShow(true)
}
{show && <MessageContainer messagePromise={messagePromise} />}
另外一種交互思路就是初始化時就需要馬上請求數(shù)據(jù)。此時我們?yōu)榱舜_保 getMessage() 只執(zhí)行一次,可以新增一個非 state 狀態(tài)來記錄組件的初始化情況。默認值為 false,初始化之后設置為 true。
const i = useRef(false)
let __api = i.current ? null : getMessage()
const [
messagePromise,
setMessagePromise
] = useState(null)
然后在 useEffect 中,將其設置為 true,表示組件已經(jīng)初始化過了。
useEffect(() => {
i.current = true
}, [])
這是利用 useState 的內(nèi)部機制,初始化值只會賦值一次來做到的。從而我們可以放心更改后續(xù) __api 的值為 null.
從這個細節(jié)的角度來說,函數(shù)組件多次執(zhí)行的確會給開發(fā)帶來一些困擾,Vue3/Solid 只執(zhí)行一次的機制會更舒適一些,不過處理得當也能避免這個問題。
二、Suspense 嵌套
接下來,我們需要考慮的就是 Suspense 嵌套執(zhí)行的問題就行了。這個執(zhí)行起來非常簡單。我們只需要將有異步請求的模塊用 Suspense 包裹起來當成一個子組件。然后該子組件可以當成一個常規(guī)的子組件作為 Suspense 組件的子組件。
例如,我們聲明一個子組件如下所示:
const getApi = async () => {
const res = await fetch('https://api.chucknorris.io/jokes/random')
return res.json()
}
export default function Index(props) {
const api = getApi()
return (
<div>
<div id='tips'>多個 Suspense 嵌套,子組件第一部分</div>
<div className="content">
<div className='_05_dou1_message'>父級消息: {props.value}</div>
<Suspense fallback={<div>Loading...</div>}>
<Item api={api} />
</Suspense>
</div>
</div>
)
}
const Item = ({api}) => {
const joke = api ? use(api) : {value: 'nothing'}
return (
<div className='_03_a_value_update'>子級消息:{joke.value}</div>
)
}
然后我可以將這個子組件放在 Suspense 內(nèi)就可以了。
import DouPlus1 from './Dou1'
import DouPlus2 from './Dou2'
const MessageOutput = ({messagePromise}) => {
const messageContent = use(messagePromise)
return (
<div>
<p>{messageContent.value}</p>
<DouPlus1 value={messageContent.value} />
<DouPlus2 value={messageContent.value} />
</div>
)
}
在另外一個子組件中,我們還設計了內(nèi)部狀態(tài),用于實現(xiàn)切換按鈕,來增加頁面交互的復雜度。并且每次切換都會請求接口。
如果切換時,上一個接口沒有請求完成,React 會自己處理好數(shù)據(jù)的先后問題。不需要我們額外考慮競態(tài)條件的情況。完整代碼如下:
var tabs = ['首頁', '視頻', '探索']
export default function Index() {
var r = useRef(false)
var api = r.current ? null : getApi()
const [promise, setPromise] = useState(api)
const [current, setCurrent] = useState(0)
useEffect(() => {
r.current = true
}, [])
return (
<div>
<div id='tips'>多個 Suspense 嵌套,子組件第二部分</div>
<div className="content">
{tabs.map((item, index) => (
<button
id='btn_05_item'
className={current == index ? 'active' : ''}
onClick={() => {
setCurrent(index)
setPromise(getApi())
}}
key={item}
>{item}</button>
))}
<Suspense fallback={<div className='_05_a_value_item'>Loading...</div>}>
<Item api={promise} />
</Suspense>
</div>
</div>
)
}
const Item = ({api}) => {
const joke = use(api)
return (
<div className='_05_a_value_item'>{joke.value}</div>
)
}
三、總結
當我們要在復雜交互的情況下使用嵌套 Suspense 來解決問題,如果我們組件劃分得當、與數(shù)據(jù)依賴關系處理得當,那么代碼就會相當簡單。不過這對于開發(fā)者來說,會有另外一個層面的要求。那就是如何合理的處理好組件歸屬問題。
許多前端頁面開發(fā)難度往往都是由于組件劃分不合理,屬性歸屬問題處理不夠到位導致的。因此 Suspense 在這個層面有了一個剛需,開發(fā)者必須要具備合理劃分組件的能力,否則即使使用了 Suspense,也依然可能導致頁面一團混亂。