[譯]為什麼說Suspense是一種巨大的突破?

來源:

https://medium.com/react-in-depth/why-react-suspense-will-be-a-game-changer-37b40fea71ec?

medium.com

這篇文章不會深入研究React Suspense的技術細節以及它如何在幕後工作,已經有很多很棒的博客文章,視頻和會議演講。相反,我想更多地關注Suspense對應用程序開發人員的影響,就像我們如何考慮應用中的載入狀態和架構一樣。

簡單的介紹

為了讓所有沒有聽說過Suspense或者不知道它是什麼的人更好的理解,我仍然想要簡單的介紹一下Suspense。

去年,Dan Abramov在JSConf冰島提出Suspense,在處理React應用程序中的非同步數據獲取時,Suspense被認為是一種提升開發者開發體驗的巨大改進。這是一個巨大的變化,因為每個正在構建動態Web應用程序的人都知道,這仍然是開發過程中主要的痛點之一,同樣也會產生許多的樣板代碼。

同時,Suspense也改變了我們思考載入狀態的方式,即我們不應該將fetching component或data source耦合,而是應該更多的關注UI(將數據獲取這些內容交給React框架去處理)。為了提升用戶體驗,我們的應用程序應該在合適的時機展示spinners(loading),Suspense將有助於將這部分內容解耦。

Suspense不僅能用於API數據提取範圍,還可以應用於任何非同步數據流,例如,code split或assents loading。 React.lazy與Suspense特性已經在React穩定版本中發布,其允許用戶輕鬆對動態載入bundle進行拆分,而無需手動處理載入狀態。包含數據獲取功能的Suspense完全版本必須等到今年晚些時候,但已經可以通過當前的alpha版本進行體驗。

通常的想法是, Suspense允許組件「suspend」它們的渲染。例如,如果他們需要從外部來源載入額外數據,一旦所有依賴的資源(數據或資源文件)都存在了,React將重新嘗試渲染組件。

為了實現上面描述的功能,React使用Promises。組件可以在其render方法中拋出Promise(或者在組件渲染期間調用的任何東西,例如新的靜態方法getDerivedStateFromProps); React捕獲拋出的Promise並在組件樹上查找最接近的Suspense組件,它充當一種邊界;Suspense組件接受一個組件作為fallback prop,當其子樹中的任何子項被掛起時,都會呈現該元素。

React還會跟蹤拋出的Promise。一旦promise被resolve了,就會再次渲染組件。這假定由於Promise被resolve,被suspend的組件現在已經獲取了能夠正確渲染所需的所有信息。為此,我們使用某種形式的緩存來存儲數據,在每次渲染時,我們通過這個緩存來確定數據是否已經可用(然後它只是從變數中讀取它), 在這種情況下它會觸發fetch,並拋出Promise的結果來讓React捕獲。如上所述,這不僅適用於data fetching,任何可以使用Promise描述的非同步操作都適用,code split是一個非常明顯和流行的例子。

Suspense的核心概念與error boundaries非常相似,error boundaries在React 16中引入,允許在應用程序內的任何位置捕獲未捕獲的異常,然後在組件樹中展示跟錯誤信息相關的組件。以同樣的方式,Suspense組件從其子節點捕獲任何拋出的Promises,不同之處在於對於Suspense我們不必使自定義組件充當邊界,Suspense組件就是那個邊界;而在error boundary中,我們需要為邊界組件定義(componentDidCatch)方法。

這一整套方法大大簡化了我們考慮應用程序載入狀態的方式,降低了開發人員的心智負擔。

對於大多數應用開發者而言,他們通常不考慮數據源,而是考慮介面或應用程序中的邏輯和信息層次結構。而且您知道還有誰不關心您的數據來源嗎?用戶。沒有人喜歡具有數千個獨立loading的應用程序,其中一些只閃爍幾毫秒,頁面內容在數據請求的過程中會發生跳動。

所以為什麼Suspense是一種巨大的突破呢?

要了解這個問題,讓我們來看看,目前如何在我們的應用程序中處理數據提取。 最原始的方法是將所有必需的信息存儲為本地狀態,這看起來像這樣:

class DynamicData extends Component {
state = {
loading: true,
error: null,
data: null
};

componentDidMount () {
fetchData(this.props.id)
.then((data) => {
this.setState({
loading: false,
data
});
})
.catch((error) => {
this.setState({
loading: false,
error: error.message
});
});
}

componentDidUpdate (prevProps) {
if (this.props.id !== prevProps.id) {
this.setState({ loading: true }, () => {
fetchData(this.props.id)
.then((data) => {
this.setState({
loading: false,
data
});
})
.catch((error) => {
this.setState({
loading: false,
error: error.message
});
});
});
}
}

render () {
const { loading, error, data } = this.state;
return loading ? (
<p>Loading...</p>
) : error ? (
<p>Error: {error}</p>
) : (
<p>Data loaded ??</p>
);
}
}

我們在組件mount時獲取數據,並修改state;此外,我們還通過local state來跟蹤錯誤和載入狀態。這看起來很熟悉嗎?即使你沒有使用本地的state,也可能是某種抽象,但你仍然需要寫很多的三元表達式來處理這些狀態。

我不會說這種方法本身是不好的(它能夠滿足簡單用例的需要,而且我們顯然可以輕鬆地對其進行優化,例如將實際的data fetcing抽象到單獨的方法中)。但是這種方式要想規模化(scale)非常難,開發體驗也很糟糕。我們可以看到這種方式有如下幾個問題:

  • ??醜陋的三元表達式→糟糕的DX: 載入和錯誤狀態是通過渲染中的三元組定義的,從而使代碼不必要地複雜化。我們不是描述了一個渲染函數,我們描述了三個。
  • ??樣板代碼→壞DX: 處理所有這些狀態帶來了許多樣板代碼:在mount的時候觸發fetch,更新loading狀態;並在成功時將數據存儲在state中,或在失敗時存儲錯誤信息。我們需要為使用外部數據的每個組件重複此操作。
  • ??受限數據和載入狀態→糟糕的DX和UX: 狀態被處理並存儲在組件中,這意味著我們將在應用程序中展示大量的loading;並且如果我們有依賴於相同數據的不同組件,則會對相同的endpoint進行多次不必要的重複調用。通過這種方法,載入狀態與數據提取及其組件相關聯,這種限制使得,我們只能在特定的組件內處理它,而不能在更廣泛的應用程序環境中處理它。
  • ??重新獲取數據→壞DX

    更改頁面的id,然後觸發重新獲取數據邏輯很難實現。我們必須在componentDidMount中進行初始的data fetching,另外還要檢查componentDidUpdate中的id是否發生了變化,來決定是否需要再次執行data fetching。
  • ??閃爍的loading→糟糕的用戶體驗

    如果用戶的互聯網連接足夠快,顯示loading只有幾毫秒甚至比完全沒有顯示任何東西更糟糕,這會使你的應用程序感覺更加笨拙和慢。

你能看到這種模式嗎?對於許多人來說,這可能並不令人感到驚訝,但對我而言,實際上並非如此清晰地說明了實際開發人員和用戶體驗的實際情況。

因此,在確定問題之後,我們如何解決這些問題?

Context

長期以來,Redux一直是解決這些問題的優秀方案。藉助React 16中的「新」Context API,我們獲得了另一個很棒的工具,可幫助我們在全局級別定義和公開數據,同時使其可以在深層嵌套的組件樹中輕鬆訪問。所以為了簡單起見,我們將在這裡使用後者。

首先,我們可以輕鬆地將之前存儲在state的所有信息提取到context中,這將允許我們與其他組件共享它。此外,還能通過provider對外暴露的方法來執行data fetching,以便我們的組件只要調用了該方法,就能更新context中存儲的信息。在React 16.6中發布的contextType使得它更加優雅,不那麼冗長。

provider還可以作為緩存的一種形式,如果數據已經存在或載入,則阻止我們多次請求相同的數據,例如,由另一個組件觸發。

const DataContext = React.createContext();

class DataContextProvider extends Component {
// We want to be able to store multiple sources in the provider,
// so we store an object with unique keys for each data set +
// loading state
state = {
data: {},
fetch: this.fetch.bind(this)
};

fetch (key) {
if (this.state[key] && (this.state[key].data || this.state[key].loading)) {
// Data is either already loaded or loading, so no need to fetch!
return;
}

this.setState(
{
[key]: {
loading: true,
error: null,
data: null
}
},
() => {
fetchData(key)
.then((data) => {
this.setState({
[key]: {
loading: false,
data
}
});
})
.catch((e) => {
this.setState({
[key]: {
loading: false,
error: e.message
}
});
});
}
);
}

render () {
return <DataContext.Provider value={this.state} {...this.props} />;
}
}

class DynamicData extends Component {
static contextType = DataContext;

componentDidMount () {
this.context.fetch(this.props.id);
}

componentDidUpdate (prevProps) {
if (this.props.id !== prevProps.id) {
this.context.fetch(this.props.id);
}
}

render () {
const { id } = this.props;
const { data } = this.context;

const idData = data[id];

return idData.loading ? (
<p>Loading...</p>
) : idData.error ? (
<p>Error: {idData.error}</p>
) : (
<p>Data loaded ??</p>
);
}
}

我們甚至可以嘗試刪除組件中的三元組。假設我們希望loading組件在組件樹中更高的層級,覆蓋的不僅僅是這個組件。既然我們在context中有載入狀態,我們可以在我們想要的地方簡單地訪問它,並在那裡顯示loading,對吧?

這仍然是有問題的,因為AsyncData組件需要被渲染,以便首先觸發data fetching。當然,我們也可以在組件樹的更高一個層次來執行data fetching,而不是在組件中觸發它,但這並沒有真正解決問題,它只是將其移動到其他地方。它對代碼的可讀性和可維護性也很不利,因為AsyncData依賴於其他一些組件來為它進行數據載入。這種依賴既不明確也不好。理想情況下,我們的組件可以獨立工作,因此可以將它們放在任何位置,而不必依賴於其周圍組件樹中特定位置的其他組件。 但至少現在我們將所有數據和載入狀態放在一個中心位置,這是一種改進。由於我們能夠將provider放在任何地方,我們可以從任何我們想要的地方使用這些信息和功能,這意味著其他組件可以利用它(不再需要冗餘代碼),並且可以重用已經載入的數據,從而消除了不必要的API調用。

我們來總結一下這種方式的優缺點:

  • ??醜陋的三元組:這裡沒有任何改變,現在我們所能做的就是將三元組移到其他地方,這並沒有真正解決DX問題。
  • ??樣板代碼:我們刪除了之前所需的所有樣板。我們只需觸發從上下文中獲取和讀取數據以及載入狀態,從而減少重複代碼,從而提高剩餘可讀性和可維護性。

    ??受限數據和載入狀態:我們現在有一個可以在應用程序的任何地方訪問的全局狀態。所以我們顯著改善了這種情況,但是無法解決所有問題:如果我們想要顯示載入狀態,載入狀態仍然會耦合到數據源(即使我們發現這些依賴關係的作弊)載入各自信息的多個組件,我們仍然必須明確知道哪些來源並手動檢查所有單獨的載入狀態。
  • ??重新獲取數據: 這裡什麼都沒改變......
  • ??閃爍的loading: 這裡仍然有問題

Suspense

所以Suspense如何來解決上面這些問題呢?

首先,我們可以擺脫context,數據獲取和緩存將由cache provider完成,它實際上可以是任何東西: context,localStorage,window對象(如果你真的想要甚至是Redux),你可以命名它。所有這些provider基本上都存儲了我們要求的信息。在每個請求中,它首先檢查信息是否已經存在了,如果是這樣,直接return;如果沒有,獲取數據,並拋出Promise。在解析Promise之前,它將獲取的數據存儲在它用於緩存的任何內容中,這樣當React觸發重新渲染時,一切都復用。顯然,考慮到緩存失效和SSR等問題,使用更複雜的用例會變得更複雜,但這是它的一般要點。

這種緩存功能也是包含data fetching的完全版Suspense尚未正式release的原因之一。如果你想要一個實驗性的緩存功能,可以使用名為react-cache的實驗package。但請注意,在早期階段,API肯定會發生變化,許多常見用例尚未涵蓋。

除此之外,我們還可以擺脫所有載入狀態三元組。更重要的是,不是在組件mount和update的時候獲取,而是藉助Suspense在render階段來執行,如果數據還不可用,則執行suspend。這可能看起來像一個反模式(畢竟我們總是被告知不要這樣做),但考慮到如果數據在緩存中,provider將只需要返回它並且渲染就可以了。

import createResource from ./magical-cache-provider;
const dataResource = createResource((id) => fetchData(id));

class DynamicData extends Component {
render () {
const data = dataResource.read(this.props.id);
return <p>Data loaded ??</p>;
}
}

最後,我們可以放置suspend組件並定義我們想要在獲取數據時展現的fallback組件。

class App extends Component {
render () {
return (
<Suspense fallback={<p>Loading...</p>}>
<DeepNesting>
<ThereMightBeSeveralAsyncComponentsHere />
</DeepNesting>
</Suspense>
);
}
}

// We can also be very specific with multiple boundaries
// They dont need to know what components might be suspending
// their render or why, they just catch whatever bubbles up and
// handle it as intended
class App extends Component {
render () {
return (
<Suspense fallback={<p>Loading...</p>}>
<DeepNesting>
<MaybeSomeAsycComponent />
<Suspense fallback={<p>Loading content...</p>}>
<ThereMightBeSeveralAsyncComponentsHere />
</Suspense>
<Suspense fallback={<p>Loading footer...</p>}>
<DeeplyNestedFooterTree />
</Suspense>
</DeepNesting>
</Suspense>
);
}
}

然後我們來總結一下Suspense的特點:

  • ??醜陋的三元組:不見了。fallback渲染現在由suspense處理,這使代碼更直觀,載入狀態已成為UI關注點,與實際data fetching分離。
  • ??樣板代碼:我們完全不需要生命周期方法來觸發獲取,並且進一步改進了這個。此外,未來的將會由package來充當cache provider,只需要在更改存儲解決方案時切換它們。
  • ??限制數據和載入狀態:解決了。現在我們有明確的載入狀態邊界,其並不關心觸發載入的來源或原因。每當boundary內的任何組件被suspend時,將呈現載入狀態。
  • ??重複獲取數據:由於我們(可以)在render方法中直接傳遞源,當props更新時,如果數據獲取依賴於改props,將會觸發重新獲取數據,而無需我們執行任何操作。cache provider負責這一點。

    ??閃爍的loading: 嗯,這還是個問題 。

Concurrent mode徹底解決所有問題

Concurrent模式,以前稱為Async React,是另一個即將推出的功能,它允許React一次處理多個任務,根據定義的優先順序在它們之間切換,有效地允許它進行多任務。安德魯·克拉克在最後一次ReactConf上做了一次精彩的演講,包括一個對用戶產生深遠影響的精彩演示。我不想在這裡詳細介紹所有細節,但這確實值得一提。

但是,通過向我們的應用程序添加併發模式,Suspense可以使用一個新功能,我們可以通過Suspense組件上的prop來控制。如果我們現在傳入maxDuration,boundary將延遲顯示loading一段時間,從而防止loading不必要地閃爍,來實現良好的用戶體驗。

// Instead of this...
ReactDOM.render(<App />, document.getElementById(root));

// ...we do this
ReactDOM.createRoot(document.getElementById(root)).render(<App />);

要明確的是,這不會使數據獲取的速度更快,但在用戶層面會有這樣的感受,並且用戶體驗將得到顯著改善。

此外,Suspense並不強依賴於併發模式。正如我們之前看到的那樣,一般的功能在沒有併發模式的情況下,能夠完美地工作並且已經解決了許多問題,併發模式更多的是錦上添花,不是絕對必要但如果有的話很棒。

總結一下:Suspense的提出,最大的優勢是提升開發體驗,減少樣板代碼,使得代碼更好維護,並且在一定程度上帶來更好的用戶體驗。

推薦閱讀:

TAG:React | 前端框架 | 前端開發 |