[譯] 爲何 React Suspense 將會逆轉 Web 應用開發的遊戲規則 ?

原文地址:medium.com/react-in-de…html

原文做者:Julian Burrreact

在本篇文章中,我不想太深刻解釋有關 React Suspense 的實現細節和它內部的工做原理,由於已經有不少優秀的博客文章視頻討論作過這些事情了。相反,我更願意把重點放在 Suspense 將會如何影響在應用開發時咱們對加載狀態和架構應用的思考。git

Suspense 簡要介紹

鑑於有些人可能沒有據說過 Suspense 或者根本不瞭解它,所以我會先給出一個關於 Suspense 的簡要總結。github

在去年冰島舉行的 JSConf 大會上,Dan Abramov 介紹了 Suspense 。在解決 React 應用中有關異步數據獲取的難題時,Suspense 被稱做是對開發者體驗極大改進的 API 。這是件很使人興奮的事情,由於每一個正在構建動態 web 應用的開發者都知道這是一個主要痛點,而且也是帶來巨大樣版代碼的緣由之一。web

Suspense 一樣會改變咱們對加載狀態的思考方式,它不該該與加載組件或者數據源耦合,而應以 UI 關注點存在。咱們的應用應該在用戶體驗中顯示有意義的 spinner 。Suspense 經過解耦如下關注點幫助咱們實現這一點:緩存

Suspense 並不關心你暫停(多少次)的緣由,所以一個簡單的 spinner 就能和代碼分割、數據加載、圖片加載等場景組合在一塊兒。不管下面的樹須要什麼。若是網絡速度足夠快,甚至能夠不顯示 spinner 。網絡

Suspense 不只在數據加載時有用,它甚至能夠被應用到任何異步數據流中去。例如代碼分割或圖片加載。React.lazy 結合 Suspense 組件在 React 最新穩定版本中已經可使用,這容許咱們進行動態導入的代碼分割,而無需手動處理加載狀態。包含數據加載功能在內的完整 Suspense API 將會在今年以內發佈,不過你能夠經過 alpha 版本提早使用它。架構

Suspense 的設計思想是讓組件具備「暫停」渲染的能力,例如,組件須要從外部資源中加載額外數據時。等到數據都加載完成,React 纔會嘗試從新渲染組件。併發

React 使用 Promise 來實現該功能。組件能在 render 方法調用時拋出 Promise(或者任何在組件渲染時調用的方法,例如,新的靜態方法 getDerivedStateFromProps )。React 會捕獲 Promise 並沿着組件樹往上尋找最近的 Suspense 組件,而且它會充當一種邊界。Suspense 組件接收一個名爲 fallback 的 prop,只要它的子樹中有任何組件處於暫停狀態,fallback 中的組件就會當即被渲染。異步

React 還會跟蹤拋出的 Promise 。組件中的 Promise 一旦 resolve ,React 就會嘗試去繼續渲染該組件。由於咱們假定因爲 Promise 已經被 resolve ,這也就意味着暫停的組件已經具備正確渲染所需的所有數據。爲此,咱們使用某種形式的緩存來存儲數據。該緩存取決於每次渲染時數據是否可用(若是可用就會像從變量中取值同樣讀取它),若數據沒有準備好,則會觸發 fetch 而後拋出 Promise 以便 React 捕獲。如上所述,這並非數據加載所獨有的,任何可使用 Promise 來描述的異步操做均可以充分利用 Suspense ,顯然代碼分割是一個很是明顯且流行的例子。

Suspense 的核心概念與錯誤邊界很是類似。而錯誤邊界在 React 16 中被介紹爲可以在應用的任何地方捕捉未捕獲的異常,一樣的它經過在樹中放置組件(在這種狀況下爲任何帶有 componentDidCatch 生命週期方法的組件)來處理從該組件下面拋出的全部異常。無獨有偶,Suspense 組件捕獲任何由子組件拋出的 Promise ,不一樣的是咱們並不須要一個特定的組件來充當邊界,由於 Suspense 組件本身就是,它可讓咱們定義 fallback 來決定後備的渲染組件。

1

這樣的功能顯著地簡化了咱們對應用中加載狀態的思考方式而且讓咱們做爲開發者的心智模型與 UX 和 UI 設計師更加一致。

設計師一般並不會考慮數據源,而是更多的考慮用戶界面或應用程序的邏輯組織和信息層次結構。你知道還有誰不在乎數據源嗎?答案是用戶。沒有人會喜歡成千上萬個加載時的 spinner ,而且其中的一些只會閃爍幾毫秒,當數據加載完成時,頁面內容將會上下跳動。

爲何 Suspense 被稱做是巨大的突破呢?

問題

爲了理解 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 而是某種抽象,但極可能仍有不少加載三元組分散在你的應用程序中。

我並不認爲這種方法自己是錯誤的(它能夠知足簡單用例的需求,而且咱們能夠很容易地優化它,例如先將請求數據的邏輯分離到新的方法中),雖然它不能很好地擴展,但開發者體驗確定會變得更好。爲了具體闡述,我列出了此種方法存在的某些問題:

1. 👎 醜陋的三元組 → 糟糕的 DX

在 render 方法中加載和錯誤狀態經過三元組定義,這使咱們的代碼變得沒必要要的複雜化。咱們不是在描述 render 函數,而是在描述組件樹。

2. 👎 樣板代碼 → 糟糕的 DX

爲了管理全部的狀態咱們不得不寫不少樣板代碼:在 mount 時請求數據,成功時更新 loading 狀態和存儲數據到 state 或者失敗時存儲錯誤信息。咱們會爲每一個須要外部數據的組件重複上面全部的步驟。

3. 👎 受限數據和加載狀態 → 糟糕的 DX & UX

咱們會發現狀態的處理和存儲全都在一個組件內,這也就意味着應用中將會存在其餘不少須要加載數據的 spinner ,若是咱們有依賴於相同數據的不一樣組件,此時就會產生不少沒必要要的 API 調用代碼。這回到了我以前提出的觀點,使加載狀態依賴於數據源的心智模型彷佛並不正確。經過這種方法咱們發現加載狀態與數據加載以及組件強耦合在一塊兒,這限制了咱們只能在組件內處理問題(或者使用 hack 解決它),而不可以在更普遍的應用場景中使用它。

4. 👎 從新獲取數據 → 糟糕的 DX

改變 id 後則須要從新獲取數據的邏輯是種很冗餘的實現。咱們既要在 componentDidMount 中初始化數據還要額外的在 componentDidUpdate 中檢查 id 是否改變。

5. 👎 閃爍的 spinner → 糟糕的 DX

若是用戶的網速足夠快,顯示只出現幾毫秒的 spinner 比什麼也不顯示要糟糕的多,這讓你的應用變得笨拙且緩慢。所以感知性能纔是關鍵。


如今你知道這種模式的不足之處了嗎?對於許多人來講這並不使人感到驚訝,但對於我而言,實際上並無說明開發人員和用戶體驗的具體狀況。

所以,既然咱們明確了問題所在,那麼該如何解決它們呢?

用 Context 改進

在很長的一段時間內 Redux 一直是上述問題的解決方案。但隨着 React 16 版本新的 」Context API「 發佈,咱們又有了另外一個很好的工具幫助咱們在全局定義和暴露數據,同時可以在深嵌套的組件樹中輕鬆訪問它們。所以爲了簡單起見,咱們將在這裏使用後者。

首先,咱們將本來儲存在組件 state 中的全部數據轉換到 context provider 中去,以方便其餘組件共享該數據。咱們也能夠經過 provider 暴露加載數據的方法,這樣咱們的組件只需觸發該方法而後經過 context consumer 讀取加載後的數據。最近的 React 16.6 版本發佈的 contextType 使得它更加優雅,不那麼繁瑣。

provider 還可用做緩存形式,若是數據已經存在或者被其餘組件觸發即正在加載中,此時即可以免屢次沒必要要的網絡請求。

const DataContext = React.createContext();

class DataContextProvider extends Component {
  // 咱們想在該 provider 中儲存多種數據
  // 所以咱們用惟一的 key 做爲每一個數據集對象的鍵名
  // 加載狀態
  state = {
    data: {},
    fetch: this.fetch.bind(this)
  };

  fetch (key) {
    if (this.state[key] && (this.state[key].data || this.state[key].loading)) {
      // 數據要麼已經加載完成,要麼正在加載中,所以沒有必要再次請求數據
      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 spinner 放在組件樹更往上的地方,使它做用於不止一個組件。由於如今咱們擁有在 context 中的 loading state ,因此將 loading spinner 放在咱們想要顯示的地方會變得異常的容易,難道不是嗎?

但這仍然有問題,由於只有 AsyncData 組件開始渲染纔會第一時間觸發數據加載方法。固然,咱們能夠把數據加載方法提高到樹中更往上的地方,而不是在此組件內觸發,但這並無真正解決問題,而只是把它移到了別處。這樣作一樣會影響代碼的可讀性與可維護性,忽然間 AsyncData 組件會依賴於其餘組件來爲其進行數據的加載。這樣的依賴既不清晰也不正確。理想狀況下,你應該讓組件儘量的獨立工做,這樣一來你就能夠把它們放在任何位置,而沒必要依賴其周圍組件樹中特定位置的其餘組件。

但至少咱們成功的將數據和加載狀態集中在一個地方,這稱得上是一種進步。既然咱們能夠將 provider 放置在任何地方,咱們即可以隨時隨地使用這些數據和功能,這意味着其餘組件也能夠利用它(而再也不使用冗餘的代碼)而且能夠重用已加載的數據,從而消除了沒必要要的 API 調用。

爲了更好的理解這一點,讓咱們再看看最初所面臨的問題:

1. 👎 醜陋的三元組

並無改變,咱們所能作的只是將三元組移到別處,但這並不能解決 DX 問題

2. 👍 樣板代碼

咱們移除了以前須要的全部樣板代碼。咱們只須要觸發數據加載方法而後從 context 中讀取數據和 loading state ,由此咱們減小了許多重複代碼,剩下的則是可讀性和可維護性高的代碼。

3. 👍 受限數據和加載狀態

如今咱們擁有了在應用中任何地方均可以被讀取的全局狀態。所以咱們大大改善了此種狀況,可是卻不能解決全部的問題:loading state 仍然和數據源耦合在一塊兒,若是咱們想根據加載各自數據的多個組件顯示相應的加載狀態,咱們仍需明確地知道是哪個數據源而後手動檢查單獨的 loading state 。

4. 👎 從新獲取數據

沒有解決該問題。

5. 👎 閃爍的 spinner

一樣的也沒有解決該問題。


我想咱們都贊成這是一個可靠的改進,但它仍然留下了一些沒有解決的問題。

Suspense 出場

咱們該如何使用 Suspense 來作的更好呢?

首先,咱們能夠先去除 context ,數據處理和緩存將會由 cache provider 完成,它能夠是任何東西。Context、localStorage 和普通的對象(甚至是 Redux 若是你須要的話)等等。全部的這些 provider 只是幫助咱們存儲請求後的數據。在每次數據請求中,它會首先檢查是否有緩存。若是有則直接讀取,若沒有則進行數據請求同時拋出 Promise ,在 Promise resolve 以前,它將後備的信息儲存在用於緩存的任何內容中,一旦 React 組件觸發重渲染,此時一切都是可用的。顯然,若考慮到緩存失效和 SSR 的問題,在使用更復雜的用例時狀況也會變得更加複雜,但這是它的通常要點。

這樣難解決的緩存問題也是爲何數據加載形式的 Suspense 沒有加入到當前 React 穩定版本的緣由之一。若是你對此十分好奇,你能夠提早使用試驗性的 react-cache 包,可是它並不穩定而且在未來確定會有巨大的改版。

除此以外,咱們能夠刪除全部的 loading state 三元組。更重要的是,Suspense 在組件渲染時將會條件性地加載數據,若是數據沒有緩存則會暫停渲染組件,而不是在 mount 與 update 時加載數據。這可能看起來像一個反模式(畢竟咱們被告知不要這樣作),但考慮到若是數據已經在緩存中,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>;
  }
}
複製代碼

終於咱們能夠放置邊界組件而且在數據加載時渲染咱們先前定義的 fallback 組件。咱們能夠將 Suspense 組件放置到任何地方,就像以前解釋過的同樣,這些邊界組件可以捕獲其全部子組件中冒泡上來的 Promise 。

class App extends Component {
  render () {
    return (
      <Suspense fallback={<p>Loading...</p>}> <DeepNesting> <ThereMightBeSeveralAsyncComponentsHere /> </DeepNesting> </Suspense>
    );
  }
}
// 咱們能夠具體地使用多個邊界組件。
// 它們不須要知道哪一個組件被暫停渲染
// 或是爲何,它們只是捕獲任何冒泡上來的 Promise
// 而後按預期處理。
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>
    );
  }
}
複製代碼

我認爲這無疑會使代碼變得更加清晰,邏輯上的數據流從上到下變得更加易讀。如今來看看哪些問題被解決了呢?

1. ❤️ 醜陋的三元組

fallback 組件由邊界組件負責渲染,這使得代碼更容易遵循且更加直觀。loading state 變成了 UI 關注點而且與數據加載解耦。

2. ❤️ 樣板代碼

咱們經過刪除在組件生命週期方法中觸發數據加載的代碼更完美地解決了這個問題。而且還預見到將來可以充當 cache provider 的庫,只要你想更改存儲的解決方案,就能夠隨時切換它們。

3. ❤️ 受限數據和加載狀態

如今咱們擁有了因 loading state 存在的明確的邊界組件,所以咱們並不關心數據加載方法是在哪被觸發或是爲何被觸發。只要邊界組件中的任何組件被暫停,loading state 就會被當即渲染出來。

4. ❤️ 從新獲取數據

既然咱們可以在 render 方法中直接讀取數據源,所以只要傳入的 id 不一樣 React 就能自動觸發並從新加載數據,而咱們則不須要作任何事情。cache provider 幫咱們作到了。

5. 👎 閃爍的 spinner

這仍然是個未解決的問題。🤔


這些即是巨大的改進,但咱們仍有一個問題沒有解決 ......

然而,既然咱們在使用 Suspense ,那麼 React 還有另外一個技巧來幫助咱們使用它。

終極方案:併發模式

併發模式,以前也被叫作異步 React ,它是另外一個即將發佈的新特性,可讓 React 一次處理多個任務,並根據定義的優先級在它們之間切換,有效地容許 React 進行多個任務。Andrew Clark 在 2018 React Conf 上作了很棒的演講,其中包含了一個它對用戶影響的完美示例。在這裏我並不想深刻太多,由於已經有一篇文章講解的十分詳細。

然而,將併發模式添加到咱們的應用中,Suspense 則會擁有經過組件上的 prop 來控制的新功能。若是咱們傳入一個 maxDuration 屬性,邊界組件就會推遲顯示 spinner 直到超過設定的時間,這樣一來便避免了 spinner 沒必要要的閃爍。與此同時,它還能確保 spinner 顯示的最短期,從根本上解決了相同的問題而且讓用戶體驗儘量的友好。

// 不須要這行代碼
ReactDOM.render(<App />, document.getElementById('root')); // 咱們只需經過這行代碼就能夠切換到併發模式 ReactDOM.createRoot(document.getElementById(‘root’)).render(<App />); 複製代碼

要明確的是,這並不能使數據加載的更快,但對於用戶來講倒是這樣的,而且用戶體驗會獲得顯著改善。

此外,併發模式並非 Suspense 所必須的。就像咱們以前看到的同樣,在沒有併發模式的狀況下 Suspense 一樣可以很好地工做而且解決了許多問題。併發模式更像是錦上添花,不是必要的但若是有則會更好。

相關文章
相關標籤/搜索