支持動態路由的 React Server Side Rendering 實現

本文做者 Bermudarat

頭圖來自 Level up your React architecture with MVVM, 做者 Danijel Vincijanovichtml

1. 前言

在開始正文前,先介紹幾個概念(已經瞭解的朋友能夠跳過):前端

Server Side Rendering(SSR):服務端渲染,簡而言之就是後臺語言經過模版引擎生成 HTML 。實現方式依賴於後臺語言,例如 Python Flask 的 Jinja、Django 框架、Java 的 VM、Node.js 的 Jade 等。react

  • 優勢:SEO 友好、更短的白屏時間;
  • 缺點:每次都需請求完整頁面、先後端開發職責不清;

Client Side Rendering(CSR):客戶端渲染,服務器只提供接口,路由以及渲染都丟給前端。webpack

  • 優勢:服務端計算壓力小、能夠實現頁面的局部刷新:無需每次都請求完整頁面、先後端分離;
  • 缺點:SEO 難度高、用戶白屏時間長;

同構:先後端共用一套代碼邏輯,全部渲染功能均由前端實現。在服務端輸出含最基本的 HTML 文件;在客戶端進一步渲染時,判斷已有的 DOM 節點和即將渲染出的節點是否相同。如不一樣,從新渲染 DOM 節點,如相同,則只需綁定事件便可(這個過程,在 React 中稱之爲 注水)。同構是實現 SSR 的一種方式,側重點在於代碼複用。git

靜態路由:靜態路由須要在頁面渲染前聲明好 URL 到頁面的映射關係。如 Angular、Ember 中的路由,React Router v4 以前版本也採用此種路由。github

動態路由:動態路由拋開了靜態路由在渲染前定義映射關係的作法,在渲染過程當中動態生成映射。React Router v4 版本提供了對動態路由的支持。web

Code Splitting:也就是代碼分割,是由諸如 Webpack,Rollup 和 Browserify(factor-bundle)這類打包器支持的一項技術,可以在打包文件時建立多個包並在運行時動態加載。json

Next.jsNuxt.js 是目前成熟的同構框架,前者基於 React,後者基於 Vue。有了這些框架,開發者能夠方便地搭建一個同構應用:只對首屏同構直出,知足 SEO 需求,減小白屏時間;使用前端路由進行頁面跳轉,實現局部渲染。這些同構框架,已經在工程中獲得了普遍應用。然而知其然也要知其因此然,對於一個功能完善的同構應用,須要解決如下幾個方面的問題:後端

  1. 服務端:如何匹配 URL;頁面數據預獲取;響應字符串的組裝與返回。
  2. 客戶端:應用如何進行數據管理;如何使用服務端獲取的數據進行渲染;客戶端和服務端的數據獲取方式不一樣,如何保持一致。
  3. 工程化:如何結合 Code Splitting,區分服務端和客戶端,輸出分塊合理的 JS/CSS 文件;對於沒法 SSR 的深層組件,如何延遲到客戶端再初始化。

上述問題的解決過程當中,有不少坑會踩,本文主要討論第一點。此外,提出一種解決方案,在服務端不使用中心化的路由配置,結合 Code Splitting ,經過一次預渲染,獲取當前 URL 對應的模塊名和數據獲取方法。api

2. 基於 React 的 SSR 實現

2.1 通用思路

React 提供了 四個方法 用來在服務端渲染 React 組件。其中,renderToStaticMarkuprenderToStaticNodeStream 不會在 React 內部建立額外的 DOM 屬性,一般用於生成靜態頁面。同構中經常使用的是 renderToStringrenderToNodeStream 這兩個方法:前者將應用渲染成字符串;後者將應用渲染爲 Stream 流,能夠顯著下降首字節響應時間(TTFB)。

實現一個同構的 React 應用,須要如下幾個步驟(下文均以字符串渲染爲例):

  1. 獲取匹配當前 URL 的路由,進而獲取對應的數據獲取方法;
  2. 調用第一步得到的方法請求數據;
  3. 結合上一步獲取的數據(此處可使用 Redux 等數據管理模塊),調用 React 提供的 renderToString 方法,將應用渲染成字符串;
  4. 將序列化的數據、上一步得到的字符串、客戶端渲染所需的 JS/CSS 文件路徑組裝成 HTML 字符串,而後返回;
  5. 瀏覽器獲取響應後,進行解析。

這是實現同構的通用思路,Next.js 框架也是這種思路。
以上步驟的第一步,是獲取匹配當前 URL 的路由。不一樣的路由對應不一樣的數據獲取方法,這是後續步驟的前提。

2.2 使用 React Router

React Router v4 提供了 [React Router Config](
https://github.com/ReactTrain... 實現中心化的靜態路由配置,用於獲取 React 應用的路由信息,方便在服務端渲染時獲取數據:

With the introduction of React Router v4, there is no longer a centralized route configuration. There are some use-cases where it is valuable to know about all the app's potential routes such as:

  • Loading data on the server or in the lifecycle before rendering the next screen
  • Linking to routes by name
  • Static analysis

React Router Config 提供了 matchRoutes 方法實現路由匹配。如何使用,在 文檔 中有詳細的說明:

// routes 爲中心化的路由配置文件
const routes = [
  {
    path: "/",
    component: Root,
    loadData: () => getSomeData()
  }
];
const loadBranchData = location => {
    const branch = matchRoutes(routes, location.pathname);
    // 調用 route 上定義的數據獲取方法
    const promises = branch.map(({route, match}) => {
        return route.loadData ? route.loadData(match): Promise.resolve(null);
    });
    return Promise.all(promises);
};

// 預獲取數據,並在 HTML 文件中寫入數據
loadBranchData(req.URL).then(data => {
    putTheDataSomewhereTheClientCanFindIt(data);
});

loadData 方法除了做爲路由的屬性外,也能夠在 Root 的靜態方法中定義。

// Root 組件
const Root = () => {
    ...
};
Root.loadData = () => getSomeData();
// 路由配置
const routes = [
  {
    path: "/",
    component: Root
  }
];
// 頁面匹配
const loadBranchData = location => {
    // routes 爲中心化的路由配置文件
    const branch = matchRoutes(routes, location.pathname);
    // 調用 component 上的靜態數據獲取方法
    const promises = branch.map(({route, match}) => {
        return route.component.loadData ? route.component.loadData(match): Promise.resolve(null);
    });
    return Promise.all(promises);
};

接下就可使用預獲取的數據進行渲染。

HTML 字符串中須要包含客戶端渲染所需的 JS/CSS 標籤。對於沒有 Code Splitting 的應用,很容易定位這些資源文件。然而對於一個複雜的單頁應用,不進行 Code Splitting 會致使 JS 文件體積過大,增長了傳輸時間和瀏覽器解析時間,從而致使頁面性能降低。在 SSR 時,如何篩選出當前 URL 對應的 JS/CSS 文件,是接下來要解決的問題。

2.3 Code Splitting 與 SSR

![](
https://p1.music.126.net/ZAXt...

Webpack 根據 ECMAScript 提案實現了用於動態加載模塊的 import 方法。React v16.6 版本提供了 React.lazySuspend,用於動態加載組件。然而 React.lazySuspend 並不適用於 SSR,咱們仍須要引入第三方的動態加載庫:

React.lazy and Suspense are not yet available for server-side rendering. If you want to do code-splitting in a server rendered app, we recommend Loadable Components. It has a nice guide for bundle splitting with server-side rendering.

目前已有不少成熟的第三方的動態加載庫: 早期的 React 官方文檔中推薦的 react-loadable,最新推薦的 @loadable/component,以及 react-universal-component 等等,他們提出這樣一種解決方案:

  1. 在 Webpack 打包時,輸出每個動態加載組件對應的 JS/CSS 配置。Webpack 提供了輸出包含全部模塊信息的 json 文件的 CLI 命令:

webpack --profile --json > compilation-stats.json。除了命令行的方式,配置文件也能夠經過 webpack-stats-plugin 插件生成。此外,一些第三方動態加載庫也提供了插件生成這些配置(例如 react-loadable 提供的 ReactLoadablePlugin);

  1. 渲染時,經過 React Context 獲取這次渲染中全部動態加載的組件的模塊名 chunkNames
  2. 從第一步產生的配置文件中,提取 chunkNames 對應的分塊代碼信息,並組裝成 JS/CSS 標籤。

react-universal-component 爲例,代碼實現以下:

import {ReportChunks} from 'react-universal-component'
import flushChunks from 'webpack-flush-chunks'
import ReactDOM from 'react-dom/server'
// webpackStats 中包含了應用中全部模塊的數據信息,能夠經過 webpack 打包得到
import webpackStats from './dist/webpackstats.json';

function renderToHtml () => {
    // 保存匹配當前 URL 的組件 chunk
    let chunkNames = [];
    const appHtml = ReactDOM.renderToString(
        // ReportChunks 經過 React Context 將 report 方法傳遞至每一個動態加載組件上。組件在加載時,執行 report 方法,從而將組件的模塊名傳遞至外部。
        <ReportChunks report={chunkName => chunkNames.push(chunkName)}>
            <App />
        </ReportChunks>
    );
    // 提取 webpacStats 中 chunkNames 的信息,並組裝爲標籤;
    const {scripts} = flushChunks(webpackStats, {
        chunkNames,
    });
    // 後續省略
}

綜上,使用 React Router 進行服務端渲染,須要執行如下步驟:

  1. Webpack 打包時,輸出包含全部動態加載組件對應 JS/CSS 信息的配置文件;
  2. 使用 React Router 的中心化配置文件,獲取當前 URL 對應組件的靜態數據獲取方法;
  3. 使用動態加載庫,結合第一步的配置文件,在應用渲染過程當中,獲取代碼分塊信息;
  4. HTML 字符串組裝。

上述過程,流程以下:
![](
https://p1.music.126.net/zF63...

3 動態路由匹配

3.1 靜態路由 Vs 動態路由

上述討論中,在進行 URL 匹配時,咱們使用了中心化的靜態路由配置。React Router v4 版本的最大改進,就是提出了動態路由。Route 做爲一種真正的 React 組件,與 UI 展現緊密結合,而不是以前版本中的僞組件。有了動態路由組件,咱們再也不須要中心化的路由配置。

與靜態路由相比,動態路由在設計上有不少 改進之處。此外,動態路由在深層路由的書寫上,也比中心化的靜態路由要方便。
使用 React Router Config 進行中心化的靜態路由配置須要提供以下的路由配置文件:

const routes = [
    {
        component: Root,
        routes: [
            {
                path: "/",
                exact: true,
                component: Home
            },
            {
                path: "/child/:id",
                component: Child,
                routes: [
                    {
                        path: "/child/:id/grand-child",
                        component: GrandChild
                    }
                ]
            }
        ]
    }
];

採用動態路由,則徹底不須要上述配置文件。 以 Child 組件爲例, 能夠在組件中配置子路由。

function Child() {
    // 使用 match.path,能夠避免前置路徑的重複書寫
    let match = useRouteMatch();
    return (
        <div>
            <h>Child</h>
            <Route path={`${match.path}/grand-child`} />
        </div>
    )
}

可是若是使用動態路由的話,該如何與當前 URL 匹配呢?

3.2. 動態加載庫的改進

前面介紹了,react-universal-component 等動態加載組件, 能夠經過一次渲染,獲取對應當前 URL 的模塊名。

let chunkNames = [];
    const appHtml = ReactDOM.renderToString(
        <ReportChunks report={chunkName => chunkNames.push(chunkName)}>
            <App />
        </ReportChunks>
    );

咱們是否可使用相似的方式,經過一次渲染,將定義在組件上的數據獲取方法傳遞至外部呢?好比下面的書寫方式:

let chunkNames = [];
    let loadDataMethods = [];
    const appHtml = ReactDOM.renderToString(
        <ReportChunks report={(chunkName, loadData) => {
            chunkNames.push(chunkName);
            loadDataMethods.push(loadData);
        }}>
            <App />
        </ReportChunks>
    );

react-universal-component 中, ReportChunks 組件使用 React Contextreport 方法傳遞至每一個動態加載組件上。組件在加載時,執行 report 方法,將組件的模塊名傳遞至外部。

所以,咱們只須要修改動態加載方法,使其在執行 report 方法時,同時將模塊名 chunkName 和組件上的靜態方法返回便可:

// AsyncComponent 提供在服務端同步加載組件的功能
class AsyncComponent extends Component {
    constructor(props) {
        super(props);
        const {report} = props;
        // syncModule 爲內置函數,不對用戶暴露,主要功能是使用 webpack 提供的 require.resolveWeak 方法實現模塊的同步加載;
        const comp = syncModule(resolveWeak, load);
        if (report && comp) {
            const exportStatic = {};
            // 將 comp 的靜態方法複製至 exportStatic
            hoistNonReactStatics(exportStatic, comp);
            exportStatic.chunkName = chunkName;
            // 將 chunkName 和靜態方法傳遞給外部
            report(exportStatic);
        }
    }
    // ...
}

完整的實現能夠參考 react-asyncmodulereact-asyncmodule 提供了 AsyncChunk 組件,與 react-universal-component 提供的 ReportChunks 組件類似,做用是將 report 方法傳遞至每一個動態加載組件上。使用方法以下:

let modules = [];
    const saveModule = (m) => {
        // m 中包含 chunkName 和靜態數據獲取方法;
        const { chunkName } = m;
        // 過濾重複的 chunkName
        if (modules.filter(e => e.chunkName === chunkName).length) return;
        modules.push(m);
    };
    const appHtml = ReactDOM.renderToString(
        <AsyncChunk report={saveModule}>
            <App />
        </AsyncChunk>
    );

完整流程以下:
![](
https://p1.music.126.net/9yrS...

4. 侷限性和解決辦法

經過一次預渲染,獲取對應當前 URL 的模塊名和數據獲取方法,適用於大部分動態路由的場景。可是若是動態加載組件自己是否渲染依賴於數據,那麼在預渲染時,這個組件的模塊名和靜態方法不能正常獲取。以下:

const PageA = AsyncComponent(import('./PageA'));
const BasicExample = (props) => {
        const {canRender} = props;
        return (
            <Router>
                <Route exact path="/">
                {
                    canRender ? <PageA /> : <div>Render Nothing!</div>
                }
                </Route>
            </Router>
        );
};
BasicExample.getInitialProps = () => {
    // 此處獲取 canRender,用於肯定 PageA 組件是否渲染
};

預渲染時 canRenderundefined, 不會渲染 PageA ,因此也不能獲取到 PageA 對應的模塊名和靜態方法。正式渲染時,服務端渲染出的頁面中會缺乏 PageA 中的數據信息。爲了解決這個問題,業務代碼須要在 PageAcomponentDidMount 生命週期中,進行數據的獲取,以正確展現頁面。

此外,預渲染可使用 renderToStaticMarkup 方法,相比 renderToStringrenderToStaticMarkup 不會生成額外的 React 屬性,所以減小了 HTML 字符串的大小。可是預渲染自己增長了服務端的計算壓力,因此能夠考慮緩存預渲染結果,實現思路以下:

  1. 定義緩存 moduleCache
  2. 對於每次請求,使用 React Router 的 matchPath 方法,在 moduleCache 中查找是否有此 path string 模式(例如 /user/:name)的緩存,若是有,則使用緩存的方法進行數據獲取;
  3. 若是沒有,則進行預渲染,並將獲取的模塊信息存入緩存。

使用這種方法,對於不一樣的 path string 模式,只需在第一次請求時進行一次預渲染。以後再次請求,使用緩存數據便可。

5. 參考資料

均之外鍊形式列出

本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索