如何用 React 作服务端渲染

Photo by Stage 7 Photographyhtml

原文连接:如何用 React 作服务端渲染 - 知乎专栏node

服务端渲染的一些优缺点这里就不说了,相信你们都已经很是清楚地知道了,本文意在讲述如何将一个简单的浏览器端渲染的 React SPA 按部就班地升级为支持服务端渲染。react

初始化一个普通的单页应用(浏览器端渲染)

在搭建服务端渲染应用以前咱们如今搭建一个基于浏览器端渲染的单页应用,该单页应用包含简单的路由功能。webpack

mkdir react-ssr
cd react-ssr
yarn init
复制代码

依赖安装:git

yarn add react react-dom react-router-dom
复制代码

首先建立 App 的入口文件 src/App.jsxgithub

import React from 'React';
import { Switch, Route, Link } from 'react-router-dom';

import Home from './pages/Home';
import Post from './pages/Post';

export default () => (
    <div>
        <Switch>
            <Route exact path="/" component={ Home } />
            <Route exact path="/post" component={ Post } />
        </Switch>
    </div>
)
复制代码

其次建立两个页面组件 src/pages/Home.jsxsrc/pages/Post.jsxweb

// Home.jsx
import React from 'react';
import { Link } from 'react-router-dom';

export default () => (
    <div> <h1>Page Home.</h1> <Link to="/post">Link to Post</Link> </div>
);

// Post.jsx
import React, { Component } from 'react';
import { Link } from 'react-router-dom';

export default class Post extends Component {
    constructor(props) {
        super(props);
        this.state = {
            post: {},
        };
    }
    componentDidMount() {
        setTimeout(() => this.setState({
            post: {
                title: 'This is title.',
                content: 'This is content.',
                author: '大板栗.',
                url: 'https://github.com/justclear',
            },
        }), 2000);
    }
    render() {
        const post = this.state.post;
        return (
            <div> <h1>Page Post</h1> <Link to="/">Link to Home</Link> <h2>{ post.title }</h2> <p>By: { post.by }</p> <p>Link: <a href={post.url} target="_blank">{post.url}</a></p> </div>
        );
    }
};
复制代码

而后建立 webpack 的入口文件 src/index.jsxexpress

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';

ReactDOM.render(
    <BrowserRouter> <App></App> </BrowserRouter>
    , document.getElementById('root'));
复制代码

package.jsonnpm

{
    "scripts": {
        "build:client": "NODE_ENV=development webpack -w",
    },
}
复制代码

到此,一个最简单的基于 React 带路由跳转的单页应用就完成了,下面是效果:json

React-Client-Side-Rendering

加入服务端渲染功能

顾名思义,要加入服务端渲染功能,就必需要有一个服务器,为了方便起见,这里就以 express 框架为例(固然你也可使用 koa, fastify, restify 等等你全部熟悉的框架):

yarn add express
复制代码

首先建立服务端代码的入口文件 server/index.js

import fs from 'fs';
import path from 'path';
import express from 'express';

import React from 'react';
import { StaticRouter } from "react-router-dom";
import { renderToString } from 'react-dom/server';
import App from '../src/App';

const app = express();

app.get('/*', (req, res) => {
    const renderedString = renderToString(
        <StaticRouter> <App></App> </StaticRouter>
    );

    fs.readFile(path.resolve('index.html'), 'utf8', (error, data) => {
        if (error) {
            res.send(`<p>Server Error</p>`);
            return false;
        }

        res.send(data.replace('<div id="root"></div>', `<div id="root">${renderedString}</div>`));
    })
});

app.listen(3000);
复制代码

其次配置打包服务端代码的 webpack 配置 webpack.server.js

const path = require('path');

module.exports = {
    mode: 'development',
    entry: './server/index.js',
    output: {
        filename: 'app.js',
        path: path.resolve('server/build'),
    },
    target: 'node',
    resolve: {
        extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
    },
    module: {
        rules: [{
            test: /\.jsx?$/,
            use: 'babel-loader',
            exclude: /node_modules/,
        }],
    },
};
复制代码

package.json

{
    "scripts": {
        "build:server": "NODE_ENV=development webpack -w --config webpack.server.js",
        "start": "nodemon server/build/app.js"
      },
}
复制代码

注:若是使用服务端渲染的话,文档建议须要把 src/index.jsx 中的 ReactDOM.render 换成 ReactDOM.hydrate,由于下个主版本 ReactDOM.render 将再也不支持服务端渲染。

react-dom docs: Using ReactDOM.render() to hydrate a server-rendered container is deprecated and will be removed in React 17. Use hydrate() instead.

最后 npm start 后会看到以下页面:

React-Server-Side-Rendering

咋一看和浏览器端渲染的结果同样,可是若是咱们分别查看两个页面的源代码的话,就会发现区别:

React-Client-Side-Rendering-Source

React-Server-Side-Rendering-Source

会很明显的发现第二张服务器端渲染的页面源代码中的 <div id="root"></div> 中多了一些代码,仔细观察的话会发现其实就是 Home.jsx 所渲染的代码。

至此,咱们已经实现了 React 服务端渲染的功能了。

不过此时若是你点击页面中的 Link to Post 连接的话,会发现路由跳转 /post 后渲染的仍是 Home.jsx 的内容,这是由于咱们没有在服务端中作对应的 路由匹配

服务端匹配路由

react-router-dom 路由模块提供一个 matchPath 方法来匹配路由。

在匹配路由以前咱们先来作一件事,就是把路由抽离成 src/routes.js

// routes.js
import Home from './pages/Home';
import Post from './pages/Post';

export default [{
    path: '/',
    exact: true,
    component: Home
}, {
    path: '/post',
    exact: true,
    component: Post,
}];

复制代码

而后在 server/index.js 中引入:

// ...
import { StaticRouter, matchPath } from 'react-router-dom';
import routes from '../src/routes';
// ...

app.get('/*', (req, res) => {
    const currentRoute = routes.find(route => matchPath(req.url, route)) || {};
    // ...
    const renderedString = renderToString(
        <StaticRouter location={ req.url }> <App></App> </StaticRouter>
    );
});
复制代码

经过数组的 find 方法配合 matchPath 方法匹配出当前路由的信息,而后在 <StaticRouter></StaticRouter> 组件中加上 location 的属性并传入当前的路由 req.url,此时若是从新点击页面中的 Link to Post 连接的话,/post 路由下的组件就能正常渲染了:

React-Server-Side-Rendering-Match-Path

此时你可能又会发现,跟以前的浏览器端渲染相比,跳转到 Post 页面后,并无获取到 componentDidMount 中定义的异步数据,这是由于 componentDidMount 生命周期函数只会在浏览器环境下才会执行,因此服务端是不会执行该函数的,因此也就没法获取到数据了,这显然不是咱们想要的结果。咱们指望的样子是路由跳转后能和浏览器端渲染同样,能够正常获取这些异步数据。

那咱们如何在服务端中获取这些数据后再返回给浏览器呢?

服务端异步获取数据

新建一个 src/helpers/fetchData.js 辅助函数来获取数据:

export default () => {
    return new Promise((resolve) => {
        setTimeout(() => resolve({
            title: 'This is title.',
            content: 'This is content.',
            author: '大板栗.',
            url: 'https://github.com/justclear',
        }), 2000);
    })
};
复制代码

实现的思路是,在匹配路由的时候就判断当前路由所包含的组件是否须要加载数据,若是须要,则去加载:

// ...
app.get('/*', (req, res) => {
    const currentRoute = routes.find(route => matchPath(req.url, route)) || {};
    const promise = currentRoute.fetchData ? currentRoute.fetchData() : Promise.resolve(null);

    promise.then(data => {
        // data here...
    }).catch(console.log);
});
复制代码

这里的逻辑就是判断 src/routes.js 中的路由对象中 fetchData 这个 key 是否有值,若是 fetchData 被三目运算判断为 true,则认为该路由须要获取数据,因此接下来咱们要给 path/post 的路由对象加上 fetchData,表示对应的 Post 组件须要异步获取数据:

// src/routes.js
import Home from './pages/Home';
import Post from './pages/Post';

import fetchData from './helpers/fetchData';

export default [{
    path: '/',
    exact: true,
    component: Home
}, {
    path: '/post',
    exact: true,
    component: Post,
    fetchData,
}];
复制代码

此时当路由匹配到 /post 的时候,就会执行 currentRoute.fetchData() 这个 promise,获取到数据后就能够渲染 Post 组件了:

promise.then(data => {
    const context = {
        data,
    };
    const renderedString = renderToString(
        <StaticRouter context={context} location={req.url}> <App></App> </StaticRouter>
    );

    res.send(template());

    function template() {
        return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>React Server Side Rendering</title> </head> <body> <div id="root">${renderedString}</div> <script>window.__ROUTE_DATA__ = ${JSON.stringify(data)}</script> <script src="dist/app.js"></script> </body> </html> `;
    }
}).catch(console.log);
复制代码

拿到数据 data 后应该传给 <StaticRouter></StaticRouter> 组件中的 context 属性中,这样就能够在组件自身的 props.staticContext 上获取到相应的数据,另外你还须要把 JSON.stringify(data) 赋值给 window.__ROUTE_DATA____ROUTE_DATA__ 能够按你想要的方式命名,方便咱们在组件内部经过判断 window.__ROUTE_DATA__ 的值来采起不一样的获取数据的策略。

不过此时若是你点击 Link to Post 的话,你可能会发现页面打不开了:

React-Server-Side-Rendering-Error

这是由于请求 /dist/app.js 被当成了普通的路由了,没有被当成一个静态资源来返回有效的 JavaScript 代码,解决方案就是在 server/index.js 中加入同样代码:

// ...
const app = express();
app.use(express.static('dist'));
// ...
复制代码

而后把 template 函数中的 <script src="dist/app.js"></script> 改为 <script src="/app.js"></script>

React-Server-Side-Rendering-Success

如今 /app.js 能够正确地返回了 JavaScript 代码了。

如今服务端已经把获取的 data 经过 window.__ROUTE_DATA__ = JSON.stringify(data) 的方式返回给浏览器端了,咱们如今须要在 Post.jsx 组件内部来使用这个状态:

// ...
export default class Post extends Component {
    constructor(props) {
        super(props);
        if (props.staticContext && props.staticContext.data) {
            this.state = {
                post: props.staticContext.data
            };
        } else {
            this.state = {
                post: {},
            };
        }
    }
    componentDidMount() {
        if (window.__ROUTE_DATA__) {
            this.setState({
                post: window.__ROUTE_DATA__,
            });
            delete window.__ROUTE_DATA__;
        } else {
            fetchData().then(data => {
                this.setState({
                    post: data,
                });
            })
        }
    }
    // ...
};
复制代码

React-Server-Side-Rendering-Final

你会发现当 /post 路由是由浏览器端打开的时候,组件会去判断 window.__ROUTE_DATA__ 是否有值,此时会发现 window.__ROUTE_DATA__null,因此会去执行 fetchData 来获取数据,因此你会看到进入 /post 后等待了 2 秒才显示数据。而直接刷新此页面的话,就无需等待,直接可看到结果。

总结

如今 React 服务端渲染 支持算是基本完成了,固然这还远远不够,实际项目中运用的话确定会复杂不少,好比经过 Webpack Dynamic Importsreact-loadable 等工具来优化代码以及如何配合 Redux 来使用等等等等。

本文的目的是让一些对 React Server Side Rendering 技术还不太了解或者没什么概念的同窗对服务端渲染有个初步的了解。

如需查看完整的项目,请移步 Github

FEFollow.png
相关文章
相关标签/搜索