【笔记】搭建React服务端渲染环境

基于node,前后端分离,前端同构,前后端路由匹配

Posted by Jerry on February 13, 2019

前言

上个月公司进行官网改版,考虑需要seo,故无法使用纯单页模式,早期工作模式是前端提供静态页面,后端工作人员来套模版,但会导致代码不同步,且每一次的改动增加双方的改动量。也因为Jquery这种比较老的模式如今也被MVVM锁代替,因此打算直接上node来支撑前端服务去调用后端的restfulAPI,本文将着重讲解React服务端渲染( Server-Side Rendering )搭建要点。

服务端同构渲染好处

  • 完整可索引的HTML页面,用于SEO,解决single-page中搜索引擎无法抓取页面内容
  • 加速首屏渲染,无需像单页一样等待js全部加载
  • 同构将使得代码更加易于维护,客户端和服务端共享部分代码
  • 真正意义上的前端后分离,指责分工上更加明确

需要解决的问题

  • 如何规划目录结构
  • 如何实现同构
  • 前后端路由如何匹配
  • 如何设置不同的title, meta tags, keywords等
  • 静态资源部署
  • 服务端log日志

技术栈

  • Node
  • Express
  • React/Redux/React-router
  • React-helmet
  • morgan
  • webpack

原理

img (注:图片来自网络)

目录结构

src
├── client 前端开发文件夹
│   └── browser.js  客户端渲染
├── components  react组件
├── page   page目录
│   ├── Home    页面相关资源
│   │   ├── less    
│   │   ├── img
│   │   └── js
│   ├── About
│   └── ...
├── static 前端静态资源
│   ├── less
│   └── img
├── store 前端静态资源
│   └── store.js 前端redux状态控制存放
└── server 后端开发目录夹
    ├── api.js 后端restfulAPI的包装
    ├── routes.js 路由管理
    └── temple.js 页面模版

webpack配置

公共

const webpack = require('webpack');
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const nodeExternals = require('webpack-node-externals');

const isProduction = process.env.NODE_ENV === 'production';

const clientLoaders = isProduction ? [
    new ExtractTextPlugin({
        filename: 'index.css',
        allChunks: true
    }),
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
    }),
    new webpack.optimize.OccurrenceOrderPlugin(),
    new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false }, sourceMap: false })
] : [
    new ExtractTextPlugin({
        filename: 'index.css',
        allChunks: true
    })
];

server端

{
    entry: './src/server.js',
    output: {
      path: path.resolve(__dirname, 'dist/'),
      filename: 'server.js',
      libraryTarget: 'commonjs2',
      publicPath: '/'
    },
    target: 'node',
    node: {
      console: false,
      global: false,
      process: false,
      Buffer: false,
      __filename: false,
      __dirname: false
    },
    externals: nodeExternals(),
    module: {
      rules: [
        {
          test: /\.js$/,
          loader: 'babel-loader'
        },
        {
          test: /\.json$/,
          loader: 'json-loader'
        },
        {
          test: /\.less$/,
          loader: 'ignore-loader'
        }
      ]
    }
  }

client端

{
    entry: {
        app: './src/client/browser.js'
    },
    output: {
        path: path.resolve(__dirname, '../static/resource/developer/webresource/'),
        publicPath: "/developer/webresource/",
        filename: '[name].js'
    },
    plugins: clientLoaders,
    module: {
      rules: [
          {
              test: /\.json$/,
              loader: 'json-loader'
          },{
              test: /\.js$/,
              loader: 'babel-loader',
              exclude: /node_modules/
          },{
              test: /\.less$/,
              loader: ExtractTextPlugin.extract({
                  fallback: 'style-loader',
                  use: ['css-loader?minimize','less-loader?minimize']
              })
          },{
              test: /\.(png|jpe?g|gif)(\?.*)?$/,
              loader: 'file-loader?limit=2048&name=assets/images/[name]_[hash:4].[ext]'
          }, {
              test: /\.(woff|eot|ttf|otf|svg)(\?.*)?$/,
              loader: 'url',
              query: {
                  limit: 10000,
                  name: 'images/fonts/[name].[hash:4].[ext]'
              }
          }
      ]
    },
    resolve: {
      extensions: ['.js', '.jsx']
    }
  }

同构实现

在客户端,通过调用ReactDOM.hydrate方法把Virtual DOM转换成真实DOM最后渲染到界面。

ReactDOM.hydrate((<App />, document.getElementById('root'));  

在服务端,通过调用ReactDOMServer.renderToString方法把Virtual DOM转换成HTML字符串返回给客户端,从而达到服务端渲染的目的。

const server = express();
server.use('*',(req,res,next)=>{
    const jsx = (
        <App />
    );
    const reactDom = renderToString( jsx );
    res.end( renderPage( reactDom, reduxState, helmetData ) );
});

路由以及服务端异步获取数据实现

客户端路由可以不依赖服务端,根据hash方式或者调用history API,不同的URL渲染不同的视图,实现无缝的页面切换,用户体验极佳。但服务端渲染不同的地方在于,在渲染之前,必须根据URL正确找到相匹配的组件返回给客户端。
在服务端异步获取数据的实现中,我们在服务端上进行 API 调用,将结果存储在 Redux 中,然后使用再渲染携带着相关数据的完整的 HTML 渲染给客户端。

我们先创建一个routes文件

//routes.js

export default [
    {
        path: "/",
        component: Home,
        exact: true,
    },
    {
        path: "/intro",
        component: Intro,
        exact: true,
    },
    {
        path: "/intro/:id",
        component: Intro,
        exact: true,
    },
    {
        path: "/agreement",
        component:Agreement,
        exact: true,
    },
    {
        path: "/case",
        component:Case,
        exact: true,
    }
];

然后我们静态声明每个组件的 data requirements:

import { fetchData } from "../store";

class Home extends React.Component {
    /* ... */

    render( ) {
        const { circuits } = this.props;

        return (
            /* ... */
        );
    }
}

Home.serverFetch = fetchHome;
const mapStateToProps = ( state ) => ( {
    circuits: state.data,
} );

const mapDispatchToProps = {
    fetchHome,
};

export default connect(mapStateToProps, mapDispatchToProps)(Home);

fetchHome是自由命名,每个页面可对应不同的name。

注意,fetchHome是一个 Redux thunk action,当它被 dispatched 时,返回一个 Promise。

const server = express();
server.get('*',(req,res)=>{
    const context = {};
    const store = createStore();
    store.dispatch(initializeSession());

    const dataRequirements =
        routes.filter( route => matchPath( req.url, route ) )
            .map( route => route.component )
            .filter( comp => comp.serverFetch )
            .map( comp => store.dispatch( comp.serverFetch() ) );

    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();
        const helmetData = Helmet.renderStatic();

        res.writeHead( 200, { "Content-Type": "text/html; charset=utf-8" } );
        res.end( renderPage( reactDom, reduxState, helmetData ) );
    });
});

可以看到,最终是收集了dataRequirements,并且等待所有 API 调用返回数据。最后,我们继续进行服务端渲染,这时 Redux 中已有数据可用了。

Helmet自定义<head>标签

既然要优化seo,因此路由到不同页面的时候必须去针对<head>标签配置不同的title, meta tags, keywords等。
react-helmet提供了很好的解决方案。并且对SSR支持友好。

import Helmet from "react-helmet";

export default class About extends Component {
    render() {
        return (
           <Helmet>
               <title>关于我们</title>
           </Helmet>
        )
    }
}
/* ... */

const server = express();
server.get('*',(req,res)=>{
    /*...*/

    const dataRequirements =
        routes.filter( route => matchPath( req.url, route ) )
            .map( route => route.component )
            .filter( comp => comp.serverFetch )
            .map( comp => store.dispatch( comp.serverFetch() ) );

    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();
        const helmetData = Helmet.renderStatic();

        res.writeHead( 200, { "Content-Type": "text/html; charset=utf-8" } );
        res.end( renderPage( reactDom, reduxState, helmetData ) );
    });
});

function renderPage( reactDom, reduxState, helmetData ) {
    return `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            ${ helmetData.title.toString( ) }
            ${ helmetData.meta.toString( ) }
            <title>React SSR</title>
        </head>
        
        /* ... */
    `;
}

资源静态化部署

考虑到静态资源需要进行CDN,因此静态资源单独抽离,进行部署,然后在模版中单独引入即可。

//template.js

const renderPage = (body,reduxState,helmetData) => {
    return `
    <!DOCTYPE html>
    <html>
      <head>
        ${helmetData.title.toString()}
        <link rel="shortcut icon" href="//static.bimface.com/favicon.ico" type="image/x-icon"/>
        <link rel="icon" href="//static.bimface.com/favicon.ico" type="image/x-icon"/>
        
        <link rel="stylesheet" href="http://static.bimface.com/developer/webresource/index.css" />
      </head>
      
      <body>
        <div id="root">${body}</div>
        <script>
          window.REDUX_DATA = ${JSON.stringify(reduxState)}
        </script>
        <script src="http://static.bimface.com/developer/webresource/app.js"></script>
      </body>
    </html>
  `;
};

export default renderPage;

log日志引入

morgan是express默认的日志中间件,也可以脱离express,作为node.js的日志组件单独使用。

直接引用

const server = express();
server.use(morgan('combined'));

日志切割

线上应用的日志如果都存在同一个文件,时间久文件会变得很大,影响性能的同时也增加的查看成本。所以可以使用日志分割将每天的日志生成一个文件。
使用rotating-file-stream即可。

import rfs from 'rotating-file-stream';

const server = express();
var accessLogStream = rfs(`${new Date().getFullYear()}-${new Date().getMonth() + 1}-${new Date().getDate()}.log`, {
    interval: '1d',
    path: '/data/logs'
});
server.use(morgan('combined', { stream: accessLogStream }));