[譯]揭祕 React 服務端渲染

原文:Demystifying server-side rendering in Reactjavascript

做者:Alex Moldovanhtml

揭祕 React 服務端渲染

讓咱們來近距離接觸一個可以讓你使用 React 構建 universal 應用的特性——React 服務端渲染( Server-Side Rendering )。前端

服務端渲染(如下簡稱 SSR )是一個將經過前端框架構建的網站經過後端渲染模板的形式呈現的過程。java

可以在服務端和客戶端上渲染的應用稱爲 universal 應用。react

爲何要 SSR

爲了弄明白咱們爲何須要 SSR,咱們首先須要瞭解過去 10 年 Web 應用的發展歷程。git

這與單頁應用(如下簡稱 SPA )的興起息息相關。與傳統的 SSR 應用相比, SPA 在速度和用戶體驗方面具備很大的優點。github

可是這裏有一個問題。SPA 的初始服務端請求一般返回一個沒有 DOM 結構的 HTML 文件,其中只包含一堆 CSS 和 JS links。而後,應用須要另外 fetch 一些數據來呈現相關的 HTML 標籤。express

這意味着用戶將不得不等待更長時間的初始渲染。這也意味着爬蟲可能會將你的頁面解析爲空。redux

所以,關於這個問題的解決思路是:首先在服務端上渲染你的 app(渲染首屏),接着再在客戶端上使用 SPA。後端

SSR + SPA = Universal App

你會在別的文章中發現 Isomorphic App 這個名詞,這和 Universal App 是一回事。

如今,用戶沒必要等待加載你的 JS,而且可以在初始請求返回響應後當即獲取徹底渲染完成的 HTML。

想象一下,這能給用戶在緩慢的 3G 網絡上的操做帶來多大的速度提高。你幾乎能夠當即在屏幕上獲取內容,而不是花了 20s 纔等到網站加載完畢。

如今,全部向您的服務器發出的請求都會返回徹底呈現的 HTML。對你的 SEO 部門來講是個好消息! 網絡爬蟲會索引你在服務器上呈現的任何內容,就像它對網絡上其餘靜態網站所作的那樣。

回顧一下,SSR 有如下兩個好處:

  1. 加快了首屏渲染時間
  2. 完整的可索引的 HTML 頁面(有利於 SEO)

一步一步理解 SSR

讓咱們採用一步步迭代的方式去構建一個完整的 SSR 實例。咱們從 React 的服務端渲染相關的 API開始,而後逐漸添加內容。

你能夠經過 follow 這個倉庫和查看定義在那兒的 tag 來理解每個構建步驟。

基本設置

首先,爲了使用 SSR,咱們須要一個 server。咱們將使用一個簡單的 Express 服務來渲染咱們的 React 應用。

server.js:

import express from "express";
import path from "path";

import React from "react";
import { renderToString } from "react-dom/server";
import Layout from "./components/Layout";

const app = express();

app.use( express.static( path.resolve( __dirname, "../dist" ) ) );

app.get( "/*", ( req, res ) => {
    const jsx = ( <Layout /> );
    const reactDom = renderToString( jsx );

    res.writeHead( 200, { "Content-Type": "text/html" } );
    res.end( htmlTemplate( reactDom ) );
} );

app.listen( 2048 );

function htmlTemplate( reactDom ) {
    return `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            <title>React SSR</title>
        </head>
        
        <body>
            <div id="app">${ reactDom }</div>
            <script src="./app.bundle.js"></script>
        </body>
        </html>
    `;
}
複製代碼

在第 10 行,咱們指定了 Express 須要 serve 的靜態文件所在的文件夾。

咱們建立了一個路由來處理全部非靜態的請求。這個路由會返回一個已渲染完畢的 HTML 字符串。

須要注意的是,咱們爲客戶端代碼和服務端代碼使用了相同的 Babel 插件,因此 JSX 和 ES6 Modules 能夠在server.js中工做。

客戶端上相對應的渲染函數爲ReactDOM.hydrate。該函數將接收已由服務端渲染的 React app, 並將附加事件處理程序。

要查看完整示例,請查看倉庫中的basictag。

好了!你剛剛建立了你的第一個服務端渲染的 React app!

React Router

咱們必須誠實地說,這個 app 目前尚未太多功能。因此讓咱們再添加幾個路由,思考一下咱們該如何在服務端處理這個部分。

/components/Layout.js:

import { Link, Switch, Route } from "react-router-dom";
import Home from "./Home";
import About from "./About";
import Contact from "./Contact";

export default class Layout extends React.Component {
    /* ... */

    render() {
        return (
            <div>
                <h1>{ this.state.title }</h1>
                <div>
                    <Link to="/">Home</Link>
                    <Link to="/about">About</Link>
                    <Link to="/contact">Contact</Link>
                </div>
                <Switch>
                    <Route path="/" exact component={ Home } />
                    <Route path="/about" exact component={ About } />
                    <Route path="/contact" exact component={ Contact } />
                </Switch>
            </div>
        );
    }
}
複製代碼

如今 Layout 組件會在客戶端上渲染多個路由。

咱們須要模擬服務器上的路由。你能夠在下面看到應該要完成的更改。

server.js:

/* ... */
import { StaticRouter } from "react-router-dom";
/* ... */

app.get( "/*", ( req, res ) => {
    const context = { };
    const jsx = (
        <StaticRouter context={ context } location={ req.url }> <Layout /> </StaticRouter>
    );
    const reactDom = renderToString( jsx );

    res.writeHead( 200, { "Content-Type": "text/html" } );
    res.end( htmlTemplate( reactDom ) );
} );

/* ... */
複製代碼

在服務端,咱們須要將咱們的 React 應用外包一層StaticRouter,而且給StaticRouter提供location

備註:context用於在渲染 React DOM 時跟蹤潛在的重定向操做。這須要經過來自服務端對 3XX 的響應來處理。

能夠在相同倉庫中的router標籤看到關於路由的完整例子。

Redux

既然咱們已經擁有路由的功能,那就讓咱們來整合 Redux 吧。

在簡單場景下,咱們經過 Redux 來處理客戶端的狀態管理。可是,若是咱們須要根據狀態來渲染部分的 DOM 呢?這時,就有必要在服務端初始化 Redux 了。

若是你的 app 在服務端上dispatch actions的話,那麼它就須要捕獲狀態並經過網絡將其與 HTML 結果一塊兒發送至客戶端。在客戶端,咱們將該初始狀態裝入 Redux 中。

首先讓咱們來看看服務端代碼:

/* ... */
import { Provider as ReduxProvider } from "react-redux";
/* ... */

app.get( "/*", ( req, res ) => {
    const context = { };
    const store = createStore( );

    store.dispatch( initializeSession( ) );

    const jsx = (
        <ReduxProvider store={ store }> <StaticRouter context={ context } location={ req.url }> <Layout /> </StaticRouter> </ReduxProvider>
    );
    const reactDom = renderToString( jsx );

    const reduxState = store.getState( );

    res.writeHead( 200, { "Content-Type": "text/html" } );
    res.end( htmlTemplate( reactDom, reduxState ) );
} );

app.listen( 2048 );

function htmlTemplate( reactDom, reduxState ) {
    return ` /* ... */ <div id="app">${ reactDom }</div> <script> window.REDUX_DATA = ${ JSON.stringify( reduxState ) } </script> <script src="./app.bundle.js"></script> /* ... */ `;
}
複製代碼

它看起來很醜陋,但咱們須要將完整的 JSON 格式的 state 與咱們的 HTML 一塊兒發送給客戶端。

而後讓咱們來看看客戶端:

app.js

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import { Provider as ReduxProvider } from "react-redux";

import Layout from "./components/Layout";
import createStore from "./store";

const store = createStore( window.REDUX_DATA );

const jsx = (
    <ReduxProvider store={ store }> <Router> <Layout /> </Router> </ReduxProvider>
);

const app = document.getElementById( "app" );
ReactDOM.hydrate( jsx, app );
複製代碼

請注意,咱們調用了兩次createStore,第一次在服務端,而後是在客戶端。可是,在客戶端咱們使用服務端上保存的任何狀態來初始化客戶端上的 狀態。這個過程相似於 DOM hydration。

能夠在相同倉庫中的redux標籤看到關於 Redux 的完整例子。

Fetch Data

最後一個比較棘手的難題是加載數據。假設咱們有一個提供 JSON 數據的 API。

在咱們的代碼倉庫中,我從一個公共的 API 中獲取了 2018 年 F1 賽季的全部事件。假設咱們想要在主頁上顯示全部時間。

咱們能夠在 React app 掛載完畢( mounted )並渲染完全部內容後再從客戶端調用咱們的 API。但這會形成很差的用戶體驗,可能須要在用戶看到相關內容以前展現一個 loader 或 spinner。

咱們的 SSR app 中,Redux 首先在服務端上存儲數據,再將數據發送客戶端。咱們能夠利用到這一點。

若是咱們在服務端上進行 API 調用,將結果存儲在 Redux 中,而後使用再渲染攜帶着相關數據的完整的 HTML 渲染給客戶端,會怎麼樣?

可是,咱們如何才能分辨某次 API 調用對應的是什麼頁面呢?

首先,咱們須要一種不一樣的方式來聲明路由。讓咱們建立一個路由配置文件。

export default [
    {
        path: "/",
        component: Home,
        exact: true,
    },
    {
        path: "/about",
        component: About,
        exact: true,
    },
    {
        path: "/contact",
        component: Contact,
        exact: true,
    },
    {
        path: "/secret",
        component: Secret,
        exact: true,
    },
];
複製代碼

而後咱們靜態聲明每一個組件的 data requirements:

/* ... */
import { fetchData } from "../store";

class Home extends React.Component {
    /* ... */

    render( ) {
        const { circuits } = this.props;

        return (
            /* ... */
        );
    }
}
Home.serverFetch = fetchData; // static declaration of data requirements

/* ... */
複製代碼

請記住,serverFetch能夠自由命名。

注意,fetchData是一個 Redux thunk action,當它被 dispatched 時,返回一個 Promise。

在服務端,咱們可使用一個來自react-router的函數——matchPath

/* ... */
import { StaticRouter, matchPath } from "react-router-dom";
import routes from "./routes";

/* ... */

app.get( "/*", ( req, res ) => {
    /* ... */

    const dataRequirements =
        routes
            .filter( route => matchPath( req.url, route ) ) // filter matching paths
            .map( route => route.component ) // map to components
            .filter( comp => comp.serverFetch ) // check if components have data requirement
            .map( comp => store.dispatch( comp.serverFetch( ) ) ); // dispatch data requirement

    Promise.all( dataRequirements ).then( ( ) => {
        const jsx = (
            <ReduxProvider store={ store }> <StaticRouter context={ context } location={ req.url }> <Layout /> </StaticRouter> </ReduxProvider>
        );
        const reactDom = renderToString( jsx );

        const reduxState = store.getState( );

        res.writeHead( 200, { "Content-Type": "text/html" } );
        res.end( htmlTemplate( reactDom, reduxState ) );
    } );
} );

/* ... */
複製代碼

經過這種方式,咱們獲得了一個組件列表,當 React 在當前 URL 下開始被渲染成字符串時,列表中的組件纔會 mount。

咱們收集了 data requirements,而且等待全部 API 調用返回數據。最後,咱們繼續進行服務端渲染,這時 Redux 中已有數據可用了。

能夠在相同倉庫中的fetch-data標籤看到關於數據獲取的完整例子。

你可能會注意到,這帶來了性能損失,由於咱們將渲染延遲到了數據被 fetch 完成以後。

這時就須要你本身來權衡了,並且你須要盡力去弄明白哪些調用是重要的而哪些又是不重要的。舉個例子,在一個電商 app 中,fetch 產品列表是較爲重要的,而產品價格和在 sidebar 的 filters 能夠被延遲加載。

Helmet

讓咱們來看看做爲 SSR 的福利之一的 SEO。在使用 React 時,你可能想要在<head>標籤中設置不一樣的 title, meta tags, keywords 等等。

請記住,一般狀況下<head>標籤並不屬於 React app 的一部分。

在這種狀況下react-helmet 提供了很好的解決方案。而且,它對 SSR 有着很好的支持。

import React from "react";
import Helmet from "react-helmet";

const Contact = () => (
    <div> <h2>This is the contact page</h2> <Helmet> <title>Contact Page</title> <meta name="description" content="This is a proof of concept for React SSR" /> </Helmet> </div> ); export default Contact; 複製代碼

你只需在組件樹中的任意位置添加您的head數據。這使你能夠在客戶端上更改已掛載的 React app 之外的值。

如今,咱們添加對 SSR 的支持:

/* ... */
import Helmet from "react-helmet";
/* ... */

app.get( "/*", ( req, res ) => {
    /* ... */
        const jsx = (
            <ReduxProvider store={ store }> <StaticRouter context={ context } location={ req.url }> <Layout /> </StaticRouter> </ReduxProvider>
        );
        const reactDom = renderToString( jsx );
        const reduxState = store.getState( );
        const helmetData = Helmet.renderStatic( );

        res.writeHead( 200, { "Content-Type": "text/html" } );
        res.end( htmlTemplate( reactDom, reduxState, helmetData ) );
    } );
} );

app.listen( 2048 );

function htmlTemplate( reactDom, reduxState, helmetData ) {
    return ` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> ${ helmetData.title.toString( ) } ${ helmetData.meta.toString( ) } <title>React SSR</title> </head> /* ... */ `;
}
複製代碼

如今,咱們就有了一個功能齊全的 React SSR 示例。

咱們從經過 Express 來渲染一個簡單的 HTML 字符串開始,逐漸添加了路由、狀態管理和數據獲取。最後,咱們除了 React 應用範圍之外的程序更改(處理head標籤)

完整的例子請查看 https://github.com/alexnm/react-ssr。

小結

正如你所見, SSR 也並非什麼大難題。但它可能會變得複雜。若是你一步步地構建你的需求,它會更容易掌握。

值得將 SSR 應用到你的 app 中嗎?一如既往,這須要看狀況。若是你的網站是面向成千上萬的用戶,則這是必須的。若是你正在構建一個相似於工具/儀表板之類的應用程序,你可能並不須要它。

固然,利用好 universal apps 的確可以讓前端社區獲得進步。

你有與 SSR 相似的方法嗎?或者你認爲我在這篇文章中遺漏了什麼嗎?請在 Twitter上給我留言。

若是你認爲這篇文章有用,請幫我在社區中分享它。

相關文章
相關標籤/搜索