三、服务器端渲染

通用 JavaScript 或同构 JavaScript 是我们将在本章中实现的功能的不同名称。更确切地说,我们将开发我们的应用,并在服务器端和客户端呈现应用的页面。它将不同于主要在客户端呈现的Angular1或主干单页应用。我们的方法在技术方面更加复杂,因为您需要部署用于服务器端渲染的全栈技能,但拥有此经验将使您成为更理想的程序员,从而可以将您的职业生涯提升到下一个水平——您将能够在市场上为您的技能收取更高的费用。

当服务器端值得实现时

服务器端呈现是文本内容(如新闻门户)初创企业/公司中非常有用的功能,因为它有助于通过不同的搜索引擎实现更好的索引。这是任何新闻和内容密集型网站的基本功能,因为它有助于增加有机流量。在本章中,我们还将使用服务器端渲染运行我们的应用。服务器端渲染可能有用的其他公司是娱乐企业,在这些企业中,用户没有那么多耐心,如果网页加载缓慢,他们可能会关闭浏览器。一般来说,所有B2C(面向消费者)应用都应该使用服务器端渲染,以改善访问其网站的用户的体验。

本章的重点将包括以下内容:

  • 重新排列整个服务器端代码,为服务器端渲染做准备
  • 开始使用 react dom/server 及其renderToString方法
  • RoutingContext与服务器端工作的 react 路由匹配
  • 改进客户端应用,使其针对同构 JavaScript 应用进行优化

你准备好了吗?我们的第一步是在后端模拟数据库的响应(在整个服务器端渲染在模拟数据上正常工作之后,我们将创建一个真正的 DB 查询)。

模拟数据库响应

首先,我们将在后端模拟数据库响应,以便准备直接进入服务器端渲染;我们将在本章后面对其进行更改:

$ [[you are in the server directory of your project]]
$ touch fetchServerSide.js  

fetchServerSide.js文件将包含从我们的数据库中获取数据的所有函数,以便使服务器端工作。

如前所述,我们现在将模拟它,在fetchServerSide.js中使用以下代码:

export default () => { 
    return { 
'article':{ 
      '0': { 
        'articleTitle': 'SERVER-SIDE Lorem ipsum - article one', 
        'articleContent':'SERVER-SIDE Here goes the content of the 
         article' 
      }, 

      '1': { 
        'articleTitle':'SERVER-SIDE Lorem ipsum - article two', 
        'articleContent':'SERVER-SIDE Sky is the limit, the 
         content goes here.' 
      } 
    } 
  } 
} 

制作这个模拟对象的目的是,我们将能够看到服务器端渲染在实现后是否正常工作,因为正如您可能已经注意到的,我们在每个标题和内容的开头都添加了SERVER-SIDE,因此它将帮助我们了解我们的应用是否从服务器端渲染中获取数据。稍后,此函数将替换为对 MongoDB 的查询。

下一个帮助我们实现服务器端渲染的方法是创建一个handleServerSideRender函数,该函数将在每次请求到达服务器时触发。

为了在前端每次调用后端时触发handleServerSideRender,我们需要使用使用app.use的 Express 中间件。到目前为止,我们使用了一些外部库,例如:

app.use(cors()); 
app.use(bodyParser.json({extended: false})) 

在本书中,我们将首次编写自己的小型中间件功能,其行为方式与corsbodyParser(外部libs也是中间件)类似。

在此之前,我们先导入 React 服务器端呈现(server/server.js中所需的依赖项:

import React from 'react'; 
import {createStore} from 'redux'; 
import {Provider} from 'react-redux'; 
import {renderToStaticMarkup} from 'react-dom/server'; 
import ReactRouter from 'react-router'; 
import {RoutingContext, match} from 'react-router'; 
import * as hist  from 'history'; 
import rootReducer from '../src/reducers'; 
import reactRoutes from '../src/routes'; 
import fetchServerSide from './fetchServerSide'; 

因此,在添加所有这些导入的server/server.js之后,该文件将如下所示:

import http from 'http'; 
import express from 'express'; 
import cors from 'cors'; 
import bodyParser from 'body-parser'; 
import falcor from 'falcor'; 
import falcorExpress from 'falcor-express'; 
import falcorRouter from 'falcor-router'; 
import routes from './routes.js'; 
import React from 'react' 
import { createStore } from 'redux' 
import { Provider } from 'react-redux' 
import { renderToStaticMarkup } from 'react-dom/server' 
import ReactRouter from 'react-router'; 
import { RoutingContext, match } from 'react-router'; 
import * as hist  from 'history'; 
import rootReducer from '../src/reducers'; 
import reactRoutes from '../src/routes'; 
import fetchServerSide from './fetchServerSide'; 

这里解释的大部分内容与前面章节中的客户端开发类似。重要的是以给定的方式导入历史,例如:import * as hist from 'history'RoutingContext匹配是在服务器端使用React-Router的一种方式。renderToStaticMarkup函数将在服务器端为我们生成一个 HTML 标记。

添加新导入后,在 Falcor 的中间件设置下:

// this already exists in your codebase 
app.use('/model.json', falcorExpress.dataSourceRoute((req, res) => { 
  return new falcorRouter(routes); // this already exists in your 
   codebase 
})); 

在该model.json代码下,添加以下内容:

let handleServerSideRender = (req, res) => 
{ 
  return; 
}; 

let renderFullHtml = (html, initialState) => 
{ 
  return; 
}; 
app.use(handleServerSideRender); 

每当服务器端收到来自客户端应用的请求时,app.use(handleServerSideRender)事件就会被触发。然后,我们将准备要使用的空函数:

  • handleServerSideRender:将使用renderToString创建有效的服务器端 HTML 标记
  • renderFullHtml:这是一个帮助函数,它将把我们新 React 的 HTML 标记嵌入到一个完整的 HTML 文档中,我们将在后面看到

handleServerSideRender 函数

首先,我们将创建一个新的 Redux 应用商店实例,该实例将在每次调用后端时创建。其主要目的是为应用提供初始状态信息,以便它能够基于当前请求创建有效的标记。

我们将使用我们已经在客户端应用中使用的Provider组件,该应用将包装Root组件。这将使我们的所有组件都可以使用该商店。

这里最重要的部分是ReactDOMServer.renderToString()在将标记发送到客户端之前,呈现应用的初始 HTML 标记。

下一步是使用store.getState()函数从 Redux 存储获取初始状态。初始状态将在我们的renderFullHtml函数中传递,稍后您将了解到这一点。

在我们处理两个新功能(handleServerSideRenderrenderFullHtml之前,请将其替换为server.js

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

替换为以下内容:

app.use('/static', express.static('dist')); 

这就是我们dist项目的全部内容。它将作为本地主机地址(http://localhost:3000/static/app.js*下的静态文件提供。这将有助于我们在初始服务器端呈现后制作一个单页应用。

同时确保app.use('/static', express.static('dist'));直接放在app.use(bodyParser.urlencoded({extended: false }));下方。否则,如果您在server/server.js文件中错误地放置了它,它可能无法工作。

在您完成express.static前面的工作后,让我们将此功能做得更完整:

let renderFullHtml = (html, initialState) => 
{ 
  return; // this is already in your codebase 
}; 

将前面的空函数替换为以下改进版本:

let renderFullPage = (html, initialState) => 
{ 
  return ` 
<!doctype html> 
<html> 
<head> 
<title>Publishing App Server Side Rendering</title> 
</head> 
<body> 
<h1>Server side publishing app</h1> 
<div id="publishingAppRoot">${html}</div> 
<script> 
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)} 
</script> 
<script src="/static/app.js"></script> 
</body> 
</html> 
    &grave; 
}; 

简而言之,当用户第一次访问网站时,我们的服务器将发送此 HTML 代码,因此我们需要创建带有主体和头部的 HTML 标记,以使其正常工作。服务器端发布应用的标题暂时在这里,用于检查我们是否正确获取服务器端 HTML 模板。稍后您可以通过以下命令找到$html

${html}  

Notice that we are using ES6 templates (Google ES6 template literals) syntax with &grave;.

在这里,我们将在后面输入由renderToStaticMarkup函数生成的值。renderFullPage函数的最后一步是在window.INITIAL_STATE = ${JSON.stringify(initialState)}窗口中给出初始的服务器端渲染状态,这样当向服务器发出第一个请求时,应用可以在客户端正确工作,并在后端获取数据。

好的,接下来让我们通过替换以下内容来关注handleServerSideRender函数:

let handleServerSideRender = (req, res) => 
{ 
  return; 
}; 

替换为更完整的函数版本,如下所示:

let handleServerSideRender = (req, res, next) => { 
  try { 
    let initMOCKstore = fetchServerSide(); // mocked for now 

    // Create a new Redux store instance 
    const store = createStore(rootReducer, initMOCKstore); 
    const location = hist.createLocation(req.path); 

    match({ 
      routes: reactRoutes, 
      location: location, 
    }, (err, redirectLocation, renderProps) => { 
      if (redirectLocation) { 
        res.redirect(301, redirectLocation.pathname + 
        redirectLocation.search); 
      } else if (err) { 
        console.log(err); 
        next(err); 
        // res.send(500, error.message); 
      } else if (renderProps === null) { 
        res.status(404) 
        .send('Not found'); 
      } else { 

      if  (typeofrenderProps === 'undefined') { 
        // using handleServerSideRender middleware not required; 
        // we are not requesting HTML (probably an app.js or other 
        file) 
        return; 
      } 

        let html = renderToStaticMarkup( 
          <Provider store={store}> 
          <RoutingContext {...renderProps}/> 
          </Provider> 
        ); 

        const initialState = store.getState() 

        let fullHTML = renderFullPage(html, initialState); 
        res.send(fullHTML); 
      } 
    }); 
  } catch (err) { 
      next(err) 
  } 
} 

let initMOCKstore = fetchServerSide();表达式正在从 MongoDB 获取数据(暂时模拟,稍后改进)。接下来,我们用store = createStore(rootReducer, initMOCKstore)创建一个服务器端的 Redux 故事。我们还需要为我们的应用的用户准备一个正确的位置,供 react 路由使用location = hist.createLocation(req.path)(在req.path中有一个简单的路径,在浏览器中;/register/login或简单的main page /。react 路由提供功能match,以匹配服务器端的正确路由。

在服务器端匹配路由后,我们将看到以下内容:

// this is already added to your codebase: 
let html = renderToStaticMarkup( 
<Provider store={store}> 
<RoutingContext {...renderProps}/> 
</Provider> 
); 

const initialState = store.getState(); 

let fullHTML = renderFullPage(html, initialState); 
res.send(fullHTML); 

正如您在这里看到的,我们正在使用renderToStaticMarkup创建服务器端 HTML 标记。在这个函数中,有一个提供程序,它的存储区以前是通过let initMOCKstore = fetchServerSide()获取的。在 Redux 提供程序中,我们有RoutingContext,它只是将所有必需的道具向下传递到我们的应用中,这样我们就可以有一个正确创建的标记服务器端。

所有这些之后,我们只需要准备我们的 Redux 商店的initialStateconst initialState = store.getState();以及更高版本的let fullHTML = renderFullPage(html, initialState);,就可以将我们需要的所有东西通过res.send(fullHTML)发送给客户。

我们已经完成了服务器端的准备工作。

仔细检查 server/server.js

在进行客户端开发之前,我们将仔细检查server/server.js,因为我们的代码顺序很重要,这是一个容易出错的文件:

import express from 'express'; 
import cors from 'cors'; 
import bodyParser from 'body-parser'; 
import falcor from 'falcor'; 
import falcorExpress from 'falcor-express'; 
import falcorRouter from 'falcor-router'; 
import routes from './routes.js'; 
import React from 'react' 
import { createStore } from 'redux' 
import { Provider } from 'react-redux' 
import { renderToStaticMarkup } from 'react-dom/server' 
import ReactRouter from 'react-router'; 
import { RoutingContext, match } from 'react-router'; 
import * as hist from 'history'; 
import rootReducer from '../src/reducers'; 
import reactRoutes from '../src/routes'; 
import fetchServerSide from './fetchServerSide'; 

const app = express(); 

app.server = http.createServer(app); 
// CORS - 3rd party middleware 
app.use(cors()); 
// This is required by falcor-express middleware to work correctly 
 with falcor-browser 
app.use(bodyParser.json({extended: false})); 

app.use(bodyParser.urlencoded({extended: false})); 

app.use('/static', express.static('dist')); 

app.use('/model.json', falcorExpress.dataSourceRoute(function(req, res) { 
  return new falcorRouter(routes); 
})); 

let handleServerSideRender = (req, res, next) => { 
  try { 
    let initMOCKstore = fetchServerSide(); // mocked for now 
    // Create a new Redux store instance 
    const store = createStore(rootReducer, initMOCKstore); 
    const location = hist.createLocation(req.path); 
    match({ 
      routes: reactRoutes, 
      location: location, 
      }, (err, redirectLocation, renderProps) => { 
        if (redirectLocation) { 

          res.redirect(301, redirectLocation.pathname +  
          redirectLocation.search); 
        } else if (err) { 

          next(err); 
        // res.send(500, error.message); 
        } else if (renderProps === null) { 

          res.status(404) 
          .send('Not found'); 
        } else { 
            if (typeofrenderProps === 'undefined') { 
            // using handleServerSideRender middleware not 
             required; 
            // we are not requesting HTML (probably an app.js or 
             other file) 

            return; 
          } 
          let html = renderToStaticMarkup( 
            <Provider store={store}> 
            <RoutingContext {...renderProps}/> 
            </Provider> 
          ); 

          const initialState = store.getState() 
          let fullHTML = renderFullPage(html, initialState); 
          res.send(fullHTML); 
        } 
       }); 
    } catch (err) { 
    next(err) 
  } 
} 

let renderFullPage = (html, initialState) => 
{ 
return &grave; 
<!doctype html> 
<html> 
<head> 
<title>Publishing App Server Side Rendering</title> 
</head> 
<body> 
<h1>Server side publishing app</h1> 
<div id="publishingAppRoot">${html}</div> 
<script> 
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)} 
</script> 
<script src="/static/app.js"></script> 
</body> 
</html> 
&grave; 
}; 

app.use(handleServerSideRender); 

app.server.listen(process.env.PORT || 3000); 
console.log(&grave;Started on port ${app.server.address().port}&grave;); 

export default app; 

这里提供了在后端进行服务器端渲染所需的一切。让我们继续讨论前端方面的改进。

前端调整以使服务器端渲染正常工作

我们需要对前端进行一些调整。首先,转到src/layouts/CoreLayout.js中的文件并添加以下内容:

import React from 'react'; 
import { Link } from 'react-router'; 

import themeDecorator from 'material-ui/lib/styles/theme- 
 decorator'; 
import getMuiTheme from 'material-ui/lib/styles/getMuiTheme'; 

class CoreLayout extends React.Component { 
  static propTypes = { 
    children :React.PropTypes.element 
  } 

根据前面的代码,要添加的新内容是:

import themeDecorator from 'material-ui/lib/styles/theme-decorator'; 
import getMuiTheme from 'material-ui/lib/styles/getMuiTheme'; 

此外,改进render功能,将default导出到:

  render () { 
    return ( 
<div> 
<span> 
    Links:   <Link to='/register'>Register</Link> |  
      <Link to='/login'>Login</Link> |  
      <Link to='/'>Home Page</Link> 
</span> 
<br/> 
   {this.props.children} 
</div> 
    ); 
  } 

export default themeDecorator(getMuiTheme(null, { userAgent: 'all' }))(CoreLayout); 

我们需要在CoreLayout组件中进行更改,因为默认情况下,Material UI 设计正在检查您在哪个浏览器中运行它,并且正如您所预测的,服务器端没有浏览器,因此我们需要在我们的应用中提供{ userAgent: 'all' }是否设置为all的信息。这将有助于避免控制台中关于服务器端 HTML 标记与客户端浏览器生成的 HTML 标记不同的警告。

我们还需要改进发布应用组件中的组件WillMount/_fetch功能,因此它将只在前端启动。转到src/layouts/PublishingApp.js文件,然后替换此旧代码:

componentWillMount() { 
  this._fetch(); 
} 

将其替换为新的改进代码:

componentWillMount() { 
  if(typeof window !== 'undefined') { 
    this._fetch(); // we are server side rendering, no fetching 
  } 
} 

if(typeof window !== 'undefined')语句检查是否存在窗口(在服务器端,该窗口将是未定义的)。如果是,则开始通过 Falcor(在客户端时)获取数据。

接下来,转到containers/Root.js文件并将其更改为以下内容:

import React  from 'react'; 
import {Provider}  from 'react-redux'; 
import {Router}  from 'react-router'; 
import routes  from '../routes'; 
import createHashHistory  from 'history/lib/createHashHistory'; 

export default class Root extends React.Component { 
  static propTypes = { 
    history : React.PropTypes.object.isRequired, 
    store   : React.PropTypes.object.isRequired 
  } 

render () { 
    return ( 
<Provider store={this.props.store}> 
<div> 
<Router history={this.props.history}> 
{routes} 
</Router> 
</div> 
</Provider> 
    ); 
  } 
} 

如您所见,我们已删除代码的这一部分:

// deleted code from Root.js 
const noQueryKeyHistory = createHashHistory({ 
  queryKey: false 
}); 

我们改变了这一点:

<Router history={noQueryKeyHistory}> 

为此:

<Router history={this.props.history}> 

我们为什么要这么做?它可以帮助我们从客户端浏览器的 URL 中去掉/#/符号,这样下次我们点击http://localhost:3000/register时,我们的server.js就可以通过req.path看到用户当前的 URL(在我们点击http://localhost:3000/register时,req.path就等于/register,我们在handleServerSideRender函数中使用。

完成所有这些操作后,您将能够在客户端浏览器中看到以下内容:

1-2 秒后,由于在PublishingApp.js中触发了实this._fetch()功能,它将变为以下内容:

当然,当您转到页面的 HTML 源时,可以看到服务器呈现的标记:

总结

我们已经完成了基本的服务器端渲染,如屏幕截图所示。服务器端渲染中唯一缺少的部分是从 MongoDB 中获取真实数据——这将在下一章中实现(我们将在server/fetchServerSide.js中解锁此获取)。

在卸载服务器端的数据库查询后,我们将开始改进应用的整体外观,并实现一些对我们很重要的关键功能,例如添加/编辑/删除文章。