让我们仔细研究下这个能让你用 React 构建通用应用程序的特性吧。

服务端渲染(以下简称 SSR)是前端框架后端系统上运行时渲染。如果一个应用程序在服务端和客户端都可以渲染,那么它被称作通用应用程序(universal app)。

为何需要 SSR 呢?

我们应该先了解 Web 应用程序在过去 10 年的发展历程,这有助于我们理解这个问题。

这与 单页应用(以下简称 SPA)的兴起密切相关。与传统的服务端渲染应用相比,SPA 在速度和用户体验方面具有巨大优势。

但问题随之而来。SPA 的初始服务端请求,通常返回带有一组 CSS 和 JavaScript(JS)链接的 HTML 文件,然后需要提取外部文件以呈现相关标记。

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

因此,其解决方法是首先在服务端渲染应用,然后在客户端使用 SPA。

SSR + SPA = 通用应用

你可能会在其他文章中看到“同构应用(isomorphic app)”这个词,这跟“通用应用(universal app)”是一回事。

现在,用户不必等待 JS 加载,并能在初始请求返回响应后,立即获得完全渲染的 HTML

试想一下,这能给使用 3G 网络的用户带来多么大的便利。你几乎可以立即在屏幕上获取内容,而不必浪费 20 多秒等它加载完成。

image-14

现在,所有发至服务器的请求都将返回完全渲染后的 HTML,这对 SEO 部门来说是个好消息!

爬虫会像索引网络上的其他静态网站那样,索引你在服务器上呈现的所有内容。

综上所述,SSR 有两个主要优点:

  • 更快的初始页面渲染
  • 完全可索引的 HTML 页面

逐步理解 SSR

我们采取迭代的方法来构建完整的 SSR 示例,从用于服务端渲染的 React API 开始,逐步添加内容。

你可以 follow 这个仓库并查看为每个构建步骤定义的标签。

基本设置

首先,为了使用 SSR,我们需要一台服务器。我们将使用一个简单的 Express 应用来渲染 React 应用程序。

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

在第十行,我们指定了 Express 要服务的位于输出文件夹中的静态文件。

我们创建了一个处理所有非静态请求的路由,该路由将返回渲染完毕的 HTML。

我们使用 renderToString (第 13 到 14 行)来将起始 JSX 转化成要插入到 HTML 模板中的 string

请注意,我们在客户端和服务端代码中使用了相同的 Babel 插件,因此 JSX 和 ES Modules 可以在 server.js 中运行。

客户端上相应的函数目前是 ReactDOM.hydrate,该函数将使用服务端渲染的 React 应用程序,并将附加事件处理程序。

import ReactDOM from "react-dom";
import Layout from "./components/Layout";

const app = document.getElementById( "app" );
ReactDOM.hydrate( <Layout />, app );

你可以查看仓库中的 basic 标签来浏览完整示例。

搞定了,你刚刚创建了第一个服务端渲染的 React 应用!

React 路由

坦白讲,该应用的功能还不够丰富。所以我们添加一些路由,来看看如何处理服务端部分。

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 组件会在服务端渲染多个路由。

我们需要模拟服务器端的路由,主要的更改如下所示。

/* ... */
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 组件中并为其提供 location

附带说明一下,context 用于在渲染 React DOM 时追踪潜在的重定向。这需要通过服务端的 3XX 响应来处理。

你可以查看相同仓库中的 router 标签来浏览有关路由的完整示例。

Redux

现在我们已经具备了路由功能,我们来整合 Redux

在简单的情况下,我们需要 Redux 来处理客户端的状态管理。但如果我们需要基于状态来渲染部分部分 DOM 呢?这就需要在服务端初始化 Rudux 了。

如果你的应用在服务端调度操作 ,那就需要捕获该状态并将其和 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 状态和 HTML 一起发送。

接下来看看客户端:

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 标签来浏览完整示例。

获取数据

最后一个难题就是数据的加载,这有点棘手。

假设我们有一个提供 JSON 数据的API。

在我们的代码库中,我利用一个公共的 API 获取了 2018 年 Formula 1 赛季的所有事件。假设我们要在主页显示所有事件。

在 React 应用挂载完成并渲染完所有内容后,我们能从客户端调用 API。但这会导致糟糕的用户体验,可能在用户看到相关内容之前,页面会显示正在加载。

我们已经有了 Redux 来在服务端存储数据以及发送数据至客户端。

如果我们在服务端调用 API,将结果存在 Redux 中,然后用相关数据来渲染完整的 HTML 给客户端,会如何呢?

但我们怎么知道应该调用哪些呢?

首先,我们需要以一种不同的方式来声明路由,所以我们看一下路由配置文件。

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

然后我们静态声明每个组件的数据需求。

/* ... */
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,它返回一个 Promise。

在服务端,我们使用一个名为 matchRoute 的特别的函数,它来自 react-router

/* ... */
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 下被渲染成字符串时所需装载的组件列表。

我们收集了 data requirements 并等待所有调用的 API 的返回值。最终,我们恢复服务端渲染,这时 Redux 中已经得到了数据。

可以查看相同仓库fetch-data 标签来浏览完整示例。

你可能会注意到,这导致了性能损失,因为我们在获取数据之后才进行渲染。

这时你就需要权衡了,你需要尽可能弄清楚哪些调用是必不可少的。举例来说,对于一个电商应用,获取产品列表是至关重要的,需要尽快加载,但是价格以及侧边栏选择器可以被延迟加载。

Helmet

作为 SSR 的奖励,让我们来看看 SEO。在使用 React 时,你可能想在 <head> 标签中设置不同的值,例如 title , meta tags , key words 等等。

请注意, <head> 标签通常不属于 React 应用。

在这种情况下,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 应用以外的值。

现在,我们添加了对 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 应用以外的更改。

最终的代码库位于前面提到的相同仓库master 分支上。

总结

如你所见,SSR 算不上多大的难题,但它可以变得复杂。如果你一步步构建自己的需求,那会简单很多。

在应用中添加 SSR 有必要吗?和往常一样,这需要结合实际情况。如果你的网站是公开的,并且可以供成千上万的用户访问,那答案就是必须的。但如果你要构建一个工具/仪表板之类的应用,那就没什么必要了。

无论如何,利用好通用应用,是前端社区的一大进步。

你用过类似 SSR 的方法吗?或者你是否认为这篇文章存在纰漏?欢迎留言讨论。

如果你认为这篇文章对你有帮助,请在社区中分享吧!

原文:https://www.freecodecamp.org/news/demystifying-reacts-server-side-render-de335d408fe4/,作者:Alex Moldovan