[譯]利用React Router4實現的服務端直出渲染(SSR)

原文javascript

咱們已經熟悉React 服務端渲染(SSR)的基本步驟,如今讓咱們更進一步利用 React RouterV4 實現客戶端和服務端的同構。畢竟大多數的應用都須要用到web前端路由器,因此要讓SSR可以正常的運行,瞭解路由器的設置是十分有必要的html

基本步驟

路由器配置

前言已經簡單的介紹了React SSR,首先咱們須要添加ReactRouter4到咱們的項目中前端

$ yarn add react-router-dom

# or, using npm
$ npm install react-router-dom
複製代碼

接着咱們會描述一個簡單的場景,其中組件是靜態的且不須要去獲取外部數據。咱們會在這個基礎之上去了解如何完成取到數據的服務端渲染。java

在客戶端,咱們只需像之前同樣將咱們的的App組件經過ReactRouter的BrowserRouter來包起來。react

src/index.jsweb

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

import App from './App';

ReactDOM.hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById('root')
);
複製代碼

在服務端咱們將採起相似的方式,可是改成使用無狀態的 StaticRouterexpress

server/index.jsnpm

app.get('/*', (req, res) => {
  const context = {};
  const app = ReactDOMServer.renderToString(
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );

  const indexFile = path.resolve('./build/index.html');
  fs.readFile(indexFile, 'utf8', (err, data) => {
    if (err) {
      console.error('Something went wrong:', err);
      return res.status(500).send('Oops, better luck next time!');
    }

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

app.listen(PORT, () => {
  console.log(`😎 Server is listening on port ${PORT}`);
});
複製代碼

StaticRouter組件須要 locationcontext屬性。咱們傳遞當前的url(Express req.url)給location,設置一個空對象給context。context對象用於存儲特定的路由信息,這個信息將會以staticContext的形式傳遞給組件json


運行一下程序看看結果是否咱們所預期的,咱們給App組件添加一些路由信息redux

src/App.js

import React from 'react';
import { Route, Switch, NavLink } from 'react-router-dom';
import Home from './Home';
import Posts from './Posts';
import Todos from './Todos';
import NotFound from './NotFound';

export default props => {
  return (
    <div>
      <ul>
        <li>
          <NavLink to="/">Home</NavLink>
        </li>
        <li>
          <NavLink to="/todos">Todos</NavLink>
        </li>
        <li>
          <NavLink to="/posts">Posts</NavLink>
        </li>
      </ul>

      <Switch>
        <Route
          exact
          path="/"
          render={props => <Home name="Alligator.io" {...props} />}
        />
        <Route path="/todos" component={Todos} />
        <Route path="/posts" component={Posts} />
        <Route component={NotFound} />
      </Switch>
    </div>
  );
};
複製代碼

如今若是你運行一下程序($ yarn run dev),咱們的路由在服務端被渲染,這是咱們所預期的。

利用404狀態來處理未找到資源的網絡請求

咱們作一些改進,當渲染NotFound組件時讓服務端使用404HTTP狀態碼來響應。首先咱們將一些信息放到NotFound組件的staticContext

import React from 'react';

export default ({ staticContext = {} }) => {
  staticContext.status = 404;
  return <h1>Oops, nothing here!</h1>;
};
複製代碼

而後在服務端,咱們能夠檢查context對象的status屬性是不是404,若是是404,則以404狀態響應服務端請求。

server/index.js

// ...

app.get('/*', (req, res) => {
  const context = {};
  const app = ReactDOMServer.renderToString(
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );

  const indexFile = path.resolve('./build/index.html');
  fs.readFile(indexFile, 'utf8', (err, data) => {
    if (err) {
      console.error('Something went wrong:', err);
      return res.status(500).send('Oops, better luck next time!');
    }

    if (context.status === 404) {
      res.status(404);
    }

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

// ...
複製代碼

重定向

補充一下,咱們能夠作一些相似重定向的工做。若是咱們有使用Redirect組件,ReactRouter會自動添加劇定向的url到context對象的屬性上。

server/index.js (部分)

if (context.url) {
  return res.redirect(301, context.url);
}
複製代碼

讀取數據

有時候咱們的服務端渲染應用須要數據呈現,咱們須要用一種靜態的方式來定義咱們的路由而不是隻涉及到客戶端的動態的方式。失去定義動態路由的定義是服務端渲染最適合所須要的應用的緣由(譯者注:這句話的意思應該是SSR不容許路由是動態定義的)。


咱們將使用fetch在客戶端和服務端,咱們增長isomorphic-fetch到咱們的項目。同時咱們也增長serialize-javascript這個包,它能夠方便的序列化服務器上獲取到的數據。

$ yarn add isomorphic-fetch serialize-javascript

# or, using npm:
$ npm install isomorphic-fetch serialize-javascript
複製代碼

咱們定義咱們的路由信息爲一個靜態數組在routes.js文件裏

src/routes.js

import App from './App';
import Home from './Home';
import Posts from './Posts';
import Todos from './Todos';
import NotFound from './NotFound';

import loadData from './helpers/loadData';

const Routes = [
  {
    path: '/',
    exact: true,
    component: Home
  },
  {
    path: '/posts',
    component: Posts,
    loadData: () => loadData('posts')
  },
  {
    path: '/todos',
    component: Todos,
    loadData: () => loadData('todos')
  },
  {
    component: NotFound
  }
];

export default Routes;
複製代碼

有一些路由配置如今有一個叫loadData的鍵,它是一個調用loadData函數的函數。這個是咱們的loadData函數的實現

helpers/loadData.js

import 'isomorphic-fetch';

export default resourceType => {
  return fetch(`https://jsonplaceholder.typicode.com/${resourceType}`)
    .then(res => {
      return res.json();
    })
    .then(data => {
      // only keep 10 first results
      return data.filter((_, idx) => idx < 10);
    });
};
複製代碼

咱們簡單的使用fetch來從REST API 獲取數據

在服務端咱們將使用ReactRouter的matchPath去尋找當前url所匹配的路由配置並判斷它有沒有loadData屬性。若是是這樣,咱們調用loadData去獲取數據並把數據放到全局window對象中在服務器的響應中

server/index.js

import React from 'react';
import express from 'express';
import ReactDOMServer from 'react-dom/server';
import path from 'path';
import fs from 'fs';
import serialize from 'serialize-javascript';
import { StaticRouter, matchPath } from 'react-router-dom';
import Routes from '../src/routes';

import App from '../src/App';

const PORT = process.env.PORT || 3006;
const app = express();

app.use(express.static('./build'));

app.get('/*', (req, res) => {
  const currentRoute =
    Routes.find(route => matchPath(req.url, route)) || {};
  let promise;

  if (currentRoute.loadData) {
    promise = currentRoute.loadData();
  } else {
    promise = Promise.resolve(null);
  }

  promise.then(data => {
    // Lets add the data to the context
    const context = { data };

    const app = ReactDOMServer.renderToString(
      <StaticRouter location={req.url} context={context}>
        <App />
      </StaticRouter>
    );

    const indexFile = path.resolve('./build/index.html');
    fs.readFile(indexFile, 'utf8', (err, indexData) => {
      if (err) {
        console.error('Something went wrong:', err);
        return res.status(500).send('Oops, better luck next time!');
      }

      if (context.status === 404) {
        res.status(404);
      }
      if (context.url) {
        return res.redirect(301, context.url);
      }

      return res.send(
        indexData
          .replace('<div id="root"></div>', `<div id="root">${app}</div>`)
          .replace(
            '</body>',
            `<script>window.__ROUTE_DATA__ = ${serialize(data)}</script></body>`
          )
      );
    });
  });
});

app.listen(PORT, () => {
  console.log(`😎 Server is listening on port ${PORT}`);
});
複製代碼

請注意,咱們添加組件的數據到context對象。在服務端渲染中咱們將經過staticContext來訪問它。

如今咱們能夠在須要加載時獲取數據的組件的構造函數和componentDidMount方法裏添加一些判斷

src/Todos.js

import React from 'react';
import loadData from './helpers/loadData';

class Todos extends React.Component {
  constructor(props) {
    super(props);

    if (props.staticContext && props.staticContext.data) {
      this.state = {
        data: props.staticContext.data
      };
    } else {
      this.state = {
        data: []
      };
    }
  }

  componentDidMount() {
    setTimeout(() => {
      if (window.__ROUTE_DATA__) {
        this.setState({
          data: window.__ROUTE_DATA__
        });
        delete window.__ROUTE_DATA__;
      } else {
        loadData('todos').then(data => {
          this.setState({
            data
          });
        });
      }
    }, 0);
  }

  render() {
    const { data } = this.state;
    return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>;
  }
}

export default Todos;
複製代碼

工具類

ReactRouterConfig是由ReactRouter團隊提供和維護的包。它提供了兩個處理ReactRouter和SSR更便捷的工具matchRoutesrenderRoutes

matchRoutes

前面的例子都很是簡單都,都沒有嵌套路由。有時在多路由的狀況下,使用matchPath是行不通的,由於它只能匹配一條路由。matchRoutes是一個能幫助咱們匹配多路由的工具。

這意味着在匹配路由的過程當中咱們能夠往一個數組裏存放promise,而後調用promise.all去解決全部匹配到的路由的取數邏輯。

import { matchRoutes } from 'react-router-config';

// ...

const matchingRoutes = matchRoutes(Routes, req.url);

let promises = [];

matchingRoutes.forEach(route => {
  if (route.loadData) {
    promises.push(route.loadData());
  }
});

Promise.all(promises).then(dataArr => {
  // render our app, do something with dataArr, send response
});

// ...
複製代碼

renderRoutes

renderRoutes接收咱們的靜態路由配置對象並返回所需的Route組件。爲了matchRoutes能適當的工做renderRoutes應該被使用。

經過使用renderRoutes,咱們的程序改爲了一個更簡潔的形式。

src/App.js

import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Switch, NavLink } from 'react-router-dom';

import Routes from './routes';

import Home from './Home';
import Posts from './Posts';
import Todos from './Todos';
import NotFound from './NotFound';

export default props => {
  return (
    <div>
      {/* ... */}

      <Switch>
        {renderRoutes(Routes)}
      </Switch>
    </div>
  );
};
複製代碼

譯者注

  • SSR服務端React組件的生命週期不會運行到componentDidMount,componentDidMount只有在客戶端纔會運行。
  • React16再也不推薦使用componentWillMount方法,應使用constructor來代替。
  • staticContext的實現應該跟redux的高階組件connect相似,也是經過包裝一層react控件來實現子組件的屬性傳遞。
  • 文章只是對SSR作了一個入門的介紹,如Loadable和樣式的處理在文章中沒有介紹,但這兩點對於SSR來講很重要,之後找機會寫一篇相關的博文
相關文章
相關標籤/搜索