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