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

精通 React 全栈 Web 开发(全)

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

译者:飞龙

协议: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,向你展示如何为应用程序添加更高级的功能,如集成的所见即所得编辑器和扩展应用程序的 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

新术语重要词汇以粗体显示。例如,屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的形式出现在文本中:“点击创建链接,使用默认设置创建连接。”

警告或重要说明会出现在这样的框中。提示和技巧会出现在这样的形式中。

第一章:使用 Node.js,Express.js,MongoDB,Mongoose,Falcor 和 Redux 配置全栈

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

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

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

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

  • 安装Node Version ManagerNVM)以便更轻松地管理 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。Mongoose 是一个对象数据建模库,为您的数据提供了严格的建模环境。它强制实施结构,同时也允许您保持 MongoDB 强大的灵活性。

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

通常,我们几乎在任何地方都会使用对象表示法(JSON)--在 React 作为库的情况下,JSON 被广泛用于虚拟 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 安装

您可以在Tutorials部分找到所有 MongoDB 的安装说明。

以下是来自 MongoDB 网站的截图:

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

Robomongo 用于 MongoDB 的 GUI

Robomongo 是一个跨平台的桌面客户端,可用于 SQL 数据库,类似于 MySQL 或 PostgreSQL。

在开发应用程序时,拥有一个图形用户界面并能够快速查看数据库中的集合是很好的。如果您熟悉使用 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 中是如何工作的

  • 减速器的概念和基本用法

让我们从基础知识开始。

单一的不可变状态树

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

Redux 中的所有变化(action)都是显式的,因此你可以通过开发工具跟踪应用程序中所有操作的历史记录。

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

不可变性- action 和状态树都是只读的

由于 Redux 基于函数式编程范式,你不能以与 Facebook(和其他)FLUX 实现相同的方式修改/改变状态树中的值。

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

{ 

    type: 'ADD_ARTICLE', 

    payload: '_____HERE_GOES_INFORMATION_ABOUT_THE_CHANGE_____' 

}

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

纯函数和不纯函数

纯函数是指没有任何副作用的函数,比如,I/O(读取文件或 HTTP 请求)。不纯的函数会产生副作用,比如,如果你发起一个 HTTP 请求,它可能会针对完全相同的参数Y,Z(function(X,Y)返回不同的值,因为端点会返回一个随机值,或者因为服务器错误而宕机。

对于相同的X,Y参数,纯函数总是可预测的。在 Redux 中,我们只在 reducer 和 action 中使用纯函数(否则 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 配置(在此处描述),我们需要dist中的index.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="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 模块的入口在'./src/app.js'。webpack 构建整个应用程序,遵循从app.js导入的所有内容,最终输出位于路径'./dist'。我们的应用程序位于contentBase: './dist',将位于端口3000。我们还配置了 ES2015 和 React 的使用,以便 webpack 将 ES2015 编译为 ES5,并将 React 的 JSX 编译为 JavaScript。如果您对 webpack 的配置选项感兴趣,请阅读其文档。

其余重要依赖项的安装和 npm dev 脚本

安装 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没有^符号,因为我们希望使用每个软件包的确切版本,以确保所有软件包都安装了包中给出的正确和确切的版本。否则,您可能会遇到一些困难,例如,如果您添加"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 中的这个实用程序允许您保持应用程序状态对象,分派动作,并允许您将一个减速器作为参数,告诉您应用程序如何通过动作进行更新。

react-redux是 Redux 与 React 的有用绑定(这样我们将编写更少的代码并且更加高效):

<Provider store>

Provider store帮助我们使 Redux 存储可用于子组件中的connect()调用(如下所示):

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

connect将在任何需要监听我们应用程序中减速器变化的组件中使用。您将在本章后面看到如何使用它。

对于商店,我们使用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> 

    ); 

  }

在上面的代码片段中,我们正在遍历文章 Mock 对象(从 reducer 的状态中传递给 this.props )并使用 articlesJSX.push(currentArticleJSX); 创建文章的数组(在 JSX 中)。创建完成后,我们将把 articlesJSX 添加到 return 语句中:

<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 年及以后编写的应用程序中使用旧的 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;

在这段代码中,你可以找到我们发布应用程序的一个众所周知、简洁、可读的模型,其中包含两篇文章。

现在我们将从前端 Falcor 的模型中获取这些数据,在我们的src/layouts/PublishingApp.js React 组件中,我们将添加一个名为_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覆盖 Promises 意味着我们的代码更易读,并避免了回调地狱的情况,其中嵌套多个回调使代码非常难以阅读和扩展。

async/await功能取自 ECMAScript 7,受 C#启发。它允许您编写函数,这些函数在等待结果之前似乎被阻塞在每个异步操作上,然后才继续进行下一个操作。

在我们的示例中,代码将执行如下:

  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 模型中获取数据之后,我们需要 dispatch 一个 action,该 action 将相应地改变文章的 reducer,并最终重新渲染我们的文章列表,从const articleMock(在src/reducers/article.js中)中获取。

但在我们能够 dispatch 一个 action 之前,我们需要做以下事情:

创建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 中一个重要的规则是 actions 必须是纯函数。现在,我们将在actions/article.js中硬编码一个名为ARTICLES_LIST_ADD的常量。

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

import {bindActionCreators} from 'redux'; 

import articleActions from '../actions/article.js';

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

const mapDispatchToProps = (dispatch) => ({ 

});

添加articleActions: bindActionCreators(articleActions, dispatch),这样我们就能将文章的 actions 绑定到我们的this.props组件中:

const mapDispatchToProps = (dispatch) => ({ 

  articleActions: bindActionCreators(articleActions, dispatch) 

});

由于在我们的组件中进行了上述更改(articleActions: bindActionCreators(articleActions, dispatch)),我们现在能够从 props 中 dispatch 一个 action,因为现在当你使用this.props.articleActions.articlesList(articles)时,从 Falcor 获取的articles对象将在我们的 reducer 中可用(从那里,我们只需一步就能让我们的应用程序获取数据工作)。

现在,在完成这些更改后,在_fetch函数中为我们的组件添加一个 action:

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 中接收一个 action。让我们改进我们的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中删除了它。

我们添加了一个新的 case,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'));)。

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

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;

正如您所看到的,我们已经创建了我们的第一个路线,它将匹配我们在layouts/PublishingApp.js中的_fetch函数中的articles.length

我们在articlesCountInDB中硬编码了数字 2,稍后我们将向数据库发出查询。

这里的新东西是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}PublishingApp.js中吗?)。基于索引([0, 1]),我们通过const singleArticleObject = articlesArrayFromDB[index];获取了模拟数据。稍后,我们将其放入pathindexpath: ['articles', index]),这样 Falcor 就知道singleArticleObject的值属于我们的 JSON 图对象中的哪个路径。

返回文章数组:

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文件中导入时起作用。这是一个用于在应用服务器上创建虚拟 JSON 图文档的DataSource库。正如您在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中,您需要将模拟的数字 2(文章计数)替换为 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; 

    }) 

  } 

}

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

文章数组在}).then((articlesArrayFromDB) => {中返回,然后我们简单地迭代并创建一个结果数组。

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

与服务器/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. 所有这些步骤都允许我们从数据库中获取所有全栈应用程序的数据,传输到用户的浏览器(用户可以看到一篇文章)。

总结

我们还没有开始设计应用程序,但在我们的书中,我们将使用 Material Design CSS for React(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 flag)。您将在本章中学习如何使用它。

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

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

流程如下:

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

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

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

在我们的情况下,资源是 falcor-router 的路由,它与后端有密切关系,但在更分布式的平台上也可以工作。

请记住,JWT 令牌类似于私钥--您必须保护它们!

JWT 令牌的结构

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

{ 

'typ': 'JWT', 

'alg': 'HS256' 

}

总的来说,这部分完全是为我们准备好的,所以在实现时我们不必关心头部。

第二部分包括以 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 的登录(后端)

为了使我们的代码库更有组织性,我们将创建一个名为routesSession.js的新文件,而不是将一个路由添加到我们的server/routes.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文件中创建了一个初始调用登录路由。我们将使用'call'(**call: async (callPath, args) => **)而不是使用 GET 方法。这相当于旧的 RESTful 方法中的 POST。

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

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

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

然后我们将使用一个用户管理员来检查它们是否与我们的数据库匹配。用户需要知道真实的明文密码是123456才能获得正确的登录 JWT 令牌:

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

const userStatementQuery = { 

  $and: [ 

      { 'username': username }, 

      { 'password': password } 

  ] 

}

分离 DB 配置 - configMongoose.js

我们需要将 DB 配置从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 模型。用户是我们 Mongoose 模型,指向我们 MongoDB 中的pubUsers集合。最后,通过将其添加到默认导出对象中,我们导出了User模型。

routesSession.js文件中实现 JWT

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

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 组件

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 组件

目前,我们将创建一个模拟的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。为什么我们需要它?因为在更大的应用程序中,您总是会有许多不同的 reducer;例如,在我们的应用程序中,我们将有诸如以下的 reducer:

  • 文章的 reducer:保留与文章相关的内容(返回所有文章等)

  • 会话的 reducer:与我们用户的会话相关(登录注册等)

  • 编辑器的 reducer:与编辑器的操作相关(编辑文章删除文章添加新文章等)

  • 路由的 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,它将管理我们的 store,并且为了在本书后面实现服务器渲染:

$ 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,您可以在reducer/index.js中找到),并且能够处理服务器渲染的初始状态。

在运行应用程序之前,对 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 的 props 发送到 Root 组件。***export const store = configureStore(window.__INITIAL_STATE__)*** 部分是为了服务器端渲染,我们将在接下来的章节中添加。我们还使用 history 库来管理浏览器的历史记录。

我们运行应用程序的屏幕截图

当前,当您执行 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 库是一个非常方便的库,它将帮助我们验证发布应用程序中的表单。我们将在登录和注册等页面上使用它,您将在接下来的页面上看到。

工作在 LoginForm 和 DefaultInput 组件上

在安装新依赖项后,让我们创建一个文件夹,用于保存与哑组件相关的文件(这些组件没有访问任何存储库;它们通过回调与我们应用程序的其他部分进行通信---您以后会更多了解这一点):

$ 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)通过export default 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并在浏览器中运行后,您将看到以下内容:

正如您在浏览器控制台中所看到的,我们可以看到提交的凭据对象(凭据对象{用户名:"admin",密码:"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})更新错误状态。

下一步是在render函数中添加Snackbar,它将向用户显示一种错误类型。在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,否则您将从 Material UI 的开发者控制台中收到警告。好的,我们正在处理登录错误,现在让我们处理成功的登录。

在 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的示例--不要在你的代码中写入它,我们马上就会做)。

再多看几个例子,你就会习惯的。

前端实现(RegisterView 和 RegisterForm)

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

$ 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完全相同的表单。用户点击Register按钮后,它会发送一个回调到src/views/RegisterView.js组件(我们马上就会创建这个)。

请记住,在组件目录中,我们只保留 DUMB 组件,因此与应用程序的其余部分的所有通信必须通过回调来完成,就像这个例子中一样。

RegisterView

让我们创建一个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 存储/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函数,所以在constructorrender函数之间添加函数,如下所示:

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 单页面应用程序不同。我们的方法在技术上更加复杂,因为您需要部署在服务器端渲染上工作的全栈技能,但拥有这种经验将使您成为一个更受欢迎的程序员,因此您可以将自己的职业发展到下一个水平--您将能够在市场上更高价地出售您的技能。

何时值得实施服务器端

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

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

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

  • 开始使用 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 文档中,我们稍后会看到

handleServerSideRender函数

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

我们将使用Provider组件,该组件已经在我们客户端应用程序中使用,将包装Root组件。这将使存储可用于我们所有的组件。

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

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

在我们开始处理两个新功能(handleServerSideRenderrenderFullHtml)之前,请在server.js中进行以下替换:

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

用以下替换:

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

这就是我们dist项目中的所有内容。它将作为静态文件在本地主机地址(http://localhost:3000/static/app.js*)下可用。这将帮助我们在初始服务器端渲染后创建单页面应用程序。

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

在完成express.static的前述工作后,让我们将这个函数做得更完整:

let renderFullHtml = (html, initialState) => 

{ 

  return; // this is already in your codebase 

}; 

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

let renderFullPage = (html, initialState) => 

{ 

  return &grave; 

<!doctype html> 

<html> 

<head> 

<title>Publishing App Server Side Rendering</title> 

</head> 

<body> 

<h1>Server side publishing app</h1> 

<div id="publishingAppRoot">${html}</div> 

<script> 

window.__INITIAL_STATE__ = ${JSON.stringify(initialState)} 

</script> 

<script src="/static/app.js"></script> 

</body> 

</html> 

    &grave; 

}; 

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

${html}

请注意,我们使用 ES6 模板(Google ES6 模板文字)语法与&grave;

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

好的,接下来让我们专注于handleServerSideRender函数,通过以下替换:

let handleServerSideRender = (req, res) => 

{ 

  return; 

}; 

用以下更完整版本的函数替换:

let handleServerSideRender = (req, res, next) => { 

  try { 

    let initMOCKstore = fetchServerSide(); // mocked for now 

    // Create a new Redux store instance 

    const store = createStore(rootReducer, initMOCKstore); 

    const location = hist.createLocation(req.path); 

    match({ 

      routes: reactRoutes, 

      location: location, 

    }, (err, redirectLocation, renderProps) => { 

      if (redirectLocation) { 

        res.redirect(301, redirectLocation.pathname + 

        redirectLocation.search); 

      } else if (err) { 

        console.log(err); 

        next(err); 

        // res.send(500, error.message); 

      } else if (renderProps === null) { 

        res.status(404) 

        .send('Not found'); 

      } else { 

      if  (typeofrenderProps === 'undefined') { 

        // using handleServerSideRender middleware not required; 

        // we are not requesting HTML (probably an app.js or other 

        file) 

        return; 

      } 

        let html = renderToStaticMarkup( 

          <Provider store={store}> 

          <RoutingContext {...renderProps}/> 

          </Provider> 

        ); 

        const initialState = store.getState() 

        let fullHTML = renderFullPage(html, initialState); 

        res.send(fullHTML); 

      } 

    }); 

  } catch (err) { 

      next(err) 

  } 

} 

let initMOCKstore = fetchServerSide();表达式正在从 MongoDB 获取数据(目前是模拟的,以后会改进)。接下来,我们使用store = createStore(rootReducer, initMOCKstore)创建了服务器端的 Redux 存储。我们还需要准备一个正确的位置,供 react-router 在服务器端使用,使用location = hist.createLocation(req.path)(在req.path中有一个简单的路径,就在浏览器中;/register/login或简单地主页/)。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()获取的存储的 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="/static/app.js"></script> 

</body> 

</html> 

&grave; 

}; 

app.use(handleServerSideRender); 

app.server.listen(process.env.PORT || 3000); 

console.log(&grave;Started on port ${app.server.address().port}&grave;); 

export default app; 

在这里,您拥有了后端服务器端渲染所需的一切。让我们继续进行前端改进。

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

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

import React from 'react'; 

import { Link } from 'react-router'; 

import themeDecorator from 'material-ui/lib/styles/theme- 

 decorator'; 

import getMuiTheme from 'material-ui/lib/styles/getMuiTheme'; 

class CoreLayout extends React.Component { 

  static propTypes = { 

    children :React.PropTypes.element 

  } 

从上述代码中,要添加的新内容是:

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

import getMuiTheme from 'material-ui/lib/styles/getMuiTheme'; 

除此之外,改进render函数并导出default到:

  render () { 

    return ( 

<div> 

<span> 

    Links:   <Link to='/register'>Register</Link> |  

      <Link to='/login'>Login</Link> |  

      <Link to='/'>Home Page</Link> 

</span> 

<br/> 

   {this.props.children} 

</div> 

    ); 

  } 

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

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

我们还需要改进发布应用程序组件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,并使用req.path(在我们的情况下,当访问http://localhost:3000/register时,req.path等于/register),我们在handleServerSideRender函数中使用它。

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

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中添加所见即所得的编辑器,这是由 Facebook 团队为 React 创建的富文本编辑框架

  • 在我们的 Redux 前端应用程序中添加创建新文章的功能

前端改进之前的后端总结

在上一章中,我们进行了服务器端渲染,这将影响我们的用户,使他们能更快地看到他们的文章,并且将改善我们网站的 SEO,因为整个 HTML 标记都是在服务器端渲染的。

让我们的服务器端渲染百分之百生效的最后一件事是取消在/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的 promise(find函数来自 Mongoose)。你还可以发现我们正在返回从我们的 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信息中删除。正如您所看到的,它还检查是否有localStorage**if(typeof localStorage !== 'undefined' && localStorage.token)**,因为您可以想象,当执行服务器端渲染时,localStorage是未定义的(服务器端没有localStoragewindow,像客户端一样)。

在创建前端添加文章功能之前的重要说明

我们已经到了需要从文章集合中删除所有文档的地步,否则在执行下一步时可能会遇到一些麻烦,因为我们将使用 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 的朋友们制作的,它帮助我们制作强大的所见即所得工具。在我们的发布应用程序中,它将是有用的,因为我们希望为我们的编辑提供良好的工具,以便在我们的平台上创建有趣的文章。

让我们先安装它:

npm i --save draft-js@0.5.0

我们将在我们的书中使用 0.5.0 版本的 draft-js。在开始编码之前,让我们安装另一个有用的依赖项,以后在通过 Falcor 从数据库中获取文章时会有帮助。执行以下命令:

npm i --save falcor-json-graph@1.1.7

总的来说,falcor-json-graph@1.1.7语法为我们提供了使用 Falcor 辅助库提供的不同 sentinels 的能力(这将在下一章节中详细描述)。

draft-js 所需的样式表

为了给 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中,我们已经在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="/static/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文件。它目前是模拟的,但我们现在将对其进行改进。

只是让您知道,我们现在将制作所见即所得的骨架。我们稍后会在书中进行改进。在这一点上,所见即所得不会有任何功能,比如加粗文本或创建带有 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 文件的所见即所得编辑器的构造函数。let initialEditorFromProps = EditorState.createWithContent(ContentState.createFromText(''));表达式只是创建了一个空的所见即所得容器。稍后,我们将改进它,以便在需要编辑所见即所得时能够从数据库接收ContentState

editorState: initialEditorFromProps是我们当前的状态。我们的**this.onChange = (editorState) => { **行在每次更改时触发,因此我们的视图组件在src/views/articles/AddArticleView.js将了解所见即所得中的任何更改。

无论如何,您可以在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 组件

在继续添加所有所见即所得功能,比如加粗之前,我们需要改进views/articles/AddArticleView.js组件,添加一些东西。使用以下内容安装一个将 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位置创建 WYSWIG 按钮:

$ [[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 WYSIWYG 中创建它。它用在以下组件中:

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'} 

];

正如您所看到的,在我们的 WYSIWYG 中,编辑器将能够使用粗体、斜体和下划线。

用于放置所有这些内联样式的最后一个组件是以下内容:

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

下一步是在我们的WYSIWYGeditor组件(src/components/articles/WYSIWYGeditor.js)中导入InlineStyleControlsBlockStyleControls

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-jsRichUtils来在我们的 WYSIWYG 中设置标志。我们正在使用我们在import { BlockStyleControls, InlineStyleControls }中定义的BLOCK_TYPESINLINE_STYLES中的某些格式选项,从'./wysiwg/WYSIWGbuttons'导入。

在我们改进了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-jsRichUtils之间进行通信,告诉它用户点击了什么,以及他们当前使用的模式(如粗体、标题 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}变量(它在 content 的变量中接收了一个纯文本值)替换为一个新的变量,它使用dangerouslySetInnerHTML在文章卡中显示所有的 HTML:

<div dangerouslySetInnerHTML={{__html: content}} />

这将帮助我们展示由我们的所见即所得编辑器生成的 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 的 Map 来替换这种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 如何标记我们的 DB 中的 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组件中所需的所有导入:

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-wise 方法来使事情变得更简单。

改进 PublishingApp 和 DashboardView

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中导入。

通过替换这段代码,在AddArticleView组件中使用bindActionCreators来拥有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')。

第二个返回是在编辑模式下;如果是,那么他可以通过点击RaisedButton组件并调用onClick方法的_articleSubmit来提交。

编辑文章的能力(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将作为this.props.params.articleID的 props 发送给我们(这是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元素一个唯一的 key 属性。

创建一个新的操作和减速器

修改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中的减速器:

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 case。我们使用我们的mapHelpers.addItem;一般来说,如果_id存在于 Map 中,那么它将替换一个值(这对编辑操作非常有效)。

在 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语句中检查它是否是一个对象。然后,如果不是,我们将空的所见即所得作为默认值(检查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函数来恢复我们的所见即所得编辑器的状态。

在 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 中用于正确更新文章的 reducer 中的文章。

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 开关:

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组件:

在点击提交文章按钮后的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 的 sentinels 以及$ref$atom$error在 Falcor 中的工作原理

  • JSON 图是什么以及它是如何工作的

  • Falcor 中虚拟 JSON 概念是什么

Falcor 旨在解决的问题

在单页面应用程序时代之前,客户端获取数据并没有问题,因为所有数据都是在服务器上获取的,即使是在那时,服务器也会将 HTML 标记发送到客户端。每当有人点击 URL(href)时,我们的浏览器会从服务器请求全新的 HTML 标记。

基于非 SPA 应用程序的前述原则,Ruby on Rails 成为了 Web 开发技术栈的王者,但后来情况发生了变化。自 2009-2010 年以来,我们一直在创建越来越多的 JavaScript 客户端应用程序,这些应用程序更有可能从后端获取一次,例如一个bundle.js文件。它们被称为 SPA。

由于 SP 应用程序的趋势,出现了一些新问题,这些问题对于非 SP 应用程序的开发人员来说是未知的,比如从后端的 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 个月的时间来掌握一项技术时,你可能会在像 Facebook 这样预算更大的大公司中找到学习 Relay/GraphQL 的机会。

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 中的新数据类型称为sentinel。我将在本章稍后尝试解释它。

一般来说,在 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; 

在上一个代码片段中我们做了什么?ECMAScript6 中的extends关键字展示了类语法的简洁性的一个例子。扩展FalcorDataSource意味着PublishingAppDataSource继承了FalcorDataSource的所有内容,并且它使onBeforeRequest方法具有了我们自定义的行为(通过改变config.headers)。onBeforeRequest方法在我们创建xhr实例之前检查了我们改变的配置。这有助于我们通过令牌、用户名和角色修改XMLHttpRequest,以防我们应用的用户在此期间注销,我们可以将该信息发送到后端。

在你在falcorModel.js中实现了之前的代码并且用户已登录后,这些变量将被添加到每个请求中:

改进 server.js 和 routes.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,然后我们检查正确的authToken是什么,使用let 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在后端实现添加/编辑/删除之前,我们需要介绍一下 sentinels 的概念,因为这对我们全栈应用的健康非常重要,稍后将会解释原因。

Falcor 的 sentinel 实现

让我们了解一下 sentinels 是什么。它们是使 Fullstack 的 Falcor 应用程序工作所必需的。这是一套你必须学会的工具。

它们是专门用于使后端和客户端之间的数据传输更加简单和开箱即用的新原始值类型(新的 Falcor 原始值的示例是$error$ref)。这里有一个类比:在常规 JSON 中,你有字符串、数字和对象等类型。另一方面,在 Falcor 的虚拟 JSON 中,你还可以使用$error$ref$atom等 sentinel,以及之前列出的标准 JSON 类型。

有关 sentinels 的更多信息,请参阅netflix.github.io/falcor/documentation/model.html#sentinel-metadata

在这个阶段,理解 Falcor 的 sentinels 是如何工作的很重要。Falcor 中不同类型的 sentinel 在以下部分有解释。

$ref sentinel

根据文档,“引用是一个具有$type键的 JSON 对象,其值为ref,以及一个value键,其值为Path数组。”

正如文档所述,“引用就像 UNIX 文件系统中的符号链接”,这个比喻非常好。

$ref的一个例子如下:

{ $type: 'ref', value: ['articlesById', 'STRING_ARTICLE_ID_HERE'] } 

如果你使用$ref(['articlesById','STRING_ARTCILE_ID_HERE']),它等同于前面的例子。$ref sentinel 是一个函数,它将数组的细节转换成$type和值的表示对象。

你可以在任何与 Falcor 相关的项目中找到部署/使用$ref的两种方法,但在我们的项目中,我们将坚持使用$ref(['articlesById','STRING_ARTCILE_ID_HERE'])的约定。

为了明确起见,这是如何在我们的代码库中导入$ref sentinel 的方法:

// 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 sentinel。你已经安装了falcor-json-graph库,因为安装已经在前一章中描述过;如果没有,请使用以下命令:

npm i --save falcor-json-graph@1.1.7

但是在整个$ref中,articlesById是什么意思?在前面的例子中,STRING_ARTICLE_ID_HERE又是什么意思?让我们看一个来自我们项目的例子,这可能会让你更清楚。

$ref sentinel 的详细示例

假设我们的 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 改进我们文章的喜欢数量

让我们将我们的例子改进为以下内容:

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 将自行遵循$ref哨兵,知道路由名称(在这种情况下是articlesById路由)和我们正在寻找的对象的 ID(在我们的例子中是123456987654)。我们将在实践中使用它。

要理解的是,这是它的简化版本的工作方式,但为了理解$ref,最好的类比是 UNIX 的符号链接。

在我们项目中实际使用$ref

好的,这是很多理论--让我们开始编码吧!我们将改进我们的 Mongoose 模型。

然后我们将之前描述的$ref哨兵添加到server/routes.js文件中:

// example of ref, don't write it yet: 

let articleRef = $ref(['articlesById', currentMongoID]); 

我们还将添加两个 Falcor 路由,articlesByIdarticles.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,我们在数据库中会得到不完整的对象(在这里设置这个标志是非常重要的一步)。有一些 draft-js 对象是必需的,但默认情况下是空的(特别是 draft-js 对象的entityMap属性)。

服务器/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; 

在前面的代码片段中,唯一新的东西是我们从'falcor-json-graph';导入jsonGraph,然后添加let $ref = jsonGraph.ref;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 来说,返回一个对象就更棘手了。为什么呢?

Falcor 在大量使用 JavaScript 的对象和数组进行差异比较,当我们告诉一个对象/数组被$atom标记包裹(例如在我们的例子中$atom(articleResObj.articleContentJSON)),那么 Falcor 就知道不应该深入到这个数组/对象中。出于性能原因,它是这样设计的。

性能原因是什么?例如,如果你返回一个包含 10,000 个非常深的对象的数组而没有包裹这个数组,构建和比较模型可能需要非常非常长的时间。一般来说,出于性能原因,任何你想通过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]); 

正如你所看到的,通过这样做,我们正在通知(使用$reffalcor-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 };)。最后一件事是将新文章 ID 和计数数字从后端返回到前端,借助 falcor-router 的帮助,因此我们替换这个注释:

// 

// 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 的详细信息。接下来,我们用 newArticleID 获取新的 ID,并确保它是一个字符串。在所有这些之后,我们用 let NewArticleRef = $ref(['articlesById', newArticleID]); 定义了一个新的 $ref 哨兵。

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关键字(async _articleSubmit())。新的东西是这个请求:

// 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将更新的newArticle与来自 MongoDB 的真实 ID 推送到文章的 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 

          } 

        ]; 

      }); 

    } 

  }, 

正如你在这里所看到的,我们仍然使用article.save方法,类似于articles.add路由。需要注意的重要事情是,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

删除文章

为了实现delete功能,我们需要创建一个新的路由:

 { 

  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,但这次,这是我们在这里返回的唯一内容,因为文档已被删除,所以我们需要做的唯一事情就是通知浏览器缓存旧文章已被作废,没有任何需要替换的内容,就像更新示例中一样。

前端-编辑和删除

我们已经在后端实现了updatedelete路由。接下来,在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构造函数,将其更改为在基本情况下使用导出的名为errorFuncUtil的函数作为回调,以处理 Falcor$error哨兵返回的错误:

// 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和以下内容:

  <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 sentinel 时都会运行它。

有关错误选择器的更多详细信息,请访问netflix.github.io/falcor/documentation/model.html#the-errorselector-value

$error sentinel 的后端实现

我们将分两步执行后端实现:

  1. 一个错误示例,只是为了测试我们的客户端代码。

  2. 在我们确保错误处理工作正常之后,我们将适当地保护端点。

测试我们与$error 相关的代码

让我们从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标记。

我们的$error测试的目标是替换一个负责获取文章的工作路由(articles[{integers}])。在我们破坏这个路由之后,我们将能够测试我们的前端和后端设置是否正常工作。在我们测试错误之后(参考下一个截图),我们将从articles[{integers}]中删除这个破坏性的$error代码。继续阅读以获取详细信息。

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 秒内看到这个错误:

正如你所看到的,在窗口底部有黑色背景上的白色文本:

如果你运行应用,在主页上看到与截图上相同的错误消息,那么它告诉你一切都很好!

在成功测试后清理$error

当你确信错误处理对你起作用时,你可以替换旧代码:

 { 

    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 === falserole !== '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,以便能够上传文章封面照片。除此之外,我们还将通过新功能来一般改进我们的发布应用程序。

读累了记得休息一会哦~

公众号:古德猫宁李

  • 电子书搜索下载

  • 书单分享

  • 书友学习交流

网站:沉金书屋 https://www.chenjin5.com

  • 电子书搜索下载

  • 电子书打包资源分享

  • 学习资源分享

第六章:AWS S3 用于图像上传和关键应用程序功能的封装

目前我们有一个可以工作但缺少一些关键功能的应用程序。本章我们的重点将包括以下功能的实现/改进:

  • 打开一个新的 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 中,什么是存储桶?存储桶是 Amazon 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 的 UX 在此期间已经更改,屏幕截图也会给您确切的步骤):

单击“创建”按钮后,将密钥复制到安全位置(我们将在稍后使用它们):

不要忘记复制密钥(访问密钥 ID 和秘密访问密钥)。您将在本书后面学习在代码中放置它们以后使用 S3 服务。当然,屏幕截图中的密钥是不活跃的。它们只是示例;您需要拥有自己的密钥。

为用户设置 S3 权限

最后一件事是使用以下步骤添加 AmazonS3FullAccess 权限:

  1. 转到权限选项卡:

  1. 单击附加策略,选择 AmazonS3FullAccess。附加后,它将列在以下示例中:

现在我们将继续创建一个新的存储桶用于图像文件。

  1. 您已经完成了密钥,并且已经为密钥授予了 S3 策略;现在,我们需要准备将保存图像的存储桶。首先,您需要转到 AWS 控制台的主页,如下所示(console.aws.amazon.com/console/home):

  1. 您将看到类似 AWS 服务显示所有服务的东西(或者,从服务列表中找到它,就像 IAM 一样):

  1. 单击 S3 - 云中的可扩展存储(如上一截图中所示)。之后,您将看到类似于此的视图(我有六个存储桶;当您有一个新帐户时,您将看到零个):

在那个存储桶中,我们将保存文章的静态图像(您将在接下来的页面中学习确切的方法)。

  1. 通过单击“创建存储桶”按钮来创建存储桶:

  1. 选择 publishing-app 名称(或其他适合您的名称)。

在截图中,我们选择了 Frankfurt。但是,例如,当您创建帐户并且您的 URL 显示"?region=us-west-2"时,请选择 Oregon。在分配帐户时,重要的是在正确的区域创建 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 xmlns="http://s3.amazonaws.com 

         /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 中的环境变量

我们将通过节点的环境变量传递所有四个参数(AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_BUCKET_NAMEAWS_REGION_NAME)。

首先,让我们安装一个节点库,它将从文件中创建环境变量,以便我们能够在本地主机中使用它们:

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,并且可以使用该回调找到百分比的进度,以便用户可以查看uploadonError的状态。当发生错误时,将触发此回调:当完成时,此回调将向我们发送已上传到 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函数中用div初始化了ImgUploader组件,返回一个临时占位符。

我们还准备了一个带有必需属性updateImgUrlpropTypes。这将是一个回调函数,将发送最终上传的图片位置(必须保存在数据库中--我们将在稍后使用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获取uploadProgress的信息(第一个if语句)。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 

    }); 

  }

正如你所看到的,我们在 newArticle 对象中添加了 articlePicUrl: this.state.articlePicUrl。我们还引入了一个名为 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 ,你将会看到一个新的图片选择器,位于所见即所得框和提交文章按钮之间(查看截图):

当然,如果你按照所有的说明正确操作,点击“选择文件”后,你将能够上传一个新的图片到 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在用户没有在所见即所得编辑器中编辑任何内容时会用到,我们需要从数据库中获取默认值(否则,编辑按钮会将空值保存到数据库中并破坏整个编辑体验)。

让我们改进_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,方式与LoginView中一样,所以我不会详细描述它。最重要的是要注意,通过onSubmit,我们调用了this._articleSubmit函数。我们还添加了两个DefaultInput组件(标题和副标题):这两个输入框中的数据将在async _articleSubmit(articleModel)中使用(根据本书中先前的实现,您已经知道这一点)。

根据 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,以便我们能够输入标题和副标题,并将它们作为回调发送到articleModel中的_articleEditSubmit函数。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; 

      });

正如您所看到的,我们已经开始使用falcorModel.get来获取articleSubTitle属性。

当然,我们需要将这个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> 

      );

在主页上所有这些更改之后,你可以找到一个编辑过的文章,包括标题、副标题、封面照片和内容(由我们的所见即所得编辑器创建):

仪表板改进(现在我们可以剥离剩余的 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 标记,就像这个例子中一样:

总结

我们已经实现了书中涵盖的所有功能。下一步是开始着手部署这个应用程序。

如果你想提高编码技能,最好自己完全实现一些功能。以下是一些我们发布应用程序中仍然缺少的功能的想法。

我们可以有一个单独的链接指向某篇文章,这样你可以与朋友分享。如果你想在数据库中创建一个与某篇文章相关的易读唯一标识,这可能会很有用。因此,用户可以分享类似于reactjs.space/an-article-about-a-dog这样的链接,而不是链接到类似于reactjs.space/570b6e26ae357d391c6ebc1dreactjs.space是我们在生产服务器上将使用的域名)。

可能有一种方法将一篇文章与发布它的编辑关联起来。目前是模拟的。你可以取消模拟。

用户在登录状态下无法更改其用户详细信息--这可能是练习更全面的全栈开发的好方法。

用户无法设置他们的头像图片--你可以以类似的方式添加这个功能,就像我们实现封面图片一样。

创建一个更强大的 Draft.JS 所见即所得编辑器与插件。强大的插件易于实现提及、贴纸、表情符号、标签、撤销/重做等功能。访问www.draft-js-plugins.com/了解更多详情。实现你最喜欢的一个或两个。

在下一章中,我们将开始使用www.mLab.com在线部署我们的 MongoDB 实例,这是一个作为服务提供商,可以帮助我们轻松构建可扩展的 MongoDB 节点。

让我们开始部署的乐趣吧!

不要忘记休息一下哦~

公众号:古德猫宁李

  • 电子书搜索下载

  • 书单分享

  • 书友学习交流

网站:沉金书屋 https://www.chenjin5.com

  • 电子书搜索下载

  • 电子书打包资源分享

  • 学习资源分享

第七章: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 副本集的自动故障转移工作原理。正如我们在前面的截图中所看到的,有三个节点:副本的flipflop,以及一个仲裁者。在 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. 让我们假设在本书中我们的详细信息如下:

DB 用户名:usermlab

DB 密码:pwdmlab

在我们将使用用户名和密码的地方,我将使用这些详细信息。

  1. 之后,我们需要创建与我们的本地 MongoDB 相同的集合:

集合 | 添加集合 | 文章集合 | 添加集合 | 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 在前面的屏幕截图中提供的)。

所有 Mongoenv变量都可以从 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 实例上远程工作,因此您不再需要运行mongod命令。

现在是时候准备我们的 Docker 容器,并完全使用 ECS(EC2 容器服务)和负载均衡器在 EC2 上部署它了。

什么是 Docker?它是一款非常有用的开源软件,可以帮助您将任何应用程序打包、运输和作为一个轻量级容器运行(例如,与虚拟机相比)。

容器的目标与虚拟机类似--最大的区别在于 Docker 是针对软件开发而创建的,而不是虚拟机。您还需要知道,完全虚拟化的系统有自己分配的资源,这会导致最小的资源共享,这与 Docker 容器不同。当然,在虚拟机中,您会获得更多的隔离,但代价是虚拟机更加沉重(需要更多的磁盘空间、RAM 和其他资源)。Docker 的容器轻巧,并且能够在不同容器之间共享更多的东西,与虚拟机相比。

好处是 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 机器,则还需要为 Windows 或 OS X 安装 Docker Toolbox。这很简单,其安装程序可在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 就像 GitHub 对 Git 存储库一样对应 Docker 容器。您可以在 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。Dockerfile 的类比可以是 Bash 语言,您可以在任何 Linux/Unix 机器上使用。当然,它是不同的,但编写指令以创建作业的一般思想是相似的。

修改我们的代码库以创建它

目前,我们确信我们的 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);

正如你所看到的,我们已经添加了连接特定 DB 用户的能力。我们需要它,因为我们之前使用的本地主机不需要任何用户,但是当我们开始使用 mLab MongoDB 时,指定我们数据库的用户是必须的。否则,我们将无法正确进行身份验证。

从这一点开始,您不需要在系统的后台运行mongod进程,因为应用程序将连接到您在上一章中创建的 mLab MongoDB 节点。mLab MongoDB(免费版本)全天候运行,但如果您计划将其用于生产就绪的应用程序,那么您需要更新它并开始使用副本集功能(这在上一章中提到过)。

您可以尝试使用以下命令运行项目:

npm start

然后您应该能够加载应用程序:

现在的重要区别是所有 CRUD 操作(通过我们的发布应用程序进行读/写)都是在我们的远程 MongoDB 上进行的(而不是在本地的 MongoDB 上)。

发布应用程序使用 mLab MongoDB 后,我们准备准备我们的 Docker 镜像,然后在几个 AWS EC2 实例上部署它,使用 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可以在您的本地机器上删除 repo,以防您想要删除它:
 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

两章前,我们为静态图像上传实现了 Amazon 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

然后点击蓝色的启动实例按钮:

按钮看起来像这样:

点击按钮后,您将被重定向到Amazon Machine Image (AMI )页面:

AMI 有一个可以运行 EC2 实例的镜像列表。每个镜像都有一个预安装软件列表。例如,最标准的镜像如下:

它有预安装的软件;例如,Amazon Linux AMI 是一个 EBS 支持的、由 AWS 支持的镜像。默认镜像包括 AWS 命令行工具、Python、Ruby、Perl 和 Java。存储库包括 Docker、PHP、MySQL、PostgreSQL 和其他软件包。

在同一页上,您还可以在市场上购买其他 AMI,或者由社区创建和共享的免费 AMI。您还可以过滤图像,以便只列出免费层:

为了使这个分步指南简单,让我们选择前面屏幕截图中的镜像;它的名称将类似于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 作为新规则后,您可以点击“审查和启动”按钮:

然后,在您满意地审查实例后,在该视图中点击名为“Launch”的蓝色按钮:

点击“启动”按钮后,您将看到一个模态框,上面写着“选择现有密钥对或创建新的密钥对”:

通常,您需要创建一个新的密钥对。给它命名为pubapp-ec2-key-pair,然后点击下载按钮,如下面的屏幕截图所示:

在您下载了pubapp-ec2-key-pai之后,您将能够点击蓝色的“启动”按钮。接下来,您将看到以下内容:

从这个屏幕,您可以直接转到 EC2 启动日志(点击“查看启动日志”链接),这样您就能够找到您的实例列在其中,如下面的屏幕截图所示:

太好了。你的第一个 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

点击“加载”按钮并选择pubapp-ec2-key-pair.pem文件,然后将其转换为ppk

然后你需要点击“保存私钥”按钮。你完成了;你可以关闭puttygen.exe并打开pageant.exe。从中,做以下操作:

  • 选择添加密钥

  • 然后检查你的密钥是否已正确添加到 Pageant 密钥列表

如果你的私钥在列表中,你就可以使用putty.exe了。

如果你已经打开了 PuTTy 程序,你需要通过输入你的 EC2 实例 IP 并点击“打开”按钮来通过 SSH 登录,就像前面的截图所示。PuTTy 允许在 Windows 上使用 SSH 连接。

通过 SSH 连接到 EC2 实例

在之前的一章中,我们启动了 EC2 实例后,发现了我们的公共 IP(记住你的公共 IP 会不同):52.29.107.244。我们需要使用这个公共 IP 连接到远程 EC2 实例。

我已经把pubapp-ec2-key-pair.pem保存在我的Downloads目录中,所以去到你下载.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 示例中一样。

成功登录后,你将能够看到这个:

以下说明适用于所有操作系统用户(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命令后,它将显示类似以下输出:

如果您查看前面的截图,您会看到一切都很好,我们可以继续使用以下命令运行发布应用程序的 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)后,您将能够在线找到我们的发布应用程序,因为我们已经将 EC2 实例的安全组设置为向世界公开端口80。以下是截图:

如果您在公共 IP 下看到我们的发布应用程序,那么您刚刚成功在 Amazon AWS EC2 上部署了一个 Docker 容器!

我们刚刚经历的过程非常低效和手动,但确切地显示了在我们开始使用 ECS 时底层发生了什么。

我们当前的方法中缺少以下内容:

  • 与其他亚马逊服务集成,如负载平衡、监控、警报、崩溃恢复和路由 53。

  • 自动化,因为目前我们无法快速有效地部署 10 个 Docker 容器。如果您想为不同的服务部署不同的 Docker 容器,这也很重要,例如,您可以为前端、后端甚至数据库(在我们的情况下,我们使用 mLab,所以这里不需要)设置单独的容器。

您刚刚学会了亚马逊网络服务的基础知识。

ECS 的基础知识 - AWS EC2

EC2 容器服务帮助您创建一组 Docker 容器实例(在多个 EC2 实例上有多个相同容器的副本)。每个容器都会自动部署--这意味着您无需像我们在上一章中那样登录任何 EC2 实例进行 SSH(手动方法)。整个工作由 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

对于 Image,我们使用与在本地运行docker run时相同的参数。在przeor/pub-app-docker的情况下,ECS 将知道它必须从hub.docker.com/r/przeor/pub-app-docker/下载容器。

目前,让我们将最大内存保持在默认值(300)上。将两个端口映射设置为80

在撰写本书时,如果您的容器不公开端口80,可能会出现一些问题。这可能是 ECS 向导的一个错误;没有向导,容器上可以使用任何端口。

在任务定义视图中点击高级选项:

您将看到一个带有附加选项的滑动面板。

我们需要指定以下内容:

  • 命令:这必须用逗号分隔,所以我们使用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 上创建一个托管区,因此点击名为 Create Hosted Zone 的蓝色按钮:

之后,您将不会看到任何托管区,因此再次点击蓝色按钮:

表格将有一个域名字段,在那里您输入您的域名(在我们的情况下,是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域名的别名。点击以下按钮:

然后,您将看到以下视图:

从别名的单选按钮中选择 Yes,然后从列表中选择 ELB,如下例所示:

在 DNS 更改完成后(可能需要最多 48 小时),一切都将正常工作。为了改善我们应用的体验,让我们也将www.reactjs.space的别名指向reactjs.space,这样如果有人在域名前输入www.,它将按预期工作。

再次点击名为 Create Record Set 的按钮,选择一个别名,并输入www,之后您将能够选择www.reactjs.space域名。这样做并点击 Create 按钮:

总结

我们已经完成了所有的 AWS/Docker 设置。成功更改 DNS 后,您将能够在http://reactjs.space地址下找到我们的应用程序:

下一章将讨论持续集成的基础知识,并帮助您在应用程序完全生产就绪之前完成剩下的事情(目前还缺少文件压缩)。

让我们在下一章中继续,更详细地描述本书中将涵盖的剩余主题。

第九章:使用单元测试和行为测试进行持续集成

我们成功了!恭喜!我们创建了一个在某个特定域名下运行的全栈应用(在本书中是reactjs.space)。整个设置中缺少的部分是部署过程。部署应该是零停机时间的。我们需要一个冗余的服务器来运行我们的应用程序。

我们的应用程序还缺少一些步骤,以使其能够专业地进行缩小、单元和行为测试。

在本章中,我们将向你介绍一些额外的概念,这些概念是掌握全栈开发所必需的。其余的缺失部分留给你作为家庭作业。

何时编写单元测试和行为测试

一般来说,有一些建议关于何时编写单元测试和/或行为测试。

我们在 ReactPoland 经常有客户是创业公司。作为对他们的一般管理,我们建议以下内容:

  • 如果你的创业公司正在寻求增长,并且需要你的产品来实现它,那就不要担心测试。

  • 在创建了最小可行产品MVP)之后,当扩展你的应用程序时,必须进行这些测试

  • 如果你是一家成熟的公司,为客户构建应用程序,并且非常了解他们的需求,那么你必须进行测试。

前两点与初创公司和年轻公司有关。第三点主要与成熟的公司有关。

根据你和你的产品所处的位置,你需要自己决定是否值得编写测试。

React 惯例

有一个项目完全展示了全栈开发设置应该是什么样子的,网址是React JS.co

访问这个网站,学习如何将你的应用程序与单元测试和行为测试集成,并了解有关如何制作 React Redux 应用程序的最新最佳惯例。

测试的因果报应

在本章中,我们不会指导你设置测试,因为这不在本书的范围内。本章的目的是向你介绍在线资源,这些资源将帮助你理解更大的图景。

Karma 是最流行的用于单元测试和行为测试的工具之一。其主要目标是在开发任何应用程序时提供一个高效的测试环境。

使用这个测试运行器,你可以获得许多功能。有一个很好的视频在karma-runner.github.io解释了 Karma 的整体情况。

一些主要特性如下:

  • 在真实设备上进行测试:您可以使用真实浏览器和真实设备,如手机、平板电脑或 PhantomJS 来运行测试(PhantomJS 是一个无头 WebKit,可通过 JavaScript API 进行脚本化;它对各种 Web 标准具有快速和本地支持:DOM 处理、CSS 选择器、JSON、Canvas 和 SVG)。有不同的环境,但有一个工具可以在所有这些环境上运行。

  • 远程控制:您可以远程运行测试,例如,在 IDE 每次保存时,这样您就不必手动运行。

  • 测试框架不可知:您可以使用 Jasmine、Mocha、QUnit 和其他框架编写测试。完全取决于您。

  • 持续集成:Karma 与 Jenkins、Travis 或 CircleCI 等 CI 工具完美配合。

如何编写单元测试和行为测试

让我们举个例子,说明如何正确设置项目,以便能够编写测试。

访问非常流行的 Redux starter kit 的 GitHub 仓库,网址为github.com/davezuko/react-redux-starter-kit

然后访问此存储库的package.json文件。我们可以在那里找到可能的命令/脚本是什么:

[PRE0]

您可以在 NPM 测试后找到它运行以下命令:

[PRE1]

您可以在build/karma.conf中找到 Karma 的配置文件,网址为github.com/davezuko/react-redux-starter-kit/blob/master/build/karma.conf.js

截至内容(2016 年 7 月)如下:

[PRE2]

正如您在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文件中所看到的,有大量的依赖项:

[PRE3]

让我们大致描述一下那里使用了什么:

  • Babel-polyfill 模拟了完整的 ES6 环境

  • Sinon 是一个独立的、与测试框架无关的 JavaScript 测试框架,用于间谍、存根和模拟

如果在被测试的代码中调用了其他外部服务,间谍是很有用的。您可以检查它是否被调用,它有什么参数,它是否返回了东西,甚至它被调用了多少次!

存根的概念与间谍的概念非常相似。最大的区别在于存根替换了目标函数。它们还用自定义行为(替换它)替换了被调用的代码,比如抛出异常或返回一个值。它们还能够调用作为参数提供的回调函数。存根代码返回指定的结果。

Mocks 是一种更智能的存根。 Mocks 用于断言数据,不应返回数据,而存根仅用于返回数据,不应断言。 Mocks 可以在断言时破坏您的测试,而存根则不能。

Chai 是用于 Node.js 和浏览器的 BDD/TDD 断言框架。在前面的例子中,它已经与 Mocha 测试框架配对使用了。

逐步测试 CoreLayout

让我们分析CoreLayout.spec.js的测试。这个组件在发布应用程序中扮演着与 CoreLayout 类似的角色,因此这是描述如何开始为应用程序编写测试的好方法。

CoreLayout 测试文件位置(2016 年 7 月)可在github.com/davezuko/react-redux-starter-kit/blob/master/tests/layouts/CoreLayout.spec.js找到。

内容如下:

[PRE4]

react-addons-test-utils库使得使用 Mocha 轻松测试 React 组件成为可能。我们在前面的例子中使用的方法是浅渲染,可以在facebook.github.io/react/docs/test-utils.html#shallow-rendering找到。

这个功能帮助我们测试render函数,并且是在我们的组件中渲染一级深度的结果。然后我们可以断言关于它的render方法返回的事实,如下所示:

[PRE5]

首先,我们在shallowRender方法中提供一个组件(在这个例子中,它将是 CoreLayout)。然后,我们使用method.render,然后我们使用renderer.getRenderOutput返回输出。

在我们的情况下,这个函数在这里被调用(请注意,以下示例中缺少分号,因为我们描述的起始器与我们的不同的 linting 选项):

[PRE6]

你可以发现_component变量包含了renderer.getRenderOutput的结果。这个值被断言如下:

[PRE7]

在那个测试中,我们测试我们的代码是否返回div。但是如果你访问文档,你可以找到以下代码示例:

[PRE8]

你也可以在下面找到断言的例子:

[PRE9]

在前面的两个例子中,你可以期望一个类型为div,或者你可以期望更具体的关于 CoreLayout 返回的信息(取决于你的需求)。

第一个测试断言组件的类型(如果是div),第二个例子测试断言 CoreLayout 返回的正确组件如下:

[PRE10]

第一个是单元测试,因为这并不完全是在测试用户是否看到了正确的东西。第二个是行为测试。

通常,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 上的一个暂存服务器上。持续集成不在本书的范围内,所以如果你有兴趣将这一步添加到整个发布应用项目中,也有相关的书籍。

总结

我们的发布应用程序正在运行。与任何数字项目一样,我们仍然有很多可以改进的地方,以便获得更好的最终产品。例如,以下作业是给你的:

  • 在前端添加一个压缩,这样在互联网上加载时会更轻。

  • 正如前面提到的,您需要开始使用 Karma 和 Mocha 进行单元和行为测试。本章节详细描述了一个示例设置。

  • 您需要选择一个 CI 工具,比如 Travis,创建您的 YML 文件,并在 AWS 上准备环境。

这是除了本书 350 多页涵盖的所有内容之外,你可以额外做的。在这本书中,你构建了一个全栈 React + Redux + Falcor + Node + Express + Mongo 应用程序。我希望能和你保持联系;在 Twitter/GitHub 上关注我,以便保持联系,或者如果你有任何额外的问题,给我发电子邮件。

祝你好运,下一个商业全栈应用程序的开发顺利,再见。

posted @ 2024-05-16 14:51  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报