使用 React(和 Redux)进行服务器端渲染 服务器端渲染概述 准备就绪 开始编码 我的 JavaScript 代码去哪儿了?! 路由器,到处都是路由器! 开始使用 Redux

2025-06-11

使用 React(和 Redux)进行服务器端渲染

简而言之,服务器端渲染

准备工作

让我们开始编码

我的 JavaScript 去哪儿了?!?

路由器,到处都是路由器!

启动 Redux

刚开始接触 React 的时候,我被它的强大所震撼。我一个接一个地构建组件,最后遇到一个 Facebook 的分享组件时,我突然发现:“哦,页面上根本没有内容!”

这可不太好,不是吗?那又是怎么回事?

大多数自动化网站爬虫在读取网站内容之前不会执行 JavaScript。
(谷歌可能会,但我无法确认)。而使用 React 时,在 JavaScript 执行之前页面上没有任何内容。这意味着爬虫无法读取你网站上的任何内容。

此外,React 并非将内容传递给访问者的最快方式。如果你查看浏览器开发者工具中的“网络”选项卡,你会注意到它首先请求页面,然后执行 JavaScript 包——在大多数情况下,之后你的 React 应用才会请求真正的内容。

在访问者看到任何内容之前,会发生很多次调用。

那么,面对这两个问题,我们能做些什么呢?

简而言之,服务器端渲染

有一种非常酷的技术叫做服务器端渲染。它的作用非常简单:在服务器上执行你的 React 应用,并将 HTML 结果发送到访客的客户端。客户端渲染 HTML,然后请求你的 JavaScript。它不需要再次渲染应用,而是将已经发送的 HTML 与 React 应用中的所有功能进行整合。

采用这种方法,内容无需客户端执行任何 JavaScript 即可显示。此外,您无需发送三个请求即可获取内容。访问者应该看到的所有内容都会在初始调用时发送。

准备工作

上面的解释听起来很简单。可惜,事实并非如此。实现过程中有一些复杂的部分需要我们理解,但我尽量保持简单易懂。

在开始之前,你需要安装webpack, webpack-cli, webpack-merge, webpack-node-external, express, babel, babel-preset-env, babel-preset-react, babel-loader,当然react还有react-dom。现在应该可以了。

现在我们需要设置 Webpack。Webpack 是一个模块打包工具。我想你们大多数人都用过它,或者至少听说过它。我不想深入讲解 Webpack,因为它的内容足够写一篇(或者五篇)文章了。

为了实现服务器端渲染,我们将对 webpack 进行更复杂的设置。我们将把 webpack 配置拆分成多个文件,这样就不需要重复编写太多代码了。

最后,我们需要两个 webpack 配置。一个用于服务器端代码,一个用于客户端。这两个文件有很多共同点,所以我们创建一个名为 的文件,webpack.base.js其中包含这两个配置共享的大部分配置。



// webpack.base.js

module.exports = {
  stats: {
    colors: true,
    reasons: true,
    chunks: false
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
    ]
  }
}


Enter fullscreen mode Exit fullscreen mode

这里看到的是 webpack 的标准配置。导出一个 JavaScript 对象,其中包含一些 webpack 的配置信息。stats 部分用于修复 webpack 输出中的一些问题,module 部分则告诉 webpack 在遇到满足“test”正则表达式的文件时应该使用哪种类型的加载器。加载器是一个函数,它以某种方式转换文件的内容。

好的,我刚才说了不止一个设置。我们先来处理服务器端,因为我们很快就要开始处理服务器端了。



// webpack.server.js

const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.js');

const config = {
    target: 'node',
    entry:  ['./index.js'],
    output:  {
        filename: 'server.js',
        path: path.resolve(__dirname, 'build'),
    },
    resolve: {
        extensions: ['.js', '.json']
    },
}

module.exports = merge(baseConfig, config);


Enter fullscreen mode Exit fullscreen mode

这是我们的服务器端配置,您可以在文件的末尾看到,我们将webpack.base.js文件中的 baseConfig 对象与该文件中的内容合并,以获取服务器端的最终 webpack 配置。

输入键告诉 webpack 应该从哪里开始查找代码。输出键告诉 webpack 打包后的文件应该保存到哪里。

在我们进入服务器应用程序之前,让我们快速设置一下 Babel:



// .babelrc

{
    "presets": [
        "react",
    ["env", {
        "targets": {
            "browsers": "last 2 versions"
        },
            "useBuiltIns": "usage",
        "loose": true,
        "modules": false
        }]
    ],

}


Enter fullscreen mode Exit fullscreen mode

我无法详细解释它到底做了什么。那样的话文章就太长了。简而言之,它设置了 Babel 来理解常见的 React 内容。

让我们告诉 npm 关于 webpack 和我们刚刚创建的设置。在package.jsonadd 的脚本部分中:



// package.json add to scripts

{
    scripts: {
        ...
        "server": "webpack --watch --config webpack.server.js --mode development"
    }
}



Enter fullscreen mode Exit fullscreen mode

让我们开始编码

下一步是设置某种服务器来响应访问者的请求。本文将使用 Express,但您可以使用任何您想要的服务器。

让我们使用来安装 Express npm install express

之后我们设置一个服务器:



// index.js

import express from 'express';
const app = express();

app.use(express.static('public'));

app.get('*', (req, res) => {
    // Here we are going to handle that react rendering and stuff.
});

app.listen(3000, () => {
    console.log('Server is listenting to port 3000');
});



Enter fullscreen mode Exit fullscreen mode

我们做了什么?我们设置了一个 Express 服务器,监听 3000 端口。我们添加了public一个用于存放静态资源的文件夹。我们还创建了一个带通配符的 GET 路由。这意味着每次调用都会与此路由匹配。

你可能注意到了,我引入的是 Express viaimport而不是require。这是因为我们想要编写同构 JavaScript。也就是无需修改即可在服务器和客户端上运行的 JavaScript。

现在我们想创建一些可以在服务器上渲染的内容。我们来创建一个主页视图组件,用户访问我们的应用时应该看到它。



// client/Homepage.js

import React from 'react';

class Homepage extends React.Component {

    render() {
        return (
            <div class="wrapper">
                <h1>Welcome, this is the homepage</h1>
                <button onClick={() => console.log("I', clicked!"}>
                    Click me!
                </button>
            </div>
        )
    }

}

export default Homepage;



Enter fullscreen mode Exit fullscreen mode

很简单,不是吗?这里我唯一想说的是,我把所有客户端代码都放到了一个文件夹中client。各位,一定要保持代码的整洁有序!

我们有服务器,有 React 组件,让我们将它们整合在一起。



// renderer.js

import { renderToString } from 'react-dom/server';
import Homepage from './client/Homepage';

function renderer() {

    const content = renderToString(
        <Homepage />
      );

    return content;
}

export default renderer;



Enter fullscreen mode Exit fullscreen mode

为了让一切看起来简洁明了,我们将渲染代码放入了一个名为 renderer 的函数中。如你所见,它从 react-dom/server 导入了一个名为 的特殊函数renderToString()。渲染到字符串与你可能知道的类似render()。它只渲染一次组件,并将其转换为 HTML 字符串。

之后,我们调用renderer()我们的 Express 服务器并将结果发送回给访问者。



// index.js

import express from 'express';
import renderer from './renderer';

const app = express();

app.get('*', (req, res) => {
    const result = renderer();
    res.send(result);
});

app.listen(3000, () => {
    console.log('Server is listenting to port 3000');
});



Enter fullscreen mode Exit fullscreen mode

瞧,第一个在服务器上渲染的 React 组件。你会注意到,无论你调用哪个路由,Homepage 组件每次都会被渲染。现在没关系,我们一会儿就解决这个问题。现在,请放心,我们已经启动并运行了服务器端渲染。

我的 JavaScript 去哪儿了?!?

您可能尝试在主页上使用,button并且可能已经注意到“哦,没有控制台日志......我的 JavaScript 去哪儿了!?!”。

它消失了,是因为renderToString()。它渲染了一个 HTML 字符串,但却删除了所有 JavaScript 代码。为了解决这个问题,我们需要为应用创建一个客户端包,并在访客客户端收到 HTML 代码后立即请求它。

为此,让我们回到 Webpack。我们需要前面提到的客户端设置。我们这样做是因为有些服务器代码在客户端上是不必要的,或者我们可能有一些极其机密的业务逻辑不想分享,或者可能是 API 机密……你懂的。



// webpack.client.js

const webpack = require('webpack');
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.js');

const config = {
    context: __dirname,
    entry: ['./client/index.js'],
    output: {
        path: path.join(__dirname, 'public'),
        filename: 'bundle.js',
    publicPath: '/',
    pathinfo: false,
    },
    resolve: {
        extensions: ['.js', '.json', '.scss']
    },
    optimization: {
        minimize: true
    },
};

module.exports = merge(baseConfig, config);


Enter fullscreen mode Exit fullscreen mode

与之前的服务器端配置一样,我们构建一个客户端配置,然后将其与基础配置合并。需要注意的是,我们使用了另一个入口点,并创建了另一个输出文件。因此,在两种配置下运行 webpack 后,我们最终会得到两个文件server.jsbundle.js

要启动 webpack,我们需要向 npm 添加一个新脚本:



// package.json add to scripts

{
    scripts: {
        ...
        "client": "webpack --watch --config webpack.client.js --mode development"
    }
}


Enter fullscreen mode Exit fullscreen mode

为了让 webpack 真正构建我们的代码,bundle.js我们需要先编写它。所以我们开始吧:



// client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import Homepage from './homepage';

const jsx = <Homepage />

ReactDOM.hydrate(jsx, document.getElementById('app'));



Enter fullscreen mode Exit fullscreen mode

对于 React 应用来说,这是一个相当标准的设置。我们使用 Homepage 组件,并将其渲染到一个 ID 为“app”的 div 中。因为这里讨论的是客户端,所以当这段代码到达时,页面已经渲染好了。为了让 React 理解,我们不希望它重新渲染整个页面,我们需要使用hydrate而不是render来告诉 React 只在 HTML 代码中添加一些内容。

现在我们有了 JavaScript 文件来提供给客户端,但是刷新页面时仍然没有显示。这是因为我们需要告诉客户端真正请求该client.js文件。为此,我们在函数中添加了一些代码renderer()



// renderer.js

import { renderToString } from 'react-dom/server';
import Homepage from './client/Homepage';

function renderer() {

    const content = renderToString(
        <Homepage />
      );

    const jsx = `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
        </head>

        <body>
            <div id="app">${content}</div>
            <script src="/bundle.js"></script>
        </body>
        </html>
    `;

    return jsx;
}

export default renderer;


Enter fullscreen mode Exit fullscreen mode

我们的渲染器返回的不再是简单的 React 组件,而是一个包含bundle.js所有内容的完整网站。客户端渲染完 HTML 代码后,会立即请求包含完整代码的 JavaScript,这样你想要创建的按钮就能正常工作了。或者至少应该如此 ;)

路由器,到处都是路由器!

现在我们在服务器渲染的前端已经有了一个真正的 React 应用,我们可以讨论路由问题了。还记得吗?无论我们使用什么路由,最终都会转到主页。为了解决这个问题,我们安装了一个名为 的工具react-router。我想你们都知道它的BrowserRouter工作原理。问题是……我们不能使用 BrowserRouter,因为它专门用于根据浏览器 URL 渲染路由。但是我们的服务器上没有浏览器。所以我们现在有点棘手。

我们要做的是,使用 StaticRouter 作为服务器部分,并使用老式的浏览器路由器作为前端。

但是,每当我们向软件添加新路线时,我们不想编写两个单独的路线声明,对吗?

因此,让我们将所有路由放在一个文件中,并使用它来配置两个路由器。为此,有一个名为 的小工具react-router-config。它允许我们编写一个配置数组,并提供了一个渲染路由的函数。



// routes.js

import Homepage from './client/Homepage';
import AboutUs from './client/AboutUs';

const routes = [
    {
        component: Homepage,
        path: "/",
        exact: true
    },
    {
        component: AboutUs,
        path: "/",
        exact: true
    }    
]

export default routes;



Enter fullscreen mode Exit fullscreen mode

数组中的每个对象都采用与Route组件相同的参数react-router

在我们的客户端代码中,我们现在可以执行以下操作来获取我们的 BrowserRouter:



// client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import routes from '../routes.js';

const jsx = (
    <Router>
      <div class="wrapper">{renderRoutes(routes)}</div>
    </Router>
);


ReactDOM.hydrate(jsx, document.getElementById('app'));



Enter fullscreen mode Exit fullscreen mode

我们使用
renderRoutesfromreact-router-config并传递我们的路由数组。结果将是一个完整渲染的Route组件包。

服务器端稍微复杂一些。这里我们使用StaticRouter。静态路由器没有浏览器 URL,所以我们需要向它传递一个请求,以便它能够获取我们想要渲染哪个路由的必要信息。

请求从我们的快速应用程序传递到renderer我们之前编写的用于呈现网站的函数中。



// renderer.js

import { renderToString } from 'react-dom/server';
import { StaticRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import routes from './routes.js';

function renderer(req) {

    const context = {};

    const content = renderToString(
        <Router context={context} location={req.path} query={req.query}>
            <div>{renderRoutes(routes)}</div>
        </Router>
    );

    const jsx = `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
        </head>

        <body>
            <div id="app">${content}</div>
            <script src="/bundle.js"></script>
        </body>
        </html>
    `;

    return {jsx, context};
}

export default renderer;


Enter fullscreen mode Exit fullscreen mode

如您所见,我们的操作与客户端基本相同,但我们使用了StaticRouter。我们将请求中的位置传递给它,以便它确定要执行的操作。此外,我们还向它发送了一个查询,以便我们能够对请求中的参数做出反应。最后一件事是上下文。上下文可以在路由器渲染的组件中访问。您可以使用它进行错误处理或设置渲染页面的状态。

例如,假设您想通过 API 加载产品,但请求的产品不存在。您希望服务器的初始调用返回 404 状态码而不是 200。这时就可以使用上下文。在组件中,只需在context.status = 404渲染后设置 ,res.status(context.status)如下所示:



// index.js

import express from 'express';
import renderer from './renderer';

const app = express();

app.get('*', (req, res) => {
    const result = renderer(req);
    const context = result.context;
    if(context && context.status !== undefined) {
        res.status(context.status);
    }
    res.send(result.jsx);
});

app.listen(3000, () => {
    console.log('Server is listenting to port 3000');
});



Enter fullscreen mode Exit fullscreen mode

好了,现在你的应用中的路由已经正常工作了。客户端和服务器端都一样。创建新路由时,你只需要将它们添加到 routes 数组中,两个路由器就知道该做什么了。

启动 Redux

你现在脑子疼吗?不疼?很好。因为我把最烦人(是的,很烦人!)的部分留到了最后:Redux。

事实上,我最后想谈的不仅仅是 Redux,我还想谈一下在服务器端渲染 React 应用时各种异步数据获取。问题在于,正如你记得的,我们习惯于renderToString预渲染页面,然后将其发送到客户端。但renderToString它只会渲染所有内容一次,因此并不关心渲染前可能需要从 API 获取的数据。但我们希望搜索引擎和其他工具能够获取我们遗漏的那些重要数据。

所以我们需要做的是在实际调用 之前获取渲染完整视图所需的所有数据renderToString。因此,对于我们需要组合的每个视图,都需要某种“内容获取”函数来加载请求视图所需的每一条内容。

然后,在内容可用后,我们需要将数据传递给我们的 React 应用程序,然后在所有内容可用的情况下调用renderToString

首先,我们将 Redux 融入其中:npm install redux react-redux redux-thunk

现在我们已经安装了 Redux,让我们首先做简单的部分,即前端。

我们将在这里使用一种非标准的方法,但您最终会明白我们为什么这样做。




// createStore.js

import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';

import rootReducer from './rootReducer';

const middleware = applyMiddleware(thunk);

export default initialState => createStore(rootReducer, initialState, middleware);



Enter fullscreen mode Exit fullscreen mode

我们编写了一个函数,它接受一个 initialState 参数并返回已创建的 store。我们将在客户端和服务端都使用它。



// client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import {Provider} from 'react-redux';

import createStore from '../createStore';
import routes from '../routes';
import rootReducer from '../rootReducer';

const store = createStore({});

const jsx = (
    <Provider store={store}>
        <Router>
          <div class="wrapper">{renderRoutes(routes)}</div>
        </Router>
    </Provider>
);


ReactDOM.hydrate(jsx, document.getElementById('app'));



Enter fullscreen mode Exit fullscreen mode

我们用我们的新功能创建了一个商店createStore(),并将应用程序包装在其中Provider

为了确保一切按计划进行,我们需要在数据加载完成后,获得视图渲染所需的所有数据。只要你有一个视图组件,所有数据都会加载到其中,然后传递给子组件,那么就很容易确定需要加载哪些数据。如果视图中有许多组件自行加载数据,那么很容易就会忘记一些数据获取操作。

为了能够加载数据,我们需要一个加载数据的函数,以便在渲染之前调用它。为了实现这一点,我们需要稍微重写一下视图组件。

我添加了一个到 Redux Store 的连接。我不想讨论如何做到这一点以及这一切意味着什么,因为我一周前写了一篇关于它的文章。



请随意阅读!



// client/Homepage.js

import React from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';

import fetchSomeData from './somewhere';

class Homepage extends React.Component {
    componentDidMount() {
        const {fetchSomeData: doFetch} = this.props;
        doFetch();
    }

    render() {
        return (
            <div class="wrapper">
                <h1>Welcome, this is the homepage</h1>
                <button onClick={() => console.log("I', clicked!"}>
                    Click me!
                </button>
            </div>
        )
    }

}

const mapStateToProps = state => ({
    someData: getSomeData(state);
})

const mapDispatchToProps = dispatch => bindActionCreators({
    fetchSomeData,
}, dispatch);


function loadData(store, match, cookie) {
    const actionToBeDispatched = [];
    actionToBeDispatched.push(store.dispatch(fetchSomeData()));

    return Promise.all(actionsToBeDispatched);
}

export default {
  loadData,
  component: connect(mapStateToProps, mapDispatchToProps)(Homepage),
};



Enter fullscreen mode Exit fullscreen mode

在这个代码示例中,有两件事非常重要。首先,我们得到了一个loadData()函数。它的作用是加载我们需要的每一位数据。loadData()返回一个Promise值,让我们知道所有操作何时完成。

另外,我们不像以前那样导出组件,而是导出一个包含 和loadData()组件本身的对象。这个对象会在路由中拆分,这样每个路由仍然包含一个组件,但会有一个 loadData 键。



// routes.js

import Homepage from './client/Homepage';
import AboutUs from './client/AboutUs';

const routes = [
    {
        ...Homepage,
        path: "/",
        exact: true
    },
    {
        ...AboutUs,
        path: "/",
        exact: true
    }    
]

export default routes;



Enter fullscreen mode Exit fullscreen mode

好的,让我们把所有东西整合在一起!



// index.js

import express from 'express';
import { matchRoutes } from 'react-router-config';
import renderer from './renderer';
import createStore from './createStore';

const app = express();

app.get('*', (req, res) => {
    const store = createStore({});
    const promises = matchRoutes(routes, req.path).map(
        ({ route, match }) => (route.loadData ? route.loadData(store, match,             req.get('cookie') || {}, req.query) : null)
    );

    Promise.all(promises).then(() => {
        const result = renderer(req, store);
        const context = result.context;
        if(context && context.status !== undefined) {
            res.status(context.status);
        }
        res.send(result.jsx);
    });
});

app.listen(3000, () => {
    console.log('Server is listenting to port 3000');
});



Enter fullscreen mode Exit fullscreen mode

在路由中,我们使用createStore之前编写的 创建一个存储。然后,我们使用一个名为 的函数,matchRoutes()该函数接受路由数组和请求路径作为参数。它返回一个匹配路由的数组。每个元素都包含路由和来自 的匹配数据react-router

这样,我们就可以调用loadData()路由对象中的函数,并等待它完成数据加载。之后,我们将存储的内容传递给renderer()函数,以确保一切正常渲染。

loadData()本例中传入了一个 Cookie 和一个查询参数。它们并非必需,但我决定保留它们,以便大家能够了解如何在loadData()函数中添加 Cookie 或查询参数。

我们快完成了。最后一件事是确保客户端加载时的状态与服务器一致。现在,服务器会正​​确设置所有内容,然后将其发送给客户端。但是客户端会把事情搞乱,因为它不知道服务器端存储的内容是什么。所以我们需要将存储发送给客户端。

让我们更新我们的renderer()



// renderer.js

import { renderToString } from 'react-dom/server';
import { StaticRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import routes from './routes.js';

function renderer(req, store) {

    const context = {};
    const state = store.getState();

    const content = renderToString(
        <Router context={context} location={req.path} query={req.query}>
            <div>{renderRoutes(routes)}</div>
        </Router>
    );

    const jsx = `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
        </head>

        <body>
            <div id="app">${content}</div>
            <script>
                window.STORE_DATA = ${JSON.stringify(state).replace('<script>', '')}
            </script>
            <script src="/bundle.js"></script>
        </body>
        </html>
    `;

    return {jsx, context};
}

export default renderer;


Enter fullscreen mode Exit fullscreen mode

我们习惯JSON.stringify()将整个商店的数据放入一个变量中。
我们会删除所有script标签,因为它们会造成混乱,并且存在潜在的安全问题。

你还记得我们之前写过一个叫 的函数吗createStore()?它当时没什么意义,因为……呃……有点多余。现在我们的状态存储在一个变量中,我们可以把这个状态存入createStore()函数中,从而创建一个 store。然后恍然大悟 ;)



// client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';

import {Provider} from 'react-redux';

import createStore from '../createStore';
import routes from '../routes';
import rootReducer from '../rootReducer';

const store = createStore(window.STORE_DATA);

const jsx = (
    <Provider store={store}>
        <Router>
          <div class="wrapper">{renderRoutes(routes)}</div>
        </Router>
    </Provider>
);


ReactDOM.hydrate(jsx, document.getElementById('app'));



Enter fullscreen mode Exit fullscreen mode

好的,就是这样。虽然路途漫长,但我们终于走到了尽头。我们的 React 应用可以运行了,路由也设置好了,Redux Store 也已到位,并且我们有一个在客户端上进行 hydrated 的服务器端渲染应用。你可以在浏览器中禁用 JavaScript 来测试。页面应该会按照你想要的方式显示,只是没有任何 JavaScript 函数。

这里还有一些事情我没有解决。比如前端的身份验证。你可以使用 Cookie 来实现,但还是有点棘手。这篇文章够长了,也许我下次再讨论这个问题。

一如既往,我希望你有所收获,也希望你喜欢这篇文章。如果喜欢,欢迎点赞或留言。我非常感激你的提示、建议,或者仅仅是一句友好的“你好”。

感谢阅读!

鏂囩珷鏉ユ簮锛�https://dev.to/markusclaus/server-side-rendering-with-react-and-redux-59od
PREV
ES6 与 ES7 Javascript 生命周期的变化 [ES6,ES7,ES8]
NEXT
简单代码与过于简单的代码不同:Elm vs JavaScript