如何使用 Router 爲你頁面帶來更快的加載速度

引言

React-Router 在 6.4 版本中 Release 了一系列 loaderFunction、defer 等 Data Apis,將數據獲取和頁面渲染進行分離從而帶來更好的用戶體驗。

今天這篇文章就來和大家一起來探索 Data Apis 是如何爲我們的頁面帶來更好的用戶體驗,

Why is the data apis better?

通常在以往的頁面渲染中,無論是服務端渲染還是客戶端渲染都無法逃過數據與頁面交互造成用戶體驗遲鈍的關係。

往往大部分頁面中真正具有意義的頁面元素都需要等待數據加載完成後重新渲染纔可以直接展示給用戶,所以優化發起數據請求的時機對於用戶看到頁面真正有意義的內容來說是必不可少的方式。

首先,我們先從 Client Side Render 以及 Server Side Render 兩方面來分析 React Router 在未使用 Data Apis 之前是頁面渲染與數據獲取是如何工作的。

Client Side Render

首先,在客戶端渲染中由於我們的頁面是由一個一個靜態資源構成並不存在服務端的概念。

自然,頁面的上的關鍵對客展示內容的渲染更像是一個瀑布:

像這樣的組件在我們的應用程序中數不勝數,通常我們會在各個組件掛載生命週期中發起數據請求,數據請求返回後在重新渲染攜帶數據的子組件。

或許,子組件中如何仍然存在數據獲取請求時整個頁面渲染就像是一個特別大的瀑布加載過程,顯而易見這會兒導致我們的應用程序比原始的體驗效果差許多。

在 V6 的 React Router 中在客戶端渲染中爲路由提供了 LoaderData 的概念,可以將數據請求和組件渲染分離。

簡單來說,在頁面接受到路由訪問時就可以同步開始數據請求而無需依賴任何組件渲染:

通過分離渲染和數據的過程,完美的解決瀑布式的體驗問題。

不要小瞧這部分數據獲取帶來的良好體驗,圖中的例子只是一次數據請求,當頁面中需要加載的數據擁有一定量級時這樣的方式會爲我們的頁面大大縮短加載 / 渲染時間帶來更好的用戶體驗。

當然,在傳統 SPA 應用中數據請求如何和頁面渲染並行觸發。同樣我們會使用一個 Loading 之類的骨架來爲頁面展示 Loading 內容。

「但是」,React Router 在 6.4 的 data apis 中提供了一個 defer api 以及 Await component 來解決這一問題:選擇性的推遲頁面部分內容的渲染,數據渲染並不會阻塞整個頁面的渲染。

稍後,我們也會爲大家嘗試使用 Data Apis 來體驗這一過程。

Server Side Render

讓我們在聚焦於服務端渲染應用,同樣在服務端渲染框架中諸如 NextJs、NuxtJs 等各種框架。由於我們的應用不單單是由靜態資源組件,而是擁有了服務的概念。

在 SSR 模式下,天然具有將數據獲取和頁面渲染分離的優勢。自然,我們可以在 SPA 的基礎上優化數據請求的過程。我們可以在請求到達我們的服務時立即發起數據請求:

即使擁有多個數據請求我們也可以方便的在請求到來時並行加載數據:

不過這一切都沒有問題了嗎?顯而易見,在進行數據請求的過程中用戶訪問我們的頁面只能得到一片白。這段時間是非常糟糕的用戶體驗。

那麼,這部分的用戶體驗我們當真就沒有辦法了嗎?

在 React 18 之前的確是沒有好的辦法。要麼就是給用戶在客戶端渲染時展示 Loading 將數據仍然和渲染進行掛載,顯然這並不是一個兩全的辦法。更像是一種取捨,在用戶白屏和 Loading 態之間做選擇。

但是在 React 18 之後,我們可以藉助 Streaming 的過程配合 React Router 的 defer api/Await compoennt 進行鍼對性的部分頁面渲染:

假設我們的頁面中有 A、B 兩個組件需要在獲取數據後纔可以進行有意義的對客內容展示,當用戶訪問我們的頁面內容時可以看作以下過程:

完美的解決了我們在原始 SSR 下要麼白屏要麼選擇將數據獲取依賴組件渲染的兩難。

或許說到這裏有些同學會想到 React 18 的新特性:Server Component,的確 Server Componet 也可以完美的解決上述問題。

不過,現階段的 Server Component 對於交互稍微複雜的網站來說更像是一種小型玩具,你不僅時刻需要注意它的編寫方式同時只有極少部分組件纔可以當作服務端組件進行數據獲取,當然仁者見仁,起碼現階段的 RSC 在我體驗後仍然是覺得有些不盡人意。

Remix.run 提供了開箱即用的上述功能,你無需任何繁瑣的 SSR 應用配置即可快速在你的應用程序中體驗上述功能。

快速上手

說了那麼多理論知識,接下來我們就來簡單體驗下 Data Apis 應該如何使用。

項目 demo。

createBrowserRouter

在 V6 之前通常我們會直接使用 <BrowserRouter /> 組件來作爲我們應用程序的根節點,我相信大多數同學 React 應用仍是這樣在使用路由。

在 V6 後提供了一種新的方式來創建路由對象 createBrowserRoute Api ,只有使用了 createBrowserRoute Api 創建的路由對象才被允許使用路由的 data apis。

自然,我們首先應該使用 createBrowserRoute 來創建一個所謂的路由對象:

// 默認數據獲取方法
const getDeferredData = () => {
  return new Promise((r) => {
    setTimeout(() => {
      r({ name: '19Qingfeng' });
    }, 2000);
  });
};

const getNormalData = () => {
  return new Promise((r) => {
    setTimeout(() => {
      r({ name: 'wang.haoyu' });
    }, 2000);
  });
};
// 創建數據路由對象
const router = createBrowserRouter([
  {
    path: '/',
    Component: App,
    children: [
      {
        index: true,
        Component: Normal,
        loader: async () => {
          const data = await getNormalData();
          return json({
            data
          });
        }
      },
      {
        path: 'deferred',
        Component: Deferred,
        loader: () => {
          const deferredDataPromise = getDeferredData();
          return defer({
            deferredDataPromise
          });
        }
      }
    ]
  }
]);

上邊的代碼中我們使用 createBrowserRouter 創建了一個攜帶數據的路由對象。

創建路由對象時,根路徑和 deferred 路徑乍一看大同小異。不過還是稍稍有些不同的:

RouterProvider

在調用 createBrowserRouter 獲得 router 對象時,我們仍然需要在我們的根組件將創建的路由對象傳遞給我們的應用程序,此時就需要使用到 RouterProvider Api:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import { RouterProvider } from 'react-router-dom';
import router from './routes/router.tsx';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router}></RouterProvider>,
  </React.StrictMode>
);

這一步我們需要將創建的路由對象傳入 RouterProvider ,同時將 RouterProvider 作爲應用程序的根組件傳遞給 createRoot Api。

useLoaderData/Suspense/Await

要使用 Router Data Apis 其實我們僅僅需要在原始的應用程序中更換上述兩個創建路由對象時的 Api 即可。

接下來的部分,我們已經在路由定義時將數據請求和組件拆分開來,那麼在組件渲染中我們如何獲取這部分數據請求返回的數據。

ReactRouter 中提供了一個 useLoaderData 的 hook 來爲我們在組件中獲取路由中 loader 的加載數據:

import { useLoaderData } from 'react-router';

function Normal() {
  // 直接使用 useLoaderData 獲取當前組件對應 loader 返回的數據
  const { data } = useLoaderData();

  return (
    <div>
      <h3>Hello</h3>
      <p>{data.name}</p>
    </div>
  );
}

export default Normal;

這一過程看起來行雲流水般的絲滑。首先在定義路由列表時將數據和渲染拆分開來,請求到來時會同步觸發數據請求和頁面渲染。

當我們在頁面渲染途中需要路由中定義的數據時,只需要簡單的通過 useLoaderData 來獲取對應數據即可。

當我們首次訪問根路徑時,應用會同時觸發根路徑下的 loaderFunction 等待 loaderFunction 執行完畢後使用 loaderFunction 中返回的數據進行頁面渲染。

不過上邊的截圖中明顯可以看到,在訪問根路徑時頁面會有部分的白屏之後纔開始直接渲染頁面。

這是因爲我們在根路徑下的 loader 定義成了阻塞的異步邏輯:

        loader: async () ={
          const data = await getNormalData();
          return json({
            data
          });
        }

頁面渲染需要依賴 loader 中的數據,而 loader 的執行又是一種異步的阻塞邏輯,自然首次打開頁面時需要等待這部分的 loader 執行完畢纔可以渲染。

雖然說這一步我們已經將頁面的渲染和數據獲取通過 loader 的方式拆分開來,不過由於渲染需要依賴 loader 中的數據又會造成阻塞的方式,這樣的用戶體驗自然也是比較糟糕的。

值得慶幸的是 ReactRouter 中爲我們提供了兩種方式來處理這個問題:

// main.tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider
      router={router}
      fallbackElement={<div>Loading...</div>}
    ></RouterProvider>
  </React.StrictMode>
);

通過爲 RouterProvider 定義 fallbackElement 元素可以在整個頁面切換時增加骨架展位從而給予用戶及時的加載反饋。

這種方式雖然簡單,但是 fallbackElement 的方式是頁面級別的加載。

有時我們的頁面只有部分模塊內容需要依賴 loader 的數據完成纔可以渲染真正有意義的內容,大多數時候頁面中的其他元素都是靜態(不依賴於數據加載)的模塊。

粗暴的使用 fallbackElement 在 loader 執行時阻塞整個頁面的渲染並不是在站點體驗上的最優解。

同時 fallbackElement 只會在頁面首次加載時纔會生效,當你的頁面擁有多個 page 進行 SPA 跳轉時,需要配合 navigation.state 來判斷頁面是否處於跳轉加載態。

這次,讓我們訪問 /deferred 路徑:

上邊的截圖中可以看到,頁面在加載時可以分爲兩個部分:

需要額外留意的是,大家不要將這一部分和在 useEffect 中發起數據請求混淆。deffer router 的優勢正是在於對於發起數據請求的時機優化:

讓我們再次聚焦到 <Deffer /> 組件上:

// router
{
        path: 'deferred',
        Component: Deferred,
        loader: () => {
          const deferredDataPromise = getDeferredData();
          return defer({
            deferredDataPromise
          });
        }
}

// deffer component
import { Suspense } from 'react';
import { Await, useLoaderData } from 'react-router';

function Deferred() {
  const { deferredDataPromise } = useLoaderData()

  return (
    <>
      <div>This is deferred Page!</div>
      <div>
        <h3>Hello</h3>
        <Suspense fallback={'loading deferred data'}>
          <Await resolve={deferredDataPromise}>
            {(data) => {
              return <p>{data.name}</p>;
            }}
          </Await>
        </Suspense>
      </div>
    </>
  );
}

export default Deferred;

由於我們在路由定義時,/deferred 路徑對應的 loader 中並不存在任何阻塞邏輯,同時我們通過 defer 方法返回了數據請求的 promise,此時我們並沒有在 loader 中等待這個 promise 狀態完成。

之後,我們在組件中使用 Suspense 配合 Await 組件來實現頁面部分元素的 loading 態從而對於頁面進行一種漸進式加載方式:

直到這一步,我們使用 defer 配合 Await 在頁面渲染和數據請求中真正做到了同步進行,給予用戶更好的加載體驗。

React Router 是如何實現 Defer 這一過程

Loaders 調用時機

上邊的章節中我們講到 ReactRouter 數據路由的優勢以及如何在我們的站點中使用數據路由來優化我們的頁面。

接下來的這個章節,我們就來簡單聊聊 ReactRouter 的 Data apis 的實現思路。

首先,loader 的定義、執行不難理解,只要在用戶訪問到當前路徑時 ReactRouter 會尋找到當前路徑下匹配到的所有 route 對象,自然我們只需要在渲染 route.Component 前調用執行所有的 loader 即可:

packages/react-router-dom/index.tsx

createBrowserRouter 內部會通過 createRouter 創建一個路由對象(該路由對象類似 without data apis router,用來控制頁面路由對象)。

創建完成後會立即調用內部的 initialize 方法初始化路由 state:

重點就在於 initialize 的 startNavigation 的方法,在 SPA 應用下默認 state.initialized 是 false 會進入 startNavigation 方法。

所謂 startNavigation 即是 data route apis 中內部的跳轉方法,每次跳轉 ReactRouter 內部都會在內部實際調用該方法。

初始化時,調用 startNavigation 會傳入第二個參數 state.location (當前頁面路由),即會觸發當前路由 Router 邏輯。

startNavigation 中會進行一系列操作,比如通過 router match 來尋找當前 state.location 下的 route 對象等等,重點就在於 handleLoaders 方法。

handleLoaders 方法正是執行當前匹配路徑的所有 loaders 方法,當執行完所有 loaders 獲取當前路由的路由數據。

可以清楚的看到在調用 handleLoaders 方法時是 await 的阻塞邏輯,自然也就和我們上述根路徑的 case match 上了。

簡單來說,客戶端代碼在執行 createBrowserRouter 方法後就會立即進行 initialize 方法從而對於當前 location 路徑尋找匹配的 route 對象執行當前路由下的 loader 方法。

當然,當我們調用 usenavigate() 返回值跳轉時,同樣也是通過 startNavigation 重新調用這一過程。

同時,在 initialize 方法執行完畢後會返回 createBrowserRouter 內部定義的 router 對象,該方法內部控制了當前路由的對象和保存了 router 的各個實例方法(跳轉等)。

Loader Data 是如何關聯頁面渲染的

上一步我們清楚了在頁面加載後,會調用 startNavigation 方法執行所有 loader 獲取 loaderFunction 返回的數據。

這次,讓我們再次聚焦回到 startNavigation 方法中:

startNavigation 在結尾會獲取到當前 location 的 match (當前所有匹配的路由對象)以及 loaderData (當前所有匹配的 loader 返回值)。

之後會在結尾調用,completeNavigation 方法。顧名思義,該方法爲完成跳轉的方法,在 completeNavigation 中首先會進行一系列 actionData、loaderData、block 之類的判斷,這部分邏輯並不是我們的重點,重點在於:

completeNavigation 默認會調用 updateState 去更新最新的路由數據。

可以看到 updateState 方法會合並獲得最新的 state 狀態(包含當前 location 下的最新的 loaderData 以及 match 等等),同時調用 subscribers 訂閱方法來調用 subscriber 方法傳入最新的 routerState。

那麼,更新後的數據會被哪裏訂閱呢?不知道大家還記不記得我們通過 createBrowserRouter 方法創建的 router 對象會被傳入 <RouterProvider router={router} /> 中。

RouterProvider 組件中會訂閱 initialize 返回的 router 對象,當調用 updateState 更新後會通知更新 RouterProvider 的 setState 改變該組件的 state 狀態。

當 router state 改變時觸發 stateState 方法,更新 RouterProvider 的 state 值,同時該組件中會通過 DataRouterStateContext.Provider 將最新的 router state 傳遞給子組件中。

因爲我們的應用程序都是被 RouterProvider 包裹,自然當我們調用 useLoaderData 時只需要通過 context 的形式即可在組件中獲得最新的 state 。

這一步整個流程就變的清晰了,當頁面路由改變時

  1. 觸發 startNavigation 尋找當前匹配的 route 對象。

  2. 執行當前匹配 route 對象的 loaderFunction 獲得返回值。

  3. startNavigation 執行完成後會調用 completeNavigation 更新 router 的 state。

  4. RouterProvider 中由於 subscriber 了 router state 的變化,自然 RouterProvider 也會同步更新當前組件頂層的 state,同時通過 provider 的方式傳遞給所有子組件最新的 state。

  5. 最後,當我們在組件中調用 useLoaderData 時,由於 provider 中的 value 發生變化,useLoaderData 也會獲得最新的 loaderData。

Defer & Await

瞭解了 ReactRouter 中 loader 是如何被調用以及如何將 loaderData 關聯到頁面數據上後我們來看看 defer 的大致實現過程。

Defer

其實 defer 的實現 ReactRouter 做了非常多的邊界 case ,比如在頁面快速切換時取消上一次的 defer Promise 等等之類的邊界判斷。

這裏我們僅僅關心正常的 defer 是如何被執行的,關注一個大概的執行流程即可。有興趣的同學可以自行翻閱 ReactRouter 的源代碼去向詳細閱讀了解。

首先 defer 的存放位置在 packages/router/utils.ts 中:

我們可以看到 defer 方法返回的是一個 DeferredData 的實例:

DeferredData 這個類中,在初始化時會爲 defer 包裹的對象中每個值調用 trackPromise 方法:

trackPromise 方法會爲 defer 中的每個值標記 _tracked 爲 true 表示該 Promise 已經被 ReactRouter 追蹤。

同時 trackPromise 會返回返回一個新的 Promise:

abortPromise 表示 ReactRouter 中取消 defer 請求的邏輯,我們暫時無需關注它。

重點在於,當 defer 中的 promise 完成 / 失敗後都會調用 this.onSettle 方法:

onSettle 方法會爲 defer 方法中每個 promise 的值在 fulfilled 後根據返回的 data/error 標記對應的 _error 以及 _data 屬性分別爲錯誤信息 / resoved 的值。

所以,簡單來說 defer 方法會爲包裹的 object 中每個值分別打上 tag 帶上 _tracked 以及在 Promise 變爲完成態後爲 promise 標記 _data 或者 _error 屬性。

Await

defer 往往是需要使用 Await 來配合使用。

Await 的實現就稍微顯得簡單了些,首先我們在看看 Await 組件中的 AwaitErrorBoundary 他會接受外部傳入的 resolve ,通常這個 resolve 會是 useLoaderData 獲取的 defer 方法返回的 promise。

它的實現就類似於我們通常使用的 ErrorBoundary 組件,AwaitErrorBoundary 組件的 render 函數中會首先獲取到外部傳入的 resolve 。

如果當前 resolve 已經被標記 ReactRouter 追蹤 (_tracked 爲 true),那麼此時會根據 _data/_error 來判斷該 Promise 的狀態:

在成功和失敗狀態下 render 方法一目瞭然,當失敗時會渲染 AwaitContext.Provider 傳入當前 promise,同時將 children 重製爲 errorElement。

當成功時,會正常將 children 傳入爲外部的 children。

自然,由於 pendding 狀態的 Promise 會向外 throw promise ,我們在使用 Await 組件時需要配合 Suspense 組件。

由於我們在子組件(Await) 中 throw 出了當前 Promise,Supense 對於子組件會開啓 fallback 進行異步加載等待 Promise 完成後又會更新狀態重新渲染子組件(reRender 時 Await 中的 primise 已經不爲 pendding,自然就會進入成功 / 失敗的渲染邏輯了)。

之後,在瞭解了 AwaitErrorBoundary 的邏輯後,我們再來回到 ResolveAwait 組件:

ResolveAwait 做的事情也非常簡單,判斷傳入的 children 是否爲 function,如果爲 function 的情況會調用 useAsyncValue 獲取到 AwaitContext.Provider 的值調用該函數,如果不爲函數則會直接渲染該組件(我們需要在組件內部自己通過 useAsyncvalue 獲取外層 reolve 的返回值)。

這也是 ReactRouter Await Children 組件的兩種傳入方式,具體可以參考 文檔說明。

而所謂的 useAsyncValue 自然就是對應了 AwaitContext.Provider 傳入的 value,獲取了 primise._data 從而獲取到 promise resolve 的值。

當然,與 useAsyncValue 對應的也存在 useAsyncError ,我們可以在 errorElement 中通過 useAsyncError 獲取 promise rejected 的原因。

到這裏,defer、Await 的執行流程我們就大概理清楚了:

Server Side Render

服務端 ReactRouter 簡介

上述過程中對於 ReactRouter V6.4 新增的 data apis 的原理進行了淺析,我們瞭解到了在客戶端執行時 data apis 的執行過程。

不過,上邊的流程僅僅是針對於客戶端渲染,也就是我們通常的 CSR 過程。

在 SSR 服務端渲染下,其實還是有非常多的不同的,比如通常在服務端中我們會在 createStateRouter 來處理服務端路由。

從而讓路由的 loader 不會打包進入客戶端代碼,而是僅在我們的 Server 上運行 loaderFunction。

每次頁面請求到來時,服務端會同步執行 React 組件渲染以及在服務端執行 loaderFunction ,客戶端完全不進行任何 Loader 的感知。

在 createRouter 方法中如果存在 hydrationData 的話首次渲染是會標記 initialized 爲 true 的。

我們剛纔也提高過,如果 hydrationData 爲 true 時,是不會在初始化時調用 startNavigation 的,自然也不會觸發 laoder 的運行。

所謂的 hydrationData 及時在 SSR 時服務端數據和客戶端數據交互的橋樑,ReactRouter 默認會通過 __staticRouterHydrationData 在 window 上傳遞。

當然,服務端渲染的 ReactRouter 又是另一個話題了。如果你有服務端需求強烈建議大家可以嘗試下 Remix。

Remix 中已經通過了一系列封裝來爲我們提供開箱即用的 ReactRouter Server Side Render。

Remix Defer

關於 Remix 在服務端渲染時做了許多構建相關的處理,簡單來說他會在服務端構建時確定好每個路由需要的靜態資源列表,說實話我也沒看完這部分,筆者這裏就不再展開了。

唯一想提到的就是上文我們說過,我們可以在客戶端通過 defer 返回的對象中使用 Promise 來延遲我們部分頁面的加載。

同時,我們也提到過在服務端渲染時通常 loaderFunction 並不會在客戶端執行,而是在服務器上執行當前路由對應的 loaderFunction。

那麼,如果我們通過 streaming 配合 defer 使用時,不知道大家有沒有想過 「Remix 是如何格式化服務端 loaderFunction 的 defer 呢?」

上述這麼說可能有些抽象,有些同學不瞭解 remix 可能並不清楚我在說什麼。

簡單來說 Remix 會在服務器上執行 loaderFunction,如果 loaderFunction 中返回 defer 的 promise,比如:

const fetchPromiseData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({});
    }, 10000);
  });
};
// 當前路由的 loaderFunction
export const loader: LoaderFunction = async ({ request, context }) => {
  return defer({
    data: fetchPromiseData(),
  });
};

remix 會在服務端執行 loader ,然後將**「服務端 pendding 狀態的 promise 傳遞給客戶端」**,客戶端會判斷服務端 Promise 狀態:

一切看起來都和客戶端一模一樣,不過重點就在於 Remix 將服務端 loaderFunction 中 defer 返回的 Promise 序列化後返回給客戶端,客戶端也會得到這部分序列化後的 Promise ,聽起來非常神奇對吧。

如果你直接使用 ReactRouter 作爲你的服務端渲染應用,這部分 Promise 的序列化是需要你自己進行實現的。

實際上這部分 Promise 的序列化是在 Remix 的 <Scripts /> 組件中實現的:

在頁面初始化渲染時,藉助 <Await />__remixContext 的自定義 api 來實現了類似序列化的 Promise 在 Server 和 Client 中來維持相同狀態通知。

具體 Remix 的相關 Defer 解讀這裏我就不再展開了,有興趣的同學我們可以在評論區一起交流。

如果大家 Remix 有興趣的話,之後我也會爲大家帶來 Remix 的文章分享。

寫在結尾

如果有興趣學習 ReactRouter 路由渲染原理部分的同學可以參考我的這篇 從 0 到 1 手把手帶你實現一款 Vue-Router(https://juejin.cn/post/7049953227818663966),其實關於路由 Render 的原理 Vue 和 React 是大同小異的實現思路。

文章中爲大家分享了 React Data Apis 的優勢、用法以及原理淺析,希望文章中的內容可以幫助到大家。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/i2MRvAicXCDZkHjO9gbgYQ