精通-React-全栈-Web-开发-全-

精通 React 全栈 Web 开发(全)

原文:zh.annas-archive.org/md5/331e92929bb5da9fbd82c2e50c0fc4f2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

近年来 JavaScript 编程语言的创新至关重要。例如,自 2009 年以来,Node 的兴起赋予了开发者使用相同的编程语言在浏览器和后端的能力。环境变化在 2017 年也没有放缓。这本书将向您介绍一些新的热门概念,这些概念可以进一步加快全栈开发过程。

在 2016 年和 2017 年,对使用 Falcor 或 GraphQL 等全栈技术使应用程序更快的需求更大。这本书不仅仅是一本关于如何在 Node 中暴露 API 端点并开始使用客户端应用程序消费它们的指南。您将学习如何使用 Netflix 的最新技术,称为 Falcor。除此之外,您还将学习如何使用 React 和 Redux 设置项目。

在这本书中,您将找到从零开始使用 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux 库构建全栈应用的巨大教程。您还将学习如何使用 Docker 和亚马逊的 AWS 服务部署您的应用程序。

本书涵盖的内容

第一章, 使用 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux 配置全栈,从零开始引导您设置应用程序。它帮助您了解 npm 中的不同库如何构建一个可用的全栈 React 入门套件。

第二章, 我们的发布应用的全栈登录和注册,指导您如何设置 JWT 令牌以实现基本的全栈认证机制。

第三章, 服务器端渲染,教您如何将服务器端渲染添加到应用程序中,这对于加快应用程序执行和搜索引擎优化很有帮助。

第四章, 客户端的高级 Redux 和 Falcor,展示了如何向您的应用程序添加更多高级功能,例如集成 WYSIWYG 编辑器和扩展应用程序的 Material-UI 组件,从应用程序用户的视角来看。

第五章, Falcor 高级概念,带您深入了解 Falcor 及其后端 Falcor-Router 的相关开发指南。

第六章, AWS S3 用于图片上传和总结关键应用功能,指导您完成发布应用文章封面照片的上传过程。

第七章, 在 mLab 上部署 MongoDB,教您如何为您的应用程序准备远程数据库。

第八章, Docker 和 EC2 容器服务,教您如何设置 AWS/Docker。

第九章,使用单元和行为测试进行持续集成,展示了你需要准备发布应用程序的 CI 和测试所需的内容。

你需要这本书什么

本书是在使用 macOS El Capitan 和 Sierra 的情况下编写的。它在 Linux Ubuntu 和 Windows 10 机器上进行了测试(有关这三个操作系统的差异,已添加了一些额外的注释)。

工具集安装的其余部分在第一章中展示,使用 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux 配置全栈

这本书是为谁而写的

你是否想从头开始构建和了解全栈开发?那么这本书就是为你而写的。

如果你是一名寻找提高全栈开发技能集的 React 开发者,那么你也会感到宾至如归。你将使用最新的技术从头开始构建你的下一个全栈发布应用程序。在端到端的指导下创建你的第一个全栈应用程序。

我们在书中假设你已经具备了 React 库的基本知识。

惯例

在这本书中,你会找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“在项目的目录中,创建一个名为initData.js的文件。”

代码块设置如下:

[
    {
        articleId: '987654',
        articleTitle: 'Lorem ipsum - article one',
        articleContent: 'Here goes the content of the article'
    },
    {
        articleId: '123456',
        articleTitle: 'Lorem ipsum - article two',
        articleContent: 'Sky is the limit, the content goes here.'
    }
]

任何命令行输入或输出都如下所示:

mkdir server
cd server
touch index.js

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“通过单击创建链接以使用默认值创建连接。”

警告或重要提示会以这样的框出现。

小贴士和技巧看起来像这样。

读者反馈

我们读者的反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大价值的标题。

要向我们发送一般反馈,只需发送电子邮件至feedback@packtpub.com,并在邮件主题中提及本书的标题。

如果你在一个领域有专业知识,并且你对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲所有者,我们有一些事情可以帮助你从你的购买中获得最大价值。

下载示例代码

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”标签上。

  3. 点击“代码下载与错误清单”。

  4. 在搜索框中输入本书的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买本书的来源。

  7. 点击“代码下载”。

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Mastering-Full-Stack-React-Web-Development。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/上获取。查看它们吧!

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/MasteringFullStackReactWebDevelopment_ColorImages.pdf下载此文件。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击“错误提交表单”链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的“错误清单”部分的现有错误清单中。

要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索框中输入本书的名称。所需信息将出现在“错误清单”部分。

盗版

在互联网上对版权材料的盗版是一个横跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者和我们提供有价值内容的能力方面所提供的帮助。

问题

如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:配置使用 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux 的全栈开发环境

欢迎来到精通全栈 React Web 开发。在这本书中,你将使用 JavaScript 创建一个通用全栈应用程序。我们将要构建的应用程序是一个类似于市场上目前流行的发布平台,例如:

有许多较小的发布平台,当然,我们的应用程序将比上述列表中的功能要少,因为我们只会关注主要功能,如发布文章、编辑文章或删除文章(你可以用来实现自己想法的核心功能)。除此之外,我们还将专注于构建一个健壮的应用程序,因为它可以构建,因为这些类型的应用程序最重要的特点是可扩展性。有时,一篇文章的网页流量会比整个网站的流量还要多(在行业中,一篇文章的流量可能是整个网站的 10,000 倍,因为例如,一篇文章可能通过社交媒体获得疯狂的关注)。

本书的第一章全部关于设置项目的主要依赖项。

本章的重点将包括以下主题:

  • 安装Node 版本管理器NVM)以简化 Node 管理

  • 安装 Node 和 NPM

  • 在我们的本地环境中准备 MongoDB

  • Robomongo 作为 Mongo 的 GUI

  • Express.js 配置

  • Mongoose 的安装和配置

  • 客户端应用程序的初始 React Redux 结构

  • 在后端和前端使用 Netflix Falcor 作为旧 RESTful 方法的粘合剂和替代品

我们将使用在 2015 年和 2016 年获得了大量关注的非常现代的应用程序栈--我确信你在本书中将要学习的栈在未来几年将会更加流行,因为我们公司MobileWebPro.pl对之前提到的技术产生了巨大的兴趣。你将从这本书中获得很多收获,并能够跟上构建健壮、全栈应用程序的最新方法。

更多关于我们的技术栈

在这本书中,我们假设你已经熟悉 JavaScript(ES5 和 ES6),我们还将向你介绍一些 ES7 和 ES8 的机制。

对于客户端,你将使用 React.js,这你应该已经很熟悉了,所以我们不会详细讨论 React 的 API。

对于客户端的数据管理,我们将使用 Redux。我们还将向您展示如何使用 Redux 设置服务器端渲染。

对于数据库,你将学习如何使用 MongoDB 和 Mongoose。后者是一个对象数据建模库,为你的数据提供了一个严格的建模环境。它强制执行结构,同时它也允许你保持使 MongoDB 如此强大的灵活性。

Node.js 和 Express.js 是前端开发者开始全栈开发的标准化选择。Express 的框架对Netflix-Falcor.js创建的创新客户端后端数据获取机制提供了最佳支持。我们相信你会喜欢 Falcor,因为它简单,并且在做全栈开发时能为你节省大量时间。我们将在本书的后面详细解释为什么使用这个数据获取库比构建 RESTful API 的标准流程更有效率。

通常,我们几乎会在所有地方使用对象表示法(JSON)--使用 React 作为库,JSON 被大量用于比较 Virtual DOM(底层)。Redux 使用 JSON 树作为其单一状态树容器。Netflix Falcor 的库也使用一个高级概念,称为虚拟 JSON 图(我们将在后面详细描述)。最后,MongoDB 也是一个基于文档的数据库。

JSON 无处不在--这个设置将极大地提高我们的生产力,主要是因为 Falcor 将所有东西绑定在一起。

环境准备

为了启动,你需要在你的操作系统上安装以下工具:

  • MongoDB

  • Node.js

  • NPM--与 Node.js 自动安装

我们强烈建议使用 Linux 或 OS X 进行开发。对于 Windows 用户,我们建议设置一个虚拟机,并在其中进行开发。为此,你可以使用Vagrant (www.vagrantup.com/),它会在后台创建一个虚拟环境,几乎以原生方式在 Windows 上进行开发,或者你可以直接使用 Oracle 的VirtualBox (www.virtualbox.org/),并在虚拟桌面上工作,然而这里的性能比原生工作要低得多。

NVM 和 Node 安装

NVM 是一个在开发过程中保持不同 Node 版本在机器上的非常实用的工具。如果你还没有在你的系统上安装 NVM,请访问github.com/creationix/nvm获取说明。

在你的系统上安装了 NVM 之后,你可以输入以下内容:

$ nvm list-remote

此命令列出了所有可用的 Node 版本。在我们的例子中,我们将使用 Node v4.0.0,因此你需要在你的终端中输入以下内容:

$ nvm install v4.0.0
$ nvm alias default v4.0.0

这些命令将安装 Node 版本 4.0.0 并设置为默认。本书中使用 NPM 2.14.23,你可以使用以下命令检查你的版本:

$ npm -v
2.14.23

在你本地机器上安装了相同版本的 Node 和 NPM 之后,我们就可以开始设置我们将要使用的其余工具了。

MongoDB 安装

您可以在教程部分下的 docs.mongodb.org/manual/installation/ 找到所有 MongoDB 指令。

以下是从 MongoDB 网站截取的屏幕截图:

图片

安装 Node.js 的说明和准备好的软件包可以在 nodejs.org 找到。

Robomongo GUI for MongoDB

Robomongo 是一个跨平台的桌面客户端,可以与 SQL 数据库中的 MySQL 或 PostgreSQL 相比。

在开发应用程序时,拥有一个 GUI 并能够快速查看数据库中的集合是很有帮助的。如果您熟悉使用 shell 进行数据库管理,这是一个可选步骤,但如果这是您与数据库打交道的第一步,那么它将非常有帮助。

要获取 Robomongo(适用于所有操作系统),请访问 robomongo.org/ 并在您的机器上安装一个。

在我们的案例中,我们将使用 Robomongo 的 0.9.0 RC4 版本。

运行 MongoDB 并在 Robomongo GUI 中查看我们的集合

在您的机器上安装 MongoDB 和 Robomongo 后,您需要运行其守护进程,该进程监听连接并将它们委托给数据库。要在终端中运行 Mongo 守护进程,请使用以下命令:

mongod

然后执行以下步骤:

  1. 打开 Robomongo 的客户端--以下屏幕将出现:

图片

  1. 通过点击创建链接创建一个默认连接:

图片

  1. 为您的连接选择一个名称并使用默认的端口 27017,然后点击保存。

到目前为止,您已经完成了本地数据库的设置,并且可以使用 GUI 客户端预览其内容。

将第一个示例集合导入数据库

在项目的目录下,创建一个名为 initData.js 的文件:

touch initData.js

在我们的案例中,我们正在构建发布应用程序,因此它将是一个文章列表。在以下代码中,我们有一个包含两个文章的 JSON 格式的示例集合:

[ 
    { 
        articleId: '987654', 
        articleTitle: 'Lorem ipsum - article one', 
        articleContent: 'Here goes the content of the article' 
    }, 
    { 
        articleId: '123456', 
        articleTitle: 'Lorem ipsum - article two', 
        articleContent: 'Sky is the limit, the content goes here.' 
    } 
]

通常,我们从模拟的文章集合开始--稍后我们将添加一个功能来将更多文章添加到 MongoDB 的集合中,但现在我们将只保留两个文章以保持简洁。

要列出您本地的数据库,通过输入以下命令打开 Mongo shell:

$ mongo

在 Mongo shell 中,输入以下命令:

show dbs

以下是一个完整示例:

Welcome to the MongoDB shell. 
For interactive help, type "help". 
For more comprehensive documentation, see 
 http://docs.mongodb.org/ 
Questions? Try the support group 
 http://groups.google.com/group/mongodb-user 
Server has startup warnings: 
2016-02-25T13:31:05.896+0100 I CONTROL  [initandlisten] 
2016-02-25T13:31:05.896+0100 I CONTROL  [initandlisten] ** WARNING: soft rlimits too low. Number of files is 256, should be at least 1000 
> show dbs 
local  0.078GB 
>

在我们的示例中,它显示在本地主机中有一个名为 local 的数据库。

将文章导入 MongoDB

在以下内容中,我们将使用终端(命令提示符)来将文章导入数据库。您也可以使用 Robomongo 通过 GUI 来完成此操作:

mongoimport --db local --collection articles --jsonArray initData.js --host=127.0.0.1

请记住,您需要在终端中打开一个新标签页,并且当您在 Mongo shell 中时,mongo import 将会工作(不要与 mongod 进程混淆)。

然后您将在终端中看到以下信息:

connected to: 127.0.0.1
imported 2 documents

如果您收到错误 Failed: error connecting to db server: no reachable servers,请确保在指定的主机 IP (127.0.0.1) 上运行了 mongod

通过命令行导入这些文章后,你也会在 Robomongo 中看到这一点:

使用 Node.js 和 Express.js 设置服务器

一旦我们在 MongoDB 中有了我们的文章集合,我们就可以开始在我们的 Express.js 服务器上工作,以便处理这个集合。

首先,我们需要在我们的目录中创建一个 NPM 项目:

npm init --yes

--yes 标志意味着我们将使用 package.json 的默认设置。

接下来,让我们在 server 目录中创建一个 index.js 文件:

mkdir server
cd server
touch index.js

index.js 中,我们需要添加一个 Babel/register 以获得更好的 ECMAScript 2015 和 2016 规范的覆盖。这将使我们能够支持如 asyncgenerator 函数这样的结构,这些在 Node.js 的当前版本中默认不可用。

以下为 index.js 文件的内容(我们将在稍后安装 Babel 的 dev 依赖项):

// babel-core and babel-polyfill to be installed later in that  
//chapter 
require('babel-core/register'); 
require('babel-polyfill'); 
require('./server');

安装 express 和其他初始依赖项:

npm i express@4.13.4  cors@2.7.1 body-parser@1.15.0--save

在命令中,你可以在 express 和其他库后面看到 @4.13.4。这些是我们将要安装的库的版本,我们有意选择这些版本以确保它们与 Falcor 一起工作良好,但很可能你可以跳过这些版本,新版本也应该同样有效。

我们还需要安装 dev 依赖项(我们将所有 npm install 命令分散到单独的文件中以提高可读性):

npm i --save-dev babel@6.5.2 
npm i --save-dev babel-core@6.6.5 
npm i --save-dev babel-polyfill@6.6.1 
npm i --save-dev babel-loader@6.2.4 
npm i --save-dev babel-preset-es2015@6.6.0 
npm i --save-dev babel-preset-react@6.5.0 
npm i --save-dev babel-preset-stage-0@6.5.0

我们需要的 babel-preset-stage-0 是用于 ES7 特性的。babel-preset-es2015babel-preset-react 是用于 JSX 和 ES6 支持的必需品。

此外,请注意,我们安装 Babel 以使我们的 Node 服务器能够使用 ES6 特性。我们需要添加 .babelrc 文件,因此创建以下内容:

$ [[[you are in the main project's directory]]] 
$ touch .babelrc 

然后打开 .babelrc 文件,并填充以下内容:

{ 
'presets': [ 
'es2015', 
'react', 
'stage-0' 
  ] 
}

记住,.babelrc 是一个隐藏文件。可能最好的编辑 .babelrc 的方式是在文本编辑器(如 Sublime Text)中打开整个项目。然后你应该能够看到所有隐藏文件。

我们还需要以下库:

  • babelbabel-core/register:这是一个将新的 ECMAScript 函数转换为现有版本的库

  • cors:这个模块负责以简单的方式创建对我们域的跨源请求

  • body-parser:这是解析请求体的中间件

在此之后,你的项目文件结构应该看起来像以下这样:

├── node_modules 
│   ├── *** 
├── initData.js 
├── package.json 
└── server 
    └── index.js

*** 是一个通配符,这意味着有一些文件是我们项目所需的,但我们没有在这里列出,因为这会太长。

在我们的服务器上工作(server.js)

我们将开始处理我们的 server/server.js 文件,这是我们的项目中的新文件,因此我们需要首先在项目的 server 目录中使用以下命令创建它:

touch server.js

server/server.js 文件的内容如下:

import http from 'http'; 
import express from 'express'; 
import cors from 'cors'; 
import bodyParser from 'body-parser'; 

const app = express(); 
app.server = http.createServer(app); 

// CORS - 3rd party middleware 
app.use(cors()); 

// This is required by falcor-express middleware  
//to work correctly with falcor-browser 
app.use(bodyParser.json({extended: false})); 

app.get('/', (req, res) => res.send('Publishing App Initial Application!')); 

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

这些文件使用babel/register库,这样我们就可以在我们的代码中使用 ES6 语法。在index.js文件中,我们有一个来自 Node.js 的http模块(nodejs.org/api/http.html#http_http)。接下来,我们有expresscorsbody-parser

Cors 是用于在 Express 应用程序中动态或静态启用跨源资源共享CORS)的中间件 - 它将在我们的开发环境中很有用(我们将在生产服务器上删除它)。

Body-parser 是 HTTP 体解析的中间件。它有一些花哨的设置,可以帮助我们更快地构建应用。

在我们开发的这个阶段,我们的应用看起来是这样的:

图片

Mongoose 和 Express.js

目前,我们有一个简单的 Express.js 服务器。现在我们必须将 Mongoose 添加到我们的项目中:

npm i mongoose@4.4.5 --save

一旦我们安装了 Mongoose 并在后台运行了 MongoDB 数据库,我们就可以将其导入到server.js文件中并进行编码:

import http from 'http'; 
import express from 'express'; 
import cors from 'cors'; 
import bodyParser from 'body-parser'; 
import mongoose from 'mongoose'; 

mongoose.connect('mongodb://localhost/local'); 

const articleSchema = { 
    articleTitle:String, 
    articleContent:String 
}; 

const Article = mongoose.model('Article', articleSchema,  'articles');
const app = express(); 
app.server = http.createServer(app); 

// CORS - 3rd party middleware 
app.use(cors()); 

// This is required by falcor-express middleware to work correctly  
//with falcor-browser 
app.use(bodyParser.json({extended: false})); 

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

app.get('/', (req, res) => {  
    Article.find( (err, articlesDocs) => { 
      const ourArticles = articlesDocs.map((articleItem) => { 
        return &grave;<h2>${articleItem.articleTitle}</h2>            
        ${articleItem.articleCon tent}&grave;; 
      }).join('<br/>'); 

      res.send(&grave;<h1>Publishing App Initial Application!</h1>        
      ${ourArticles}&grave;); 
    }); 
}); 

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

运行项目的总结

确保你使用以下命令在你的机器上后台运行 MongoDB:

mongod

在你的终端(或在 Windows 上的 PowerShell)中运行mongod命令后,你应该在你的控制台中看到以下类似的内容:

图片

在运行服务器之前,请确保你的package.json文件中的devDependencies看起来像以下这样:

"devDependencies": { 
"babel": "6.5.2", 
"babel-core": "6.6.5", 
"babel-loader": "6.2.4", 
"babel-polyfill": "6.6.1", 
"babel-preset-es2015": "6.6.0", 
"babel-preset-react": "6.5.0", 
"babel-preset-stage-0": "6.5.0" 
  }

在运行服务器之前,请确保你的package.json文件中的依赖项看起来像以下这样:

"dependencies": { 
"body-parser": "1.15.0", 
"cors": "2.7.1", 
"express": "4.13.4", 
"mongoose": "4.4.5" 
  }

在主目录下,使用以下命令运行 Node:

node server/index.js 

之后,你的终端应该显示以下类似的内容:

$ node server/index.js
Started on port 3000

图片

Redux 基本概念

在本节中,我们将仅涵盖 Redux 的最基本概念,这将帮助我们制作简单的发布应用。在本章中,应用将只处于只读模式;在本书的后面部分,我们将添加更多功能,例如添加/编辑文章。你将在后面的章节中了解到关于 Redux 的所有重要规则和原则。

涵盖的基本主题包括:

  • 什么是状态树?

  • Redux 中不可变性的工作原理

  • Reducers 的概念和基本用法

让我们从基础知识开始。

单个不可变状态树

Redux 最重要的原则是,你将用单个 JavaScript 对象来表示你应用程序的整个状态。

Redux 中的所有更改(动作)都是显式的,因此你可以通过开发工具跟踪应用程序中的所有动作的历史。

图片

上述截图是一个简单的、示例开发工具使用案例,你将在你的开发环境中使用它。它将帮助你跟踪应用中状态的变化。示例显示了如何通过在状态中三次增加计数器值+1。当然,我们的发布应用结构将比这个例子复杂得多。你将在本书的后面部分了解更多关于这个开发工具的内容。

不可变性 - 动作和状态树是只读的

由于 Redux 基于函数式编程范式,你不能像在 Facebook 的(和其他)FLUX 实现中那样修改/突变你的状态树中的值。

与其他 FLUX 实现一样,一个动作是一个描述变化的普通对象--比如添加一篇文章(在下面的代码中,我们为了简洁起见模拟了有效载荷):

{ 
    type: 'ADD_ARTICLE', 
    payload: '_____HERE_GOES_INFORMATION_ABOUT_THE_CHANGE_____' 
}

一个动作是我们应用状态树变化的最小表示。让我们为我们的发布应用准备动作。

纯函数和不纯函数

一个纯函数是一个没有副作用的功能,例如,例如 I/O(读取文件或 HTTP 请求)。不纯函数有副作用,所以,例如,如果你调用 HTTP 请求,它可以为完全相同的参数Y,Z(函数(X,Y))返回不同的值,因为端点返回给我们一个随机值,或者可能因为服务器错误而宕机。

纯函数对于相同的X,Y参数总是可预测的。在 Redux 中,我们只在 reducer 和动作中使用纯函数(否则 Redux 的lib将无法正常工作)。

在这本书中,你将学习整个结构和在哪里进行 API 调用。所以如果你遵循这本书,那么你就不必太担心 Redux 中的那个原则。

Reducer 函数

Redux 的 reducer 可以与 Facebook 的 Flux 的单个 store 进行比较。重要的是,reducer 始终接受一个先前的状态并返回对新对象的新的引用(使用Object.assign和其他类似方法),这样我们就可以使用不可变 JS 帮助我们构建比旧 Flux 实现更可预测的应用状态。

因此,创建一个新的引用是最优的,因为 Redux 使用旧引用来访问未更改的 reducer 中的值。这意味着即使每个动作通过 reducer 创建了一个全新的对象,那些未更改的值在内存中仍然有之前的引用,所以我们不会过度使用机器的计算能力。一切都很快速。

在我们的应用中,我们将有一个文章 reducer,它将帮助我们从视图层列出、添加、编辑和删除文章。

第一个 reducer 和 webpack 配置

首先,让我们为我们的发布应用创建一个 reducer:

mkdir src 
cd src 
mkdir reducers 
cd reducers 
touch article.js 

因此,我们的第一个 reducer 的位置是src/reducers/article.js,我们的reducers/article.js的内容如下:

const articleMock = { 
'987654': { 
        articleTitle: 'Lorem ipsum - article one', 
        articleContent: 'Here goes the content of the article' 
    }, 
'123456': { 
        articleTitle: 'Lorem ipsum - article two', 
        articleContent: 'Sky is the limit, the content goes here.' 
    } 
}; 

const article = (state = articleMock, action) => { 
    switch (action.type) { 
        case 'RETURN_ALL_ARTICLES': 
            return Object.assign({}, state); 
        default: 
            return state; 
    } 
} 
export default article;

在前面的代码中,我们有articleMock保存在浏览器内存中(它与initData.js中的相同)--稍后,我们将从我们的后端数据库中获取这些数据。

箭头函数const article正在获取action.type,它将来自常数(我们稍后会创建它们),就像 Facebook 的 FLUX 实现一样工作。

对于 switch 语句中的默认 return,我们提供 state = articleMock 的状态(上面的 return state; 部分)。这将返回我们发布应用程序在首次启动时的初始状态,在发生任何其他动作之前。更确切地说,在我们的情况下,默认将与我们开始从后端获取数据之前的 RETURN_ALL_ARTICLES 动作完全相同(在实现后端文章获取机制之后;然后默认将返回一个空对象)。

由于我们的 webpack 配置(在此描述),我们需要在 distindex.html。让我们创建一个 dist/index.html 文件:

pwd 
/Users/przeor/Desktop/React-Convention-Book/src/reducers 
cd ../.. 
mkdir dist 
cd dist 
touch index.html 

dist/index.html 文件的内容如下:

<!doctype html> 
<html lang="en"> 
<head> 
<title>Publishing App</title> 
<meta charset="utf-8"> 

</head> 
<body> 
<div id="publishingAppRoot"></div> 

<script src="img/app.js"></script> 
</body> 
</html>

我们有一个文章 reducerdist/index.html,但在我们开始构建 Redux 的发布应用程序之前,我们需要为我们的构建自动化配置 webpack。

首先安装 webpack(你可能需要 sudo 根权限):

npm i --save-dev webpack@1.12.14 webpack-dev-server@1.14.1 

然后,在 package.jsoninitData.js 文件旁边的主目录中,输入以下内容:

touch webpack.config.js

然后创建 webpack 配置:

module.exports = { 
    entry: ['babel-polyfill', './src/app.js'], 
    output: { 
        path: './dist', 
        filename: 'app.js', 
        publicPath: '/' 
    }, 
    devServer: { 
        inline: true, 
        port: 3000, 
        contentBase: './dist' 
    }, 
    module: { 
        loaders: [ 
            { 
                test: /.js$/, 
                exclude: /(node_modules|bower_components)/, 
                loader: 'babel', 
        query: { 
                    presets: ['es2015', 'stage-0', 'react'] 
                } 
            } 
        ] 
    } 
}

简单来说,webpack 配置说明 CommonJS 模块的入口在 entry './src/app.js'。webpack 会根据 app.js 中的所有导入构建整个应用程序,最终输出位于路径 './dist'。我们位于 contentBase: './dist' 的应用程序将运行在端口 3000。我们还配置了使用 ES2015 和 React,以便 webpack 会将 ES2015 编译成 ES5,并将 React 的 JSX 编译成 JavaScript。如果你对 webpack 的配置选项感兴趣,那么请阅读其文档。

其余的重要依赖安装和 npm 开发脚本

安装 webpack 所使用的 Babel 工具(检查配置文件):

npm i --save react@0.14.7 react-dom@0.14.7 react-redux@4.4.0 redux@3.3.1

我们还需要更新我们的 package.json 文件(添加 scripts):

"scripts": { 
"dev": "webpack-dev-server" 
  },

我们完整的 package.json 应该如下所示,包含所有前端依赖:

01{ 
"name": "project", 
"version": "1.0.0", 
"description": "", 
"scripts": { 
"dev": "webpack-dev-server" 
  }, 
"dependencies": { 
"body-parser": "1.15.0", 
"cors": "2.7.1", 
"express": "4.13.4", 
"mongoose": "4.4.5", 
"react": "0.14.7", 
"react-dom": "0.14.7", 
"react-redux": "4.4.0", 
"redux": "3.3.1" 
  }, 
"devDependencies": { 
"babel": "6.5.2", 
"babel-core": "6.6.5", 
"babel-loader": "6.2.4", 
"babel-polyfill": "6.6.1", 
"babel-preset-es2015": "6.6.0", 
"babel-preset-react": "6.5.0", 
"babel-preset-stage-0": "6.5.0", 
"webpack": "1.12.14", 
"webpack-dev-server": "1.14.1" 
  } 
}

如你所意识到的那样,提到的 package.json 中没有我们想要的 ^ 符号,因为我们想使用每个包的确切版本,以确保所有我们的包都安装了在 package 中给出的正确且确切的版本。否则,你可能会有一些困难,例如,如果你添加 "mongoose": "4.4.5",带有 ^ 符号,那么它将安装一个较新的版本,这会在控制台产生一些额外的警告。让我们坚持书中提到的版本,以避免我们正在构建的应用程序出现不必要的麻烦。我们无论如何都要避免 NPM 依赖地狱。

在处理 src/app.js 和 src/layouts/PublishingApp.js

让我们创建我们的 app.js 文件,我们应用程序的主要部分将位于 src/app.js

//[[your are in the main directory of the project]] cd src
touch app.js

我们新的 src/app.js 文件的内容如下:

import React from 'react'; 
import { render } from 'react-dom'; 
import { Provider } from 'react-redux'; 
import { createStore } from 'redux'; 
import article from './reducers/article'; 
import PublishingApp from './layouts/PublishingApp'; 

const store = createStore(article); 

render( 
<Provider store={store}> 
<PublishingApp /> 
</Provider>, 
    document.getElementById('publishingAppRoot') 
);

新的部分是 store = createStore(article) 部分--这个来自 Redux 的实用工具让你可以保持一个应用程序状态对象,分发一个动作,并允许你提供一个作为参数的 reducer,它告诉你应用程序如何通过动作更新。

react-redux 是将 Redux 绑定到 React 中的一个有用的绑定(因此我们将编写更少的代码,并更有效率):

<Provider store>

Provider store 帮助我们将 Redux store 传递给子组件中的 connect() 调用(如下所示):

connect([mapStateToProps], [mapDispatchToProps], [mergeProps], [options])

connect 将用于任何必须监听我们应用程序中 reducer 变化的组件。你将在本章后面看到如何使用它。

对于存储,我们使用 const store = createStore(article)--仅为了简洁,我将提到在构建我们应用程序的下一个步骤中,我们将使用存储中的几个方法:

store.getState();

getState 函数会给你当前应用程序的状态:

store.dispatch({ type: 'RETURN_ALL_ARTICLES' });

dispatch 函数可以帮助你更改应用程序的状态:

store.subscribe(() => { 

});

订阅允许你注册一个回调,每当分发了一个动作时,Redux 都会调用它,这样视图层就可以了解应用程序状态的变化并刷新其视图。

总结 React-Redux 应用程序

让我们完成我们的第一个 React-Redux 应用程序。为了总结,让我们看看我们当前的目录结构:

&boxvr;&boxh;&boxh; dist 
&boxv;   &boxur;&boxh;&boxh; index.html 
&boxvr;&boxh;&boxh; initData.js 
&boxvr;&boxh;&boxh; node_modules 
&boxv;   &boxvr;&boxh;&boxh; ********** (A LOT OF LIBRARIES HERE) 
&boxvr;&boxh;&boxh; package.json 
&boxvr;&boxh;&boxh; server 
&boxv;   &boxvr;&boxh;&boxh; index.js 
&boxv;   &boxur;&boxh;&boxh; server.js 
&boxvr;&boxh;&boxh; src 
&boxv;   &boxvr;&boxh;&boxh; app.js 
&boxv;   &boxur;&boxh;&boxh; reducers 
&boxv;       &boxur;&boxh;&boxh; article.js 
&boxur;&boxh;&boxh; webpack.config.js

现在我们需要创建我们应用程序的主视图。在我们的第一个版本中,我们将将其放入布局目录:

pwd
/Users/przeor/Desktop/React-Convention-Book/src
mkdir layouts
cd layouts
touch PublishingApp.js

PublishingApp.js 的内容是:

import React from 'react'; 
import { connect } from 'react-redux'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

const mapDispatchToProps = (dispatch) => ({ 
}); 

class PublishingApp extends React.Component { 
  constructor(props) { 
    super(props); 
  } 
  render () { 
    console.log(this.props);     
    return ( 
<div> 
          Our publishing app 
</div> 
    ); 
  } 
} 
export default connect(mapStateToProps, mapDispatchToProps)(PublishingApp);

前面介绍了 ... 旁边的 ES7 语法 ...

const mapStateToProps = (state) => ({ 
  ...state 
});

... 是一个扩展操作符,在 Mozilla 的文档中有很好的描述,作为;一个表达式,在需要多个参数(函数调用)或多个元素(数组字面量)的地方进行扩展。在我们的案例中,这个 ... 操作符将一个对象状态扩展到第二个(在我们的案例中,空对象 { })。它在这里是这样写的,因为,在未来,我们将指定多个必须从我们的应用程序状态映射到 this.props 组件的 reducer。

完成我们的第一个静态发布应用程序

在我们的静态应用程序中最后要做的就是在 this.props 中渲染文章。

多亏了 Redux,reducer 中模拟的对象现在是可用的,所以如果你在 PublishingApp.js 的渲染函数中检查 console.log(this.props),那么你将能够访问我们的 articles 对象:

const articleMock = { 
'987654': { 
        articleTitle: 'Lorem ipsum - article one', 
        articleContent: 'Here goes the content of the article' 
    }, 
"123456": { 
        articleTitle: 'Lorem ipsum - article two', 
        articleContent: 'Sky is the limit, the content goes here.' 
    } 
};

在我们的案例中,我们需要更改 React 的渲染函数,如下所示(在 src/layouts/PublishingApp.js):

 render () { 
    let articlesJSX = []; 

    for(let articleKey in this.props) { 
        const articleDetails = this.props[articleKey]; 
        const currentArticleJSX = ( 
          <div key={articleKey}> 
          <h2>{articleDetails.articleTitle}</h2> 
          <h3>{articleDetails.articleContent}</h3> 
          </div>); 

        articlesJSX.push(currentArticleJSX); 
    } 

    return ( 
      <div> 
      <h1>Our publishing app</h1> 
          {articlesJSX} 
      </div> 
    ); 
  }

for(let articleKey in this.props) over the article Mock object (passed from the reducer's state in this.props) and creating an array of articles (in JSX) with articlesJSX.push(currentArticleJSX);. After it is created, then we will have added the articlesJSX into the return statement:
<div> 
<h1>Our publishing app</h1> 
          {articlesJSX} 
</div>

这个注释将在端口 3000 上启动你的项目:

npm run dev

在你检查 localhost:3000 之后,新的静态 Redux 应用程序应该看起来如下所示:

太好了,我们已经在 Redux 中创建了一个静态应用!现在是时候使用 Falcor 从我们的 MongoDB 数据库中获取数据了。

Falcor 的基本概念

Falcor 就像是一种粘合剂,介于:

  • 后端及其数据库结构(记住将 initData.js 导入 MongoDB)

  • 前端 Redux 单个状态树容器

它以一种比为单页应用程序构建老式的 REST API 更有效的方式将各个部分粘合在一起。

就像“Redux 基本概念”部分一样,在这一部分中,我们将只学习 Falcor 的最基本概念,它们将帮助我们以只读模式构建一个简单的全栈应用。在本书的后面部分,您将学习如何使用 Falcor 添加/编辑文章。

我们将关注最重要的方面:

  • Falcor 的模型是什么?

  • 从 Falcor(前端和后端)检索值

  • JSON 图的概念和基本用法

  • 代理概念和基本用法

  • 如何从后端检索数据

  • 如何使用 Express.js 的中间件配置我们的第一个路由

    falcor-router

什么是 Falcor 以及为什么我们需要在我们的全栈发布应用中使用它?

让我们首先考虑网页和 Web 应用之间的区别:

  • 万维网WWW)被发明时,网页服务于少量的大型资源(如 HTML、PDF 和 PNG 文件)。例如,您可以从服务器请求 PDF、视频或文本文件。

  • 自从大约 2008 年以来,Web 应用的开发变得越来越流行。Web 应用服务于大量的小型资源。这对我们意味着什么?您需要使用 AJAX 调用向服务器发送大量的小型 REST API 请求。旧的许多 API 请求方法会导致延迟,从而减慢移动/Web 应用的运行速度。

为什么我们在 2016 年及以后的 APP 中使用旧的 REST API 请求(就像我们在 2005 年做的那样)?这正是 Falcor 大放异彩的地方;它解决了后端与前端之间延迟和紧密耦合的问题。

紧密耦合和延迟与无处不在的单个模型

如果您熟悉前端开发,您知道如何向 API 发起请求。这种旧的方法总是迫使您将后端 API 与前端 API 工具紧密耦合。它总是这样:

  1. 您创建一个 API 端点,例如applicationDomain.com/api/recordDetails?id=92

  2. 您在前端使用 HTTP API 请求来获取数据:

        { 
            id: '92', 
            title: 'example title', 
            content: 'example content' 
        }

在大型应用中,维护真正的 DRY RESTful API 很困难,这个问题导致了很多未优化的端点,因此前端有时不得不进行多次往返才能获取特定视图所需的数据(有时获取的数据远多于所需,这甚至给应用最终用户带来了更多的延迟)。

想象一下,您有一个拥有超过 50 个不同 API 端点的庞大应用。当您的应用的第一版完成后,您的客户或老板发现了一种更好的方法来结构化应用中的用户流程。这意味着什么?这意味着您必须修改前端和后端端点以满足用户界面层的更改。这被称为前端和后端之间的紧密耦合。

Falcor 为这种情况带来了什么,以改善导致使用 RESTful API 效率低下的两个领域?答案是无处不在的单个模型。

如果所有数据都在客户端的内存中可访问,构建您的 Web 应用程序将会非常简单。

Falcor 提供了帮助您感觉所有数据都在指尖的实用工具,而无需编写后端 API 端点和客户端消费工具。

客户端和服务器端不再紧密耦合。

Falcor 帮助您在服务器上表示所有应用程序的数据作为一个虚拟的 JSON 模型。

当进行客户端编程时,Falcor 让您感觉整个应用程序的 JSON 模型都可以在本地访问,并允许您以与从内存中的 JSON 相同的方式读取数据——您很快就会学会这一点!

由于 Falcor 浏览器库和falcor-express中间件,您可以从云端的模型中按需检索数据。

Falcor 透明地处理所有网络通信,并保持您的客户端应用程序与服务器和数据库同步。

在本章中,我们还将学习如何使用falcor-router

客户端 Falcor

让我们先从 NPM 安装 Falcor:

pwd 
/Users/przeor/Desktop/React-Convention-Book 
npm i --save falcor@0.1\. 
16 falcor-http-datasource@0.1.3 

falcor-http-datasource帮助我们从服务器检索数据到客户端,开箱即用(无需担心 HTTP API 请求)——我们将在将客户端模型移动到后端时使用它。

让我们在客户端创建我们的应用程序 Falcor 模型:

cd src
touch falcorModel.js

然后falcorModel.js的内容将如下所示:

import falcor from 'falcor';  
import FalcorDataSource from 'falcor-http-datasource'; 

let cache = { 
  articles: [ 
    { 
        id: 987654, 
        articleTitle: 'Lorem ipsum - article one', 
        articleContent: 'Here goes the content of the article' 
    }, 
    { 
        id: 123456, 
        articleTitle: 'Lorem ipsum - article two from backend', 
        articleContent: 'Sky is the limit, the content goes here.' 
    } 
  ] 
}; 

const model = new falcor.Model({ 
'cache': cache 
}); 
export default model;

在此代码中,您可以找到一个包含两篇文章的我们发布应用的知名、简洁且易于阅读的模型。

现在,我们将从我们的src/layouts/PublishingApp.js React 组件的前端 Falcor 模型中检索数据,我们将添加一个名为_fetch()的新函数,该函数将负责在应用程序启动时检索所有文章。

我们需要首先导入我们的 Falcor 模型,因此需要在PublishingApp.js文件的顶部添加以下内容:

import falcorModel from '../falcorModel.js';

在我们的PublishingApp类中,我们需要添加以下两个函数;componentWillMount_fetch(更多解释随后):

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

  componentWillMount() { 
    this._fetch(); 
  } 

  async _fetch() { 
    const articlesLength = await falcorModel. 
      getValue('articles.length'). 
      then((length) => length ); 

    const articles = await falcorModel. 
      get(['articles', {from: 0, to: articlesLength-1},  
      ['id','articleTitle', 'articleContent']])  
      .then((articlesResponse) => articlesResponse.json.articles); 
  } 
  // below here are next methods o the PublishingApp

这里,您可以看到名为_fetch的异步函数。这是一种特殊语法,允许您像使用let articlesLength = await falcorModellet articles = await falcorModel一样使用await关键字。

使用async await代替 Promise 意味着我们的代码更易于阅读,并避免了回调地狱的情况,其中多个回调嵌套在一起使得代码难以阅读和扩展。

async/await特性是从受 C#启发的 ECMAScript 7 中提取的。它允许您编写在等待异步操作结果之前在每个异步操作上看似阻塞的函数,然后继续执行下一个操作。

在我们的例子中,代码将按以下方式执行:

  1. 首先它将调用 Falcor 的模型以获取文章数量,如下所示:
        const articlesLength = await falcorModel. 
          getValue('articles.length'). 
          then( (length) =>  length );

  1. 在文章的Length变量中,我们将从我们的模型中获得articles.length的计数(在我们的例子中将是数字二)。

  2. 在我们知道我们的模型中有两篇文章之后,接下来的代码块执行以下操作:

        let articles = await falcorModel. 
          get(['articles', {from: 0, to: articlesLength-1},
          ['id','articleTitle', 'articleContent']]).  
          then( (articlesResponse) => articlesResponse.json.articles);

falcorModel.get(['articles', {from: 0, to: articlesLength-1}, ['id','articleTitle', 'articleContent']]). 上的 get 方法也是一个异步操作(与 http 请求 类似)。在 get 方法的参数中,我们提供了我们模型中文章的位置(在 src/falcorModel.js 中),因此我们提供了以下路径:

falcorModel.get( 
['articles', {from: 0, to: articlesLength-1}, ['id','articleTitle', 'articleContent']] 
)

上述 Falcor 路径的解释基于我们的模型。让我们再次调用它:

{ 
  articles: [ 
    { 
        id: 987654, 
        articleTitle: 'Lorem ipsum - article one', 
        articleContent: 'Here goes the content of the article' 
    }, 
    { 
        id: 123456, 
        articleTitle: 'Lorem ipsum - article two from backend', 
        articleContent: 'Sky is the limit, the content goes here.' 
    } 
  ] 
}

我们对 Falcor 所说的:

  1. 首先,我们想要使用以下方式从我们的对象中的articles获取数据:
        ['articles']

  1. 接下来,从 articles 集合中选择所有文章的子集,范围是 {from: 0, to: articlesLength-1}(我们之前获取的 articlesLength),路径如下:
        ['articles', {from: 0, to: articlesLength-1}]

  1. 最后一步是向 Falcor 解释,你想要从对象中获取哪些属性。所以 falcorModel.get 查询中的完整路径如下:
        ['articles', {from: 0, to: articlesLength-1},   
        ['id','articleTitle', 'articleContent']]

  1. ['id','articleTitle', 'articleContent'] 数组表示你想要从每篇文章中获取这三个属性。

  2. 最后,我们从 Falcor 接收一个文章对象的数组:

图片

在我们从 Falcor 模型获取数据之后,我们需要分发一个动作,该动作将相应地更改文章的 reducer,并最终从 const articleMock(在 src/reducers/article.js 中)重新渲染我们的文章列表。

但在我们能够分发一个动作之前,我们需要做以下事情:

创建 actions 目录并包含 article.js

pwd 
$ /Users/przeor/Desktop/React-Convention-Book 
cd src 
mkdir actions 
cd actions 
touch article.js 

按照以下方式为我们的 src/actions/article.js 文件创建内容:

export default { 
  articlesList: (response) => { 
    return { 
      type: 'ARTICLES_LIST_ADD', 
      payload: { response: response } 
    } 
  } 
}

在那个 actions/article.js 文件中并没有太多内容 .。如果你已经熟悉 FLUX,那么它非常相似。对于 Redux 中的动作,一个重要的规则是它必须是一个纯函数。现在,我们将一个名为 ARTICLES_LIST_ADD 的常量硬编码到 actions/article.js 中。

src/layouts/PublishingApp.js 文件中,我们需要在文件顶部添加一个新的导入代码:

import {bindActionCreators} from 'redux'; 
import articleActions from '../actions/article.js';

当你在 PublishingApp 中添加了上述两个文件后,然后修改同一文件中现有的函数如下:

const mapDispatchToProps = (dispatch) => ({ 
});

添加 articleActions: bindActionCreators(articleActions, dispatch) 以便我们能够将文章的动作绑定到我们的 this.props 组件:

const mapDispatchToProps = (dispatch) => ({ 
  articleActions: bindActionCreators(articleActions, dispatch) 
});

多亏了在组件中提到的这些更改 (articleActions: bindActionCreators(articleActions, dispatch)),我们现在将能够从 props 中分发一个动作,因为现在,当你使用 this.props.articleActions.articlesList(articles) 时,从 Falcor 获取的 articles 对象将可用于我们的 reducer(并且从那里,只需一步就可以让我们的应用程序获取数据工作)。

现在,在你完成这些更改之后,将一个动作添加到我们的组件的 _fetch 函数中:

this.props.articleActions.articlesList(articles);

我们整个获取函数看起来如下:

 async _fetch() { 
    const articlesLength = await falcorModel. 
      getValue('articles.length'). 
      then( (length) => length); 

    let articles = await falcorModel. 
      get(['articles', {from: 0, to: articlesLength-1},  
      ['id','articleTitle', 'articleContent']]).  
      then( (articlesResponse) => articlesResponse.json.articles); 

    this.props.articleActions.articlesList(articles); 
  }

同时,别忘了从 ComponentWillMount 中调用 _fetch

 componentWillMount() { 
    this._fetch(); 
  }

到这一点,我们应当能够接收到 Redux 的 reducer 中的动作。让我们改进我们的 src/reducers/article.js 文件:

const article = (state = {}, action) => { 
    switch (action.type) { 
        case 'RETURN_ALL_ARTICLES': 
            return Object.assign({}, state); 
        case 'ARTICLES_LIST_ADD': 
            return Object.assign({}, action.payload.response); 
        default: 
            return state; 
    } 
} 
export default article

如你所见,我们不再需要articleMock,所以我们已经从src/reducers/article.js中删除了它。

我们添加了一个新的情况,ARTICLES_LIST_ADD

   case 'ARTICLES_LIST_ADD': 
        let articlesList = action.payload.response; 
        return Object.assign({}, articlesList);

它返回一个新的articlesList对象(由于Object.assign,在内存中有新的引用)。

不要混淆两个文件,它们具有相同的名称和其他位置,例如:

reducers/article.js

actions/article.js

你需要确保你正在编辑正确的文件,否则应用程序将无法工作。

客户端 Falcor + Redux 的概述

如果你运行http://localhost:3000/index.html,你会看到,目前我们有两个独立的应用程序:

  • 一个在前端使用 Redux 和客户端 Falcor

  • 一个在 MongoDB、Mongoose 和 Express 使用的后端

我们需要将它们放在一起,以便我们有应用程序的一个状态源(来自 MongoDB)。

将 Falcor 模型移到后端

我们还需要更新我们的package.json文件:

"scripts": { 
  "dev": "webpack-dev-server", 
  "start": "npm run webpack; node server", 
  "webpack": "webpack --config ./webpack.config.js" 
},

因为我们要开始全栈开发部分,所以需要在package.json中的脚本中添加npm start--这将帮助编译客户端代码,将它们放入dist文件夹(通过 webpack 生成),并在dist中创建静态文件,然后使用此文件夹作为静态文件的源(检查server/server.js中的app.use(express.static('dist'));)。

下一个重要的事情是在后端安装 Falcor 所需的新依赖项:

npm i --save falcor-express@0.1.2 falcor-router@0.2.12

当你最终安装了新依赖项并配置了在相同端口上运行后端和前端的基本脚本后,然后按照以下方式编辑server/server.js

  1. 在我们的文件顶部,导入新的库到server/server.js
        import falcor from 'falcor'; 
        import falcorExpress from 'falcor-express';

  1. 然后在以下两个之间:
        app.use(bodyParser.json({extended: false})); 
        app.use(express.static('dist'));

  1. 在后端添加管理 Falcor 的新代码:
        app.use(bodyParser.json({extended: false})); 

        let cache = { 
          articles: [ 
            { 
                id: 987654, 
                articleTitle: 'Lorem ipsum - article one', 
                articleContent: 'Here goes the content of the article' 
            }, 
            { 
                id: 123456, 
                articleTitle: 'Lorem ipsum - article two from          
                backend', 
                articleContent: 'Sky is the limit, the content goes          
                here.' 
            } 
          ] 
        }; 

        var model = new falcor.Model({ 
          cache: cache 
        }); 

        app.use('/model.json', falcorExpress.dataSourceRoute((req,               
        res) => { 
            return model.asDataSource(); 
        })); 
        app.use(express.static('dist'));

  1. 上述代码几乎与src/falcorModel.js文件中的代码相同。唯一的区别是现在 Falcor 将从后端的模拟对象中获取数据,在server.js中称为cache

  2. 第二部分是在前端更改我们的数据源,因此需要在src/falcorModel.js文件中更改以下旧代码:

        import falcor from 'falcor'; 
        import FalcorDataSource from 'falcor-http-datasource'; 

        let cache = { 
          articles: [ 
          { 
            id: 987654, 
            articleTitle: 'Lorem ipsum - article one', 
            articleContent: 'Here goes the content of the article' 
          }, 
          { 
            id: 123456, 
            articleTitle: 'Lorem ipsum - article two from backend', 
            articleContent: 'Sky is the limit, the content goes here.' 
          } 
         ] 
        }; 

        const model = new falcor.Model({ 
        'cache': cache 
        }); 

        export default model;

  1. 更改为以下更新后的代码:
        import falcor from 'falcor'; 
        import FalcorDataSource from 'falcor-http-datasource'; 

        const model = new falcor.Model({ 
          source: new FalcorDataSource('/model.json') 
        }); 

        export default model;

  1. 使用以下命令运行你的应用程序:
 npm start

  1. 你将在浏览器开发工具中看到 Falcor 发起的新 HTTP 请求--例如,在我们的案例中:

图片

如果你正确地遵循所有指示,那么你也可以通过执行以下操作直接从你的浏览器向你的服务器发送请求:

http://localhost:3000/model.json?paths=[["articles", {"from":0,"to":1},   
["articleContent","articleTitle","id"]]]&method=get.

然后你将看到响应中的jsonGraph

图片

你不必担心前面的两个截图。它们只是 Falcor 在 Falcor 语言中如何在后端和前端之间通信的一个示例。你不必再担心暴露 API 端点和编程前端来理解后端提供的数据了。Falcor 会自动完成所有这些,你将在制作此发布应用程序的过程中了解更多细节。

配置 Falcor 的路由(Express.js)

目前,后端上的我们的模型是硬编码的,所以它保持在服务器的 RAM 内存中。我们需要添加从我们的 MongoDB 的文章集合中读取数据的能力--这就是 falcor-router 发挥作用的地方。

我们需要创建我们的路由定义文件,这些文件将被 falcor-router 库消费:

$ pwd 
/Users/przeor/Desktop/React-Convention-Book 
$ cd server 
$ touch routes.js 

我们已经创建了 server/routes.js 文件;该路由的内容如下:

const PublishingAppRoutes = [{ 
  route: 'articles.length', 
  get: () => { 
    const articlesCountInDB = 2; // hardcoded for example 
    return { 
      path: ['articles', 'length'], 
      value: articlesCountInDB 
    }; 
  } 
}]; 
export default PublishingAppRoutes;

如你所见,我们已经创建了一条路由,它将匹配 _fetch 函数(在 layouts/PublishingApp.js 中)的 articles.length

我们在 articlesCountInDB 中硬编码了数字二,稍后我们将在这里进行数据库查询。

这里新的是 route: 'articles.length',这是一个简单的路由,用于通过 Falcor 匹配。

更准确地说,Falcor 路由的路径与你提供的完全相同的东西,例如在 src/layouts/PublishingApp.js (_fetch 函数) 中,用于匹配这个前端调用:

 // location of that code snippet: src/layouts/PublishingApp.js 
 const articlesLength = await falcorModel. 
    getValue('articles.length'). 
    then((length) => length);

  • path: ['articles', 'length']:这个属性告诉我们 Falcor 的路径(它在后端和前端被 Falcor 消费)。我们需要提供这个,因为有时,一个路由可以返回许多不同的对象作为服务器文章(你将在我们创建的下一条路由中看到)。

  • value: articlesCountInDB:这是一个返回值。在这种情况下,它是一个整数,但它也可以是一个具有多个属性的对象,正如你稍后将要了解到的。

第二条路由用于从后端返回我们的两个文章

我们的第二条路由(也是本章的最后一个)将是以下内容:

{ 
  route: 'articles[{integers}]["id","articleTitle","articleContent"]', 
  get: (pathSet) => { 
    const articlesIndex = pathSet[1]; 
    const articlesArrayFromDB = [{ 
    'articleId': '987654', 
    'articleTitle': 'BACKEND Lorem ipsum - article one', 
    'articleContent': 'BACKEND Here goes the content of the article' 
    }, { 
    'articleId': '123456', 
    'articleTitle': 'BACKEND Lorem ipsum - article two', 
    'articleContent': 'BACKEND Sky is the limit, the content goes here.' 
    }]; // That are our mocked articles from MongoDB 

    let results = []; 
    articlesIndex.forEach((index) => { 
      const singleArticleObject = articlesArrayFromDB[index]; 
      const falcorSingleArticleResult = { 
        path: ['articles', index], 
        value: singleArticleObject 
      }; 
      results.push(falcorSingleArticleResult); 
    }); 

    return results; 
  } 
}

第二条路由中的新内容是 pathSet,如果你将其输出到控制台,那么你将看到,在我们的情况下(当我们尝试运行我们的全栈应用时),以下内容:

[  
'articles', 
  [ 0, 1 ], 
  [ 'articleContent', 'articleTitle', 'id' ]  
]

pathSet 告诉我们客户端请求了哪些索引(在我们的例子中是 [ 0, 1 ])。

因为,在这种情况下,我们正在返回一个文章数组(多个文章),我们需要创建一个结果变量:

let results = [];

遍历请求的索引:

articlesIndex.forEach((index) => { 
   const singleArticleObject = articlesArrayFromDB[index]; 
   const falcorSingleArticleResult = { 
     path: ['articles', index], 
     value: singleArticleObject 
   }; 
   results.push(falcorSingleArticleResult); 
 });

{from: 0, to: articlesLength-1} in PublishingApp.js?). Based on the indexes ([0, 1]) we fetch mocked data via const singleArticleObject = articlesArrayFromDB[index];. Later we put into the path and index (path: ['articles', index],) so Falcor knows to what path in our JSON graph object, the value singleArticleObject belongs to.

返回该文章数组:

console.info(results) 
 return results;

console.info 将显示该路径返回的内容:

[{ 
  path: ['articles', 0], 
  value: { 
    articleId: '987654', 
    articleTitle: 'BACKEND Lorem ipsum - article one', 
    articleContent: 'BACKEND Here goes the content of the article' 
  } 
}, { 
  path: ['articles', 1], 
  value: { 
    articleId: '123456', 
    articleTitle: 'BACKEND Lorem ipsum - article two', 
    articleContent: 'BACKEND Sky is the limit, the content goes here.' 
  } 
}]

最后的润色,使全栈 Falcor 运行

目前,我们还在路由中使用模拟数据,但在我们开始调用 MongoDB 之前,我们需要整理当前设置,这样你就可以在你的浏览器中看到它运行。

打开你的 server/server.js 并确保你导入以下两个内容:

import falcorRouter from 'falcor-router'; 
import routes from './routes.js';

现在我们已经导入了我们的 falcor-routerroutes.js--我们需要使用它们,所以修改以下旧代码:

// This is old code, remove it and replace with new 
app.use('/model.json', falcorExpress.dataSourceRoute((req, res) =>  { 
  return model.asDataSource(); 
}));

将前面的代码替换为:

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

这只会在 falcor-router 已经在 server.js 文件中安装并导入时工作。这是一个 DataSource 库,它在你应用服务器上创建一个虚拟的 JSON 图文档。正如你在 server.js 中迄今为止所看到的,我们有一个由我们硬编码的模型提供的 DataSourcereturn model.asDataSource();。这里的路由器将做同样的事情,但现在你将能够根据你的应用需求匹配路由。

此外,正如你所看到的,新的 falcorRouter 接受我们的路由参数 return new falcorRouter(routes);

如果你正确地遵循了指示,你将能够运行项目:

npm start

在端口 3000 上,你会看到以下内容:

根据 Falcor 的路由添加 MongoDB/Mongoose 调用

让我们回到我们的 server/routes.js 文件。我们需要移动(从 server.js 删除并移动到 routes.js)以下代码:

// this goes to server/routes.js 
import mongoose from 'mongoose'; 

mongoose.connect('mongodb://localhost/local'); 

const articleSchema = { 
  articleTitle:String, 
  articleContent:String 
}; 
const Article = mongoose.model('Article', articleSchema, 'articles');

在第一个路由 articles.length 中,你需要将模拟的数字二(文章数量)替换为 Mongoose 的 count 方法:

 route: 'articles.length', 
    get: () => { 
    return Article.count({}, (err, count) => count) 
    .then ((articlesCountInDB) => { 
      return { 
        path: ['articles', 'length'], 
        value: articlesCountInDB 
      } 
    }) 
  }

我们在 get 中返回一个 Promise(由于 Mongoose 的异步性质,在执行任何数据库请求时,它总是返回一个 Promise,如示例中的 Article.count)。

方法 Article.count 简单地从我们的 Article 模型(本书开头在 MongoDB/Mongoose 子章节 中准备)中检索文章数量的整数。

第二个路由 route: 'articles[{integers}]["id","articleTitle","articleContent"]' 需要按照以下方式更改:

{ 
  route: 'articles[{integers}]["id","articleTitle","articleContent"]', 
  get: (pathSet) => { 
    const articlesIndex = pathSet[1]; 

    return Article.find({}, (err, articlesDocs) => articlesDocs) 
    .then ((articlesArrayFromDB) => { 
      let results = []; 
      articlesIndex.forEach((index) => { 
        const singleArticleObject =          
        articlesArrayFromDB[index].toObject(); 
        const falcorSingleArticleResult = { 
          path: ['articles', index], 
          value: singleArticleObject 
        }; 
        results.push(falcorSingleArticleResult); 
      }); 
      return results; 
    }) 
  } 
}

我们再次使用 Article.find 返回一个 Promise。同时,我们已经从数据库中删除了模拟的响应,而是使用 Article.find 方法。

文章数组通过 }).then ((articlesArrayFromDB) => { 返回,接下来我们只需迭代并创建一个结果数组。

注意,在 const singleArticleObject = articlesArrayFromDB[index].toObject(); 我们使用了 .toObject 方法。这对于使这个工作非常重要。

与 server/routes.js 和 package.json 进行双重检查

为了在应用没有运行的情况下节省你的时间,我们可以再次检查后端的 Falcor 路由是否正确准备:

import mongoose from 'mongoose'; 

mongoose.connect('mongodb://localhost/local'); 

const articleSchema = { 
  articleTitle:String, 
  articleContent:String 
}; 

const Article = mongoose.model('Article', articleSchema, 'articles'); 

const PublishingAppRoutes = [ 
  { 
    route: 'articles.length', 
      get: () =>  Article.count({}, (err, count) => count) 
        .then ((articlesCountInDB) => { 
          return { 
            path: ['articles', 'length'], 
            value: articlesCountInDB 
          }; 
      }) 
  }, 
  { 
    route: 'articles[{integers}]  
    ["id","articleTitle","articleContent"]', 
    get: (pathSet) => { 
      const articlesIndex = pathSet[1]; 

      return Article.find({}, (err, articlesDocs) =>         
      articlesDocs); 
       .then ((articlesArrayFromDB) => { 
          let results = []; 

          articlesIndex.forEach((index) => { 
            const singleArticleObject =              
            articlesArrayFromDB[index].toObject(); 
            const falcorSingleArticleResult = { 
              path: ['articles', index], 
              value: singleArticleObject 
            }; 

            results.push(falcorSingleArticleResult); 
          }); 

          return results; 
        }) 
      } 
  } 
]; 

export default PublishingAppRoutes;

确保你的 server/routes.js 文件看起来与前面的代码以及你使用的其他代码元素相似。

此外,检查你的 package.json 看起来如下所示:

{ 
"name": "project", 
"version": "1.0.0", 
"scripts": { 
"dev": "webpack-dev-server", 
"start": "npm run webpack; node server", 
"webpack": "webpack --config ./webpack.config.js" 
  }, 
"dependencies": { 
"body-parser": "¹.15.0", 
"cors": "².7.1", 
"express": "⁴.13.4", 
"falcor": "⁰.1.16", 
"falcor-express": "⁰.1.2", 
"falcor-http-datasource": "⁰.1.3", 
"falcor-router": "0.2.12", 
"mongoose": "4.4.5", 
"react": "⁰.14.7", 
"react-dom": "⁰.14.7", 
"react-redux": "⁴.4.0", 
"redux": "³.3.1" 
  }, 
"devDependencies": { 
"babel": "⁶.5.2", 
"babel-core": "⁶.6.5", 
"babel-loader": "⁶.2.4", 
"babel-polyfill": "⁶.6.1", 
"babel-preset-es2015": "⁶.6.0", 
"babel-preset-react": "⁶.5.0", 
"babel-preset-stage-0": "⁶.5.0", 
"webpack": "¹.12.14", 
"webpack-dev-server": "¹.14.1" 
  } 
}

关于 package.json 的重要事项是,我们已经从 "mongoose": "4.4.5" 中移除了 ^。我们这样做是因为如果 NPM 安装了高于 4.4.5 的任何版本,那么我们会在 bash/命令行中得到一个警告。

我们的第一个工作全栈应用

之后,你应该有一个完整的全栈版本的应用程序正在运行:

几乎在每一步,我们应用的 UI 部分都是相同的。前面的截图是发布应用,它执行以下操作:

  1. 使用 Falcor-ExpressFalcor-Router 从数据库获取数据。

  2. 数据从后端(源为 MongoDB)移动到前端。我们填充 Redux 的 src/reducers/article.js 状态树。

  3. 我们根据我们的单个状态树渲染 DOM 元素。

  4. 所有这些步骤使我们能够将全栈应用的所有数据从数据库传输到用户的浏览器(因此用户可以看到文章)。

摘要

我们还没有开始设计应用界面,但在我们的书中,我们将使用为 React 设计的 Material Design CSS(material-ui.com)。在下一章中,我们将开始使用它来处理用户注册和登录。之后,我们将使用 Material Design 的组件重新设计我们应用的主页。

为了在你阅读本书的过程中给你一个目标预览,这里有一张应用截图以及接下来章节中出版应用将如何改进:

图片

在前面的截图中有我们应用的一个示例文章。我们正在使用几个 Material Design 组件来简化我们的工作并使出版应用看起来更加专业。你将在稍后学习到这些内容。

你准备好在下一章中为我们的出版应用全栈登录和注册功能工作了么?让我们继续享受乐趣。

第二章:我们发布应用程序的全栈登录和注册

JSON Web TokenJWT)是一种安全令牌格式,相对较新,但效果很好。它是一个开放标准(RFC 7519),在处理在 Web 应用程序环境中传递声明的问题时,它改进了 OAuth2 和 OpenID 连接。

实际上,流程如下:

  • 服务器分配一个编码的 JSON 对象

  • 在客户端被提醒后,它将编码的令牌与每个请求一起发送到服务器

  • 根据该令牌,服务器知道谁在发送请求

在开始使用它之前,值得访问jwt.io/网站并尝试一下:

登录成功后,JWT 解决方案为我们前端应用程序提供一个对象,告诉我们当前用户的授权情况:

{'iss': 'PublishginAppIssuer','name': 'John Doe','admin':true}

iss是一个发行者属性——在我们的案例中,它将是我们的发布应用程序的后端应用程序。已登录用户的名称很明显——John Doe已成功登录。admin属性只是说明一个已识别的用户(使用正确的登录名和密码登录到我们的后端应用程序)是一个管理员('admin': true标志)。您将在本章中学习如何使用它。

除了前面例子中提到的内容外,JWT 的响应还包含有关主题/声明的信息、生成的签名 SHA256 令牌和过期日期。这里的重要规则是您必须确信您的令牌发行者。您需要信任随响应提供的内 容。这可能听起来很复杂,但在实际应用中非常简单。

重要的是,您需要保护由 JWT 生成的令牌——这一点将在本章后面详细说明。

流程如下:

  1. 我们的客户端发布应用程序从我们的 express 服务器请求令牌。

  2. 发布后端应用程序向前端 Redux 应用程序发行令牌。

  3. 之后,每次我们从后端获取数据时,我们都会检查用户是否有权访问后端请求的资源——资源消耗令牌。

在我们的案例中,资源是一个 falcor-router 的路由,它与后端有密切的关系,但这在更分散的平台上也同样适用。

记住 JWT 令牌类似于私钥——您必须确保它们的安全!

JWT 令牌的结构

头部包含后端需要的信息,以便根据该信息识别要执行的加密操作(元数据、使用的算法和密钥):

{ 
'typ': 'JWT', 
'alg': 'HS256' 
}

通常,这部分对我们来说是 100%自动完成的,所以我们实现时不需要关心头信息。

第二部分由 JSON 格式提供的声明组成,例如:

  • 发行者:这让我们知道谁发行了令牌

  • 受众:这让我们知道这个令牌必须被我们的应用程序消耗

  • 发行日期:这让我们知道令牌是在何时创建的

  • 过期日期:这让我们知道令牌何时过期,因此我们需要生成一个新的令牌

  • 主题:这让我们知道应用中的哪个部分可以使用令牌(在更大的应用中很有用)

除了这些声明之外,我们还可以创建由应用创建者特别定义的自定义声明:

{ 
'iss': 'http://theIssuerAddress', 
'exp': '1450819372', 
'aud': 'http://myAppAddress', 
'sub': 'publishingApp', 
'scope': ['read'] 
}

新的 MongoDB 用户集合

我们需要在数据库中创建一个用户集合。用户将拥有以下权限:

  • 在我们的发布应用中添加新文章

  • 在我们的发布应用中编辑现有文章

  • 删除我们发布应用中的文章

第一步是我们需要创建一个集合。你可以从 Robomongo 的 GUI 中这样做(本书开头介绍),但我们将使用命令行。

首先,我们需要创建一个名为 initPubUsers.js 的文件:

$ [[you are in the root directory of your project]]
$ touch initPubUsers.js

然后将以下内容添加到 initPubUsers.js 文件中:

[ 
  { 
'username' : 'admin', 
'password' : 'c5a0df4e293953d6048e78bd9849ec0ddce811f0b29f72564714e474615a7852', 
'firstName' : 'Kamil', 
'lastName' : 'Przeorski', 
'email' : 'kamil@mobilewebpro.pl', 
'role' : 'admin', 
'verified' : false, 
'imageUrl' : 'http://lorempixel.com/100/100/people/' 
  } 
]

说明

SHA256 字符串,c5a0df4e293953d6048e78bd9849ec0ddce811f0b29f72564714e474615a7852,相当于一个密码,123456,其盐字符串等于 pubApp

如果你想要自己生成这个加盐密码散列,那么请访问 www.xorbin.com/tools/sha256-hash-calculator 并在他们的网站上输入 123456pubApp。你将得到以下屏幕:

这些步骤仅在开始时需要。之后我们需要编写一个注册表单,为我们的应用加盐密码。

将 initPubUsers.js 文件导入 MongoDB

在我们的 initPubUsers.js 文件中正确的内容后,我们可以运行以下命令行来将新的 pubUsers 集合导入到数据库中:

mongoimport --db local --collection pubUsers --jsonArrayinitPubUsers.js --host=127.0.0.1

你将得到与我们在 第一章 中导入文章后相同的终端输出,配置 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux 全栈,看起来像这样:

2009-04-03T11:36:00.566+0200  connected to: 127.0.0.1
2009-04-03T11:36:00.569+0200  imported 1 document

在登录的 falcor-route 上工作

现在我们需要开始使用 falcor-router 来创建一个新的端点,该端点将使用 JWT 库为客户端应用提供唯一的令牌。

我们需要做的第一件事是在后端提供 secret

让我们创建那个 secret 端点的配置文件:

$ cd server
$ touch configSecret.js

现在,我们需要将此 secret 的内容放入其中:

export default { 
'secret': process.env.JWT_SECRET || 'devSecretGoesHere' 
}

在未来,我们将在生产服务器上使用环境变量,所以表示法 process.env.JWT_SECRET || 'devSecretGoesHere' 意味着 JWT_SECRET 环境变量不存在,因此使用默认的 secret 端点的 string,devSecretGoesHere。在此阶段我们不需要任何开发环境变量。

创建 falcor-router 的登录(后端)

为了使我们的代码库更有组织性,我们不会在 server/routes.js 文件中添加更多路由,而是将创建一个名为 routesSession.js 的新文件,并在该文件中保存所有与当前登录用户会话相关的端点。

确保你处于 server 目录:

$ cd server

首先打开 server.js 文件,添加一行代码,这将允许你将用户名和密码发送到后端。添加以下内容:

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

这需要在 app.use(bodyParser.json({extended: false})); 之下添加,这样你最终得到的 server.js 代码将如下所示:

import http from 'http'; 
import express from 'express'; 
import cors from 'cors'; 
import bodyParser from 'body-parser'; 
import mongoose from 'mongoose'; 
import falcor from 'falcor'; 
import falcorExpress from 'falcor-express'; 
import Router from 'falcor-router'; 
import routes from './routes.js'; 

var app = express(); 
app.server = http.createServer(app); 

// CORS - 3rd party middleware 
app.use(cors()); 

// This is required by falcor-express middleware to work correctly with falcor-browser 
app.use(bodyParser.json({extended: false})); 
app.use(bodyParser.urlencoded({extended: false})); 

最后需要添加一行新行,以便使其正常工作。然后在同一目录下创建一个新文件,使用以下命令:

$ touch routesSession.js 

并将以下初始内容放入 routesSession.js 文件中:

export default [ 
  {  
    route: ['login'] , 
    call: (callPath, args) => 
      { 
      const { username, password } = args[0]; 

      const userStatementQuery = { 
          $and: [ 
              { 'username': username }, 
              { 'password': password } 
          ] 
        } 
      } 
  } 
];

调用路由的工作原理

我们刚刚在 routesSession.js 文件中创建了一个初始的调用登录路由。我们不是使用 GET 方法,而是使用 'call'(**call: async (callPath, args) => **)。这在旧的 RESTful 方法中相当于 POST。

在 Falcor 路由中,调用方法和 get 方法之间的区别在于我们可以通过 args 提供参数。这允许我们从客户端获取用户名和密码:

计划是在我们收到以下凭据后:

const { username, password } = args[0];

然后,我们将它们与我们的数据库中的一个用户 admin 进行比较。用户需要知道真实的明文密码是 123456,才能获取正确的登录 JWT 令牌:

在这一步中,我们还准备了一个 userStatementQuery——这将在稍后查询数据库时使用:

const userStatementQuery = { 
  $and: [ 
      { 'username': username }, 
      { 'password': password } 
  ] 
}

分离数据库配置 - configMongoose.js

我们需要将数据库配置从 routes.js 中分离出来:

$ [[we are in the server/ directory]]
$ touch configMongoose.js

以及其新内容:

import mongoose from 'mongoose'; 

const conf = { 
  hostname: process.env.MONGO_HOSTNAME || 'localhost', 
  port: process.env.MONGO_PORT || 27017, 
  env: process.env.MONGO_ENV || 'local', 
}; 

mongoose.connect(&grave;mongodb://${conf.hostname}:  
${conf.port}/${conf.env}&grave;); 

const articleSchema = { 
articleTitle:String, 
articleContent:String 
}; 

const Article = mongoose.model('Article', articleSchema,  
'articles'); 

export default { 
  Article 
};

说明

我们刚刚引入了以下新的 env 变量:MONGO_HOSTNAMEMONGO_PORTMONGO_ENV。当准备生产环境时,我们将使用它们。

mongodb://${conf.hostname}:${conf.port}/${conf.env} 表达式正在使用自 EcmaScript6 以来可用的模板功能。

configMongoose.jsconfig 的其余部分你应该已经了解,因为我们已经在 第一章 中介绍了它,使用 Node.js、Express.js、MongoDB、Mongoose、Falcor 和 Redux 配置全栈

改进 routes.js 文件

在我们创建了两个新文件 configMongoose.jsroutesSession.js 之后,我们必须改进我们的 server/routes.js 文件,以便使所有内容协同工作。

第一步是从 routes.js 中删除以下代码:

import mongoose from 'mongoose'; 

mongoose.connect('mongodb://localhost/local'); 

const articleSchema = { 
articleTitle:String, 
articleContent:String 
}; 

const Article = mongoose.model('Article', articleSchema,  
'articles');

用以下新代码替换它:

import configMongoosefrom './configMongoose'; 
import sessionRoutes from './routesSession'; 
const Article = configMongoose.Article;

此外,我们还需要按照以下方式将 sessionRoutes 展开到当前的 PublishingAppRoutes 中:

const PublishingAppRoutes =  
    ...sessionRoutes, 
  { 
  route: 'articles.length',

PublishingAppRoutes 的开始处,你需要展开 ...sessionRoutesroutes,这样登录路由就可以在整个 Falcor 路由中使用。

说明

我们已经移除了帮助我们运行第一个 Mongoose 查询的旧代码,该查询用于获取文章,并将所有内容移动到 configMongoose 中,以便我们可以在项目的不同文件中使用它。我们还导入了会话路由,并在之后使用 ... 展开操作将它们扩展到名为 PublishingAppRoutes 的数组中。

在实现 JWT 之前检查应用是否工作

在这一点上,当执行 npm start 时,应用程序应该正在运行并显示文章列表:

![

当使用 npm start 运行时,你应该得到以下信息,以验证一切是否正常工作:

Hash: eeeb09711c820a7978d5 
Version2,: webpack 1.12.14 
Time: 2609ms 
 Asset    Size  Chunks             Chunk Names 
app.js  1.9 MB       0  [emitted]  main 
   [0] multi main 40 bytes {0} [built] 
    + 634 hidden modules 
Started on port 3000

创建 Mongoose 用户模型

在文件 configMongoose.js 中,我们需要创建并导出一个 User 模型。向该文件添加以下代码:

const userSchema = { 
'username' : String, 
'password' : String, 
'firstName' : String, 
'lastName' : String, 
'email' : String, 
'role' : String, 
'verified' : Boolean, 
'imageUrl' : String 
}; 

const User = mongoose.model('User', userSchema, 'pubUsers'); 

export default { 
  Article, 
  User 
};

说明

userSchema 描述了我们的用户 JSON 模型。用户是我们指向 MongoDB 中 pubUsers 集合的 Mongoose 模型。最后,我们通过将其添加到 export default 对象中导出 User 模型。

routesSession.js 文件中实现 JWT

第一步是将我们的 User 模型导出到 routesSession 范围内,通过在该文件顶部添加一个 import 语句:

import configMongoosefrom './configMongoose'; 
const User = configMongoose.User;

安装 jsonwebtokencrypto (用于 SHA256):

$ npmi --save jsonwebtoken crypto

在安装 jsonwebtoken 后,我们需要将其导入到 routesSession.js

import jwt from 'jsonwebtoken'; 
import crypto from 'crypto'; 
import jwtSecret from './configSecret';

routesSession 中导入所有内容后,继续使用 route: ['login']

你需要改进 userStatementQuery,使其包含 saltedPassword 而不是纯文本:

const saltedPassword = password+'pubApp';  
// pubApp is our salt string 
const saltedPassHash = crypto 
.createHash('sha256') 
.update(saltedPassword) 
.digest('hex'); 
const userStatementQuery = { 
  $and: [ 
      { 'username': username }, 
      { 'password': saltedPassHash } 
  ] 
}

因此,我们不会查询纯文本,而是查询盐化的 SHA256 密码。

userStatementQuery 下,返回 Promise,以下为详细信息:

return User.find(userStatementQuery, function(err, user) { 
   if (err) throw err; 
 }).then((result) => { 
   if(result.length) { 
     return null;  
     // SUCCESSFUL LOGIN mocked now (will implement next) 
   } else { 
     // INVALID LOGIN 
     return [ 
       { 
         path: ['login', 'token'],  
         value: "INVALID" 
       }, 
       { 
         path: ['login', 'error'],  
         value: "NO USER FOUND, incorrect login  
         information" 
       } 
     ]; 
   } 
   return result; 
 });

说明

User.find 是来自 Mongoose 用户模型的 Promise(我们在 configMongoose.js 中创建的)--这是一个标准方法。然后我们作为第一个参数提供 userStatementQuery,这是一个包含用户名和密码的过滤器对象:(*{ username, password } = args[0];).

接下来,我们提供一个函数,它是查询完成后调用的回调函数:(function(err, user) {). 我们使用 if(result.length) {} 计算结果的数量。

如果 result.length=== 0,那么我们模拟了 return 语句,并且正在执行以下 else 代码的返回:

 return [ 
    { 
      path: ['login', 'token'],  
      value: "INVALID" 
    }, 
    { 
      path: ['login', 'error'],  
      value: 'NO USER FOUND, incorrect login  
      information' 
    } 
  ];

如你稍后所学,我们将在前端请求该令牌的路径,['login', 'token']。在这种情况下,我们没有找到正确的用户名和提供的密码,所以我们返回 "INVALID" 字符串,而不是 JWT 令牌。路径 ['login', 'error'] 更详细地描述了错误类型,以便将消息显示给提供无效登录凭据的用户。

在 falcor-route 上成功登录

我们需要改进成功的登录路径。我们有一个处理无效登录的情况;我们需要创建一个处理成功登录的情况,所以替换以下代码:

return null; // SUCCESSFUL LOGIN mocked now (will implement next)

用以下代码返回成功登录的详细信息:

const role = result[0].role; 
const userDetailsToHash = username+role; 
const token = jwt.sign(userDetailsToHash, jwtSecret.secret); 
return [ 
  { 
    path: ['login', 'token'], 
    value: token 
  }, 
  { 
    path: ['login', 'username'], 
    value: username 
  }, 
  { 
    path: ['login', 'role'], 
    value: role 
  }, 
  { 
    path: ['login', 'error'], 
    value: false 
  } 
];

说明

如你所见,我们现在从数据库中获取的唯一东西是角色 value === result[0].role。我们需要将其添加到哈希中,因为我们不希望我们的应用程序容易受到攻击,以至于普通用户可以通过一些黑客手段获得管理员角色。令牌的值基于 userDetailsToHash = username+role 计算---现在就足够了。

在我们对此满意之后,后端需要做的唯一一件事就是返回带有值的路径:

  • 登录令牌['login', 'token']

  • 用户名['login', 'username']

  • 登录用户的角色['login', 'role']

  • 没有错误发生的['login', 'error']信息

下一步是在前端使用这个路由。运行应用,如果一切正常,我们就可以开始在前端进行编码了。

前端和 Falcor

让我们在 Redux 应用中创建一个新的登录路由。为了做到这一点,我们需要引入react-router

$ npmi --save react-router@1.0.0redux-simple-router@0.0.10redux-thunk@1.0.0

重要的是要使用正确的 NPM 版本,否则事情会出错!

安装它们之后,我们需要在src中添加路由:

$ cd src
$ mkdir routes
$ cd routes
$ touch index.js

然后,将此index.js文件的内容设置为以下内容:

import React  from 'react'; 
import {Route, IndexRoute} from 'react-router'; 
import CoreLayout  from '../layouts/CoreLayout'; 
import PublishingApp  from '../layouts/PublishingApp'; 
import LoginView  from '../views/LoginView'; 

export default ( 
<Route component={CoreLayout} path='/'> 
<IndexRoute component={PublishingApp} name='home' /> 
<Route component={LoginView} path='login' name='login' /> 
</Route> 
);

到目前为止,我们缺少两个应用组件,称为CoreLayoutLoginView(我们将在下一分钟实现它们)。

核心布局组件

CoreLayout组件是我们整个应用的包装器。通过执行以下操作来创建它:

cd ../layouts/ 
touch CoreLayout.js 

然后,用以下内容填充它:

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

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

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

export default CoreLayout;

如你所知,当前路由的所有内容都将进入{this.props.children}目标(这是一个你必须事先了解的basicReact.JS概念)。我们还创建了两个链接到我们的路由作为标题。

登录视图组件

目前,我们将创建一个模拟的LoginView组件。让我们创建views目录:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir views
$ cd views
$ touch LoginView.js

以下代码显示了LoginView.js文件的内容,其中包含FORM GOES HERE占位符:

import React from 'react'; 
import Falcor from 'falcor'; 
import falcorModel from '../falcorModel.js'; 
import {connect} from 'react-redux'; 
import {bindActionCreators} from 'redux'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

// You can add your reducers here 
const mapDispatchToProps = (dispatch) => ({}); 

class LoginView extends React.Component { 
  render () { 
    return ( 
<div> 
<h1>Login view</h1> 
          FORM GOES HERE 
</div> 
    ); 
  } 
} 

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

我们完成了routes/index.js中所有缺失的部分,但在我们的路由应用开始工作之前,还有一些其他待办事项。

我们应用的根容器

因为我们的应用变得越来越复杂,我们需要创建一个容器,它将生活在这个容器中。为了做到这一点,让我们在src位置执行以下操作:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir containers
$ cd containers
$ touch Root.js

Root.js将成为我们的主要根文件。此文件的内容如下:

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

const noQueryKeyHistory = createHashHistory({ 
queryKey: false 
}); 

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

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

目前它只是一个简单的容器,但稍后我们将实现更多功能,如调试、热重载等。noQueryKeyHistory告诉路由器,我们不希望在 URL 中包含任何随机字符串,因此我们的路由将看起来更美观(这不是什么大问题,你可以将 false 标志更改为 true,看看我在说什么)。

configureStore 和 rootReducer 的剩余配置

让我们先创建rootReducer。为什么我们需要它?因为在更大的应用中,你总是会遇到许多不同的还原器;例如,在我们的应用中,我们将有如下的还原器:

  • 文章还原器:它保留与文章相关的信息(RETURN_ALL_ARTICLES等)

  • 会话还原器:这将与我们的用户会话(LOGINREGISTER等)相关

  • 编辑器还原器:它将与编辑器的操作(EDIT_ARTICLEDELETE_ARTICLEADD_NEW_ARTICLE等)相关

  • 路由的 reducer:这将管理我们路由的状态(开箱即用,因为它由 redux-simple-router 的外部库管理)

让我们在 reducers 目录中创建一个 index.js 文件:

$ pwd
$ [[[you shall be at the src folder]]]
$ cd reducers
$ touch index.js

index.js 文件的内容如下:

import {combineReducers} from 'redux'; 
import {routeReducer} from 'redux-simple-router'; 
import article  from './article'; 

export default combineReducers({ 
  routing: routeReducer, 
  article 
});

这里新引入的是来自 Redux 的 combineReducers 函数。这正是我之前写过的。我们将有多个 reducer——在我们的例子中,我们还从 redux-simple-router 库中引入了 routeReducer

下一步是创建 configureStore,它将管理我们的存储,并在本书后面实现服务器端渲染:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir store
$ cd store
$ touch configureStore.js

configureStore.js 文件的内容如下:

import rootReducer  from '../reducers'; 
import thunk  from 'redux-thunk'; 
import {applyMiddleware,compose,createStore} from 'redux'; 

export default function configureStore (initialState, debug =  
false) { 
let createStoreWithMiddleware; 
const middleware = applyMiddleware(thunk); 

createStoreWithMiddleware = compose(middleware); 

const store = createStoreWithMiddleware(createStore)( 
rootReducer, initialState 
  ); 
  return store; 
}

在前面的代码中,我们正在导入我们最近创建的 rootReducer。我们还导入了 redux-thunk 库,这个库对于服务器端渲染非常有用(本书后面将描述)。

最后,我们导出一个由许多不同的 reducer 组成的 store(目前是路由和您可以在 reducer/index.js 中找到的文章的 reducer),并且能够处理服务器端渲染的初始状态。

运行应用前在 layouts/PublishingApp.js 中的最后微调

我们应用中最后更改的是,我们的发布应用中有过时的代码。

为什么它会过时?因为我们引入了 rootReducercombineReducers。所以如果您检查 PublishingApp 的渲染代码,它将不会工作:

let articlesJSX = []; 

for(let articleKey in this.props) { 
const articleDetails = this.props[articleKey]; 

const currentArticleJSX = ( 
<div key={articleKey}> 
<h2>{articleDetails.articleTitle}</h2> 
<h3>{articleDetails.articleContent}</h3> 
</div>); 

articlesJSX.push(currentArticleJSX); 
}

您需要将其更改为以下内容:

let articlesJSX = []; 

for(let articleKey in this.props.article) { 
const articleDetails = this.props.article[articleKey]; 

const currentArticleJSX = ( 
<div key={articleKey}> 
<h2>{articleDetails.articleTitle}</h2> 
<h3>{articleDetails.articleContent}</h3> 
</div>); 

articlesJSX.push(currentArticleJSX); 
}

您看到区别了吗?旧的 for(let articleKey in this.props) 已更改为 for(let articleKey in this.props.article),而 this.props[articleKey] 已更改为 this.props.article[articleKey]。为什么?我会再次提醒:现在每个新的 reducer 都将通过在 routes/index.js 中创建的名称在我们的应用中可用。我们命名了我们的 reducer 为 article,所以我们现在必须将其添加到 this.props.article 中以使这些功能协同工作。

运行应用前 src/app.js 的最后更改

最后一件事情是改进 src/app.js,使其使用根容器。我们需要更改旧代码:

// old codebase, to improve: 
import React from 'react' 
import { render } from 'react-dom' 
import { Provider } from 'react-redux' 
import { createStore } from 'redux' 
import article from './reducers/article' 
import PublishingApp from './layouts/PublishingApp' 

const store = createStore(article) 

render( 
<Provider store={store}> 
<PublishingApp store={store} /> 
</Provider>, 
document.getElementById('publishingAppRoot') 
);

我们需要将前面的代码更改为以下内容:

import React from 'react'; 
import ReactDOM from 'react-dom'; 
import createBrowserHistory from 'history/lib/createBrowserHistory'; 
import {syncReduxAndRouter} from 'redux-simple-router'; 
import Root from './containers/Root'; 
import configureStore from './store/configureStore'; 

const target  = document.getElementById('publishingAppRoot'); 
const history = createBrowserHistory(); 

export const store = configureStore(window.__INITIAL_STATE__); 

syncReduxAndRouter(history, store); 

const node = ( 
<Root 
      history={history} 
      store={store}  /> 
); 

ReactDOM.render(node, target);

我们开始直接使用 Root 而不是 Provider,并且我们需要将 store 和 history 的属性发送到 Root 组件。***export const store = configureStore(window.__INITIAL_STATE__)*** 这部分是为了服务器端渲染,我们将在接下来的章节中添加。我们还使用 history 库用 JavaScript 管理浏览器的历史记录。

我们运行应用的截图

目前当您执行 npm start 时,您将看到以下两个路由。

首页

登录视图

正在处理登录表单,该表单将调用后端以进行身份验证

好的,所以我们在项目结构可扩展性方面做了很多准备工作(routesrootReducerconfigStores 等)。

为了从用户的角度使我们的应用看起来更美观,我们将开始使用 Material Design CSS。为了使我们的表单工作更轻松,我们将开始使用formsy-react库。让我们来安装它:

$ npm i --save material-ui@0.14.4formsy-react@0.17.0

在撰写本书时,Material UI 的版本.20.14.4 是最好的选择;我使用这个版本是因为生态系统变化非常快,所以最好在这里标记所使用的版本,这样你在遵循本书中的说明时就不会有任何惊喜。

formsy-react库是一个非常实用的库,它将帮助我们验证发布应用中的表单。我们将在登录和注册等页面上使用它,正如你将在下一页看到的那样。

LoginFormDefaultInput组件上工作

在我们完成安装新的依赖项后,让我们创建一个文件夹,用于保存与无状态组件(没有访问任何存储的组件;它们通过回调与我们的应用的其他部分进行通信---你将在稍后了解更多关于这个的信息)相关的文件:

$ pwd
$ [[[you shall be at the src folder]]]
$ mkdir components
$ cd components
$ touch DefaultInput.js

然后使这个文件的内容如下:

import React from 'react'; 
import {TextField} from 'material-ui'; 
import {HOC} from 'formsy-react'; 

class DefaultInput extends React.Component { 
  constructor(props) { 
    super(props); 
    this.changeValue = this.changeValue.bind(this); 
    this.state = {currentText: null} 
  } 

changeValue(e) { 
this.setState({currentText: e.target.value}) 
this.props.setValue(e.target.value); 
this.props.onChange(e); 
  } 

  render() { 
    return ( 
<div> 

<TextField 
          ref={this.props.name} 
          floatingLabelText={this.props.title} 
          name={this.props.name} 
          onChange={this.changeValue} 
          required={this.props.required} 
          type={this.props.type} 
          value={this.state.currentText ?  
          this.state.currentText : this.props.value} 
          defaultValue={this.props.defaultValue} /> 
        {this.props.children} 
</div>); 
  } 
}; 

export default HOC(DefaultInput);

说明

formsy-react中的{HOC}是装饰组件(在 React 的 ECMAScript5 中称为mixin)的另一种方式,导出默认的HOC(DefaultInput)--你可以在github.com/christianalfoni/formsy-react/blob/master/API.md#formsyhoc找到更多关于这个的信息。

我们还在使用来自material-uiTextField;它具有不同的属性。以下是一些属性:

  • ref: 我们希望为每个输入项都提供一个带有其名称(用户名和电子邮件)的ref

  • floatingLabelText: 这是一个看起来很不错的浮动文本(也称为标签)。

  • onChange: 这告诉函数的名称,当有人正在 TextField 中输入时必须调用该函数。

  • required: 这有助于我们管理表单中的必填输入项。

  • value: 当然,这是我们的 TextField 的当前值。

  • defaultValue: 这是一个初始值。非常重要的一点是,当组件调用组件的构造函数时,它只被调用一次。

当前的文本(this.state.currentText)是DefaultInput组件的值---它会在每次由TextFieldonChange属性中提供的回调函数触发的changeValue事件上改变。

创建LoginForm并使其与LoginView一起工作

下一步是创建LoginForm。这将使用DefaultInput组件,以下是一些命令:

$ pwd
$ [[[you shall be at the components folder]]]
$ touch LoginForm.js

然后我们的src/components/LoginForm.js文件的内容如下:

import React from 'react'; 
import Formsy from 'formsy-react'; 
import {RaisedButton, Paper} from 'material-ui'; 
import DefaultInput from './DefaultInput'; 

export class LoginForm extends React.Component { 
  constructor() { 
    super(); 
  } 

  render() { 
    return ( 
<Formsy.FormonSubmit={this.props.onSubmit}> 
<Paper zDepth={1} style={{padding: 32}}> 
<h3>Log in</h3> 
<DefaultInput 
onChange={(event) => {}}  
name='username' 
title='Username (admin)' 
required /> 

<DefaultInput 
onChange={(event) => {}}  
type='password' 
name='password' 
title='Password (123456)' 
required /> 

<div style={{marginTop: 24}}> 
<RaisedButton 
              secondary={true} 
              type="submit" 
              style={{margin: '0 auto', display: 'block', width:  
              150}} 
              label={'Log in'} /> 
</div> 
</Paper> 
</Formsy.Form> 
    ); 
  } 
}

在前面的代码中,我们有使用DefaultInput组件的LoginForm组件。这是一个简单的React.js表单,在提交后调用this.props.onSubmit--这个onSubmit函数将在稍后的src/views/LoginView.js智能组件中定义。我不会过多地谈论组件上的附加样式,因为这取决于您如何进行样式设计--您将很快看到我们应用所应用的样式的截图。

改进src/views/LoginView.js

在我们运行应用程序之前,当前开发阶段的最后一部分是改进LoginView组件。

src/views/LoginView.js中做出以下更改。导入我们新的LoginForm组件:

import {LoginForm} from '../components/LoginForm.js'; 
Add a new constructor of that component: 
 constructor(props) { 
    super(props); 
    this.login = this.login.bind(this); 
    this.state = { 
      error: null 
    }; 
  }

然后在您完成导入和构造函数之后,您需要一个名为login的新函数:

async login(credentials) { 
console.info('credentials', credentials); 

    await falcorModel 
      .call(['login'],[credentials]) 
      .then((result) =>result); 

const tokenRes = await falcorModel.getValue('login.token'); 
console.info('tokenRes', tokenRes); 
    return; 
  }

在这一点上,login函数只将我们的新 JWT 令牌打印到控制台--现在足够了;稍后我们将在此基础上构建更多功能。

此处的最后一步是改进我们的render函数:

 render () { 
    return ( 
<div> 
<h1>Login view</h1> 
          FORM GOES HERE 
</div> 
    ); 
  }

对新的方法,如下所示:

 render () { 
    return ( 
<div> 
<h1>Login view</h1> 
<div style={{maxWidth: 450, margin: '0 auto'}}> 
<LoginForm 
onSubmit={this.login} /> 
</div> 
</div> 
    ); 
  }

太好了!现在我们已经完成了!以下是在运行npm start并在浏览器中运行它后您将看到的内容:

图片

如您在浏览器控制台中所见,我们可以看到提交的凭据对象(credentials Object {username: "admin", password: "123456"})以及从后端获取的令牌(tokenRes eyJhbGciOiJIUzI1NiJ9.YWRtaW5hZG1pbg.NKmrphxbqNcL_jFLBdTWGM6Y_Q78xks5E2TxBZRyjDA)。所有这些都告诉我们,我们在实现发布应用程序的登录机制方面正在按计划进行。

重要

如果您遇到错误,请确保在创建哈希时使用了123456密码。否则,输入适用于您情况的自定义密码。

制作 DashboardView 组件

到目前为止,我们有一个未完成的登录功能,但在继续工作之前,让我们创建一个简单的src/views/DashboardView.js组件,该组件将在成功登录后显示:

$ pwd 
$ [[[you shall be at the views folder]]] 
$ touch DashboardView.js

添加以下简单内容:

import React from 'react'; 
import Falcor from 'falcor'; 
import falcorModel from '../falcorModel.js'; 
import { connect } from 'react-redux'; 
import { bindActionCreators } from 'redux'; 
import { LoginForm } from '../components/LoginForm.js'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

// You can add your reducers here 
const mapDispatchToProps = (dispatch) => ({}); 

class DashboardView extends React.Component { 
render () { 
    return ( 
<div> 
<h1>Dashboard - loggedin!</h1> 
</div> 
    ); 
  } 
} 
export default connect(mapStateToProps, mapDispatchToProps)(DashboardView);

这是一个简单的组件,目前是静态的。稍后,我们将将其构建得更加完善。

关于仪表板,我们需要在src/routes/index.js文件中创建的最后一件事情是添加一个新的路由:

import DashboardView from '../views/DashboardView'; 

export default ( 
<Route component={CoreLayout} path='/'> 
<IndexRoute component={PublishingApp} name='home' /> 
<Route component={LoginView} path='login' name='login' /> 
<Route component={DashboardView} path='dashboard'   name='dashboard' /> 
</Route> 
);

我们刚刚使用 react-router 的配置添加了第二个路由。它使用位于../views/DashboardView文件的DashboardView组件。

完成登录机制

在我们发布应用程序的这个阶段,对登录的最后改进仍然位于src/views/LoginView.js位置:

首先,让我们添加处理无效登录的方法:

console.info('tokenRes', tokenRes); 

if(tokenRes === 'INVALID') { 
    const errorRes = await falcorModel.getValue('login.error'); 
    this.setState({error: errorRes}); 
    return; 
} 

return;

我们添加了if(tokenRes === 'INVALID')这个条件,以便用this.setState({error: errorRes})更新错误状态。

下一步是将Snackbar添加到render函数中,以便向用户显示错误类型。在LoginView组件的顶部添加以下导入:

import { Snackbar } from 'material-ui';

然后您需要更新render函数如下:

<Snackbar 
  autoHideDuration={4000} 
  open={!!this.state.error} 
  message={this.state.error || ''}  
  onRequestClose={() => null} />

所以在添加之后,render函数将看起来像这样:

render () { 
  return ( 
<div> 
<h1>Login view</h1> 
<div style={{maxWidth: 450, margin: '0 auto'}}> 
<LoginForm 
onSubmit={this.login} /> 
</div> 
<Snackbar autoHideDuration={4000} 
          open={!!this.state.error} 
          message={this.state.error || ''}  
onRequestClose={() => null} /> 
</div> 
  ); 
}

SnackBar onRequestClose在这里是必需的,否则你将在开发者的控制台中收到警告。好的,所以我们正在处理登录错误,现在让我们来处理成功的登录。

在 LoginView 组件中处理成功的登录

为了处理成功的令牌的后端响应,添加登录函数:

if(tokenRes === 'INVALID') { 
const errorRes = await falcorModel.getValue('login.error'); 
this.setState({error: errorRes}); 
      return; 
    }

处理正确响应的新代码,如下所示:

if(tokenRes) { 
const username = await falcorModel.getValue('login.username'); 
const role = await falcorModel.getValue('login.role'); 

localStorage.setItem('token', tokenRes); 
localStorage.setItem('username', username); 
localStorage.setItem('role', role); 

this.props.history.pushState(null, '/dashboard'); 
}

说明

在我们知道tokenRes不是INVALID并且它不是未定义的(否则向用户显示致命错误)之后,我们遵循以下步骤:

我们从 Falcor 的模型中获取用户名(await falcorModel.getValue('login.username'))。我们获取用户的角色(await falcorModel.getValue('login.role'))。然后我们将所有已知的变量从后端保存到localStoragewith

localStorage.setItem('token', tokenRes); 
localStorage.setItem('username', username); 
localStorage.setItem('role', role);

在同一端,我们使用this.props.history.pushState(null, '/dashboard')将用户发送到/dashboard路由。

关于 DashboardView 和安全的几个重要注意事项

在这个阶段,我们不会对DashboardView进行安全设置,因为没有需要保护的重要内容——我们将在将更多资产/功能放入这个路由时进行,到本书的结尾,这个路由将是一个编辑仪表板,将控制系统中所有文章。

我们剩下的唯一一步是将它变成一个RegistrationView组件。到这个时候,这个路由也将对每个人开放。在本书的后面部分,我们将创建一个机制,使得只有主管理员才能将新编辑添加到系统中(并管理他们)。

开始新的编辑注册工作

为了完成注册,让我们首先在我们的用户方案中做一些更改,这些更改位于 Mongoose 的配置文件server/configMongoose.js的位置:

const userSchema = { 
'username' : String, 
'password' : String, 
'firstName' : String, 
'lastName' : String, 
'email' : String, 
'role' : String, 
'verified' : Boolean, 
'imageUrl' : String 
};

按照以下新方案:

const userSchema = { 
'username' : { type: String, index: {unique: true, dropDups: true }}, 
'password' : String, 
'firstName' : String, 
'lastName' : String, 
'email' : { type: String, index: {unique: true, dropDups: true }}, 
'role' : { type: String, default: 'editor' }, 
'verified' : Boolean, 
'imageUrl' : String 
};

如你所见,我们已经在usernameemail字段上添加了唯一索引。我们还为角色添加了一个默认值,因为在我们集合中的下一个用户将是一个编辑(而不是管理员)。

添加注册的 falcor-route

在位于server/routesSession.js的文件中,你需要添加一个新的路由(在登录路由旁边):

 {  
    route: ['register'], 
    call: (callPath, args) => 
      { 
        const newUserObj = args[0]; 
        newUserObj.password = newUserObj.password+'pubApp'; 
        newUserObj.password = crypto 
          .createHash('sha256') 
          .update(newUserObj.password) 
          .digest('hex'); 
          const newUser = new User(newUserObj); 
          return newUser.save((err, data) => { if (err) return err; }) 
          .then ((newRes) => { 
            /* 
              got new obj data, now let's get count: 
             */ 
             const newUserDetail = newRes.toObject(); 

            if(newUserDetail._id) { 
              return null; // Mocked for now 
            } else { 
              // registration failed 
              return [ 
                { 
                  path: ['register', 'newUserId'],  
                  value: 'INVALID' 
                }, 
                { 
                  path: ['register', 'error'],  
                  value: 'Registration failed - no id has been                                  
                  created' 
                } 
              ]; 
            } 
            return; 
          }).catch((reason) =>console.error(reason)); 
      } 
  }

这段代码实际上只是通过const newUserObj = args[0]从前端接收新用户的对象。

然后我们对将要存储在我们数据库中的密码进行加盐处理:

newUserObj.password = newUserObj.password+'pubApp'; 
newUserObj.password = crypto 
  .createHash('sha256') 
  .update(newUserObj.password) 
  .digest('hex');

然后我们通过const newUser = new User(newUserObj)从 Mongoose 创建一个新的用户模型,因为newUser变量是用户的新模型(尚未保存)。接下来我们需要用以下代码保存它:

return newUser.save((err, data) => { if (err) return err; })

在它被保存到数据库并且 Promise 被解决之后,我们首先通过将 Mongoose 结果的对象转换成一个简单的 JSON 结构const newUserDetail = newRes.toObject();来管理数据库中的无效条目。

完成之后,我们将向 Falcor 的模型返回一个INVALID信息:

 // registration failed 
    return [ 
      { 
        path: ['register', 'newUserId'],  
        value: 'INVALID' 
      }, 
      { 
        path: ['register', 'error'],  
        value: 'Registration failed - no id has been created' 
      }

因此,我们已经处理了来自 Falcor 的无效用户注册。下一步是替换以下内容:

// you shall already have this in your codebase, just a recall 
if(newUserDetail._id) { 
  return null; // Mocked for now 
} 
The preceding code needs to be replaced with: 
if(newUserDetail._id) { 
const newUserId = newUserDetail._id.toString(); 

  return [ 
    { 
      path: ['register', 'newUserId'],  
      value: newUserId 
    }, 
    { 
      path: ['register', 'error'],  
      value: false  
    } 
  ]; 
}

说明

我们需要将新用户的 ID 转换为字符串,newUserId = newUserDetail._id.toString()(否则代码会出错)。

如你所见,我们有一个标准的返回语句,它补充了 Falcor 中的模型。

为了快速回顾,在它正确返回后端之后,我们将在前端以如下方式请求此值:const newUserId = await falcorModel.getValue(['register', 'newUserId']);(这只是一个如何在客户端获取这个新的UserId的示例——不要将其写入你的代码中,我们将在一会儿做这个操作)。

在几个更多示例之后,你会习惯它的。

前端实现(注册视图和注册表单)

让我们首先创建一个组件,它将在前端管理注册表单,以下是一些操作:

$ pwd 
$ [[[you shall be at the components folder]]] 
$ touch RegisterForm.js 

该文件的内容将是:

import React from 'react'; 
import Formsy from 'formsy-react'; 
import {RaisedButton, Paper} from 'material-ui'; 
import DefaultInput from './DefaultInput'; 

export class RegisterForm extends React.Component { 
  constructor() { 
    super(); 
  } 

  render() { 
    return ( 
<Formsy.FormonSubmit={this.props.onSubmit}> 
<Paper zDepth={1} style={{padding: 32}}> 
<h3>Registration form</h3> 
<DefaultInput 
  onChange={(event) => {}}  
  name='username' 
  title='Username' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  name='firstName' 
  title='Firstname' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  name='lastName' 
  title='Lastname' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  name='email' 
  title='Email' 
  required /> 

<DefaultInput 
  onChange={(event) => {}}  
  type='password' 
  name='password' 
  title='Password' 
  required /> 

<div style={{marginTop: 24}}> 
<RaisedButton 
              secondary={true} 
              type="submit" 
              style={{margin: '0 auto', display:                      
              'block', width: 150}} 
              label={'Register'} /> 
</div> 
</Paper> 
</Formsy.Form> 
    ); 
  } 
}

之前的注册组件创建了一个与LoginForm中完全相同的表单。当用户点击注册按钮时,它向src/views/RegisterView.js组件发送一个回调(我们将在一会儿创建这个组件)。

记住,在组件目录中我们只保留简单的组件,所以与整个应用的其余部分的通信必须通过回调函数来完成,就像这个例子一样。

注册视图

让我们创建一个RegisterView文件:

$ pwd 
$ [[[you shall be at the views folder]]] 
$ touch RegisterView.js

其内容如下:

import React from 'react'; 
import falcorModel from '../falcorModel.js'; 
import { connect } from 'react-redux'; 
import { bindActionCreators } from 'redux'; 
import { Snackbar } from 'material-ui'; 
import { RegisterForm } from '../components/RegisterForm.js'; 

const mapStateToProps = (state) => ({  
  ...state  
}); 
const mapDispatchToProps = (dispatch) => ({});

这些是我们智能组件中使用的标准事物(我们需要falcorModel来与后端通信,以及mapStateToPropsmapDispatchToProps来与我们的 Redux 的 store/reducer 通信)。

好的,注册视图的内容还不止这些;接下来让我们添加一个组件:

const mapDispatchToProps = (dispatch) => ({}); 

class RegisterView extends React.Component { 
  constructor(props) { 
    super(props); 
    this.register = this.register.bind(this); 
    this.state = { 
      error: null 
    }; 
  } 

  render () { 
    return ( 
<div> 
<h1>Register</h1> 
<div style={{maxWidth: 450, margin: '0 auto'}}> 
<RegisterForm 
onSubmit={this.register} /> 
</div> 
</div> 
    ); 
  } 
} 
export default connect(mapStateToProps, mapDispatchToProps)(RegisterView);

register function, so between the constructor and the render function add the function, as follows:
async register (newUserModel) {console.info("newUserModel",  newUserModel); 

    await falcorModel 
      .call(['register'],[newUserModel]) 
      .then((result) =>result); 

      const newUserId = await falcorModel.getValue(['register',  
      'newUserId']); 

    if(newUserId === 'INVALID') { 
      const errorRes = await falcorModel.getValue('register.error'); 

      this.setState({error: errorRes}); 
      return; 
    } 

    this.props.history.pushState(null, '/login'); 
  }

如你所见,async register (newUserModel)函数是异步的,并且对await友好。接下来,我们只是在控制台中使用console.info("newUserModel", newUserModel)记录用户提交的内容。之后,我们通过调用查询 falcor-router:

await falcorModel 
      .call(['register'],[newUserModel]) 
      .then((result) => result);

在我们调用路由器之后,我们使用以下方式获取响应:

const newUserId = await falcorModel.getValue(['register', 'newUserId']);

根据后端的响应,我们执行以下操作:

  • 对于INVALID,我们在组件的状态中获取并设置错误信息(this.setState({error: errorRes}))。

  • 如果用户注册正确,那么我们就有了他们的新 ID,并且我们要求用户使用历史记录的 push 状态(this.props.history.pushState(null, '/login');)进行登录。

我们在routes/index.js中没有为RegisterView创建路由,并且在CoreLayout中也没有链接,所以我们的用户无法使用它。在routes/index.js中添加新的导入:

import RegisterView from '../views/RegisterView';

然后添加一个路由,所以routes/index.js中的导出默认值将看起来像这样:

export default ( 
<Route component={CoreLayout} path='/'> 
<IndexRoute component={PublishingApp} name='home' /> 
<Route component={LoginView} path='login' name='login' /> 
<Route component={DashboardView} path='dashboard'  name='dashboard' /> 
<Route component={RegisterView} path='register' name='register' /> 
</Route> 
);

最后,在src/layoutsCoreLayout.js文件中的render方法内添加一个链接:

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

在这一点上,我们应该能够使用此表单进行注册:

摘要

在下一章中,我们将开始着手我们应用的服务器端渲染。这意味着在每次向我们的 Express 服务器发送请求时,我们将根据客户端的请求生成 HTML 标记。这个特性对我们这样的应用非常有用,因为我们这类用户对网页加载速度的要求非常高。

你可以想象,大多数新闻网站都是为了娱乐,这意味着我们潜在用户的注意力集中时间较短。加载速度很重要。还有一些观点认为,服务器端渲染也有助于搜索引擎优化的原因。

爬虫有更简单的方式来读取我们文章中的文本,因为它们不需要执行服务器端的 JavaScript 来从服务器获取它(与不使用服务器端渲染的单页应用相比)。

至少有一点是确定的:如果你在你的文章发布应用中实现了服务器端渲染,那么谷歌可能会认为你关心你应用的快速加载,因此它可能会给你带来一些优势,相对于那些不关心服务器端渲染的完整单页网站。

第三章:服务器端渲染

全局 JavaScript,或同构 JavaScript,是我们要在本章中实现的功能的不同名称。更准确地说,我们将开发我们的应用,并在服务器和客户端上渲染应用页面。它将不同于主要在客户端渲染的 Angular1 或 Backbone 单页应用程序。从技术角度来看,我们的方法更复杂,因为你需要部署你的全栈技能,这些技能在服务器端渲染上工作,但拥有这种经验将使你成为一个更受欢迎的程序员,你可以将你的技能在市场上提升到下一个水平——你将能够为你的技能收取更高的费用。

当服务器端值得实施时

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

本章我们将关注的重点包括以下内容:

  • 对整个服务器端代码进行重新排列以准备服务器端渲染

  • 开始使用 react-dom/server 及其 renderToString 方法

  • RoutingContext 和在服务器端工作的 react-router 的匹配

  • 优化客户端应用程序,使其适用于同构 JavaScript 应用程序

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

模拟数据库响应

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

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

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

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

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

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

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

帮助我们实现服务器端渲染的下一件事是创建一个 handleServerSideRender 函数,该函数将在每次请求击中服务器时被触发。

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

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

在这本书中,我们第一次将编写自己的、小的中间件函数,其行为类似于corsbodyParser(也是中间件的外部libs)。

在这样做之前,让我们导入 React 服务器端渲染所需的依赖项(server/server.js):

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

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

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

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

在我们添加了新的导入之后,然后在 Falcor 的中间件设置下:

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

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

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

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

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

  • handleServerSideRender:它将使用renderToString来创建有效的服务器端 HTML 标记。

  • renderFullHtml:这是一个辅助函数,它将我们的新 React HTML 标记嵌入到整个 HTML 文档中,正如我们稍后将会看到的。

处理服务器端渲染的函数

首先,我们将创建一个新的 Redux 存储实例,该实例将在每次调用后端时创建。这个主要目的是向我们的应用程序提供初始状态信息,以便它可以根据当前请求创建有效的标记。

我们将使用已经在我们的客户端应用中使用的Provider组件,它将包装Root组件。这将使存储对所有组件可用。

这里最重要的部分是ReactDOMServer.renderToString(),用于渲染我们应用程序的初始 HTML 标记,在我们将标记发送到客户端之前。

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

在我们处理两个新函数(handleServerSideRenderrenderFullHtml)之前,在server.js中替换以下内容:

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

替换为以下内容:

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

这就是我们的dist项目中的所有内容。它将作为静态文件在本地地址(http://localhost:3000/static/app.js*)下可用。这将帮助我们创建一个单页应用程序,在初始服务器端渲染之后。

确保将app.use('/static', express.static('dist'));直接放置在app.use(bodyParser.urlencoded({extended: false }));之下。否则,如果在这个server/server.js文件中放置不当,它可能不会工作。

在完成express.static的前面工作后,让我们使这个函数更加完整:

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

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

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

简而言之,当用户第一次访问网站时,我们的服务器将发送这段 HTML 代码,因此我们需要创建带有 body 和 head 的 HTML 标记,以便使其工作。服务器端发布应用的 header 只是临时性的,用于检查我们是否正确地获取了服务器端 HTML 模板。稍后你可以使用以下命令找到$html

${html}  

注意,我们正在使用带有&grave;的 ES6 模板(Google ES6 模板字面量)语法。

在这里,我们稍后会放置由renderToStaticMarkup函数生成的值。renderFullPage函数的最后一步是在窗口中提供初始的服务器端渲染状态,使用window.INITIAL_STATE = ${JSON.stringify(initialState)},这样应用就可以在客户端正确地使用从后端获取的数据工作,当第一次向服务器发出请求时。

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

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

用以下更完整的函数版本替换:

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

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

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

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

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

        const initialState = store.getState() 

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

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

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

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

const initialState = store.getState(); 

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

如你所见,我们正在使用renderToStaticMarkup创建服务器端的 HTML 标记。在这个函数内部,有一个使用之前通过let initMOCKstore = fetchServerSide()获取的 store 的 Provider。在 Redux Provider 内部,我们有RoutingContext,它简单地传递所有必要的 props 到我们的应用,这样我们就可以在服务器端创建正确的标记。

在完成所有这些之后,我们只需要用const initialState = store.getState();来准备我们的 Redux Store 的initialState,然后使用let fullHTML = renderFullPage(html, initialState);来获取发送给客户端所需的一切,使用res.send(fullHTML)

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

重新检查 server/server.js

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

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

const app = express(); 

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

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

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

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

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

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

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

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

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

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

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

app.use(handleServerSideRender); 

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

export default app; 

这里你就有在服务器端进行渲染所需的一切。让我们继续进行前端方面的改进。

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

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

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

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

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

从前面的代码中,需要添加的新内容是:

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

此外,改进render函数并将default导出为:

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

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

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

我们还需要改进发布应用程序组件中的WillMount/_fetch函数,使其仅在前端触发。然后转到src/layouts/PublishingApp.js文件,替换以下旧代码:

componentWillMount() { 
  this._fetch(); 
} 

用以下新改进的代码替换它:

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

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

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

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

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

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

正如你所看到的,我们已经删除了这部分代码:

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

我们已经做了以下更改:

<Router history={noQueryKeyHistory}> 

变为以下内容:

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

我们为什么要做所有这些?这有助于我们从客户端浏览器 URL 中去除/#/标志,因此下次当我们点击例如http://localhost:3000/register时,我们的server.js可以看到用户当前的 URL,即我们在handleServerSideRender函数中使用的req.path(在我们的情况下,当点击http://localhost:3000/register时,req.path等于/register)。

在完成所有这些之后,你将在客户端浏览器中看到以下内容:

在 1-2 秒后,它将变为以下内容,因为PublishingApp.js中触发了真实的this._fetch()函数:

当然,你可以通过查看页面 HTML 源代码来查看服务器端渲染的标记:

摘要

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

在取消模拟服务器端数据库查询后,我们将开始改进应用程序的整体外观并实现一些对我们来说非常重要的关键功能,例如添加/编辑/删除文章。

第四章:客户端的高级 Redux 和 Falcor

Redux 是我们应用程序的状态容器,它保存有关 React 视图层如何在浏览器中渲染的信息。另一方面,Falcor 与 Redux 不同,因为它是一个全栈工具集,它取代了过时的 API 端点数据通信方法。在下一页中,我们将从客户端使用 Falcor,但你需要记住 Factor 是一个全栈库。这意味着,我们需要在两端使用它(在后端我们使用一个名为 Falcor-Router 的附加库)。从第五章,“Falcor 高级概念”开始,我们将使用全栈 Falcor。而在当前章节中,我们将只关注客户端。

专注于应用程序的前端

目前,我们的应用程序是一个简单的入门套件,它是进一步开发的骨架。我们需要更多地关注面向客户的客户端前端,因为在这个时代拥有一个好看的客户端前端非常重要。多亏了 Material UI,我们可以重用许多东西来使我们的应用程序看起来更漂亮。

需要注意的是,响应式网页设计目前(以及总体上)不在此书的范围内,因此你需要找出如何改进所有样式以适应移动设备。我们将要工作的应用程序在平板电脑上看起来很好,但小屏幕手机可能看起来不那么好。

在本章中,我们将集中精力做以下工作:

  • 取消模拟fetchServerSide.js

  • 添加一个新的ArticleCard组件,这将使我们的主页对用户来说更加专业

  • 改进我们应用程序的一般外观

  • 实现登出功能

  • Draft.js中添加一个所见即所得(WYSIWYG)编辑器,这是一个由 Facebook 团队为 React 创建的富文本编辑器框架

  • 在我们的 Redux 前端应用程序中添加创建新文章的功能

在前端改进之前完成后端工作

在上一章中,我们执行了服务器端渲染,这将影响我们的用户,使他们能够更快地看到他们的文章,并且由于整个 HTML 标记都在服务器端渲染,这将提高我们网站的 SEO。

要使我们的服务器端渲染工作达到 100%,最后一步是取消模拟/server/fetchServerSide.js中的服务器端文章获取。获取的新代码如下:

import configMongoose from './configMongoose'; 
const Article = configMongoose.Article; 

export default () => { 
  return Article.find({}, function(err, articlesDocs) { 
    return articlesDocs; 
  }).then ((articlesArrayFromDB) => { 
    return articlesArrayFromDB; 
  }); 
}

Article.find (the find function comes from Mongoose). You can also find that we are returning an array of articles that are fetched from our MongoDB.

改进handleServerSideRender

下一步是对handleServerSideRender函数进行微调,该函数目前保存在/server/server.js文件中。当前的函数如下代码片段所示:

// te following code should already be in your codebase: 
let handleServerSideRender = (req, res, next) => { 
try { 
    let initMOCKstore = fetchServerSide(); // mocked for now 

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

我们需要用这个改进版本来替换它:

// this is an improved version: 
let handleServerSideRender = async (req, res, next) => { 
  try { 
    let articlesArray = await fetchServerSide(); 
    let initMOCKstore = { 
      article: articlesArray 
    } 

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

我们改进的handleServerSideRender有什么新内容?如您所见,我们添加了async await。回想一下,它帮助我们使代码在异步调用(如对数据库的查询)上不那么痛苦(看起来同步的生成器风格代码)。这个 ES7 特性使我们能够将异步调用写成同步的样子——在底层,async await要复杂得多(在它被转换为 ES5 之后,以便可以在任何现代浏览器中运行),但我们将不会深入探讨async await是如何工作的,因为这不在这个章节的范围内。

在 Falcor 中更改路由(前端和后端)

您还需要将两个 ID 变量名更改为_id_id是 Mongo 集合中文档 ID 的默认名称)。

server/routes.js中查找以下旧代码:

route: 'articles[{integers}]["id","articleTitle","articleContent"]',

将其更改为以下内容:

route: 'articles[{integers}]["_id","articleTitle","articleContent"]',

唯一的改变是我们将返回_id而不是id。我们需要在src/layouts/PublishingApp.js中获取_id值,所以找到以下代码片段:

get(['articles', {from: 0, to: articlesLength-1}, ['id','articleTitle', 'articleContent']]).

将其更改为带有_id的新路由:

get(['articles', {from: 0, to: articlesLength-1}, ['_id','articleTitle', 'articleContent']]).

我们的网站页眉和文章列表需要改进

由于我们已经完成了服务器端渲染和从数据库中获取文章的封装,让我们从前端开始。

首先,从server/server.js中删除以下标题;我们不再需要它了:

<h1>Server side publishing app</h1>

您也可以在src/layouts/PublishingApp.js中删除这个标题:

<h1>Our publishing app</h1>

在注册和登录视图(src/LoginView.js)中删除h1标记:

<h1>Login view</h1>

src/RegisterView.js中删除注册:

<h1>Register</h1>

所有这些h1行都不需要,因为我们想要一个看起来更美观的设计,而不是过时的设计。

然后,转到src/CoreLayout.js并从 Material UI 导入一个新的AppBar组件和两个按钮组件:

import AppBar from 'material-ui/lib/app-bar'; 
import RaisedButton from 'material-ui/lib/raised-button'; 
import ActionHome from 'material-ui/lib/svg-icons/action/home';

将此AppBar连同内联样式一起添加到render中:

 render () { 
    const buttonStyle = { 
      margin: 5 
    }; 
    const homeIconStyle = { 
      margin: 5, 
      paddingTop: 5 
    }; 

    let menuLinksJSX = ( 
    <span> 
        <Link to='/register'> 
       <RaisedButton label='Register' style={buttonStyle}  /> 
     </Link>  
        <Link to='/login'> 
       <RaisedButton label='Login' style={buttonStyle}  /> 
     </Link>  
      </span>); 

    let homePageButtonJSX = ( 
     <Link to='/'> 
          <RaisedButton label={<ActionHome />} 
           style={homeIconStyle}  /> 
        </Link>); 

    return ( 
      <div> 
        <AppBar 
          title='Publishing App' 
          iconElementLeft={homePageButtonJSX} 
          iconElementRight={menuLinksJSX} /> 
          <br/> 
          {this.props.children} 
      </div> 
    ); 
  }

我们为buttonStylehomeIconStyle添加了内联样式。menuLinksJSXhomePageButtonJSX的视觉输出将会得到改善。这就是在那些AppBar更改之后您的应用将呈现的样子:

新的 ArticleCard 组件

为了改进主页的外观,下一步是使文章卡片基于 Material Design CSS。让我们首先创建一个组件文件:

$ [[you are in the src/components/ directory of your project]]
$ touch ArticleCard.js

然后,在ArticleCard.js文件中,让我们使用以下内容初始化ArticleCard组件:

import React from 'react'; 
import {  
  Card,  
  CardHeader,  
  CardMedia,  
  CardTitle,  
  CardText  
} from 'material-ui/lib/card'; 
import {Paper} from 'material-ui'; 

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

  render() { 
    return <h1>here goes the article card</h1>; 
  } 
}; 
export default ArticleCard;

如您在前面的代码中可以看到,我们已从 material-ui/card 导入所需的组件,这将帮助我们的主页文章列表看起来更美观。下一步是使用以下内容改进文章卡片的render函数:

render() { 
  let title = this.props.title || 'no title provided'; 
  let content = this.props.content || 'no content provided'; 

  const paperStyle = { 
    padding: 10,  
    width: '100%',  
    height: 300 
  }; 

  const leftDivStyle = { 
    width: '30%',  
    float: 'left' 
  }; 

  const rightDivStyle = { 
    width: '60%',  
    float: 'left',  
    padding: '10px 10px 10px 10px' 
  }; 

  return ( 
    <Paper style={paperStyle}> 
      <CardHeader 
        title={this.props.title} 
        subtitle='Subtitle' 
        avatar='/static/avatar.png' 
      /> 

      <div style={leftDivStyle}> 
        <Card > 
          <CardMedia 
            overlay={<CardTitle title={title} 
             subtitle='Overlay subtitle' />}> 
            <img src='/static/placeholder.png' height="190" /> 
          </CardMedia> 
        </Card> 
      </div> 
      <div style={rightDivStyle}> 
        {content} 
      </div> 
    </Paper>); 
}

如您在前面的代码中可以看到,我们创建了一个文章卡片,并为Paper组件以及左右div添加了一些内联样式。如果您想的话,可以随意更改这些样式。

通常,我们在之前的render函数中缺少两张静态图片,分别是src= '/static/placeholder.png'avatar='/static/avatar.png'。让我们按照以下步骤添加它们:

  1. dist 目录中创建一个名为 placeholder.png 的 PNG 文件。在我的情况下,我的 placeholder.png 文件如下所示:

  1. 还需要在 dist 目录中创建一个 avatar.png 文件,它将在 /static/avatar.png 中公开。这里不提供截图,因为它包含我的个人照片。

express.js 中的 /static/ 文件在 /server/server.js 文件中使用 codeapp.use('/static', express.static('dist')); 公开(您已经在其中添加了它,因为我们已经在上一章中添加了它)。

最后,您需要导入 ArticleCard 并修改 layouts/PublishingApp.js 的渲染,从旧的单视图修改为新视图。

在文件顶部添加 import

import ArticleCard from '../components/ArticleCard';

然后,用这个新视图替换渲染:

render () { 

  let articlesJSX = []; 
  for(let articleKey in this.props.article) { 
    const articleDetails = this.props.article[articleKey]; 

    const currentArticleJSX = ( 
      <div key={articleKey}> 
        <ArticleCard  
          title={articleDetails.articleTitle} 
          content={articleDetails.articleContent} /> 
      </div> 
    ); 

    articlesJSX.push(currentArticleJSX); 
  } 
  return ( 
    <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        {articlesJSX} 
    </div> 
  ); 
}

上述新代码仅在此新的 ArticleCard 组件中有所不同:

<ArticleCard  
  title={articleDetails.articleTitle} 
  content={articleDetails.articleContent} />

我们还在 div style={{height: '100%', width: '75%', margin: 'auto'}} 中添加了一些样式。

在完全按照这些步骤执行样式的情况下,您将看到以下内容:

这是注册用户视图:

这是登录用户视图:

仪表板 - 添加文章按钮、注销和页眉改进

我们目前的计划是创建一个注销机制,让我们的页眉知道用户是否已登录,并基于此信息在页眉中显示不同的按钮(当用户未登录时显示“登录/注册”,当用户登录时显示“仪表板/注销”)。我们将在仪表板中创建一个“添加文章”按钮,并创建一个带有模拟 WYSIWYG 的模拟视图(我们稍后会取消模拟)。

WYSIWYG 代表 所见即所得,当然。

WYSIWYG 模拟将位于 src/components/articles/WYSIWYGeditor.js,因此您需要在 components 中创建一个新的目录和文件,以下是一些命令:

$ [[you are in the src/components/ directory of your project]]
$ mkdir articles
$ cd articles
$ touch WYSIWYGeditor.js

然后我们的 WYSIWYGeditor.js 模拟内容将如下所示:

import React from 'react'; 

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

  render() { 
    return <h1>WYSIWYGeditor</h1>; 
  } 
}; 
export default WYSIWYGeditor;

下一步是在 src/views/LogoutView.js 中创建一个注销视图:

$ [[you should be at src/views/ directory of your project]]
$ touch LogoutView.js

src/views/LogoutView.js 文件的内容如下:

import React from 'react'; 
import {Paper} from 'material-ui'; 

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

  componentWillMount() { 
    if (typeof localStorage !== 'undefined' && localStorage.token) { 
      delete localStorage.token; 
      delete localStorage.username; 
      delete localStorage.role; 
    } 
  } 

  render () { 
    return ( 
      <div style={{width: 400, margin: 'auto'}}> 
        <Paper zDepth={3} style={{padding: 32, margin: 32}}> 
          Logout successful. 
        </Paper> 
      </div> 
    ); 
  } 
} 
export default LogoutView;

这里提到的 logout 视图是一个没有连接到 Redux 的简单视图(与 LoginView.js 相比)。我们使用一些样式使其看起来更美观,使用了 Material UI 的 Paper 组件。

当用户访问注销页面时,componentWillMount 函数将从 localStorage 信息中删除。如您所见,它还检查是否存在带有 **if(typeof localStorage !== 'undefined' && localStorage.token) **localStorage,因为正如您所想象的那样,当您执行服务器端渲染时,localStorage 是未定义的(服务器端没有 localStoragewindow,就像客户端一样)。

在创建前端添加文章功能之前的重要注意事项

我们已经到达了需要从你的 MongoDB 文章集合中删除所有文档的点,否则在执行下一步之前你可能会有一些麻烦,因为我们将要使用 draft-js 库和一些其他东西,它们在服务器端需要一个新的模式。我们将在下一章创建该后端模式,因为本章专注于前端。

现在立即删除你的 MongoDB 文章集合中的所有文档,但保留用户集合不变(不要从数据库中删除用户)。

AddArticleView 组件

在创建了LogoutViewWYSIWYGeditor组件之后,让我们创建我们流程中缺失的最后一个组件:src/views/articles/AddArticleView.js文件。现在让我们创建一个目录和文件:

$ [[you are in the src/views/ directory of your project]]
$ mkdir articles
$ cd articles
$ touch AddArticleView.js

因此,你将在views/articles目录中找到该文件。我们需要将其内容放入其中:

import React from 'react'; 
import {connect} from 'react-redux'; 
import WYSIWYGeditor from '../../components/articles/WYSIWYGeditor.js'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

const mapDispatchToProps = (dispatch) => ({ 

}); 

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

  render () { 
    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Add Article</h1> 
        <WYSIWYGeditor /> 
      </div> 
    ); 
  } 
} 
export default connect(mapStateToProps, mapDispatchToProps)(AddArticleView);

如此可见,这是一个简单的 React 视图,它导入了我们刚才创建的WYSIWYGeditor组件(import WYSIWYGeditor from '../../components/articles/WYSIWYGeditor.js')。我们有一些内联样式,以便让视图对用户看起来更美观。

让我们通过修改位于**src/routes/index.js*位置的routes文件来创建两个新的路由,一个用于登出,另一个用于添加文章功能:

import React from 'react'; 
import {Route, IndexRoute} from 'react-router'; 
import CoreLayout from '../layouts/CoreLayout'; 
import PublishingApp from '../layouts/PublishingApp'; 
import LoginView from '../views/LoginView'; 
import LogoutView from '../views/LogoutView'; 
import RegisterView from '../views/RegisterView'; 
import DashboardView from '../views/DashboardView'; 
import AddArticleView from '../views/articles/AddArticleView'; 

export default ( 
  <Route component={CoreLayout} path='/'> 
    <IndexRoute component={PublishingApp} name='home' /> 
    <Route component={LoginView} path='login' name='login' /> 
    <Route component={LogoutView} path='logout' name='logout' /> 
    <Route component={RegisterView} path='register' 
       name='register' /> 
    <Route component={DashboardView} path='dashboard' 
       name='dashboard' /> 
    <Route component={AddArticleView} path='add-article' 
       name='add-article' /> 
  </Route> 
);

如同在src/routes/index.js文件中解释的那样,我们添加了两个路由:

  • <Route component={LogoutView} path='logout' name='logout' />

  • <Route component={AddArticleView} path='add-article' name='add-article' />

不要忘记使用以下方式导入这两个视图的组件:

import LogoutView from '../views/LogoutView'; 
import AddArticleView from '../views/articles/AddArticleView';

现在,我们已经创建了视图并创建了进入该视图的路由。最后一步是在我们的应用中显示这两个路由的链接。

首先,让我们创建src/layouts/CoreLayout.js组件,以便它将有一个登录/登出类型的登录,这样登录用户将看到与未登录用户不同的按钮。修改CoreLayout组件中的render函数如下:

  render () { 
    const buttonStyle = { 
      margin: 5 
    }; 
    const homeIconStyle = { 
      margin: 5, 
      paddingTop: 5 
    }; 

    let menuLinksJSX; 
    let userIsLoggedIn = typeof localStorage !== 'undefined' &&  
     localStorage.token && this.props.routes[1].name !== 'logout'; 

    if (userIsLoggedIn) { 
      menuLinksJSX = ( 
     <span> 
          <Link to='/dashboard'> 
      <RaisedButton label='Dashboard' style={buttonStyle}  /> 
    </Link>  
          <Link to='/logout'> 
      <RaisedButton label='Logout' style={buttonStyle}  /> 
    </Link>  
      </span>); 
    } else { 
      menuLinksJSX = ( 
     <span> 
         <Link to='/register'> 
      <RaisedButton label='Register' style={buttonStyle}  /> 
    </Link>  
           <Link to='/login'> 
       <RaisedButton label='Login' style={buttonStyle}  /> 
     </Link>  
       </span>); 
    } 

    let homePageButtonJSX = ( 
      <Link to='/'> 
        <RaisedButton label={<ActionHome />} style={homeIconStyle}  
         /> 
      </Link>); 

    return ( 
      <div> 
        <AppBar 
          title='Publishing App' 
          iconElementLeft={homePageButtonJSX} 
          iconElementRight={menuLinksJSX} /> 
          <br/> 
          {this.props.children} 
      </div> 
    ); 
  }

你可以看到,前面代码中的新部分如下:

  let menuLinksJSX; 
  let userIsLoggedIn = typeof localStorage !== 
  'undefined' && localStorage.token && this.props.routes[1].name 
   !== 'logout'; 

  if (userIsLoggedIn) { 
    menuLinksJSX = ( 
  <span> 
        <Link to='/dashboard'> 
    <RaisedButton label='Dashboard' style={buttonStyle}  /> 
  </Link>  
        <Link to='/logout'> 
    <RaisedButton label='Logout'style={buttonStyle}  /> 
  </Link>  
      </span>); 
  } else { 
    menuLinksJSX = ( 
  <span> 
        <Link to='/register'> 
    <RaisedButton label='Register' style={buttonStyle}  /> 
  </Link>  
        <Link to='/login'> 
    <RaisedButton label='Login' style={buttonStyle}  /> 
  </Link>  
      </span>); 
  }

我们添加了let userIsLoggedIn = typeof localStorage !== 'undefined' && localStorage.token && this.props.routes[1].name !== 'logout';。如果不在服务器端(如前所述,它没有localStorage),则找到userIsLoggedIn变量。然后,它检查localStorage.token是否为yes,并使用this.props.routes[1].name !== 'logout'表达式检查用户是否没有点击登出按钮。this.props.routes[1].name值/信息由redux-simple-routerreact-router提供。这始终是客户端当前路由的名称,因此我们可以根据该信息渲染适当的按钮。

修改 DashboardView

正如你将发现的,我们添加了if (userIsLoggedIn)语句,新部分是包含到正确路由的仪表板和登出RaisedButton实体。

在这个阶段,最后要完成的部分是修改 src/views/DashboardView.js 组件。使用从 react-router 导入的 {Link} 组件添加到 /add-article 路由的链接。此外,我们需要导入新的 Material UI 组件,以便使 DashboardView 更美观:

import {Link} from 'react-router'; 
import List from 'material-ui/lib/lists/list'; 
import ListItem from 'material-ui/lib/lists/list-item'; 
import Avatar from 'material-ui/lib/avatar'; 
import ActionInfo from 'material-ui/lib/svg-icons/action/info'; 
import FileFolder from 'material-ui/lib/svg-icons/file/folder'; 
import RaisedButton from 'material-ui/lib/raised-button'; 
import Divider from 'material-ui/lib/divider';

在您将所有这些导入到您的 src/views/DashboardView.js 文件之后,然后我们需要开始改进 render 函数:

render () { 

    let articlesJSX = []; 
    for(let articleKey in this.props.article) { 
      const articleDetails = this.props.article[articleKey]; 
      const currentArticleJSX = ( 
        <ListItem 
          key={articleKey} 
          leftAvatar={<img  
          src='/static/placeholder.png'  
          width='50'  
          height='50' />} 
          primaryText={articleDetails.articleTitle} 
          secondaryText={articleDetails.articleContent} 
        /> 
      ); 

      articlesJSX.push(currentArticleJSX); 
    } 
    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <Link to='/add-article'> 
          <RaisedButton  
            label='Create an article'  
            secondary={true}  
            style={{margin: '20px 20px 20px 20px'}} /> 
        </Link> 

        <List> 
          {articlesJSX} 
        </List> 
      </div> 
    ); 
  }

在这里,我们为 DashboardView 创建了新的 render 函数。我们使用 ListItem 组件来制作我们漂亮的项目列表。我们还添加了链接和按钮到 /add-article 路由。有一些内联样式,但请随意根据您的喜好来设计这个应用。

让我们看看在添加了创建文章按钮并添加了文章的新视图之后,应用的所有这些变化后的截图:

图片

在模拟了 /add-article 视图中的 WYSIWYG 之后:

图片

我们新的注销视图页面看起来是这样的:

图片

开始我们的 WYSIWYG 工作吧

让我们安装一个 draft-js 库,它是一个“在 React 中构建富文本编辑器的框架,由不可变模型驱动,并抽象了跨浏览器的差异”,正如他们在网站上所描述的。

通常,draft-js 是由 Facebook 的朋友们制作的,它帮助我们创建强大的 WYSIWYG 工具。在我们的发布应用中,这将很有用,因为我们希望为我们的编辑提供良好的工具,以便在我们的平台上创建有趣的文章。

让我们先安装它:

npm i --save draft-js@0.5.0

我们将在本书中使用 draft-js 的 0.5.0 版本。在我们开始编码之前,让我们安装一个额外的依赖项,这将有助于我们稍后通过 Falcor 从数据库中获取文章。执行以下命令:

npm i --save falcor-json-graph@1.1.7

通常,falcor-json-graph@1.1.7 语法为我们提供了使用通过 Falcor 辅助库提供的不同哨兵的能力(这将在下一章中详细描述)。

draft-js WYSIWYG 的样式表

为了样式化 draft-js 编辑器,我们需要在 dist 文件夹中创建一个新的 CSS 文件,位于 dist/styles-draft-js.css。这是我们唯一放置 CSS 样式表的地方:

.RichEditor-root { 
  background: #fff; 
  border: 1px solid #ddd; 
  font-family: 'Georgia', serif; 
  font-size: 14px; 
  padding: 15px; 
} 

.RichEditor-editor { 
  border-top: 1px solid #ddd; 
  cursor: text; 
  font-size: 16px; 
  margin-top: 10px; 
  min-height: 100px; 
} 

.RichEditor-editor .RichEditor-blockquote { 
  border-left: 5px solid #eee; 
  color: #666; 
  font-family: 'Hoefler Text', 'Georgia', serif; 
  font-style: italic; 
  margin: 16px 0; 
  padding: 10px 20px; 
} 

.RichEditor-controls { 
  font-family: 'Helvetica', sans-serif; 
  font-size: 14px; 
  margin-bottom: 5px; 
  user-select: none; 
} 

.RichEditor-styleButton { 
  color: #999; 
  cursor: pointer; 
  margin-right: 16px; 
  padding: 2px 0; 
} 

.RichEditor-activeButton { 
  color: #5890ff; 
}

在您在 dist/styles-draft-js.css 创建了这个文件之后,我们需要将其导入到 server/server.js 中,在那里我们一直在创建 HTML 头部,所以以下代码已经存在于 server.js 文件中:

let renderFullPage = (html, initialState) => 
{ 
  return &grave; 
    <!doctype html> 
    <html> 
      <head> 
        <title>Publishing App Server Side Rendering</title> 
        <link rel="stylesheet" type="text/css" 
         href="/static/styles-draft-js.css" /> 
      </head> 
      <body> 
        <div id="publishingAppRoot">${html}</div> 
        <script> 
          window.__INITIAL_STATE__ = 
           ${JSON.stringify(initialState)} 
        </script> 
        <script src="img/app.js"></script> 
      </body> 
    </html> 
    &grave; 
};

然后您需要包含样式表的链接,如下所示:

<link rel="stylesheet" type="text/css" href="/static/styles-draft- 
 js.css" />

到目前为止,还没有什么特别之处。在我们完成我们的富文本 WYSIWYG 编辑器的样式之后,让我们享受一些乐趣。

编码 draft-js 骨架

让我们回到 src/components/articles/WYSIWYGeditor.js 文件。它目前是模拟的,但我们将现在对其进行改进。

顺便提醒一下,我们现在将创建一个 WYSIWYG 的骨架。我们将在本书的后续章节中对其进行改进。到目前为止,WYSIWYG 不会有任何功能,例如使文本加粗或使用 OL 和 UL 元素创建列表。

import React from 'react'; 
import { 
  Editor,  
  EditorState,  
  ContentState,  
  RichUtils,  
  convertToRaw, 
  convertFromRaw 
} from 'draft-js'; 

export default class   WYSIWYGeditor extends React.Component { 
  constructor(props) { 
    super(props); 

    let initialEditorFromProps = 
     EditorState.createWithContent 
     (ContentState.createFromText('')); 

    this.state = { 
      editorState: initialEditorFromProps 
    }; 

    this.onChange = (editorState) => {  
      var contentState = editorState.getCurrentContent(); 

      let contentJSON = convertToRaw(contentState); 
      props.onChangeTextJSON(contentJSON, contentState); 
      this.setState({editorState})  
    }; 
  } 

  render() { 
    return <h1>WYSIWYGeditor</h1>; 
  } 
}

在这里,我们只创建了我们的新 draft-js 文件的 WYSIWYG 的构造函数。let initialEditorFromProps = EditorState.createWithContent(ContentState.createFromText(''));这个表达式只是创建了一个空的 WYSIWYG 容器。稍后,我们将对其进行改进,以便我们能够在想要编辑 WYSIWYG 时从数据库接收ContentState

editorState: initialEditorFromProps是我们当前的状态。我们的**this.onChange = (editorState) => { **这一行会在每次更改时触发,因此我们的src/views/articles/AddArticleView.js视图组件将知道 WYSIWYG 中的任何更改。

无论如何,你可以在facebook.github.io/draft-js/查看 draft-js 的文档。

这只是开始;下一步是在onChange下添加两个新功能:

this.focus = () => this.refs['refWYSIWYGeditor'].focus(); 
this.handleKeyCommand = (command) => this._handleKeyCommand(command);

在我们的WYSIWYGeditor类中添加一个新函数:

_handleKeyCommand(command) { 
   const {editorState} = this.state; 
   const newState = RichUtils.handleKeyCommand(editorState, 
    command); 

   if (newState) { 
     this.onChange(newState); 
     return true; 
   } 
   return false; 
 }

在所有这些更改之后,你的WYSIWYGeditor类的构建应该看起来像这样:

export default class   WYSIWYGeditor extends React.Component { 
  constructor(props) { 
    super(props); 

    let initialEditorFromProps = 
     EditorState.createWithContent 
     (ContentState.createFromText('')); 

    this.state = { 
      editorState: initialEditorFromProps 
    }; 

    this.onChange = (editorState) => {  
      var contentState = editorState.getCurrentContent(); 

      let contentJSON = convertToRaw(contentState); 
      props.onChangeTextJSON(contentJSON, contentState); 
      this.setState({editorState}); 
    }; 

    this.focus = () => this.refs['refWYSIWYGeditor'].focus(); 
    this.handleKeyCommand = (command) => 
     this._handleKeyCommand(command); 
  }

这个类的其余部分如下:

  _handleKeyCommand(command) { 
    const {editorState} = this.state; 
    const newState = RichUtils.handleKeyCommand(editorState, 
     command); 

    if (newState) { 
      this.onChange(newState); 
      return true; 
    } 
    return false; 
  } 

  render() { 
    return <h1> WYSIWYGeditor</h1>; 
  } 
}

下一步是使用以下代码改进render函数:

 render() { 
    const { editorState } = this.state; 
    let className = 'RichEditor-editor'; 
    var contentState = editorState.getCurrentContent(); 

    return ( 
      <div> 
        <h4>{this.props.title}</h4> 
        <div className='RichEditor-root'> 
          <div className={className} onClick={this.focus}> 
            <Editor 
              editorState={editorState} 
              handleKeyCommand={this.handleKeyCommand} 
              onChange={this.onChange} 
              ref='refWYSIWYGeditor' /> 
          </div> 
        </div> 
      </div> 
    ); 
  }

在这里,我们所做的是简单地使用 draft-js API 来制作一个简单的富文本编辑器;稍后,我们将使其更加功能化,但现在让我们专注于简单的事情。

改进views/articles/AddArticleView组件

在我们添加所有 WYSIWYG 功能(如加粗)之前,我们需要通过以下方式使用一些东西来改进views/articles/AddArticleView.js组件:安装一个库,该库将 convert draft-js 状态转换为纯 HTML:

npm i --save draft-js-export-html@0.1.13

我们将使用这个库来保存只读的纯 HTML,供我们的普通读者使用。接下来,将其导入到src/views/articles/AddArticleView.js

import { stateToHTML } from 'draft-js-export-html';

通过更改构造函数并添加一个名为_onDraftJSChange的新函数来改进AddArticleView

class AddArticleView extends React.Component { 
  constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 

    this.state = { 
      contentJSON: {}, 
      htmlContent: '' 
    }; 
  } 

  _onDraftJSChange(contentJSON, contentState) { 
    let htmlContent = stateToHTML(contentState); 
    this.setState({contentJSON, htmlContent}); 
  }

我们需要在每次更改时保存this.setState({contentJSON, htmlContent});的状态。这是因为contentJSON将被保存到数据库中,以便我们有关于我们的 WYSIWYG 不可变信息的记录,而htmlContent将是我们的读者服务器。htmlContentcontentJSON变量都将保存在文章集合中。AddArticleView类中的最后一件事是修改render到新代码,如下所示:

render () { 
   return ( 
     <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
       <h1>Add Article</h1> 
       <WYSIWYGeditor 
         initialValue='' 
         title='Create an article' 
         onChangeTextJSON={this._onDraftJSChange} /> 
     </div> 
   ); 
 }

在所有这些更改之后,你将看到的新视图如下:

为我们的 WYSIWYG 添加更多格式化功能

让我们从我们的 WYSIWYG 的第二版开始工作,它提供了更多选项,如下所示:

在遵循这里提到的步骤之后,你将能够按照以下方式格式化文本,并从中提取 HTML 标记,这样我们就可以在我们的 MongoDB 文章集合中保存 WYSIWYG 的 JSON 状态和纯 HTML。

在以下新文件中,称为WYSIWYGbuttons.js,我们将导出两个不同的类,并且我们将使用以下方式将它们导入到components/articles/WYSWIWYGeditor.js

// don't write it, this is only an example:
import { BlockStyleControls, InlineStyleControls } from 
 './wysiwyg/WYSIWY
    Gbuttons';

通常,这个新文件将包含三个不同的 React 组件,如下所示:

  • StyleButton:这将是用于BlockStyleControlsInlineStyleControls的通用样式按钮。不要因为WYSIWYGbuttons文件中首先创建StyleButton React 组件而感到困惑。

  • BlockStyleControls:这是一个导出的组件,将用于块控件,如H1H2BlockquoteULOL

  • InlineStyleControls:此组件用于粗体、斜体和下划线。

现在我们知道在新文件中,您将创建三个独立的 React 组件。

首先,我们需要在src/components/articles/wysiwyg/WYSIWYGbuttons.js位置创建 WYSWYG 按钮:

$ [[you are in the src/components/articles directory of your project]]
$ mkdir wysiwyg
$ cd wysiwyg
$ touch  WYSIWYGbuttons.js

该文件的内容将是按钮组件:

import React from 'react'; 

class StyleButton extends React.Component { 
  constructor() { 
    super(); 
    this.onToggle = (e) => { 
      e.preventDefault(); 
      this.props.onToggle(this.props.style); 
    }; 
  } 

  render() { 
    let className = 'RichEditor-styleButton'; 
    if (this.props.active) { 
      className += ' RichEditor-activeButton'; 
    } 

    return ( 
      <span className={className} onMouseDown={this.onToggle}> 
        {this.props.label} 
      </span> 
    ); 
  } 
}

上述代码为我们提供了一个具有特定标签this.props.label的可重用按钮。如前所述,不要与WYSIWYGbuttons混淆;它是一个通用的按钮组件,将在内联和块类型按钮控件中重用。

在该组件下,您可以放置以下对象:

const BLOCK_TYPES = [ 
  {label: 'H1', style: 'header-one'}, 
  {label: 'H2', style: 'header-two'}, 
  {label: 'Blockquote', style: 'blockquote'}, 
  {label: 'UL', style: 'unordered-list-item'}, 
  {label: 'OL', style: 'ordered-list-item'} 
];

此对象是块类型,我们可以在我们的 draft-js WYSWYG 中创建它。它用于以下组件:

export const BlockStyleControls = (props) => { 
  const {editorState} = props; 
  const selection = editorState.getSelection(); 
  const blockType = editorState 
    .getCurrentContent() 
    .getBlockForKey(selection.getStartKey()) 
    .getType(); 

  return ( 
    <div className='RichEditor-controls'> 
      {BLOCK_TYPES.map((type) => 
        <StyleButton 
          key={type.label} 
          active={type.style === blockType} 
          label={type.label} 
          onToggle={props.onToggle} 
          style={type.style} 
        /> 
      )} 
    </div> 
  ); 
};

上述代码是一系列用于块样式格式的按钮。我们将在稍后将其导入WYSIWYGeditor。如您所见,我们使用export const BlockStyleControls = (props) => {导出它。

BlockStyleControls组件下放置下一个对象,但这次是为了内联样式,如Bold

var INLINE_STYLES = [ 
  {label: 'Bold', style: 'BOLD'}, 
  {label: 'Italic', style: 'ITALIC'}, 
  {label: 'Underline', style: 'UNDERLINE'} 
];

如您所见,在我们的 WYSWYG 中,编辑器将能够使用粗体、斜体和下划线。

对于所有这些内联样式,您可以放置的最后一个组件如下:

export const InlineStyleControls = (props) => { 
  var currentStyle = props.editorState.getCurrentInlineStyle(); 
  return ( 
    <div className='RichEditor-controls'> 
      {INLINE_STYLES.map(type => 
        <StyleButton 
          key={type.label} 
          active={currentStyle.has(type.style)} 
          label={type.label} 
          onToggle={props.onToggle} 
          style={type.style} 
        /> 
      )} 
    </div> 
  ); 
};

如您所见,这非常简单。我们每次都在映射块和内联样式定义的样式,并根据每次迭代创建StyleButton

下一步是将InlineStyleControlsBlockStyleControls导入我们的WYSIWYGeditor组件(src/components/articles/WYSIWYGeditor.js):

import { BlockStyleControls, InlineStyleControls } from './wysiwyg/WYSIWYGbuttons';

然后,在WYSIWYGeditor构造函数中,包含以下代码:

this.toggleInlineStyle = (style) => 
this._toggleInlineStyle(style); 
this.toggleBlockType = (type) => this._toggleBlockType(type);

绑定到toggleInlineStyletoggleBlockType两个箭头函数,这些函数将作为有人选择切换以在WYSIWYGeditor中使用内联或块类型时的回调(我们将在稍后创建这些函数)。

创建这两个新函数:

 _toggleBlockType(blockType) {this.onChange( 
      RichUtils.toggleBlockType( 
        this.state.editorState, 
        blockType 
      ) 
    ); 
  } 

  _toggleInlineStyle(inlineStyle) { 
    this.onChange( 
      RichUtils.toggleInlineStyle( 
        this.state.editorState, 
        inlineStyle 
      ) 
    ); 
  }

在这里,两个函数都使用 draft-js 的RichUtils来设置我们 WYSWYG 内部的标志。我们使用从import { BlockStyleControls, InlineStyleControls } from './wysiwg/WYSIWYGbuttons';中定义的BLOCK_TYPESINLINE_STYLES中的某些格式化选项。

在我们完成改进WYSIWYGeditor构造和_toggleBlockType_toggleInlineStyle函数后,然后我们可以开始改进我们的render函数:

 render() { 
    const { editorState } = this.state; 
    let className = 'RichEditor-editor'; 
    var contentState = editorState.getCurrentContent(); 

    return ( 
      <div> 
        <h4>{this.props.title}</h4> 
        <div className='RichEditor-root'> 
          <BlockStyleControls 
            editorState={editorState} 
            onToggle={this.toggleBlockType} /> 

          <InlineStyleControls 
            editorState={editorState} 
            onToggle={this.toggleInlineStyle} /> 

          <div className={className} onClick={this.focus}> 
            <Editor 
              editorState={editorState} 
              handleKeyCommand={this.handleKeyCommand} 
              onChange={this.onChange} 
              ref='refWYSIWYGeditor' /> 
          </div> 
        </div> 
      </div> 
    ); 
  }

如您可能注意到的,在前面的代码中,我们只添加了BlockStyleControlsInlineStyleControls组件。同时请注意,我们正在使用onToggle={this.toggleBlockType}onToggle={this.toggleInlineStyle}回调;这是为了在我们WYSIWYGbuttons和 draft-js RichUtils之间通信,了解用户点击了什么以及他们当前使用的是哪种模式(例如粗体、标题 1、UL 或 OL)。

将新文章推入文章 reducer

我们需要在src/actions/article.js位置创建一个名为pushNewArticle的新操作:

export default { 
  articlesList: (response) => { 
    return { 
      type: 'ARTICLES_LIST_ADD', 
      payload: { response: response } 
    } 
  }, 
  pushNewArticle: (response) => { 
    return { 
      type: 'PUSH_NEW_ARTICLE', 
      payload: { response: response } 
    } 
  } 
}

下一步是改进src/components/ArticleCard.js组件,通过改进其中的render函数:

return ( 
   <Paper style={paperStyle}> 
     <CardHeader 
       title={this.props.title} 
       subtitle='Subtitle' 
       avatar='/static/avatar.png' 
     /> 

     <div style={leftDivStyle}> 
       <Card > 
         <CardMedia 
           overlay={<CardTitle title={title} subtitle='Overlay 
            subtitle' />}> 
           <img src='/static/placeholder.png' height='190' /> 
         </CardMedia> 
       </Card> 
     </div> 
     <div style={rightDivStyle}> 
       <div dangerouslySetInnerHTML={{__html: content}} /> 
     </div> 
   </Paper>); 
}

在这里,我们将旧的{content}变量(它接收内容变量中的纯文本值)替换为一个新的变量,该变量在文章卡片中使用dangerouslySetInnerHTML显示所有 HTML:

<div dangerouslySetInnerHTML={{__html: content}} />

这将帮助我们向读者展示我们 WYSIWYG 生成的 HTML 代码。

MapHelpers 用于改进我们的 reducers

通常,所有 reducers 必须在发生变化时返回对象的新引用。在我们的第一个例子中,我们使用了Object.assign

// this already exsits in your codebasecase 'ARTICLES_LIST_ADD': 
let articlesList = action.payload.response; 
return Object.assign({}, articlesList);

我们将用 ES6 的 Maps 替换这个Object.assign方法。

case 'ARTICLES_LIST_ADD': 
  let articlesList = action.payload.response; 
  return mapHelpers.addMultipleItems(state, articlesList);

在前面的代码中,你可以找到一个新的ARTICLES_LIST_ADD,使用mapHelpers.addMultipleItems(state, articlesList)

为了制作我们的地图助手,我们需要创建一个名为utils的新目录和一个名为mapHelpers.js(src/utils/mapHelpers.js)的文件:

$ [[you are in the src/ directory of your project]]
$ mkdir utils
$ cd utils
$ touch mapHelpers.js

然后,你可以将这个第一个函数输入到那个src/utils/mapHelpers.js文件中:

const duplicate = (map) => { 
  const newMap = new Map(); 
  map.forEach((item, key) => { 
    if (item['_id']) { 
      newMap.set(item['_id'], item); 
    } 
  }); 
  return newMap; 
}; 

const addMultipleItems = (map, items) => { 
  const newMap = duplicate(map); 

  Object.keys(items).map((itemIndex) => { 
    let item = items[itemIndex]; 
    if (item['_id']) { 
      newMap.set(item['_id'], item); 
    } 
  }); 

  return newMap; 
};

这个重复项只是在内存中创建一个新的引用,以便使我们的不可变性成为 Redux 应用程序的要求。我们还通过if(key === item['_id'])检查是否存在一个边缘情况,即键与我们的对象 ID(_id中的_)不同(_在这里是故意的,因为这是 Mongoose 标记我们数据库中的 ID 的方式)。addMultipleItems函数将项目添加到新的重复映射中(例如,在文章成功获取后)。

我们需要的下一个代码更改是在同一文件src/utils/mapHelpers.js中:

const addItem = (map, newKey, newItem) => { 
  const newMap = duplicate(map); 
  newMap.set(newKey, newItem); 
  return newMap; 
}; 

const deleteItem = (map, key) => { 
  const newMap = duplicate(map); 
  newMap.delete(key); 

  return newMap; 
}; 

export default { 
  addItem, 
  deleteItem, 
  addMultipleItems 
};

如您所见,我们为单个项目添加了add函数和delete函数。之后,我们将所有这些从src/utils/mapHelpers.js导出。

下一步是改进src/reducers/article.js reducer,以便在其中使用地图工具:

import mapHelpers from '../utils/mapHelpers'; 

const article = (state = {}, action) => { 
  switch (action.type) { 
    case 'ARTICLES_LIST_ADD': 
      let articlesList = action.payload.response; 
      return mapHelpers.addMultipleItems(state, articlesList); 
    case 'PUSH_NEW_ARTICLE': 
      let newArticleObject = action.payload.response; 
      return mapHelpers.addItem(state, newArticleObject['_id'], 
       newArticleObject); 
    default: 
      return state; 
  } 
} 
export default article

src/reducers/article.js文件中的新内容是什么?如您所见,我们改进了ARTICLES_LIST_ADD(已讨论)。我们添加了一个新的PUSH_NEW_ARTICLE;情况;这将把一个新的对象推入我们的 reducer 状态树。它与将项目推入数组类似,但我们使用我们的 reducer 和 maps。

CoreLayout 改进

因为我们正在将前端切换到 ES6 的 Map,所以我们也需要确保在从服务器端渲染接收对象后,它也是一个 Map(而不是一个纯 JS 对象)。查看以下代码:

// The following is old codebase: 
import React from 'react'; 
import { Link } from 'react-router'; 
import themeDecorator from 'material-ui/lib/styles/theme- 
 decorator'; 
import getMuiTheme from 'material-ui/lib/styles/getMuiTheme'; 
import RaisedButton from 'material-ui/lib/raised-button'; 
import AppBar from 'material-ui/lib/app-bar'; 
import ActionHome from 'material-ui/lib/svg-icons/action/home';

CoreLayout component:
import React from 'react'; 
import {Link} from 'react-router'; 
import themeDecorator from 'material-ui/lib/styles/theme- 
 decorator'; 
import getMuiTheme from 'material-ui/lib/styles/getMuiTheme'; 
import RaisedButton from 'material-ui/lib/raised-button'; 
import AppBar from 'material-ui/lib/app-bar'; 
import ActionHome from 'material-ui/lib/svg-icons/action/home'; 
import {connect} from 'react-redux'; 
import {bindActionCreators} from 'redux'; 
import articleActions from '../actions/article.js'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

const mapDispatchToProps = (dispatch) => ({ 
  articleActions: bindActionCreators(articleActions, dispatch) 
});

CoreLayout组件之上,我们添加了 Redux 工具,因此我们将在CoreLayout组件中拥有状态树和可用的动作。

此外,在CoreLayout组件中,添加componentWillMount函数:

  componentWillMount() { 
    if (typeof window !== 'undefined' && !this.props.article.get) 
     { 
      this.props.articleActions.articlesList(this.props.article); 
    } 
  }

此函数负责检查文章属性是否为 ES6 Map。如果不是,则向articlesList发送一个动作来完成工作,之后我们就有this.props.article中的 Map 了。

最后,我们需要改进CoreLayout组件中的export

const muiCoreLayout = themeDecorator(getMuiTheme(null, { 
 userAgent: 'all' }))(CoreLayout); 
 export default connect(mapStateToProps, 
 mapDispatchToProps)(muiCoreLayout);

上述代码帮助我们连接到 Redux 的单状态树以及它允许的动作。

为什么使用 Map 而不是 JS 对象?

通常,ES6 的 Map 具有一些易于数据操作的功能——例如.get.set函数,这使得编程更加愉快。它还有助于拥有更简单的代码,以便能够保持 Redux 所要求的不可变性。

Map 方法比slice/c-oncat/Object.assign更容易使用。我相信每种方法都有一些优点和缺点,但在这个应用程序中,我们将使用 ES6 Map 的方法,以便在完全设置好之后使事情更简单。

改进PublishingAppDashboardView

src/layouts/PublishingApp.js文件中,我们需要改进我们的render函数:

render () { 

  let articlesJSX = []; 

  this.props.article.forEach((articleDetails, articleKey) => { 
    const currentArticleJSX = ( 
      <div key={articleKey}> 
        <ArticleCard  
          title={articleDetails.articleTitle} 
          content={articleDetails.articleContent} /> 
      </div> 
    ); 

    articlesJSX.push(currentArticleJSX); 
  }); 

  return ( 
    <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        {articlesJSX} 
    </div> 
  ); 
}

如前述代码所示,我们将旧的for(let articleKey in this.props.article) {代码切换为this.props.article.forEach,因为我们已经从对象切换到使用 Map。

我们需要在src/views/DashboardView.js文件的render函数中也做同样的操作:

render () { 

  let articlesJSX = []; 
  this.props.article.forEach((articleDetails, articleKey) => { 
    const currentArticleJSX = ( 
      <ListItem 
        key={articleKey} 
        leftAvatar={<img src='/static/placeholder.png'  
    width='50'  
    height='50' />} 
        primaryText={articleDetails.articleTitle} 
        secondaryText={articleDetails.articleContent} 
      /> 
    ); 

    articlesJSX.push(currentArticleJSX); 
  }); 

  return ( 
    <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
      <Link to='/add-article'> 
        <RaisedButton  
          label='Create an article'  
          secondary={true}  
          style={{margin: '20px 20px 20px 20px'}} /> 
      </Link> 

      <List> 
        {articlesJSX} 
      </List> 
    </div> 
  ); 
}

PublishingApp组件中的原因相同,我们切换到使用 ES6 的新 Map,我们还将使用新的 ES6 forEach方法:

this.props.article.forEach((articleDetails, articleKey) => {

AddArticleView的调整

在我们完成准备我们的应用程序以将新文章保存到文章的 reducer 中之后,我们需要调整src/views/articles/AddArticleView.js组件。AddArticleView.js中的新导入如下:

import {bindActionCreators} from 'redux'; 
import {Link} from 'react-router'; 
import articleActions from '../../actions/article.js'; 
import RaisedButton from 'material-ui/lib/raised-button';

如前述代码所示,我们正在导入RaisedButtonLink,这对于在成功添加文章后重定向编辑器到仪表板视图非常有用。然后,我们导入articleActions,因为我们需要在文章提交时执行this.props.articleActions.pushNewArticle(newArticle);动作。如果你遵循了前几章的说明,bindActionCreators已经导入到了你的AddArticleView中。

使用bindActionCreators以便在AddArticleView组件中拥有articleActions,通过替换以下代码片段:

// this is old code, you shall have it already 
const mapDispatchToProps = (dispatch) => ({ 
});

这是新的bindActionCreators代码:

const mapDispatchToProps = (dispatch) => ({ 
  articleActions: bindActionCreators(articleActions, dispatch) 
});

以下是对AddArticleView组件的更新构造函数:

 constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 
    this._articleSubmit = this._articleSubmit.bind(this); 

    this.state = { 
      title: 'test', 
      contentJSON: {}, 
      htmlContent: '', 
      newArticleID: null 
    }; 
  }

当编辑器想要添加文章后,将需要_articleSubmit方法。我们还为我们的标题、contentJSON(我们将在其中保留 draft-js 文章状态)、htmlContentnewArticleID添加了一些默认状态。下一步是创建_articleSubmit函数:

 _articleSubmit() { 
    let newArticle = { 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    } 

    let newArticleID = 'MOCKEDRandomid' + Math.floor(Math.random() 
     * 10000); 

    newArticle['_id'] = newArticleID; 
    this.props.articleActions.pushNewArticle(newArticle); 
    this.setState({ newArticleID: newArticleID}); 
  }

如您在此处所见,我们通过 this.state.titlethis.state.htmlContentthis.state.contentJSON 获取我们当前写作的状态,并根据这些信息创建一个 newArticle 模型:

let newArticle = { 
  articleTitle: this.state.title, 
  articleContent: this.state.htmlContent, 
  articleContentJSON: this.state.contentJSON 
}

然后我们使用 newArticle['_id'] = newArticleID; 模拟新的文章 ID(稍后我们将将其保存到数据库中),并通过 this.props.articleActions.pushNewArticle(newArticle); 将其推入我们的文章 reducer 中。唯一要做的是使用 this.setState({ newArticleID: newArticleID}); 设置 newarticleID。最后一步是更新 AddArticleView 组件中的 render 方法:

 render () { 
    if (this.state.newArticleID) { 
      return ( 
        <div style={{height: '100%', width: '75%', margin: 
         'auto'}}> 
          <h3>Your new article ID is 
           {this.state.newArticleID}</h3> 
          <Link to='/dashboard'> 
            <RaisedButton 
              secondary={true} 
              type='submit' 
              style={{margin: '10px auto', display: 'block', 
               width: 150}} 
              label='Done' /> 
          </Link> 
        </div> 
      ); 
    } 

    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Add Article</h1> 
        <WYSIWYGeditor 
          name='addarticle' 

          onChangeTextJSON={this._onDraftJSChange} /> 
          <RaisedButton 
            onClick={this._articleSubmit} 
            secondary={true} 
            type='submit' 
            style={{margin: '10px auto', display: 'block', width: 
             150}} 
            label={'Submit Article'} /> 
      </div> 
    ); 
  }

render 方法中,我们有一个语句检查文章编辑器是否已经创建了一个文章(点击了提交文章按钮)if(this.state.newArticleID)。如果是,则编辑器将看到他新文章的 ID 和一个链接到仪表板的按钮(链接为 to='/dashboard')。

第二个返回值是在编辑器处于编辑模式的情况下;如果是,则可以通过点击具有 onClick 方法 _articleSubmitRaisedButton 组件来提交它。

编辑文章的能力(EditArticleView 组件)

我们可以添加一篇文章,但还不能编辑它。让我们实现这个功能。

首先要做的是在 src/routes/index.js 中创建一个路由:

import EditArticleView from '../views/articles/EditArticleView';

然后编辑路由:

export default ( 
  <Route component={CoreLayout} path='/'> 
    <IndexRoute component={PublishingApp} name='home' /> 
    <Route component={LoginView} path='login' name='login' /> 
    <Route component={LogoutView} path='logout' name='logout' /> 
    <Route component={RegisterView} path='register' 
     name='register' /> 
    <Route component={DashboardView} 
    path='dashboard' name='dashboard' /> 
    <Route component={AddArticleView} 
    path='add-article' name='add-article' /> 
    <Route component={EditArticleView} 
  path='/edit-article/:articleID' name='edit-article' /> 
  </Route> 
);

如您所见,我们已经添加了 EditArticleViews 路由,其 path='/edit-article/:articleID';如您所知,articleID 将以 props 的形式 this.props.params.articleID 发送到我们(这是 redux-router 的默认功能)。

下一步是创建 src/views/articles/EditArticleView.js 组件,这是一个新的组件(目前是模拟的):

import React from 'react'; 
import Falcor from 'falcor'; 
import {Link} from 'react-router'; 
import falcorModel from '../../falcorModel.js'; 
import {connect} from 'react-redux'; 
import {bindActionCreators} from 'redux'; 
import articleActions from '../../actions/article.js'; 
import WYSIWYGeditor from '../../components/articles/WYSIWYGeditor'; 
import {stateToHTML} from 'draft-js-export-html'; 
import RaisedButton from 'material-ui/lib/raised-button'; 

const mapStateToProps = (state) => ({ 
  ...state 
}); 

const mapDispatchToProps = (dispatch) => ({ 
  articleActions: bindActionCreators(articleActions, dispatch) 
}); 

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

  render () { 
    return <h1>An edit article MOCK</h1> 
  } 
} 
export default connect(mapStateToProps, 
 mapDispatchToProps)(EditArticleView);

在这里,您可以找到一个具有 render 函数的标准视图组件,该函数返回一个模拟(我们将在稍后改进它)。我们已放置所有必需的导入(我们将在 EditArticleView 组件的下一个迭代中使用它们)。

让我们在文章的编辑中添加一个仪表板链接

src/views/DashboardView.js 中进行小的调整:

 let articlesJSX = []; 
  this.props.article.forEach((articleDetails, articleKey) => { 
    let currentArticleJSX = ( 
      <Link to={&grave;/edit-article/${articleDetails['_id']}&grave;} 
       key={articleKey}> 
        <ListItem 
          leftAvatar={<img  
          src='/static/placeholder.png' 
          width='50' 
          height='50' />} 
          primaryText={articleDetails.articleTitle} 
          secondaryText={articleDetails.articleContent} 
        /> 
      </Link> 
    ); 

    articlesJSX.push(currentArticleJSX); 
  });

这里,我们需要更改两件事:向 to={/edit-article/${articleDetails['_id']} 添加一个 Link 属性。这将使用户在点击 ListItem 后重定向到文章的编辑视图。我们还需要给 Link 元素一个唯一的键属性。

创建一个新的动作和 reducer

修改 src/actions/article.js 文件并添加这个名为 EDIT_ARTICLE 的新动作:

export default { 
  articlesList: (response) => { 
    return { 
      type: 'ARTICLES_LIST_ADD', 
      payload: { response: response } 
    } 
  }, 
  pushNewArticle: (response) => { 
    return { 
      type: 'PUSH_NEW_ARTICLE', 
      payload: { response: response } 
    } 
  }, 
  editArticle: (response) => { 
    return { 
      type: 'EDIT_ARTICLE', 
      payload: { response: response } 
    } 
  } 
}

下一步是改进 src/reducers/article.js 中的 reducer:

import mapHelpers from '../utils/mapHelpers'; 

const article = (state = {}, action) => { 
  switch (action.type) { 
    case 'ARTICLES_LIST_ADD': 
      let articlesList = action.payload.response; 
      return mapHelpers.addMultipleItems(state, articlesList); 
    case 'PUSH_NEW_ARTICLE': 
      let newArticleObject = action.payload.response; 
      return mapHelpers.addItem(state, newArticleObject['_id'], 
       newArticleObject); 
    case 'EDIT_ARTICLE': 
      let editedArticleObject = action.payload.response; 
      return mapHelpers.addItem(state, editedArticleObject['_id'], 
       editedArticleObject); 
    default: 
      return state; 
  } 
};export default article;

如您在此处所见,我们为 EDIT_ARTICLE 添加了一个新的 switch 案例使用我们的 mapHelpers.addItem;一般来说,如果 Map 中存在 _id,则它将替换一个值(这对于编辑操作效果很好)。

src/components/articles/WYSIWYGeditor.js 中的编辑模式

现在我们通过改进 WYSIWYGeditor.js 文件中的构建来实现我们 WYSIWYGeditor 组件中使用编辑模式的能力:

export default class  WYSIWYGeditor extends React.Component { 
  constructor(props) { 
    super(props); 

    let initialEditorFromProps; 

    if (typeof props.initialValue === 'undefined' || typeof 
     props.initialValue !== 'object') { 
      initialEditorFromProps = 
       EditorState.createWithContent 
       (ContentState.createFromText('')); 
    } else { 
      let isInvalidObject = typeof props.initialValue.entityMap 
       === 'undefined' || typeof props.initialValue.blocks === 
       'undefined'; 

      if (isInvalidObject) { 
        alert('Invalid article-edit error provided, exit'); 
        return; 
      } 
      let draftBlocks = convertFromRaw(props.initialValue); 
      let contentToConsume = 
       ContentState.createFromBlockArray(draftBlocks); 

      initialEditorFromProps = 
       EditorState.createWithContent(contentToConsume); 
    } 

    this.state = { 
      editorState: initialEditorFromProps 
    }; 

    this.focus = () => this.refs['refWYSIWYGeditor'].focus(); 
    this.onChange = (editorState) => {  
      var contentState = editorState.getCurrentContent(); 

      let contentJSON = convertToRaw(contentState); 
      props.onChangeTextJSON(contentJSON, contentState); 
      this.setState({editorState})  
    }; 

    this.handleKeyCommand = (command) => 
     this._handleKeyCommand(command); 
      this.toggleInlineStyle = (style) => 
       this._toggleInlineStyle(style); 
      this.toggleBlockType = (type) => 
       this._toggleBlockType(type); 
  }

你可以在这里看到修改后构造函数的样子。

如你所知,draft-js 必须是一个对象,所以我们首先在第一个if语句中检查它是否是一个对象。然后,如果不是,我们将默认使用一个空的 WYSIWYG(检查if(typeof props.initialValue === 'undefined' || typeof props.initialValue !== 'object')))。

else语句中,我们放置以下内容:

let isInvalidObject = typeof props.initialValue.entityMap === 
 'undefined' || typeof blocks === 'undefined'; 
if (isInvalidObject) { 
  alert('Error: Invalid article-edit object provided, exit'); 
  return; 
} 
let draftBlocks = convertFromRaw(props.initialValue); 
let contentToConsume = 
 ContentState.createFromBlockArray(draftBlocks); 
 initialEditorFromProps = 
 EditorState.createWithContent(contentToConsume);

在这里,我们检查是否有一个有效的 draft-js JSON 对象;如果没有,我们需要抛出一个关键错误并返回,因为否则错误可能会崩溃整个浏览器(我们需要使用withif(isInvalidObject)来处理这个边缘情况)。

在我们得到一个有效的对象后,我们使用 draft-js 库提供的convertFromRawContentState.createFromBlockArrayEditorState.createWithContent函数来恢复我们的 WYSIWYG 编辑器的状态。

EditArticleView 的改进

在完成文章编辑模式之前,最后一个改进是提高src/views/articles/EditArticleView.js

class EditArticleView extends React.Component { 
  constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 
    this._articleEditSubmit = this._articleEditSubmit.bind(this); 
    this._fetchArticleData = this._fetchArticleData.bind(this); 

    this.state = { 
      articleFetchError: null, 
      articleEditSuccess: null, 
      editedArticleID: null, 
      articleDetails: null, 
      title: 'test', 
      contentJSON: {}, 
      htmlContent: '' 
    }; 
  }

这是我们的构造函数;我们将有一些状态变量,例如articleFetchErrorarticleEditSuccesseditedArticleIDarticleDetailstitlecontentJSONhtmlContent

通常,所有这些变量都是自解释的。关于这里的articleDetails变量,我们将保留从reducer/mongoDB获取的整个对象。例如titlecontentHTMLcontentJSON等数据都保存在articleDetails状态中(你很快就会看到)。

在完成EditArticleView构造函数后,添加一些新函数:

 componentWillMount() { 
    this._fetchArticleData(); 
  } 

  _fetchArticleData() { 
    let articleID = this.props.params.articleID; 
    if (typeof window !== 'undefined' && articleID) { 
        let articleDetails = this.props.article.get(articleID); 
        if(articleDetails) { 
          this.setState({  
            editedArticleID: articleID,  
            articleDetails: articleDetails 
          }); 
        } else { 
          this.setState({ 
            articleFetchError: true 
          }) 
        } 
    } 
  } 

  onDraftJSChange(contentJSON, contentState) { 
    let htmlContent = stateToHTML(contentState); 
    this.setState({contentJSON, htmlContent}); 
  } 

  _articleEditSubmit() { 
    let currentArticleID = this.state.editedArticleID; 
    let editedArticle = { 
      _id: currentArticleID, 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    } 

    this.props.articleActions.editArticle(editedArticle); 
    this.setState({ articleEditSuccess: true }); 
  }

componentWillMount中,我们将使用_fetchArticleData获取文章的相关数据。_fetchArticleData通过react-redux从 props 中获取文章的 ID(let articleID = this.props.params.articleID;)。然后,我们检查是否不在服务器端if(typeof window !== 'undefined' && articleID)。之后,我们使用.get Map 函数从 reducer 获取详细信息(let articleDetails = this.props.article.get(articleID);),并根据情况设置组件的状态如下:

if (articleDetails) { 
  this.setState({  
    editedArticleID: articleID,  
    articleDetails: articleDetails 
  }); 
} else { 
  this.setState({ 
    articleFetchError: true 
  }) 
}

在这里,你可以看到在articleDetails变量中,我们保留了从 reducer/DB 获取的所有数据。一般来说,现在我们只有前端部分,因为本书后面将介绍如何从后端获取已编辑的文章。

_onDraftJSChange函数与AddArticleView组件中的类似。

_articleEditSubmit相当标准,所以我会留给你去阅读代码。我只提一下_id: currentArticleID非常重要,因为它在后面的reducer/mapUtils中用于正确更新文章。

EditArticleView 的渲染改进

最后的部分是改进EditArticleView组件中的render函数:

render () { 
    if (this.state.articleFetchError) { 
      return <h1>Article not found (invalid article's ID 
       {this.props.params.articleID})</h1>; 
    } else if (!this.state.editedArticleID) { 
        return <h1>Loading article details</h1>; 
    } else if (this.state.articleEditSuccess) { 
      return ( 
        <div style={{height: '100%', width: '75%', margin: 
         'auto'}}> 
          <h3>Your article has been edited successfully</h3> 
          <Link to='/dashboard'> 
            <RaisedButton 
              secondary={true} 
              type='submit' 
              style={{margin: '10px auto', display: 'block', 
               width: 150}} 
              label='Done' /> 
          </Link> 
        </div> 
      ); 
    } 

    let initialWYSIWYGValue = 
     this.state.articleDetails.articleContentJSON; 

    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Edit an existing article</h1> 
        <WYSIWYGeditor 
          initialValue={initialWYSIWYGValue} 
          name='editarticle' 
          title='Edit an article' 
          onChangeTextJSON={this._onDraftJSChange} /> 
          <RaisedButton 
            onClick={this._articleEditSubmit} 
            secondary={true} 
            type='submit' 
            style={{margin: '10px auto', display: 'block', 
             width: 150}} 
            label={'Submit Edition'} /> 
      </div> 
    ); 
  }

我们使用 if(this.state.articleFetchError), else if(!this.state.editedArticleID), 和 else if(this.state.articleEditSuccess) 来管理组件的不同状态,如下所示:

<WYSIWYGeditor 
  initialValue={initialWYSIWYGValue} 
  name='editarticle' 
  title='Edit an article' 
  onChangeTextJSON={this._onDraftJSChange} />

在这部分,主要变化是添加了一个名为 initialValue 的新属性,该属性传递给 WYSIWYGeditor,即 draft-js JSON 对象。

删除文章的功能实现

让我们在 src/actions/article.js 中创建一个新的删除操作:

deleteArticle: (response) => { 
  return { 
    type: 'DELETE_ARTICLE', 
    payload: { response: response } 
  } 
}

接下来,让我们在 src/reducers/article.js 中添加一个 DELETE_ARTICLE switch 情况:

import mapHelpers from '../utils/mapHelpers'; 

const article = (state = {}, action) => { 
  switch (action.type) { 
    case 'ARTICLES_LIST_ADD': 
      let articlesList = action.payload.response; 
      return mapHelpers.addMultipleItems(state, articlesList); 
    case 'PUSH_NEW_ARTICLE': 
      let newArticleObject = action.payload.response; 
      return mapHelpers.addItem(state, newArticleObject['_id'], 
       newArticleObject); 
    case 'EDIT_ARTICLE': 
      let editedArticleObject = action.payload.response; 
      return mapHelpers.addItem(state, editedArticleObject['_id'], 
       editedArticleObject); 
    case 'DELETE_ARTICLE': 
      let deleteArticleId = action.payload.response; 
      return mapHelpers.deleteItem(state, deleteArticleId); 
    default: 
      return state; 
  } 
export default article

实现删除按钮的最后一步是修改 src/views/articles/EditArticleView.js 组件中的 Import PopOver(它将再次询问你是否确定要删除文章):

import Popover from 'material-ui/lib/popover/popover'; 
Improve the constructor of EditArticleView: 
class EditArticleView extends React.Component { 
  constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 
    this._articleEditSubmit = this._articleEditSubmit.bind(this); 
    this._fetchArticleData = this._fetchArticleData.bind(this); 
    this._handleDeleteTap = this._handleDeleteTap.bind(this); 
    this._handleDeletion = this._handleDeletion.bind(this); 
    this._handleClosePopover = 
     this._handleClosePopover.bind(this); 

    this.state = { 
      articleFetchError: null, 
      articleEditSuccess: null, 
      editedArticleID: null, 
      articleDetails: null, 
      title: 'test', 
      contentJSON: {}, 
      htmlContent: '', 
      openDelete: false, 
      deleteAnchorEl: null 
    }; 
  }

这里新增加的是 _handleDeleteTap, _handleDeletion, _handleClosePopoverstate (htmlContent, openDelete, deleteAnchorEl)。然后,向 EditArticleView 添加三个新函数:

 _handleDeleteTap(event) { 
    this.setState({ 
      openDelete: true, 
      deleteAnchorEl: event.currentTarget 
    }); 
  } 

  _handleDeletion() { 
    let articleID = this.state.editedArticleID; 
    this.props.articleActions.deleteArticle(articleID); 

    this.setState({ 
      openDelete: false 
    }); 
    this.props.history.pushState(null, '/dashboard'); 
  } 

  _handleClosePopover() { 
    this.setState({ 
      openDelete: false 
    }); 
  }

改进 render 函数中的返回值:

let initialWYSIWYGValue = 
 this.state.articleDetails.articleContentJSON; 

 return ( 
   <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
     <h1>Edit an exisitng article</h1> 
     <WYSIWYGeditor 
       initialValue={initialWYSIWYGValue} 
       name='editarticle' 
       title='Edit an article' 
       onChangeTextJSON={this._onDraftJSChange} /> 
       <RaisedButton 
         onClick={this._articleEditSubmit} 
         secondary={true} 
         type='submit' 
         style={{margin: '10px auto', display: 'block', 
          width: 150}} 
         label={'Submit Edition'} /> 
     <hr /> 
     <h1>Delete permanently this article</h1> 
       <RaisedButton 
         onClick={this._handleDeleteTap} 
         label='Delete' /> 
       <Popover 
         open={this.state.openDelete} 
         anchorEl={this.state.deleteAnchorEl} 
         anchorOrigin={{horizontal: 'left', vertical: 
          'bottom'}} 
         targetOrigin={{horizontal: 'left', vertical: 'top'}} 
         onRequestClose={this._handleClosePopover}> 
         <div style={{padding: 20}}> 
           <RaisedButton  
             onClick={this._handleDeletion}  
             primary={true}  
             label="Permanent delete, click here"/> 
         </div> 
       </Popover> 
   </div> 
 );

关于 render,所有新内容都位于新的 hr 标签下:<h1>: 删除此文章永久<h1>RaisedButton: DeletePopover 是来自 Material-UI 的一个组件。你可以在www.material-ui.com/v0.15.0-alpha.1/#/components/popover找到更多关于此组件的文档。以下截图显示了它应该在 browserRaisedButton: 永久删除,点击此处 标签下的样子。AddArticleView 组件:

点击 SUBMIT ARTICLE 按钮后的 AddArticleView 组件:

仪表板组件:

EditArticleView 组件:

EditArticleView 组件上的 DELETE 按钮:

在第一次点击后的 EditArticleView 组件上的 DELETE 按钮(弹出组件):

PublishingApp 组件(主页):

摘要

目前,我们已经在前端使用 Redux 取得了很大进展,将应用程序的状态存储在其单状态树中。重要的缺点是,在刷新后,所有数据都会消失。

在下一章中,我们将开始实现后端,以便将文章存储到我们的数据库中。

如你所知,Falcor 是我们的粘合剂,取代了旧的流行 RESTful 方法;你很快就会掌握与 Falcor 相关的内容。你还将了解 Relay/GraphQL 和 Falcor 之间的区别。两者都试图解决类似的问题,但方式非常不同。

让我们更深入地了解我们的全栈 Falcor 应用程序。我们将使其对我们的最终用户更加出色。

第五章:Falcor 高级概念

目前,我们的应用程序具有添加、编辑和删除文章的能力,但仅限于前端,借助 Redux 的 reducers。我们需要添加一些全栈机制,以便能够在数据库上执行 CRUD 操作。我们还需要在后台添加一些安全功能,以便未经认证的用户无法对 MongoDB 集合执行 CRUD 操作。

让我们暂时停止编码。在我们开始开发全栈 Falcor 机制之前,让我们更详细地讨论我们的 React、Node 和 Falcor 设置。

理解为什么我们在技术栈中选择了 Falcor 是很重要的。一般来说,在我工作的定制软件开发公司(你可以在www.ReactPoland.com上找到更多信息),我们使用 Falcor,因为它在开发全栈移动/Web 应用程序方面为客户带来了许多优势。其中一些如下:

  • 概念的简单性

  • 与 RESTful 方法相比,开发速度提高 30%以上

  • 浅度学习曲线,因此学习 Falcor 的开发者可以非常快速地变得有效

  • 一种非常令人惊叹的数据获取方式(从后端到客户端)

我将暂时将这些四个要点简短而精炼。在本章的后面部分,你将了解到使用 Falcor 和 Node 时可能会遇到的问题。

目前,我们已经使用 React、Redux、Falcor、Node、Express 和 MongoDB 组装了一种全栈入门套件。它还不是完美的。我们将把它作为本章的重点,其中包括以下主题:

  • 更好地理解无 REST 数据获取解决方案的整体概念,以及 Falcor 与 Relay/GraphQL 之间的相似之处和不同之处

  • 如何在后台安全地路由以验证用户

  • 如何通过 errorSelectors 在后端处理错误并将其无缝发送到前端

  • 详细了解 Falcor 的哨兵以及$ref$atom$error在 Falcor 中是如何工作的

  • JSON 图是什么以及它是如何工作的

  • Falcor 中的虚拟 JSON 概念是什么

Falcor 旨在解决的问题

在单页应用时代之前,客户端获取数据并没有问题,因为所有数据都是始终在服务器上获取的,即使如此,服务器也会将 HTML 标记发送到客户端。每次有人点击 URL(href)时,我们的浏览器都会从服务器请求全新的 HTML 标记。

基于非 SPA 应用程序的先前原则,Ruby on Rails 成为了 Web 开发技术栈的王者,但后来情况发生了变化。自 2009-2010 年以来,我们一直在创建越来越多的 JavaScript 客户端应用程序,它们更有可能从后端一次性获取,例如bundle.js文件。它们被称为 SPA。

由于这种 SP Apps 趋势,一些新的问题出现了,这些问题对于非 SP Apps 开发者来说是未知的,例如从后端的 API 端点获取数据以在客户端消费这些 JSON 数据。

通常,RESTful 应用程序的传统工作流程如下:

  1. 在后端创建端点。

  2. 在前端创建获取机制。

  3. 通过在前端根据 API 规范编写 POST/GET 请求来从后端获取数据。

  4. 当你从后端获取 JSON 数据到前端时,你可以消费这些数据,并基于特定的用例使用它来创建 UI 视图。

如果客户或老板等人改变主意,这个过程会变得有些令人沮丧,因为你已经在后端和前端实现了整个代码。后来后端 API 端点变得不再相关,因此你需要根据变更后的需求从头开始工作。

虚拟 JSON - 一个模型无处不在

对于 Falcor 来说,一个模型无处不在是这一伟大库的主要口号。总的来说,使用它的主要目的是创建一个在前端和后端完全相同的单一 JSON 模型。这对我们意味着什么?这意味着如果任何东西发生变化,我们需要更改模型,这个模型在前后端都是一样的——所以在这种情况下,我们需要调整我们的模型,而不用担心后端如何提供数据以及前端如何获取数据。

Falcor 的创新之处在于引入了一个名为虚拟 JSON(类似于 React 的虚拟 DOM)的新概念。这让你可以将所有远程数据源(例如,在我们的案例中是 MongoDB)表示为一个单一的领域模型。整个想法是,你可以以相同的方式编码,而不必关心数据在哪里:是在客户端内存缓存中还是在服务器上?你不需要关心,因为 Falcor 以其创新的方法为你做了很多工作(例如,使用xhr请求进行查询)。

数据获取是开发人员面临的问题。Falcor 的出现就是为了帮助简化这个问题。你可以从后端获取数据到前端,比以往任何时候都少写代码!

2016 年 5 月,我看到的唯一可行的竞争对手是 Facebook 的库,称为 Relay(在客户端)和 GraphQL(在后端)。

让我们尝试比较一下两者。

Falcor 与 Relay/GraphQL 的比较

就像任何工具一样,总有优点和缺点。

当然,Falcor 在小/中型项目中总是比 Relay/GraphQL 更好,至少除非你有精通开发者(或者你就是自己)非常了解 Relay/GraphQL。为什么是这样呢?

通常,Relay(用于前端)和 GrapQL(用于后端)是两种不同的工具,你必须高效地使用它们才能正确使用。

在商业环境中,你往往没有太多时间去从头学习新事物。这也是 React 成功的一个原因。

为什么 React 会成功?React 对于成为一个高效的开发者来说更容易掌握。CTO 或技术总监雇佣一个了解 jQuery(例如)的新手开发者,然后 CTO 可以轻松地预测这位初级开发者将在 7 到 14 天内有效地使用 React;我曾教过一些对 JavaScript/jQuery 有基本知识的初级前端开发者,我发现他们相当快地就能够在 React 中高效地创建客户端应用程序。

我们可以在 Falcor 中找到相同的情况。与 Relay + GraphQL 相比,Falcor 就像 React 的简单性与 Angular 单体框架的复杂性相比。

在前几段中描述的单一因素意味着 Falcor 更适合预算有限的小型/中型项目。

当你有 6 个月的时间掌握一项技术时,你可能会在大公司中找到学习 Relay/GraphQL 的机会,这些公司拥有庞大的预算,例如 Facebook。

FalcorJS 可以在两周内有效掌握,但 GraphQL + Relay 不行。

大图相似性

这两个工具都在试图解决相同的问题。它们在设计上对开发者和网络都高效(试图优化与 RESTful 方法相比的查询数量)。

他们具有查询后端服务器以获取数据的能力,并且具有批量处理能力(因此你可以通过一个网络请求获取超过两组不同的数据)。两者都有一些缓存能力。

技术差异 - 概述

通过技术概述,我们可以发现,通常,Relay 允许你从 GraphQL 服务器查询未定义数量的项目。相比之下,在 Falcor 中,你需要先询问后端它有多少个项目,然后才能查询集合对象的详细信息(例如,在我们的书中,文章的情况)。

通常,这里最大的区别是 GraphQL/Relay 是一个查询语言工具,而 Falcor 不是。什么是查询语言?它是一种你可以从前端进行查询的语言,类似于 SQL,如下所示:

post: () => Relay.QL 
  fragment on Articles { 
    title, 
    content 
  } 

之前的代码可以通过Relay.QL从前端进行查询,然后 GraphQL 以与 SQL 相同的方式处理查询,如下所示:

SELECT title, content FROM Articles 

如果数据库中有,例如,一百万篇文章,而你没有预料到这么多在前端,事情可能会变得更难。

在 Falcor 中,你将采取不同的做法,就像你已经学到的:

const articlesLength = await falcorModel. 
  getValue('articles.length'). 
  then((length) => length); 

const articles = await falcorModel. 
  get(['articles', {from: 0, to: articlesLength-1}, 
   ['_id','articleTitle', 'articleContent']]).  
  then((articlesResponse) => articlesResponse.json.articles); 

在先前的 Falcor 示例中,你必须首先知道 MongoDB 实例中有多少条记录。

这是最重要的区别之一,并为双方创造了一些挑战。

对于 GraphQL 和 Relay 来说,问题是这些查询语言的力量是否值得学习曲线中产生的复杂性,因为这种复杂性可能对小型/中型项目来说不值得。

现在已经讨论了基本的不同之处,让我们专注于 Falcor 和改进我们当前的发布应用程序。

提高我们的应用程序并使其更加可靠

我们需要改进以下事项:

  • 登录后,我们应在每个请求中发送用户详情(令牌、用户名和角色;你可以在本节后面的 改进前端上的 Falcor 代码 部分找到截图)

  • 后端需要得到保护,以便在执行添加/编辑/删除操作之前进行身份验证检查。

  • 我们需要在后端提供捕获错误的能力,并在前端通知用户某些操作不正确

保护需要身份验证的路由

目前,我们的应用程序具有添加/编辑/删除路由的能力。我们当前实现的问题是我们没有检查发起 CRUD 操作的客户端是否有权限这样做。

保护 Falcor 路由的解决方案需要对我们当前的实施进行一些更改,因此对于每个请求,在执行操作之前,我们将检查是否从客户端获得了正确的令牌,以及发起调用的用户是否有编辑的能力(在我们的情况下,这意味着如果任何人有编辑角色并且正确地使用用户名和密码进行身份验证,那么他可以添加/编辑/删除文章)。

Falcor 中的 JSON 图和 JSON 封装

如 Falcor 文档所述,“JSON 图是将图信息建模为 JSON 对象的约定。使用 Falcor 的应用程序将它们的所有领域数据表示为一个单一的 JSON 图对象。”

通常情况下,Falcor 中的 JSON 图是有效的 JSON,并具有一些新特性。更精确地说,JSON 图引入了除了字符串、数字和布尔值之外的新数据类型。在 Falcor 中,这种新数据类型被称为 哨兵。我将在本章后面尝试解释它。

通常,在 Falcor 中理解第二重要的是 JSON 封装。好事是它们开箱即用,所以你不必过于担心它们。但如果你想知道简短而直接的答案,JSON 封装帮助通过 HTTP 协议发送 JSON 的模型。这是一种从前端到后端(使用 .call.set.get 方法)传输数据的方式。同样,在发送改进后的模型详情到客户端之前,在处理请求详情之后,Falcor 会将其放入一个 封装 中,以便通过网络轻松传输。

JSON 封装的类比是,你将一份书面清单放入信封中,因为你不想将一些有价值的信息从点 A 发送到点 B;网络不关心你在这个信封中发送什么。最重要的是,发送者和接收者知道应用程序模型的环境。

你可以在 netflix.github.io/falcor/documentation/jsongraph.html 找到有关 JSON 图和封装的更多信息。

改进前端上的 Falcor 代码

目前,在用户授权后,所有数据都保存在本地存储中。我们需要通过在每个请求中发送该数据--令牌、用户名和角色--来闭合循环,以便我们可以再次检查用户是否正确认证。如果没有,那么我们需要在请求中发送认证错误并将其显示在前端。

以下截图中的排列对于安全原因特别重要,以确保未经授权的用户无法在我们的数据库中添加/编辑/删除文章:

在截图上,你可以找到获取localStorage数据信息的地方。

以下是我们当前在src/falcorModel.js中的代码:

// this code is already in the codebase 
const falcor = require('falcor'); 
const FalcorDataSource = require('falcor-http-datasource'); 

const model = new falcor.Model({ 
  source: new FalcorDataSource('/model.json') 
}); 
export default model; 

我们需要将其更改为一个新的、改进的版本:

import falcor from 'falcor'; 
import FalcorDataSource from 'falcor-http-datasource'; 

class PublishingAppDataSource extends FalcorDataSource { 
  onBeforeRequest ( config ) { 
    const token = localStorage.token; 
    const username = localStorage.username; 
    const role = localStorage.role; 

    if (token && username && role) { 
      config.headers['token'] = token; 
      config.headers['username'] = username; 
      config.headers['role'] = role; 
    } 
  } 
} 

const model = new falcor.Model({ 
  source: new PublishingAppDataSource('/model.json') 
}); 
export default model; 

extends keyword from ECMAScript6 shows an example of where the simplicity of the class syntax shines. Extending the FalcorDataSource means that PublishingAppDataSource inherits everything that the FalcorDataSource has and it makes the onBeforeRequest method have our custom behavior (by mutating config.headers). The onBeforeRequest method is checking the configuration mutated by us before our xhr instance is created. This helps us modify the XMLHttpRequest with a token, username, and role--in case our app's user logs out in the meantime, we can send that information to the backend.

在你在falcorModel.js中实现前面的代码并且用户登录后,这些变量将被添加到每个请求中:

改进server.jsroutes.js

通常,我们目前从server/routes.js文件中导出一个对象数组。我们需要改进它,所以我们将返回一个函数,该函数将修改我们的对象数组,以便我们能够控制将哪个路由返回给哪个用户,并且如果用户没有有效的令牌或足够的权限,我们将返回一个错误。这将提高我们整个应用程序的安全性。

server/server.js文件中,找到以下旧代码:

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

用这个改进版本替换它:

app.use('/model.json', falcorExpress.dataSourceRoute((req, res) 
 => { 
  return new falcorRouter( 
      [] 
        .concat(routes(req, res)) 
    ); 
})); 

在我们的新版本中,我们假设routes变量是一个带有reqres变量的函数。

让我们改进路由本身,这样我们就不再返回一个数组了,而是一个返回数组的函数(这样我们就有了更多的灵活性)。

下一步是改进server/routes.js文件,以便创建一个接收currentSession对象的函数,该对象存储有关请求的所有信息。我们需要在routes.js中做出以下更改:

// this code is already in your codebase: 
const PublishingAppRoutes = [ 
    ...sessionRoutes, 
  { 
  route: 'articles.length', 
    get: () => { 
      return Article.count({}, function(err, count) { 
        return count; 
      }).then ((articlesCountInDB) => { 
        return { 
          path: ['articles', 'length'], 
          value: articlesCountInDB 
        } 
      }) 
  } 
}, 
//  
// ...... There is more code between, it has been truncated in 
 //order to save space 
// 
export default PublishingAppRoutes;  

而不是导出一个路由数组,我们需要导出一个函数,该函数将根据当前请求的头部详细信息返回路由。

server/routes.js文件的上半部分(包含导入)如下所示:

import configMongoose from './configMongoose'; 
import sessionRoutes from './routesSession'; 
import jsonGraph from 'falcor-json-graph'; 
import jwt from 'jsonwebtoken'; 
import jwtSecret from './configSecret'; 

let $atom = jsonGraph.atom; // this will be explained later 
                            //in the chapter 
const Article = configMongoose.Article; 

然后导出一个新的函数:

export default ( req, res ) => { 
  let { token, role, username } = req.headers; 
  let userDetailsToHash = username+role; 
  let authSignToken = jwt.sign(userDetailsToHash, 
   jwtSecret.secret); 
  let isAuthorized = authSignToken === token; 
  let sessionObject = {isAuthorized, role, username}; 

  console.info(&grave;The ${username} is authorized === &grave;, 
   isAuthorized); 

  const PublishingAppRoutes = [ 
      ...sessionRoutes, 
    { 
    route: 'articles.length', 
      get: () => { 
        return Article.count({}, function(err, count) { 
          return count; 
        }).then ((articlesCountInDB) => { 
          return { 
            path: ['articles', 'length'], 
            value: articlesCountInDB 
          } 
        }) 
    } 
  }]; 

  return PublishingAppRoutes; 
} 

首先,我们将req(请求详情)和res(代表 HTTP 响应的对象)变量接收进箭头函数中。根据req提供的信息,我们获取头部详细信息(let { token, role, username } = req.headers;)。接下来,我们有userDetailsToHash,然后我们检查正确的authTokenlet authSignToken = jwt.sign(userDetailsToHash, jwtSecret.secret))。之后,我们检查用户是否被授权(let isAuthorized = authSign === token)。然后我们创建一个sessionObject,它将在之后的所有 Falcor 路由中被重复使用(let sessionObject = {isAuthorized, role, username};)。

目前,我们有一个路由(articles.length),这在第二章中已有描述,即“我们发布应用的完整栈登录和注册”(所以目前还没有什么新内容)。

如前述代码所示,我们不是直接导出PublishingAppRoutes,而是使用箭头函数export default (req, res)进行导出。

我们需要在articles.length下重新添加第二个路由,称为articles[{integers}]["_id","articleTitle","articleContent"],并在server/routes中使用以下代码:

 { 
    route: 
     'articles[{integers}]["_id","articleTitle","articleContent"]', 
    get: (pathSet) => { 
      const articlesIndex = pathSet[1]; 

      return Article.find({}, function(err, articlesDocs) { 
        return articlesDocs; 
      }).then ((articlesArrayFromDB) => { 
        let results = []; 
        articlesIndex.forEach((index) => { 
          const singleArticleObject = 
           articlesArrayFromDB[index].toObject(); 

          const falcorSingleArticleResult = { 
            path: ['articles', index], 
            value: singleArticleObject 
          }; 

          results.push(falcorSingleArticleResult); 
        }); 
        return results; 
      }) 
    } 
  } 

这是获取数据库中文章的路由,并返回falcor-route的路由。它与之前介绍的是完全相同的;唯一的区别是现在它是函数的一部分(export default ( req, res ) => { ... })。

在我们开始使用falcor-router在后台实现添加/编辑/删除功能之前,我们需要先了解哨兵的概念,因为它对我们的全栈应用的健康至关重要,原因将在稍后解释。

Falcor 的哨兵实现

让我们了解什么是哨兵。它们是使 Fullstack 的 Falcor 应用工作所必需的。它是一套你必须学习的工具。

这些是新原始值类型,专门用于使后端和客户端之间的数据传输更加容易和直接(新 Falcor 原始值示例包括$error$ref)。这里有一个类比:你有一个常规 JSON 中的类型,如字符串、数字和对象,另一方面,在 Falcor 的虚拟 JSON 中,你可以使用与之前列出的标准 JSON 类型并行的哨兵,如$error$ref$atom

关于哨兵的更多信息可在netflix.github.io/falcor/documentation/model.html#sentinel-metadata找到。

在这个阶段,理解 Falcor 的哨兵是如何工作的是非常重要的。Falcor 中不同类型的哨兵将在以下章节中解释。

$ref哨兵

根据文档,“一个引用是一个具有$type键且其值为ref,以及一个value键且其值为Path数组对象的 JSON 对象。”

“一个引用就像 UNIX 文件系统中的符号链接,”正如文档所述,这个比较非常恰当。

$ref的一个例子如下:

{ $type: 'ref', value: ['articlesById', 'STRING_ARTICLE_ID_HERE'] } 

如果你使用$ref(['articlesById','STRING_ARTCILE_ID_HERE']),它与前面的例子是等价的。$ref哨兵是一个函数,它将数组的详细信息转换为那种$type和值的表示对象。

你可以在任何与 Falcor 相关的项目中找到部署/使用$ref的两种方法,但在我们的项目中,我们将坚持使用$ref(['articlesById','STRING_ARTCILE_ID_HERE'])约定。

为了明确起见,这是在我们的代码库中导入$ref哨兵的方法:

// wait, this is just an example, don't code this here: 
import jsonGraph from 'falcor-json-graph'; 
let $ref = jsonGraph.ref; 
// now you can use $ref([x, y]) function 

在你导入 falcor-json-graph 之后,你可以使用 $ref 监视器。你已经在上一章中描述了安装过程,已经安装了 falcor-json-graph 库;如果没有,请使用以下内容:

npm i --save falcor-json-graph@1.1.7 

但在那个 $ref 大事件中,“articlesById”是什么意思?在先前的例子中,“STRING_ARTICLE_ID_HERE”又是什么意思?让我们看看我们项目中的一个例子,可能会让你更清楚。

$ref 监视器的详细示例

假设我们在 MongoDB 实例中有两篇文章:

// this is just explanation example, don't write this here 
// we assume that _id comes from MongoDB 
[ 
  { 
    _id: '987654', 
    articleTitle: 'Lorem ipsum - article one', 
    articleContent: 'Here goes the content of the article' 
  }, 
  { 
    _id: '123456', 
    articleTitle: 'Lorem ipsum - article two', 
    articleContent: 'Sky is the limit, the content goes here.' 
  } 
] 

因此,基于我们的带有模拟文章(ID 987654123456)的数组示例,$ref 将如下所示:

// JSON envelope is an array of two $refs  
// The following is an example, don't write it 
[ 
  $ref([ articlesById,'987654' ]), 
  $ref([ articlesById,'123456' ]) 
] 

更详细的答案如下:

// JSON envelope is an array of two $refs (other notation than 
 //above, but the same end effect) 
[ 
  { $type: 'ref', value: ['articlesById', '987654'] }, 
  { $type: 'ref', value: ['articlesById', '123456'] } 
] 

需要注意的一个重要事项是,“articlesById”是一个尚未创建的新路由(我们将在下一刻这样做)。

但为什么在我们的文章中需要那些 $ref 呢?

通常,你可以在数据库中的许多地方保留对一个对象的引用(就像 Unix 中的符号链接一样)。在我们的例子中,它是一个在文章集合中具有特定 _id 的文章。

何时使用 $ref 监视器会很有用?想象一下,在我们的发布应用模型中,我们添加了一个“最近访问的文章”功能,并提供了喜欢文章的能力(就像在 Facebook 上一样)。

基于这两个新功能,我们的新模型将如下所示(这只是一个例子;不要编写代码):

// this is just explanatory example code: 
let cache = { 
  articles: [ 
    { 
        id: 987654, 
        articleTitle: 'Lorem ipsum - article one', 
        articleContent: 'Here goes the content of the article' 
        numberOfLikes: 0 
    }, 
    { 
        id: 123456, 
        articleTitle: 'Lorem ipsum - article two from backend', 
        articleContent: 'Sky is the limit, the content goes 
         here.', 
        numberOfLikes: 0 
    } 
  ], 
  recentlyVisitedArticles: [ 
    { 
        id: 123456, 
        articleTitle: 'Lorem ipsum - article two from backend', 
        articleContent: 'Sky is the limit, the content goes 
         here.', 
        numberOfLikes: 0 
    } 
  ] 
}; 

根据我们先前示例中的模型,如果有人喜欢了 ID 为 123456 的文章,我们将在模型中需要更新两个地方。这正是 $ref 发挥作用的地方。

使用 $ref 改善我们的文章的 numberOfLikes

让我们改进我们的例子到以下内容:

let cache = { 
  articlesById: { 
    987654: { 
        _id: 987654, 
        articleTitle: 'Lorem ipsum - article one', 
        articleContent: 'Here goes the content of the article' 
        numberOfLikes: 0 
    }, 
    123456: { 
        _id: 123456, 
        articleTitle: 'Lorem ipsum - article two from backend', 
        articleContent: 'Sky is the limit, the content goes 
         here.', 
        numberOfLikes: 0 
    } 
  }, 
  articles: [ 
    { $type: 'ref', value: ['articlesById', '987654'] }, 
    { $type: 'ref', value: ['articlesById', '123456'] } 
  ], 
  recentlyVisitedArticles: [ 
    { $type: 'ref', value: ['articlesById', '123456'] } 
  ] 
}; 

在我们改进的 $ref 示例中,你可以找到需要告诉 Falcor 你想在 articlesrecentlyVisitedArticles 中包含的文章 ID 的标记。Falcor 将会根据路由名称(在这种情况下是 articlesById 路由)和我们要查找的对象的 ID(在我们的例子中是 123456987654)来跟随 $ref 监视器。我们将在稍后将其用于实践。

了解这是它工作原理的简化版本,但要理解 $ref 的最佳类比是 UNIX 的符号链接。

在我们的项目中 $ref 的实际应用

好吧,理论讲得够多了——让我们开始编码!我们将改进我们的 Mongoose 模型。

然后,我们将之前描述的 $ref 监视器添加到 server/routes.js 文件中:

// example of ref, don't write it yet: 
let articleRef = $ref(['articlesById', currentMongoID]); 

我们还将添加两个 Falcor 路由,“articlesById”和“articles.add”。在前端,我们将对 src/layouts/PublishingApp.jssrc/views/articles/AddArticleView.js 进行一些改进。

让我们开始吧。

Mongoose 配置改进

我们首先要做的事情是打开 server/configMongoose.js 中的 Mongoose 模型:

// this is old codebase, you already shall have it: 
import mongoose from 'mongoose'; 

const conf = { 
  hostname: process.env.MONGO_HOSTNAME || 'localhost', 
  port: process.env.MONGO_PORT || 27017, 
  env: process.env.MONGO_ENV || 'local', 
}; 

mongoose.connect(&grave;mongodb://${conf.hostname}:${conf.port}/ 
 ${conf.env}&grave;); 

const articleSchema = { 
  articleTitle:String, 
  articleContent:String 
} 

我们将改进到这个版本:

import mongoose from 'mongoose'; 
const Schema = mongoose.Schema; 

const conf = { 
  hostname: process.env.MONGO_HOSTNAME || 'localhost', 
  port: process.env.MONGO_PORT || 27017, 
  env: process.env.MONGO_ENV || 'local', 
}; 

mongoose.connect(&grave;mongodb://${conf.hostname}:${conf.port}/ 
 ${conf.env}&grave;); 

const articleSchema = new Schema({ 
    articleTitle:String, 
    articleContent:String, 
    articleContentJSON: Object 
  },  
  {  
    minimize: false  
  } 
); 

在前面的代码中,你会发现我们导入了new const Schema = mongoose.Schema。后来,我们通过添加articleContentJSON: Object来改进我们的articleSchema。这是必需的,因为 draft-js 的状态将保存在一个 JSON 对象中。如果用户创建一篇文章,将其保存到数据库中,然后稍后想编辑这篇文章,这将非常有用。在这种情况下,我们将使用这个articleContentJSON来恢复 draft-js 编辑器的状态。

第二件事是提供带有{ minimize: false }的选项。这是必需的,因为默认情况下 Mongoose 会移除所有空对象,例如{ emptyObject: {}, nonEmptyObject: { test: true } },所以如果不设置minimize: false,那么我们数据库中就会得到不完整的对象(这是一个非常重要的步骤,这里要有这个标志)。

服务器routes.js的改进

server/routes.js文件中,我们需要开始使用$ref哨兵。该文件中的导入应如下所示:

import configMongoose from './configMongoose'; 
import sessionRoutes from './routesSession'; 
import jsonGraph from 'falcor-json-graph'; // this is new 
import jwt from 'jsonwebtoken'; 
import jwtSecret from './configSecret'; 

let $ref = jsonGraph.ref; // this is new 
let $atom = jsonGraph.atom; // this is new 
const Article = configMongoose.Article; 

jsonGraph from 'falcor-json-graph'; and then add let $ref = jsonGraph.ref; and let $atom = jsonGraph.atom.

我们在routes.js作用域中添加了$ref哨兵。我们需要准备一个新的路由,articlesById[{keys}]["_id","articleTitle","articleContent","articleContentJSON"],如下所示:

 { 
    route: 'articlesById[{keys}]["_id","articleTitle", 
     "articleContent","articleContentJSON"]', 
      get: function(pathSet) { 
      let articlesIDs = pathSet[1]; 
      return Article.find({ 
            '_id': { $in: articlesIDs} 
        }, function(err, articlesDocs) { 
          return articlesDocs; 
        }).then ((articlesArrayFromDB) => { 
          let results = []; 

          articlesArrayFromDB.map((articleObject) => { 
            let articleResObj = articleObject.toObject(); 
            let currentIdString = String(articleResObj['_id']); 

            if (typeof articleResObj.articleContentJSON !== 
             'undefined') { 
              articleResObj.articleContentJSON = 
               $atom(articleResObj.articleContentJSON); 
            } 

            results.push({ 
              path: ['articlesById', currentIdString], 
              value: articleResObj 
            }); 
          }); 
          return results; 
        }); 
    } 
  }, 

articlesById[{keys}]路由已定义,键是请求 URL 中我们需要返回的 ID,正如您通过const articlesIDs = pathSet[1];所看到的那样。

要更具体地了解pathSet,请查看以下示例:

// just an example: 
[ 
  { $type: 'ref', value: ['articlesById', '123456'] }, 
  { $type: 'ref', value: ['articlesById', '987654'] } 
] 

在这种情况下,falcor-router将跟随articlesById,在pathSet中,您将得到以下内容(您可以看到pathSet的确切值):

['articlesById', ['123456', '987654']] 

您可以从const articlesIDs = pathSet[1]``;中找到articlesIDs的值:

['123456', '987654'] 

您稍后会发现,我们使用这个articlesIDs值:

// this is already in your codebase: 
return Article.find({ 
            '_id': { $in: articlesIDs} 
        }, function(err, articlesDocs) { 

如您在'_id': { $in: articlesIDs}中看到的那样,我们正在传递一个articlesIDs数组。基于这些 ID,我们将接收到一个根据 ID 找到的特定文章数组(相当于 SQL 的WHERE语句)。接下来的步骤是遍历接收到的文章:

// this already is in your codebase: 
articlesArrayFromDB.map((articleObject) => { 

将对象推入results数组:

// this already is in your codebase: 
let articleResObj = articleObject.toObject(); 
let currentIdString = String(articleResObj['_id']); 

if (typeof articleResObj.articleContentJSON !== 'undefined') { 
  articleResObj.articleContentJSON = 
   $atom(articleResObj.articleContentJSON); 
} 

results.push({ 
  path: ['articlesById', currentIdString], 
  value: articleResObj 
}); 

上述代码片段中几乎没有新内容。唯一的新内容是这一句:

// this already is in your codebase: 
if (typeof articleResObj.articleContentJSON !== 'undefined') { 
  articleResObj.articleContentJSON = 
   $atom(articleResObj.articleContentJSON); 
} 

我们在这里明确使用了 Falcor 的$atom哨兵:$atom(articleResObj.articleContentJSON);

JSON 图原子

$atom哨兵是附加到值上的元数据,模型必须以不同的方式处理。您可以用 Falcor 简单地返回数字类型的值或字符串类型的值。为什么返回对象会更复杂?

Falcor 通过大量使用 JavaScript 的对象和数组进行 diff 操作,当我们告诉一个对象/数组被$atom哨兵(例如,在我们的例子中是$atom(articleResObj.articleContentJSON))包装时,Falcor 就知道它不应该深入到那个数组/对象中。这是出于性能考虑而设计成这样的。

为什么要考虑性能原因?例如,如果您返回一个未包装的包含 10,000 个非常深层对象的数组,构建和 diff 模型可能需要非常长的时间。通常,出于性能考虑,您想要通过falcor-router返回到前端的对象和数组必须在这样做之前被$atom包装;否则,您将得到这样的错误(如果您没有通过$atom包装此对象):

Uncaught MaxRetryExceededError: The allowed number of retries 
have been exceeded. 

当 Falcor 试图在未在后台事先用$atom哨兵包装的情况下获取那些深层对象时,这个错误将在客户端显示。

改进articles[{integers}]路由

现在,我们需要返回一个$ref哨兵到articlesById而不是所有文章的详细信息,因此我们需要更改以下旧代码:

// this already shall be in your codebase: 
  { 
    route: 
     'articles[{integers}]["_id","articleTitle","articleContent"]', 
    get: (pathSet) => { 
      const articlesIndex = pathSet[1]; 

      return Article.find({}, function(err, articlesDocs) { 
        return articlesDocs; 
      }).then ((articlesArrayFromDB) => { 
        let results = []; 
        articlesIndex.forEach((index) => { 
          const singleArticleObject = 
           articlesArrayFromDB[index].toObject(); 

          const falcorSingleArticleResult = { 
            path: ['articles', index], 
            value: singleArticleObject 
          }; 

          results.push(falcorSingleArticleResult); 
        }); 
        return results; 
      }) 
    } 
  } 

我们将改进到以下新代码:

 { 
    route: 'articles[{integers}]', 
    get: (pathSet) => { 
      const articlesIndex = pathSet[1]; 

      return Article.find({}, '_id', function(err, articlesDocs) { 
        return articlesDocs; 
      }).then ((articlesArrayFromDB) => { 
        let results = []; 
        articlesIndex.forEach((index) => { 
          let currentMongoID = 
           String(articlesArrayFromDB[index]['_id']); 
          let articleRef = $ref(['articlesById', currentMongoID]); 

          const falcorSingleArticleResult = { 
            path: ['articles', index], 
            value: articleRef 
          }; 

          results.push(falcorSingleArticleResult); 
        }); 
        return results; 
      }) 
    } 
  }, 

发生了什么变化?看看旧代码库中的路由:articles[{integers}]["_id","articleTitle","articleContent"]。目前,我们的articles[{integers}]路由(在新版本中)并没有直接返回for["_id","articleTitle","articleContent"]数据,因此我们必须删除它,以便让 Falcor 知道这个事实(articlesById现在返回详细的信息)。

下一个变化是,我们创建了一个新的$ref哨兵,如下所示:

// this is already in your codebase: 
let currentMongoID = String(articlesArrayFromDB[index]['_id']); 
let articleRef = $ref(['articlesById', currentMongoID]); 

如您所见,通过这样做,我们通过$ref通知falcor-router,如果前端请求关于article[{integers}]的更多信息,那么falcor-router应该遵循articlesById路由以从数据库检索该数据。

在此之后,看看这个旧路径的值:

// old version 
const singleArticleObject = articlesArrayFromDB[index].toObject(); 

const falcorSingleArticleResult = { 
  path: ['articles', index], 
  value: singleArticleObject 
}; 

您会发现它已经被articleRef的值所替换:

// new improved version 
let articleRef = $ref(['articlesById', currentMongoID]); 

const falcorSingleArticleResult = { 
  path: ['articles', index], 
  value: articleRef 
}; 

如您可能已注意到,在旧版本中,我们返回了关于一篇文章的所有信息(singleArticleObject变量),但在新版本中,我们只返回$ref哨兵(articleRef)。

$ref哨兵使falcor-router在后台自动跟随,所以如果第一个路由中有任何引用,Falcor 将解析所有的$ref哨兵,直到获取所有挂起的数据;之后,它将数据在一个请求中返回,这大大减少了延迟(而不是执行多个 HTTP 请求,所有跟随$refs的内容都在一个浏览器到后端的调用中获取)。

服务器路由中的新路由:server/routes.js: articles.add

我们唯一需要做的事情是将一个新的articles.add路由添加到路由器中:

 { 
    route: 'articles.add', 
    call: (callPath, args) => { 
      const newArticleObj = args[0]; 
      var article = new Article(newArticleObj); 

      return article.save(function (err, data) { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
        else { 
          return data; 
        } 
      }).then ((data) => { 
        return Article.count({}, function(err, count) { 
        }).then((count) => { 
          return { count, data }; 
        }); 
      }).then ((res) => { 
        // 
        // we will add more stuff here in a moment, below 
        // 
        return results; 
      }); 
    } 
  } 

如您在此处所见,我们从前端接收到一个新文章的详细信息,使用const newArticleObj = args[0];,然后我们使用var article = new Article(newArticleObj);创建一个新的Article模型。之后,article变量有一个.save方法,在接下来的查询中会被调用。我们执行了两个查询,这两个查询都从 Mongoose 返回一个 promise。以下是第一个查询:

return article.save(function (err, data) { 

这个 .save 方法只是帮助我们将文档插入数据库。在保存文章后,我们需要计算数据库中有多少文章,因此我们运行第二个查询:

return Article.count({}, function(err, count) { 

在保存文章并计数后,我们返回该信息(return { count, data };)。最后,我们需要使用 falcor-router 将新的文章 ID 和计数数量从后端返回到前端,所以我们替换此注释:

// 
// we will add more stuff here in a moment, below 
// 

在其位置,我们将有这段新的代码,帮助我们实现所需的功能:

 let newArticleDetail = res.data.toObject(); 
 let newArticleID = String(newArticleDetail['_id']); 
 let NewArticleRef = $ref(['articlesById', newArticleID]); 
 let results = [ 
   { 
     path: ['articles', res.count-1], 
     value: NewArticleRef 
   }, 
   { 
     path: ['articles', 'newArticleID'], 
     value: newArticleID 
   }, 
   { 
     path: ['articles', 'length'], 
     value: res.count 
   } 
 ]; 
 return results; 

newArticleDetail details here. Next, we take the new ID with newArticleID and make sure that it's a string. After all that, we define a new $ref sentinel with let NewArticleRef = $ref(['articlesById', newArticleID]);.

results 变量中,您可以找到三个新的路径:

  • path: ['articles', res.count-1]:此路径构建模型,因此我们可以在客户端收到响应后,在 Falcor 模型中拥有所有信息。

  • path: ['articles', 'newArticleID']:这有助于我们在前端快速获取新的 ID。

  • path: ['articles', 'length']:当然,这会更新我们文章集合的长度,因此前端 Falcor 模型在添加新文章后可以拥有最新的信息。

我们刚刚为添加文章创建了一个后端路由。现在让我们开始处理前端,以便我们将所有新文章推入数据库。

为了添加文章而进行的客户端更改

src/layouts/PublishingApp.js 文件中,找到此代码:

get(['articles', {from: 0, to: articlesLength-1}, ['_id','articleTitle', 'articleContent']]). 

改进为带有 articleContentJSON 的改进版本:

get(['articles', {from: 0, to: articlesLength-1}, ['_id','articleTitle', 'articleContent', 'articleContentJSON']]).  

下一步是改进 src/views/articles/AddArticleView.js 中的 _submitArticle 函数,并添加 falcorModel 导入:

// this is old function to replace: 
  _articleSubmit() { 
    let newArticle = { 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    } 

    let newArticleID = 'MOCKEDRandomid' + Math.floor(Math.random() *    
    10000); 

    newArticle['_id'] = newArticleID; 
    this.props.articleActions.pushNewArticle(newArticle); 
    this.setState({ newArticleID: newArticleID}); 
  } 

用以下改进版本替换此代码:

 async _articleSubmit() { 
    let newArticle = { 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    } 

    let newArticleID = await falcorModel 
      .call( 
            'articles.add', 
            [newArticle] 
          ). 
      then((result) => { 
        return falcorModel.getValue( 
            ['articles', 'newArticleID'] 
          ).then((articleID) => { 
            return articleID; 
          }); 
      }); 

    newArticle['_id'] = newArticleID; 
    this.props.articleActions.pushNewArticle(newArticle); 
    this.setState({ newArticleID: newArticleID}); 
  } 

此外,在 AddArticleView.js 文件顶部,添加此导入;否则,async_articleSumbit 不会工作:

import falcorModel from '../../falcorModel.js'; 

如您所见,我们在函数名(async _articleSubmit())之前添加了 async 关键字。新的是这个请求:

// this already is in your codebase: 
let newArticleID = await falcorModel 
  .call( 
        'articles.add', 
        [newArticle] 
      ). 
  then((result) => { 
    return falcorModel.getValue( 
        ['articles', 'newArticleID'] 
      ).then((articleID) => { 
        return articleID; 
      }); 
  }); 

在这里,我们等待 falcorModel.call。在 .call 参数中,我们添加 newArticle。然后,在承诺解决之后,我们通过以下方式检查 newArticleID

// this already is in your codebase: 
return falcorModel.getValue( 
        ['articles', 'newArticleID'] 
      ).then((articleID) => { 
        return articleID; 
      }); 

之后,我们简单地使用与旧版本完全相同的内容:

newArticle['_id'] = newArticleID; 
this.props.articleActions.pushNewArticle(newArticle); 
this.setState({ newArticleID: newArticleID}); 

这只是通过 articleActions 将带有真实 MongoDB ID 的更新后的 newArticle 推送到文章的 reducer。我们还使用 setStatenewArticleID,这样您就可以看到新文章已经正确地使用真实的 Mongo ID 创建。

关于路由返回的重要注意事项

您应该知道,在每条路由中,我们返回一个对象或一个对象数组;即使只有一条路由返回,这两种方法都是可行的。以这个为例:

// this already is in your codebase (just an example) 
    { 
    route: 'articles.length', 
      get: () => { 
        return Article.count({}, function(err, count) { 
          return count; 
        }).then ((articlesCountInDB) => { 
          return { 
            path: ['articles', 'length'], 
            value: articlesCountInDB 
          } 
        }) 
    } 
  },  

这也可以返回一个包含一个对象的数组,如下所示:

     get: () => { 
        return Article.count({}, function(err, count) { 
          return count; 
        }).then ((articlesCountInDB) => { 
          return [ 
            { 
              path: ['articles', 'length'], 
              value: articlesCountInDB 
            } 
          ] 
        }) 
    } 

如您所见,即使只有一个 articles.length,我们也是返回一个数组(而不是单个对象),这同样有效。

与之前描述的原因相同,这就是为什么在 articlesById 中,我们将多个路由推入数组的原因:

// this is already in your codebase 
let results = []; 

articlesArrayFromDB.map((articleObject) => { 
  let articleResObj = articleObject.toObject(); 
  let currentIdString = String(articleResObj['_id']); 

  if (typeof articleResObj.articleContentJSON !== 'undefined') { 
    articleResObj.articleContentJSON = 
     $atom(articleResObj.articleContentJSON); 
  } 
  // pushing multiple routes 
  results.push({ 
    path: ['articlesById', currentIdString], 
    value: articleResObj 
  }); 
}); 
return results; // returning array of routes' objects 

这是在 Falcor 章节中可能值得提及的一点。

全栈 - 编辑和删除文章

让我们在server/routes.js文件中创建一个用于更新现有文档的路由(编辑功能):

 { 
  route: 'articles.update', 
  call: async (callPath, args) =>  
    { 
      let updatedArticle = args[0]; 
      let articleID = String(updatedArticle._id); 
      let article = new Article(updatedArticle); 
      article.isNew = false; 

      return article.save(function (err, data) { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
      }).then ((res) => { 
        return [ 
          { 
            path: ['articlesById', articleID], 
            value: updatedArticle 
          }, 
          { 
            path: ['articlesById', articleID], 
            invalidate: true 
          } 
        ]; 
      }); 
    } 
  }, 

如您所见,我们仍然使用与articles.add路由类似的article.save方法。需要注意的是,Mongoose 需要设置isNew标志为falsearticle.isNew = false;)。如果您不提供此标志,那么您将得到类似于以下的 Mongoose 错误:

{"error":{"name":"MongoError","code":11000,"err":"insertDocument 
 :: caused by :: 11000 E11000 duplicate key error index: 
 staging.articles.$_id _ dup key: { : 
 ObjectId('1515b34ed65022ec234b5c5f') }"}} 

代码的其余部分相当简单;我们保存文章的模型,然后通过falcor-router返回更新后的模型,具体如下:

// this is already in your code base: 
return [ 
  { 
    path: ['articlesById', articleID], 
    value: updatedArticle 
  }, 
  { 
    path: ['articlesById', articleID], 
    invalidate: true 
  } 
]; 

新的是invalidate标志。正如文档中所述,“invalidate方法同步地从模型缓存中删除多个路径或路径集。”换句话说,您需要告诉前端上的 Falcor 模型在["articlesById", articleID]路径上已经发生了变化,这样您就可以在前后端都有同步的数据。

关于 Falcor 中invalidate的更多信息,您可以访问netflix.github.io/falcor/doc/Model.html#invalidate

删除文章

为了实现删除功能,我们需要创建一个新的路由:

 { 
  route: 'articles.delete', 
  call: (callPath, args) =>  
    { 
      const toDeleteArticleId = args[0]; 
      return Article.find({ _id: toDeleteArticleId }). 
       remove((err) => { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
      }).then((res) => { 
        return [ 
          { 
            path: ['articlesById', toDeleteArticleId], 
            invalidate: true 
          } 
        ] 
      }); 
    } 
  } 

这也使用了invalidate,但这次,这是我们在这里返回的唯一东西,因为文档已经被删除,所以我们唯一需要做的是通知浏览器缓存,旧文章已被失效,没有东西可以替换,就像更新示例中那样。

前端 - 编辑和删除

我们已经在后端实现了更新删除路由。接下来,在src/views/articles/EditArticleView.js文件中,您需要找到以下代码:

// this is old already in your codebase: 
  _articleEditSubmit() { 
    let currentArticleID = this.state.editedArticleID; 
    let editedArticle = { 
      _id: currentArticleID, 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    } 

    this.props.articleActions.editArticle(editedArticle); 
    this.setState({ articleEditSuccess: true }); 
  } 

用这个async _articleEditSubmit函数替换它:

 async _articleEditSubmit() { 
    let currentArticleID = this.state.editedArticleID; 
    let editedArticle = { 
      _id: currentArticleID, 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    } 

    let editResults = await falcorModel 
      .call( 
            ['articles', 'update'], 
            [editedArticle] 
          ). 
      then((result) => { 
        return result; 
      }); 

    this.props.articleActions.editArticle(editedArticle); 
    this.setState({ articleEditSuccess: true }); 
  } 

如您所见,最重要的是我们在_articleEditSubmit函数中实现了.call函数,该函数使用editedArticle变量发送编辑对象的详细信息。

在同一文件中,找到_handleDeletion方法:

// old version 
  _handleDeletion() { 
    let articleID = this.state.editedArticleID; 
    this.props.articleActions.deleteArticle(articleID); 

    this.setState({ 
      openDelete: false 
    }); 
    this.props.history.pushState(null, '/dashboard'); 
  } 

更改为新的改进版本:

 async _handleDeletion() { 
    let articleID = this.state.editedArticleID; 

    let deletetionResults = await falcorModel 
      .call( 
            ['articles', 'delete'], 
            [articleID] 
          ). 
      then((result) => { 
        return result; 
      }); 

    this.props.articleActions.deleteArticle(articleID); 
    this.setState({ 
      openDelete: false 
    }); 
    this.props.history.pushState(null, '/dashboard'); 
  } 

与删除类似,唯一的区别是我们只通过.call发送被删除文章的articleID

保护 CRUD 路由

我们需要实现一种方法来保护所有添加/编辑/删除路由,并创建一个通用的DRY(不要重复自己)方式来通知用户后端发生的错误。例如,前端可能发生的错误,我们需要在我们的 React 实例的客户端应用程序中用错误消息通知用户:

  • 认证错误:您没有权限执行此操作

  • 超时错误:例如,您使用外部 API 的服务;我们需要通知用户任何潜在的错误

  • 数据不存在:可能存在用户调用不存在于我们数据库中的文章 ID 的情况,所以让我们通知他

总的来说,我们现在的目标是创建一种通用的方法,将所有潜在的错误消息从后端移动到客户端,以便我们可以改善我们应用程序的使用体验。

$error 哨兵基础

这里是$error哨兵(与 Falcor 相关的变量类型),它通常是一种返回错误的方法。

通常,正如你应该已经知道的,Falcor 会批量处理请求。多亏了它们,你可以在一个 HTTP 请求中获取来自不同的 falcor-routes 的数据。以下是一个你可以一次性获取的示例:

  • 一个数据集:完整且准备就绪,可以检索

  • 第二个数据集:第二个数据集,可能包含错误

当第二个数据集出现错误时,我们不想影响一个数据集的获取过程(你需要记住,我们示例中的两个数据集是在一个请求中获取的)。

这里有一些有用的文档部分,可能有助于你理解 Falcor 中的错误处理:

netflix.github.io/falcor/doc/Model.html#~errorSelector

netflix.github.io/falcor/documentation/model.html#error-handling

netflix.github.io/falcor/documentation/router.html(在此页面上搜索$error以找到更多文档中的示例)

客户端 DRY 错误管理

让我们从对 CoreLayout(src/layouts/CoreLayout.js)的改进开始。在AppBar下,使用以下内容导入一个新的snackbar组件:

import AppBar from 'material-ui/lib/app-bar'; 
import Snackbar from 'material-ui/lib/snackbar'; 

然后,在导入部分,在 CoreLayout 外部创建一个新的函数并导出它:

let errorFuncUtil =  (errMsg, errPath) => { 
} 
export { errorFuncUtil as errorFunc }; 

然后找到CoreLayout构造函数,将其更改为在 Falcor $error哨兵返回错误时使用导出的函数errorFuncUtil作为回调:

// old constructor 
constructor(props) { 
  super(props); 
} 

这里是新的函数:

constructor(props) { 
  super(props); 
    this.state = { 
      errorValue: null 
    } 

  if (typeof window !== 'undefined') { 
    errorFuncUtil = this.handleFalcorErrors.bind(this); 
  } 

} 

正如你可以在这里找到的,我们引入了一个新的errorValue状态(默认状态是null)。然后,仅在前端(因为if(typeof window !== 'undefined')),我们将this.handleErrors.bind(this)分配给我们的errorFuncUtil

正如你很快就会发现的,这是因为导出的errorFuncUtil将被导入到我们的falcorModel.js中,在那里我们将使用最佳可能的 DRY 方式通知 CoreLayout 关于 Falcor 后端发生的任何错误。这件事的好处是,我们只需实现一次,但它将成为通知我们客户端应用程序用户任何错误的一种通用方式(这也会在未来节省我们的开发工作量,因为任何错误都将通过我们现在正在实施的方法来处理)。

我们需要在 CoreLayout 中添加一个新的函数,称为handleFalcorErrors

handleFalcorErrors(errMsg, errPath) { 
  let errorValue = &grave;Error: ${errMsg} (path ${JSON.stringify(errPath)})&grave; 
  this.setState({errorValue}); 
} 

handleFalcorErrors函数正在设置我们的错误的新状态。我们将使用errMsg(我们在后端创建,你很快就会学到)和errPath(可选,但这是错误发生的falcor-route路径)来组合用户错误。

好的,我们已经把所有东西都准备好了;从CoreLayout函数中缺失的只有改进后的渲染。CoreLayout 的新渲染如下:

 render () { 
    let errorSnackbarJSX = null; 
    if (this.state.errorValue) { 
      errorSnackbarJSX = <Snackbar 
        open={true} 
        message={this.state.errorValue} 
        autoHideDuration={8000} 
        onRequestClose={ () => console.log('You can add custom 
         onClose code') } />; 
    } 

    const buttonStyle = { 
      margin: 5 
    }; 
    const homeIconStyle = { 
      margin: 5, 
      paddingTop: 5 
    }; 

    let menuLinksJSX; 
    let userIsLoggedIn = typeof localStorage !== 'undefined' && 
     localStorage.token && this.props.routes[1].name !== 'logout'; 

    if (userIsLoggedIn) { 
      menuLinksJSX = ( 
  <span> 
        <Link to='/dashboard'> 
     <RaisedButton label='Dashboard' style={buttonStyle}  /> 
  </Link>  
        <Link to='/logout'> 
     <RaisedButton label='Logout' style={buttonStyle}  /> 
  </Link>  
      </span>); 
    } else { 
      menuLinksJSX = ( 
  <span> 
          <Link to='/register'> 
      <RaisedButton label='Register' style={buttonStyle}  /> 
  </Link>  
        <Link to='/login'> 
    <RaisedButton label='Login' style={buttonStyle}  /> 
  </Link>  
      </span>); 
    } 

    let homePageButtonJSX = ( 
    <Link to='/'> 
        <RaisedButton label={<ActionHome />} 
         style={homeIconStyle}  /> 
      </Link>); 
    return ( 

        <div> 
          {errorSnackbarJSX} 
          <AppBar 
            title='Publishing App' 
            iconElementLeft={homePageButtonJSX} 
            iconElementRight={menuLinksJSX} /> 
            <br/> 
            {this.props.children} 
        </div> 

    ); 
  } 

正如你可以在这里找到的,新部分与 Material-UI 的snackbar组件相关。看看这个:

let errorSnackbarJSX = null; 
if (this.state.errorValue) { 
  errorSnackbarJSX = <Snackbar 
    open={true} 
    message={this.state.errorValue} 
    autoHideDuration={8000} />; 
} 

erroSnackbarJSX and the following:
  <div> 
    {errorSnackbarJSX} 
    <AppBar 
      title='Publishing App' 
      iconElementLeft={homePageButtonJSX} 
      iconElementRight={menuLinksJSX} /> 
      <br/> 
      {this.props.children} 
  </div> 

确保将{errorSnackbarJSX}放置与此示例完全相同的位置。否则,你可能会在应用测试运行期间遇到一些问题。你现在已经完成了与 CoreLayout 改进相关的一切。

调整 - 前端 FalcorModel.js

src/falcorModel.js文件中,识别以下代码:

// already in your codebase, old code: 
import falcor from 'falcor'; 
import FalcorDataSource from 'falcor-http-datasource'; 

class PublishingAppDataSource extends FalcorDataSource { 
  onBeforeRequest ( config ) { 
    const token = localStorage.token; 
    const username = localStorage.username; 
    const role = localStorage.role; 

    if (token && username && role) { 
      config.headers['token'] = token; 
      config.headers['username'] = username; 
      config.headers['role'] = role; 
    } 
  } 
} 

const model = new falcor.Model({ 
  source: new PublishingAppDataSource('/model.json') 
}); 
export default model; 

这段代码需要通过向falcor.Model添加一个新选项来改进:

import falcor from 'falcor'; 
import FalcorDataSource from 'falcor-http-datasource'; 
import {errorFunc} from './layouts/CoreLayout'; 

class PublishingAppDataSource extends FalcorDataSource { 
  onBeforeRequest ( config ) { 
    const token = localStorage.token; 
    const username = localStorage.username; 
    const role = localStorage.role; 

    if (token && username && role) { 
      config.headers['token'] = token; 
      config.headers['username'] = username; 
      config.headers['role'] = role; 
    } 
  } 
} 

let falcorOptions = { 
  source: new PublishingAppDataSource('/model.json'),    
  errorSelector: function(path, error) { 
    errorFunc(error.value, path); 
    error.$expires = -1000 * 60 * 2; 
    return error; 
  }  
}; 

const model = new falcor.Model(falcorOptions); 
export default model; 

我们首先添加的是将errorFunc导入到该文件顶部的操作:

import {errorFunc} from './layouts/CoreLayout'; 

除了errorFunc之外,我们还引入了falcorOptions变量。源代码与上一个版本相同。我们添加了errorSelector,它在客户端每次调用后端和后端的falcor-router返回一个$error哨兵时都会运行。

关于错误选择器的更多详细信息可以在netflix.github.io/falcor/documentation/model.html#the-errorselector-value找到。

$error 哨兵的后端实现

我们将分两步进行后端实现:

  1. 这是一个错误示例,仅用于测试我们的客户端代码。

  2. 在我们确认错误处理正确无误后,我们将正确地保护端点。

测试我们的与错误相关的代码

让我们从server/routes.js文件中的导入开始:

import configMongoose from './configMongoose'; 
import sessionRoutes from './routesSession'; 
import jsonGraph from 'falcor-json-graph'; 
import jwt from 'jsonwebtoken'; 
import jwtSecret from './configSecret'; 

let $ref = jsonGraph.ref; 
let $atom = jsonGraph.atom; 
let $error = jsonGraph.error; 
const Article = configMongoose.Article; 

唯一的新事物是,你需要从falcor-json-graph导入$error哨兵。

我们对\(错误进行测试的目标是替换一个负责获取文章的(`articles[{integers}]`)工作路由。在我们破坏这个路由后,我们将能够测试我们的前端和后端设置是否正常工作。在测试了错误(参考下一张截图)后,我们将从`articles[{integers}]`中删除这个破坏性的\)错误代码。继续阅读以获取详细信息。

使用article路由进行测试:

 { 
    route: 'articles[{integers}]', 
    get: (pathSet) => { 
      const articlesIndex = pathSet[1]; 

      return { 
        path: ['articles'], 
        value: $error('auth error') 
      } 

      return Article.find({}, '_id', function(err, articlesDocs) { 
        return articlesDocs; 
      }).then ((articlesArrayFromDB) => { 
        let results = []; 
        articlesIndex.forEach((index) => { 
          let currentMongoID = 
           String(articlesArrayFromDB[index]['_id']); 
          let articleRef = $ref(['articlesById', currentMongoID]); 

          const falcorSingleArticleResult = { 
            path: ['articles', index], 
            value: articleRef 
          }; 

          results.push(falcorSingleArticleResult); 
        }); 
        return results; 
      }) 
    } 
  }, 

如你所见,这只是一个测试。我们很快就会改进这段代码,但让我们测试一下$error('auth error')哨兵中的文本是否会显示给用户。

运行 MongoDB:

$ mongod   

然后,在另一个终端中运行服务器:

$ npm start  

在运行这两个命令后,将你的浏览器指向http://localhost:3000,你将看到这个错误持续 8 秒钟:

如你所见,窗口底部有白色文字在黑色背景上:

如果你运行了应用,并在主页上看到截图中的错误消息,那么这表明一切正常!

在成功测试后清理$错误

在你确信错误处理对你有效后,你可以替换旧代码:

 { 
    route: 'articles[{integers}]', 
    get: (pathSet) => { 
      const articlesIndex = pathSet[1]; 

      return { 
        path: ['articles'], 
        value: $error('auth error') 
      } 
      return Article.find({}, '_id', function(err, articlesDocs) { 

更改为以下内容,不返回错误:

 { 
    route: 'articles[{integers}]', 
    get: (pathSet) => { 
      const articlesIndex = pathSet[1]; 
      return Article.find({}, '_id', function(err, articlesDocs) { 

现在,当你尝试从后端获取文章时,应用程序将正常启动而不会抛出错误。

完成路由的安全包装

我们已经在server/routes.js中实现了一些逻辑,用于检查用户是否有权限,具体如下:

// this already is in your codebase: 
export default ( req, res ) => { 
  let { token, role, username } = req.headers; 
  let userDetailsToHash = username+role; 
  let authSignToken = jwt.sign(userDetailsToHash, jwtSecret.secret); 
  let isAuthorized = authSignToken === token; 
  let sessionObject = {isAuthorized, role, username}; 
  console.info(&grave;The ${username} is authorized === &grave;, isAuthorized); 

在此代码中,你会发现我们可以在每个需要授权和编辑角色的角色开始处创建以下逻辑:

// this is example of falcor-router $errors, don't write it: 
if (isAuthorized === false) { 
  return { 
    path: ['HERE_GOES_THE_REAL_FALCOR_PATH'], 
    value: $error('auth error') 
  } 
} elseif(role !== 'editor') { 
  return { 
    path: ['HERE_GOES_THE_REAL_FALCOR_PATH'], 
    value: $error('you must be an editor in order 
     to perform this action') 
  } 
} 

如你所见,这只是一个示例(现在不要更改它;我们很快就会实现它),带有path['HERE_GOES_THE_REAL_FALCOR_PATH']

首先,我们使用isAuthorized === false检查用户是否完全授权;如果没有授权,他将看到一个错误(使用我们刚刚实现的通用错误机制):

在未来,我们可能在我们的发布应用程序中有更多角色,所以如果某人不是编辑,那么他将在错误中看到以下内容:

要保护哪些路由

对于需要在我们应用程序文章中授权的路由(server/routes.js),添加以下内容:

route: 'articles.add', 

这是旧代码:

// this is already in your codebase, old code: 
  { 
    route: 'articles.add', 
    call: (callPath, args) => { 
      const newArticleObj = args[0]; 
      var article = new Article(newArticleObj); 

      return article.save(function (err, data) { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
        else { 
          return data; 
        } 
      }).then ((data) => { 
// code has been striped out from here for the sake of brevity, 
 nothing changes below 

带有auth检查的新代码如下:

 { 
    route: 'articles.add', 
    call: (callPath, args) => { 
      if (sessionObject.isAuthorized === false) { 
        return { 
          path: ['articles'], 
          value: $error('auth error') 
        } 
      } else if(sessionObject.role !== 'editor' && 
       sessionObject.role !== 'admin') { 
        return { 
          path: ['articles'], 
          value: $error('you must be an editor 
           in order to perform this action') 
        } 
      } 

      const newArticleObj = args[0]; 
      var article = new Article(newArticleObj); 

      return article.save(function (err, data) { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
        else { 
          return data; 
        } 
      }).then ((data) => { 
// code has been striped out from here for 
 //the sake of brevity, nothing changes below 

正如你在这里可以看到的,我们添加了两个检查,isAuthorized === false和角色!== 'editor'。以下路由内容几乎相同(只是路径略有变化)。

这里是articles更新:

route: 'articles.update', 

这是旧代码:

// this is already in your codebase, old code: 
  { 
  route: 'articles.update', 
  call: async (callPath, args) =>  
    { 
      const updatedArticle = args[0]; 
      let articleID = String(updatedArticle._id); 
      let article = new Article(updatedArticle); 
      article.isNew = false; 

      return article.save(function (err, data) { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
      }).then ((res) => { 
// code has been striped out from here for the 
 //sake of brevity, nothing changes below 

带有auth检查的新代码如下:

 { 
  route: 'articles.update', 
  call: async (callPath, args) =>  
    { 
      if (sessionObject.isAuthorized === false) { 
        return { 
          path: ['articles'], 
          value: $error('auth error') 
        } 
      } else if(sessionObject.role !== 'editor' && 
       sessionObject.role !== 'admin') { 
        return { 
          path: ['articles'], 
          value: $error('you must be an editor 
           in order to perform this action') 
        } 
      } 

      const updatedArticle = args[0]; 
      let articleID = String(updatedArticle._id); 
      let article = new Article(updatedArticle); 
      article.isNew = false; 

      return article.save(function (err, data) { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
      }).then ((res) => { 
// code has been striped out from here 
 //for the sake of brevity, nothing changes below 

articles delete: 
route: 'articles.delete', 

找到以下旧代码:

// this is already in your codebase, old code: 

  { 
  route: 'articles.delete', 
  call: (callPath, args) =>  
    { 
      let toDeleteArticleId = args[0]; 
      return Article.find({ _id: toDeleteArticleId }).remove((err) => { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
      }).then((res) => { 
// code has been striped out from here 
 //for the sake of brevity, nothing changes below 

将其替换为以下带有auth检查的新代码:

 { 
  route: 'articles.delete', 
  call: (callPath, args) =>  
    { 

      if (sessionObject.isAuthorized === false) { 
        return { 
          path: ['articles'], 
          value: $error('auth error') 
        } 
      } else if(sessionObject.role !== 'editor' && 
       sessionObject.role !== 'admin') { 
        return { 
          path: ['articles'], 
          value: $error('you must be an 
           editor in order to perform this action') 
        } 
      } 

      let toDeleteArticleId = args[0]; 
      return Article.find({ _id: toDeleteArticleId }).remove((err) => { 
        if (err) { 
          console.info('ERROR', err); 
          return err; 
        } 
      }).then((res) => { 
// code has been striped out from here 
 //for the sake of brevity, nothing below changes 

摘要

如你所见,返回值几乎相同--我们可以减少代码重复。我们可以为它们创建一个辅助函数,这样代码会更少,但你需要记住,当你返回错误时,需要设置一个类似于你请求的路径。例如,如果你在articles.update上,那么你需要在该文章的路径上返回错误(或者如果你在XYZ.update上,错误将转到XYZ路径)。

在下一章中,我们将实现 AWS S3,以便能够上传文章的封面照片。除此之外,我们还将通过新功能一般性地改进我们的发布应用程序。

第六章:AWS S3 用于图片上传和总结关键应用功能

目前我们有一个工作但缺少一些关键功能的 app。本章的重点将包括以下功能实现/改进:

  • 打开一个新的 AWS 账户

  • 为您的 AWS 账户创建身份和访问管理IAM

  • 设置 AWS S3 存储桶

  • 为文章添加上传照片的功能(添加和编辑文章封面)

  • 添加设置标题、副标题和“叠加副标题”的功能(在添加/编辑文章视图中)

仪表板上的文章目前内容中包含 HTML;我们需要改进这一点:

我们需要完成这些事情。在我们完成这些改进后,我们将进行一些重构。

AWS S3 - 简介

亚马逊的 AWS S3 是亚马逊服务器上静态资产(如图片)的简单存储服务。它帮助您在云中托管安全、安全且高度可扩展的对象(如图片)。

在线存储静态资产的方法相当方便且简单--这就是为什么我们将在整本书中使用它。

我们将在我们的应用中使用它,因为它为我们提供了许多在托管图像资产时在自家的 Node.js 服务器上难以访问的可扩展性功能。

通常,Node.js 不应该用于托管比我们现在使用的更大的资产。甚至不要考虑在 Node.js 服务器上实现图像上传机制(根本不推荐)--我们将使用亚马逊的服务来处理。

生成密钥(访问密钥 ID 和秘密密钥)

在我们开始添加新的 S3 存储桶之前,我们需要为我们 AWS 账户生成密钥(accessKeyIdsecretAccessKey)。

我们需要在 Node.js 应用中保留以下示例详细信息的集合:

const awsConfig = { 
  accessKeyId: 'EXAMPLE_LB7XH_KEY_BGTCA', 
  secretAccessKey: 'ExAMpLe+KEY+FYliI9J1nvky5g2bInN26TCU+FiY', 
  region: 'us-west-2', 
  bucketKey: 'your-bucket-name-' 
};

亚马逊 S3 中的存储桶是什么?存储桶是您在亚马逊 S3 中拥有的文件的一种命名空间。您可以为不同的项目拥有几个存储桶。如您所见,我们的下一步将是创建与您的accountDefinebucketKey(为我们文章的图片的命名空间)相关的accessKeyIdsecretAccessKey。定义一个您希望物理存储文件的区域。如果您的项目指定了目标位置,这将加快图像的加载速度,并且通常可以减少延迟,因为图像将托管在离我们发布应用的客户端/用户更近的地方。

要创建 AWS 账户,请访问aws.amazon.com/

创建账户或登录您的账户:

下一步是创建 IAM,将在下一节中详细介绍。

关于 AWS 创建 在您为某个区域创建账户后,如果您想创建一个 S3 存储桶,您需要选择与您的账户分配到相同区域的区域;否则,在以下页面设置 S3 时可能会遇到问题。

IAM

让我们准备新的accessKeyIdsecretAccessKey。你需要访问你的 Amazon 控制台中的 IAM 页面。你可以在服务列表中找到它:

IAM 页面看起来像这样 (console.aws.amazon.com/iam/home?#home):

前往 IAM 资源 | 用户:

在下一页,你会看到一个按钮;点击它:

点击后,你会看到一个表单。至少填写一个用户,如截图所示(截图提供了你必须完成的精确步骤,即使在此期间 AWS 的用户体验已经改变):

点击“创建”按钮后,将密钥复制到安全的地方(我们将在稍后使用它们):

不要忘记复制密钥(访问密钥 ID 和秘密访问密钥)。你将在本书后面的代码中学习如何放置它们以使用 S3 服务。当然,截图中的密钥不是活动的;它们只是示例;你需要有自己的。

为用户设置 S3 权限

最后一步是按照以下步骤添加 AmazonS3FullAccess 权限:

  1. 前往“权限”选项卡:

  1. 点击“附加策略”并选择 AmazonS3FullAccessAfter。附加后,它将列在以下示例中:

现在我们将进入创建用于图像文件的新存储桶。

  1. 你已经完成了密钥的设置,并授予了密钥的 S3 策略;现在,我们需要准备将保存图像的存储桶。首先,你需要转到 AWS 控制台主页面,它看起来如下 (console.aws.amazon.com/console/home):

  1. 你会看到类似 AWS 服务显示所有服务(或者,你可以像 IAM 一样从服务列表中找到它)的内容:

  1. 在 S3 - 云可扩展存储(如前一张截图所示)上点击。之后,你会看到一个类似这样的视图(我这里有六个存储桶;当你有一个新账户时,你将有一个零):

在该存储桶中,我们将保存我们文章的静态图像(你将在接下来的页面中了解具体方法)。

  1. 通过点击“创建存储桶”按钮来创建一个存储桶:

  1. 选择 publishing-app 名称(或者选择对你有效的其他名称)。

在截图中,我们选择了法兰克福。但是,例如,当你创建账户并且你的 URL 显示"?region=us-west-2"时,请选择俄勒冈州。在创建 S3 存储桶时,将账户分配到相应的区域是很重要的。

  1. 存储桶创建完成后,从存储桶列表中点击它:

  1. 以 publishing-app 名称命名的空存储桶看起来如下:

  1. 当你在这个视图中时,浏览器中的 URL 会告诉你确切的区域和存储桶(这样你就可以在稍后进行后端配置时使用它):
        // just an example link to the bucket 
        https://console.aws.amazon.com/s3/home?region=eu-central-  
        1&bucket=publishing-app&prefix=

  1. 最后,确保 publishing-app 存储桶的 CORS 配置正确。点击该视图中的“属性”选项卡,你将获得其详细视图:

图片

  1. 然后,点击“添加 CORS”按钮:

图片

  1. 之后,将以下内容粘贴到文本区域(以下为跨源资源共享定义;它定义了在同一个域中加载的 Pub 应用程序与 AWS 服务中不同域的资源交互的方式):
        <?xml version="1.0" encoding="UTF-8"?> 
        <CORSConfiguration title-page-name"/>         /doc/2006-03-01/"> 
            <CORSRule> 
                <AllowedOrigin>*</AllowedOrigin> 
                <AllowedMethod>GET</AllowedMethod> 
                <AllowedMethod>POST</AllowedMethod> 
                <AllowedMethod>PUT</AllowedMethod> 
                <MaxAgeSeconds>3000</MaxAgeSeconds> 
                <AllowedHeader>*</AllowedHeader> 
            </CORSRule> 
        </CORSConfiguration>

  1. 它现在将看起来像以下示例:

图片

  1. 点击“保存”按钮。完成所有步骤后,我们可以开始编写图像上传功能。

在 AddArticleView 中编写图像上传功能

在你能够继续之前,你需要有你在前几页创建的 S3 存储桶的访问详情。AWS_ACCESS_KEY_ID来自前一小节,我们在那个视图中创建了一个用户:

图片

AWS_SECRET_ACCESS_KEY与 AWS 访问密钥相同(正如你从名称本身就可以猜到)。AWS_BUCKET_NAME是存储桶的名称(在我们的书中,我们称之为 publishing-app)。对于AWS_REGION_NAME,我们将使用eu-central-1

要找到AWS_BUCKET_NAMEAWS_REGION_NAME的最简单方法是在你处于该视图(如前一小节所述)时查看 URL。

图片

检查浏览器中的 URL:https://console.aws.amazon.com/s3/home?region=eu-central-1#&bucket=publishing-app&prefix=

区域和存储桶名称在 URL 中非常明显(我想强调这一点,因为你的区域和存储桶名称可能因你所在的位置而不同)。

此外,确保你的 CORS 设置正确,并且你的权限/附加策略与上述描述完全一致。否则,你可能会在以下子节中遇到问题。

Node.js 中的环境变量

我们将通过 node 的环境变量传递所有四个参数(AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_BUCKET_NAMEAWS_REGION_NAME)。

首先,让我们安装一个 node 库,它可以从文件中创建环境变量,这样我们就可以在本地主机中使用它们:

npm i -save node-env-file@0.1.8

这些环境变量是什么?一般来说,我们将使用它们将一些敏感数据的变量传递给应用程序--我们在这里具体谈论的是当前环境设置(开发或生产)的 AWS 密钥和 MongoDB 的登录/密码信息。

你可以通过访问它们来读取这些环境变量,如下面的示例所示:

// this is how we will access the variables in 
//the server.js for example: 
env.process.AWS_ACCESS_KEY_ID 
env.process.AWS_SECRET_ACCESS_KEY 
env.process.AWS_BUCKET_NAME 
env.process.AWS_REGION_NAME

在我们的本地开发环境中,我们将在服务器的目录中保留这些信息,所以从你的命令提示符执行此操作:

$ [[you are in the server/ directory of your project]]
$ touch .env

你已经创建了一个server/.env文件;下一步是将内容放入其中(从该文件,node-env-file将在我们的应用程序运行时读取环境变量):

AWS_ACCESS_KEY_ID=_*_*_*_*_ACCESS_KEY_HERE_*_*_*_*_ 
AWS_SECRET_ACCESS_KEY=_*_*_*_*_SECRET_KEY_HERE_*_*_*_*_ 
AWS_BUCKET_NAME=publishing-app 
AWS_REGION_NAME=eu-central-1

在这里,你可以看到节点环境文件的结构。每一行新内容都包含一个键和值。在那里,你需要粘贴你在阅读本章时创建的键。将这些值替换为你自己的:*_*_ACCESS_KEY_HERE_*__*_SECRET_KEY_HERE_**_

在你创建了 server/.env 文件后,安装所需的依赖项,该依赖项将抽象整个图像上传过程;在项目目录中使用 npm 进行此操作:

npm i --save react-s3-uploader@3.0.3

react-s3-uploader 组件非常适合我们的用例,并且很好地抽象了 aws-sdk 的功能。这里的主要点是,我们需要正确配置 .env 文件(使用正确的变量),然后 react-s3-uploader 将为我们处理后端和前端的工作(正如你很快就会看到的)。

改进我们的 Mongoose 文章模式

我们需要改进模式,以便在我们的文章集合中有一个存储图片 URL 的位置。编辑旧的文章模式:

// this is old codebase to improve: 
var articleSchema = new Schema({ 
    articleTitle: String, 
    articleContent: String, 
    articleContentJSON: Object 
  },  
  {  
    minimize: false  
  } 
);

更改为新的、改进的版本:

var articleSchema = new Schema({ 
    articleTitle: String, 
    articleContent: String, 
    articleContentJSON: Object, 
    articlePicUrl: { type: String, default: 
     '/static/placeholder.png' } 
  },  
  {  
    minimize: false  
  } 
);

如你所见,我们引入了 articlePicUrl,默认值为 /static/placeholder.png。现在,我们将能够保存包含图片 URL 变量的文章对象。

如果你忘记更新 Mongoose 模型,那么它不会让你将此值保存到数据库中。

添加 S3 的上传路由

我们需要将一个新库导入到 server/server.js 文件中:

import s3router from 'react-s3-uploader/s3router';

我们最终会得到以下内容:

// don't write it, this is how your server/server.js 
 //file should look like: 
import http from 'http'; 
import express from 'express'; 
import cors from 'cors'; 
import bodyParser from 'body-parser'; 
import falcor from 'falcor'; 
import falcorExpress from 'falcor-express'; 
import FalcorRouter from 'falcor-router'; 
import routes from './routes.js'; 

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

import s3router from 'react-s3-uploader/s3router'; 

var app = express(); 
app.server = http.createServer(app); 

// CORS - 3rd party middleware 
app.use(cors()); 

// This is required by falcor-express middleware 
// to work correctly with falcor-browser 
app.use(bodyParser.json({extended: false})); 
app.use(bodyParser.urlencoded({extended: false}));

我把所有这些都放在这里,这样你可以确保你的 server/server.js 文件与此匹配。

还有一件事要做,就是修改 server/index.js 文件。找到以下内容:

require('babel-core/register'); 
require('babel-polyfill'); 
require('./server');

更改为以下改进版本:

var env = require('node-env-file'); 
// Load any undefined ENV variables form a specified file. 
env(__dirname + '/.env'); 

require('babel-core/register'); 
require('babel-polyfill'); 
require('./server');

为了澄清,env(__dirname + '/.env'); 告诉我们结构中 .env 文件的位置(你可以从 console.log 中找到,__dirname 变量是服务器文件的系统位置--这必须与真实的 .env 文件位置匹配,以便系统能够找到它)。

下一步是将以下内容添加到我们的 server/server.js 文件中:

app.use('/s3', s3router({ 
  bucket: process.env.AWS_BUCKET_NAME, 
  region: process.env.AWS_REGION_NAME, 
  signatureVersion: 'v4', 
  headers: {'Access-Control-Allow-Origin': '*'},  
  ACL: 'public-read' 
}));

如你所见,我们已经开始使用在 server/.env 文件中定义的环境变量。对我来说,process.env.AWS_BUCKET_NAME 等于 publishing-app,但如果你定义了不同的值,那么它将从 server/.env 中检索另一个值(多亏了我们刚刚定义的 env express 中间件)。

基于那个后端配置(环境变量和通过 import s3router from 'react-s3-uploader/s3router' 设置 s3router),我们将能够使用 AWS S3 存储桶。我们需要准备前端,这首先将在添加文章视图上实现。

在前端创建 ImgUploader 组件

我们将创建一个名为 ImgUploader 的组件。这个组件将使用 react-s3-uploader 库,该库负责将上传抽象到 Amazon S3。在回调中,你将收到 information:onProgress,你可以通过那个回调找到百分比进度,所以用户可以看到上传的状态。当发生错误时,这个回调会被触发:这个回调会发送给我们已上传到 S3 的文件的位置。

你将在后续章节中了解更多细节;让我们先创建一个文件:

    $ [[you are in the src/components/articles directory of your   
    project]]
$ touch ImgUploader.js

你已经创建了 src/components/articles/ImgUploader.js 文件,下一步是准备导入。所以,将以下内容添加到 ImgUploader 文件的顶部:

import React from 'react'; 
import ReactS3Uploader from 'react-s3-uploader'; 
import {Paper} from 'material-ui'; 

class ImgUploader extends React.Component { 
  constructor(props) { 
    super(props); 
    this.uploadFinished = this.uploadFinished.bind(this); 

    this.state = { 
      uploadDetails: null, 
      uploadProgress: null, 
      uploadError: null, 
      articlePicUrl: props.articlePicUrl 
    }; 
  } 

  uploadFinished(uploadDetails) { 
    // here will be more code in a moment 
  } 

  render () { 
    return <div>S3 Image uploader placeholder</div>; 
  } 
} 

ImgUploader.propTypes = {  
  updateImgUrl: React.PropTypes.func.isRequired  
}; 
export default ImgUploader;

如您所见,我们在 render 函数中初始化了 ImgUploader 组件,使用 div 返回一个临时的占位符。

我们还准备了 propTypes,其中有一个名为 updateImgUrl 的必需属性。这将是一个回调函数,它将发送最终上传的图像的位置(必须保存在数据库中--我们将在稍后使用这个 updateImgUrl 属性)。

在那个 ImgUploader 组件的状态中,我们有以下内容:

// this is already in your codebase: 
this.state = { 
  uploadDetails: null, 
  uploadProgress: null, 
  uploadError: null, 
  articlePicUrl: props.articlePicUrl 
};

在这些变量中,我们将存储我们组件的所有状态,根据当前状态和 props.articlePicUrl,并将 URL 细节发送到 AddArticleView 组件(我们将在本章稍后,在完成 ImgUploader 组件后完成)。

总结 ImgUploader 组件

下一步是改进 ImgUploader 中的 uploadFinished 函数,所以找到旧的空函数:

 uploadFinished(uploadDetails) { 
    // here will be more code in a moment 
  }

用以下内容替换它:

 uploadFinished(uploadDetails) { 
    let articlePicUrl = '/s3/img/'+uploadDetails.filename; 
    this.setState({  
      uploadProgress: null, 
      uploadDetails:  uploadDetails, 
      articlePicUrl: articlePicUrl 
    }); 
    this.props.updateImgUrl(articlePicUrl); 
  }

如您所见,uploadDetails.filename 变量来自我们导入到 ImgUploader 文件顶部的 ReactS3Uploader 组件。在上传成功后,我们将 uploadProgress 重置为 null,设置上传的详细信息,并通过回调使用 this.props.updateImgUrl(articlePicUrl) 发送详细信息。

下一步是改进 ImgUploader 中的 render 函数:

 render () { 
    let imgUploadProgressJSX; 
    let uploadProgress = this.state.uploadProgress; 
    if(uploadProgress) { 
      imgUploadProgressJSX = ( 
          <div> 
            {uploadProgress.uploadStatusText} 
({uploadProgress.progressInPercent}%) 
          </div> 
        ); 
    } else if(this.state.articlePicUrl) { 
      let articlePicStyles = { 
        maxWidth: 200,  
        maxHeight: 200,  
        margin: 'auto' 
      }; 
      imgUploadProgressJSX = <img src={this.state.articlePicUrl} 
       style={articlePicStyles} />; 
    } 

    return <div>S3 Image uploader placeholder</div>; 
  }

这个渲染过程还不完整,但让我们描述一下到目前为止我们已经添加了什么。代码主要是通过 this.state(第一个 if 语句)获取 uploadProgress 的信息。else if(this.state.articlePicUrl) 是关于在上传完成后渲染图像的内容。好吧,但我们从哪里获取这些信息呢?下面是其余部分:

   let uploaderJSX = ( 
        <ReactS3Uploader 
        signingUrl='/s3/sign' 
        accept='image/*' 
          onProgress={(progressInPercent, uploadStatusText) => { 
            this.setState({  
              uploadProgress: { progressInPercent,  
              uploadStatusText },  
              uploadError: null 
            }); 
          }}  
          onError={(errorDetails) => { 
            this.setState({  
              uploadProgress: null, 
              uploadError: errorDetails 
            }); 
          }} 
          onFinish={(uploadDetails) => { 
            this.uploadFinished(uploadDetails); 
          }} /> 
      );

uploaderJSX 变量与我们的 react-s3-uploader 库完全相同。从代码中可以看出,对于进度,我们使用 uploadProgress: { progressInPercent, uploadStatusText } 来设置状态,并设置 uploadError: null(以防用户收到错误消息)。在出错时,我们设置状态,以便告知用户。在完成时,我们运行 uploadFinished 函数,该函数之前已经详细描述过。

ImgUploader 的完整 render 函数将如下所示:

 render () { 
    let imgUploadProgressJSX; 
    let uploadProgress = this.state.uploadProgress; 
    if(uploadProgress) { 
      imgUploadProgressJSX = ( 
          <div> 
            {uploadProgress.uploadStatusText} 
             ({uploadProgress.progressInPercent}%) 
          </div> 
        ); 
    } else if(this.state.articlePicUrl) { 
      let articlePicStyles = { 
        maxWidth: 200,  
        maxHeight: 200,  
        margin: 'auto' 
      }; 
      imgUploadProgressJSX = <img src={this.state.articlePicUrl} 
       style={articlePicStyles} />; 
    } 

    let uploaderJSX = ( 
        <ReactS3Uploader 
        signingUrl='/s3/sign' 
        accept='image/*' 
          onProgress={(progressInPercent, uploadStatusText) => { 
            this.setState({  
              uploadProgress: { progressInPercent, 
               uploadStatusText },  
              uploadError: null 
            }); 
          }}  
          onError={(errorDetails) => { 
            this.setState({  
              uploadProgress: null, 
              uploadError: errorDetails 
            }); 
          }} 
          onFinish={(uploadDetails) => { 
            this.uploadFinished(uploadDetails); 
          }} /> 
      ); 

    return ( 
      <Paper zDepth={1} style={{padding: 32, margin: 'auto', 
       width: 300}}> 
        {imgUploadProgressJSX} 
        {uploaderJSX} 
      </Paper> 
    ); 
  }

如您所见,这是ImgUploader的全部渲染。我们使用内联样式的Paper组件(来自material-ui),这样整个界面对于文章的最终用户/编辑来说会看起来更好。

AddArticleView 改进

我们需要将ImgUploader组件添加到AddArticleView中;首先,我们需要将其导入到src/views/articles/AddArticleView.js文件中,如下所示:

import ImgUploader from '../../components/articles/ImgUploader';

接下来,在AddArticleView的构造函数中找到以下旧代码:

// this is old, don't write it: 
class AddArticleView extends React.Component { 
  constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 
    this._articleSubmit = this._articleSubmit.bind(this); 

    this.state = { 
      title: 'test', 
      contentJSON: {}, 
      htmlContent: '', 
      newArticleID: null 
    }; 
  }

更改为以下改进版本:

class AddArticleView extends React.Component { 
  constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 
    this._articleSubmit = this._articleSubmit.bind(this); 
    this.updateImgUrl = this.updateImgUrl.bind(this); 

    this.state = { 
      title: 'test', 
      contentJSON: {}, 
      htmlContent: '', 
      newArticleID: null, 
      articlePicUrl: '/static/placeholder.png' 
    }; 
  }

如您所见,我们已经将其绑定到updateImgUrl函数,并添加了一个新的状态变量articlePicUrl(默认情况下,我们将指向/static/placeholder.png,以防用户没有选择封面)。

让我们改进这个组件的功能:

// this is old codebase, just for your reference: 
  async _articleSubmit() { 
    let newArticle = { 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    } 

    let newArticleID = await falcorModel 
      .call( 
            'articles.add', 
            [newArticle] 
          ). 
      then((result) => { 
        return falcorModel.getValue( 
            ['articles', 'newArticleID'] 
          ).then((articleID) => { 
            return articleID; 
          }); 
      }); 

    newArticle['_id'] = newArticleID; 
    this.props.articleActions.pushNewArticle(newArticle); 
    this.setState({ newArticleID: newArticleID}); 
  }

将此代码更改为以下内容:

 async _articleSubmit() { 
    let newArticle = { 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON, 
      articlePicUrl: this.state.articlePicUrl 
    } 

    let newArticleID = await falcorModel 
      .call( 
            'articles.add', 
            [newArticle] 
          ). 
      then((result) => { 
        return falcorModel.getValue( 
            ['articles', 'newArticleID'] 
          ).then((articleID) => { 
            return articleID; 
          }); 
      }); 

    newArticle['_id'] = newArticleID; 
    this.props.articleActions.pushNewArticle(newArticle); 
    this.setState({ newArticleID: newArticleID }); 
  } 

  updateImgUrl(articlePicUrl) { 
    this.setState({  
      articlePicUrl: articlePicUrl 
    }); 
  }

如您所见,我们已经将articlePicUrl: this.state.articlePicUrl添加到newArticle对象中。我们还引入了一个名为updateImgUrl的新函数,它是一个简单的回调,用于设置一个新的状态,包含articlePicUrl变量(在this.state.articlePicUrl中,我们保留即将保存到数据库的当前文章的图片 URL)。

src/views/articles/AddArticleView.js中需要改进的只有我们当前的渲染。以下是旧版本:

// your current old codebase to improve: 
    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Add Article</h1> 
        <WYSIWYGeditor 
          name='addarticle' 
          title='Create an article' 
          onChangeTextJSON={this._onDraftJSChange} /> 
          <RaisedButton 
            onClick={this._articleSubmit} 
            secondary={true} 
            type='submit' 
            style={{margin: '10px auto', display: 'block', 
             width: 150}} 
            label={'Submit Article'} /> 
      </div> 
    ); 
  }

我们需要使用ImgUploader改进此代码:

   return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Add Article</h1> 
        <WYSIWYGeditor 
          name='addarticle' 
          title='Create an article' 
          onChangeTextJSON={this._onDraftJSChange} /> 

        <div style={{margin: '10px 10px 10px 10px'}}>  
          <ImgUploader  
              updateImgUrl={this.updateImgUrl}  
              articlePicUrl={this.state.articlePicUrl} /> 
        </div> 

        <RaisedButton 
          onClick={this._articleSubmit} 
          secondary={true} 
          type='submit' 
          style={{margin: '10px auto', display: 'block', 
           width: 150}} 
          label={'Submit Article'} /> 
      </div> 
    ); 
  }

您可以看到,我们使用属性发送当前的articlePicUrl(这将在以后很有用,并且也会给我们提供默认的placeholder.png位置)以及更新img URL 的回调,称为updateImgUrl

如果您访问http://localhost:3000/add-article,您将在 WYSIWYG 框和提交文章按钮之间看到一个新图像选择器(查看截图):

当然,如果您正确地遵循了所有指示,在点击选择文件后,您将能够上传新的图片到 S3 存储桶,缩略图中的图片将被替换,如下例所示:

如您所见,我们可以上传图片。下一步是取消模拟查看,这样我们就可以看到我们的文章封面有一只狗(并且狗的图片来自数据库中的文章集合)。

对 PublishingApp、ArticleCard 和 DashboardView 的一些剩余调整

我们可以添加一篇文章。我们需要取消模拟视图中的图片 URL,这样我们就可以看到数据库中的真实 URL(而不是在img src属性中模拟)。

让我们从src/layouts/PublishingApp.js开始,改进旧版的_fetch函数:

// old codebase to improve: 
  async _fetch() { 
    let articlesLength = await falcorModel. 
      getValue('articles.length'). 
      then((length) =>  length); 

    let articles = await falcorModel. 
      get(['articles', {from: 0, to: articlesLength-1}, 
      ['_id','articleTitle', 'articleContent', 
      'articleContentJSON']]).  
      then((articlesResponse) => {   
        return articlesResponse.json.articles; 
      }).catch(e => { 
        return 500; 
      }); 

    if(articles === 500) { 
      return; 
    } 

    this.props.articleActions.articlesList(articles); 
  }

将此代码替换为以下内容:

 async _fetch() { 
    let articlesLength = await falcorModel. 
      getValue('articles.length'). 
      then((length) => length); 

    let articles = await falcorModel. 
      get(['articles', {from: 0, to: articlesLength-1}, 
       ['_id','articleTitle', 'articleContent', 
       'articleContentJSON', 'articlePicUrl']]).  
      then((articlesResponse) => {   
        return articlesResponse.json.articles; 
      }).catch(e => { 
        console.debug(e); 
        return 500; 
      }); 

    if(articles === 500) { 
      return; 
    } 

    this.props.articleActions.articlesList(articles); 
  }

如您所见,我们已经开始通过falcorModel.get方法获取articlePicUrl

下一步,同样在PublishingApp文件中,是改进render函数,因此您需要改进以下代码:

// old code: 
    this.props.article.forEach((articleDetails, articleKey) => { 
      let currentArticleJSX = ( 
        <div key={articleKey}> 
          <ArticleCard  
            title={articleDetails.articleTitle} 
            content={articleDetails.articleContent} /> 
        </div> 
      );

向其中添加一个新的属性,该属性将传递图片 URL:

   this.props.article.forEach((articleDetails, articleKey) => { 
      let currentArticleJSX = ( 
        <div key={articleKey}> 
          <ArticleCard  
            title={articleDetails.articleTitle} 
            content={articleDetails.articleContent}  
            articlePicUrl={articleDetails.articlePicUrl} /> 
        </div> 
      );

如您所见,我们正在将获取的articlePicUrl传递给ArticleCard组件。

改进 ArticleCard 组件

在我们通过属性传递articlePicUrl变量之后,我们需要改进以下内容(src/components/ArticleCard.js):

// old code to improve: 
  render() { 
    let title = this.props.title || 'no title provided'; 
    let content = this.props.content || 'no content provided'; 

    let paperStyle = { 
      padding: 10,  
      width: '100%',  
      height: 300 
    }; 

    let leftDivStyle = { 
      width: '30%',  
      float: 'left' 
    } 

    let rightDivStyle = { 
      width: '60%',  
      float: 'left',  
      padding: '10px 10px 10px 10px' 
    } 

    return ( 
      <Paper style={paperStyle}> 
        <CardHeader 
          title={this.props.title} 
          subtitle='Subtitle' 
          avatar='/static/avatar.png' 
        /> 

        <div style={leftDivStyle}> 
          <Card > 
            <CardMedia 
              overlay={<CardTitle title={title} 
               subtitle='Overlay subtitle' />}> 
              <img src='/static/placeholder.png' height='190' /> 
            </CardMedia> 
          </Card> 
        </div> 
        <div style={rightDivStyle}> 
          <div dangerouslySetInnerHTML={{__html: content}} /> 
        </div> 
      </Paper>); 
  }

改成以下内容:

 render() { 
    let title = this.props.title || 'no title provided'; 
    let content = this.props.content || 'no content provided'; 
    let articlePicUrl = this.props.articlePicUrl || 
     '/static/placeholder.png'; 

    let paperStyle = { 
      padding: 10,  
      width: '100%',  
      height: 300 
    }; 

    let leftDivStyle = { 
      width: '30%',  
      float: 'left' 
    } 

    let rightDivStyle = { 
      width: '60%',  
      float: 'left',  
      padding: '10px 10px 10px 10px' 
    } 

    return ( 
      <Paper style={paperStyle}> 
        <CardHeader 
          title={this.props.title} 
          subtitle='Subtitle' 
          avatar='/static/avatar.png' 
        /> 

        <div style={leftDivStyle}> 
          <Card > 
            <CardMedia 
              overlay={<CardTitle title={title} 
               subtitle='Overlay subtitle' />}> 
              <img src={articlePicUrl} height='190' /> 
            </CardMedia> 
          </Card> 
        </div> 
        <div style={rightDivStyle}> 
          <div dangerouslySetInnerHTML={{__html: content}} /> 
        </div> 
      </Paper>); 
  }

render的开始处,我们使用let articlePicUrl = this.props.articlePicUrl || '/static/placeholder.png';,稍后,我们在我们的图像的 JSX 中使用它(img src={articlePicUrl} height='190')。

在这两个更改之后,您可以看到带有真实封面的文章,如下所示:

改进 DashboardView 组件

让我们通过封面改进仪表板,所以请在src/views/DashboardView.js中找到以下代码:

// old code: 
  render () { 
    let articlesJSX = []; 
    this.props.article.forEach((articleDetails, articleKey) => { 
      let currentArticleJSX = ( 
        <Link  
          to={&grave;/edit-article/${articleDetails['_id']}&grave;}  
          key={articleKey}> 
          <ListItem 

            leftAvatar={<img src='/static/placeholder.png'   
                                    width='50'  
                                    height='50' />} 
            primaryText={articleDetails.articleTitle} 
            secondaryText={articleDetails.articleContent} 
          /> 
        </Link> 
      ); 

      articlesJSX.push(currentArticleJSX); 
    }); 
    // below is rest of the render's function

用以下内容替换它:

 render () { 
    let articlesJSX = []; 
    this.props.article.forEach((articleDetails, articleKey) => { 
      let articlePicUrl = articleDetails.articlePicUrl || 
       '/static/placeholder.png'; 
      let currentArticleJSX = ( 
        <Link  
                to={&grave;/edit-article/${articleDetails['_id']}&grave;}  
key={articleKey}> 
          <ListItem 

            leftAvatar={<img src={articlePicUrl} width='50' 
             height='50' />} 
            primaryText={articleDetails.articleTitle} 
            secondaryText={articleDetails.articleContent} 
          /> 
        </Link> 
      ); 

      articlesJSX.push(currentArticleJSX); 
    }); 
    // below is rest of the render's function

如您所见,我们已经将模拟占位符替换为真实的封面照片,因此在我们的文章仪表板(在登录后可用)中,我们将找到真实的缩略图。

编辑文章的封面照片

关于文章的照片,我们需要在src/views/articles/EditArticleView.js文件中做一些改进,例如导入ImgUploader

import ImgUploader from '../../components/articles/ImgUploader';

在您导入ImgUploader之后,改进EditArticleView的构造函数。找到以下代码:

// old code to improve: 
class EditArticleView extends React.Component { 
  constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 
    this._articleEditSubmit = this._articleEditSubmit.bind(this); 
    this._fetchArticleData = this._fetchArticleData.bind(this); 
    this._handleDeleteTap = this._handleDeleteTap.bind(this); 
    this._handleDeletion = this._handleDeletion.bind(this); 
    this._handleClosePopover = 
     this._handleClosePopover.bind(this); 

    this.state = { 
      articleFetchError: null, 
      articleEditSuccess: null, 
      editedArticleID: null, 
      articleDetails: null, 
      title: 'test', 
      contentJSON: {}, 
      htmlContent: '', 
      openDelete: false, 
      deleteAnchorEl: null 
    }; 
  }

用以下改进的构造函数替换它:

class EditArticleView extends React.Component { 
  constructor(props) { 
    super(props); 
    this._onDraftJSChange = this._onDraftJSChange.bind(this); 
    this._articleEditSubmit = this._articleEditSubmit.bind(this); 
    this._fetchArticleData = this._fetchArticleData.bind(this); 
    this._handleDeleteTap = this._handleDeleteTap.bind(this); 
    this._handleDeletion = this._handleDeletion.bind(this); 
    this._handleClosePopover = 
     this._handleClosePopover.bind(this); 
    this.updateImgUrl = this.updateImgUrl.bind(this); 

    this.state = { 
      articleFetchError: null, 
      articleEditSuccess: null, 
      editedArticleID: null, 
      articleDetails: null, 
      title: 'test', 
      contentJSON: {}, 
      htmlContent: '', 
      openDelete: false, 
      deleteAnchorEl: null, 
      articlePicUrl: '/static/placeholder.png' 
    }; 
  }

如您所见,我们已经将其绑定到新的updateImgUrl函数(它将是ImgUploader的回调),并为articlePicUrl创建了一个新的默认状态。

下一步是改进当前_fetchArticleData函数:

// this is old already in your codebase: 
  _fetchArticleData() { 
    let articleID = this.props.params.articleID; 
    if(typeof window !== 'undefined' && articleID) { 
        let articleDetails = this.props.article.get(articleID); 
        if(articleDetails) { 
          this.setState({  
            editedArticleID: articleID,  
            articleDetails: articleDetails 
          }); 
        } else { 
          this.setState({ 
            articleFetchError: true 
          }) 
        } 
    } 
  }

用以下改进的代码替换它:

 _fetchArticleData() { 
    let articleID = this.props.params.articleID; 
    if(typeof window !== 'undefined' && articleID) { 
        let articleDetails = this.props.article.get(articleID); 
        if(articleDetails) { 
          this.setState({  
            editedArticleID: articleID,  
            articleDetails: articleDetails, 
            articlePicUrl: articleDetails.articlePicUrl, 
            contentJSON: articleDetails.articleContentJSON, 
            htmlContent: articleDetails.articleContent 
          }); 
        } else { 
          this.setState({ 
            articleFetchError: true 
          }) 
        } 
    } 
  }

在这里,我们在初始获取中添加了一些新的this.setState变量,例如articlePicUrlcontentJSONhtmlContent。文章获取在这里是因为我们需要在ImgUploader中设置一个可能被更改的当前图像的封面。contentJSONhtmlContent在这里是为了以防用户在 WYSIWYG 编辑器中没有进行任何编辑,我们需要从数据库中获取默认值(否则,编辑按钮会将空值保存到数据库中,并破坏整个编辑体验)。

让我们改进_articleEditSubmit函数。这是旧代码:

// old code to improve: 
  async _articleEditSubmit() { 
    let currentArticleID = this.state.editedArticleID; 
    let editedArticle = { 
      _id: currentArticleID, 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON 
    // striped code for our convience

改成以下改进版本:

  async _articleEditSubmit() { 
    let currentArticleID = this.state.editedArticleID; 
    let editedArticle = { 
      _id: currentArticleID, 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON, 
      articlePicUrl: this.state.articlePicUrl 
    } 
    // striped code for our convenience

下一步是为EditArticleView组件添加一个新函数:

 updateImgUrl(articlePicUrl) { 
    this.setState({  
      articlePicUrl: articlePicUrl 
    }); 
  }

为了完成文章编辑封面,最后一步是改进旧的渲染:

// old code to improve: 
    let initialWYSIWYGValue = 
     this.state.articleDetails.articleContentJSON; 

    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Edit an existing article</h1> 
        <WYSIWYGeditor 
          initialValue={initialWYSIWYGValue} 
          name='editarticle' 
          title='Edit an article' 
          onChangeTextJSON={this._onDraftJSChange} /> 

        <RaisedButton 
          onClick={this._articleEditSubmit} 
          secondary={true} 
          type='submit' 
          style={{margin: '10px auto', 
           display: 'block', width: 150}} 
          label={'Submit Edition'} /> 
        <hr />

用以下内容替换它:

   let initialWYSIWYGValue = 
    this.state.articleDetails.articleContentJSON; 

    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Edit an existing article</h1> 
        <WYSIWYGeditor 
          initialValue={initialWYSIWYGValue} 
          name='editarticle' 
          title='Edit an article' 
          onChangeTextJSON={this._onDraftJSChange} /> 

        <div style={{margin: '10px 10px 10px 10px'}}>  
          <ImgUploader updateImgUrl={this.updateImgUrl} 
           articlePicUrl={this.state.articlePicUrl} /> 
        </div> 

        <RaisedButton 
          onClick={this._articleEditSubmit} 
          secondary={true} 
          type='submit' 
          style={{margin: '10px auto', 
           display: 'block', width: 150}} 
          label={'Submit Edition'} /> 
        <hr/>

如您所见,我们已经添加了ImgUploader并将其样式设置为与AddArticleView中完全相同。ImgUploader的其余部分为我们完成工作,以便我们的用户可以编辑文章照片。

在这个屏幕截图中,您可以看到经过最近的所有改进后编辑视图应该是什么样子。

添加添加/编辑文章标题和副标题的功能

通常,我们应在server/configMongoose.js文件中改进文章模型。首先找到以下代码:

// old codebase: 
var articleSchema = new Schema({ 
    articleTitle: String, 
    articleContent: String, 
    articleContentJSON: Object, 
    articlePicUrl: { type: String, default: 
     '/static/placeholder.png' } 
  },  
  {  
    minimize: false  
  } 
);

用以下改进的代码替换它,如下所示:

var defaultDraftJSobject = { 
    'blocks' : [], 
    'entityMap' : {} 
} 

var articleSchema = new Schema({ 
    articleTitle: { type: String, required: true, default: 
     'default article title' }, 
    articleSubTitle: { type: String, required: true, default: 
     'default subtitle' }, 
    articleContent: { type: String, required: true, default: 
     'default content' }, 
    articleContentJSON: { type: Object, required: true, default: 
     defaultDraftJSobject }, 
    articlePicUrl: { type: String, required: true, default: 
     '/static/placeholder.png' } 
  },  
  {  
    minimize: false  
  } 
);

如您所见,我们在我们的模型中添加了许多必需的属性;这将影响保存不完整对象的能力,所以一般来说,我们的模型将在我们的发布应用的生命周期中保持一致性。

我们还在我们的模型中添加了一个新的属性,名为 articleSubTitle,我们将在本章的后面使用它。

AddArticleView 的改进

通常,我们将添加两个 DefaultInput 组件(标题和副标题),整个表单将使用 formsy-react,所以请在 src/views/articles/AddArticleView.js 中添加新的导入:

import DefaultInput from '../../components/DefaultInput'; 
import Formsy from 'formsy-react';

下一步是改进 async _articleSubmit,所以更改旧代码:

// old code to improve: 
  async _articleSubmit() { 
    let newArticle = { 
      articleTitle: articleModel.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON, 
      articlePicUrl: this.state.articlePicUrl 
    } 

    let newArticleID = await falcorModel 
      .call( 
            'articles.add', 
            [newArticle] 
          ). 
          // rest code below is striped

用以下内容替换它:

  async _articleSubmit(articleModel) { 
    let newArticle = { 
      articleTitle: articleModel.title, 
      articleSubTitle: articleModel.subTitle, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON, 
      articlePicUrl: this.state.articlePicUrl 
    } 

    let newArticleID = await falcorModel 
      .call( 
            'articles.add', 
            [newArticle] 
          ).

如您所见,我们在 _articleSubmit 参数中添加了 articleModel;这将从 formsy-react 中来,就像我们在 LoginViewRegisterView 中实现的那样。我们还向 newArticle 对象中添加了 articleSubTitle 属性。

旧的 render 函数返回值如下:

// old code below: 
    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Add Article</h1> 
        <WYSIWYGeditor 
          name='addarticle' 
          title='Create an article' 
          onChangeTextJSON={this._onDraftJSChange} /> 

        <div style={{margin: '10px 10px 10px 10px'}}>  
          <ImgUploader updateImgUrl={this.updateImgUrl} 
           articlePicUrl={this.state.articlePicUrl} /> 
        </div> 

        <RaisedButton 
          onClick={this._articleSubmit} 
          secondary={true} 
          type='submit' 
          style={{margin: '10px auto', 
           display: 'block', width: 150}} 
          label={'Submit Article'} /> 
      </div> 
    );

更改为以下内容:

   return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Add Article</h1> 

        <Formsy.Form onSubmit={this._articleSubmit}> 
          <DefaultInput  
            onChange={(event) => {}}  
            name='title'  
            title='Article Title (required)' required /> 

          <DefaultInput  
            onChange={(event) => {}}  
            name='subTitle'  
            title='Article Subtitle' /> 

          <WYSIWYGeditor 
            name='addarticle' 
            title='Create an article' 
            onChangeTextJSON={this._onDraftJSChange} /> 

          <div style={{margin: '10px 10px 10px 10px'}}>  
            <ImgUploader updateImgUrl={this.updateImgUrl} 
             articlePicUrl={this.state.articlePicUrl} /> 
          </div> 

          <RaisedButton 
            secondary={true} 
            type='submit' 
            style={{margin: '10px auto', 
             display: 'block', width: 150}} 
            label={'Submit Article'} /> 
        </Formsy.Form> 
      </div> 
    );

Formsy.Form the same way as in the LoginView, so I won't describe it in detail. The most important thing to notice is that with onSubmit, we call the this._articleSubmit function. We have also added two DefaultInput components (title and subtitle): the data from those two inputs will be used in async _articleSubmit(articleModel) (as you already know based on previous implementations in this book).

根据 Mongoose 配置和 AddArticleView 组件的变化,你现在可以给新文章添加标题和副标题,如下截图所示:

我们仍然缺少编辑标题和副标题的功能,所以现在让我们来实现它。

编辑文章标题和副标题的能力

前往 src/views/articles/EditArticleView.js 文件并添加新的导入(类似于 add 视图):

import DefaultInput from '../../components/DefaultInput'; 
import Formsy from 'formsy-react';

改进旧的 _articleEditSubmit 函数,从当前版本:

// old code: 
  async _articleEditSubmit() { 
    let currentArticleID = this.state.editedArticleID; 
    let editedArticle = { 
      _id: currentArticleID, 
      articleTitle: this.state.title, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON, 
      articlePicUrl: this.state.articlePicUrl 
    } 
    // rest of the function has been striped below

更改为以下内容:

 async _articleEditSubmit(articleModel) { 
    let currentArticleID = this.state.editedArticleID; 
    let editedArticle = { 
      _id: currentArticleID, 
      articleTitle: articleModel.title, 
      articleSubTitle: articleModel.subTitle, 
      articleContent: this.state.htmlContent, 
      articleContentJSON: this.state.contentJSON, 
      articlePicUrl: this.state.articlePicUrl 
    } 
    // rest of the function has been striped below

如您所见,我们和 AddArticleView 中做的是一样的事情,所以你应该熟悉它。最后要做的就是更新 render,以便我们能够输入将作为回调发送给 _articleEditSubmit 的标题和副标题,数据在 articleModel 中。render 函数中的旧返回值如下:

// old code: 
    return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Edit an existing article</h1> 
        <WYSIWYGeditor 
          initialValue={initialWYSIWYGValue} 
          name='editarticle' 
          title='Edit an article' 
          onChangeTextJSON={this._onDraftJSChange} /> 
        <div style={{margin: '10px 10px 10px 10px'}}>  
          <ImgUploader updateImgUrl={this.updateImgUrl} 
           articlePicUrl={this.state.articlePicUrl} /> 
        </div> 
        <RaisedButton 
          onClick={this._articleEditSubmit} 
          secondary={true} 
          type='submit' 
          style={{margin: '10px auto', 
           display: 'block', width: 150}} 
          label={'Submit Edition'} /> 
        <hr /> 
        {/* striped below */}

render 函数中的新改进返回值如下:

   return ( 
      <div style={{height: '100%', width: '75%', margin: 'auto'}}> 
        <h1>Edit an existing article</h1> 
        <Formsy.Form onSubmit={this._articleEditSubmit}> 
          <DefaultInput  
            onChange={(event) => {}} 
            name='title'  
            value={this.state.articleDetails.articleTitle} 
            title='Article Title (required)' required /> 

          <DefaultInput  
            onChange={(event) => {}} 
            name='subTitle'  
            value={this.state.articleDetails.articleSubTitle} 
            title='Article Subtitle' /> 

          <WYSIWYGeditor 
            initialValue={initialWYSIWYGValue} 
            name='editarticle' 
            title='Edit an article' 
            onChangeTextJSON={this._onDraftJSChange} /> 

          <div style={{margin: '10px 10px 10px 10px'}}>  
            <ImgUploader updateImgUrl={this.updateImgUrl} 
             articlePicUrl={this.state.articlePicUrl} /> 
          </div> 

          <RaisedButton 
            onClick={this._articleEditSubmit} 
            secondary={true} 
            type='submit' 
            style={{margin: '10px auto', 
             display: 'block', width: 150}} 
            label={'Submit Edition'} /> 
        </Formsy.Form> 
        {/* striped below */}

我们在这里做的是和之前在 AddArticleView 中做的一样。我们引入了 Formsy.Form,当用户点击提交按钮(提交编辑)时,它会回调文章的标题和副标题。

这里是一个示例,展示它应该如何看起来:

ArticleCard 和 PublishingApp 的改进

改进 ArticleCard 中的 render 函数,使其也能显示副标题(目前是模拟的)。src/components/ArticleCard.js 文件中的旧内容如下:

// old code: 
  render() { 
    let title = this.props.title || 'no title provided'; 
    let content = this.props.content || 'no content provided'; 
    let articlePicUrl = this.props.articlePicUrl || 
     '/static/placeholder.png'; 

    let paperStyle = { 
      padding: 10,  
      width: '100%',  
      height: 300 
    }; 

    let leftDivStyle = { 
      width: '30%',  
      float: 'left' 
    } 

    let rightDivStyle = { 
      width: '60%',  
      float: 'left',  
      padding: '10px 10px 10px 10px' 
    } 

    return ( 
      <Paper style={paperStyle}> 
        <CardHeader 
          title={this.props.title} 
          subtitle='Subtitle' 
          avatar='/static/avatar.png' 
        /> 

        <div style={leftDivStyle}> 
          <Card > 
            <CardMedia 
              overlay={<CardTitle title={title} 
               subtitle='Overlay subtitle' />}> 
              <img src={articlePicUrl} height='190' /> 
            </CardMedia> 
          </Card> 
        </div> 
        <div style={rightDivStyle}> 
          <div dangerouslySetInnerHTML={{__html: content}} /> 
        </div> 
      </Paper>); 
  }

让我们将其更改为以下内容:

 render() { 
    let title = this.props.title || 'no title provided'; 
    let subTitle = this.props.subTitle || ''; 
    let content = this.props.content || 'no content provided'; 
    let articlePicUrl = this.props.articlePicUrl || 
     '/static/placeholder.png'; 

    let paperStyle = { 
      padding: 10,  
      width: '100%',  
      height: 300 
    }; 

    let leftDivStyle = { 
      width: '30%',  
      float: 'left' 
    } 

    let rightDivStyle = { 
      width: '60%',  
      float: 'left',  
      padding: '10px 10px 10px 10px' 
    } 

    return ( 
      <Paper style={paperStyle}> 
        <CardHeader 
          title={this.props.title} 
          subtitle={subTitle} 
          avatar='/static/avatar.png' 
        /> 

        <div style={leftDivStyle}> 
          <Card > 
            <CardMedia 
              overlay={<CardTitle title={title} 
               subtitle={subTitle} />}> 
              <img src={articlePicUrl} height='190' /> 
            </CardMedia> 
          </Card> 
        </div> 
        <div style={rightDivStyle}> 
          <div dangerouslySetInnerHTML={{__html: content}} /> 
        </div> 
      </Paper>); 
  }

如您所见,我们定义了一个新的 subTitle 变量,并在 CardHeaderCardMedia 组件中使用它,因此现在它也会显示副标题。

另一件事是要让 PublishingApp 也获取本章中引入的副标题,因此我们需要改进以下旧代码:

// old code: 
  async _fetch() { 
    let articlesLength = await falcorModel. 
      getValue('articles.length'). 
      then((length) => length); 

    let articles = await falcorModel. 
      get(['articles', {from: 0, to: articlesLength-1}, 
       ['_id','articleTitle', 'articleContent', 
       'articleContentJSON', 'articlePicUrl']]).  
      then((articlesResponse) => {   
        return articlesResponse.json.articles; 
      }).catch(e => { 
        console.debug(e); 
        return 500; 
      }); 
    // no changes below, striped

用以下内容替换它:

 async _fetch() { 
    let articlesLength = await falcorModel. 
      getValue('articles.length'). 
      then((length) => length); 

    let articles = await falcorModel. 
      get(['articles', {from: 0, to: articlesLength-1}, ['_id', 
       'articleTitle', 'articleSubTitle','articleContent', 
       'articleContentJSON', 'articlePicUrl']]).  
      then((articlesResponse) => {   
        return articlesResponse.json.articles; 
      }).catch(e => { 
        console.debug(e); 
        return 500; 
      });

如您所见,我们已经以 articleSubTitle 属性开始了 falcorModel.get

当然,我们需要将这个subTitle属性传递给PublishingApp类中的render函数的ArticleCard组件:

// old code: 
    this.props.article.forEach((articleDetails, articleKey) => { 
      let currentArticleJSX = ( 
        <div key={articleKey}> 
          <ArticleCard  
            title={articleDetails.articleTitle} 
            content={articleDetails.articleContent} 
      articlePicUrl={articleDetails.articlePicUrl} /> 
        </div> 
      );

最后,我们将得到以下结果:

   this.props.article.forEach((articleDetails, articleKey) => { 
      let currentArticleJSX = ( 
        <div key={articleKey}> 
          <ArticleCard  
            title={articleDetails.articleTitle} 
            content={articleDetails.articleContent}  
            articlePicUrl={articleDetails.articlePicUrl} 
      subTitle={articleDetails.articleSubTitle} /> 
        </div> 
      );

在主页上所有这些更改之后,你可以找到一个带有标题、副标题、封面照片和内容(由我们的 WYSIWYG 编辑器创建)的编辑过的文章:

仪表板改进(现在我们可以去除剩余的 HTML)

本章的最后一步是改进仪表板。它将根据 props 中的 HTML 进行字符串连接,以便在用户浏览我们的应用时有一个更好的外观和感觉。找到以下代码:

// old code: 
    this.props.article.forEach((articleDetails, articleKey) => { 
      let articlePicUrl = articleDetails.articlePicUrl || 
       '/static/placeholder.png'; 
      let currentArticleJSX = ( 
        <Link to={&grave;/edit-article/${articleDetails['_id']}&grave;} 
         key={articleKey}> 
          <ListItem 

            leftAvatar={<img src={articlePicUrl} width='50' 
             height='50' />} 
            primaryText={articleDetails.articleTitle} 
            secondaryText={articleDetails.articleContent} 
          /> 
        </Link> 
      );

用以下内容替换:

   this.props.article.forEach((articleDetails, articleKey) => { 
      let articlePicUrl = articleDetails.articlePicUrl || 
       '/static/placeholder.png'; 
      let articleContentPlanText = 
       articleDetails.articleContent.replace(/</?[^>]+(>|$)/g, 
       ''); 
      let currentArticleJSX = ( 
        <Link to={&grave;/edit-article/${articleDetails['_id']}&grave;} 
         key={articleKey}> 
          <ListItem 

            leftAvatar={<img src={articlePicUrl} width='50' 
             height='50' />} 
            primaryText={articleDetails.articleTitle} 
            secondaryText={articleContentPlanText} 
          /> 
        </Link> 
      );

如你所见,我们只是从 HTML 中去除 HTML 标签,这样我们就会得到更好的secondaryText,没有 HTML 标记,就像这个例子一样:

摘要

我们已经实现了书中涵盖的所有功能。下一步是开始部署这个应用。

如果你想提高你的编码技能,自己完全实现一些功能是个不错的主意。以下是我们发布应用中仍缺失的一些功能想法。

我们可以有一个指向特定文章的单独链接,这样你就可以与朋友分享。如果你想在数据库中创建一个与特定文章相关的人可读的唯一 slug,这可能很有用。所以,而不是链接到像reactjs.space/570b6e26ae357d391c6ebc1dreactjs.space是我们将在生产服务器上使用的域名)这样的东西,用户可以分享一个像reactjs.space/an-article-about-a-dog这样的链接。

可能有一种方法可以将文章与发布它的编辑器关联起来。目前它是模拟的。你可以取消模拟。

用户在登录时无法更改他们的用户详情--这可能是一个练习更多全栈开发的不错方式。

用户无法设置他们的头像图片--你可以以我们实现图片封面类似的方式添加这个功能。

创建一个更健壮的带有插件的 Draft.JS WYSIWYG 编辑器。对于提及、贴纸、表情符号、标签、撤销/重做等功能,健壮的插件易于实现。访问www.draft-js-plugins.com/获取更多关于它们的信息。实现一个或两个你最喜欢的。

在下一章中,我们将开始使用www.mLab.com在线部署我们的 MongoDB 实例,它是一个数据库即服务提供商,帮助我们轻松构建可扩展的 MongoDB 节点。

让我们从部署的乐趣开始吧!

第七章:mLab 上的 MongoDB 部署

我们已经到了需要开始规划我们的应用程序部署的阶段。我们选择了 MongoDB 作为我们的数据库。使用它的不同方法用于扩展--您可以自己使用自己的服务器做所有事情(耗时且要求高)或者您可以使用为您执行复制/扩展的服务,例如数据库即服务提供商。

在本章中,我们将涵盖以下主题:

  • 创建 mLab 账户和创建新的 MongoDB 部署

  • MongoDB 中的复制集是如何工作的,以及您如何在 mLab 中使用它

  • 在实时演示中测试复制集(在 mLab 中切换)

  • 设置数据库用户和密码

  • 了解在 AWS EC2 上部署需要准备的事项

mLab 概述

在我们的案例中,我们将使用 mLab 来减少在 MongoDB 上配置底层配置的时间,并更多地构建一个健壮可扩展的应用程序。

如果我们访问www.mLab.com,有一个免费的数据库计划(我们将在本章中使用)和一个付费的数据库计划:

通常,mLab 提供以下一些有趣的功能:

  • 云自动化工具:这些提供 AWS、Azure 或 Google 上的按需配置(准备),复制集(将在本章后面详细描述);以及分片集群。这些还提供通过自动故障转移的无缝、零停机时间扩展和高可用性。

  • 备份和恢复工具:这些提供自动备份,在紧急情况下可以在后续项目阶段提供帮助。

  • 监控和警报工具:例如,有一个慢查询工具,可以帮助您找到慢查询,这些查询可以通过添加索引进行优化。

  • 在线数据浏览工具:当您登录到 mLab 的管理面板时,您可以通过浏览器浏览 MongoDB 的集合。

复制集连接和高可用性

在 MongoDB 中,有一个使用自动故障转移确保高可用性的功能。简而言之,故障转移是一个确保如果主服务器(拥有您数据库最重要的副本)失败,那么如果原始主服务器不可用,则次要成员的数据库将成为主数据库的功能。

次要成员的数据库是一个服务器,它保存了所谓的只读备份

主数据库和次要数据库经常相互复制,以确保始终同步。次要服务器主要用于读取操作。

从头开始(没有 mLab)实现整个复制集功能相当耗时,但 mLab 提供此功能是为了抽象这一部分,以便我们的整个过程将更加自动化。

MongoDB 故障转移

mLab 还提供了一个测试我们应用程序中故障转移场景的出色工具,可在flip-flop.mlab.com找到。

在这里,我们可以测试 MongoDB 副本集的自动故障转移功能。正如我们可以在前面的截图中所见,有三个节点:副本的翻转折叠,以及一个仲裁者。在 flip-flop 的演示中,您可以连接到仲裁者服务器,主服务器将下线,集群将故障转移到其他节点。您可以自己尝试并享受乐趣!

您可以在docs.mlab.com/connecting/#replica-set-connections找到更多有关如何使用 flip-flop 演示的文档。

mLab 的免费与付费计划

在这本书中,我们将指导您使用免费计划来操作 mLab。在 mLab 中,副本集在付费计划中可用(起价为每月 15 美元),当然,您也可以免费使用 flip-flop 的演示来体验 MongoDB 的非常重要的功能。

新的 mLab 账户和节点

  1. 前往mlab.com/signup/,如下面的截图所示:

图片

  1. 通过点击收件箱中的确认链接来验证您的电子邮件。

  2. 点击创建新按钮,如下面的截图所示:

图片

  1. 您现在处于创建新部署页面。选择单节点 | 沙盒(免费),如下面的截图所示:

图片

  1. 当您仍然在mlab.com/create(创建新部署)时,将数据库名称设置为publishingapp,然后点击创建新的 MongoDB 部署按钮,如下面的截图所示:

图片

  1. 在遵循前面的步骤之后,您应该能够在仪表板上找到 MongoDB 部署(mlab.com/home),如下面的截图所示:

图片

创建数据库的用户/密码和其他配置

目前,数据库已准备好用于我们的发布应用程序,但它仍然是空的。

我们需要采取以下步骤才能使用它:

  1. 创建用户/密码组合。我们需要点击刚刚创建的数据库,找到名为“用户”的标签页。点击它后,点击添加新数据库用户按钮,然后在表单中填写详细信息,如下面的截图所示:

图片

  1. 让我们假设这本书的详细信息如下:

数据库用户名:usermlab

数据库密码:pwdmlab

在我们需要使用用户名和密码的地方,我将使用这些详细信息。

  1. 之后,我们需要创建与本地 MongoDB 中相同的集合:

集合 | 添加集合 | articles

集合 | 添加集合 | pubUsers

  1. 在执行所有前面的步骤之后,您应该会看到如下截图所示的内容:

图片

  1. 在这个阶段,最后一步是从以下截图中写下 Mongo 的详细信息:

图片

配置总结

我们需要保留并分享所有来自 mLab 的信息,以及 AWS S3 的详细信息。这些细节将在下一章部署我们的应用在 Amazon AWS EC2 时有用。

在本书的这一部分,有一些细节我们需要单独保留:

AWS_ACCESS_KEY_ID=<<access-key-obtained-in-previous-chapter>>
AWS_SECRET_ACCESS_KEY=<<secret-key-obtained-in-previous-chapter>>
AWS_BUCKET_NAME=publishing-app
AWS_REGION_NAME=eu-central-1
MONGO_USER=usermlab
MONGO_PASS=pwdmlab
MONGO_PORT=<<port-from-your-mlab-node>>
MONGO_ENV=publishingapp
MONGO_HOSTNAME=<<hostname-from-your-mlab-node>>

确保你已经将端口和主机名替换为正确的值(如前一个屏幕截图中所提供的 mLab 提供的)。

所有 Mongo env变量都可以从 mLab 获得,在那里你可以找到一个类似以下链接(这是一个在编写本章时创建的账户的示例):

mongo ds025762.mlab.com:25762/publishingapp -u <dbuser> -p <dbpassword>

摘要

在下一章,我们将开始在 AWS EC2 平台上我们的生产服务器上使用这些环境变量。将这些细节记录在一个易于访问、安全的地方,因为我们很快就会使用它们。

最后一步是检查应用是否运行正确,并使用远程 mLab MongoDB(而不是使用mongd命令运行的本地 MongoDB)。你可以通过运行npm start来实现,然后你应该能看到发布应用的空主页。因为我们已经从本地数据库迁移到了远程数据库,而远程数据库是空的,所以你需要注册一个新用户,并尝试在 mLab 下发布一篇文章来存储数据。

第八章:Docker 和 EC2 容器服务

我们已经完成了与数据库作为后端的所有相关事宜,使用的是 mLab。发布应用程序应该在 mLab MongoDB 实例上 100%远程工作,所以你不再需要运行mongod命令了。

是时候准备我们的 Docker 容器,并使用 ECS(EC2 容器服务)和负载均衡器将其完全部署到 EC2 上了。

什么是 Docker?它是一个非常实用的开源软件,可以帮助你打包、运输和运行任何应用程序作为一个轻量级的容器(例如,与虚拟机相比)。

容器的目标与虚拟机类似--最大的区别是 Docker 是为了软件开发而创建的,而不是 VM。你还需要意识到,一个完全虚拟化的系统为其分配了自身的资源,这导致了最小资源共享,这与 Docker 容器不同。当然,在 VM 中,你得到更多的隔离,但代价是 VM 要重得多(需要更多的磁盘空间、RAM 和其他资源)。Docker 的容器轻量级,并且与 VM 相比,能够在不同的容器之间共享更多东西。

好的一点是,Docker 的容器是硬件和平台无关的,所以关于你正在工作的内容是否能在任何地方运行的担忧都消失了。

通常,Docker 的好处是它提高了开发者的生产力,帮助他们更快地发布软件,帮助他们将软件从本地开发机器移动到 AWS 上的生产部署,等等。Docker 还允许对软件进行版本控制(类似于 Git),这在需要快速在生产服务器上回滚时非常有帮助。

在本章中,你将学习以下内容:

  • 在非 Linux 机器上使用 Docker Toolbox 安装 Docker 应用程序

  • 测试你的 Docker 设置是否正确

  • 准备发布应用程序以使用 mLab Mongo 作为数据库

  • 为发布应用程序创建一个新的 Docker 容器

  • 创建你的第一个 Dockerfile,该文件将在 Linux CentOS 上部署发布应用程序

  • EC2 容器服务

  • AWS 负载均衡器

  • 使用 Amazon Route 53 进行 DNS 服务

  • AWS 身份和访问管理(IAM)

使用 Docker Toolbox 安装 Docker

安装 Docker 相当简单。访问官方安装页面docs.docker.com/engine/installation/,因为它会根据你的操作系统提供最佳指导。有适用于 iOS 和 Windows 的易于遵循的安装程序,以及针对不同 Linux 发行版的许多说明。

如果你使用的是非 Linux 机器,那么你还需要安装 Docker Toolbox for Windows 或 OS X。这很简单,因为它的安装程序可以在www.docker.com/products/docker-toolbox找到,如下面的截图所示:

图片

如果你使用的是 Linux,你需要执行一些额外的步骤,因为你需要在 BIOS 中开启虚拟化:

在你的本地机器上安装 Docker(包括在 OS X 和 Windows 上的 Toolbox)后,请在终端中运行以下命令:

$ docker info

在运行此命令后,你将能够看到以下类似截图的内容:

如果你看到类似的内容,那么你的安装就成功了。让我们继续学习 Docker。

Docker Hub - 一个 hello world 示例

在我们开始创建发布应用的 Docker 容器之前,让我们先玩一个官方的 Docker hello world 示例,这将让你了解 Docker Hub 的工作方式。

Docker Hub 对于 Docker 容器来说,就像 GitHub 对于 Git 仓库一样。你可以在 Docker 中拥有公共和私有容器。Docker Hub 的主页看起来是这样的:

只是为了让你有个感觉,如果你访问 hub.docker.com/explore/,你可以看到不同已经准备好使用的容器,比如这个,例如:

仅为了我们的演示练习,我们将使用一个名为 hello world 的容器,该容器在 hub.docker.com/r/library/hello-world/ 公开可用。

为了运行这个 hello-world 示例,请在你的终端中运行以下命令:

$ docker run hello-world

在运行此命令后,你将看到以下类似的内容:

让我们理解刚刚发生了什么:我们使用 docker run 命令来启动基于镜像的容器(在我们的例子中,我们使用了 hello world 容器镜像)。在这种情况下,我们执行以下操作:

  1. 运行命令,告诉 Docker 使用无额外命令启动名为 hello-world 的容器。

  2. 在按 Enter 键后,Docker 将下载 Docker Hub。

  3. 然后,它将在虚拟机中使用 Docker Toolbox 在非 Linux 系统上启动容器。

如前所述,hello-world 镜像来自名为 Docker Hub 的公共注册表(你可以在 hub.docker.com/r/library/hello-world/ 访问)。

Dockerfile 示例

每个镜像都由一个 Dockerfile 组成。hello-world 示例的 Dockerfile 看起来如下所示:

// source: https://github.com/docker-library/hello-world/blob/master/Dockerfile 
FROM scratch 
COPY hello / 
CMD ["/hello"]

Dockerfile 是一组指令,告诉 Docker 如何构建容器镜像。我们将在稍后创建自己的。Dockerfile 的一个类比可以在任何 Linux/Unix 机器上使用的 Bash 语言。当然,它不同,但编写指令以创建工作的总体思想是相似的。

为了创建它,我们需要对我们的代码库进行修改

目前,我们确信我们的 Docker 应用程序的设置是正确的。

首先,我们需要对我们的当前代码库进行一些修改,因为有一些小的调整才能使其正常工作。

确保以下文件具有适当的内容。

server/.env文件的内容必须如下:

AWS_ACCESS_KEY_ID=<<___AWS_ACCESS_KEY_ID__>> 
AWS_SECRET_ACCESS_KEY=<<___AWS_SECRET_ACCESS_KEY__>> 
AWS_BUCKET_NAME=publishing-app 
AWS_REGION_NAME=eu-central-1 
MONGO_USER=<<___your_mlab_mongo_user__>> 
MONGO_PASS=<<___your_mlab_mongo_pass__>> 
MONGO_PORT=<<___your_mlab_mongo_port__>> 
MONGO_ENV=publishingapp 
MONGO_HOSTNAME=<<___your_mlab_mongo_hostname__>>

现在,我们将从文件中加载环境变量,但稍后我们将从 AWS 面板加载它们。将所有这些机密数据保留在服务器上并不真正符合生产安全。我们现在是出于简洁的考虑使用它;稍后,我们将删除它,转而采用更安全的方法。

关于 Mongo 环境变量,我们在上一章关于设置 mLab(如果遗漏了此点所需的任何细节,请回到该章节)中学习了它们。

server/index.js文件的内容必须如下:

var env = require('node-env-file'); 
// Load any undefined ENV variables form a specified file.  
env(__dirname + '/.env'); 

require("babel-core/register"); 
require("babel-polyfill"); 
require('./server');

确保你在server/index.js的开始处加载.env文件。这将是为了从环境变量(server/.env)中加载 mLab Mongo 详细信息所必需的。

server/configMongoose.js文件的内容必须替换。找到以下代码:

// this is old code from our codebase: 
import mongoose from 'mongoose'; 
var Schema = mongoose.Schema; 

const conf = { 
  hostname: process.env.MONGO_HOSTNAME || 'localhost', 
  port: process.env.MONGO_PORT || 27017, 
  env: process.env.MONGO_ENV || 'local', 
}; 
mongoose.connect(&grave;mongodb://${conf.hostname}: 
 ${conf.port}/${conf.env}&grave;);

同样改进的代码的新版本必须如下:

import mongoose from 'mongoose'; 
var Schema = mongoose.Schema; 

const conf = { 
  hostname: process.env.MONGO_HOSTNAME || 'localhost', 
  port: process.env.MONGO_PORT || 27017, 
  env: process.env.MONGO_ENV || 'local', 
}; 

let dbUser 
if(process.env.MONGO_USER && process.env.MONGO_PASS) { 
  dbUser = {user: process.env.MONGO_USER, pass: 
   process.env.MONGO_PASS} 
} else { 
  dbUser = undefined; // on local dev not required 
} 
mongoose.connect(&grave;mongodb://${conf.hostname}:${conf.port}/${conf.env}&grave;, dbUser);

如你所见,我们增加了连接到特定数据库用户的可能性。我们需要它,因为我们之前工作的 localhost 不需要任何用户,但当我们开始使用 mLab MongoDB 时,指定我们的数据库用户是必须的。否则,我们无法正确认证。

从这一点开始,你不需要在你的系统后台运行mongod进程,因为应用程序将连接到你在上一章中创建的 mLab MongoDB 节点。mLab MongoDB(免费版)全天候运行,但如果计划将其用于生产就绪应用程序,那么你需要更新它并开始使用副本集功能(这在上一章中已提及)。

你可以尝试使用以下命令运行项目:

npm start

然后,你应该能够加载应用程序:

图片

现在的重要区别是,所有的 CRUD 操作(通过我们的发布应用程序的读写)都是在我们的远程 MongoDB(而不是本地)上完成的。

在发布应用程序使用 mLab MongoDB 之后,我们就可以准备我们的 Docker 镜像,然后使用 AWS Load Balancer 和 EC2 Container Service 在 AWS EC2 的几个实例上部署它。

在发布应用程序的 Docker 镜像上工作

在继续之前,您应该能够通过使用远程 mLab MongoDB 来在本地运行您的项目。这是必需的,因为我们将在 Docker 容器中启动我们的发布应用。然后我们的应用将远程连接到 Mongo。我们不会在任何一个 Docker 容器中运行任何 MongoDB 进程。这就是为什么在以下步骤中使用 mLab 如此重要的原因。

让我们在终端/命令行中执行以下命令来创建 Dockerfile:

[[your are in the project main directory]]
$ touch Dockerfile

在您的新 Dockerfile 中输入以下内容:

FROM centos:centos7 

RUN yum update -y 
RUN yum install -y tar wget 
RUN wget -q https://nodejs.org/dist/v4.0.0/node-v4.0.0-linux-x64.tar.gz -O - | tar xzf - -C /opt/ 

RUN mv /opt/node-v* /opt/node 
RUN ln -s /opt/node/bin/node /usr/bin/node 
RUN ln -s /opt/node/bin/npm /usr/bin/npm 

COPY . /opt/publishing-app/ 

WORKDIR /opt/publishing-app 

RUN npm install 
RUN yum clean all 

EXPOSE 80 
CMD ["npm", "start"]

让我们一步一步地看看我们将要在我们的发布应用中与 Docker 一起使用的 Dockerfile:

  • FROM centos:centos7:这意味着我们将使用来自hub.docker.com/r/_/centos/公共 Docker 仓库的 CentOS 7 Linux 发行版作为起点。

您可以使用任何其他包作为起点,例如 Ubuntu,但我们使用 CentOS 7,因为它更轻量级,并且通常非常适合 Web 应用部署。您可以在www.centos.org/找到更多详细信息。

所有命令的文档可在docs.docker.com/engine/reference/builder/找到。

  • RUN yum update -y:这通过命令行使用yum--更新包,这是任何 Linux 设置的常规做法。

  • RUN yum install -y tar wget:这安装了两个包作为tar(用于解压文件)和wget(用于下载文件)。

  • RUN wget -q https://nodejs.org/dist/v4.0.0/node-v4.0.0-linux-x64.tar.gz -O - | tar xzf - -C /opt/*:这个命令将node4.0.0下载到我们的 CentOS 容器中,解压它,并将所有文件放入/opt/目录。

  • RUN mv /opt/node-v /opt/node*:这会将我们刚刚下载并解压的文件夹(带有node)重命名为不带版本命名的简单node

  • RUN ln -s /opt/node/bin/node /usr/bin/node:我们将/opt/node/bin/node位置与/usr/bin/node链接链接起来,这样我们就能在终端中使用简单的$ node命令。这是 Linux 用户的常规做法。

  • RUN ln -s /opt/node/bin/npm /usr/bin/npm:与node相同,但与npm。我们将其链接起来,以便更容易使用,并将其链接到我们的 CentOS 7 上的$ npm

  • COPY . /opt/publishing-app/:这会将上下文中的所有文件复制(.(点)符号是启动容器构建时的位置。我们将在稍后进行操作。)它将所有文件复制到我们的容器中/opt/publishing-app/位置。

在我们的案例中,我们在发布应用的目录中创建了 Dockerfile,因此它将容器中的所有项目文件复制到指定的位置/opt/publishing-app/

  • WORKDIR /opt/publishing-app:在我们将发布应用的文件放入我们的 Docker 容器后,我们需要选择工作目录。它类似于在任何 Unix/Linux 机器上的$ cd /opt/publishing-app

  • RUN npm install: 当我们在工作目录 /opt/publishing-app 中时,我们运行标准的 npm install 命令。

  • RUN yum clean all: 我们清理 yum 缓存。

  • EXPOSE 80: 我们定义了使用我们的发布应用程序的端口。

  • CMD ["npm", "start"]: 然后,我们指定如何在 Docker 容器中运行应用程序。

我们还将在主项目目录中创建一个 .dockerignore 文件:

$ [[[in the main directory]]]
$ touch .dockerignore

文件内容如下:

.git 
node_modules 
.DS_Store

我们不想复制提到的文件(.DS_Store 是针对 OS X 的特定文件)。

构建发布应用程序容器

目前,你将能够构建 Docker 的容器。

在项目主目录中,你需要运行以下命令:

docker login

login 命令将提示你输入 Docker 用户名和密码。认证正确后,你可以运行 build 命令:

docker build -t przeor/pub-app-docker .

当然,用户名和容器名称组合必须是你的。用你的详细信息替换它。

前面的命令将使用 Dockerfile 命令构建容器。这是你将看到的内容(步骤 1,步骤 2 等):

构建成功后,你将在你的终端/命令行中看到类似以下的内容:

[[[striped from here for the sake of brevity]]]
Step 12 : EXPOSE 80
 ---> Running in 081e0359cbd5
 ---> ce0433b220a0
Removing intermediate container 081e0359cbd5
Step 13 : CMD npm start
 ---> Running in 581df04c8c81
 ---> 1970dde57fec
Removing intermediate container 581df04c8c81
Successfully built 1910dde57fec

如你从 Docker 终端中看到的那样,我们已经成功构建了一个容器。下一步是本地测试它,然后学习一些 Docker 的基础知识,最后开始我们的 AWS 部署工作。

本地运行发布应用程序容器

为了测试容器是否已正确构建,请执行以下步骤。

运行以下命令:

$ docker-machine env

前面的命令将给出类似以下的内容:

export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://192.168.99.100:2376"
export DOCKER_CERT_PATH="/Users/przeor/.docker/machine/machines/default"
export DOCKER_MACHINE_NAME="default"
# Run this command to configure your shell: 
# eval $(docker-machine env)

我们正在寻找 DOCKER_HOST IP 地址;在这种情况下,它是 192.168.99.100

这个 Docker 主机 IP 将用于检查我们的应用程序是否在容器中正确运行。记下来。

下一步是使用以下命令运行我们的本地容器:

$ docker run -d -p 80:80  przeor/pub-app-docker npm start

关于标志:d 标志代表 "分离",因此进程将在后台运行。你可以使用以下命令列出所有正在运行的 Docker 进程:

docker ps

一个示例输出如下:

-p 标志告诉我们容器的端口 80 绑定到 Docker IP 主机的端口 80。因此,如果我们将在容器中将 Node 应用程序暴露在端口 80 上,那么它将能够在 IP 地址的标准端口 80 上运行(在示例中,它将是 192.168.99.100:80;显然,端口 80 是用于所有 HTTP 请求的)。

przeor/pub-app-docker 命令将指定我们想要运行的容器名称。

使用 npm start,我们告诉 Docker 容器启动后要运行的命令(否则,容器将立即启动并停止)。

关于 docker run 的更多参考资料可在 docs.docker.com/engine/reference/run/ 找到。

前面的命令将运行应用程序,如下面的截图所示:

正如你所见,浏览器 URL 栏中的 IP 地址是 http://192.168.99.100。这是我们的 Docker 主机 IP。

容器调试

如果容器对你不起作用,就像以下截图所示,请使用以下命令进行调试并找出原因:

docker run -i -t -p 80:80 przeor/pub-app-docker

这个带有 -i -t -p 标志的命令将在终端/命令行中显示所有日志,如下面的截图所示(这只是一个示例,以展示你可以在本地调试 Docker 容器的能力):

将 Docker 容器推送到远程仓库

如果容器在本地运行正常,那么它几乎就准备好进行 AWS 部署了。

在推送容器之前,让我们将 .env 文件添加到 .dockerignore,因为其中包含所有你不会放入容器中的敏感数据。因此,在 .dockerignore 文件中,添加以下内容:

.git 
node_modules 
.DS_Store 
.env

在将 .env 文件添加到 .gitignore 之后,我们需要修改 server/index.js 文件并添加一个额外的 if 语句:

if(!process.env.PORT) { 
  var env = require('node-env-file'); 
  // Load any undefined ENV variables form a specified file.  
  env(__dirname + '/.env'); 
}

这个 if 语句检查我们是在本地运行应用程序(带有 .env 文件)还是在 AWS 实例上远程运行(然后我们以更安全的方式传递 env 变量)。

在将 .env 文件添加到 .dockerignore(并修改 server/index.js)之后,构建好即将推送的容器:

docker build -t przeor/pub-app-docker

关于环境变量,我们将通过 AWS 高级选项添加它们。你将在稍后了解这一点,但为了了解如何在本地运行时添加它们,请查看以下示例(命令标志中提供了假数据):

$ docker run -i -t -e PORT=80 -e AWS_ACCESS_KEY_ID='AKIMOCKED5JM4VUHA' -e AWS_SECRET_ACCESS_KEY='k3JxMOCKED0oRI6w3ZEmENE1I0l' -e AWS_BUCKET_NAME='publishing-app' -e AWS_REGION_NAME='eu-central-1' -e MONGO_USER='usermlab' -e MONGO_PASS='MOCKEDpassword' -e MONGO_PORT=25732 -e MONGO_ENV='publishingapp' -e MONGO_HOSTNAME='ds025761.mlab.com' -p 80:80 przeor/pub-app-docker
npm start

确保你已经提供了正确的 AWS_REGION_NAME。我的是 eu-central-1,但你的可能不同。

正如你所见,server/.env 文件中的所有内容都已经移动到了 Bash 终端的 Docker 运行命令中:

AWS_ACCESS_KEY_ID=<<___AWS_ACCESS_KEY_ID__>>
AWS_SECRET_ACCESS_KEY=<<___AWS_SECRET_ACCESS_KEY__>>
AWS_BUCKET_NAME=publishing-app
AWS_REGION_NAME=eu-central-1
MONGO_USER=<<___your_mlab_mongo_user__>>
MONGO_PASS=<<___your_mlab_mongo_pass__>>
MONGO_PORT=<<___your_mlab_mongo_port__>>
MONGO_ENV=publishingapp
MONGO_HOSTNAME=<<___your_mlab_mongo_hostname__>>
PORT=80

正如这里所示,-e 标志是用于 env 变量的。最后一步是将容器推送到由 Docker Hub 托管的远程仓库:

docker push przeor/pub-app-docker

然后,你将在你的 Bash/命令行中找到类似以下的内容:

推送仓库的链接将与以下类似:

上述截图是从推送的 Docker 仓库制作的。

有用 Docker 命令的总结

以下是一些有用的 Docker 命令:

  • 这个命令将列出所有镜像,如果需要从你的本地机器删除仓库,可以使用 docker rm
 docker images
 docker rm CONTAINER-ID

  • 你可以使用 CONTAINER-ID 的前三个字符。你不需要写下整个容器 ID。这是一个便利。

  • 这个命令用于停止正在运行的 Docker 容器:

        docker ps
docker stop CONTAINER-ID

  • 你可以使用以下方法使用容器的版本标签:
        docker tag przeor/pub-app-docker:latest przeor/pub-app-  
        docker:0.1
docker images

  • 在列出 Docker 镜像后,你可能注意到你有两个容器,一个带有 latest 标签,另一个带有 0.1 标签。这是一种跟踪更改的方式,因为如果你推送容器,标签也会在 Docker Hub 上列出。

  • 检查容器的本地 IP:

        $ docker-machine env

  • 从 Dockerfile 构建你的容器:
        docker build -t przeor/pub-app-docker .

  • 以“分离”模式运行你的容器:
        $ docker run -d -p 80:80 przeor/pub-app-docker npm start

  • 运行你的容器以调试它,而不将其分离,这样你就可以在容器的 Bash 终端中找到正在发生的事情:
        docker run -i -t -p 80:80 przeor/pub-app-docker

AWS EC2 上的 Docker 简介

在两章之前,我们实现了亚马逊 AWS S3 用于静态图像上传。你应该已经有了 AWS 账户,因此你已准备好进行以下步骤以在 AWS 上创建我们的部署。

通常,你可以使用免费 AWS 层级的步骤,但在这个教程中,我们将使用付费版本。在开始本节关于如何在 AWS 上部署 Docker 容器的部分之前,请阅读 AWS EC2 定价。

AWS 还通过其名为EC2 容器服务ECS)的服务提供了对 Docker 容器的强大支持。

如果你购买了这本书,这可能意味着你之前还没有使用过 AWS。因此,我们将首先在 EC2 上手动部署 Docker,以便向你展示 EC2 实例的工作原理,这样你就可以从这本书中获得更多知识。

我们的主要目标是使我们的 Docker 容器部署自动化,但现在是手动方法。如果你已经使用过 EC2,你可以跳过下一个子节,直接进入 ECS。

手动方法 - EC2 上的 Docker

我们之前(几页之前)使用以下命令在本地运行我们的 Docker 容器:

$ docker run -d -p 80:80  przeor/pub-app-docker npm start

我们将做同样的事情,但不是在本地,而是在 EC2 实例上,现在 100%手动;稍后,我们将使用 AWS ECS 100%自动完成。

在我们继续之前,让我们了解什么是 EC2。它位于亚马逊网络服务云中的可扩展计算能力。在 EC2 中,你不需要预先投资购买任何硬件。你所支付的一切都是为了使用 EC2 实例所花费的时间。这允许你更快地部署应用程序。非常快,你就可以添加新的虚拟服务器(当有更大的网络流量需求时)。有一些机制可以自动使用AWS CloudWatch来扩展 EC2 实例的数量。亚马逊 EC2 让你能够根据变化的需求(如流行度的激增)进行扩展或缩减,这个功能减少了你需要预测流量的需求(并为你节省时间和金钱)。

目前,我们只将使用一个 EC2 实例(在本书的后面部分,我们将看到带有负载均衡器和 ECS 的更多 EC2 实例)。

基础 - 启动 EC2 实例

我们将启动一个 EC2 实例,然后通过 SSH 登录到它(在 Windows 操作系统上,你可以使用PuTTY)。

通过访问此链接登录 AWS 控制台:eu-central-1.console.aws.amazon.com/console/home

点击 EC2 链接:eu-central-1.console.aws.amazon.com/ec2/v2/home

然后点击蓝色的“启动实例”按钮:

图片

按钮看起来是这样的:

图片

点击按钮后,你将被重定向到亚马逊机器镜像AMI)页面:

图片

AMI 有一个可以运行 EC2 实例的镜像列表。每个镜像都有一个预安装软件列表。例如,最标准的镜像如下:

图片

它已预安装了软件;例如,Amazon Linux AMI 是一个基于 EBS、AWS 支持的镜像。默认镜像包括 AWS 命令行工具、Python、Ruby、Perl 和 Java。仓库包括 Docker、PHP、MySQL、PostgreSQL 和其他包。

在同一页面上,您还可以找到市场上可购买的或其他社区免费创建和共享的其他 AMIs。您还可以过滤镜像,使其仅列出免费层:

图片

为了使这个逐步指南简单,让我们选择前一个截图中的镜像;其名称将与Amazon Linux AMI 2016.03.3 (HVM), SSD Volume Type类似。

镜像的名称可能略有不同;无需担心。

点击蓝色选择按钮。然后您将被转到第 2 步:选择实例类型页面,如下截图所示:

图片

从此页面,选择以下内容:

图片

然后,点击此按钮:

图片

最简单的方法是选择默认选项:

  1. 审查。

  2. 配置安全组(我们将在此选项卡中进行一些更改)。

  3. 标记实例(保持默认选项)。

  4. 添加存储(保持默认选项)。

  5. 配置实例(保持默认选项)。

  6. 选择实例类型。

  7. 选择一个 AMI。

  8. 通常,一直点击下一个按钮,直到我们到达配置安全组。

您可以在顶部找到的进度指示器如下:

图片

我们现在的目标是到达安全配置页面,因为我们需要稍微自定义允许的端口。安全组由控制 EC2 实例(即防火墙选项)网络流量的规则组成。为了安全起见,将名称设置为ssh-and-http-security-group

图片

如您在此处可以看到,您还需要点击添加规则按钮并添加一个名为 HTTP 的新规则。这将允许我们的新 EC2 实例通过端口 80 对所有 IP 地址可用。

在您添加了名称并将 HTTP 端口 80 作为新规则后,您可以点击审查和启动按钮:

图片

然后,在您满意审查实例后,点击该视图中的蓝色按钮“启动”:

图片

在您点击启动按钮后,您将看到一个模态窗口,提示选择现有的密钥对或创建新的密钥对:

图片

通常,您需要创建一个新的密钥对。将其命名为pubapp-ec2-key-pair,然后点击下载按钮,如下截图所示:

图片

在您下载了pubapp-ec2-key-pai后,您将能够点击蓝色启动按钮。接下来,您将看到以下内容:

图片

从此屏幕,您可以直接转到 EC2 启动日志(点击查看启动日志链接),这样您就可以找到您的实例,如下面的截图所示:

图片 1

太好了。您的第一个 EC2 已经成功启动!我们需要登录到它,并从那里设置 Docker 容器。

保存您 EC2 实例的公网 IP。在之前的启动日志中,您可以找到我们刚刚创建的机器的公网 IP 为 52.29.107.244。

您的 IP 将不同(当然,这只是一个示例)。将其保存在某处;我们将在稍后使用它,因为您需要它通过 SSH 登录到服务器并安装 Docker 应用程序。

PuTTy 通过 SSH 访问 - 仅限 Windows 用户

如果您不在 Windows 上工作,您可以跳过本节。

我们将使用 PuTTy,它可以在www.chiark.greenend.org.uk/~sgtatham/putty/download.html下载(putty.exepageant.exeputtygen.exe)。

下载 EC2 实例的密钥对,并使用puttygen.exe将其转换为ppk

图片 2

点击“加载”按钮并选择pubapp-ec2-key-pair.pem文件,然后将其转换为ppk

然后您需要点击“保存私钥”按钮。您已经完成了;您可以关闭puttygen.exe并打开pageant.exe。从它,您需要做以下操作:

  • 选择“添加密钥”

  • 然后检查您的密钥是否已正确添加到 Pageant 密钥列表

如果您的私钥在列表中,您就可以使用putty.exe了。

图片 5

如果您已经打开了 PuTTy 程序,您需要通过 SSH 输入您的 EC2 实例 IP 并点击打开按钮,如前面的截图所示。PuTTy 允许在 Windows 上使用 SSH 连接。

通过 SSH 连接到 EC2 实例

在前一章中,在我们启动 EC2 实例后,我们找到了我们的公网 IP(记住您的公网 IP 将不同):52.29.107.244。我们需要使用这个公网 IP 连接到远程 EC2 实例。

我已经将pubapp-ec2-key-pair.pem保存在我的下载目录中,所以请转到您下载.pem文件的目录:

$ cd ~/Downloads/
$ chmod 400 pubapp-ec2-key-pair.pem
$ ssh -i pubapp-ec2-key-pair.pem ec2-user@52.29.107.244

在 Windows 的 PuTTy 中,完成此步骤后,界面将与此类似。您需要在 PuTTy 窗口中提供 IP 和端口,以便正确登录到机器。当您收到输入用户名的提示时,请使用ec2-user,就像 SSH 示例中那样。

登录成功后,您将能够看到以下内容:

图片 3

以下说明适用于所有操作系统用户(OS X、Linux 和 Windows),因为我们是通过 SSH 登录到 EC2 实例的。以下命令是必需的:

[ec2-user@ip-172-31-26-81 ~]$ sudo yum update -y
[ec2-user@ip-172-31-26-81 ~]$ sudo yum install -y docker
[ec2-user@ip-172-31-26-81 ~]$ sudo service docker start

这些命令将更新yum包管理器,并在后台安装和启动 Docker 服务:

[ec2-user@ip-172-31-26-81 ~]$ sudo usermod -a -G docker ec2-user
[ec2-user@ip-172-31-26-81 ~]$ exit

> ssh -i pubapp-ec2-key-pair.pem ec2-user@52.29.107.244
[ec2-user@ip-172-31-26-81 ~]$ docker info

在您运行docker info命令后,它将显示类似于以下输出的内容:

图片 4

如果你查看前面的截图,你会看到一切正常,我们可以继续使用以下命令运行发布应用的 Docker 容器:

    [ec2-user@ip-172-31-26-81 ~]$ docker run -d PORT=80 -e AWS_ACCESS_KEY_ID='AKIMOCKED5JM4VUHA' -e AWS_SECRET_ACCESS_KEY='k3JxMOCKED0oRI5w3ZEmENE1I0l' -e AWS_BUCKET_NAME='publishing-app' -e AWS_REGION_NAME='eu-central-1' -e MONGO_USER='usermlab' -e MONGO_PASS='MOCKEDpassword' -e MONGO_PORT=25732 -e MONGO_ENV='publishingapp' -e MONGO_HOSTNAME='ds025761.mlab.com' -p 80:80 przeor/pub-app-docker npm start

确保你已经提供了正确的AWS_REGION_NAME。我的设置为eu-central-1,但你的可能不同。

正如你所见,从server/.env文件中的所有内容都已经移动到了 Bash 终端中的docker run命令中:

AWS_ACCESS_KEY_ID=<<___AWS_ACCESS_KEY_ID__>>
AWS_SECRET_ACCESS_KEY=<<___AWS_SECRET_ACCESS_KEY__>>
AWS_BUCKET_NAME=publishing-app
AWS_REGION_NAME=eu-central-1
MONGO_USER=<<___your_mlab_mongo_user__>>
MONGO_PASS=<<___your_mlab_mongo_pass__>>
MONGO_PORT=<<___your_mlab_mongo_port__>>
MONGO_ENV=publishingapp
MONGO_HOSTNAME=<<___your_mlab_mongo_hostname__>>
PORT=80

同时,如果你有不同的设置,请确保重命名AWS_BUCKET_NAMEAWS_REGION_NAMEMONGO_ENV(如果你设置得与上一章中建议的不同)。

然后,为了检查一切是否顺利进行,你也可以使用以下命令:

[ec2-user@ip-172-31-26-81 ~]$ docker ps

这个命令将显示 Docker 进程是否作为分离容器在后台正确运行。在 10-30 秒后,当npm start运行整个项目时,你可以用以下命令进行测试:

[ec2-user@ip-172-31-26-81 ~]$ curl http://localhost

在应用程序正确引导之后,你可以看到类似以下输出:

图片

在你访问 EC2 实例的公网 IP(在我们的例子中,它是52.29.107.244)之后,你将能够找到我们已在线设置的发布应用。以下是一个截图:

图片

如果你看到我们的发布应用在公网 IP 下,那么你就已经成功地在 Amazon AWS EC2 上部署了一个 Docker 容器!

我们刚才经历的过程非常低效且手动,但它确切地展示了当我们开始使用 ECS 时幕后发生了什么。

我们在当前的方法中缺少以下内容:

  • 与其他 Amazon 服务的集成,例如负载均衡、监控、警报、故障恢复和 route 53。

  • 自动化,因为我们目前无法高效地快速部署 10 个 Docker 容器。如果你想要为不同的服务部署不同的 Docker 容器,这也同样重要,例如,你可以为前端、后端甚至数据库(在我们的案例中,我们使用 mLab,因此这里不需要)分别设置容器。

你刚刚学习了 Amazon Web Services 的基础知识。

ECS 的基础 - AWS EC2

EC2 容器服务可以帮助你创建一个 Docker 容器实例的集群(在多个 EC2 实例上运行相同容器的多个副本)。每个容器都会自动部署--这意味着你不需要通过 SSH 登录到任何 EC2 实例,就像我们在上一章中做的那样(手动方法)。整个工作都由 AWS 和 Docker 软件完成,你将在未来学习如何使用它们(一种更自动化的方法)。

例如,您设置想要拥有五个不同的 EC2 实例——在公开端口 80 上的 EC2 实例组,这样您就能在http://[[EC2_PUBLIC_IP]]地址下找到发布的应用程序。此外,我们在所有 EC2 实例和外部世界之间添加了一个负载均衡器,以便在流量激增或任何 EC2 实例出现故障的情况下,负载均衡器将用新的实例替换故障的 EC2 实例,或根据流量调整 EC2 实例的数量。

AWS 负载均衡器的一个出色功能是它会用端口 80 对每个 EC2 实例进行 ping 操作,如果被 ping 的实例没有以正确的代码(200)响应,那么它将终止故障实例并启动一个全新的带有我们发布应用镜像的 Docker 容器。这有助于我们保持应用的持续可用性。

此外,我们将使用 Amazon Route 53 来拥有一个高度可用和可扩展的云域名系统DNS)网络服务,这样我们就能设置一个顶级域名;在我们的案例中,我将使用我为这本书专门购买的域名:http://reactjs.space

当然,那将是我们的 HTTP 地址。如果您构建不同的服务,您需要购买自己的域名,以便遵循说明并了解 Amazon Route 53 的工作原理。

使用 ECS

在我们开始使用 ECS 之前,让我们了解一些基本术语:

  • 集群:这是我们流程的主要部分,它将底层资源(EC2 实例和任何附加存储)池化。它将许多 EC2 实例聚合成一个容器化应用程序,旨在可扩展。

  • 任务定义:此任务确定您将在每个 EC2 实例上运行哪些 Docker 容器(即docker run命令),它还帮助您定义更高级的选项,例如您想要传递到容器中的环境变量。

  • 服务:这是集群和任务定义之间的一种粘合剂。服务处理我们集群中正在运行的任务的登录。这还包括您想要运行的任务(容器及其设置的组合)的版本管理。每次您更改任务中的任何设置时,都会创建您任务的新版本。在服务中,您指定任务及其版本,您希望在您的 ECS EC2 实例上运行。

访问 AWS 控制台并找到 ECS。点击链接进入 EC2 容器服务控制台。在那里,您将找到一个名为“开始”的蓝色按钮:

之后,您将看到一个包含以下步骤的 ECS 向导:

  1. 创建一个任务定义。

  2. 配置服务。

  3. 配置集群。

  4. 审查。

第 1 步 - 创建任务定义

在 ECS 中,任务定义是一个容器的配方。这是帮助 ECS 理解你希望在 EC2 实例上运行的 Docker 容器的东西。这是一个 ECS 自动执行以成功部署我们的发布应用容器的步骤配方或蓝图。

本步骤的详细信息如下所示:

图片

在前面的屏幕截图中,你可以看到我们的任务定义名称是pubapp-task,容器名称是pubapp-container

对于镜像,我们使用与本地运行容器时相同的参数docker run。在przeor/pub-app-docker的情况下,ECS 将知道它必须从hub.docker.com/r/przeor/pub-app-docker/下载容器。

目前,让我们将最大内存保持为默认值(300)。将端口映射设置为80

在撰写本书时,如果你的容器没有暴露端口80,可能会出现一些问题。这可能是 ECS 向导的 bug;没有向导,容器上可以使用任何端口。

在任务定义视图中点击“高级选项”:

图片

你将看到一个带有附加选项的幻灯片面板:

图片

我们需要指定以下事项:

  • 命令:这个命令必须用逗号分隔,所以我们使用npm,start

  • 工作目录:我们使用/opt/publishing-app(在 Dockerfile 中设置了相同的路径)。

  • 环境变量:在这里,我们指定server/.env文件中的所有值。这部分设置很重要;如果没有通过环境变量提供正确的详细信息,应用程序将无法正确运行。

  • 其余的值/输入:保持默认值不变。

添加所有环境变量非常重要。我们需要非常小心,因为在这里很容易犯错误,这可能会破坏 EC2 实例内的应用程序。

在进行所有这些更改后,你可以点击“下一步”按钮。

第 2 步 - 配置服务

通常,服务是一种机制,它保持一定数量的 EC2 实例运行,同时检查它们的健康状态(使用弹性负载均衡器ELB))。ELB 自动将传入的应用程序流量分配到多个 Amazon EC2 实例。如果服务器在端口 80(默认值,但可以更改为更高级的健康检查)上没有响应,那么服务将运行新的服务,同时关闭不健康的那个。这有助于你保持应用程序非常高的可用性。

图片

服务名称是pubapp-service。在这本书中,我们将设置三个不同的 EC2 实例(你可以设置更少或更多;这取决于你),所以这是“所需任务数”输入的数字。

在相同的步骤中,我们还需要设置弹性负载均衡器ELB):

图片

  • 容器名称:主机端口:从下拉列表中选择pubapp-container:80

  • ELB 监听器协议:HTTP

  • ELB 监听端口*:80

  • ELB 健康检查:保持默认;你可以在退出向导时更改它(在特定 ELB 的页面上)

  • 服务 IAM 角色:向导将为我们创建此角色

在完成所有这些后,你可以点击“下一步”按钮继续:

图片

第 3 步 - 配置集群

现在,你需要设置 ECS 容器代理的详细信息,也就是集群。在这里,你指定集群的名称、你希望使用的实例类型、实例数量(必须大于服务所需数量),以及密钥对。

图片

  • 集群名称:我们的集群名称是pubapp-ecs-cluster

  • EC2 实例类型:t2.micro(在生产环境中,请使用更大的实例)。

  • 实例数量:五个,这意味着服务将保持三个实例运行,另外两个实例将在备用状态,等待任何致命情况。备用状态意味着(在我们的设置中),我们一次只会使用三个实例,而另外两个准备就绪,但未被积极使用(流量不会被重定向到它们)。

  • 密钥对:我在本章前面指定了名为pubapp-ec2-key-pair的密钥对。始终将它们保存在安全的地方以备后用。

在同一页面上,你还会找到安全组和容器实例 IAM 角色的设置,但我们会保持默认设置:

图片

第 4 步 - 审查

最后,检查一切是否正常:

图片

然后,选择“启动实例并运行服务”:

图片

启动状态

点击“启动”按钮后,你会看到一个显示状态的页面。保持打开状态,直到所有框都显示成功指示器为绿色:

图片

这是所有设置完成后运行的样子:

图片

所有框都显示成功指示器后,你将能够点击顶部的“查看服务”按钮:

图片

在它可用后,点击该按钮(查看服务)。

查找你的负载均衡器地址

点击“查看服务”按钮后,你会看到主仪表板,其中列出了所有你的集群(目前只有一个):

图片

点击 pubapp-ecs-cluster,你会看到以下内容:

图片

在前面的屏幕上,从列表中选择 pubapp-service:

图片

然后,你会看到以下内容:

图片

在此页面上,选择弹性负载均衡器:

图片

ELB 的最终视图如下:

图片

在前面的视图中,你将在“描述名称”选项卡下找到一个类似这样的弹性负载均衡器地址:

    DNS name: 
EC2Contai-EcsElast-1E4Y3WOGMV6S4-39378274.eu-central-
    1.elb.amazonaws.com (A Record)

如果你尝试打开地址但无法工作,请给它更多时间。EC2 实例可能正在运行我们的 Docker 发布应用容器。在 ECS 集群的初始运行期间,我们必须保持耐心。

这是你的 ELB 地址,你可以将其放入浏览器中查看发布应用:

图片

AWS Route 53

本章的最后一步是设置 Route 53,这是一个高度可用和可扩展的云 DNS 网络服务。

对于这一步,您有两个选择:

  • 已经注册了自己的域名

  • 通过 Route 53 注册新域名

在以下过程中,我们将使用第一个选项,因此我们假设我们已经注册了reactjs.space域名(当然,您需要拥有自己的域名才能成功执行这些步骤)。

我们将通过将名称http://reactjs.space转换为我们的 ELB(EC2Contai-EcsElast-1E4Y3WOGMV6S4-39378274.eu-central-1.elb.amazonaws.com)的地址来路由最终用户,这样用户就可以通过在浏览器的地址栏中输入reactjs.space来以更用户友好的方式访问我们的应用程序。

从 AWS 服务列表中选择 Route 53:

图片

您将能够看到一个如下所示的主页:

图片

下一步是在 Route 53 上创建一个托管区域,因此请点击名为“创建托管区域”的蓝色按钮:

图片

在此之后,您将看不到任何托管区域,因此请再次点击蓝色按钮:

图片

表单将有一个域名字段,您可以在其中输入您的域名(在我们的情况下,它是reactjs.space):

图片

成功!现在您将能够看到您的 DNS 名称:

图片

下一步是将 DNS 记录停放在您域名的提供商上。最后一步是在您的域名注册商处更改 DNS 设置;在我的情况下,如下所示(您的将不同):

ns-1276.awsdns-31.org.
ns-1775.awsdns-29.co.uk.
ns-763.awsdns-31.net.
ns-323.awsdns-40.com.

注意最后的.(点);您可以去掉它们,这样我们最终要更改的 DNS 如下所示:

ns-1276.awsdns-31.org
ns-1775.awsdns-29.co.uk
ns-763.awsdns-31.net
ns-323.awsdns-40.com

在完成所有这些步骤之后,您可以访问http://reactjs.space网站(DNS 更改可能需要长达 48 小时)。

最后一件事情是创建一个指向我们的弹性负载均衡器的reactjs.space域名的别名。点击以下按钮:

图片

然后,您将看到以下视图:

图片

从别名单选按钮中选择“是”,然后从列表中选择 ELB,如下例所示:

图片

目前,在 DNS 更改完成后(可能需要长达 48 小时),一切都将正常工作。为了提高我们应用程序的体验,让我们也创建一个从www.reactjs.spacereactjs.space的别名,这样如果有人在域名名前输入www.,它将按预期工作。

再次点击名为“创建记录集”的按钮,选择一个别名,并输入www,之后您将能够选择www.reactjs.space域名。这样做并点击创建按钮:

图片

摘要

我们完成了所有的 AWS/Docker 设置。在 DNS 更改成功后,您将能够在http://reactjs.space地址下找到我们的应用程序:

图片

下一章将讨论持续集成的基础知识,并帮助你完成应用在达到 100%生产就绪前的剩余工作(目前尚未进行代码压缩)。

让我们在下一章中继续,对书中将要涵盖的剩余主题进行更详细的描述。

第九章:与单元测试和行为测试的持续集成

我们做到了;恭喜!我们已经创建了一个全栈应用程序,它运行在特定的域名下(在这本书中是reactjs.space)。整个设置中缺少的部分是部署流程。部署应该是零停机时间。我们需要有一个冗余的服务器来运行我们的应用程序。

我们在应用程序中缺少一些步骤,使其能够专业地工作,包括压缩、单元测试和行为测试。

在本章中,我们将向你介绍一些需要掌握全栈开发所需的一些额外概念。剩余的缺失部分留给你作为作业。

何时编写单元测试和行为测试

通常,有一些关于何时编写单元测试和/或行为测试的建议。

在 ReactPoland,我们经常有客户运营初创公司。作为对他们的一般治理,我们建议以下内容:

  • 如果你的初创公司正在寻找增长动力,你需要你的产品来实现它,那么不要担心测试

  • 在你创建了你的最小可行产品MVP)之后,在扩展你的应用程序时,你必须有这些测试

  • 如果你是一家已经建立起来的公司,正在为你的客户开发应用程序,并且你对他们的需求非常了解,那么你肯定需要进行测试

前两点与初创公司和年轻公司相关。第三点主要与已经建立起来的公司相关。

根据你和你产品的位置,你需要自己决定是否值得编写测试。

React 约定

有一个项目展示了全栈开发设置应该如何看起来,请访问React JS.co

访问这个网站,了解如何将你的应用程序与单元测试和行为测试集成,并了解最新的最佳实践,以制作 React Redux 应用程序。

Karma 测试

我们不会在本章中指导你设置测试,因为这超出了本书的范围。本章的目的是向你提供在线资源,帮助你理解更大的图景。

Karma 是最受欢迎的单元测试和行为测试工具之一。主要目标是提供一个高效测试环境,以便在开发任何应用程序时使用。

使用这个测试运行器,你将获得许多功能。有一个很好的视频解释了 Karma 的整体情况,请访问karma-runner.github.io

一些主要功能如下:

  • 在真实设备上的测试:你可以使用真实的浏览器和真实设备,如手机、平板电脑或 PhantomJS 来运行测试(PhantomJS 是一个可以通过 JavaScript API 脚本化的无头 WebKit,它对各种网络标准有快速和本地的支持:DOM 处理、CSS 选择器、JSON、Canvas 和 SVG)。有不同环境,但有一个在所有这些环境中运行的工具。

  • 远程控制:你可以远程运行测试,例如,在 IDE 中每次保存时,这样你就不必手动操作。

  • 测试框架无关:你可以使用 Jasmine、Mocha、QUnit 和其他框架编写测试。这完全取决于你。

  • 持续集成:Karma 与 CI 工具(如 Jenkins、Travis 或 CircleCI)配合得很好。

如何编写单元和行为测试

让我们提供一个示例,说明如何正确设置项目以便能够编写测试。

访问非常受欢迎的 Redux 入门套件的 GitHub 仓库 github.com/davezuko/react-redux-starter-kit

然后访问这个仓库的 package.json 文件。我们可以从中找到可能的命令/脚本:

 "scripts": { 
    "clean": "rimraf dist", 
    "compile": "better-npm-run compile", 
    "lint": "eslint src tests server", 
    "lint:fix": "npm run lint -- --fix", 
    "start": "better-npm-run start", 
    "dev": "better-npm-run dev", 
    "dev:no-debug": "npm run dev -- --no_debug", 
    "test": "better-npm-run test", 
    "test:dev": "npm run test -- --watch", 
    "deploy": "better-npm-run deploy", 
    "deploy:dev": "better-npm-run deploy:dev", 
    "deploy:prod": "better-npm-run deploy:prod", 
    "codecov": "cat coverage/*/lcov.info | codecov" 
  },

如你所见,在 NPM 测试之后,它运行以下命令:

   "test": { 
      "command": "babel-node ./node_modules/karma/bin/ 
       karma start build/karma.conf", 
      "env": { 
        "NODE_ENV": "test", 
        "DEBUG": "app:*" 
      } 
    }

你可以在 build/karma.conf 中找到 Karma 的配置文件,位于 github.com/davezuko/react-redux-starter-kit/blob/master/build/karma.conf.js

内容(2016 年 7 月)如下:

import { argv } from 'yargs' 
import config from '../config' 
import webpackConfig from './webpack.config' 
import _debug from 'debug' 

const debug = _debug('app:karma') 
debug('Create configuration.') 

const karmaConfig = { 
  basePath: '../', // project root in relation to bin/karma.js 
  files: [ 
    { 
      pattern: &grave;./${config.dir_test}/test-bundler.js&grave;, 
      watched: false, 
      served: true, 
      included: true 
    } 
  ], 
  singleRun: !argv.watch, 
  frameworks: ['mocha'], 
  reporters: ['mocha'], 
  preprocessors: { 
    [&grave;${config.dir_test}/test-bundler.js&grave;]: ['webpack'] 
  }, 
  browsers: ['PhantomJS'], 
  webpack: { 
    devtool: 'cheap-module-source-map', 
    resolve: { 
      ...webpackConfig.resolve, 
      alias: { 
        ...webpackConfig.resolve.alias, 
        sinon: 'sinon/pkg/sinon.js' 
      } 
    }, 
    plugins: webpackConfig.plugins, 
    module: { 
      noParse: [ 
        //sinon.js/ 
      ], 
      loaders: webpackConfig.module.loaders.concat([ 
        { 
          test: /sinon(|/)pkg(|/)sinon.js/, 
          loader: 'imports?define=>false,require=>false' 
        } 
      ]) 
    }, 
    // Enzyme fix, see: 
    // https://github.com/airbnb/enzyme/issues/47 
    externals: { 
      ...webpackConfig.externals, 
      'react/addons': true, 
      'react/lib/ExecutionEnvironment': true, 
      'react/lib/ReactContext': 'window' 
    }, 
    sassLoader: webpackConfig.sassLoader 
  }, 
  webpackMiddleware: { 
    noInfo: true 
  }, 
  coverageReporter: { 
    reporters: config.coverage_reporters 
  } 
} 

if (config.globals.__COVERAGE__) { 
  karmaConfig.reporters.push('coverage') 
  karmaConfig.webpack.module.preLoaders = [{ 
    test: /.(js|jsx)$/, 
    include: new RegExp(config.dir_client), 
    loader: 'isparta', 
    exclude: /node_modules/ 
  }] 
} 

// cannot use &grave;export default&grave; because of Karma. 
module.exports = (cfg) => cfg.set(karmaConfig)

如你在 karma.conf.js 中所见,他们使用 Mocha(检查带有 "frameworks: ['mocha']" 的行)。配置文件中使用的其余选项在可用的文档中有描述,文档位于 karma-runner.github.io/1.0/config/configuration-file.html。如果你对学习 Karma 配置感兴趣,那么 karma.conf.js 应该是你的起点文件。

Mocha 是什么,为什么你需要它?

在 Karma 配置文件中,我们发现它使用 Mocha 作为 JS 测试框架 (mochajs.org/)。让我们分析代码库。

我们可以在 config/index.js 文件中找到 dir_test : 'tests',因此基于这个变量,Karma 的 config 知道 Mocha 的测试位于 tests/test-bundler.js 文件中。

让我们看看在 https://github.com/davezuko/react-redux-starter-kit/tree/master/teststests 目录里有什么。正如你在 test-bundler.js 文件中看到的,这里有很多依赖项:

// --------------------------------------- 
// Test Environment Setup 
// --------------------------------------- 
import 'babel-polyfill' 
import sinon from 'sinon' 
import chai from 'chai' 
import sinonChai from 'sinon-chai' 
import chaiAsPromised from 'chai-as-promised' 
import chaiEnzyme from 'chai-enzyme' 

chai.use(sinonChai) 
chai.use(chaiAsPromised) 
chai.use(chaiEnzyme()) 

global.chai = chai 
global.sinon = sinon 
global.expect = chai.expect 
global.should = chai.should()

让我们大致描述一下在那里使用的内容:

  • Babel-polyfill 模拟完整的 ES6 环境

  • Sinon 是一个独立的、与测试框架无关的 JavaScript 测试库,用于间谍、模拟和存根。

间谍在测试代码中调用其他外部服务时很有用。你可以检查它是否被调用,它有哪些参数,它是否返回了某些内容,甚至它被调用了多少次!

模拟概念与间谍概念非常相似。最大的区别是模拟会替换目标函数。它们也会用自定义行为(如抛出异常或返回值)替换被调用的代码。它们还能调用作为参数提供的回调函数。模拟代码返回指定结果。

模拟器是一种更智能的存根。模拟器用于断言数据,并且当使用存根仅用于返回数据且不应断言时,不应返回数据。模拟器可以在断言时填充您的测试,而存根则不能。

Chai 是 Node.js 和浏览器的 BDD/TDD 断言框架。在上一个示例中,它已经与 Mocha 测试框架配对。

逐步测试 CoreLayout

让我们分析 CoreLayout.spec.js 测试。此组件在发布应用中的角色类似于 CoreLayout,因此它是描述您如何开始编写应用程序测试的好方法。

核心布局测试文件位置(2016 年 7 月)可在github.com/davezuko/react-redux-starter-kit/blob/master/tests/layouts/CoreLayout.spec.js找到。

内容如下:

import React from 'react' 
import TestUtils from 'react-addons-test-utils' 
import CoreLayout from 'layouts/CoreLayout/CoreLayout' 

function shallowRender (component) { 
  const renderer = TestUtils.createRenderer() 

  renderer.render(component) 
  return renderer.getRenderOutput() 
} 

function shallowRenderWithProps (props = {}) { 
  return shallowRender(<CoreLayout {...props} />) 
} 

describe('(Layout) Core', function () { 
  let _component 
  let _props 
  let _child 

  beforeEach(function () { 
    _child = <h1 className='child'>Child</h1> 
    _props = { 
      children: _child 
    } 

    _component = shallowRenderWithProps(_props) 
  }) 

  it('Should render as a <div>.', function () { 
    expect(_component.type).to.equal('div') 
  }) 
})

react-addons-test-utils 库使得使用 Mocha 测试 React 组件变得容易。我们之前示例中使用的方法是 浅渲染,它可在facebook.github.io/react/docs/test-utils.html#shallow-rendering找到。

此功能帮助我们测试 render 函数,并且是我们组件中渲染一层深度的结果。然后我们可以断言关于其 render 方法返回的内容的事实,如下所示:

function shallowRender (component) { 
  const renderer = TestUtils.createRenderer() 

  renderer.render(component) 
  return renderer.getRenderOutput() 
} 

function shallowRenderWithProps (props = {}) { 
  return shallowRender(<CoreLayout {...props} />) 
}

首先,我们在 shallowRender 方法中提供一个组件(在这个例子中,它将是 CoreLayout)。然后,我们使用 method.render,然后使用 renderer.getRenderOutput 返回输出。

在我们的案例中,该函数在这里被调用(注意以下示例中缺少分号,因为我们描述的入门项目与我们的 linting 选项不同):

describe('(Layout) Core', function () { 
  let _component 
  let _props 
  let _child 

  beforeEach(function () { 
    _child = <h1 className='child'>Child</h1> 
    _props = { 
      children: _child 
    } 

    _component = shallowRenderWithProps(_props) 
  }) 

  it('Should render as a <div>.', function () { 
    expect(_component.type).to.equal('div') 
  }) 
})

您会发现 _component 变量包含 renderer.getRenderOutput 的结果。此值如下断言:

expect(_component.type).to.equal('div')

在那个测试中,我们测试我们的代码是否返回div。但如果您访问文档,您会发现如下代码示例:

<div> 
  <span className="heading">Title</span> 
  <Subcomponent foo="bar" /> 
</div>

您也可以找到如下断言示例:

var renderer = ReactTestUtils.createRenderer(); 
result = renderer.getRenderOutput(); 
expect(result.type).toBe('div'); 
expect(result.props.children).toEqual([ 
  <span className="heading">Title</span>, 
  <Subcomponent foo="bar" /> 
]);

如前两个示例所示,您可以期望类型为 div,或者根据您的需求,可以期望更多关于 CoreLayout 返回的具体信息。

第一个测试断言组件的类型(如果它是 div),第二个示例测试断言 CoreLayout 是否返回正确的组件,如下所示:

[ 
  <span className="heading">Title</span>, 
  <Subcomponent foo="bar" /> 
]

第一个是单元测试,因为这并不是测试用户是否看到了正确的东西。第二个是行为测试。

通常,Packt 有许多关于 行为驱动开发BDD)和 测试驱动开发TDD)的书籍。

与 Travis 的持续集成

在给定的示例中,你可以在github.com/davezuko/react-redux-starter-kit/blob/master/.travis.yml找到.yml文件。

这是一个 Travis 的配置文件。这是什么?这是一个用于构建和测试软件的托管 CI 服务。通常,它是开源项目可以免费使用的工具。如果你想为私有项目使用托管的 Travis CI,那么将适用他们的费用。

如前所述,通过添加.travis.yml文件来配置 Travis。YAML 格式是一个放置在你项目根目录的文本文件。该文件的内容描述了必须执行的所有步骤以测试、安装和构建项目。

Travis CI 的目标是使你 GitHub 账户的每个提交都运行测试,当测试通过时,你可以将应用部署到 Amazon AWS 上的一个预发布服务器。持续集成不在此书的范围之内,所以如果你有兴趣将此步骤添加到整个发布应用项目中,也有相关的书籍。

摘要

我们的发布应用正在运行。就像任何数字项目一样,我们还有很多可以改进的地方,以便得到更好的最终产品。例如,以下作业是给你的:

  • 在前端添加一个 minifaction,这样在通过互联网加载时会更轻量。

  • 如前所述,你需要开始使用 Karma 和 Mocha 进行单元和行为测试。本章详细描述了一个示例设置。

  • 你需要选择一个 CI 工具,如 Travis,创建你的 YML 文件,并在 AWS 上准备环境。

这就是除了书中 350 多页内容所涵盖的所有内容外,你还可以额外做的事情,其中你构建了一个全栈 React + Redux + Falcor + Node + Express + Mongo 应用。我希望与你保持联系;在 Twitter/GitHub 上关注我,以便保持联系,或者如果你有任何额外的问题,请发送电子邮件给我。

祝你在下一个商业全栈应用中动手实践时好运,再次见到你。

posted @ 2025-09-09 11:32  绝不原创的飞龙  阅读(34)  评论(0)    收藏  举报