揭秘 React 服务端渲染

原文:Demystifying server-side rendering in React
作者:Alex Moldovan


让我们来近距离接触一个能够让你使用 React 构建 universal 应用的特性——React 服务端渲染( Server-Side Rendering )。

服务端渲染(以下简称 SSR )是一个将通过前端框架构建的网站通过后端渲染模板的形式呈现的过程。

能够在服务端和客户端上渲染的应用称为 universal 应用。

为什么要 SSR

为了弄明白我们为什么需要 SSR,我们首先需要了解过去 10 年 Web 应用的发展历程。

这与单页应用(以下简称 SPA )的兴起息息相关。与传统的 SSR 应用相比, SPA 在速度和用户体验方面具有很大的优势。

但是这里有一个问题。SPA 的初始服务端请求通常返回一个没有 DOM 结构的 HTML 文件,其中只包含一堆 CSS 和 JS links。然后,应用需要另外 fetch 一些数据来呈现相关的 HTML 标签。

这意味着用户将不得不等待更长时间的初始渲染。这也意味着爬虫可能会将你的页面解析为空。

因此,关于这个问题的解决思路是:首先在服务端上渲染你的 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 中吗?一如既往,这需要看情况。如果你的网站是面向成千上万的用户的,则这是必须的。如果你正在构建一个类似于工具/仪表板之类的应用程序,你可能并不需要它。

当然,利用好 unibersal apps 的确能够让前端社区得到进步。

你有与 SSR 类似的方法吗?或者你认为我在这篇文章中遗漏了什么吗?请在 Twitter上给我留言。

如果你认为这篇文章很有用,请帮我在社区中分享它。

Show Comments