[译]揭秘 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上给我留言。

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

相关文章
相关标签/搜索