React-蓝图-四-

30 阅读33分钟

React 蓝图(四)

原文:zh.annas-archive.org/md5/a2bffca9c62012ab857fb3d1f735ef00

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章。Reactagram

在本章中,我们将应用在前几章中开发的技能,并围绕照片构建一个社交网络应用。该应用可在桌面浏览器以及原生手机和平板电脑上使用。

在本章中,我们将通过连接到名为 Firebase 的实时数据库解决方案来探索 Flux 架构的替代方案。我们将创建一个高阶函数,我们将将其实现为一个围绕我们的路由的单例包装。这种设置将使我们能够为我们用户提供实时流以及应用中的 点赞 功能,同时仍然遵循 单向数据流 的原则。

我们还将探索另一个名为 Cloudinary 的基于云的服务。这是一个用于上传和托管图片的云服务。它是一个付费服务,但有一个慷慨的免费层,足以满足我们的需求。我们将在 Express 服务器中创建一个上传服务,用于处理图片上传,我们还将探索画布中的图片处理。

这些是我们将要讨论的主题:

  • 使用网络摄像头 API

  • 捕获 HTML5 画布中的照片输入

  • 通过操作画布像素应用图像过滤器

  • 连接到 Firebase 并将图片上传到云端

  • 实时查看所有提交照片的流

  • 实时评论和点赞

入门

我们将首先使用我们在 第六章 中开发的 Webpack 框架,高级 React。这是我们需要的从 npm 安装的依赖项:

"devDependencies": {
  "autoprefixer": "⁶.2.3",
  "babel-core": "⁶.3.26",
  "babel-loader": "⁶.2.0",
  "babel-plugin-react-transform": "².0.0",
  "babel-preset-es2015": "⁶.3.13",
  "babel-preset-react": "⁶.3.13",
  "babel-tape-runner": "².0.0",
  "classnames": "².2.3",
  "exif-component": "¹.0.1",
  "exif-js": "².1.1",
  "firebase": "².3.2",
  "history": "¹.17.0",
  "imagetocanvas": "¹.1.5",
  "react": "⁰.14.5",
  "react-bootstrap": "⁰.28.2",
  "react-dom": "⁰.14.5",
  "react-router": "¹.0.3",
  "react-transform-catch-errors": "¹.0.1",
  "react-transform-hmr": "¹.0.1",
  "reactfire": "⁰.5.1",
  "redbox-react": "¹.2.0",
  "superagent": "¹.6.1",
  "webpack": "¹.12.9",
  "webpack-dev-middleware": "¹.4.0",
  "webpack-hot-middleware": "².6.0"
},
"dependencies": {
  "body-parser": "¹.14.2",
  "cloudinary": "¹.3.0",
  "cors": "².7.1",
  "envs": "⁰.1.6",
  "express": "⁴.13.3",
  "path": "⁰.12.7"
}

我们将使用与上一章相同的设置,但我们将对 server.js 进行一些小的修改,向 index.html 添加几行,并添加一些内容到我们的 CSS 文件中。

这是我们在原始 Webpack 框架中的树结构:

├── assets
│   ├── app.css
│   ├── favicon.ico
│   └── index.html
├── package.json
├── server.js
├── source
│   └── index.jsx
└── webpack.config.js

确保你的结构与此相同是值得的。

我们需要对 server.js 文件进行一些修改。我们将设置一个上传服务,我们将从我们的应用中访问它,因此它需要支持跨源资源共享 (CORS) 以及除了我们正常的 GET 路由之外的一个 POST 路由。

打开 server.js 并将内容替换为以下内容:

'use strict';
var path = require('path');
var express = require('express');
var webpack = require('webpack');
var config = require('./webpack.config');
var port = process.env.PORT || 8080;
var app = express();
var cors = require('cors');
var compiler = webpack(config);
var cloudinary = require('cloudinary');
var bodyParser = require('body-parser');
app.use( bodyParser.json({limit:'50mb'}) );

我们的应用需要 body-parser 包来访问我们的 POST 路由中的请求数据。我们将向我们的路由发送图片,因此我们还需要确保数据限制高于默认值。请参考以下代码:

app.use(cors());

app.use(require('webpack-dev-middleware')(compiler, {
  noInfo:true,
  publicPath: config.output.publicPath,
  stats: {
    colors: true
  }
}));

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

app.use(require('webpack-hot-middleware')(compiler));
app.use(express.static(path.join(__dirname, "assets")));

cloudinary.config({
  cloud_name: 'YOUR_CLOUD_NAME',
  api_key: 'YOUR_API_KEY',
  api_secret: 'YOUR_API_SECRET'
});

var routes = function (app) {
  app.post('/upload', function(req, res) {
    cloudinary.uploader.upload(req.body.image, function(result) { 
      res.send(JSON.stringify(result));
    });
  });

这个 POST 调用将处理我们应用中的图片上传。它将图片发送到 Cloudinary 并将其存储在我们的图像流中以便稍后检索。您需要在 cloudinary.com/ 创建一个账户,并用我们在用户管理部分看到的真实凭据替换我们刚才看到的 API 凭据。以下是我们所做的主要更改:

  app.get('*', function(req, res) {
    res.sendFile(path.join(__dirname, 'assets','index.html'));
  });
}

这确保了对任何不属于静态 asset 文件夹的文件的请求都将路由到 index.html。这很重要,因为它将允许我们使用历史 API 而不是使用哈希路由来访问动态路由,让我们看一下以下代码片段:

var router = express.Router();
routes(router);
app.use(router);
app.listen(port, 'localhost', function(err) {
  if (err) {
    console.log(err);
    return;
  }

  console.log('Listening at http://localhost:'+port);
});

接下来,打开 assets/index.html 并将内容替换为以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title>Reactagram</title>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, 
    initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css" />
    <link rel="stylesheet" type="text/css"href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
    <link href='https://fonts.googleapis.com/css?family=Bitter' rel='stylesheet' type='text/css'>
    <link rel="stylesheet" href="/app.css">
  </head>
  <body>
    <div id="app"></div>
    <script src="img/bundle.js"></script>
  </body>
</html>

我们将依赖于 Bootstrap 来实现网格布局,所以我们需要添加 Bootstrap CSS 文件。我们还将添加免费的 Bitter 字体家族作为应用的主要字体。

我们将要更改的最后一件事是 app.css。我们将添加一组样式,确保我们构建的应用在 Web、平板电脑和智能手机上都能正常工作。

打开 app.css 并将内容替换为以下样式:

body {
  font-family: 'Bitter', serif;
  padding: 15px;
  margin-top: 60px;
}

这将 Bitter 字体设置为应用的主要字体,并为导航标题添加顶部边距:

body {
  font-family: 'Bitter', serif;
  padding: 15px;
  margin-top: 60px; 
}

.header {
  padding: 10px;
  font-size: 18px;
  margin: 5px; 
}

h1 {
  font-size: 18px; 
}

ul {
  list-style-type: none; 
}

#camera {
  position: absolute;
  opacity: 1; 
}

.hidden {
  display: none; 
}

hidden 类将被应用于所有应该保持隐藏且不可见的元素。现在查看以下内容:

@media all and (max-width: 320px) {
  .canvas {
    padding: 0;
    text-align: center;
    margin: 0 auto;
    display: block;
    z-index: 10;
    position: fixed;
    left: 10px;
    top: 60px; 
  }
}

这是我们唯一的 media 查询。它将确保画布在小智能手机上保持居中和固定位置。当用户上传图片时,imageCanvas 的高度和宽度将被覆盖,因此这些值仅是默认值:

#imageCanvas {
  max-width: 300px;
  height: 300px;
  margin: 0px auto;
  border: 1px solid #333; 
}

以下是将左侧和右侧菜单按钮设置到我们标题中的代码。它们将成为我们的导航元素:

.menuButtonLeft {
  position: fixed;
  padding-right: 15px;
  height: 50px;
  border-right: 2px solid #999;
  padding-top: 16px;
  top: 0;
  left: 30px;
  color: #999;
  z-index: 1; 
}

.menuButtonRight {
  padding-left: 15px;
  height: 50px;
  border-left: 2px solid #999;
  padding-top: 16px;
  top: 0;
  position: fixed;
  right: 30px;
  color: #999;
  z-index: 1; 
}

查看以下代码行:

.nav a:visited, .nav a:link {
  color: #999; }
.nav a:hover, a:focus {
  color: #fff;
  text-decoration: none;
}

.logo {
  padding-top: 16px;
  margin: 0 auto;
  text-align: center;
}

.filterButtonGrayscale {
  position: fixed;
  bottom: 55px;
  left: 40px; 
  z-index:2;
}

.filterButtonThreshold {
  position: fixed;
  bottom: 55px;
  right: 40px; 
  z-index:2;
}

.filterButtonBrightness {
  position: fixed;
  bottom: 10px;
  left: 40px; 
  z-index:2;
}

.filterButtonSave {
  position: fixed;
  bottom: 10px;
  right: 40px; 
  z-index:2;
}

这关于 过滤器 按钮。它们将在捕获图片后、发送到应用之前显示,让我们看一下以下代码片段:

.stream {
  transition: all .5s ease-in;
  -webkit-animation-duration: 1s;
  animation-duration: 1s;
  -webkit-animation-fill-mode: both;
  animation-fill-mode: both;
  height: 480px;
  margin-top: 10px;
  padding: 0; }
  .stream img {
    border: 2px solid #333;
  }

我们将重用前面章节中的 spinner。这将在用户上传图片时显示:

  .spinner {
    width: 40px;
    height: 40px;
    display: none;
    position: relative;
    margin: 100px auto;
  }

  .double-bounce1, .double-bounce2 {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    background-color: #333;
    opacity: 0.6;
    position: absolute;
    top: 0;
    left: 0;
    -webkit-animation: sk-bounce 2.0s infinite ease-in-out;
    animation: sk-bounce 2.0s infinite ease-in-out;
  }

  .double-bounce2 {
    -webkit-animation-delay: -1.0s;
    animation-delay: -1.0s;
  }

  @-webkit-keyframes sk-bounce {
    0%, 100% {
      -webkit-transform: scale(0);
    }
    50% {
      -webkit-transform: scale(1);
    }
  }
  @keyframes sk-bounce {
    0%, 100% {
      transform: scale(0);
      -webkit-transform: scale(0);
    }
    50% {
      transform: scale(1);
      -webkit-transform: scale(1);
    }
  }
}

我们的应用基本设置现在已经完成,你可以通过在终端中运行以下命令来运行它:

node server.js

你应该看到 Webpack 编译你的应用,当准备就绪时,在终端窗口中记录此信息(当然,带有不同的哈希值和毫秒计数):

Listening at http://localhost:8080
webpack built c870b4500e3efe8b5030 in 1462ms

你还需要在 Firebase 和 Cloudinary 上注册账户。这两个服务都适用于开发使用。你可以通过访问 www.firebase.com/ 并注册一个用于开发此应用的数据库名称来在 Firebase 上注册一个账户。

以下截图显示了完成代码后应用在 iPhone 上的外观:

入门

设置路由

让我们从设置应用根目录的路由配置开始这个应用。

打开 index.jsx 并将内容替换为以下代码:

import React from 'react';
import {render} from 'react-dom';
import config from './config';
import RoutesConfig from './routes';

render(
  RoutesConfig(config),
  document.getElementById('app')
);

你会注意到我们引用了两个我们尚未创建的文件,所以让我们继续将它们添加到我们的应用中。

source 文件夹的根目录下创建 config.js 文件,然后添加以下代码:

var rootUrl = "https://YOURAPP.firebaseio.com/";

YOURAPP 替换为你注册的 Firebase 应用的名称,让我们看一下以下代码片段:

var rootDb = "imageStream";
var likesDb = "likes";

module.exports = {
  rootUrl: rootUrl,
  rootDb: rootDb,
  fbImageStream: rootUrl + rootDb,
  fbLikes: rootUrl + likesDb
}

然后,创建 routes.jsx 并添加以下代码:

import React from 'react';
import { Link,
  Router,
  Route,
  NoMatch,
  IndexRoute,
  browserHistory
}
from 'react-router'
import App from './components/app';
import Welcome from './components/welcome';
import Camera from './components/camera';
import Stream from './components/stream';
import Item from './components/item';
import config from './config';
import FBConnect from './fbconnect';

在这里,我们正在导入一些尚未创建的组件。我们将逐个创建这些组件,从FBConnect开始。这个组件很特殊,因为它是一个高阶组件,它将确保被它包裹的组件能够获得正确的状态。它的工作方式与我们在第六章中探讨的 Redux 非常相似,即高级 React。现在添加以下代码:

function Routes(config) {
  return <Router history={ browserHistory }>
    <Route path="/" name="Reactagram" 
      component={ FBConnect( App, config)} >
      <Route name="Stream" path="stream"
        component={ FBConnect( Stream, config) } />
      <Route name="ItemParent" path="item"
        component={ FBConnect( Item, config) } >
        <Route name="Item" path=":key"
         component={ FBConnect( Item, config) } />
      </Route>
      <Route name="Camera" path="camera" 
        component={ FBConnect( Camera, config) } />
      <IndexRoute name="Welcome" 
        component={ FBConnect( Welcome, config) } />
    </Route>
    <Route name="404: No Match for route" path="*" component={FBConnect(App,config)} />
  </Router>
}
export default Routes;

在添加此代码后,Webpack 将抛出许多错误,并在浏览器中显示一个红色的错误屏幕。我们需要在应用再次可用之前添加所有我们将使用的组件,并且随着它们的添加,您将看到错误日志逐渐减少,直到应用可以正确显示为止。

创建一个高阶函数

高阶函数是一个接受一个或多个函数作为参数并返回一个函数作为结果的函数。所有其他函数都是一阶函数。

使用高阶函数是扩展你的组合技能的绝佳方式,也是使复杂应用更容易的简单方法。它与 mixins 的使用相辅相成,mixins 是另一种在组件中提供继承的方式。

我们将创建的是一个带有 mixins 的高阶组件。它将通过我们在config.js中提供的配置连接到 Firebase,并确保我们依赖的有状态数据能够实时同步。这是一个很高的要求,但通过使用 Firebase,我们将卸载提供此功能所需的大部分繁重工作。

如您之前所见,我们将使用此函数来包裹我们的路由组件,并为他们提供以 props 形式的状态。

在你的source文件夹根目录下创建fbconnect.jsx文件,并添加以下代码:

import React, { Component, PropTypes } from 'react';
import ReactFireMixin from 'reactfire';
import Firebase from 'firebase';
import FBFunc from './fbfunc';
import Userinfo from './userinfo';

function FBConnect(Component, config) {
  const FirebaseConnection = React.createClass({
    mixins:[ReactFireMixin, Userinfo],
    getInitialState: function() {
      return {
        data: [],
        imageStream: [],
        fbImageStream: config.fbImageStream
      };
    },
    componentDidMount() {
      const firebaseRef = new Firebase(
      this.state.fbImageStream, 'imageStream');
      this.bindAsArray(firebaseRef.orderByChild("timestamp"),
      "imageStream");

这将获取你的图像流内容并将其存储在this.state.imageStream中。状态将可用于所有包裹组件的this.props.imageStream。我们将设置它,使其按时间戳值排序。请参考以下代码:

    },
    render() {
      return <Component {...this.props}
      {...this.state} {...FBFunc} />;

在这里,我们返回传递给此函数的组件以及从 Firebase 获取的状态和FBFunc中的一组有状态函数,如下所示:

    }
  });
  return FirebaseConnection;
};
export default FBConnect;

注意

使用 Firebase 排序

Firebase 将始终以升序返回数据,这意味着新图片将被插入到底部。如果你想按降序排序,请将bindAsArray函数替换为自定义循环,然后在将数组存储到setState()之前将其反转。

你还需要创建一个文件来保存你将用于向图像流添加内容的函数。在你的项目根目录下创建一个名为fbfunc.js的文件,并输入以下代码:

import Firebase from 'firebase';

const FbFunc = {
  uploadImage(url: string, user: string) {
    let firebaseRef = new Firebase(this.fbImageStream);
    let object = JSON.stringify(
      {
        url:url,
        user:user,
        timestamp: new Date().getTime(),
        likes:0
      }
    );
    firebaseRef.push({
      text: object
    });
  },

此函数将使用图像 URL、用户名、时间戳和零个赞数将新图像存储到 Cloudinary。以下是我们like功能:

  like(key) {
    var onComplete = function(error) {
      if (error) {
        console.log('Update failed');
      }
      else {
        console.log('Update succeeded');
      }
    };
    var firebaseRef = new Firebase(`${this.props.fbImageStream}/${key}/likes`);
    firebaseRef.transaction(function(likes) {
      return likes+1;
    }, onComplete);
  },

如你所见,每次点击“点赞”都会给图片添加一个 +1 点赞数。你可以扩展此功能,以防止当前用户对自己上传的图片进行投票,并防止他们重复投票。现在参考以下代码:

addComment(e,key) {
  const comment = this.refs.comment.getValue();
  var onComplete = function(error) {
    if (error) {
      console.log('Synchronization failed');
    }
    else {
      console.log('Synchronization succeeded');
    }
  };
  let object = JSON.stringify(
    {
      comment:comment,
      user:this.props.username,
      timestamp: new Date().getTime()
    }
  );
  var firebaseRef = new Firebase(this.props.fbImageStream+`/${key}/comments`);
  firebaseRef.push({
    text: object
  }, onComplete);
},

comment 功能将在 item.jsx 中可见,这是一个显示单个照片的页面。此功能将存储新的评论,包括提交者的用户名和时间戳。现在我们继续到两个 helper 函数:

removeItem(key) {
  var firebaseRef = new Firebase(this.props.fbImageStream);
  firebaseRef.child(key).remove();
},
resetDatabase() {
  let stringsRef = new Firebase(this.props.fbImageStream);
  stringsRef.set({});
  }
};
export default FbFunc;

这些函数将允许你删除单个条目或清除整个数据库。后者在调试时特别有用,但如果你将应用程序上线,保留它是非常危险的。

创建随机用户名

为了区分传入的不同图片,你需要给用户一个名字。我们将以非常简单的方式来做这件事,所以请参考 第六章,高级 React,了解如何实现更安全的登录解决方案的详细信息。

我们将简单地从形容词列表中挑选一个单词,从名词列表中挑选另一个单词,然后从这两个单词中组合一个用户名。我们将把名字存储在 localStorage 中,如果找不到现有的名字,我们将生成一个新的名字。

注意

本地存储

所有主流浏览器现在都支持 localStorage,但如果你计划支持旧浏览器,特别是 Internet Explorer,那么考虑 polyfills 可能是明智的。关于 polyfill localStorage 的良好讨论可以在 gist.github.com/juliocesar/926500 找到。

让我们创建我们的 username 函数。创建一个名为 username.js 的文件,并将其放在 tools 文件夹中。添加以下代码:

export function username() {
  const adjs = ["autumn", "hidden", "bitter", "misty", "silent",
    "empty", "dry", "dark", "summer", "icy",
    "delicate", "quiet", "ancient", "purple",
    "lively", "nameless"];
  const nouns = ["breeze", "moon", "rain", "wind", "sea", 
    "morning", "snow", "lake", "sunset", "pine",
    "shadow", "leaf", "dawn", "frog", "smoke",
    "star"];
  const rnd = Math.floor(Math.random() * Math.pow(2, 12));
  return `${adjs[rnd % (adjs.length-1)]}-
    ${nouns[rnd % (nouns.length-1)]}`;
};

为了简洁起见,形容词和名词的数量已经减少,但你可以添加更多单词来为你的用户名增添多样性。

生成的用户名将是以下形式的变体:autumn-breezemisty-dawn,和 empty-smoke

注意

如果你想要更深入地探索名称和句子生成,我强烈建议你查看 www.npmjs.com/package/rantjs

接下来,你需要实现此功能并设置所需用户名的文件。这是 userinfo.js,它在 fbconnect.js 中被引用。将文件添加到你的 root 文件夹,然后添加以下代码:

module.exports = {

  getInitialState() {
    username: ""
  },

  componentDidMount() {
    let username;
    if(localStorage.getItem("username")) {
      username = localStorage.getItem("username");
    }

    if(!username || username === undefined) {
      localStorage.setItem("username",
      require("./tools/username").username());
    }

    this.setState({username: username})
  }

}

此文件是一个 mixin,它将在 fbconnect 中扩展 getInitialStatecomponentDidMount,并添加一个用户名状态变量,如果不存在,它将创建一个用户名并将其存储在 localStorage 中。

创建欢迎屏幕

让我们创建一个应用程序标题和欢迎屏幕。我们将使用两个不同的文件来完成,app.jsxwelcome.jsx,我们将它们放在 components 文件夹中。

添加 components/app.jsx 然后添加以下代码:

import React from 'react';
import { Grid, Col, Row, Nav, Navbar } from 'react-bootstrap';
import { Link } from 'react-router';
import Classnames from 'classnames';

module.exports = React.createClass({
  goBack() {
    return this.props.location.pathname.split("/")[1]
      ==="item" ? "/stream" : "/";
  },

goBack() 函数会根据您的当前位置将您带回到正确的页面。如果您正在查看单个项目,按下返回按钮后,您将被带回到流中。如果您正在流中,您将被带到首页,让我们看一下下面的代码片段:

  render() {
    const BackStyle = Classnames({
      hidden: this.props.location.pathname==="/",
      "menuButtonLeft": true
    });

    const PhotoStyle = Classnames({
      hidden: this.props.location.pathname==="/camera",
      "menuButtonRight": true
    });

这两种样式将防止在不需要显示链接时显示链接。返回按钮仅在您不在首页时可见,如果您在照片页面上,照片按钮将被隐藏。请参考以下代码:

    return <Grid>
  <Navbar
    componentClass="header""
    fixedTop
    inverse>
    <h1
      center
      style={{ color:"#fff" }}
      className="logo">Reactagram
    </h1>
    <Nav
      role="navigation"
      eventKey={ 0 }
      pullRight>
      <Link
        className={ BackStyle }
        to={this.goBack()}>Back</Link>
      <Link
        className={ PhotoStyle }
        to="/camera">Photo</Link>
    </Nav>
  </Navbar>
  { this.props.children }
  </Grid>
  }
});

在本节中,我们添加了一个带有固定导航栏的 Bootstrap 网格。这确保了导航栏始终存在。代码块 { this.props.children } 确保任何 React.js 组件都在网格内渲染。

接下来,创建 components/welcome.jsx 并添加以下代码:

import React from 'react';
import { Row, Col, Button } from 'react-bootstrap';

module.exports = React.createClass({
  contextTypes: {
    router: React.PropTypes.object.isRequired
  },
  historyPush(location) {
    this.context.router.push(location);
  },

我们将使用 react-router 内置的 push 功能将用户过渡到所需的位置。URL 将是 http://localhost:8080/streamhttp://localhost:8080/camera

注意

注意,这些路由是非哈希的。

让我们看一下下面的代码片段:

  renderResetButton() {
    return <Button bsStyle="danger"
    onClick={this.props.resetDatabase.bind(null, this)}>
    Reset database!
    </Button>
  },
  renderPictureButton() {
    return <Button bsStyle="default"
      onClick={this.historyPush.bind(null, '/camera')}>
      Take a picture
    </Button>
  },

我们将路由参数绑定到 historyPush 函数,作为方便用户点击进行过渡的一种方式。第一个参数是上下文,但由于我们不需要它,我们将其赋值为 null。第二个是我们希望用户过渡到的路由。让我们看一下以下代码片段:

  renderStreamButton() {
    return <Button bsStyle="default"
      onClick={ this.historyPush.bind(null, '/stream') }>
      Stream
    </Button>
  },
  render() {
    return <Row>
      <Col md={12}>
        <h1>Welcome { this.props.username }</h1>
        <p>
          Reactagram is social picture app. Take snapshots of
          yourself and share with your friends.
        </p>
        <p>
          { this.renderPictureButton() }
        </p>
        <p>
          { this.renderStreamButton() }
        </p>

        <p>
          <em>PS! The username has been automatically
          generated for you.</em>
        </p>

      </Col>
      <Col md={ 12 }>
        <h3>Reset database</h3>
        <p>
          Click here to reset your database.
          Note: This will completely
          Clear all of your uploaded pictures.
          There's no way to undo this.
        </p>
        <p>
          { this.renderResetButton() }
        </p>
      </Col>
    </Row>
  }
})

添加了前面的代码后,应用程序在浏览器中的外观将是这样。请注意,此时链接将无法工作,因为我们还没有创建组件。我们很快就会做到这一点:

创建欢迎屏幕

拍照

我们将使用相机 API 为我们的图像应用程序拍照。通过此接口,可以使用原生相机设备拍照,也可以选择图片通过网页上传。

通过添加一个具有 type="file"accept 属性的输入元素来设置 API,以通知我们的组件它接受图片。

ReactJS JSX 看起来像这样:

<Input type="file" label="Camera" onChange={this.takePhoto}
  help="Click to snap a photo" accept="image/*" />

当用户激活元素时,他们会看到一个选项,可以选择文件或使用内置相机(如果可用)拍照。在图片发送到 <input type="file"> 元素并触发其 onchange 事件之前,用户必须接受该图片。

一旦您有了图片的引用,您就可以将其渲染到图像元素或画布元素中。我们将后者作为渲染到画布,因为它为图像处理打开了大量的可能性。

创建一个名为 camera.jsx 的新文件,并将其放入 components 文件夹中。将以下代码添加到其中:

import React from 'react';
import { Link } from 'react-router';
import classNames from 'classnames';
import { Input, Button } from 'react-bootstrap';
//import Filters from '../tools/filters';

在我们添加此函数的代码之前,先将其注释掉:

import request from 'superagent';
import ImageToCanvas from 'imagetocanvas';

ImageToCanvas模块包含大量最初为这一章节编写的代码,但由于它包含大量针对相机和画布的特定代码,所以有点过于狭窄,不适合包含。如果你想深入了解画布代码,请查看 GitHub 仓库中的代码:

module.exports = React.createClass({

  getInitialState() {
    return {
      imageLoaded: false
    };
  },

我们将使用这个状态变量在显示输入字段或捕获的图片之间切换。当捕获到图片时,这个状态被设置为true。考虑以下代码:

  componentDidMount() {
    this.refs.imageCanvas.style.display="none";
    this.refs.spinner.style.display="none";
  },

如代码所示,我们将隐藏画布,直到我们有内容可以显示。旋转器应该在用户上传图片时才可见。参考以下代码中的辅助函数:

  toImg(imageData) {
    var imgElement = document.createElement('img');
    imgElement.src = imageData;
    return imgElement;
  },

  toPng(canvas) {
    var img = document.createElement('img');
    img.src = canvas.toDataURL('image/png');
    return img;
  },

这些函数在将最终图像渲染给用户时将非常有用。现在看看这个:

  putImage(img, orientation) {
    var canvas = this.refs.imageCanvas;
    var ctx = canvas.getContext("2d");
    let w = img.width;
    let h = img.height;
    const scaleH = h / 400;
    const scaleW = w / 300;
    let tempCanvas = document.createElement('canvas');
    let tempCtx = tempCanvas.getContext('2d');
    canvas.width = w/scaleW < 300 ? w/scaleW : 300;
    canvas.height = h/scaleH < 400 ? h/scaleH : 400;
    tempCanvas.width = canvas.width;
    tempCanvas.height = canvas.height;
    tempCtx.drawImage(img, 0, 0, w/scaleW, h/scaleH); 

    ImageToCanvas.drawCanvas(canvas, this.toPng(tempCanvas), orientation, scaleW, scaleH);

    this.refs.imageCanvas.style.display="block";
    this.refs.imageCanvas.style.width= w/scaleW + "px";
    this.refs.imageCanvas.style.height= h/scaleH + "px";
  },

这个函数负责处理所有必要的画布处理逻辑,以便以正确的比例显示图像。我们的默认比例是 4:3(竖幅图片),并将图像的高度和宽度缩放到大约 400 像素和 300 像素。减小图像大小会导致质量下降,但会使图像处理更快,并减小文件大小,从而提高上传速度和改善用户体验。

这确实意味着方形图片或横幅模式的照片将显得挤压。因此,这个函数可以被扩展以查找水平放置的方形或矩形照片,以便它们可以正确缩放,让我们看一下以下代码片段:

  takePhoto(event) {
    let camera = this.refs.camera,
      files = event.target.files,
      file, w, h, mpImg, orientation;
    let canvas = this.refs.imageCanvas;
    if (files && files.length > 0) {
      file = files[0];
      var fileReader = new FileReader();
      var putImage = this.putImage;
      fileReader.onload = (event)=> {
        var img = new Image();
        img.src=event.target.result;
        try {
          ImageToCanvas.getExifOrientation(
            ImageToCanvas.toBlob(img.src),
          (orientation)=> {
            putImage(img, orientation);
          });

原生设备上的相机将以不同的方向拍照。除非我们调整它,否则我们最终会得到左转、右转或颠倒的图片,让我们看一下以下代码片段:

        }
        catch (e) {
          this.putImage(img, 1);

如果我们无法获取exif信息,我们将默认将方向设置为1,这意味着不需要转换,让我们看一下以下代码片段:

        }
      }
      fileReader.readAsDataURL(file);
      this.setState({imageLoaded:true});
    }
  },

  applyGrayscale() {
    let canvas = this.refs.imageCanvas;
    let ctx=canvas.getContext("2d");
    let pixels = Filters.grayscale( 
      ctx.getImageData(0,0,canvas.width,canvas.height), {});
    ctx.putImageData(pixels, 0, 0);
  },

我们将设置三个不同的过滤器:grayscalethresholdbrightness。当我们添加filters.js时,我们将更详细地介绍过滤器:

  applyThreshold(threshold) {
    let canvas = this.refs.imageCanvas;
    let ctx=canvas.getContext("2d");
    let pixels = Filters.threshold(
      ctx.getImageData(0,0,canvas.width,canvas.height), threshold);
    ctx.putImageData(pixels, 0, 0);
  },

  applyBrightness(adjustment) {
    let canvas = this.refs.imageCanvas;
    let ctx=canvas.getContext("2d");
    let pixels = Filters.brightness(
      ctx.getImageData(0,0,canvas.width,canvas.height), adjustment);
    ctx.putImageData(pixels, 0, 0);
  },

  saveImage() {
    let canvas = this.refs.imageCanvas;
    document.body.style.opacity=0.4;
    this.refs.spinner.style.display="block";
    this.refs.imageCanvas.style.display="none";

当用户保存图片时,我们将降低整个页面的不透明度,并显示加载旋转器,如前一段代码的最后部分所示,让我们看一下以下代码片段:

    var dataURL = canvas.toDataURL();

    new Promise((resolve, reject)=> {
      request
      .post('/upload')
      .send({ image: dataURL, username: this.props.username })
      .set('Accept', 'application/json')
      .end((err, res)=> {
        console.log(err);
        if(err) {
          reject(err)
      }
      if(res.err) {
        reject(res.err);
      }
      resolve(res);
    });
  }).then((res)=> {
  const result = JSON.parse(res.text);
  this.props.uploadImage(result.secure_url,this.props.username);
  this.props.history.pushState(null,'stream');
  document.body.style.opacity=1.0;
});

当图片上传到Cloudinary后,我们将使用fbfunc.js中的uploadImage函数将结果存储在 Firebase 中。请考虑以下代码:

  },

  render() {
    const inputClass= classNames({
      hidden: this.state.imageLoaded
    });
    const grayScaleButton= classNames({
      hidden: !this.state.imageLoaded,
      "filterButtonGrayscale": true
    });
    const thresholdButton= classNames({
      hidden: !this.state.imageLoaded,
      "filterButtonThreshold": true
    });
    const brightnessButton= classNames({
      hidden: !this.state.imageLoaded,
      "filterButtonBrightness": true
    });
    const saveButton= classNames({
      hidden: !this.state.imageLoaded,
      "filterButtonSave": true
    });

在这里,classNames函数提供了一个简单的接口来切换我们 HTML 节点上的类,让我们看一下以下代码片段:

  return <div>
    <Button className={grayScaleButton} onClick={this.applyGrayscale}>Grayscale</Button>

    <Button className={thresholdButton} 
      onClick={this.applyThreshold.bind(null,128)}>Threshold
    </Button>

    <Button className={brightnessButton}
      onClick={this.applyBrightness.bind(null,40)}>Brighter
    </Button>

    <Button className={saveButton} bsStyle="success" 
        onClick={this.saveImage}>Save Image</Button>
    <div className={inputClass}>

    <Input type="file" label="Camera"  onChange={this.takePhoto}
      help="Click to snap a photo or select an image from your 
      photo roll" ref="camera" accept="image/*" />
  </div>

  <div className="spinner" ref="spinner">
    <div className="double-bounce1"></div>
    <div className="double-bounce2"></div>
  </div>

  <div className="canvas">

    <canvas ref="imageCanvas" id="imageCanvas">
      Your browser does not support the HTML5 canvas tag.
    </canvas>
  </div>

  </div>
  }
});

现在,你应该能够点击相机按钮,使用你的相机手机拍照,或者如果你在台式电脑上工作,从你的硬盘上选择一张图片。以下截图显示了使用文件浏览器和相机按钮选择的桌面图片:

拍照

滤镜现在还不能工作,但我们将要添加它们。一旦完成,请从 camera.jsx 中的 import 函数中移除注释。

添加滤镜

我们已经设置了一些用于在从图像上传器捕获图像后操作图像的过滤器按钮,但我们还没有设置实际的过滤器函数。

你通过读取画布像素,修改它们,然后将它们写回画布来应用滤镜。

我们首先获取图像像素。这是你这样做的方式:

let canvas = this.refs.imageCanvas;
let ctx= canvas.getContext("2d");
let pixels = ctx.getImageData(0,0,canvas.width,canvas.height)

camera.jsx 中,我们将 getImageData 的结果作为参数传递给 filter 函数,如下所示:

let pixels = Filters.grayscale(
ctx.getImageData(0,0,canvas.width,canvas.height), {});

现在你有了像素,你可以遍历它们并应用你的修改。

让我们来看看完整的灰度滤镜。添加一个名为 filters.js 的文件,并将其放入 tools 文件夹中。将以下代码添加到其中:

let Filters = {};

Filters.grayscale = function(pixels, args) {
  var data = pixels.data;
  for (let i=0; i < data.length; i+=4) {
    let red = data[i];
    let green = data[i+1];
    let blue = data[i+2];
    let variance = 0.2126*red + 0.7152*green + 0.0722*blue;

我们分别获取 redgreenblue 的值,然后应用 RGB 到 Luma 转换公式,这是一个将淡化颜色信息并产生灰度图像的权重集:

    data[i] = data[i+1] = data[i+2] = variance

我们然后将原始颜色值替换为新的单色颜色值,让我们看看以下代码片段:

  }
  return pixels;
};

Filters.brightness = function(pixels, adjustment) {
  var data = pixels.data;
  for (let i=0; i<data.length; i+=4) {
    data[i] += adjustment;
    data[i+1] += adjustment;
    data[i+2] += adjustment;

此滤镜通过简单地增加 RGB 值使像素更亮。这类似于将 CSS 中字体颜色设置为 #eeeeee (R: 238 G: 238 B: 238)#999 (R: 153 G: 153 B: 153)。现在我们转到阈值:

  }
  return pixels;
};

Filters.threshold = function(pixels, threshold) {
  var data = pixels.data;
  for (let i=0; i<data.length; i+=4) {
    let red = data[i];
    let green = data[i+1];
    let blue = data[i+2];
    let variance = (0.2126*red + 0.7152*green + 0.0722*blue >= threshold) ? 255 : 0;

如你所见,阈值是通过比较像素的灰度值与阈值值来应用的。一旦完成,将颜色设置为黑色或白色,让我们看看以下代码片段:

    data[i] = data[i+1] = data[i+2] = variance
  }
  return pixels;
};

module.exports = Filters;

这是一个非常基本的滤镜集,你可以通过调整值轻松创建更多。你还可以查看 github.com/kig/canvasfilters 以获取要添加的滤镜集,包括模糊、索贝尔、混合、亮度和反转。

以下截图显示了应用了亮度和阈值的图片:

添加滤镜

添加流

现在是添加 stream 功能的时候了。这非常简单,因为数据流已经通过 fbconnect.js 可用,所以我们只需映射流数据并渲染 HTML。

在你的 components 文件夹中创建一个名为 stream.jsx 的文件,并添加以下代码:

import React from 'react';
import { Grid,Row, Col, Button } from 'react-bootstrap';
import { Link } from 'react-router';

module.exports = React.createClass({
  renderStream(item, index, image, data){
    return (
      <Col
        className="stream"
        sm={ 12 }
        md={ 6 }
        lg={ 4 }
        key={ index } >
          <Link to={`/item/${item['.key']}`}>
          <img style={{ margin:'0 auto',display:'block' }}
            width="300"
            height="400"
            src={ image } />
        </Link>

        <strong style={{ display:'block', fontWeight:600,
         textAlign:'center' }}>
           { data.user }
        </strong>

        <strong style={{ display:'block', fontWeight:600,
          textAlign:'center' }}>
          Likes: { item.likes || 0 }
        </strong>

        <div style={{ padding:0,display:'block', fontWeight:600,
          textAlign:'center' }}>

          <Button bsStyle="success"
            onClick={ this.props.like.bind(this,item['.key']) }>
            Like
          </Button>
        </div>

用户可以点击“喜欢”多少次都可以,计数器每次都会更新。喜欢计数器是基于事务的,所以如果有两个或更多用户同时点击“喜欢”按钮,操作将排队直到所有“喜欢”都被计数,让我们看看以下代码片段:

      </Col>
    );
  },

  render() {
    let stream = this.props.imageStream.map((item, index) => {
      const data = JSON.parse(item.text);
      let image;
      try {
        image = 
        data.url.replace("upload/","upload/c_crop,g_center,h_300/");
      }
      catch(e) {
        console.log(e);
      }

try…catch 块将防止出现空白部分(或应用抛出错误),如果用户无意中上传了一个损坏的图片(或由于某些错误,图片上传失败)。如果捕获到错误,这将记录到控制台,并且图片将不会显示。

使用像 Cloudinary 这样的服务的好处之一是,您可以请求您图像文件的不同版本,并且无需在我们的端进行任何工作即可将其交付。

在这里,我们请求一个高度为 300、以中心为权重的裁剪图像。这确保了我们返回此页面的图像在高度上是一致的,尽管宽度可能变化几像素。

Cloudinary 提供了丰富的选项,您实际上可以用它来过滤图像而不是在 JavaScript 中进行。您可以修改应用,以便每当用户捕获图像时,您可以在进一步处理之前将其发送到 Cloudinary。所有过滤器都可以通过向 Cloudinary 提供的图像 URL 添加过滤器来应用,让我们看看以下代码片段:

      return image ?
        this.renderStream(item, index, image, data) : null;

    });
    return <Row>
      {stream}
    </Row>
  }
});

如果添加了图像,或者点赞数已更新,更改将立即在流中可见。尝试同时在一个设备上打开应用和一个浏览器窗口,或者两个浏览器窗口,您会注意到所做的任何更改都将实时同步。

创建项目页面并添加评论

如果您点击流中的任何图片,您将被带到项目页面。我们不需要为此设置新的查询,因为我们已经拥有了显示所需的所有内容。我们将从路由中获取项目键,并应用一个过滤器到图像流中,最终我们将得到一个单一的项目。

在以下屏幕截图 中,请注意已添加评论部分,并且有两个随机用户添加了一些评论:

创建项目页面并添加评论

components 文件夹中创建一个名为 item.jsx 的新文件,并添加以下代码:

import React from 'react';
import { Grid,Row, Col, Button, Input } from 'react-bootstrap';
import { Link } from 'react-router';
import { pad } from '../tools/pad';

module.exports = React.createClass({
  renderStream(item, index, image, data) {
    return (
      <Col className="stream" sm={12} md={6} lg={4} key={ index } >

        <img style={{margin:'0 auto',display:'block'}}
          width="300" height="400" src={ image } />

        <strong style={{display:'block', fontWeight:600, 
          textAlign:'center'}}>{data.user}</strong>

        <strong style={{display:'block', fontWeight:600,
          textAlign:'center'}}>Likes: {item.likes||0}</strong>

        <div style={{padding:0,display:'block', fontWeight:600, 
          textAlign:'center'}}>

          <Button bsStyle="success"
            onClick={this.props.like.bind(this,item['.key'])}>
            Like</Button>
        </div>

        {this.renderComments(item.comments)}
        {this.renderCommentField(item['.key'])}

      </Col>
    );
  },

renderStream() 函数几乎与我们为 stream.jsx 创建的函数相同,除了我们在这里移除了链接并添加了显示和添加评论的方式。请参考以下代码:

  renderComments(comments) {
    if(!comments) return;

    let data,text, commentStream=[];
    const keys = Object.keys(comments);
    keys.forEach((key)=>{
      data = comments[key];
      text = JSON.parse(data.text);
      commentStream.push(text);
    })

    return <Col md={12}><h4>Comments</h4>
      {commentStream.map((item,idx)=>{
        const date = new Intl.DateTimeFormat().format(item.timestamp)
        const utcdate = new Intl.DateTimeFormat('en-US').format(date);
        const utcdate = new Intl.DateTimeFormat('en-US').format(date);
      return <div
        key={ ´comment${idx}` }
        style={{ paddingTop:15 }}>
          { utcdate } <br/> { item.comment }
          - <small>{ item.user }</small>
      </div>
    })}</Col>
  },

首先,我们使用 Object.keys() 获取评论标识符,它返回一个键的数组。然后,我们遍历这个数组以找到并渲染每个单独的评论到 HTML 中。

我们还获取时间戳并将其转换为可读日期,使用的是国际日期格式化器。此外,在这个例子中,我们使用了 en-US 区域设置,但您可以轻松地将其与任何区域设置交换。请查看以下代码:

  renderCommentField(key) {
    return <Col md={12}>
      <hr/>
      <h4>Add your own comment</h4>
      <Input type="textarea" ref="comment"></Input>
      <Button bsStyle="info"
        onClick={this.props.addComment.bind(this,
        this.refs.comment, key)} >Comment</Button>
    </Col>
  },

在这里,我们使用 onclick 处理器将输入字段和提交按钮渲染到 fbfunc.js 中的 addComment() 函数。最后,我们返回到 render() 函数:

  render() {
    let { key } = this.props.params;
    let stream = this.props.imageStream
    .filter((item)=>{return item['.key']==key})
    .map((item, index) => {
      const data = JSON.parse(item.text);
      let image;
      try {
        image = data.url.replace("upload/","upload/c_crop,g_center,h_300/");
      } catch(e){
        console.log(e);
      }
      return image ?
      this.renderStream(item, index, image, data) : null;

    });
    return <Row>
      {stream}
      </Row>
  }
});

如上图所示,我们从路由参数中获取键,并应用一个过滤器到图像流中,这样我们就只剩下一个包含我们想要从流数据中获取的单个项目的数组。

然后,我们对数组应用一个 map 函数,获取图像,并调用 renderStream() 函数。

您需要添加我们在 item.jsx 顶部导入的 padding 文件,因此请在 tools 文件夹中创建一个名为 pad.js 的文件并添加以下代码:

export const pad = (p = '00', s = '') => {
  return p.toString().slice(s.toString().length)+s;
}

它会将 1 转换为 01,依此类推,但不会对 10、11 或 12 做任何处理。所以当你想要给字符串添加左填充时,这是一个安全的选择。

总结

你的社交照片分享应用现在可以投入使用。它现在应该能够在桌面浏览器和原生智能手机和平板电脑上完全编译并正常运行,没有任何问题。

在原生设备上,处理图像和画布可能会有些棘手。照片的文件大小常常成为一个问题,因为许多智能手机的内存非常有限,所以你可能会经常遇到渲染画布图像时的问题。

这是我们在本应用中使用缩小图像的一个原因。另一个原因当然是使将照片传输到云端时更快。这两个问题都是非常实际的,但可以或多或少地被归类为边缘情况,所以如果你们决定进一步开发这个应用,我就把这个留给你了。

在下面的屏幕截图中,你可以看到应用在 iPad 上的部署情况:

总结

这是应用的最终文件结构:

├── assets
│   ├── app.css
│   ├── favicon.ico
│   └── index.html
├── package.json
├── server.js
├── source
│   ├── components
│   │   ├── app.jsx
│   │   ├── camera.jsx
│   │   ├── item.jsx
│   │   ├── stream.jsx
│   │   └── welcome.jsx
│   ├── config.js
│   ├── fbconnect.js
│   ├── fbfunc.js
│   ├── index.jsx
│   ├── routes.jsx
│   ├── tools
│   │   ├── filters.js
│   │   ├── pad.js
│   │   └── username.js
│   └── userinfo.js
└── webpack.config.js

对于一个已经相当强大的应用来说,这是一个非常简洁的文件结构。你可以争论说config文件和 Firebase 文件可以放在自己的文件夹中,而且我不会反对。

最后,你组织文件的方式通常取决于个人偏好。有些人可能喜欢将所有 JavaScript 文件放在一个文件夹中,而其他人则更喜欢按功能排序。

注意

完成的项目可以在reactjsblueprints-chapter7.herokuapp.com在线查看。

摘要

在本章中,你学习了如何使用 HTML5 canvas 通过相机/文件读取 API 来使用相机,以及如何通过修改像素来操作图像。你连接到了 Firebase 和 Cloudinary,这两个都是流行的基于云的工具,可以帮助你作为开发者专注于你的应用而不是你的基础设施。

你还体验了通过使用 Firebase 这样的工具,你可以完全避免使用 Flux。这不是一个常见的架构,但值得知道至少有这条路可行。

最后,你制作了一个可以轻松扩展并打上你品牌标志的实时社交照片应用。

在下一章中,我们将探讨如何使用 ReactJS 开发同构应用。同构应用意味着在服务器上预先渲染的应用,所以我们将探讨向那些在其浏览器中未启用 JavaScript 的用户提供 ReactJS 应用的技巧。

第八章。将你的应用部署到云上

在本章中,我们将为我们的应用创建一个生产级管道。这包括将你的配置文件分为开发和生产版本,以及创建一个为 Node.js 服务器准备好的实例。首先,我们将查看如何设置来自第一章的 Browserify 脚手架的生产级部署,即深入 React,然后我们将查看如何使用 Webpack 进行相同的操作。

使用云服务器是部署代码最经济的方式。在云服务成为可行的选择之前,你通常会不得不将代码部署到位于单个数据中心中的物理服务器。如果你要将代码部署到多个数据中心,你需要购买或租赁更多的物理服务器,这通常需要相当大的成本。

云服务改变了这一点,因为现在你可以将你的代码部署到全球拥有数据中心的所有云服务提供商。在美国、欧洲和亚洲部署你的应用的成本通常相同,而且相对便宜。

这些是我们将详细讨论的主题:

  • 选择云服务提供商

  • 为云服务准备 Browserify 应用

  • 为云服务准备 Webpack 应用

选择云服务提供商

可供选择的大量云服务提供商中,最受欢迎和成熟的提供商包括HerokuMicrosoft AzureAmazonGoogle App EngineDigital Ocean。它们各自都有其优点和缺点,因此在决定选择哪一个之前,调查每一个都是值得的。

在这本书中,我们一直使用 Heroku 来部署我们的应用,我们将设置我们的部署以针对这个平台。让我们简要地看看使用 Heroku 的优点和缺点。

优点如下:

  • 易于使用。在初始注册后,你通常只需要发出一个单一的 Git push 来部署你的代码。

  • 当你的应用流量增加时,易于扩展。

  • 为第三方应用和云服务提供出色的插件支持。

  • 提供免费的基本层。

  • 没有基础设施管理。

现在,缺点如下:

  • 可能会变得昂贵。Heroku 提供了慷慨的免费层,但价格阶梯的第一步相当陡峭。

  • 供应商锁定问题;从 Heroku 迁移到另一个云服务提供商需要大量工作。

  • 基本层曾满足了一段时间,但最近,Heroku 增加了一项政策,即免费实例每 24 小时必须保持 6 小时不活跃。

  • 环境会不定期被清除。你无法登录实例并对环境进行本地更改,因为下一次实例刷新时它们将消失。

由于 Heroku 相对容易上手,我们将使用 Heroku 进行部署。

首先在 signup.heroku.com/ 注册一个免费账户。完成此操作后,从 toolbelt.heroku.com/ 下载 Heroku 工具包。你还需要上传你的 SSH 密钥。如果你需要生成 SSH 密钥的帮助,请访问 devcenter.heroku.com/articles/keys

设置完成后,你可以在终端中输入以下命令来创建 Heroku 应用程序:

heroku create <name>

你可以省略名称,在这种情况下,Heroku 将为你提供一个随机的名称。请注意,Heroku 需要 Git。如果你已经有了 Git 仓库,Heroku 将自动将配置参数添加到你的 .git/config 文件中。如果没有,你稍后必须手动完成。参数看起来像这样:

[remote "heroku"]
  url = https://git.heroku.com/*<name>*.git
  fetch = +refs/heads/*:refs/remotes/heroku/*

你可以在 .git 文件夹(注意点号)内找到配置文件。文件名为 config,所以完整路径是 .git/config

要部署你的应用程序,将文件添加到你的仓库并提交你的更改。然后,执行以下命令:

git push heroku master

然后,你的应用程序将基于主分支进行部署。你可以通过输入 git push heroku yourbranch:master 来部署其他分支。

使用 npm 设置云部署

如果我们立即尝试发布我们的脚手架,我们可能会遇到错误,因为我们没有告诉 Heroku 如何为我们提供应用程序。Heroku 将简单地尝试使用 npm start 运行应用程序。

npm 包是 Node.js 的基础。我们在前面的章节中简要介绍了它,但鉴于我们现在将严重依赖它,现在是时候更仔细地看看它能为你们做什么了。

你可能听说过或甚至使用过像 GruntGulpBroccoli 这样的任务运行器。它们擅长自动化任务,这样你就可以专注于编写代码,而不是执行重复性任务,例如压缩和打包你的代码、复制和连接样式表等。

然而,对于大多数任务,你最好让 npm 为你完成工作。使用 npm 脚本,你将拥有自动化常见任务所需的所有功能,而且开销和维护成本更低。

npm 包包含一些内置命令,其中之一是 npm run-script(简称 npm run)。此命令从 package.json 中提取脚本对象。传递给 npm run 的第一个参数指的是脚本对象中的一个属性。对于你自己创建的任何属性,你需要使用 npm run 来运行它们。一些属性名称已被保留,例如 startstoprestartinstallpublishtest 等。它们可以通过简单地执行 npm start 等命令来调用。

注意

有一点需要注意,如果定义了 prefoopostfoo,则 npm run foo 也会运行它们。你可以通过执行 npm run prefoopostfoo 来单独运行每个阶段。

执行 npm run 命令以查看可用的脚本;你将看到以下输出:

Lifecycle scripts included in webpack-scaffold:
 test
 echo "Error: no test specified" && exit 1
 start
 node server.js

这很有趣。我们还没有创建一个启动脚本,但 npm run 告诉我们 npm start 将运行 node server.js。这是 node 的另一个默认设置。如果你没有指定启动脚本,并且根目录中有一个 server.js 文件,那么它将被执行。

Heroku 仍然不会运行脚手架,因为 express 服务器配置为使用 Webpack 和热重载启动开发会话。你需要创建一个生产服务器,除了你的开发服务器之外。

你可以通过两种方式之一来处理这个问题:

  • 一个选择是在你的服务器代码中引入 environment 标志,例如这样:

    if(process.env.NODE_ENV !== "development"){
      // production server code
    }
    
  • 另一个选择是创建一个独立的 server 生产文件

两种方法都很好,但使用单独的文件可能更干净,所以我们选择这种方法。

准备你的 Browserify 应用以进行云部署

在本节中,我们将使用我们在 第二章 中开发的商店应用,创建一个网络商店。该应用使用 Browserify 打包代码并使用 node 运行开发服务器。我们将继续在生产中使用 node,但我们需要设置一个特定的 server 文件,以便制作一个生产就绪的应用。

提醒一下,这是我们开始之前商店应用的样子:

├── package.json
├── public
│   ├── app.css
│   ├── bundle.js
│   ├── heroku.js
│   ├── index.html
│   └── products.json
├── server.js
└── source
    ├── actions
    │   ├── cart.js
    │   ├── customer.js
    │   └── products.js
    ├── app.jsx
    ├── components
    │   ├── customerdata.jsx
    │   ├── footer.jsx
    │   └── menu.jsx
    ├── layout.jsx
    ├── pages
    │   ├── checkout.jsx
    │   ├── company.jsx
    │   ├── home.jsx
    │   ├── item.jsx
    │   ├── products.jsx
    │   └── receipt.jsx
    ├── routes.jsx
    └── stores
        ├── cart.js
        ├── customer.js
        └── products.js

我们将采取以下步骤使其准备好云部署:

  • 创建生产服务器文件

  • 安装生产依赖项

  • 修改 package.json

  • 将我们的代码库转换为 EcmaScript 5

实际的过程

创建一个名为 server.prod.js 的新文件,并将其放在项目的根目录下。将以下代码添加到其中:

var express = require("express");
var app = express();
var port = process.env.PORT || 8080;
var host = process.env.HOST || '0.0.0.0';

我们正在定义一个 express 服务器并设置主机和 port 变量。默认情况下,在 0.0.0.0 上为端口 8080。当在本地机器上运行时,这个主机地址在功能上与 localhost 相同,但在服务器上运行时可能会有所不同。如果服务器主机有多个 IP 地址,将 0.0.0.0 作为主机将匹配任何请求。使用如 localhost 这样的参数可能会导致服务器无法绑定你的应用并失败启动:

var path = require("path");
var compression = require("compression");
app.use(compression());

由于我们将向公众提供文件,因此在提供服务之前用 GZIP 压缩它们是值得的。对于文本和脚本文件,节省的量可能非常显著,在许多情况下可达 80-90%。对于流量较低的网站,这种实现已经足够好。对于流量较高的网站,在反向代理级别实现压缩是最佳方式,例如,通过使用 nginx。我们将路由所有请求到我们的 public 文件夹和所需的文件名:

app.get("*", function (req, res) {
  var file = path.join(__dirname, "public", req.path);
  res.sendFile(file);
});

最后,服务器将以调试信息启动,告诉我们已部署应用的地址:

app.listen(port, host, function (err) {
  console.log('Server started on http://'+host+':'+port)
});

下一步我们需要做的是创建一个build脚本来打包我们的 JavaScript 代码。在运行开发服务器时,代码会自动打包。这个包通常相当大。例如,商店应用的开发包是 1.4 MB。即使启用了压缩,这个文件也可能太大,不适合向用户展示。当部署到生产环境时,我们需要创建一个更小的包,以便您的应用可以更快地下载并准备好使用。幸运的是,这相当简单。

我们将使用 Browserify 和 UglifyJS 的 CLI 版本组合。后者是一个压缩工具,它会删除换行符、缩短变量名,并从我们的包中删除未使用的代码。我们将首先使用 Browserify 打包源文件,然后使用管道运算符(|)将输出发送到 UglifyJS。此操作的输出结果然后通过大于运算符(>)发送到一个bundle文件。

序列的第一部分如下:

./node_modules/.bin/browserify --extension=.jsx source/app.jsx -t [ babelify ]

当您运行此命令时,整个包将以字符串形式输出。您可以可选地指定-o bundle.js以将结果保存到包文件中。我们不希望这样做,因为我们不需要临时包。

序列的第二部分如下:

./node_modules/.bin/uglifyjs -p 5 -c drop_console=true -m --max-line-len --inline-script

我们已经指定了一些参数,让我们看看它们的作用。

-p参数跳过源文件名中出现的原始文件名前缀。这里的节省非常小,但仍然值得保留。参数后面的数字是删除的相对路径数。

-c选项代表压缩器。如果不指定任何压缩器选项,将使用默认的压缩选项。这可以节省很多字节。

接下来是drop_console=true。这告诉 UglifyJS 删除任何控制台日志。如果您在调试应用时使用了这种方法,并且忘记从代码中删除它,这将很有用。

下一个是-m,代表混淆。此选项更改并缩短了您的变量和函数名,并且是一个重要的字节节省因素。

最后两个参数不会节省任何字节,但它们仍然很有用。--max-line-len参数会在行长度超过给定值时(默认为 32,000 个字符)拆分丑化后的代码。当支持无法处理非常长行的旧浏览器时,这很有用。--inline-script参数会转义字符串中</script出现的斜杠。

仅运行此命令本身不会生成压缩包,因为我们没有指定输入。如果您将包存储在临时文件中,可以使用小于运算符和文件名(如:< bundle.js)将内容发送到前面的命令。

最后,我们将使用大于运算符将结果发送到我们想要的输出位置。

完整的命令序列如下:

NODE_ENV=production browserify --extension=.jsx source/app.jsx -t [ babelify ] | ./node_modules/.bin/uglifyjs  -p 5 -c drop_console=true -m --max-line-len --inline-script > public/bundle.js

运行第一部分的结果是一个大约 1.4 MB 的包大小。通过 UglifyJS 处理后,包大小约为 548 KB。如果你去掉选项并使用纯 UglifyJS,最终的包大小大约为 871 KB。

在打包和压缩之后,我们现在可以准备好将我们的应用到云端部署。由于我们使用了压缩,最终的包大小大约为 130 KB。与原始文件大小 1.4 MB 相比,这是一个巨大的胜利。

在我们部署代码之前,我们需要告诉 Heroku 如何启动我们的应用。我们将通过添加一个名为 Procfile 的单个文件来完成这项工作。这是一个特殊的文件,如果存在,Heroku 将会读取并执行它。如果不存在,Heroku 将尝试执行 npm start;如果失败,则尝试运行 node server.js

添加包含以下内容的 Procfile 文件:

web: node server.prod.js

完成这些后,提交你的代码并通过执行此命令将代码推送到 Heroku:

git push heroku master

最终结果应该与本地应用看起来完全相同,但现在你是在云端运行它。示例应用可在 reactjsblueprints-webshop.herokuapp.com/ 找到。以下截图显示了上述链接的网页:

实际过程

记住生成压缩后的 Browserify 包的整个命令序列非常困难。我们将将其添加到 package.json 中,以便我们可以轻松执行。

打开 package.json 并将 scripts 部分的内文替换为以下代码:

"scripts": {
  "bundle": "browserify --extension=.jsx source/app.jsx -t [ babelify ] | ./node_modules/.bin/uglifyjs  -p 5 -c drop_console=true -m --max-line-len --inline-script > public/bundle.js",
  "start": "node server.js"
},

现在你可以使用 npm run bundle 来运行打包操作。

将 Webpack 应用部署到云端

在本节中,我们将使用我们在 第六章 中开发的 Webpack 框架,高级 React。我们需要添加一些包并做一些修改。

作为提醒,这是我们开始之前的项目文件结构:

├── assets
│   ├── app.css
│   ├── favicon.ico
│   └── index.html
├── package.json
├── server.js
├── source
│   └── index.jsx
└── webpack.config.js

让我们先把我们名为 server.js 的文件重命名为 server-development.js。然后,在项目根目录中创建一个名为 server-production.js 的新文件,并添加以下代码:

'use strict';

var path = require('path');
var express = require('express');
var serveStatic = require('serve-static')
var compression = require('compression')
var port = process.env.PORT || 8080;
var host = process.env.HOST || '0.0.0.0';

在这里,我们指示服务器使用预配置的 PORTHOST 变量或默认变量,就像我们在 Browserify 服务器中所做的那样。然后,我们添加了一个错误处理器,以便我们能够优雅地响应错误。这也可以添加到 Browserify 服务器中:

var http = require('http');
var errorHandler = require('express-error-handler');

我们还添加了压缩:

var app = express();
app.use(compression());

现在我们转向 assets 文件:

var cpFile = require('cp-file');
cpFile('assets/index.prod.html', 'public/assets/index.html').then(function() {
  console.log('Copied index.html');
});
cpFile('assets/app.css', 'public/assets/app.css').then(function() {
  console.log('Copied app.css');
});

我们将手动复制所需的 asset 文件。我们只有两个文件,所以手动操作是可以接受的。如果我们有很多文件要复制,另一种方法可能更有效。一个在不同环境中都兼容的选项是 ShellJS。使用这个扩展,你可以在 JavaScript 环境中设置普通 shell 命令并执行它们。我们在这个项目中不会这样做,但值得一看。现在参考以下代码行:

var envs = require('envs');
app.set('environment', envs('NODE_ENV', 'production')); 
app.use(serveStatic(path.join(__dirname, 'public', 'assets')));

在这里,我们将 environment 设置为 production,并让 Express 知道我们的静态文件放置在 ./public/assets 文件夹中,使用 serve-static 中间件。这意味着我们可以在文件中引用 /app.css,Express 将知道在正确的 assets 文件夹中查找它。对于低流量应用,这是一个好的实现,但对于高流量应用,最好使用反向代理来提供静态文件。使用反向代理的主要好处是减轻动态服务器上的负载,将其转移到专门设计来处理资产的其它服务器。我们路由所有请求到 index.html。这不会应用于存在于 static 文件夹中的文件:

var routes = function (app) {
  app.get('*', function(req, res) {
    res.sendFile(path.join(__dirname, 'public', 'assets','index.html'));
  });
}

我们创建 server 对象以便将其传递给错误处理器:

var router = express.Router();
routes(router);
app.use(router);

Var server = http.createServer(app);

在这里,我们响应错误并条件性地关闭服务器。server 对象作为参数传递,以便错误处理器可以优雅地关闭它:

app.use(function (err, req, res, next) {
  console.log(err);
  next(err);
});

app.use( errorHandler({server: server}) );

最后,我们启动应用:

app.listen(port, host, function() {
  console.log('Server started at http://'+host+':'+port);
});

正如您所注意到的,我们添加了一些新的包。使用以下命令安装这些包:

npm install --save compression@1.6.1 envs@0.1.6 express-error-handler@1.0.1 serve-static@1.10.2 cp-file@3.1.0 rimraf@2.5.1

所有在 server.prod.js 中需要的模块都需要移动到 package.jsondependencies 部分中。您的依赖部分现在应该看起来像这样:

"devDependencies": {
  "react-transform-catch-errors": "¹.0.1",
  "react-transform-hmr": "¹.0.1",
  "redbox-react": "¹.2.0",
  "webpack-dev-middleware": "¹.4.0",
  "webpack-hot-middleware": "².6.0",
  "babel-core": "⁶.3.26",
  "babel-loader": "⁶.2.0",
  "babel-plugin-react-transform": "².0.0",
  "babel-preset-es2015": "⁶.3.13",
  "babel-preset-react": "⁶.3.13",
  "babelify": "⁷.3.0",
  "uglifyjs": "².4.10",
  "webpack": "¹.12.9",
  "rimraf": "².5.1",
  "react": "¹⁵.1.0",
  "react-dom": "¹⁵.1.0"
},
"dependencies": {
  "compression": "¹.6.1",
  "cp-file": "³.1.0",
  "envs": "⁰.1.6",
  "express": "⁴.13.3",
  "express-error-handler": "¹.0.1",
  "path": "⁰.12.7",
  "serve-static": "¹.10.2"
}

Heroku 需要的所有依赖项都必须放在正常的 dependencies 部分中,因为 Heroku 会省略 devDependencies 中的所有包。

小贴士

云部署的依赖策略

由于从 npm 下载和安装包相当慢,将仅在开发时需要的包放在 devDependencies 中,反之亦然,是一种良好的实践。我们在整本书中都这样做,所以希望您已经遵循了这种模式。

我们几乎完成了,但在我们准备好之前,我们需要创建 webpack.config.jsindex.html 的生产版本,并添加构建脚本。

将现有的 webpack.config.js 文件重命名为 Webpack-development.config.js,然后创建一个名为 Webpack-production.config.js 的文件。注意,这意味着您需要将 server-development.js 中的 Webpack 导入更改为反映这一更改。

添加以下代码:

'use strict';

var path = require('path');
var webpack = require('webpack');

module.exports = {
  entry: [
    './source/index'
  ],
  output: {
    path: path.join(__dirname, 'public', 'assets'),
    filename: 'bundle.js',
    publicPath: '/assets/'
  },
  plugins: [
    new webpack.optimize.OccurenceOrderPlugin(),

此插件重新排序包,以便最常用的包放在顶部。这应该会减小文件大小并使包更高效。我们指定这是一个生产构建,以便 Webpack 利用它拥有的最节省字节的算法:

    new webpack.DefinePlugin({
      'process.env': {
        'NODE_ENV': JSON.stringify('production')
      }
    }),

我们还将告诉它使用 UglifyJS 压缩我们的代码:

    new webpack.optimize.UglifyJsPlugin({
      compressor: {
        warnings: false
      }
    })

从 Webpack 的 production 配置中,我们移除了热加载插件,因为它仅在开发时才有意义:

  ],
  module: {
    loaders: [{
      tests: /\.js?$/,
      loaders: ['babel'],
      include: path.join(__dirname, 'source')
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  }
};

接下来,将一个名为 index-production.html 的文件添加到 assets 目录中,并添加以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title>ReactJS + Webpack Scaffold</title>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, 
    initial-scale=1">
    <link rel="stylesheet" href="/app.css">
  </head>
  <body>
    <div id="app"></div>
    <script src="img/bundle.js"></script>
  </body>
</html>

最后,将这些脚本添加到 package.json 中:

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "prestart": "npm run build",
  "start": "node server-production.js",
  "dev": "node server-development.js",
  "prebuild": "rimraf public",
  "build": "NODE_ENV=production webpack --config Webpack-production.config.js",
  "predeploy": "npm run build",
  "deploy": "echo Ready to deploy. Commit your changes and run git push heroku master"
},

这些脚本让您可以构建和部署您的应用。我们暂时没有提交更改,以便让您知道部署过程已准备就绪。

注意,在构建参数中,我们添加了 NODE_ENV=production 以防止 Babel 在构建代码时尝试使用 hot 模块替换。控制此功能的配置在 .babelrc 文件中。

你的 Webpack 框架现在已准备好用于生产!

在开发时,执行 npm run dev 并享受一个具有热重载的流畅开发环境。

npm deploy 上,构建脚本将被执行,并会通知你何时准备好发布更改。你需要通过 git addgit commit 手动添加更改,然后运行 git push heroku master。当然,你可以在部署脚本中自动化这一过程。

构建脚本也可以通过执行 npm run build 来触发。在构建脚本之前,我们将首先执行 rimraf publicRimraf 是一个环境安全的命令,用于删除 public 文件夹及其所有内容。它在 Mac/Linux 上等同于运行 rm -rf public。此命令在 Windows 上不存在,因此在那个平台上运行 rm 不会起作用,但运行 rimraf 将在任一平台上起作用。最后,脚本执行 webpack 并构建一个生产包,该包放在 public/assets/bundle.js 中。

通常情况下,Webpack 在移除未使用代码方面略有效率,因此最终生成的包大小将小于 Browserify 生成的包。本例中生成的包大约为 132 KB。

注意

注意,这并不是一个完全公平的比较,因为我们捆绑在 Browserify 部分的应用程序要大得多。

最终结果可在reactjsblueprints-wpdeploy.herokuapp.com/找到。

将 Webpack 应用程序部署到云端

为了参考,我们的文件结构现在看起来是这样的:

├── .babelrc
├── assets
│   ├── app.css
│   ├── favicon.ico
│   ├── index.html
│   └── index-production.html
├── package.json
├── server-development.js
├── server-production.js
├── source
│   └── index.jsx
├── Webpack-development.config.js
└── Webpack-production.config.js

这仍然相当易于管理。诚然,在 proddev 中分离文件需要更多的人工干预,但与在文件内部使用 if…else 循环切换代码相比,这可能是更好的选择。然而,代码组织确实是一个棘手的问题,没有一种通用的设置能令每个人都满意。对于仅涉及几个文件的少量修改,if…else 语句可能更适合在生产版本和开发版本中分割文件。

摘要

在本章中,我们将云部署添加到了本书中开发的所有两个框架中。两个示例的预览现在可在网上找到。

生成可云部署的应用程序通常意味着尽可能紧密地捆绑我们的代码。随着 HTTP/2 时代的到来,这种策略可能需要重新审视,因为生成一组可以并行下载的文件可能更有益,而不是单个捆绑包,无论它有多小。值得注意的是,非常小的文件从 gzip 中获益不大。

使用 Webpack 也可以分割你的代码包。有关 Webpack 代码分割的更多信息,请参阅webpack.github.io/docs/code-splitting.html

在下一章中,我们将基于本章中刚刚制作的生成式 Webpack 配置,开发一个流式服务器渲染的应用程序。

第九章。创建共享应用

同构应用是可以在客户端和服务器端运行的 JavaScript 应用程序。其理念是后端和前端应该尽可能共享代码。对于服务器渲染的应用,您也可以在不等待 JavaScript 代码初始化的情况下提前展示内容。

本章分为两个部分:

  • 在第一部分,我们将扩展我们在 第八章 中创建的设置,即 将您的应用部署到云端,以便它支持组件的预渲染

  • 在第二部分,我们将添加 Redux 并从服务器环境中填充您的应用数据

简而言之,以下是我们将要涉及的主题:

  • 服务器渲染与客户端渲染

  • 术语混淆

  • 修改设置以启用服务器渲染

  • 流式传输您的预渲染组件

  • 将服务器渲染的应用部署到云端

服务器渲染与客户端渲染

Node.js 使得在您的后端和前端编写 JavaScript 变得非常容易。我们一直在编写服务器代码,但直到现在,我们的所有应用都是客户端渲染的。

将您的应用作为客户端渲染的应用渲染意味着捆绑您的 JavaScript 文件,并将其与您的图像、CSS 和 HTML 文件一起分发。它可以在任何操作系统上运行的任何类型的 Web 服务器上分发。

客户端渲染的应用通常分为两个步骤:

  1. 初始请求加载 index.html 以及 CSS 和 JavaScript 文件,要么同步要么异步。

  2. 通常,应用随后会发出另一个请求,并根据服务器响应生成适当的 HTML。

对于服务器渲染的应用,第二步通常被省略。初始请求一次性加载 index.html、CSS、JavaScript 和内容。应用在内存中,准备提供服务,无需等待客户端解析和执行 JavaScript。

你有时会听到这样的论点,即服务器渲染的应用对于服务于那些设备上没有 JavaScript 或简单地将它关闭的用户是必要的。这不是一个有效的论点,因为我知道的所有调查和统计数据都将用户数量估计在约 1% 左右。

另一个论点是支持搜索引擎机器人,它们通常在解析基于 JavaScript 的内容时遇到困难。这个论点稍微更有道理,但像 Google 和 Bing 这样的主要玩家能够做到这一点,尽管您可能需要添加一个元标签,以便内容可以被索引。

注意

验证机器人是否可以读取您的网站

您可以使用 Google 的 Fetch as Googlebot 工具来验证您的内容是否被正确索引。该工具可在 www.google.com/webmasters/tools/googlebot-fetch 获取。或者,您可以参考 www.browseo.net/

服务器渲染应用的优点如下:

  • 拥有慢速计算机的用户不需要等待 JavaScript 代码解析。

  • 它还为我们提供了可预测的性能。当你控制渲染过程时,你可以测量加载你的网页所需的时间。

  • 不需要用户在他们的设备上安装 JavaScript 运行时。

  • 使搜索引擎更容易爬取你的页面。

客户端渲染应用程序的好处如下:

  • 需要处理的设置更少

  • 服务器和客户端之间没有并发问题

  • 通常更容易开发

制作一个服务器端渲染的应用程序比编写客户端渲染的应用程序更复杂,但它带来了实际的好处。我们首先将我们的脚手架准备好以适应云端,然后再添加服务器渲染功能。首先,我们需要澄清术语,因为在实际应用中,你会遇到几个不同的术语来描述服务器和客户端之间共享代码的应用程序。

术语混淆

术语同构由希腊语单词isos(意为“相等”)和morph(意为“形状”)组成。这个想法是通过使用同构这个术语,可以很容易地理解这是服务器和客户端之间共享的代码。

在数学中,同构是两个集合之间的一对一映射函数,它保留了集合之间的关系。

例如,一个同构的代码示例可能看起来像这样:

// Module A
function foo(x, y) {
  return x * y;
} 

// Module B
function bar(y, x) {
  return y * x;
}

foo(10, 20) // 200
bar(20, 10) // 200

这两个函数不相同,但它们产生相同的结果,因此在乘法中是同构的。

在数学中,同构可能是一个好术语,但显然它并不适合开发 Web 应用程序。我们在这里使用这个术语作为本章的标题,因为它是 JavaScript 社区中目前公认的用于服务器端渲染应用程序的术语。然而,它并不是一个非常好的术语,我们正在寻找一个更好的术语。

在寻找替代品的过程中,术语通用已成为许多人的选择。然而,这并不完全理想。一方面,它很容易被误解。与 Web 应用程序相关的通用最接近的定义是:被所有人使用或理解。记住,目标是描述代码共享。但是,通用也可以被理解为描述可以在任何地方运行的 JavaScript 应用程序的术语。这包括不仅限于 Web,还包括原生设备和操作系统。这种混淆在 Web 开发领域普遍存在。

第三个术语是共享,即共享 JavaScript。这更合适,因为它暗示了你的代码有一定的意义。当你编写共享 JavaScript 时,意味着你编写的代码打算在多个环境中使用。

在网上搜索代码时,你会发现所有这些术语被交替使用来描述相同的开发 Web 应用的模式。正确的命名很重要,因为它使你的代码对外部观众更容易理解。流行词汇听起来不错,听起来很好听,但使用的流行词汇越多,你的代码库理解起来就越困难。

在本章中,我们将使用“服务器端渲染”一词来表示在将其提供给用户之前渲染 HTML 的代码。我们将使用“客户端渲染”一词来表示将 HTML 的渲染推迟到用户设备的代码。最后,我们将使用“共享代码”一词来描述在服务器和客户端都可以互换使用的代码。

开发服务器端渲染应用

在 ReactJS 中开发共享应用比仅仅构建客户端渲染应用需要更多的工作。它还要求你考虑你的数据流需求。

ReactJS 中编写服务器端渲染应用有两个组成部分,可以将其视为一个等式:

在服务器实例中预渲染组件 + 从服务器到组件的单向数据流 = 好的应用程序和快乐的用户

在本节中,我们将查看等式的第一部分。我们将在本章的最后部分解决数据流问题。

添加包

我们需要从 npm 获取更多包以将它们添加到依赖项部分。这是我们需要的依赖项列表:

"devDependencies": {
  "react-transform-catch-errors": "¹.0.1",
  "react-transform-hmr": "¹.0.1",
  "redbox-react": "¹.2.0",
  "webpack-dev-middleware": "¹.4.0",
  "webpack-hot-middleware": "².6.0",
  "babel-cli": "⁶.4.5",
  "babel-core": "⁶.3.26",
  "babel-loader": "⁶.2.0",
  "babel-plugin-react-transform": "².0.0",
  "babel-preset-es2015": "⁶.3.13",
  "babel-preset-react": "⁶.3.13",
  "compression": "¹.6.1",
  "cp-file": "³.1.0",
  "cross-env": "¹.0.7",
  "exenv": "¹.2.0",
  "webpack": "¹.12.9"
},
"dependencies": {
  "express": "⁴.13.3",
  "express-error-handler": "¹.0.1",
  "path": "⁰.12.7",
  "react": "¹⁵.1.0",
  "react-bootstrap": "⁰.29.4",
  "react-breadcrumbs": "¹.3.5",
  "react-dom": "¹⁵.1.0",
  "react-dom-stream": "⁰.5.1",
  "react-router": "².4.1",
  "rimraf": "².5.1",
  "serve-static": "¹.11.0"
}

将任何缺少的包添加到 package.json 并通过执行 npm install 来更新它。

添加 CSS

我们需要为我们的页面添加样式,所以我们将使用我们在 第七章 开发 Reactagram 时使用的子集,Reactagram。将 assets/app.css 的内容替换为以下内容:

body { font-family: 'Bitter', serif; padding: 15px;
  margin-top: 50px; padding-bottom: 50px }

.header {  padding: 10px; font-size: 18px; margin: 5px; }

footer{ position:fixed; bottom:0; background: black; width:100%;
  padding:10px; color:white; left:0; text-align:center; }

h1 { font-size: 24px; }

h2 { font-size: 20px; }

h3 { font-size: 17px; }

ul { padding:0; list-style-type: none; }

.nav a:visited, .nav a:link { color: #999; }

.nav a:hover { color: #fff; text-decoration: none; }

.logo { padding-top: 16px; margin: 0 auto; text-align: center; }

#calculator{ min-width:240px; }

.calc { margin:3px; width:50px; }

.calc.wide { width:163px; }

.calcInput { width: 221px; }

将 Bootstrap CDN 添加到 index.html

由于我们添加了 react-bootstrap,因此我们需要添加 Bootstrap CDN 文件。打开 assets/index.html 并将其替换为以下内容:

<!DOCTYPE html>
<html>
  <head>
    <title>Shared App</title>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, 
    initial-scale=1, maximum-scale=1, user-scalable=no">
    <link async rel="stylesheet" type="text/css"
    href="//maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">
    <link async rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
    <link async href='https://fonts.googleapis.com/css?family=Bitter'
      rel='stylesheet' type='text/css'>
    <link async rel="stylesheet" href="/app.css">
    <link rel="stylesheet" href="/app.css">
  </head>
  <body>
    <div id="app"></div>
    <script src="img/bundle.js"></script>
  </body>
</html>

创建组件

我们不能没有一些内容就制作应用程序,所以让我们添加一些页面和路由层次结构。首先,从 source 文件夹中删除 index.jsx,从 assets 文件夹中删除 index-production.html。完成这一部分章节后,树结构将如下所示:

├── .babelrc
├── assets
│   ├── app.css
│   ├── favicon.ico
│   └── index.html
├── config.js
├── package.json
├── server-development.js
├── server-production.es6
├── server-production.js
├── source
│   ├── client
│   │   └── index.jsx
│   ├── routes
│   │   └── index.jsx
│   ├── server
│   │   └── index.js
│   └── shared
│       ├── components
│       │   ├── back.jsx
│       │   └── fontawesome.jsx
│       ├── settings.js
│       └── views
│           ├── about.jsx
│           ├── app.jsx
│           ├── calculator.jsx
│           ├── error.jsx
│           └── layout.jsx
├── Webpack-development.config.js
└── Webpack-production.config.js

我们需要勤奋地构建我们的应用程序,以便使其易于理解,并注意到客户端和服务器端渲染如何相互配合。

让我们先添加 client/index.jsx 的源代码:

import React from 'react';
import { render } from 'react-dom';
import { Router, browserHistory } from 'react-router';
import { routes } from '../routes';

render(
  <Router routes={routes} history={ browserHistory } />,
  document.getElementById('app')
);

到目前为止,代码结构应该非常熟悉。

让我们添加我们的路由。创建 routes/index.jsx 并添加以下代码:

'use strict';

import React from 'react';

import { Router, Route, IndexRoute }
  from 'react-router'
import App from '../shared/views/app';
import Error from '../shared/views/error';
import Layout from '../shared/views/layout';
import About from '../shared/views/about';
import Calculator from '../shared/views/calculator';

const routes= <Route path="/" name="Shared App" component={Layout} >
  <Route name="About" path="about" component={About} />
  <Route name="Calculator" path="calculator"
    component={Calculator} />
  <IndexRoute name="Welcome" component={App} />
  <Route path="*" name="Error" component={Error} />
</Route>

export { routes };

路由将响应 //about/calculator,其他所有内容都将路由到 error 组件。如果你访问应用程序而没有指定路由(例如,http://localhost:8080 没有结束斜杠),IndexRoute 函数将应用程序路由到 Welcome 组件。

路由被分配到我们将要创建的几个基本视图。

创建 shared/views/layout.jsx 并添加以下代码:

import React from 'react'
import { Grid, Row, Col, Nav, Navbar } from 'react-bootstrap';
import Breadcrumbs from 'react-breadcrumbs';
import Settings from '../settings';
export default class Layout extends React.Component {
  render() {
    return (
      <Grid>
        <Navbar componentClass="header"
          fixedTop inverse>
          <h1 center style={{color:"#fff"}} className="logo">
            {Settings.title}

我们将从 Settings 组件中获取标题。以下组件将创建一个你可以用作导航元素的链接路径:

          </h1>
          <Nav role="navigation" eventKey={0}
          pullRight>
          </Nav>
        </Navbar>
        <Breadcrumbs {...this.props} setDocumentTitle={true} />

参数 setDocumentTitle 是一个将使组件更改窗口标签页的文档标题为你在的 child 组件名称的参数,让我们放置以下代码:

        {this.props.children}
        <footer>
          Server-rendered Shared App
        </footer>
      </Grid>
    )
  }
}

创建 shared/views/app.jsx 并添加以下代码:

'use strict';
import React from 'react'
import { Row, Col } from 'react-bootstrap';

import { Link } from 'react-router'

export default class Index extends React.Component {
  render() {
    return (
      <Row>
        <Col md={6}>
          <h2>Welcome to my server-rendered app</h2>
          <h3>Check out these links</h3>
          <ul>
            <li><Link to="/calculator">Calculator</Link></li>
            <li><Link to="/about">About</Link></li>
          </ul>
        </Col>
      </Row>
    )
  }
}

此组件创建了一个包含两个链接的简单列表。第一个链接到 About 组件,第二个链接到 Calculator 组件。

创建 shared/views/error.jsx 并添加以下代码:

'use strict';
import React from 'react'
import { Grid, Row, Col } from 'react-bootstrap';

export default class Error extends React.Component {
  render() {
    return (
      <Grid>
        <Row>
          <Col md={6}>
            <h1>Error!</h1>
          </Col>
        </Row>

      {this.props.children}
      </Grid>
    )
  }
}

如果你在浏览器的 URL 定位器中手动输入错误路径,此组件将显示出来。

创建 shared/views/about.jsx 并添加以下代码:

'use strict';
import React from 'react'
import { Row, Col } from 'react-bootstrap';

export default class About extends React.Component {
  render() {
    return (
      <Row>
        <Col md={6}>
          <h2>About</h2>
          <p>
            This app is designed to work as either a client- or
            a server-rendered app. It's also designed to be 
            deployed to the cloud.
          </p>
        </Col>
      </Row>
    )
  }
}

About 组件是我们应用中的一个简单占位符组件。你可以用它来展示一些关于你应用的信息。

创建 shared/views/calculator.jsx 并添加以下代码:

'use strict';
import React from 'react'
import { Row, Col, Button, Input, FormGroup, FormControl, InputGroup } from 'react-bootstrap';

export default class Calculator extends React.Component {
  constructor() {
    super();
    this.state={};
    this.state._input=0;
    this.state.__prev=0;
    this.state._toZero=false;
    this.state._symbol=null;
  }

当使用 ES6 类时,getInitialState 元素已被弃用,因此我们需要在构造函数中设置初始状态。我们可以通过首先将一个空的 state 变量附加到 this 上来完成此操作。然后,我们添加三个状态:_input 是计算器输入文本框,_prev 用于保存要计算的数字,_toZero 是一个在计算时用于将输入置零的标志,_symbol 是数学运算符号(加、减、除和乘),让我们看看以下代码:

  handlePercentage(){
    this.setState({_input:this.state._input/100, _toZero:true})
  }

  handleClear(){
      this.setState({_input:"0"})
  }

  handlePlusMinus(e){
        this.setState({_input:this.state._input>0 ?
         -this.state._input:Math.abs(this.state._input)});
  }

这三个函数直接修改输入的数字。让我们继续到下一个函数:

  handleCalculate(e) {
    const value = this.refs.calcInput.props.value;
    if(this.state._symbol) {
      switch(this.state._symbol) {
        case "+":
          this.setState({_input:(Number(this.state._prev)||0)
          +Number(value),_symbol:null});
        break;
        case "-":
          this.setState({_input:(Number(this.state._prev)||0)
            -Number(value),_symbol:null});
        break;
        case "/":
          this.setState({_input:(Number(this.state._prev)||0)
            /Number(value),_symbol:null});
        break;
        case "*":
          this.setState({_input:(Number(this.state._prev)||0)
            *Number(value),_symbol:null});
        break;
      }
    }
  }

当你按下 计算 按钮(=)时,此函数会被调用。它将检查用户是否激活了数学符号,如果是,它将检查哪个符号是激活的,并在存储的数字和当前数字上执行计算,让我们看看以下代码片段:

  handleClick(e) {
    let input=Number(this.state._input)||"";
    if(this.state._toZero) {
      this.setState({_toZero: false});
      input="";
    }

如果输入的数字需要变为零,这个操作将完成这个任务并重置 _toZero 标志。现在我们转到 isNaN

    if(isNaN(e.target.value)) {
      this.setState({_toZero:true,
        _prev:this.state._input,
        _symbol:e.target.value
      })

使用 isNaN 是检查变量是否为数字的高效方法。如果不是,它是一个数学符号,我们通过将符号存储在状态中,要求输入的数字变为零(这样我们不会计算错误的数字),并将当前输入设置为 _prev 值(用于计算),让我们看看以下代码片段:

    } else {
      this.setState({_input:input+e.target.value})

如果它是一个数字,我们将其添加到 _input 状态中,让我们看看以下代码片段:

    }
  }

  handleChange(e) {
    this.setState({_input:e.target.value})
  }

  calc() {
    return (
      <div id="calculator">
        <Col md={12}>
          <FormGroup>
            <InputGroup className="calcInput" >
              <FormControl 
                ref="calcInput" 
                onChange={ this.handleChange.bind(this) }
                value={this.state._input}
              type="text" />
            </InputGroup>
          </FormGroup>
          <Input type="text" className="calcInput"
            ref="calcInput" defaultValue="0"
            onChange={this.handleChange.bind(this)}
             value={this.state._input}/>

当使用 React.createClass 时,所有函数都会自动绑定到组件上。由于我们正在使用 ES6 类,我们需要手动绑定我们的函数,让我们看看以下代码片段:

          <Button className="calc"
            onClick={this.handleClear.bind(this)}>C</Button>
          <Button className="calc"
            onClick={this.handlePlusMinus.bind(this)}>
            {String.fromCharCode(177)}</Button>
          <Button className="calc"
            onClick={this.handlePercentage.bind(this)}>%</Button>
          <Button className="calc" value="/"
            onClick={this.handleClick.bind(this)}>
            {String.fromCharCode(247)}</Button>

一些字符在标准键盘上难以定位。相反,我们可以使用 Unicode 字符代码来渲染它。字符代码及其相应图像的列表在互联网上很容易找到,让我们看看以下代码片段:

          <br/>
          <Button className="calc" value="7"
            onClick={this.handleClick.bind(this)}>7</Button>
          <Button className="calc" value="8"
            onClick={this.handleClick.bind(this)}>8</Button>
          <Button className="calc" value="9"
            onClick={this.handleClick.bind(this)}>9</Button>
          <Button className="calc" value="*"
            onClick={this.handleClick.bind(this)}>
            {String.fromCharCode(215)}</Button>
          <br/>
          <Button className="calc" value="4"
            onClick={this.handleClick.bind(this)}>4</Button>
          <Button className="calc" value="5"
            onClick={this.handleClick.bind(this)}>5</Button>
          <Button className="calc" value="6"
            onClick={this.handleClick.bind(this)}>6</Button>
          <Button className="calc" value="-"
            onClick={this.handleClick.bind(this)}>-</Button>
          <br/>
          <Button className="calc" value="1"
            onClick={this.handleClick.bind(this)}>1</Button>
          <Button className="calc" value="2"
            onClick={this.handleClick.bind(this)}>2</Button>
          <Button className="calc" value="3"
            onClick={this.handleClick.bind(this)}>3</Button>
          <Button className="calc" value="+"
            onClick={this.handleClick.bind(this)}>+</Button>
          <br/>
          <Button className="calc wide" value="0"
            onClick={this.handleClick.bind(this)}>0</Button>
          <Button className="calc"
            onClick={this.handleCalculate.bind(this)}>=</Button>
        </Col>
      </div>
    )
  }

  render() {
    return (
      <Row>
        <Col md={12}>
          <h2>Calculator</h2>
          {this.calc()}
        </Col>
      </Row>
    )
  }
}

以下截图显示了刚刚创建的 计算器 页面:

创建组件

接下来,添加两个文件:config.jsroot 文件夹,settings.jssource/shared

将此代码添加到 config.js

'use strict';
const config = {
  home: __dirname
};
module.exports = config;

然后,将此代码添加到 settings.js

'use strict';
import config from '../../config.js';

const settings = Object.assign({}, config, {
  title: 'Shared App'
});
module.exports = settings;

设置服务器端渲染的 Express React 服务器

我们现在已经完成了共享组件的制作,所以现在是时候设置服务器端渲染了。在前面的文件结构中,你可能已经注意到我们添加了一个名为 server-production.es6 的文件。我们将保留普通的 ES5 版本,但为了简化我们的代码,我们将使用现代 JavaScript 编写它,并使用 Babel 将其转换为 ES5。

使用 Babel 是我们不得不忍受的事情,直到 node 实现对 ES6/ECMAScript 2015 的完全支持。我们可以选择使用 babel-node 来运行我们的 express 服务器,但在生产环境中不建议这样做,因为它会给每个请求增加显著的开销。

让我们看看它应该是什么样子。创建 server-production.es6 并添加以下代码:

'use strict';

import path from 'path';
import express from 'express';
import compression from 'compression';
import cpFile from 'cp-file';
import errorHandler from 'express-error-handler';
import envs from 'envs';
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, match, RoutingContext } from 'react-router';
import { routes } from './build/routes';

我们将在 Express 服务器中使用客户端路由。我们将设置一个通配符 Express 路由,并在其中实现 react-router 路由,让我们看看以下代码片段:

import settings from './build/shared/settings';
import ReactDOMStream from 'react-dom-stream/server';

我们还将实现一个流式 DOM 工具,而不是使用 React 自身的 renderToStringrenderToString 方法是同步的,在 React 网站的服务器端渲染中可能会成为性能瓶颈。流使这个过程变得更快,因为您在发送之前不需要预先渲染整个字符串。对于较大的页面,renderToString 可能会引入数百毫秒的延迟,并需要更多的内存,因为它需要为整个字符串分配内存。

ReactDOMStream 异步渲染到流中,并允许在响应完全完成之前,浏览器先渲染页面。请参考以下代码:

import serveStatic from 'serve-static';

const port = process.env.PORT || 8080;
const host = process.env.HOST || '0.0.0.0';
const app = express();
const http = require('http');
app.set('environment', envs('NODE_ENV', process.env.NODE_ENV || 'production')); 
app.set('port', port);
app.use(compression());

cpFile('assets/app.css', 'public/assets/app.css').then(function() {
  console.log('Copied app.css');
});
app.use(serveStatic(path.join(__dirname, 'public', 'assets')));

const appRoutes = (app) => {
  app.get('*', (req, res) => {
    match({ routes, location: req.url },
    (err, redirectLocation, props) => {
      if (err) {
        res.status(500).send(err.message);
      }
      else if (redirectLocation) {
        res.redirect(302,
        redirectLocation.pathname + redirectLocation.search);

在进行服务器端渲染时,我们需要为错误发送 500 响应,为重定向发送 302 响应。我们可以通过匹配和检查响应状态来实现这一点。如果没有错误,我们继续进行渲染,让我们看看以下代码片段:

      } else if (props) {
        res.write(`<!DOCTYPE html>
          <html>
            <head>
              <meta charSet="utf-8" />
                <meta httpEquiv="X-UA-Compatible"
                  content="IE=edge" />
                  <meta name="viewport" content="width=device-width,
                  initial-scale=1, maximum-scale=1, user-scalable=no"/>
                  <link rel="preload" as="stylesheet" type="text/css"
                  href="//maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css"/>
                  <link rel="preload" as="stylesheet" type="text/css"
                  href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
                  <link rel="preload" as="stylesheet" href='https://fonts.googleapis.com/css?family=Bitter' type='text/css'/>
                  <link rel="preload" as="stylesheet" href="/app.css" />
                  <title>${settings.title}</title>
            </head>
        <body><div id="app">`);
      const stream = ReactDOMStream.renderToString(
        React.createElement(RoutingContext, props));
      stream.pipe(res, {end: false});
      stream.on("end", ()=> {
        res.write(`</div><script
          src="img/bundle.js"></script></body></html>`);
        res.end();
       });''
      }
      else {
        res.sendStatus(404);

最后,如果我们找不到任何属性或路由,我们需要发送一个 404 状态码,让我们看看剩余的代码:

      }
    });
  });
}

当服务器开始渲染时,它将首先写入我们的头部信息。它将异步加载初始 CSS 文件,设置标题和主体,以及第一个 div。然后,我们切换到 ReactDOMStream,它将从 RoutingContext 开始渲染我们的应用。当流完成时,我们通过包裹我们的 div 和 HTML 页面来关闭响应。服务器渲染的内容现在位于 <div id="app"></div> 内。当 bundle.js 被加载时,它将接管并替换这个 div 的内容,除非渲染的设备不支持 JavaScript。

注意,尽管 CSS 文件是异步的,但它们仍然会在加载之前阻塞渲染。可以通过内联 CSS 来绕过这个问题,让我们看看以下代码片段:

const router = express.Router();
appRoutes(router);
app.use(router);

const server = http.createServer(app);
app.use((err, req, res, next) => {
  console.log(err);
  next(err);
});
app.use( errorHandler({server: server}) );

app.listen(port, host, () => {
  console.log('Server started for '+settings.title+' at http://'+host+':'+port);
});

最后的部分与之前相同,只是修改为使用新的 JavaScript 语法。你注意到的一件事是我们从名为 build 的新文件夹中导入源组件,而不是 source。在开发时,我们可以通过在运行时使用 Babel 将源代码转换为 ES5 来避免这个问题;然而,在生产环境中这不可行。相反,我们需要手动将整个源代码转换为 ES5。

首先,让我们在 webpack.config.dev.js 中更改两行,并验证它是否在本地构建。

打开文件,将入口处的行 ./source/index 替换为 ./source/client/index,并将路径 path.join(__dirname, 'public', 'assets') 替换为 path.join(__dirname, 'assets')。然后,通过执行 npm run dev 来运行项目。

以下截图显示了应用的主页:

设置服务器渲染的 Express React 服务器

你的应用现在应该没有问题地运行,并且 http://localhost:8080 应该现在显示 共享应用 屏幕。你应该能够编辑 source 文件夹中的代码,并看到它在屏幕上实时更新。你也应该能够点击链接并使用计算器执行数学运算。

设置 Webpack 用于服务器渲染

打开 Webpack-production.config.js 并将其内容替换为以下内容:

'use strict';

var path = require('path');
var webpack = require('webpack');

module.exports = {
  entry: [
    './build/client/index'
  ],
  output: {
    path: path.join(__dirname, 'public', 'assets'),
    filename: 'bundle.js',
    publicPath: '/assets/'
  },
  plugins: [
    new webpack.optimize.OccurenceOrderPlugin(),
    new webpack.DefinePlugin({
      'process.env': {
        'NODE_ENV': JSON.stringify('production')
      }
    }),
    new webpack.optimize.UglifyJsPlugin({
      compressor: {
        warnings: false
      }
    })
  ]
};

我们不会依赖 Babel 在运行时转换我们的代码,因此我们可以删除 moduleresolve 部分。

设置用于服务器渲染的 npm 脚本

打开 package.json 并将 scripts 部分替换为以下内容:

"scripts": {
  "test": "echo \"Error: no test specified\"",
  "convert": "babel server-production.es6 > server-production.js",
  "prestart": "npm run build",
  "start": "npm run convert",
  "poststart": "cross-env NODE_ENV=production node server-production.js",
  "dev": "cross-env NODE_ENV=development node server-development.js",
  "prebuild": "rimraf public && rimraf build && NODE_ENV=production babel source/ --out-dir build",
  "build": "cross-env NODE_ENV=production webpack --progress --config Webpack-production.config.js",
  "predeploy": "npm run build",
  "deploy": "npm run convert",
  "postdeploy": "echo Ready to deploy. Commit your changes and run git push heroku master"
},

这里是一个运行 npm start 命令会执行的操作:

  1. 运行构建(在它开始之前)。

  2. 删除 publicbuild 文件夹,并将 ES2015 源代码转换为 ES5,然后放入 builder 文件夹(预构建过程)。

  3. 运行 Webpack 并在 public/assets 中创建一个 bundle(构建过程)。

  4. 运行转换,目的是将 server-production.es6 转换为 server-production.js(当它启动时)。

  5. 运行 Express 服务器(在它启动之后)。

呼呼!这是一个庞大的命令链。编译完成后,服务器启动,你可以访问 http://localhost:8080 来测试你的预渲染服务器。你一开始可能甚至不会注意到任何区别,但尝试在浏览器中关闭 JavaScript 并执行刷新操作。页面仍然会加载,你仍然可以导航。然而,计算器将无法工作,因为它需要客户端 JavaScript 才能运行。正如之前所述,目标不是支持无 JavaScript 的浏览器(因为它们很少见)。目标是提供预渲染的页面,这正是它所做到的。

我们还更改了 npm deploy。这是它所做的工作:

  1. 在部署之前运行构建。

  2. 运行 convert,将 server-production.es6 转换为 server-production.js(一旦部署)。

  3. 它会通知你已完成。这一步可以被替换为将其部署到云端(在部署后)。

注意

服务器端渲染的应用现在已经完成。你可以在 reactjsblueprints-srvdeploy.herokuapp.com/ 找到演示。

将 Redux 添加到你的服务器端渲染应用中

最后一部分是处理数据流。在客户端渲染的应用中,数据流通常是这样处理的:用户通过访问应用的主页来启动一个动作。然后应用路由到视图,渲染过程开始。在渲染后或渲染期间(异步),数据被获取并显示给用户。

在服务器端渲染的应用中,需要在渲染过程开始之前预先获取数据。获取数据的责任从客户端转移到服务器。这需要我们对如何构建我们的应用进行彻底的重新思考。在开始设计你的应用之前做出这个决定是很重要的,因为在你开始实现应用之后改变你的数据流架构是一项成本高昂的操作。

添加包

我们需要向我们的项目中添加许多新包。package.json 文件现在应该看起来像这样:

"babel-polyfill": "⁶.3.14",
"body-parser": "¹.14.2",
"isomorphic-fetch": "².2.1",
"react-redux": "⁴.2.1",
"redux": "³.2.1",
"redux-logger": "².5.0",
"redux-thunk": "¹.0.3",

我们将执行类似于第六章中所述的异构获取,因此我们需要添加 isomorphic-fetch 库。这个库将 fetch 添加为一个全局函数,使其 API 在客户端和服务器之间保持一致。

我们还将添加 Redux 和一个控制台记录器,而不是我们在第六章中使用的 devtools 记录器。

添加文件

我们将向我们的项目中添加许多新文件,并更改一些现有文件。我们将实现的功能是从一个离线新闻 API 异步获取一组新闻条目,该 API 位于 reactjsblueprints-newsapi.herokuapp.com/stories。它提供了一系列定期更新的新闻故事。

我们将从 client/index.jsx 开始。打开这个文件,并用以下代码替换其内容:

'use strict';

import 'babel-polyfill'
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, browserHistory } from 'react-router';
import { Provider } from 'react-redux';
import { routes } from '../routes';
import configureStore from '../shared/store/configureStore';

const initialState = window.__INITIAL_STATE__;
const store = configureStore(initialState);

ReactDOM.render(
  <Provider store={store}>
    <Router routes={routes} history={ browserHistory } />
  </Provider>,
  document.getElementById('app')
)

在这里,我们添加 polyfill 和与 第六章 中相同的 Redux 设置,高级 React。我们还添加了对 window.__INITIAL_STATE__ 的检查,这是我们如何将服务器渲染的内容传输到我们的应用程序。

接下来,打开 routes/index.jsx 并用以下代码替换其内容:

import React from 'react';

import { Router, Route, IndexRoute } from 'react-router'
import App from '../shared/views/app';
import Error from '../shared/views/error';
import Layout from '../shared/views/layout';
import About from '../shared/views/about';
import Calculator from '../shared/views/calculator';
import News from '../shared/views/news';
import { connect } from 'react-redux';
import { fetchPostsIfNeeded } from '../shared/actions';
import { bindActionCreators } from 'redux';

function mapStateToProps(state) {
  return {
    receivePosts: {
      posts: ('posts' in state) ? state.posts : [],
      isFetching: ('isFetching' in state)
        ? state.isFetching : true,
      lastUpdated: ('lastUpdated' in state)
        ? state.lastUpdated : null
    }
  }
}

function mapFncsToProps(dispatch) {
  return { fetchPostsIfNeeded, dispatch }
}

这些函数负责将状态和函数传递给我们的 child 组件。我们将使用它们将新闻故事传递给 News 组件以及 dispatchfetchPostsIfNeeded 函数。接下来,在 shared 中添加一个新的文件夹,命名为 actions

const routes= <Route path="/"
  name="Shared App" component={ Layout } >
  <Route name="About"
    path="about" component={ About } />
  <Route name="Calculator"
    path="calculator" component={ Calculator } />
  <Route name="News" path="news" component={
    connect(mapStateToProps, mapFncsToProps)(News) } />
  <IndexRoute name="Welcome" component={ App } />
  <Route path="*" name="Error" component={ Error } />
</Route>
export { routes };

在这个文件夹中,添加一个名为 index.js 的文件,并添加以下代码:

'use strict';

import { fetchPostsAsync } from '../api/fetch-posts';

export const RECEIVE_POSTS = 'RECEIVE_POSTS';

export function fetchPostsIfNeeded() {
  return (dispatch, getState) => {
    if(getState().receivePosts && getState().receivePosts.length {
      let json=(getState().receivePosts.posts);
      return dispatch(receivePosts(json));
    }
    else return dispatch(fetchPosts());
  }
}

这个函数将检查存储的状态是否存在并且有内容;如果没有,它将调用 fetchPosts() 的调用。这将确保我们能够利用服务器渲染的状态,并在客户端没有这样的状态时获取内容。参考以下代码中的下一个函数:

export function fetchPosts() {
  return dispatch => {
    return fetchPostsAsync(json =>  dispatch(receivePosts(json)));
  }
}

这个函数从我们的 API 文件中返回一个 fetch 操作。它调用 receivePosts() 函数,这是 Redux 函数,告诉 Redux 存储调用 RECEIVE_POSTS 减法器函数。让我们看看下面的代码片段:

export function receivePosts(json) {
  const posts = {
    type: RECEIVE_POSTS,
    posts: json,
    lastUpdated: Date.now()
  };

  return posts;
}

下一个我们将添加的文件是 fetch-posts.js。在 shared 中创建一个名为 api 的文件夹,然后添加文件,并添加以下代码:

'use strict';
import fetch from 'isomorphic-fetch'

export function fetchPostsAsync(callback) {
  return fetch(`https://reactjsblueprints-newsapi.herokuapp.com/stories`)
    .then(response => response.json())
    .then(data => callback(data))
}

这个函数简单地使用 fetch API 返回一组故事。

接下来,在 shared 中添加一个名为 reducers 的文件夹,然后添加 index.js 文件,并添加以下代码:

'use strict';
import {
  RECEIVE_POSTS
} from '../actions'

function receivePosts(state = { }, action) {
  switch (action.type) {
    case RECEIVE_POSTS:
      return Object.assign({}, state, {
        isFetching: false,
        posts: action.posts,
        lastUpdated: action.lastUpdated
      })
    default:
      return state
  }
}

export default receivePosts;

我们的减法器获取新的状态,并返回一个新的对象,其中包含我们获取的帖子集合。

接下来,在 shared 中创建一个名为 store 的文件夹,添加一个文件并命名为 configure-store.js,然后添加以下内容:

import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import createLogger from 'redux-logger'
import rootReducer from '../reducers'

export default function configureStore(initialState) {
  const store = createStore(
    rootReducer,
    initialState,
    applyMiddleware(thunkMiddleware, createLogger())
  )

  return store
}

我们创建一个函数,它接受 initialState 并返回一个包含我们的减法器和添加异步操作和日志中间件的存储。日志显示在浏览器控制台窗口中的日志数据。

最后两个文件应该放在 views 文件夹中。第一个是 news.jsx。为此,添加以下代码:

'use strict';

import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import Posts from './posts';

class App extends Component {
  constructor(props) {
    super(props)
    this.state={};
    this.state._activePost=-1;
  }

我们通过将 _activePost 设置为 -1 来初始化状态。这将防止在用户有时间点击任何帖子之前,组件显示任何帖子的正文。参考以下内容:

  componentDidMount() {
    const { fetchPostsIfNeeded, dispatch } = this.props
    dispatch(fetchPostsIfNeeded())
  }

  handleClickCallback(i) {
    this.setState({_activePost:i});
  }

这是 posts.jsx 中的回调处理函数。当用户点击新闻标题时,将设置一个新的状态,包含新闻项的 ID,让我们看看下面的代码片段:

  render() {
    const { posts, isFetching, lastUpdated } =
    this.props.receivePosts
    const { _activePost } = this.state;
    return (
      <div>
        <p>
          {lastUpdated &&
            <span>
              Last updated at {new Date(lastUpdated)
              .toLocaleTimeString()}.
            </span>
          }
        </p>
        {posts && isFetching && posts.length === 0 &&
          <h2>Loading...</h2>
        }
        {posts && !isFetching && posts.length === 0 &&
          <h2>Empty.</h2>
        }
        {posts && posts.length > 0 &&
          <div style={{ opacity: isFetching ? 0.5 : 1 }}>
            <Posts posts={posts} activePost={_activePost} 
              onClickHandler={this.handleClickCallback.bind(this)} />
          </div>
        }

Posts 组件将获得一组帖子、一个活动帖子和一个 onClick 处理器。onClick 处理器需要绑定 App 上下文,否则它将无法使用内部方法,例如 setState。如果我们不绑定它,setState 将应用于 Posts 组件的上下文,让我们看看下面的代码片段:

      </div>
    )
  }
}

App.propTypes = {
  receivePosts: React.PropTypes.shape({
    posts: PropTypes.array.isRequired,
    isFetching: PropTypes.bool.isRequired,
    lastUpdated: PropTypes.number
  }),
  dispatch: PropTypes.func.isRequired,
  fetchPostsIfNeeded: PropTypes.func.isRequired
}

我们将使用propTypes,这样 React 开发者工具就可以告诉我们是否有任何传入的 props 缺失或类型错误:

function mapStateToProps(state) {
  return {
    receivePosts: {
      posts: ('posts' in state) ?  state.posts : [],
      isFetching: ('isFetching' in state) ?
        state.isFetching : true,
      lastUpdated: ('lastUpdated' in state) ?
        state.lastUpdated : null
    }
  }
}

我们导出应用状态,以便当前导入的组件可以使用:

export default connect(mapStateToProps)(App)

我们添加到views的第二个文件是posts.jsx

'use strict';
import React, { PropTypes, Component } from 'react'

export default class Posts extends Component {
  render() {
    function createmarkup(html) { return {__html: html}; };
    return (
      <ul>
        {this.props.posts.map((post, i) =>
        <li key={i}>
          <a onClick={this.props.onClickHandler.bind(this,i)}> 
          {post.title}</a>
            {this.props.activePost===i ?
            <div style={{marginBottom: 15}}
              dangerouslySetInnerHTML= {createmarkup(post.body)} />:
            <div/>
          }

RSS 正文自带 HTML。我们必须明确允许渲染此 HTML,否则 ReactJS 将转义内容。当用户点击标题时,posts.jsx中的回调将执行handleClickCallback。它将在news.jsx中设置一个新的状态,并将此状态作为 prop 传递给posts.jsx,表示应该显示此标题的内容,让我们看看以下代码片段:

        </li>
        )}
      </ul>
    )
  }
}

Posts.propTypes = {
  posts: PropTypes.array.isRequired,
  activePost: PropTypes.number.isRequired
}

我们还需要在app.jsx文件中添加新闻项目的链接。打开文件并添加以下行:

<li><Link to="/news">News</Link></li>

通过这些更改,您就可以运行您的应用了。使用npm run dev启动它。您应该能够访问http://localhost:8080上的首页,并点击新闻链接。它应该显示加载中,直到从服务器获取内容。以下是一个说明这一点的截图:

添加文件

截图显示,即使在浏览器中阻止了 JavaScript,新闻数据也被加载并显示。

添加服务器端渲染

现在我们已经很接近了,但在完成之前我们还需要做一些工作。我们需要在我们的 Express 服务器中添加数据获取。让我们打开server-production.es6并添加必要的代码以预取数据。

在文件顶部添加以下导入:

import { Provider } from 'react-redux'
import configureStore from './build/shared/store/configure-store'
import { fetchPostsAsync } from './build/shared/api/fetch-posts'

然后,将const approutes替换为以下代码:

const appRoutes = (app) => {
  app.get('*', (req, res) => {
    match({ routes, location: req.url },
    (err, redirectLocation, props) => {
      if (err) {
        res.status(500).send(err.message);
      }
      else if (redirectLocation) {
        res.redirect(302, redirectLocation.pathname +
          redirectLocation.search);
      }
      else if (props) {

      fetchPostsAsync(posts => {
        const isFetching = false;
        const lastUpdated = Date.now()
        const initialState = {
          posts,
          isFetching,
          lastUpdated
        }

        const store = configureStore(initialState)

在这里,我们启动了fetchPostsAsync函数。当我们收到结果时,我们使用新闻项目创建一个初始状态,然后使用这个状态创建一个新的 Redux 存储实例,让我们看看以下代码片段:

        res.write(`<!DOCTYPE html>
          <html>
            <head>
              <meta charSet="utf-8" />
              <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
              <meta name="viewport" content="width=device-width,
                initial-scale=1, maximum-scale=1, user-scalable=no"/>
              <link async rel="stylesheet" type="text/css"
                href="//maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css"/>
              <link async rel="stylesheet" type="text/css" 
                href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
              <link async href='https://fonts.googleapis.com/css?family=Bitter'
              rel='stylesheet' type='text/css'/>
              <link async rel="stylesheet" href="/app.css" />
                <title>${settings.title}</title>
            </head>
            <script>
              window.__INITIAL_STATE__ = 
              ${JSON.stringify(initialState)}
            </script>

我们将初始状态添加到全局 window 中,这样我们就可以在client/index.jsx中获取它,让我们看看剩余的代码:

            <body><div id="app">`);
            const stream = ReactDOMStream.renderToString(
            <Provider store={store}>
            <RoutingContext {...props} />
            </Provider>);
            stream.pipe(res, {end: false});
            stream.on("end", ()=> {
            res.write(`</div><script src="img/bundle.js"></script></body></html>`);
              res.end();
            });''
          })
        } else {
        res.sendStatus(404);
      }
    });
  });
}

预取数据所需的所有内容就是这些。现在您应该能够执行npm start,然后打开http://localhost:8080。尝试在浏览器中关闭 JavaScript,您仍然可以导航并查看新闻列表中的项目。

您还可以创建一个新的 Heroku 实例,运行npm deploy,然后推送它。

注意

您可以在网上查看一个演示reactjsblueprints-shared.herokuapp.com

执行更快的云部署

当您现在推送到 Heroku 时,会发生的情况是 Heroku 将执行npm start,启动整个构建过程。这是有问题的,因为如果构建过程耗时过长或资源需求过高,它将失败。

您可以通过将build文件夹提交到您的仓库,然后在推送时简单地执行node server-production.js来防止这种情况。您可以通过向仓库添加一个特殊的启动文件Procfile来实现这一点。在项目根目录中创建此文件,并添加以下行:

web: NODE_ENV=production node server-production.js

注意

注意,这个文件是针对 Heroku 的。其他云服务提供商可能有一个不同的系统来指定启动程序。

最终结构

这就是我们的最终应用程序结构看起来像什么(不包括build文件夹,它基本上与source文件夹相同):

├── .babelrc
├── Procfile
├── assets
│   ├── app.css
│   ├── favicon.ico
│   └── index.html
├── config.js
├── package.json
├── server-development.js
├── server-production.es6
├── server-production.js
├── source
│   ├── client
│   │   └── index.jsx
│   ├── routes
│   │   └── index.jsx
│   └── shared
│       ├── actions
│       │   └── index.js
│       ├── api
│       │   └── fetch-posts.js
│       ├── reducers
│       │   └── index.js
│       ├── settings.js
│       ├── store
│       │   └── configure-store.js
│       └── views
│           ├── about.jsx
│           ├── app.jsx
│           ├── calculator.jsx
│           ├── error.jsx
│           ├── layout.jsx
│           ├── news.jsx
│           └── posts.jsx
├── Webpack-development.config.js
└── Webpack-production.config.js

服务器结构基本上保持不变,source文件夹是唯一一个不断增长的文件夹。这正是它应该的样子。

在开发应用程序时,值得查看结构。它提供了一个宏观视角,可以帮助你发现命名和其他结构问题的不一致性。例如,组件layout.jsx真的属于视图吗?posts.jsx又如何?它是一个视图组件,但可以争论说它是news.jsx的辅助工具,可能属于其他地方。

摘要

在本章中,我们修改了我们的 Webpack 脚手架以启用云部署。在章节的第二部分,我们添加了服务器渲染,在第三部分,我们添加了 Redux 和数据的异步预取。

通过这三个项目,你应该能够制作出任何类型的应用程序,无论大小。然而,正如你可能已经注意到的,编写支持服务器渲染的应用程序需要相当多的思考和规划。随着应用程序规模的增加,推理组织和数据获取策略变得更加困难。你将能够使用这种策略制作出非常高效的应用程序,但我建议你花时间思考如何构建你的应用程序。

本章的演示可在reactjsblueprints-srvdeploy.herokuapp.com/reactjsblueprints-shared.herokuapp.com找到。第一个链接展示了添加服务器渲染后的应用程序状态。第二个链接展示了最终的应用程序,我们在服务器端获取数据并在向用户渲染应用程序之前填充 Redux 存储。

在下一章中,我们将使用 ReactJS 创建一个游戏。我们将使用 HTML5 canvas 技术,并添加 Flowtype 进行静态类型检查。