精讀《Suspense 改變開發方式》

黃子毅發表於2020-03-16

1 引言

很多人都用過 React Suspense,但如果你認為它只是配合 React.lazy 實現非同步載入的蒙層,就理解的太淺了。實際上,React Suspense 改變了開發規則,要理解這一點,需要作出思想上的改變。

我們結合 Why React Suspense Will Be a Game Changer 這篇文章,帶你重新認識 React Suspense。

2 概述

非同步載入是前端開發的重要環節,也是一直以來樣板程式碼最嚴重的場景之一,原文通過三種取數方案的對比,逐漸找到一種最佳的非同步取數方式。

在講解這三種取數方案之前,首先通過下面這張圖說明了 Suspense 的功能:

精讀《Suspense 改變開發方式》

從上圖可以看出,子元素在非同步取數時會阻塞父元件渲染,並一直冒泡到最外層第一個 Suspense,此時 Suspense 不會渲染子元件,而是渲染 fallback,當所有子元件非同步阻塞取消後才會正常渲染。

下面介紹文中給出的三種取數方式,首先是最原始的本地狀態管理方案。

本地非同步狀態管理,直白但不利於維護

在 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>
    );
  }
}
複製程式碼

如上所述,首先申明本地狀態管理至少三種資料:非同步狀態、非同步結果與非同步錯誤,其次在不同的生命週期中處理初始化發請求與重新發請求的問題,最後在渲染函式中根據不同的狀態渲染不同的結果,所以實際上我們寫了三個渲染元件。

從下面幾個角度對上述程式碼進行評價:

  • 冗餘的三種狀態 - 糟糕的開發體驗
    • 很明顯,儲存了三套資料,渲染三種結果,不利於開發維護。
  • 冗餘的樣板程式碼 - 糟糕的開發體驗
    • 為了管理非同步狀態,上述程式碼非常冗長,顯然這個問題是存在的。
  • 資料與狀態封閉性 - 糟糕的使用者體驗 + 開發體驗
    • 所有資料與狀態管理都儲存在每一個這種元件中,將取數狀態與元件繫結的結果就是,我們只能忍受元件獨立執行的 Loading 邏輯,而無法對他們進行統一管理。
  • 重新取數 - 糟糕的開發體驗
    • 需要在另一個生命週期中申明重新取數,很明顯是個麻煩的行為。
  • 一閃而過的短暫 Loading - 糟糕的使用者體驗
    • 如果使用者網速足夠快,則 Loading 時間會非常短,此時一閃而過的 Loading 反而比沒有 Loading 更煩人,我們應該在使用者感知到卡的時候再出現 Loading 狀態。

Context 管理狀態,有進步但問題依然很多

如果利用 Context 做狀態共享,我們將取數的資料管理與邏輯程式碼寫在父元件,子元件專心用於展示,效果會好一些,程式碼如下:

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>
    );
  }
}
複製程式碼

DataContextProvider 元件承擔了狀態管理與非同步邏輯工作,而 DynamicData 元件只需要從 Context 獲取非同步狀態渲染即可,這樣來看至少解決了一部分問題,我們還是從之前的角度進行評價:

  • 冗餘的三種狀態 - 糟糕的開發體驗
    • 問題依然存在,只不過程式碼的位置轉移了一部分到父元件。
  • 冗餘的樣板程式碼 - 糟糕的開發體驗
    • 將展示與邏輯分離,成功降低了樣板程式碼數量,至少當一個非同步資料複用於多個元件時,不需要寫多份樣板程式碼了。
  • 資料與狀態封閉性 - 糟糕的使用者體驗 + 開發體驗
    • 這個問題得到一定程度解決,但是引入了新問題,即這個子元件僅在特定環境下可以正常執行。但在一個良好的設計下,元件執行不應該依賴於它所處的位置。
  • 重新取數 - 糟糕的開發體驗
    • 問題依然存在。
  • 一閃而過的短暫 Loading - 糟糕的使用者體驗
    • 問題依然存在。

Suspense 管理狀態,最棒的方案

利用 Suspense 進行非同步處理,程式碼處理大概是這樣的:

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>;
  }
}

class App extends Component {
  render() {
    return (
      <Suspense fallback={<p>Loading...</p>}>
        <DeepNesting>
          <DynamicData />
        </DeepNesting>
      </Suspense>
    );
  }
}
複製程式碼

在原文寫作的時候,Suspense 僅能對 React.lazy 生效,但現在已經可以對任何非同步狀態生效了,只要符合 Pending 中 throw promise 的規則。

我們再審視一下上面的程式碼,可以發現程式碼量減少了很多,其中和轉換成 Function Component 的寫法也有關係。

最後還是從如下幾個角度進行評價:

  • 冗餘的三種狀態 - 糟糕的開發體驗 - ⭐️
    • 可以看到,元件只要處理成功得到資料的狀態即可,三種狀態合併成了一種狀態。
  • 冗餘的樣板程式碼 - 糟糕的開發體驗 - ⭐️
    • 展示與邏輯完全分離,展示只要拿到資料展示 UI 即可。
  • 資料與狀態封閉性 - 糟糕的使用者體驗 + 開發體驗 - ⭐️
    • 這個問題得到了完美的解決,具體看下面詳細介紹。
  • 重新取數 - 糟糕的開發體驗 - ⭐️
    • 不需要關心何時需要重新取數,當資料變化時會自動執行。
  • 一閃而過的短暫 Loading - 糟糕的使用者體驗
    • 問題依然存在。

為了進一步說明 Suspense 的魔力,筆者特意把這段程式碼單獨拿出來說明:

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 達到 Loading 的效果,也就是說,如果只在最外層加一個 Suspense,那麼整個應用所有 Loading 都結束後才會渲染,然而我們也能隨心所欲的在任何層級繼續新增 Suspense,那麼對應作用域內的 Loading 就會首先執行完畢,並由當前的 Suspense 控制。

這意味著我們可以自由決定 Loading 狀態的範圍組合。 試想當 Loading 狀態交由元件控制的方案一與方案二,是不可能做到合併 Loading 時機的,而 Suspense 方案做到了將 Loading 狀態與 UI 分離,我們可以通過新增 Suspense 自由控制 Loading 的粒度。

3 精讀

Suspense 對所有子元件非同步都可以作用,因此無論是 React.lazy 還是非同步取數,都可以通過 Suspense 進行 Pending。

非同步時機被 Suspense pending 需要遵循一定規則,這個規則在之前的 精讀《Hooks 取數 - swr 原始碼》 有介紹過,即 Suspense 要求程式碼 suspended,即丟擲一個可以被捕獲的 Promise 異常,在這個 Promise 結束後再渲染元件,因此取數函式需要在 Pending 狀態時丟擲一個 Promise,使其可以被 Suspense 捕獲到。

另外,關於文中提到的 fallback 最小出現時間的保護間隔,目前還是一個 Open Issue,也許有一天 React 官方會提供支援。

不過即便官方不支援,我們也有方式實現,即讓這個邏輯由 fallback 元件實現:

<Suspense fallback={MyFallback} />;

const MyFallback = () => {
  // 計時器,200 ms 以內 return null,200 ms 後 return <Spin />
};
複製程式碼

4 總結

之所以說 Suspense 開發方式改變了開發規則,是因為它做到了將非同步的狀態管理與 UI 元件分離,所有 UI 元件都無需關心 Pending 狀態,而是當作同步去執行,這本身就是一個巨大的改變。

另外由於狀態的分離,我們可以利用純 UI 元件拼裝任意粒度的 Pending 行為,以整個 App 作為一個大的 Suspense 作為兜底,這樣 UI 徹底與非同步解耦,哪裡 Loading,什麼範圍內 Loading,完全由 Suspense 組合方式決定,這樣的程式碼顯然具備了更強的可擴充性。

討論地址是:精讀《Suspense 改變開發方式》 · Issue #238 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

精讀《Suspense 改變開發方式》

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章