React-蓝图-全-
React 蓝图(全)
原文:
zh.annas-archive.org/md5/a2bffca9c62012ab857fb3d1f735ef00译者:飞龙
前言
ReactJS 是为了解决应用程序状态问题而开发的工具,但它迅速发展成为 Web 开发的领先库。它之所以受欢迎,是因为它摒弃了以 HTML 为中心的 Web 应用开发方式,这被证明是一种非常开发者友好的 Web 应用开发方式。借助这本书,您可以了解如何利用 ReactJS 创建既有趣又易于理解的 Web 应用。
本书涵盖的内容
第一章, 深入 ReactJS,向您介绍 ReactJS 库,教您如何组织代码,并介绍模块化组件。
第二章, 创建一个网店,将指导您从首页到结账的网店构建过程,并解释单向数据流模式。
第三章, 使用 ReactJS 进行响应式 Web 开发,教您如何使用 ReactJS 开发响应式应用,并指导您构建一个基本的响应式应用。
第四章, 构建实时搜索应用,将指导您构建一个接受输入并从 API 返回数据的应用程序。
第五章, 使用 HTML5 API 创建地图应用,教您在构建地图应用程序时如何访问 HTML5 API。
第六章, 高级 React,演示了如何过渡到 JavaScript 2015 类,实现了 Redux,并指导您进行登录应用程序。
第七章, Reactagram,将指导您使用 Firebase 作为后端构建一个类似 Instagram 的应用程序。
第八章, 将您的应用部署到云端,涵盖了云策略,并指导您进行生产就绪的应用部署。
第九章, 创建一个共享应用,涵盖了如何创建一个完全同构的应用程序,并指导您使用 Redux 进行服务器端渲染的蓝图。
第十章, 制作游戏,将指导您在 ReactJS 中构建游戏引擎和游戏。
您需要为本书准备的内容
开发 Web 应用不需要任何特殊设备。您需要一个电脑(Windows、Linux 或 Mac)、一个代码编辑器(任何都可以)、互联网连接和一个控制台应用程序。
本书面向的对象
本书是为那些想用 ReactJS 开发应用程序的人而写的。它涵盖了广泛的主题,既适合经验不足的开发者,也适合高级开发者,而且不需要任何 ReactJS 的先验经验。
术语约定
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“我们可以通过使用include指令来包含其他上下文。”
代码块以如下方式设置:
<Button bsSize="medium"
onClick={CartActions.AddToCart.bind(null,
this.props.productData)}>
Add to cart
</Button>
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
getInitialState() {
return {
results: [],
resultsToShow: 10,
numResults: 0,
threshold: -60,
increase: 3,
showResults: true
}
},
任何命令行输入或输出都如下所示:
npm install --save body-parser@1.14.1 cors@2.7.1 crypto@0.0.3 express@4.13.3 mongoose@@4.3.0 passport@0.3.2
passport-http-bearer@1.0.1
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,以如下方式显示:“应将支付 API 的连接设置为结账按钮。”
注意
警告或重要注意事项以如下框中的形式出现。
小贴士
小技巧和技巧如下所示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
要发送给我们一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件的主题中提及书籍的标题。
如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 书籍的骄傲所有者,我们有一些事情可以帮助您从您的购买中获得最大价值。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与勘误表。
-
在搜索框中输入书籍的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书籍的地方。
-
点击代码下载。
您还可以通过点击 Packt Publishing 网站书籍网页上的代码文件按钮下载代码文件。您可以通过在搜索框中输入书籍的名称来访问此页面。请注意,您需要登录到您的 Packt 账户。
文件下载完成后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/reactjsblueprints。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。请查看它们!
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
如发现疑似盗版材料,请通过电子邮件联系 <copyright@packtpub.com>,并提供链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题和建议
如果您对本书的任何方面有问题,您可以通过电子邮件联系 <questions@packtpub.com>,我们将尽力解决问题。
第一章. 深入了解 ReactJS
欢迎亲爱的读者!在本书中,您将找到一套蓝图,您可以使用这些蓝图使用 ReactJS 开发现代网络应用程序。
本章将向您介绍 ReactJS,并涵盖如何使用基于组件的架构进行工作。您将学习 ReactJS 中的所有重要概念,例如创建和挂载组件、处理 props 和 states、理解生命周期方法,以及设置高效开发的工作流程。
在本章中,我们将:
-
介绍 ReactJS
-
探索 props 和 states
-
了解所有重要的生命周期方法
-
了解合成事件和虚拟 DOM
-
学习现代 JavaScript 开发者的工作流程
-
组合
-
为我们所有的应用创建基本的脚手架
即使你之前有 ReactJS 的使用经验,也值得阅读这一章,特别是脚手架部分,因为我们将使用这个脚手架来构建本书中大部分的蓝图。
让我们开始吧!
介绍 ReactJS
要高效地使用 ReactJS 进行开发,了解它是什么以及它不是什么是至关重要的。ReactJS 不是一个框架。ReactJS 将自己描述为MVC(模型-视图-控制器)设计模式中的V。它是一个视图库,您可以将其与AngularJS、Ember和Meteor等框架结合使用,甚至可以与其他流行的 JavaScript 库,如Knockout结合使用。
许多人单独使用React,并将其与称为Flux的数据流模式结合使用。Flux 背后的想法是建立单向数据流,这意味着数据应该从您的应用程序中的单个点起源并向下流动。我们将在第二章创建网络商店中更详细地探讨这个模式。
现代 JavaScript 开发
2015 年,JavaScript 在许多年后迎来了第一次重大升级。语法是 JavaScript 2015。你可能知道它叫作 EcmaScript 6。EcmaScript 委员会在 2015 年中决定更改名称,从现在开始,JavaScript 将每年更新一次。现代浏览器正在逐步实现对新特性的支持。
注意
关于本书中您将看到的代码的说明。本书将使用 JavaScript 2015。常青浏览器,如 Firefox、Chrome 和 Microsoft Edge,将根据自己的时间表实现新功能,这意味着某些浏览器可能在新功能被其他浏览器支持之前就支持了这些功能,而某些功能可能根本不会被实现。
你很可能会遇到想要利用新语言特性而又不想等待它被实现的情况。向后兼容性也是一个问题,因为你不希望让你的用户落后。
解决这两个问题的方法是使用转译器生成基线 EcmaScript-5 兼容代码,例如Traceur或Babel。由于 Babel 部分是为了 ReactJS 而构建的,我建议你选择这个,本书中,我们将依赖 Babel 来满足我们的转译需求。
在本书中,我们将通过开发迭代脚手架或基本设置来探索现代开发者的工作流程。在设置和使用这个脚手架时,我们将大量依赖终端、Node.js和npm。如果你对此不熟悉,不要担心。我们会慢慢来。
组件规范
ReactJS 组件有一套内置的方法和属性,你将依赖它们。其中一些用于调试,如displayName和propTypes;一些用于设置初始数据,如getInitialState和getDefaultProps;最后,还有一些处理组件生命周期的方法,如componentDidMount、componentShouldUpdate等。
Props 和 states
组件内的数据可以来自外部(props)或从内部实例化(states)。
由于测试性和不可变性的考虑,我们尽可能希望依赖于传递给组件的数据,而不是处理内部状态。然而,有许多原因会让你想要使用内部状态,所以让我们详细了解一下 props 和 states,以及何时使用哪一个。
Props
让我们看看一个简单的组件:
import React from 'react';
import { render } from 'react-dom';
const App = React.createClass({
render() {
return (
<div>My first component</div>
);
}
});
render(<App />, document.querySelector('#app'));
当你执行此组件时,你将在浏览器窗口中看到我的第一个组件这几个字。
注意
注意,应用程序渲染到具有id app 的div元素。
相应的 HTML 文件需要看起来像这样:
<!DOCTYPE html>
<body>
<div id="app"></div>
</body>
<script type="text/javascript" src="img/app.js"></script>
此组件定义了一个名为app的常量,它使用内置的createClass方法创建了一个 React 组件。
render方法是 ReactJS 组件中唯一必需的方法。你可以避免使用所有其他方法,但这个方法不能省略。在render方法中,你可以编写 HTML 和 JavaScript 的组合,称为JSX,或者使用 ReactJS 元素组合 HTML 代码。
注意
JavaScript 无法理解 JSX,所以当你编写 JSX 代码时,你需要将其转换为 JavaScript,然后在 JavaScript 环境中执行。将 JSX 转换为 JavaScript 最简单的方法是使用 Babel 转译器,因为它会自动完成这项工作。
无论你决定如何做,以下 JSX 代码将被转换:
<div>My first component</div>
它将被转换成这样:
React.createElement("div", null, "My first component");
createElement()方法接受三个参数:html标签、一个null字段和 HTML 代码。第二个字段实际上是一个具有属性(或null)的对象。我们稍后会回到这一点。
让我们引入属性的概念,使这个组件更有趣:
const App = React.createClass ({
render() {
return (
<div>{this.props.greeting}</div>
);
}
});
render(<App greeting="Hello world!"/>,
document.querySelector('#app'));
所有组件属性都可以通过访问 this.props 来使用。在这里,我们设置了一个初始消息,Hello World!现在,这就是您执行组件时看到的内容:

Props 不可修改,应被视为不可变。
注意
注意,props 是与组件调用一起发送的。
您可以发送任意多的属性,并且它们始终在 this.props 下可用。
如果您想发送多个属性,只需按顺序将它们添加到组件调用中:
<App greeting="Hello world" message="Enjoy the day" />
您可以通过调用 getDefaultProps 来设置组件的初始 props。当您预计某个 prop 将会在组件的生命周期后期某个点可用时,这可能会很有用:
getDefaultProps() {
return {
greeting: ""
}
}
如果您通过添加问候语来调用组件,组件将简单地显示一个空页面。如果您没有初始 prop,React 将会抛出一个错误,并抱怨您正在引用一个不存在的属性。
States
状态类似于 props,但它们是为仅在组件内部可用的变量而设计的。您可以像设置 props 一样设置状态:
setInitialState() {
return {
greeting: "Hello world!"
}
}
此外,您还可以使用 this.state 来调用变量:
render() {
return (
<div>{this.state.greeting}</div>
);
}
与 props 类似,如果您尝试使用一个不存在的状态变量,ReactJS 将会抛出一个错误。
状态主要用于您在组件内部进行更改时,这些更改只在该组件中有意义。让我们通过一个例子来理解这一点:
getInitialState: function () {
return {
random_number: 0
}
},
componentDidMount(){
setInterval(()=>{
this.setState({
random_number: Math.random()*100
});
},1000)
},
render() {
return (
<div>{this.state.random_number}</div>
);
}
在这里,我们设置一个 random_number 变量为 0。我们访问内置的 componentDidMount 方法,并启动一个每秒为该变量设置新随机数的间隔。在渲染中,我们简单地输出变量。每当状态改变时,ReactJS 都会通过重新渲染输出进行响应。每次您运行 setState 时,ReactJS 都会触发组件的重新渲染。注意限制 setState 的使用次数,因为如果您过于频繁地使用它们,可能会遇到性能问题。
render
这是组件中唯一必需的方法。它应该返回一个单一子元素,例如 JSX 结构,但如果您不想渲染任何内容,它也可以返回 null 或 false 来表示您不想渲染任何内容:
render(){
return (<div>My component</div>);
}
statics
此对象可以用来定义可以在组件上调用的静态方法:
import React from 'react';
const App = React.createClass ({
statics: {
myMethod: (foo) => {
return foo == "bar";
}
},
render() {
return null;
}
});
console.log(App.myMethod('bar')); // true
注意
注意,静态方法无法访问组件的 props 或状态。
propTypes
此对象允许您验证传递给组件的 props。这是一个可选的工具,可以帮助您在开发应用程序时,如果传递给组件的 props 与您的规范不匹配,它将在控制台日志中显示:
propTypes: {
myOptionalObject: React.PropTypes.object,
aRequiredString: React.PropTypes.string.isRequired,
anOptionalNumber: React.PropTypes.number,
aValueOfAnyKind: React.PropTypes.any,
customProp: function(props, propName, componentName) {
if (!/matchme/.test(props[propName])) {
return new Error('Validation failed!');
}
}
}
最后一个例子创建了一个自定义验证器,您可以使用它来验证更复杂的数据值。
displayName
如果您没有明确设置此值,它将自动设置,并用于调试目的:
displayName: "My component Name"
生命周期方法
生命周期方法是你在组件中可以重写的一组函数。最初,除了shouldComponentUpdate(默认为true)之外,其他都是空的。
componentDidMount
这是你在应用中会使用到最常见的方法之一。这是你放置任何希望在组件第一次渲染后立即运行的函数的地方。
你可以访问此方法中的当前状态和 props 的内容,但请注意不要在这里运行setState,因为这将触发无限更新循环。
值得注意的是,如果你正在开发一个服务器端应用程序,这个组件将不会被调用。在这种情况下,你将不得不依赖componentWillMount:
componentDidMount() {
// Executed after the component is mounted
}
componentWillMount
此方法将在组件第一次渲染之前执行。你在这里可以访问当前组件的状态和 props,并且与componentDidMount不同,在这里运行setState是安全的(ReactJS 会理解在这个方法中状态的变化应该立即设置,而不是触发重新渲染)。
此方法在服务器端和客户端应用程序中都会执行:
componentWillMount() {
// Executed before the component is mounted
}
shouldComponentUpdate
当组件接收到新的 props 或状态发生变化时,此方法会被调用。
默认情况下,shouldComponentUpdate返回一个true值。如果你重写它并返回false,尽管接收到更新的 props 或新的状态,组件也永远不会更新。如果你创建了一个应该只在满足某些条件或根本不应该更新的组件,这可能会很有用。如果你将此设置为false,你可以从速度提升中受益。然而,在使用此方法时应该非常小心,因为粗心使用可能导致难以追踪的 bug。
componentWillReceiveProps
此方法让你比较传入的 props,并且可以在调用渲染方法之前作为响应 props 转换的机会。使用componentWillReceiveProps(object nextProps)来调用此方法,以便访问传入的 props。
值得注意的是,如果你在这里调用setState,不会触发额外的重新渲染。它不会在初始渲染时被调用。
没有类似的方法来响应纯状态变化,但如果你需要在渲染之前响应状态变化,可以使用componentWillUpdate。
此方法在初始渲染时不执行:
componentWillReceiveProps(nextProps) {
// you can compare nextProps with this.props
// and optionally set a new state or execute functions
// based on the new props
}
componentWillUpdate
此方法在渲染之前执行,当组件接收到新的 props 或 states,但不在初始渲染时执行。
使用componentWillUpdate(object nextProps, object nextState)来调用此方法,以便使用nextProps和nextState访问传入的 props 和 states。
由于你可以在该方法中评估新的状态,因此在这里调用 setState 将触发无限循环。这意味着你无法在这个方法中使用 setState。如果你想根据 prop 的变化运行 setState,请使用 componentWillReceiveProps 代替:
componentWillUpdate (nextProps) {
// you can compare nextProps with this.props
// or nextState with this.state
}
componentDidUpdate
当组件接收到新的 props 或 states 并执行了 render 方法时,此方法会被执行:
componentDidUpdate() {
// Execute functions after the component has been updated
}
componentWillUnmount
最后的生命周期方法是 componentWillUnmount。这个方法在组件从 DOM 中卸载之前被调用。如果你需要清理内存或使计时器失效,这就是你要做的:
componentWillUnmount() {
// Execute functions before the component is unmounted
// from the DOM
}
合成事件和虚拟 DOM
让我们探讨常规 DOM 和虚拟 DOM 之间的差异,以及你在编写代码时需要考虑的事项。
DOM
文档对象模型(DOM)是 HTML 文档的编程 API。每次你要求浏览器渲染 HTML 时,它都会解析你所写的代码,将其转换为 DOM,然后在浏览器中显示它。它非常宽容,因此你可以编写无效的 HTML,仍然可以得到你想要的结果,甚至不知道你犯了错误。
例如,假设你写下以下代码行,并用网络浏览器解析它:
<p>I made a new paragraph! :)
在此之后,DOM 将显示以下结构:

关闭标签 </p> 会自动为你插入,并且已经创建了一个具有所有相关属性的 <p> 标签的 DOM 元素。
ReactJS 并不那么宽容。如果你在 render 方法中写下相同的 HTML,它将无法渲染并抛出 「未终止的 JSX 内容」 错误。这是因为 JSX 需要严格匹配开标签和闭标签。这实际上是一件好事,因为它可以帮助你编写语法正确的 HTML。
虚拟 DOM
虚拟 DOM 实际上是真实 DOM 的一个更简单的实现。
ReactJS 并不直接与 DOM 交互。它使用一个虚拟 DOM 的概念,通过它维护一个较小且更简化的内部元素集,并且只有在元素集的状态发生变化时,才会将更改推送到可见 DOM。这使得你可以在不影响其他元素的情况下切换可见元素的部分,简而言之,这使得 DOM 更新过程非常高效。最好的部分是,这一切都是免费的。你不必担心它,因为 ReactJS 在后台处理一切。
然而,这也意味着你不能在 DOM 中查找更改并直接进行更改,就像你通常使用库(如 jQuery)或原生 JavaScript 函数(如 getElementById())那样。
相反,你需要为目标元素附加一个名为 refs 的引用。你可以通过在元素中添加 ref="myReference" 来实现这一点。现在,你可以通过调用 React.findDOMNode(this.refs.myReference) 来获取这个引用。
合成事件处理器
每当你调用 ReactJS 中的事件处理器时,它们都会传递一个SyntheticEvent实例而不是原生事件处理器。它与原生事件处理器的接口相同,但它具有跨浏览器兼容性,因此你可以使用它而不用担心是否需要在代码中为不同的浏览器实现做出例外。
事件在冒泡阶段被触发。这意味着事件首先被捕获到最深的目标,然后传播到外部元素。
有时,你可能希望立即捕获事件。在这种情况下,在事件后添加Capture可以实现这一点。例如,要立即捕获onClick,使用onClickCapture等等。
你可以通过调用event.stopPropagation()或event.preventDefault()在适当的地方停止事件传播。
注意
可用的所有事件处理器的完整列表可在facebook.github.io/react/docs/events.html找到。
整合所有内容
当我们将所有这些整合在一起时,我们可以通过引用元素和事件处理器来扩展示例应用:
import React from 'react';
import {render} from 'react-dom';
const App = React.createClass ({
getInitialState() {
return {
greeting: "",
message: ""
}
},
componentWillMount() {
this.setState ({
greeting: this.props.greeting
});
},
componentDidMount() {
this.refs.input.focus();
},
handleClear: function (event) {
this.refs.input.value="";
this.setState ({
message: ""
});
},
handleChange: function (event) {
this.setState ({
message: event.target.value
});
},
render: function () {
return (
<div>
<h1>Refs and data binding</h1>
<h2>{this.state.greeting}</h2>
Type a message:
<br/>
<input type="text" ref="input"
onChange={this.handleChange} />
<br/>
Your message: {this.state.message}
<br/>
<input type="button"
value="Clear"
onClick={this.handleClear}
/>
</div>
);
}
});
render (
<App greeting="Let's bind some values" />,
document.getElementById('#app')
);
让我们从结尾开始。就像我们之前做的那样,我们通过渲染一个名为app的单个 ReactJS 组件,并传递一个属性到具有#app ID 的元素上来初始化我们的应用。
在应用挂载之前,我们为我们的两个状态值设置初始值:greeting和message。在应用挂载之前,我们将问候状态的设置为其传递给应用的问候属性相同的值。
然后,我们在render方法中添加输入框、一个清除按钮以及一些文本,并将onChange处理器和onClick处理器附加到这些元素上。我们还为输入框添加了ref。
在组件挂载后,我们通过其ref参数找到消息框,并告诉浏览器将其聚焦。
最后,我们可以进入事件处理器。onChange处理器绑定到handleChange。它将在每次按键时激活,并保存一个新的消息状态,即输入框的当前内容。然后 ReactJS 将在render方法中重新渲染内容。在协调过程中,它将注意到输入框中的值与上一次渲染不同,并确保这个框以更新后的值渲染。同时,ReactJS 也会在您的消息:后的空文本元素中填充状态值。
handleClear方法简单地重置消息状态并使用refs清除输入框。
这个例子稍微有些牵强。它可以缩短很多,并且将属性存储为状态通常是你应该避免的事情,除非你有非常充分的理由这样做。根据我的经验,使用本地状态是你会遇到的最容易出错的代码,也是最难编写测试的代码。
组合
组合是将事物组合在一起以形成更复杂的事物,然后将这些事物组合起来以形成更复杂的事物,如此类推的行为。
当创建超出 Hello World 的应用时,知道如何组合 ReactJS 组件至关重要。由许多小部分组成的程序比单一的大型单体应用更容易管理。
使用 ReactJS 组合应用非常简单。例如,我们刚刚创建的 Hello World 应用可以通过以下代码导入到新的组件中:
const HelloWorld = require("./helloworld.jsx");
const HelloWorld = require("./helloworld.jsx");
在你的新组件中,你可以这样使用HelloWorld变量:
render() {
return <div>
<HelloWorld />
</div>
}
你创建的每个组件都可以以这种方式导入和使用,这也是选择 ReactJS 的许多吸引人理由之一。
使用现代前端工具进行开发
很难过分强调Node.js和npm在现代 JavaScript 开发中的重要性。这些关键技术是 JavaScript 网络应用开发的核心,我们将在这本书中开发的程序中依赖Node.js和npm。
Node.js适用于 Windows、Mac 和 Linux,安装起来非常简单。我们将在这本书的所有示例中使用Node.js和npm。我们还将使用 EcmaScript 2015 和转译器将代码转换为与旧浏览器兼容的基线 JavaScript 代码。
如果你之前没有使用过这个工作流程,准备好兴奋吧,因为它不仅会使你更有效率,还会为你打开一个开发者美好世界的大门。
让我们开始吧。
Browserify
传统的 Web 开发方法需要你手动将脚本添加到index.html文件中。这通常包括一系列框架或库,再加上你自己的代码,你按顺序添加它们,以确保它们按正确的顺序加载和执行。这种方法有几个缺点。版本控制变得困难,因为你没有很好的方法来控制你的外部库的新版本是否与你的其他代码兼容。因此,许多网络应用都带有旧的 JavaScript 库。组织你的脚本也是一个问题,因为当你升级时,你必须手动添加和删除旧版本。文件大小也是一个问题,因为许多库包含了你不需要的更多功能。
如果我们有一个工具可以保持你的依赖项更新,在你遇到不兼容问题时通知你,并移除你不需要的代码,那岂不是很好?答案是肯定的,幸运的是,这样的工具确实存在。
唯一的缺点是您必须改变编写代码的方式。您不再编写依赖于全局环境变量的脚本,而是编写自包含的模块化代码,并且您始终在开始时指定您的依赖项。如果您认为这并不是一个很大的缺点,您是对的。事实上,这是一个巨大的改进,因为这使代码易于阅读和理解,并在编写测试时允许轻松的依赖注入。
组装模块化代码的最流行工具是Browserify和Webpack。
我们将专注于Browserify,简单的理由是它非常容易使用,并且具有出色的插件支持。我们将在第六章中查看Webpack,高级 React。这两个工具都将分析您的应用程序,找出您正在使用的模块,并组装一个包含您在浏览器中加载代码所需所有内容的 JavaScript 文件。
为了使这个功能正常工作,你需要一个基础文件,这是你应用程序的起点。在我们的框架中,我们将称之为app.jsx。这个文件将包含对您的模块和它所使用的组件的引用。当您创建新的组件并将它们连接到app.jsx或app.jsx的子组件时,Browserify会将它们添加到包中。
存在许多工具可以增强 Browserify 的包生成。对于 EcmaScript 2015 及更高版本的 JavaScript 代码,我们将使用Babelify。这是一个方便的工具,除了将 JavaScript 转换为 EcmaScript 5 之外,它还会将 React 特定的代码(如 JSX)转换为 EcmaScript 5。换句话说,您不需要使用单独的 JSX 转换器来使用 JSX。
我们还将使用Browser-sync,这是一个在您编辑时自动重新加载您代码的工具。这极大地加快了开发过程,并且在使用了一段时间之后,您将永远不会想回到手动刷新应用程序。
搭建我们的 React 应用程序
我们将采取以下步骤来设置我们的开发工作流程:
-
创建一个
npm项目。 -
安装依赖项。
-
创建一个服务器文件。
-
创建一个开发目录。
-
创建我们的基础
app.jsx文件。 -
运行服务器。
首先,请确保您已安装npm。如果没有,请访问nodejs.org/download/并下载安装程序。以下步骤的详细说明如下:
-
创建一个您希望应用程序排序的目录,并打开一个终端窗口,然后使用
cd进入该文件夹。通过输入
npm init然后按Enter键来初始化您的应用程序。给项目起一个名字,然后回答后续的几个问题,或者直接留空。 -
我们将使用
npm获取一些包以开始。运行以下命令将获取这些包并将依赖项添加到您新创建的package.json文件中:npm install –-save babelify@7.2.0 browserify-middleware@7.0.0 express@4.13.3 react@0.14.3 reactify@1.1.1 browser-sync@2.10.0 babel-preset-react@6.3.13 babel-preset-es2015@6.3.13 browserify@12.0.1 react-dom@0.14.3 watchify@3.6.1Babel 需要一个名为
.babelrc的配置文件。将其添加到以下代码中:{ "presets": ["es2015","react"] } -
使用您喜欢的文本编辑器创建一个新的文本文件,添加以下代码,并将其保存为
server.js:var express = require("express"); var browserify = require('browserify-middleware'); var babelify = require("babelify"); var browserSync = require('browser-sync'); var app = express(); var port = process.env.PORT || 8080;此部分设置我们的应用,使用express作为我们的 Web 服务器。它还初始化
browserify、babelify和browser-sync。最后,我们将应用设置为在端口8080上运行。process.env.PORT || 8080这一行代码的意思是您可以覆盖端口,通过在服务器脚本前加上PORT 8085来在端口8085或您想使用的任何其他端口上运行:browserify.settings ({ transform: [babelify.configure({ })], presets: ["es2015", "react"], extensions: ['.js', '.jsx'], grep: /\.jsx?$/ });这设置了 Browserify,使用 Babelify 将所有具有
.jsx文件扩展名的代码转换为 Babel。0阶段的配置意味着我们想要使用尚未获得 EcmaScript 委员会批准的实验性代码:// serve client code via browserify app.get('/bundle.js', browserify(__dirname+'/source/app.jsx'));我们希望在
index.html文件中使用<script src="img/bundle.js"></script>引用我们的 JavaScript 包。当 Web 服务器注意到对该文件的调用时,我们告诉服务器发送我们source文件夹中的 browserifiedapp.jsx文件代替:// resources app.get(['*.png','*.jpg','*.css','*.map'], function (req, res) { res.sendFile(__dirname+"/public/"+req.path); });使用此配置,我们告诉 Web 服务器从
public.folder中的任何列出的文件提供服务:// all other requests will be routed to index.html app.get('*', function (req, res) { res.sendFile(__dirname+"/public/index.html"); });这行代码指示当用户访问根路径时,Web 服务器应提供
index.html:// Run the server app.listen(port,function() { browserSync ({ proxy: 'localhost:' + port, files: ['source/**/*.{jsx}','public/**/*.{css}'], options: { ignored: 'node_modules' } }); });最后,使用
browser-sync运行 Web 服务器,代理您选择的端口。这意味着如果您指定端口8080作为端口,您的面向公众的端口将是一个代理端口(通常是3000),它将代表您访问8080:我们告诉
browser-sync监控我们source/文件夹中的所有 JSX 文件和public/文件夹中的 CSS 文件。每当这些文件发生变化时,browser-sync将更新并刷新页面。我们还告诉它忽略node_modules/文件夹中的所有文件。这通常是一个明智的做法,因为该文件夹通常会包含数千个文件,您不希望浪费时间等待这些文件被扫描: -
接下来,创建两个名为
public和source的目录。将以下三个文件:index.html和app.css添加到您的 public 文件夹中,将app.jsx添加到您的source文件夹中。在
index.html文件中写入以下内容:<!DOCTYPE html> <html> <head> <title>ReactJS Blueprints</title> <meta charset="utf-8"> <link rel="stylesheet" href="app.css" /> </head> <body> <div id="container"></div> <script src="img/bundle.js"></script> </body> </html>在
app.css文件中写入以下内容:body { background:#eee; padding:22px; } br { line-height: 2em; } h1 { font-size:24px; } h2 { font-size:18px; }在
app.jsx文件中写入以下内容:'use strict'; import React from 'react'; import { render } from 'react-dom'; const App = React.createClass({ render() { return ( <section> <h1>My scaffold</h1> <p>Hello world</p> </section> ); } }); render ( <App />, document.getElementById('container') );您的文件结构现在应该看起来像这样:
![构建我们的 React 应用]()
运行应用
前往应用的根目录,输入node server,然后按Enter。这将启动一个 Node 服务器,几秒钟后,browser-sync将打开一个带有位置http://localhost:3000的 Web 浏览器。如果您在端口3000上运行了其他 Web 服务器或进程,browser-sync将选择不同的端口。查看控制台输出以确保它选择了哪个端口。
你将在屏幕上看到app.jsx中渲染方法的全部内容。在后台,Browserify 已经使用 Babelify 将你的 JSX 和 ES2015 代码以及导入的依赖项转换为单个bundle.js文件,该文件在http://localhost:3000上提供。当这个服务器运行时,每次你在这个服务器运行时对代码进行更改,应用程序和 CSS 代码都会被刷新,所以我强烈建议你尝试与代码进行实验,实现一些生命周期方法,尝试使用状态和属性进行工作,并总体上感受一下使用 ReactJS 的工作方式。
如果这是你第一次使用这种配置,我想你现在一定感到一股强烈的兴奋感涌上心头。这种配置非常强大,使用起来也很有趣,最好的是,搭建起来几乎不费吹灰之力。
摘要
在本章中,我们探讨了当你使用 ReactJS 开发应用程序时将遇到的所有重要概念。在我们去了解如何设置和构建一个 ReactJS 应用程序之前,我们研究了组件规范、如何组合组件以及生命周期方法。最后,我们回顾了本书蓝图中所使用的搭建结构。
在下一章中,我们将学习我们的第一个蓝图并创建一个网店。我们将通过利用 Flux 模式来探索单向数据流的概念。
第二章 创建网上商店
在 20 世纪 90 年代网络商业化以来,在线销售商品一直是网络的基础。在本章中,我们将探讨如何利用 ReactJS 的力量来创建我们自己的网上商店。
我们将从创建多个不同的组件开始,例如主页、产品页、结账和收据页,我们将通过一个称为Flux的概念从数据存储中获取产品。
当我们完成时,你将拥有一个完整的蓝图,你可以在此基础上扩展并应用自己的样式。
让我们开始吧!
组件概述
在创建任何类型的网站时,在编写任何代码之前创建一个页面外观的原型通常是有益的。这使得更容易可视化你想要网站看起来什么样,以及你需要创建哪些组件。你可以使用任何类型的原型工具来创建这个,甚至一张纸也行。
通过查看我们的网站原型,我们可以看到我们需要创建以下组件:
-
一个布局组件
-
一个用于主页的首页组件
-
一个带有品牌名称和最重要链接的菜单组件
-
一个公司信息组件
-
一个产品列表组件
-
一个商品组件
-
一个结账组件
-
一个收据组件
这些只是视图组件。除了这些,我们还需要为主要的组件创建数据存储、动作和子组件。例如,对于主页上的产品组件,你需要一个图片元素、描述、价格和一个购买按钮,每次你需要列表或表格时,你都需要创建另一个子组件,等等。
我们将在进行过程中创建这些。让我们看看下面的图片:

前面的原型展示了在桌面和智能手机上查看的产品列表页面。为所有你打算支持的平台绘制原型是值得的。同时,绘制所有不同页面及其可能的状态的草图也是一个好主意。
设置商店
我们将使用第一章中的代码,深入 ReactJS,作为这个网上商店的基础。复制第一章的代码,并确保它在继续进行更改之前正在运行。
小贴士
将代码复制到另一个目录,并通过执行node server.js来运行它。它应该启动一个服务器,并自动为你打开一个浏览器窗口。
创建布局
首先,我们需要为我们的网上商店创建一个基本的布局。有许多选项可供选择。例如,你可以选择许多开源 CSS 框架中的任何一个,如Bootstrap或Foundation,或者你可以开辟自己的道路,构建一个基本的网格,并按需引入元素。
为了简化起见,我们将使用 Bootstrap 来构建这个网上商店。这是一个非常流行的框架,易于使用,并且对 React 有很好的支持。
如前所述,我们将使用来自第一章的脚手架,ReactJS 初探。此外,我们还需要一些额外的包,最重要的是:react-bootstrap、react-router、lodash、Reflux、superagent和react-router-bootstrap。为了简化起见,将你的package.json文件中的依赖关系部分替换为这些值,并在命令行中运行npm install:
"devDependencies": {
"babel-preset-es2015": "6.9.0",
"babel-preset-react": "6.11.1",
"babelify": "7.3.0",
"browser-sync": "2.13.0",
"browserify": "13.0.1",
"browserify-middleware": "7.0.0",
"history": "3.0.0",
"jsxstyle": "0.0.18",
"lodash": "4.13.1",
"react": "15.1.0",
"react-bootstrap": "0.29.5",
"react-dom": "15.1.0",
"react-router": "2.5.2",
"react-router-bootstrap": "0.23.0",
"reactify": "1.1.1",
"reflux": "0.4.1",
"serve-favicon": "2.3.0",
"superagent": "2.1.0",
"uglifyjs": "2.4.10",
"watchify": "3.7.0"
}
--save-dev选项将依赖项保存到你的package.json文件中的devDependencies键下,如前述代码所示。在生产构建中,这些依赖项将不会安装,这使部署更快。我们将在第八章中查看如何创建生产构建,将你的应用部署到云端。如果你更愿意将这些包放在常规依赖关系部分,请使用--save而不是--save-dev,在你的package.json中,上述包将位于dependencies部分而不是devDependencies部分。
我们还需要 Bootstrap,我们将使用内容分发网络(CDN)来获取它。将以下代码片段添加到你的index.html文件的<head>部分:
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css" />
<link rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
你是否想支持旧版本的 Internet Explorer 是你的选择,但如果你这样做,你将需要在你的index.html文件的<head>部分添加这部分内容:
<!--[if lt IE 9]>
<script>
(function() {
var ef = function(){};
window.console = window.console || {log:ef,warn:ef,error:ef,dir:ef};
}());
</script>
<script src="img/html5shiv.min.js"></script>
<script src="img/html5shiv-printshiv.min.js"></script>
<script src="img/jquery.min.js"></script>
<script src="img/es5-shim.js"></script>
<script src="img/es5-sham.js"></script>
<![endif]-->
这将在你的代码库中添加一个polyfill。polyfill 添加了对旧浏览器不支持 HTML5 功能的支持。
我们还希望使用 Internet Explorer 的现代功能,所以让我们添加以下meta标签:
<meta http-equiv="X-UA-Compatible" content="IE=edge">
这个设置告诉浏览器根据最新的标准版本进行渲染。这个标签是在IE8中引入的,所以如果你的用户使用的是IE7或更低版本,这个标签就不会起作用。其他设置包括IE=5到IE=11和IE=EmulateIE7到IE=EmulateIE11。使用模拟指令告诉 Internet Explorer 如何在标准和怪癖模式下进行渲染。例如,EmulateIE9在标准模式下将页面渲染为IE9,在怪癖模式下渲染为IE5。
你选择的设置取决于你的目标平台,除非你有非常具体的 IE 版本在心中,否则选择IE=edge可能是最安全的选项。
为了让智能手机以正确的比例显示页面,我们还需要添加这个meta标签:
<meta name="viewport" content="width=device-width, initial-scale=1">
这条通知智能手机浏览器,你希望以 1 倍的比例全宽显示页面。你可以调整比例和宽度,但在大多数情况下,这个设置就是你所需要的。
添加你自己的 CSS 代码
我们已经在公共文件夹中有一个 CSS 文件。我们将使用一个非常基本的 CSS 布局,并在很大程度上依赖 Bootstrap。编辑public/app.css并将其替换为以下代码:
body {
background:#eee;
padding:62px 0 0 0;
}
.row {
padding:0 0 20px 0;
}
.summary {
border-bottom: 3px double black;
}
填充只是为了确保内容落在菜单内(我们将在下一节菜单和页脚中创建的菜单内)。
添加路由处理器
让我们打开app.jsx文件,移除初始脚手架中的所有内容,并用以下代码替换:
"use strict";
import React from "react";
import Router from "react-router";
import Routes from "./routes.jsx";
import { render } from "react-dom";
render (
Routes,
document.getElementById('container')
);
在我们的导入部分,我们现在添加react-router和一个名为routes.jsx的新文件。你会注意到我们通过括号封装来从react-router获取Route。这被称为解构,与使用var Route = require("react-router").Route获取它是相同的,后者很容易输入。
接下来,我们通过应用Router.run让路由器控制我们的应用,给它提供我们新的routes文件的内容,然后像之前一样将其挂载到具有id容器标签的<div>标签上。
当然,要运行这个,你需要创建一个名为router.jsx的文件。它应该看起来像这样:
"use strict";
import React from "react";
import Layout from './layout.jsx';
import { Router, Route, browserHistory } from 'react-router'
const Routes = (
<Router history={browserHistory}>
<Route handler={Layout} path="/">
</Route>
</Router>
);
module.exports = Routes;
如你所见,这相当直接,因为我们还没有创建任何路由。同样,我们正在导入react、react-router和route,以及一个名为layout.jsx的新文件,它将成为我们的主要路由处理器。
最后,我们将 Routes 的内容导出为 Routes。这是一个必要的步骤,因为这允许你在其他脚本中稍后导入它。你可以通过在模块声明中用module.exports =代替const Routes =来简化这一点,然后跳过最后一行。这是你的选择,但我认为将代码结构化,先放导入,然后是代码,最后是模块导出,是一个好的实践。
这就是应该放入layout.jsx文件的内容:
"use strict";
import React from "react";
const Layout = React.createClass ({
render() {
return (
<div>
{ React.cloneElement(
this.props.children,
this.state
) }
</div>
);
}
});
这个页面完全是空的。我们现在唯一添加的是路由处理器。这就是你的路由更改内容将放置的地方。你围绕它放置的所有内容在切换到新路由时都不会改变,所以这是放置静态元素(如页眉、页脚和边栏)的地方。
当你把这些东西组合在一起时,你就拥有了开始构建你的网店所需的所有部件。你现在已经实现了以下内容:
-
来自第一章的脚手架,深入 ReactJS
-
Bootstrap for ReactJS
-
处理路由更改的方法
-
为旧浏览器提供的 polyfill
当你运行这段代码时,如果你的网络浏览器显示一个空白网页,不要气馁。在这个阶段,这是预期的输出。
菜单和页脚
是时候开始工作于可见的菜单组件了。让我们从菜单和页脚开始。查看我们的原型,我们看到我们想要构建一个全宽的板块,包含商店的品牌名称和菜单链接,底部则想要一条居中的单行文本,包含版权声明。
我们将通过在layout.jsx文件的import部分添加以下导入来实现这一点:
import Menu from "./components/menu.jsx";
import Footer from "./components/footer";
将render函数替换为以下代码片段:
render() {
return (
<div>
<Menu />
{ React.cloneElement (
this.props.children,
this.state
) }
<Footer />
</div>
);
}
接下来,创建一个名为 components 的目录,并在其中放置一个名为 menu.jsx 的文件。在 menu.jsx 文件中添加以下代码:
"use strict";
import React from "react";
import { Nav, NavItem, Navbar, Button };
import { Link } from 'react-router';
import { LinkContainer } from "react-router-bootstrap";
这些导入通过解构引入了 Nav、NavItem、Navbar、Button 和 LinkContainer,正如在第一章 ReactJS 初探 中提到的:
const Menu = React.createClass ({
render() {
return (
<Navbar inverse fixedTop>
<Navbar.Header>
<Navbar.Brand>
<Link to="/">My webshop</Link>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
我们创建一个带有链接品牌名称的 Navbar 实例。如果你想用图片代替文本品牌,你可以插入一个 JSX 节点而不是文本字符串,如下所示:
brand={<span class="logo"><img src="img/" height="30" width="100" alt="My webshop" /></span>}.
fixedTop 选项创建了一个固定在屏幕顶部的 Navbar 实例。如果你想有一个浮动的 Navbar 实例,请将其替换为 staticTop。你也可以添加 inverse 以获得黑色 Navbar 实例而不是灰色实例:
<Navbar.Collapse>
<Nav>
<LinkContainer eventKey={1} to="/company">
<Button bsStyle="link">
About
</Button>
</LinkContainer>
<LinkContainer eventKey={2} to="/products">
<Button bsStyle="link">
Products
</Button>
</LinkContainer>
</Nav>
<Nav pullRight>
<LinkContainer to="/checkout">
<Button bsStyle="link">
Your cart: {this.props.cart.length} items
</Button>
</LinkContainer>
</Nav>
</Navbar.Collapse>
</Navbar>
我们在导航栏中添加了三个导航项,就像我们的原型一样。我们还提供了一个 right 关键字,以便你的 Navbar 实例中的项目向右对齐。这些链接将重定向到我们尚未制作的页面,因此我们将在下一步制作这些页面:
);
}
});
module.exports = Menu;
菜单部分就到这里。我们还需要添加页脚,所以请继续在 components 文件夹中添加一个名为 footer.jsx 的文件,并添加以下代码:
"use strict";
import React from "react";
const Footer = React.createClass({
render() {
return (
<footer className="footer text-center">
<div className="container">
<p className="text-muted">Copyright 2015 Your Webshop.
All rights reserved.
</p>
</div>
</footer>
);
}
});
module.exports = Footer;
创建页面
让我们创建一个名为 pages 的子目录,并添加以下文件:
-
pages/products.jsx:"use strict"; import React from "react"; const Products = React.createClass ({ render() { return ( <div /> ); } }); module.exports = Products; -
pages/company.jsx:"use strict"; import React from "react"; import { Grid, Row, Col, Panel } from "react-bootstrap"; const Company = React.createClass ({ render() { return ( <Grid> <Row> <Col xs={12}> <Panel> <h1>The company</h1> <p>Contact information</p> <p>Phone number</p> <p>History of our company</p> </Panel> </Col> </Row> </Grid> ); } }); module.exports = Company; -
pages/checkout.jsx:"use strict"; import React from "react"; const Products = React.createClass ({ render() { return ( <div /> ); } }); module.exports = Checkout; -
pages/receipt.jsx:"use strict"; import React from "react"; const Receipt = React.createClass ({ render() { return ( <div /> ); } }); module.exports = Receipt; -
pages/item.jsx:"use strict"; import React from "react"; const Item = React.createClass ({ render() { return ( <div /> ); } }); module.exports = Item; -
pages/home.jsx:"use strict"; import React from "react"; import { Grid, Row, Col, Jumbotron } from "react-bootstrap"; import { LinkContainer } from "react-router-botstrap"; import { Link } from 'react-router'; const Home = React.createClass ({ render() { return ( <Grid> <Row> <Col xs={12}> <Jumbotron> <h1>My webshop!</h1> <p> Welcome to my webshop. This is a simple information unit where you can showcase your best products or tell a little about your webshop. </p> <p> <LinkContainer to="/products"> <Button bsStyle="primary" to="/products">View products</Button> </LinkContainer> </p> </Jumbotron> </Col> </Row> </Grid> ); } }); module.exports = Home;
现在,让我们将我们刚刚创建的链接添加到我们的路由中。打开 routes.jsx 文件,并在导入部分添加以下内容:
import Products from "./pages/products.jsx";
import Home from "./pages/home.jsx";
import Company from "./pages/company.jsx";
import Item from "./pages/item.jsx";
import Checkout from "./pages/checkout.jsx";
import Receipt from "./pages/receipt.jsx";
将 <Route handler={Layout} path="/"></Route> 代码块替换为以下内容:
<Route handler={Layout}>
<Route name="home"
path="/"
handler={Home} />
<Route name="company"
path="company"
handler={Company} />
<Route name="products"
path="products"
handler={Products} />
<Route name="item"
path="item/:id"
handler={Item} />
<Route name="checkout"
path="checkout"
handler={Checkout} />
<Route name="receipt"
path="receipt"
handler={Receipt} />
</Route>
我们已经将 receipt 页面添加到我们的文件结构和路由中,但它不会在菜单栏中可见,因为你应该只在检查订单后重定向到 receipt 页面。
如果你现在运行应用,你会在屏幕顶部看到一个菜单栏,你可以点击它。你会注意到,当你点击任何菜单选项时,应用会路由到所选页面。你还会注意到所选路由将在菜单栏中突出显示,这使得在不查看地址栏中的路由的情况下很容易知道你在哪里。
当你在网页浏览器中的响应式模式下打开应用或在智能手机上打开应用时,你会注意到菜单会折叠,并且出现一个汉堡按钮而不是菜单链接。当你点击这个按钮时,菜单会展开并显示下拉菜单中的链接。
创建产品数据库
你的商店需要产品,所以我们将为我们的网店提供一组小项目。
这类数据通常存储在某种数据库中。数据库可以是本地或远程自行提供的,或者您可以使用众多基于云的数据库服务中的任何一个。传统上,您会使用基于 SQL(结构化查询语言)的数据库,但如今,选择 NoSQL 基于文档的方法更为常见。这就是我们为我们的网店所采取的方法,我们将简单地使用平面文件来存储数据。
创建一个文件,命名为 products.json,保存在 public 文件夹中,并添加以下内容:
{
"products": {
"main_offering": [
{
"World's best novel": {
"SKU": "NOV",
"price": "$21.90",
"savings": "24% off",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"image": "http://placehold.it/{size}&text=The Novel"
}
}
],
"sale_offerings": [
{
"Fantasy book": {
"SKU": "FAN",
"price": "$6.99",
"savings": "80% off",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"image": "http://placehold.it/{size}&text=Fantasy"
}
},
{
"Mystery book": {
"SKU": "MYS",
"price": "$8.99",
"savings": "34% off",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"image": "http://placehold.it/{size}&text=Mystery"
}
},
{
"Adventure book": {
"SKU": "ADV",
"price": "$7.99",
"savings": "62% off",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"image": "http://placehold.it/{size}&text=Adventure"
}
},
{
"Science fiction book": {
"SKU": "SCI",
"price": "$5.99",
"savings": "32% off",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"image": "http://placehold.it/{size}&text=Sci-Fi"
}
},
{
"Childrens book": {
"SKU": "CHI",
"price": "$7.99",
"savings": "12% off",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"image": "http://placehold.it/{size}&text=Childrens"
}
},
{
"Economics book": {
"SKU": "ECO",
"price": "$25.99",
"savings": "7% off",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
"image": "http://placehold.it/{size}&text=Economics"
}
}
]
}
}
此文件相当于在 NoSQL 数据库中插入一些产品时您会找到的内容,例如 MongoDB。语法是 JSON(JavaScript 对象表示法),一种开放格式,以属性-值对的形式传输数据。它是简单的且与语言无关的,仅通过查看前面的结构,您就可以轻松理解其数据结构和内容。
字段应该是自解释的,但让我们来了解一下。有两种产品组,一个是主系列,另一个是促销系列。主系列只有一个项目,促销系列有六个。每个列表中的产品都有一个标题、一个 SKU(库存单位,例如,产品代码)、一个价格、一个方便格式的节省文本、一个描述和一个图片 URL。我们选择插入一个占位符代码来表示图片的像素大小,因为我们希望能够在向用户展示图片时动态更改大小。
我们希望通过访问 http://localhost:3000/products.json 来访问此文件,因此我们需要在 server.js 中添加一些内容。编辑此文件,并在 app.listen 行之前添加以下代码,然后重新启动服务器:
// json
app.get('*.json', function (req, res) {
res.sendFile(__dirname+"/public/"+req.path);
});
当您访问 http://localhost:3000/products.json 时,应该提供我们的产品。
创建一个数据存储来获取产品
建议与 ReactJS 一起使用的应用程序架构称为 Flux。虽然它不是一个框架,但可以看作是一种传输数据的设计模式。
Flux 由三个主要部分组成:调度器、存储和动作。Flux 背后的核心思想是一个称为 单向数据流 的概念。这个想法是您的应用应该有一个存储来保存您的数据,并且您的组件应该监听它以获取更新。您通过调度器与之交互,可以将调度器视为传递指令到您的动作的信使。在您的动作中,您可以获取新数据并将其传递给存储,然后存储会向您的组件发射数据。
这种模式避免了常见的多个地方需要更新应用程序状态的问题,这通常会导致难以追踪的 bug。
这可能有点难以消化,所以让我们快速看一下各个组件:
-
调度器:这是中心枢纽。它接收动作并向所有已注册的回调发送有效载荷。
-
动作:这些是指辅助方法,它们便于将数据传递给分发器。
-
存储:这些是逻辑容器,它们在分发器上注册了回调,向所有注册的回调发出状态变化。
-
视图:这指的是从存储中获取状态并将数据传递给其组件树中任何后代的 React 组件。
有许多不同的 Flux 实现。对于本章,我选择了 Reflux 作为 Flux 实现,但在 第六章 高级 React 中,我们将查看一个名为 Redux 的不同实现,以及在 第七章 Reactagram 中的替代方案。
Reflux 放弃了单个中央分发器的概念,选择合并分发器和动作的概念。这使得我们可以用更少的代码实现,并导致代码库更容易理解。
让我们创建一个 Reflux 实现。
我们已经在章节开头启动应用程序时安装了 Reflux 和名为 Superagent 的 HTTP 请求库,我们将使用它来获取我们的产品数据,所以我们可以立即开始使用 Reflux。
让我们创建我们的第一个存储。创建两个文件夹:stores 和 actions。创建两个文件,stores/products.js 和 actions/products.js。
注意
Stores 和 Actions 是常规的 JavaScript 文件,并且与 ReactJS 组件不同,它们不使用 .jsx 文件扩展名。
在 actions/products.js 中添加以下代码:
"use strict";
import Reflux from 'reflux';
const Actions = {
FetchProducts: Reflux.createAction("FetchProducts")
};
module.exports = Actions;
在这个文件中,我们定义了一个名为 FetchProducts 的单个键。然后我们分配了一个同名的 Reflux 动作。虽然可以定义不同的名称,但这可能会导致后续的混淆,因此为了保持代码库的整洁,建议重复键名。
在 stores/products.js 中添加以下代码:
"use strict";
import Reflux from 'reflux';
import Request from 'superagent';
import Actions from './actions/products';
在这里,我们导入我们刚刚创建的动作以及 superagent 和 reflux:
const ProductStore = Reflux.createStore ({
init() {
this.listenTo(Actions.FetchProducts, this.onFetchProducts);
init() 方法将只执行一次,并在导入时立即运行。这意味着它将立即执行你在 init() 中放入的所有内容:
},
onFetchProducts() {
Request
.get('/products.json')
.end((err, res)=> {
this.trigger(JSON.parse(res.text));
});
}
在这里,我们简单地访问 product.json,当它加载时,我们将结果发出给所有监听这个存储更新的组件。使用 Reflux 的 this.trigger() 内置方法发出,它发出括号内传递的对象:
});
module.exports = ProductStore;
现在已经处理好了这个问题,下一步是在我们的代码中监听这个存储的更新。打开 layout.jsx 并添加以下导入:
import Actions from "./actions/products"
import ProductStore from "./stores/products"
然后,在 render() 方法之上添加以下代码:
mixins:[
Reflux.listenTo(ProductStore, 'onFetchProducts')
],
componentDidMount() {
Actions.FetchProducts();
},
onFetchProducts(data){
this.setState({products: data.products});
},
这很令人兴奋,因为我们终于开始用内容填充我们的应用程序了。现在每当存储发出数据时,这个组件都会接收到并通过状态对象将其传播给其子组件。
构建产品列表和项目页面
我们现在要构建的视图将向用户展示您的书名选择。它将以一个全尺寸的主要产品列开始,然后提供三个较小的列中的其他产品。
让我们打开 pages/products.jsx 文件,并编写显示产品数据的代码。将文件中的所有内容替换为以下代码:
"use strict";
import React from "react";
import { Grid, Row, Col, Button } from "react-bootstrap";
import { Link } from "react-router";
const Products = React.createClass ({
propTypes: {
products: React.PropTypes.object
},
getDefaultProps() {
return {
products: {
main_offering: [],
sale_offerings: []
}
}
},
render() {
return (
<Grid>
<Offerings productData={this.props.products.main_offering}
type={"main"} maxProducts={1}/>
<Offerings productData={this.props.products.sale_offerings}
type={"ribbon"} maxProducts={3}/>
</Grid>
);
}
});
我们期望接收到一个名为 products 的 data 属性,包含两个列表:一个主要产品和销售产品。您可能还记得这些内容来自 products.json,我们在其中定义了它们。在我们的渲染代码中,我们创建了一个 Bootstrap 网格,并使用一个名为 offerings 的新组件创建了两个节点。我们向这个组件提供了三个属性:产品列表、类型以及我们想要显示的最大产品数量。在这个上下文中,type 是一个字符串,可以是 main 或 ribbon:
const Offerings = React.createClass ({
propTypes: {
type: React.PropTypes.oneOf(['main', 'ribbon']),
maxProducts: React.PropTypes.number,
productData: React.propTypes.array
},
getDefaultProps() {
return {
type: "main",
maxProducts: 3
}
},
render() {
let productData = this.props.productData.filter((data, idx)=> {
return idx < this.props.maxProducts;
});
let data = productData.map((data, idx)=> {
if(this.props.type === "main") {
return <MainOffering
{...this.props} key={idx}
productData={data}/>
}
else if(this.props.type === "main") {
return <RibbonOffering
{...this.props} key={idx}
productData={data}/>
}
});
return <Row>{data}</Row>;
}
});
在 map 函数中,我们分配了一个新的属性名为 key。这是为了帮助 ReactJS 唯一地识别组件。任何带有 key 的组件都将在渲染过程中重新排序和复用。
当您处理 props 时,通常定义一组默认属性来处理数据是个好主意。这也是通过编写易于理解的代码进行文档记录的一种方式。在这个例子中,仅通过查看属性类型和默认属性,就可以很容易地推断出 maxProducts 定义了要显示的最大产品数量。然而,type 仍然难以理解。正如您所知,它是一个字符串,可以是 main。知道它也可以被分配为 ribbon 是您需要阅读其余源代码才能理解的事情。在这些情况下,提供可选值在 docblock 代码中可能很有帮助。例如,通过添加如下 docblock 来记录这个属性:@param {string} type "main"|"ribbon"。
通过对产品列表应用 filter 函数来减少产品数据,通过 index 值返回第一个匹配项。然后我们对剩余的数据运行 map 函数,如果 type 是 main,则返回一个 MainOffering 组件;如果 type 是 ribbon,则返回一个 RibbonOffering 组件:
const MainOffering = React.createClass ({
propTypes: {
productData: React.PropTypes.object
},
render() {
const title = Object.keys(this.props.productData);
if(this.props.productData[title]){
(<Col xs={12}>
<Col md={3} sm={4} xs={12}>
<p>
<img src={this.props.productData[title].
image.replace("{size}","200x150")}/>
</p>
</Col>
<Col md={9} sm={8} xs={12}>
<Link to={"/item/"+this.props.productData[title].SKU}>
<h4>{title}</h4>
</Link>
<p>
{this.props.productData[title].description}
</p>
<p>
{this.props.productData[title].price}
{" "}
({this.props.productData[title].savings})
</p>
<p>
<Button bsSize="large">Add to cart</Button>
</p>
</Col>
</Col>
)} else {
return null;
}
}
});
MainOffering 组件创建了一个全尺寸的列,左侧有一个大型的产品图片,同时也在右侧创建了一个价格、描述和购买按钮。产品图片通过将 {size} 模板替换为一个 string 值来获得 200 x 150 的尺寸。Placehold.it 是一个方便的服务,您可以使用它来显示占位图,直到您有真实图片可以展示。网上有众多此类服务,从简单的,如 placehold.it,到展示狗、猫、自然、科技和建筑的服务:
const RibbonOffering = React.createClass ({
propTypes: {
productData: React.PropTypes.object
},
render() {
const title = Object.keys(this.props.productData);
if(this.props.productData) {
return (<Col md={4} sm={4} xs={12}>
<Col xs={12}>
<p>
<img src={this.props.productData[title].image.
replace("{size}","200x80")}/>
</p>
</Col>
<Col xs={12}>
<Link to={"/item/"+this.props.productData[title].SKU}>
<h4>{title}</h4>
</Link>
<p>
{this.props.productData[title].description}
</p>
<p>
{this.props.productData[title].price}
{" "}
({this.props.productData[title].savings})
</p>
<p>
<Button bsSize="large">Add to cart</Button>
</p>
</Col>
</Col>)
}
else {
return null;
}
}
});
这里值得提一下,在render()方法中,如果this.props.productData有标题,我们要么返回一个 ReactJS 节点,要么返回null。我们这样做的原因是因为当组件挂载时,productData将是未填充的。如果我们此时尝试使用该属性,ReactJS 将返回一个错误。一旦数据在store中获取,它就会填充,这可能需要几毫秒,也可能需要更长的时间,这取决于许多因素,但主要取决于延迟,这意味着在组件挂载时数据很可能不可用。无论如何,你不应该依赖这一点,所以最好在数据可用之前不返回任何内容:
module.exports = Products;
我们在这个文件中定义了多个组件,但请注意,我们只导出主组件,称为 products。其他组件不会通过解构获得,因为它们没有被导出。
我们已经将项目链接到item页面,因此我们需要完善它并在客户访问此页面时检索项目数据。
打开pages/item.jsx并将内容替换为以下代码:
"use strict";
import React from "react";
import Reflux from "reflux";
import { Router, State } from "react-router";
import { Grid, Row, Col, Button } from "react-bootstrap";
import CartActions from "../actions/cart";
const Item = React.createClass ({
mixins: [
Router.State
],
render() {
if (!this.props.products) return null;
// Find the requested product in our product list
let products = this.props.products.main_offering.concat(this.props.products.sale_offerings);
let data = products.filter((item)=> {
return item[Object.keys(item)].SKU ===
this.props.routeParams.id;
});
在这里,我们利用了所有产品都作为此页面的属性存在的事实,并且它们只是从完整的产品列表中返回一个过滤后的对象列表。过滤基于this.getParams().id。这是react-router提供的一个内置mixin,它获取在routes.jsx中定义的id键。
mixin是一段包含可以在其他代码中包含而不使用继承的方法的代码。这很方便,因为它允许轻松的代码注入和重用。这也存在一些缺点,因为不加批判地使用 mixins 可能会导致你使用的代码来源混淆:
if(!data.length){
return (<Grid>
<Row>
<Col xs={12}>
<h1>Product missing</h1>
<p>
I'm sorry, but the product could not be found.
</p>
</Col>
</Row>
</Grid>)} else {
return (<Grid>
<Row>
<Col xs={12}>
<ProductInfo productData={data[0]}/>
</Col>
</Row>
</Grid>
)};
}
});
这个返回声明检查新对象列表的长度,并提供项目信息或一个通知客户产品找不到的信息块:
const ProductInfo = React.createClass ({
propTypes: {
productData: React.PropTypes.object
},
render() {
const title = Object.keys(this.props.productData);
if(this.props.productData[title]){
(<Col xs={12}>
<Col md={3} sm={4} xs={12}>
<p>
<img src={this.props.productData[title].
image.replace("{size}","200x150")}/>
</p>
</Col>
<Col md={9} sm={8} xs={12}>
<h4>{title}</h4>
<p>
{this.props.productData[title].description}
</p>
<p>
{this.props.productData[title].price}
{" "}
({this.props.productData[title].savings})
</p>
<p>
<Button bsSize="large"
onClick={CartActions.AddToCart.
bind(null, this.props.productData)}>
Add to cart
</Button>
</p>
</Col>
</Col>
)}
else {
return null;
}
}
});
module.exports = Item;
最后一段代码打印出产品信息。
最终结果应该如下所示:

最后一个拼图部分添加了将项目放入购物车的动作。为此,我们需要创建另一个动作文件和一个购物车存储。
创建购物车存储
我们需要向我们的项目中添加两个额外的文件,actions/cart.js和store/carts.js。创建这些文件并将以下代码添加到actions文件中:
"use strict";
import Reflux from "reflux";
const Cart = {
AddToCart: Reflux.createAction("AddToCart"),
RemoveFromCart: Reflux.createAction("RemoveFromCart"),
ClearCart: Reflux.createAction("ClearCart")
};
module.exports = Cart;
我们定义了三个动作,一个用于添加项目,一个用于移除它们,第三个用于清空购物车。
打开store/carts.js并添加以下代码段:
"use strict";
import Reflux from "reflux";
import CartActions from "../actions/cart";
let _cart = {cart: []};
这是我们的store对象。在CartStore本身之外初始化它使其成为私有的和隐藏的,使得无法直接导入CartStore并修改store对象。通常,但不是必须的,在这样对象前加上下划线。这仅仅是一种表示我们正在处理一个private对象的方式:
const CartStore = Reflux.createStore ({
init() {
this.listenTo(CartActions.AddToCart, this.onAddToCart);
this.listenTo(CartActions.RemoveFromCart, this.onRemoveFromCart);
this.listenTo(CartActions.ClearCart, this.onClearCart);
},
这些是我们将监听并响应的操作。每当在代码中调用前面的任何操作时,我们将连接到该操作的函数将被执行:
onAddToCart(item){
_cart.cart.push(item);
this.emit();
},
当我们在代码中调用CartActions.AddToCart并传递一个项目时,此代码将项目添加到我们的cart对象中。然后我们调用this.emit(),这是我们的存储发射器。我们也可以直接调用this.trigger(这是原生的Reflux函数,用于发出数据),但如果你需要在发出数据之前执行任何函数或代码,有一个负责发出数据的单一函数是有益的:
onRemoveFromCart(item) {
_cart.cart = _cart.cart.filter((cartItem)=> {
return item !== cartItem
});
this.emit();
},
这个函数使用 JavaScript 内置的filter函数从我们的cart对象中移除一个项目。filter函数在调用时返回一个新数组,排除了我们想要移除的项目。然后我们简单地发出修改后的cart对象:
onClearCart() {
_cart.cart = [];
this.emit();
},
这将重置购物车并发出空的cart对象:
emit() {
this.trigger(_cart);
}
在这个函数中,我们发出cart对象。任何监听此存储的组件都将接收到对象并渲染新数据:
});
module.exports = CartStore;
我们还希望向用户提供一些关于其购物车状态的指示,所以打开menu.jsx并将Checkout部分的NavItemLink替换为以下代码片段:
<NavItemLink
to="/checkout">
Your cart: {this.props.cart.length} items
</NavItemLink>
在render()之前,添加一个defaultProps部分并包含以下代码:
getDefaultProps() {
return {
cart: []
}
},
所有状态更改都通过layout.jsx进行,所以打开此文件并添加以下导入:
import CartStore from "./stores/cart"
在mixins部分,添加对cart存储和当购物车发出数据时要运行的函数的监听器。现在的代码应该看起来像这样:
mixins: [
Reflux.listenTo(ProductStore, 'onFetchProducts'),
Reflux.listenTo(CartStore, 'onCartUpdated')
],
onCartUpdated(data){
this.setState({cart: data.cart});
},
Menu组件需要接收新状态,所以提供以下代码:
<Menu {...this.state} />
最后,我们需要将操作添加到添加到购物车按钮上。编辑pages/products.jsx并将MainOffering和RibbonOffering中的按钮代码替换为以下代码:
<Button bsSize="large"
onClick={CartActions.AddToCart.bind(null,
this.props.productData)}>
Add to cart
</Button>
还需要在导入部分添加以下代码行:
import CartActions from "../actions/cart";
你已经设置好了。现在当你点击产品页面上的添加到购物车按钮时,购物车将立即更新,菜单计数也将立即更新。

结账
如果你的客户不能结账,那么网店有什么用呢?毕竟,他们来这里就是为了这个。让我们设置一个结账屏幕,并让客户输入一个送货地址。
我们需要创建一些新的文件:stores/customer.js、actions/customer.js和components/customerdata.jsx。
打开actions/customer.js并添加以下代码:
"use strict";
import Reflux from "reflux";
const Actions = {
SaveAddress: Reflux.createAction("SaveAddress")
};
module.exports = Actions;
这个单一的操作将负责地址管理。
接下来,打开stores/customer.js并添加以下代码:
"use strict";
import Reflux from "reflux";
import CustomerActions from "../actions/customer";
let _customer = {customer: [], validAddress: false};
就像在cart.js中一样,我们在这里定义一个private对象来存储存储的状态。正如你可以从对象定义中读到的,我们将存储客户列表和布尔地址验证器。我们还将导入我们刚刚创建的客户操作文件:
const CustomerStore = Reflux.createStore({
init() {
this.listenTo(CustomerActions.SaveAddress, this.onSaveAddress);
},
onSaveAddress(address) {
_customer = address;
this.emit();
},
emit() {
this.trigger(_customer);
}
});
module.exports = CustomerStore;
你可以从 cart.js 文件中识别出这段代码的结构。我们监听 SaveAddress 动作,并在动作被调用时执行连接的函数。最后,每当状态对象发生变化时,都会调用发射器。
在我们编辑最后一个新文件之前,让我们打开 checkout.jsx 并设置我们需要的代码。用以下代码替换当前内容:
"use strict";
import React from "react";
import { Grid, Button, Table, Well } from "react-bootstrap";
import CartActions from "../actions/cart";
import CustomerData from "../components/customerdata";
我们从 React-Bootstrap 导入两个新函数和两个我们刚刚创建的新文件:
const Checkout = React.createClass ({
propTypes: {
cart: React.PropTypes.array,
customer: React.PropTypes.object
},
getDefaultProps() {
return {
cart: [],
customer: {
address: {},
validAddress: false
}
}
},
在本节中,我们使用两个属性初始化组件:一个 cart 数组和一个 customer 对象:
render() {
let CheckoutEnabled = (this.props.customer.validAddress && this.props.cart.length > 0);
return (
<Grid>
<Well bsSize="small">
<p>Please confirm your order and checkout your cart</p>
</Well>
<Cart {...this.props} />
<CustomerData {...this.props} />
<Button disabled={!CheckoutEnabled}
bsStyle={CheckoutEnabled ?
"success" : "default"}>
Proceed to checkout
</Button>
</Grid>
);
}
});
我们定义一个布尔变量来控制结账按钮是否可见。我们的要求很简单,我们希望购物车中至少有一件商品,并且客户已经输入了有效的姓名地址。
我们然后在 Bootstrap well 中向客户显示一条简单的消息。接下来,我们显示购物车内容(我们将在下面的代码片段中定义),然后,我们展示一系列输入字段,客户可以在其中添加地址。最后,我们显示一个按钮,将用户带到支付窗口:
const Cart = React.createClass ({
propTypes: {
cart: React.PropTypes.array
},
render() {
let total = 0;
this.props.cart.forEach((data)=> {
total += parseFloat(data[Object.keys(data)].
price.replace("$", ""));
});
let tableData = this.props.cart.map((data, idx)=> {
return <CartElement productData={data} key={idx}/>
});
if (!tableData.length) {
tableData = (<tr>
<td colSpan="3">Your cart is empty</td>
</tr>);
}
return <Table striped condensed>
<thead>
<tr>
<th width="40%">Name</th>
<th width="30%">Price</th>
<th width="30%"></th>
</tr>
</thead>
<tbody>
{tableData}
<tr className="summary" border>
<td><strong>Order total:</strong></td>
<td><strong>${total}</strong></td>
<td>
{tableData.length ?
<Button bsSize="xsmall" bsStyle="danger"
onClick={CartActions.ClearCart}>
Clear Cart
</Button> : null}
</td>
</tr>
</tbody>
</Table>;
}
});
购物车是另一个 React 组件,负责显示包含客户购物车内容的表格,包括订单的总金额。我们初始化一个用于总金额的变量并将其设置为零。然后,我们使用 JavaScript 内置的 forEach 函数遍历 cart 内容并创建订单总金额。由于价格从 JSON 文件中带有美元符号,我们需要在添加总和之前将其删除(否则 JavaScript 将简单地连接字符串)。我们还使用 parseFloat 将字符串转换为浮点数。
实际上,这不是一个理想的解决方案,因为你不想在处理价格时使用浮点数。
小贴士
尝试使用 JavaScript 将 0.1 和 0.2 相加,以了解原因(提示:它们不会等于 0.3)。
最佳解决方案是使用整数,并在需要显示小数时除以 100。因此,products.json 可以更新为包含一个价格字段,如下所示:"display_price": "$21.90","price": "2190"。然后,我们在代码中使用 price,但在视图中使用 display_price。
接下来,我们再次遍历我们的购物车内容,但这次使用 JavaScript 内置的 map 函数。我们返回一个新数组,该数组包含 CartElement 节点。然后我们渲染一个表格并插入我们刚刚创建的新数组:
const CartElement = React.createClass ({
render() {
const title = Object.keys(this.props.productData);
if(title) {
(<tr>
<td>{title}</td>
<td>{this.props.productData[title].price}</td>
<td>
<Button bsSize="xsmall" bsStyle="danger"
onClick={CartActions.RemoveFromCart.bind
(null, this.props.productData)}>
Remove
</Button>
</td>
</tr>
)
}
else {
return null
}
}
}
);
module.exports = Checkout;
CartElement 组件应该对你很熟悉,只有一个例外,onClick 方法有一个绑定。我们这样做是因为我们想在客户点击删除按钮时传递产品数据。绑定中的第一个元素是事件,第二个是数据。我们不需要传递事件,所以我们将其简单地设置为 null。
让我们看一下以下截图:

我们还需要添加 customerdata.jsx 的代码,让我们打开这个文件并添加以下代码:
"use strict";
import React from "react";
import { FormGroup, FormControl, InputGroup, Button }
from "react-bootstrap";
import CustomerActions from "../actions/customer";
import clone from 'lodash/clone';
const CustomerData = React.createClass ({
getDefaultProps() {
return {
customer: {
address: {},
validAddress: false
}
}
},
getInitialState() {
return {
customer:{
name: this.props.customer.address.name ?
this.props.customer.address.name : "",
address: this.props.customer.address.address ?
this.props.customer.address.address : "",
zipCode: this.props.customer.address.zipCode ?
this.props.customer.address.zipCode : "",
city: this.props.customer.address.city ?
this.props.customer.address.city : ""
},
validAddress: this.props.customer.validAddress ?
this.props.customer.validAddress : false
};
},
这可能看起来有点复杂,但这里的想法是,如果存在,我们将客户名称和地址验证设置为与this.props相同,如果不存在,我们使用空字符串的默认值,并将地址验证的布尔值设置为false。
我们这样做的原因是我们想显示客户如果选择在结账屏幕上添加数据,但后来决定在继续到结账屏幕之前访问商店的另一个部分时输入的数据。
validationStateName() {
if (this.state.customer.name.length > 5)
return "success";
else if (this.state.customer.name.length > 2)
return "warning";
else
return "error";
},
handleChangeName(event) {
let customer = clone(this.state.customer);
customer.name = event.target.form[0].value;
this.setState({
customer,
validAddress: this.checkAllValidations()
});
CustomerActions.SaveAddress(this.state);
},
这是四个类似部分中的第一个,这些部分处理输入验证。验证仅基于字符串长度,但可以替换为任何所需的验证逻辑。
在handleChangeName中,我们克隆状态到一个局部变量(确保我们不会意外地手动修改状态),然后,我们从输入字段设置新的名称值。输入值通过refs获取,这是 ReactJS 的一个概念。可以将引用设置到任何元素,并通过this.refs访问。
接下来,在每次更改时,我们检查我们已设置的所有验证。如果所有验证都有效,我们将地址验证器设置为布尔值true。最后,我们保存状态,然后运行将新地址存储在客户存储中的操作。此更改将发出到layout.jsx,然后该文件将数据传回此组件以及其他监听客户存储的组件。
validationStateAddress() {
if (this.state.customer.address.length > 5)
return "success";
else if (this.state.customer.address.length > 2)
return "warning";
else
return "error";
},
handleChangeAddress(event) {
let customer = clone(this.state.customer);
customer.address =
event.target.form[1].value;
this.setState({
customer,
validAddress: this.checkAllValidations()
});
CustomerActions.SaveAddress(this.state);
},
validationStateZipCode() {
if (this.state.customer.zipCode.length > 5)
return "success";
else if (this.state.customer.zipCode.length > 2)
return "warning";
else
return "error";
},
handleChangeZipCode(event) {
let customer = clone(this.state.customer);
customer.zipCode =
event.target.form[2].value;
this.setState({
customer,
validAddress: this.checkAllValidations()
});
CustomerActions.SaveAddress(this.state);
},
validationStateCity() {
if (this.state.customer.city.length > 5)
return "success";
else if (this.state.customer.city.length > 2)
return "warning";
else
return "error";
},
handleChangeCity(event) {
let customer = clone(this.state.customer);
customer.city =
event.target.form[3].value;
this.setState({
customer,
validAddress: this.checkAllValidations()
});
CustomerActions.SaveAddress(this.state);
},
checkAllValidations() {
return ("success" == this.validationStateName() &&
"success" == this.validationStateAddress() &&
"success" == this.validationStateZipCode() &&
"success" == this.validationStateCity());
},
此函数根据所有验证检查返回布尔值true或false。
render() {
return (
<div>
<form>
<FormGroup>
<FormControl
type="text"
value={ this.state.customer.address.name }
placeholder="Enter your name"
label="Name"
bsStyle={ this.validationStateName() }
hasFeedback
onChange={ this.handleChangeName }
/>
</FormGroup>
在这里,我们使用 Bootstrap 的FormGroup和FormControl函数,并根据验证检查设置样式。我们在这里设置ref参数,我们使用它来访问在我们在客户存储中保存名称时的值。每次输入字段更改时,它都会发送到onChange处理程序,handleChangeName。其余的输入字段相同,只是它们调用不同的更改处理程序和验证器:
<FormGroup>
<FormControl
type="text"
value={ this.state.customer.address }
placeholder="Enter your street address"
label="Street "
bsStyle={ this.validationStateAddress() }
hasFeedback
onChange={ this.handleChangeAddress } />
</FormGroup>
<FormGroup>
<FormControl
type="text"
value={ this.state.customer.zipCode }
placeholder="Enter your zip code"
label="Zip Code"
bsStyle={ this.validationStateZipCode() }
hasFeedback
onChange={ this.handleChangeZipCode } />
</FormGroup>
<FormGroup>
<FormControl
type="text"
value={ this.state.customer.city }
placeholder="Enter your city"
label="City"
bsStyle={this.validationStateCity()}
hasFeedback
onChange={this.handleChangeCity}/>
</FormGroup>
</form>
</div>
);
}
});
module.exports = CustomerData;
为了将新客户存储中的更改从布局传播到子组件,我们需要在layout.jsx中进行更改。打开文件并添加以下导入:
import CustomerStore from "./stores/customer"
然后,在 mixins 中添加以下代码行:
Reflux.listenTo(CustomerStore, 'onCustomerUpdated')
提供收据
下一个逻辑步骤是处理支付并为客户提供一个收据。对于支付,您需要一个支付提供商的账户,例如PayPal、Klarna、BitPay等。集成通常非常直接,如下所示:
-
您连接到支付提供商提供的数据 API。
-
传输您的 API 密钥和订单数据。
-
支付过程完成后,支付提供商将重定向到您的收据页面,并告知支付是否成功。
将支付 API 的连接设置到结账按钮上。由于每个支付提供商的集成方式不同,我们将简单地提供一个收据页面而不验证支付。
打开checkout.jsx并添加以下导入:
import { LinkContainer } from "react-router-bootstrap";
然后,将结账按钮替换为以下代码:
<LinkContainer to="/receipt">
<Button
disabled={!CheckoutEnabled}
bsStyle= {
CheckoutEnabled ? "success" : "default"
}>
Proceed to checkout
</Button>
</LinkContainer>
打开receipt.jsx文件,并用以下代码替换内容:
"use strict";
import React from "react";
import { Grid, Row, Col, Panel, Table } from "react-bootstrap";
import Router from "react-router";
import CartActions from "../actions/cart"
const Receipt = React.createClass ({
mixins: [
Router.Navigation
],
componentDidMount() {
if(!this.props.cart.length) {
this.props.history.pushState('/');
}
},
在这里,我们调用history方法并通知它,如果没有购物车数据,则将客户发送到主页。这是一个简单的验证,用于检查客户是否在预定义路径之外进入了收据页面。
这个解决方案并不非常健壮。当你与支付服务提供商设置时,你会向提供商发送一个标识符。你需要存储这个标识符,并使用它来决定是否显示收据页面以及显示什么内容:
propTypes: {
cart: React.PropTypes.array,
customer: React.PropTypes.object
},
getDefaultProps() {
return {
cart: [],
customer: {
address: {},
validAddress: false
}
}
},
componentWillUnmount() {
CartActions.ClearCart();
},
render() {
let total = 0;
this.props.cart.forEach((data)=> {
total += parseFloat(data[Object.keys(data)].
price.replace("$", ""));
});
let orderData = this.props.cart.map((data, idx)=> {
return <OrderElement productData={data} key={idx}/>
});
return (
<Grid>
<Row>
<Col xs={12}>
<h3 className="text-center">
Invoice for your purchase</h3>
</Col>
</Row>
<Row>
<Col xs={12} md={12} pullLeft>
<Panel header={"Billing details"}>
{this.props.customer.address.name}<br/>
{this.props.customer.address.address}<br/>
{this.props.customer.address.zipCode}<br/>
{this.props.customer.address.city}
</Panel>
</Col>
<Col xs={12} md={12}>
<Panel header={"Order summary"}>
<Table>
<thead>
<th>Item Name</th>
<th>Item Price</th>
</thead>
{orderData}
<tr>
<td><strong>Total</strong></td>
<td>${total}</td>
</tr>
</Table>
</Panel>
</Col>
</Row>
</Grid >
);
}
});
我们在这里重用了结账页面的代码来显示购物车内容和订单总额。我们还创建了一个新的OrderElement组件来显示客户购物车中的项目列表:
const OrderElement = React.createClass ({
render() {
const title = Object.keys(this.props.productData);
if(title) {
(<tr>
<td>{title}</td>
<td>{this.props.productData[title].price}</td>
</tr>
)
}
else {
return null;
}
}
}
);
module.exports = Receipt;

摘要
我们已经完成了第一个蓝图,即网店。你现在拥有了一个使用 ReactJS 构建的完全功能性的商店。让我们看看在本章中我们构建了什么。
首先,我们详细说明了我们需要创建的组件,并制作了一个基本的原型,展示了我们希望网站的外观。我们希望设计能够响应式,内容能够在从最小的智能手机到平板电脑和桌面计算机屏幕的各种设备上可见。
然后,我们着手布局,并选择使用 Bootstrap 来帮助我们实现响应式功能。我们采用了第一章的脚手架,深入 ReactJS,并通过添加来自npm注册表的一小部分节点模块来扩展它,主要是react-router、react-bootstrap和基于承诺的请求库superagent。
我们基于单向数据流的概念构建了网店,遵循了已建立的 Flux 模式,其中动作返回到存储库,存储库向组件发出数据。此外,我们还设置了所有数据都通过中央应用程序路由,并作为属性传播到子组件。这是一个强大的模式,因为它让你对数据的来源没有任何疑问,并且应用程序的每个部分都可以访问相同的数据和状态。
在制作网店的过程中,我们解决了许多技术难题,例如路由、表单验证和数组过滤。
最终的应用程序是一个基本的可工作的网店,可以进一步开发和设计。
在下一章中,我们将探讨如何使用 ReactJS 开发响应式应用程序!
第三章:使用 ReactJS 进行响应式 Web 开发
几年前,构建 Web 应用相对容易。您的 Web 应用在具有大致相同屏幕尺寸的台式机和笔记本电脑上查看,并且可以创建一个轻量级的移动版本来服务访问您网站的少量移动用户。如今,情况已经逆转,移动设备同样重要,有时甚至比台式机和笔记本电脑更重要。今天的屏幕尺寸可以从 4 英寸智能手机到 9 英寸平板电脑,以及任何介于两者之间的尺寸。
在本章中,我们将探讨构建适用于任何设备(无论大小或应用是否在桌面或移动浏览器上查看)的 Web 应用的实践。目标是创建一个能够适应用户设置并为每个人提供愉悦体验的应用环境。
“响应式开发”这个术语是一个涵盖一系列设计技术(如自适应、流体、液体或弹性布局,以及混合或移动开发)的通用术语。它可以分为两个主要组件:灵活布局和灵活媒体内容。
在这些主题中,我们将涵盖创建响应式 ReactJS 应用所需的所有内容:
-
创建灵活布局
-
选择合适的框架
-
使用 Bootstrap 设置响应式应用
-
创建灵活的网格
-
创建响应式菜单和导航
-
创建响应式井
-
创建响应式面板
-
创建响应式警报
-
嵌入媒体和视频内容
-
创建响应式按钮
-
创建动态进度条
-
创建流体轮播
-
与流体图片和图片元素一起工作
-
创建响应式表单字段
-
使用图标和字体图标
-
创建响应式着陆页
创建灵活布局
灵活布局的宽度会根据用户视口的尺寸而变化。视口是用户设备可查看区域的通用术语。它比“窗口”或“浏览器大小”等术语更受欢迎,因为并非所有设备都使用 Windows。您可以设计布局以使用用户宽度的百分比,或者根本不指定任何宽度,让布局无论大小都填满视口。
在我们讨论灵活布局的所有优点之前,让我们简要地看看它的对立面,即固定宽度布局。
固定宽度意味着将页面的整体宽度设置为预定的像素值,然后考虑到这个限制来设计应用元素。在可联网移动设备爆炸性增长之前,这是开发网络应用的主要设计技术。
固定宽度设计具有一定的优势。主要优势是它让设计师对外观拥有完全的控制权。基本上,用户看到的就是设计师设计的。它也更容易进行结构化,并且与固定宽度的元素(如图片和表单)一起工作,不那么麻烦。
这种设计类型的明显缺点是,你最终得到的是一个僵化的布局,它不会根据用户环境的任何变化而改变。你经常会遇到对于大视口设备来说白空间过多,这会违背某些设计原则,或者对于小视口设备来说设计过宽的情况。
采用固定宽度设计可能适用于某些用例,但它取决于你猜测哪种布局约束对大多数应用用户来说效果最佳的决定,你很可能会排除一个可能非常大的用户群体使用你的应用。
因此,一个响应式应用通常应该设计成一个灵活的布局,以便为你的应用的所有用户保持可用性。
一个自适应应用通常指的是当发生变化时易于修改的应用,而响应式意味着快速对变化做出反应。这两个术语可以互换使用,当我们使用“响应式”这个术语时,通常意味着它也应该具有自适应的特性。弹性和流体大致意思相同,通常描述的是基于百分比的布局设计,能够适应浏览器或视口大小的变化。
另一方面,移动端开发意味着创建一个专门版本的应用,该版本旨在仅在手机浏览器上运行。这种方法偶尔是可行的,但它伴随着一些权衡,例如维护一个独立的代码库,依赖浏览器嗅探将用户引导到移动版本,以及搜索引擎优化(SEO)方面的问题,因为你必须为移动版和桌面版维护不同的 URL。
混合应用指的是以这种方式开发的移动应用,它们可以托管在利用移动平台WebView的本地应用中。你可以将 WebView 视为一个专有的、全屏的浏览器,它被钩在移动平台的本地环境中。这种方法的优点是,你可以使用标准的 Web 开发实践,此外,你还可以访问通常仅限于从移动浏览器内部访问的本地功能。另一个优点是,你可以将你的应用发布到原生应用商店。
使用 ReactJS 开发原生应用是一个有吸引力的提议,而 React Native 项目也提供了一个可行的选择。使用 React Native,你可以将你关于 ReactJS 所学的所有内容应用到开发可以在苹果和安卓设备上运行的应用,并且可以发布到苹果的 App Store 和谷歌的 Play 商店。
选择正确的框架
虽然当然可以自己设置一个灵活的布局,但使用响应式框架有很多意义。例如,你可以节省大量时间,使用已经经过多年战斗测试并由一支熟练的设计师团队维护的框架。你还可以利用这样一个广泛使用的响应式框架在网络上拥有大量有用资源的优势。缺点是,你可能需要学习框架期望你如何布局你的页面,有时,你可能不完全同意框架强加给你的设计决策。
考虑到这些因素,让我们来看看一些可供你选择的重大框架:
-
Bootstrap: 毫无疑问,Bootstrap 是这个领域的领导者。它非常受欢迎,有大量的资源和扩展可用。与 React-Bootstrap 项目的结合也使得在 ReactJS 中开发网络应用时,这是一个非常明显的选择。
-
Zurb Foundation: 基础框架是继 Bootstrap 之后第二大玩家,如果你认为 Bootstrap 不适合你,它是一个自然的选择。这是一个成熟的框架,只需付出很少的努力就能提供很多复杂性。
-
Pure: 由 Yahoo! 提供的 Pure 是一个轻量级且模块化的框架。如果你担心其他框架的字节大小(这个大约有 4 KB,而 Bootstrap 大约是 150 KB,Foundation 是 350 KB),它非常合适。
-
Material Design: 由 Google 提供的 Material Design 是一个非常有力的竞争者。它带来了很多新的想法,是 Bootstrap 和 Foundation 的一个令人兴奋的替代品。还有一个名为 Material UI 的 ReactJS 实现,它将 Material Design 和 ReactJS 结合起来,这使得它成为 Bootstrap 和 React-Bootstrap 的一个有吸引力的替代品。Material Design 在其提供的 UX 元素应该如何表现和交互方面非常具有意见性,而 Bootstrap 和其他框架则给你在设置交互方面提供了更多的自由度。
显然,选择一个适合每个项目的框架并不容易。我们之前没有提到的另一个选择是独自完成,也就是说,完全自己创建网格和灵活布局。这绝对是一个可行的策略,但它也带来了一些缺点。
主要的缺点是,你将无法从多年的调整和测试中受益。尽管大多数现代浏览器都相当强大,但你的代码将在众多浏览器和设备上运行。很可能你的用户会遇到你不知道的问题,因为你没有相同的硬件配置。
最后,你必须决定你是想设计应用程序,还是想创建一个新的灵活 CSS 框架。这个选择应该清楚地说明为什么在本章中我们选择了一个特定的框架来关注,而这个框架就是 Bootstrap。
毫无疑问,Bootstrap 是前面提到的框架中最成熟和最受欢迎的,并且在社区中拥有出色的支持。网络景观仍在以快速的速度发展,您可以确信 Bootstrap 会随着它一起发展。
使用 Bootstrap 设置您的应用
我们已经在上一章中查看了一个 Bootstrap 和 React-Bootstrap 的实现,但我们只是略过了您可以做什么。让我们更深入地看看 React-Bootstrap 能为我们提供什么。
通过复制第一章中的脚手架来开始这个项目,深入 ReactJS,然后向您的项目中添加 React-Bootstrap。打开终端,转到您的项目根目录,然后使用您喜欢的dependencies或devDependencies( whichever you prefer)替换以下列表,然后从命令行运行npm install命令:
"devDependencies": {
"babel-preset-es2015": "6.9.0",
"babel-preset-react": "6.11.1",
"babelify": "7.3.0",
"browser-sync": "2.13.0",
"browserify": "13.0.1",
"browserify-middleware": "7.0.0",
"history": "3.0.0",
"jsxstyle": "0.0.18",
"react": "15.1.0",
"react-bootstrap": "0.29.5",
"react-dom": "15.1.0",
"react-router": "2.5.2",
"reactify": "1.1.1",
"serve-favicon": "2.3.0",
"superagent": "2.1.0",
"uglifyjs": "2.4.10",
"watchify": "3.7.0"
},
此外,您需要下载 Bootstrap CSS 或使用 CDN 将其包含在您的index.html文件中。然后,将以下内容添加到index.html的<head>部分:
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/font-awesome/3.2.1/css/font-awesome.min.css">
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css" />
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
创建一个灵活的网格
在 Bootstrap 等 CSS 框架的核心中,存在着网格的概念。网格是一种结构,允许您以一致的方式水平垂直堆叠内容。它提供了一个可预测的布局框架,当您编码时易于可视化。
网格由两个主要组件组成:行和列。在每一行中,您可以添加一定数量的列,从一列到最多 12 列,具体取决于框架。一些框架,如 Bootstrap,还会添加一个容器,您可以将它包裹在行和列周围。
使用网格非常适合响应式设计。您可以轻松地制作出在大桌面浏览器和小型移动浏览器上看起来都很棒的网络站。
这一切都取决于您如何构建您的列。例如,您可以将列设置为当浏览器宽度小于或等于 320 像素(典型的移动浏览器宽度)时为全宽,当浏览器宽度大于给定的像素大小时为三分之一宽度。您在浏览器尺寸上切换类的方法称为媒体查询。所有网格框架都内置了基于媒体查询切换大小的类;您很少需要自己编写媒体查询。
Bootstrap 中的网格系统使用 12 列,并且可以通过初始化为<Grid fluid={true}>来选择性地设置为流体。默认情况下是非流体,但值得注意的是,这两种设置都会返回一个响应式网格。主要区别在于流体网格始终具有 100%的宽度,并且会在每次宽度变化时不断调整。非流体网格由媒体查询控制,当视口宽度超过某些阈值时,会改变宽度。
网格列可以通过以下属性来区分:
-
xs:这是用于额外小型的设备,如手机(<768 px)
-
sm:这是用于小型设备,如平板电脑(≥768 px)的。
-
md:这是用于中等设备,如桌面(≥992 px)的。
-
lg:这是用于大型设备,如桌面(≥1200 px)的。
你也可以将push和offset与前面的属性结合使用,例如,你可以使用xsOffset来偏移超小设备上可见的列,依此类推。offset和push之间的区别在于,offset将强制其他列移动,而push将与其他列重叠。
尺寸向上膨胀。如果你定义了xs属性但没有sm、md或lg属性,所有列都将使用xs设置。如果你定义了xs和sm属性,超小视口将使用xs属性,而其他所有视口将使用sm属性。
让我们看看一个实际例子。在你的source/examples文件夹中创建一个文件(如果不存在,请创建该文件夹),命名为grid.jsx,并添加以下代码:
'use strict';
import React from 'react';
import {Grid,Row,Col} from "react-bootstrap";
在我们的脚本中,我们只导入我们当前需要的部分。在这个例子中,我们需要Grid、Row和Col,所以我们将这些命名并确保它们以这些名称导入并可用。
虽然不指定每个组件的名称而导入所有组件会更方便,但具体指定导入可以使你更容易理解你正在工作的文件中需要什么。这也可能在打包你的 JavaScript 代码以部署时减少体积,因为打包器可以移除所有可用但从未使用过的组件。请注意,这并不适用于当前版本的Browserify或Webpack(我们将在第六章 Advanced React 中讨论),但 Webpack 至少已经在路上了。
小贴士
当你从一个较大的库中导入单个组件时,请使用这种方法导入:
const Row = require('react-bootstrap').Row;
这将只导入所需的组件,而忽略库中的其余部分。如果你这样做是一致的,你的包大小将会减小。
让我们看看下面的代码:
const GridExample = React.createClass ({
render: function () {
return (
<div>
<h2>The grid</h2>
<Grid fluid={true}>
<Row>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
<Col xs = { 1 }> 1 </Col>
</Row>
<Row>
<Col xs = { 2 } sm = { 4 }> xs2 sm4 </Col>
<Col xs = { 4 } sm = { 4 }> xs4 sm4 </Col>
<Col xs = { 6 } sm = { 4 }> xs6 sm4 </Col>
</Row>
这一行将为超小设备和从小到大的设备显示不同的列尺寸:
注意
记住,尺寸是向上膨胀,而不是向下。
<Row>
<Col
xs = { 6 }
sm = { 4 }
md = { 8 }
lg = { 2 }>
xs6 sm4 md8 lg2
</Col>
<Col
xs = { 6 }
sm = { 8 }
md = { 4 }>
xs6 sm8 md4 lg10
</Col>
</Row>
这一行显示了两个列,它们的宽度根据视口的不同而有很大变化。在智能手机上,列的宽度相等。在小视口中,左侧列覆盖行的一三分之一,而右侧列覆盖剩余部分。在中视口中,左侧列突然成为主导列,但在非常大的视口中,左侧列再次减少到一个更小的比例。
这显然是一个为了展示网格功能而人为设置的例子。对于实际应用来说,这是一个非常奇怪的设置:
<Row>
<Col
xs = { 3 }
xsOffset = { 1 }>
3 offset 1
</Col>
<Col
xs = { 7 }
xsOffset = { 1 }>
7 offset 1
</Col>
</Row>
两个列在这里都开始于一个偏移量。这将在每个列的开始处创建一个空白的列:
<Row>
<Col
xs = { 4 }
xsPush = { 1 }>
4 push 1 (overlaps)
</Col>
<Col
xs={ 7 }
xsOffset = { 1 }>
7 offset 1
</Col>
</Row>
Push将列移动到右边,但不会强制其他列移动,因此它将覆盖下一个列。这意味着第二列的偏移量将被第一列的内容覆盖:
</Grid>
</div>
);
}
});
module.exports = GridExample;
为了查看这个示例,请打开app.jsx并替换内容为以下代码:
'use strict';
import React from 'react';
import ReactDom from 'react-dom';
import GridExample from './examples/grid.jsx';
ReactDom.render ((<div>
<GridExample />
</div>),
document.getElementById('container')
);
小贴士
在本章中,我们将创建许多组件,并且它们都可以通过在代码头部添加一个import语句来添加到app.jsx中。ReactJS 要求你在导入组件时首字母大写。你可以通过在括号中添加名称来在渲染代码中使用你给导入的名称。
在创建网格时,在设置时使其可见非常有好处。你可以将其添加到app.css中,使其在浏览器中显示:
div[class*="col-"] {
border: 1px dotted rgba(60, 60, 60, 0.5);
padding: 10px;
background-color: #eee;
text-align: center;
border-radius: 3px;
min-height: 40px;
line-height: 40px;
}
这种样式将使查看和调试我们添加的列变得容易。
Bootstrap 的网格系统非常灵活,可以很容易地以你想要的方式构建你的页面。在这个示例中创建的网格在所有你抛向它的设备上都是可见的且流动的。

创建一个响应式菜单和导航栏
这在第二章中进行了广泛介绍,创建一个网店,所以我们在这里只设置一个基本的菜单,并参考上一章的细节,了解如何连接到路由器并设置工作链接。
在你的source/examples文件夹中创建一个文件,命名为navbar.jsx,并添加以下代码:
'use strict';
import React from 'react';
import { Nav,
Navbar,
NavBrand,
NavItem,
NavDropdown,
MenuItem
} from 'react-bootstrap';
const Navigation = React.createClass ({
render() {
return (
<Navbar inverse fixedTop>
<Navbar.Header>
<Navbar.Brand>
Responsive Web app
</Navbar.Brand>
<Navbar.Toggle/>
</Navbar.Header>
<Navbar.Collapse>
自动添加Navbar.Collapse将使这个导航栏成为一个移动友好的导航栏,当视口小于 768 像素时,它将用汉堡按钮替换菜单项:
<Nav role="navigation" eventKey={0} pullRight>
<NavItem
eventKey={ 1 }
href = "#">
Link
</NavItem>
<NavItem
eventKey = { 2 }
href = "#">
Link
</NavItem>
<NavDropdown
eventKey = { 3 }
title = "Dropdown"
id = "collapsible-nav-dropdown">
<MenuItem eventKey={ 3.1 }>
Action
</MenuItem>
<MenuItem eventKey={ 3.2 }>
Another action
</MenuItem>
<MenuItem eventKey={ 3.3 }>
Something else here
</MenuItem>
<MenuItem divider />
<MenuItem eventKey={ 3.3 }>
Separated link
</MenuItem>
</NavDropdown>
</Nav>
<Nav pullRight>
<NavItem eventKey = { 1 } href = "#">
Link Right
</NavItem>
<NavItem eventKey = { 2 } href = "#">
Link Right
</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
);
}
});
module.exports = Navigation;
你可以在主要的Navbar组件上设置以下属性:
-
defaultExpanded:如果设置为true,这将展开小设备上的Navbar -
expanded:这将在运行时设置Navbar组件展开(需要onToggle) -
fixedBottom:这将固定Navbar组件在视口的底部 -
fixedTop:这将固定Navbar组件在视口的顶部 -
staticTop:这将使Navbar随着页面浮动 -
fluid:这与网格中的fluid设置工作方式相同 -
inverse:这将反转Navbar中的颜色 -
onToggle:这是一个当Navbar被切换时可以运行的函数 -
componentClass:这用于向Navbar添加你自己的类
创建响应式井
井是一个可以用于良好效果的嵌入元素。这是一种简单但有效的方式来强调内容。在 Bootstrap 中设置它也非常简单。
在source/examples中添加一个新文件,命名为wells.jsx,并添加以下代码:
'use strict';
import React from 'react';
import { Well } from 'react-bootstrap';
const Wells = React.createClass ({
render() {
return (
<Well bsSize = "large">
Hi, I'm a large well.
</Well>
);
}
});
module.exports = Wells;
你可以在Wells组件上设置以下属性:
bsSize:井可以是小或大

创建响应式面板
面板就像一口井,但拥有更多的信息和功能。
它可以有一个标题,也可以是可折叠的,因此它是一个很好的信息展示、表单包含等的候选者。
让我们创建一个基本的面板。在source/components中添加一个新文件,命名为panels.jsx,并添加以下代码:
'use strict';
import React from 'react';
import { Panel, Button, PanelGroup, Accordion }
from 'react-bootstrap';
const Panels = React.createClass ({
getInitialState() {
return {
open: false,
activeKey: 1
}
},
render() {
return (
<div>
<h2>Panels</h2>
<div>
<Button
onClick = { ()=> this.setState ({
open: !this.state.open })}>
{ !this.state.open ? "Open" : "Close" }
</Button>
<Panel
collapsible
expanded = { this.state.open }>
This text is hidden until you click the button.
</Panel>
这个面板默认是关闭的,并由组件的状态变量open控制。当你点击按钮时,它执行内部的setState函数。状态只是使用相当聪明的not运算符反转open变量的布尔值。当我们使用它时,我们说我们想要当前值的相反,这可能是true或false:
</div>
</div>
);
}
});
module.exports = Panels;
我们还可以对面板组件做更多的事情,但让我们先简要看看我们可以在Panel上设置哪些其他属性:
-
header (string): 将此添加到Panel初始化器中,并传递一个值以给标题添加一些内容。 -
footer (string): 这与标题相同,但会在底部而不是顶部创建信息块。 -
bsStyle (string): 这通过添加上下文类使内容有意义。你可以选择所有常见的 Bootstrap 上下文名称:primary、success、danger、info、warning以及default。 -
expanded (boolean): 这可以是true或false。这需要与collapsible一起使用。 -
defaultExpanded (boolean): 这也可以是true或false。这不会覆盖expanded函数。
你通常会想要显示多个面板并将它们分组在一起。这可以通过添加一个名为PanelGroup的组件来实现。
PanelGroups是一个包装器,你可以在你想要分组的所有面板周围设置它。如果你想分组两个面板,代码看起来是这样的:
<PanelGroup
activeKey = { this.state.activeKey }
onSelect = { (activeKey)=>
this.setState({ activeKey: activeKey })}
accordion>
<Panel
collapsible
expanded = { this.state.open }
header = "Panel 1 - Controlled PanelGroup"
eventKey = "1"
bsStyle = "info">
Panel 1 content
</Panel>
<Panel
collapsible
expanded = {this.state.open}
header = "Panel 2 - Controlled PanelGroup"
eventKey = "2"
bsStyle = "info">
Panel 2 content
</Panel>
</PanelGroup>
这是一个受控的PanelGroup实例。这意味着在任何时候只有一个面板会打开,这是通过在PanelGroup初始化器中添加activeKey属性来表示的。当你点击组中的面板时,onSelect()方法中的函数会被调用,并更新活动面板状态,然后告诉 ReactJS 打开活动面板并关闭非活动面板。
你也可以通过简单地从PanelGroup初始化器中删除activeKey和onSelect属性,以及从Panel初始化器中删除expanded属性来创建一个无控制的PanelGroup实例:
<PanelGroup accordion>
<Panel
collapsible
header = "Panel 3 - Uncontrolled PanelGroup"
eventKey = "3"
bsStyle = "info">
Panel 3 content
</Panel>
<Panel
collapsible
header = "Panel 4 - Uncontrolled PanelGroup"
eventKey = "4"
bsStyle = "info">
Panel 4 content
</Panel>
</PanelGroup>
它们之间的主要区别在于,在有控制组的情形下,每次只会打开一个面板,但在无控制组的情形下,用户可以关闭所有面板。
最后,如果你只想使用无控制面板组,你可以丢弃PanelGroup组件,转而导入Accordion组件。<Accordion />是<PanelGroup accordion />的别名。它实际上并没有节省多少代码,但可能更容易记住。代码看起来是这样的:
<Accordion>
<Panel
collapsible
header = "Panel 5 - Accordion"
eventKey = "5"
bsStyle = "info">
Panel 5 content
</Panel>
<Panel
collapsible
header = "Panel 6 - Accordion"
eventKey = "6"
bsStyle = "info">
Panel 6 content
</Panel>
</Accordion>

创建响应式警报
与面板类似,警报是填充了少量附加功能的信息块,非常适合向用户展示及时信息。
让我们看看你可以用警报做什么。
创建一个名为examples/alerts.jsx的文件,并添加以下代码:
'use strict';
import React from 'react';
import { Alert, Button } from "react-bootstrap";
const AlertExample = React.createClass ({
getInitialState() {
return {
alertVisible: true
};
},
这是我们的标志,用于保持警报可见。当这个设置为false时,警报会被隐藏:
render(){
if(this.state.alertVisible){
return (<Alert bsStyle="danger" isDismissableonDismiss={()=>{this.setState({alertVisible:false})}}>
在这里,有两个需要注意的属性。第一个是isDismissable,它渲染一个按钮,允许用户取消警报。这个属性是可选的。
第二个是onDismiss,这是一个在用户点击取消按钮时被调用的函数。在这种情况下,alertVisible标志被设置为 0,并且render函数现在返回null而不是Alert组件:
<h4>An error has occurred!</h4>
<p>Try something else and hope for the best.</p>
<p>
<Button bsStyle="danger">Do this</Button>
<span> or </span>
<Button onClick=
{()=>{this.setState({alertVisible:false})}}>
Forget it</Button>
操作按钮尚未设置任何功能,因此点击它目前是徒劳的。隐藏按钮接收一个函数,该函数将alertVisible标志设置为 0 并隐藏Alert框:
</p>
</Alert>)}
else {
return null;
}
}
});
module.exports = Alerts;
响应式嵌入媒体和视频内容
在你的网站上嵌入 YouTube 视频可以是一个值得考虑的添加项,因此让我们创建一个自定义组件来处理这个问题。
对于这个模块,我们还需要另一个依赖项,所以请继续打开终端,导航到根目录,并执行以下install命令:
npm install --save classnames
classnames组件允许你通过简单的true和false比较动态定义要包含的类,它比依赖于字符串连接和if...else语句更容易使用和理解。
创建一个名为components的文件夹,并在该文件夹中创建一个名为media.jsx的文件,然后添加以下代码:
'use strict';
import React from 'react';
import ClassNames from 'classnames';
const Media = React.createClass ({
propTypes: {
wideScreen: React.PropTypes.bool,
type: React.PropTypes.string,
src: React.PropTypes.string.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number
},
getDefaultProps() {
return {
src: "",
type: "video",
wideScreen: false,
allowFullScreen: false,
width:0,
height:0
}
},
我们需要一个属性:YouTube 源。其他的是可选的。如果没有提供宽屏,组件将以 4:3 的宽高比显示视频:
render() {
let responsiveStyle = ClassNames ({
"embed-responsive": true,
"embed-responsive-16by9": this.props.wideScreen,
"embed-responsive-4by3": !this.props.wideScreen
});
let divStyle, ifStyle;
divStyle = this.props.height ?
{paddingBottom:this.props.height} : null;
ifStyle = this.props.height ?
{height:this.props.height, width:this.props.width} : null;
if(this.props.src) {
if(this.props.type === "video") {
return (<div className={responsiveStyle}
style={divStyle}>
<iframe className="embed-responsive-item"
src={ this.props.src }
style={ifStyle}
allowFullScreen={ this.props.allowFullScreen }>
</iframe>
</div>);
} else {
return (<div className={ responsiveStyle }
style={ divStyle }>
<embed frameBorder='0'
src={ this.props.src }
style={ ifStyle }
allowFullScreen={ this.props.allowFullScreen }/>
</div>)
}
}
else {
return null;
}
}
});
module.exports = Media;
这个片段根据传递的媒体类型返回iframe或embed元素。响应式类基于 Bootstrap 提供的类,并将媒体自动缩放到任何视口。
打开app.jsx并添加以下导入:
import Media from './components/media;
然后,将< Media src="img/x7cQ3mrcKaY"/>添加到render()方法(或你想要显示的任何其他视频)。你也可以添加wideScreen可选属性来以 16 x 9 的尺寸显示视频,以及allowFullScreen,如果你希望允许用户全屏查看视频。你还可以传递height和width参数,以便使其与你的布局保持一致。
当然,这个组件不仅限于视频,任何类型的媒体内容都可以。例如,尝试用以下代码替换app.jsx中的代码:
'use strict';
import React from 'react';
import ReactDom from 'react-dom';
import Media from './components/media.jsx';
import { Grid, Row, Col } from "react-bootstrap";
ReactDom.render((<Grid fluid={true}>
<Row>
<Col xs={12} md={6}>
<Media type="image/svg+xml"
src="img/Black-crowned_Night_Heron.svg" />
</Col>
<Col xs = { 12 } md = { 6 }>
<Media
type = "video"
src = "//www.youtube.com/embed/x7cQ3mrcKaY" />
</Col>
</Row>
</Grid>),
document.getElementById( 'container' )
);
这将显示一个两列的网格,一列是 SVG,另一列是 YouTube 的视频。
创建响应式按钮
按钮在任何 Web 应用中都很常见。它们负责你在应用中进行的许多用户交互,因此了解你可用到的多种按钮类型是很有价值的。
您可以选择的选项包括超小号、小号和大号按钮,全宽按钮,激活和禁用状态,分组,上拉和下拉,以及加载状态。让我们看看代码。
创建一个名为examples/buttons.jsx的文件,并添加以下代码:
'use strict';
import React from 'react';
import { Button, ButtonGroup, ButtonToolbar, DropdownButton,
MenuItem, SplitButton } from 'react-bootstrap';
const Buttons = React.createClass({
getInitialState() {
return {
isLoading: false
}
},
setLoading() {
this.setState({ isLoading: true });
setTimeout(() => {
this.setState({ isLoading: false });
}, 2000);
},
当我们执行setLoading时,我们将isLoading状态设置为true,然后,我们设置一个计时器,在 2 秒后将状态重置为false:
render() {
let isLoading = this.state.isLoading;
return (
<div>
<h2> Buttons </h2>
<h5> Simple buttons </h5>
<ButtonToolbar>
ButtonToolbar和ButtonGroup是您可以用于分组按钮的两个组件。它们之间的主要区别在于ButtonToolbar将保留多个内联按钮或按钮组的间距,而ButtonGroup则不会:
<Button> Default </Button>
<Button bsStyle = "primary"> Primary </Button>
<Button bsStyle = "success"> Success </Button>
<Button bsStyle = "info"> Info </Button>
<Button bsStyle = "warning"> Warning </Button>
<Button bsStyle = "danger"> Danger </Button>
<Button bsStyle = "link"> Link </Button>
样式提供了视觉重量并标识了按钮的主要操作。最后的样式link使按钮看起来像普通链接,但保持了按钮的行为:
</ButtonToolbar>
<h5>Full-width buttons</h5>
<ButtonToolbar>
<Button
bsStyle = "primary"
bsSize = "xsmall"
block>
Extra small block button (full-width)
</Button>
<Button
bsStyle = "info"
bsSize = "small"
block>
Small block button (full-width)
</Button>
<Button
bsStyle = "success"
bsSize = "large"
block>
Large block button (full-width)
</Button>
</ButtonToolbar>
添加block属性将其转换为全宽按钮。bsSize属性适用于所有按钮,可以是xsmall、small或large:
<h5> Active, non-active and disabled buttons </h5>
<ButtonToolbar>
<Button> Default button - Non-active </Button>
<Button active> Default button – Active </Button>
要设置按钮的激活状态,只需添加active属性:
<Button disabled> Default button – Disabled </Button>
</ButtonToolbar>
添加disabled属性会使按钮看起来不可点击,通过将其透明度降低到原始的 50%:
<h5>Loading state</h5>
<Button
bsStyle = "primary"
disabled = { isLoading }
onClick = { !isLoading ? this.setLoading : null }>
{ isLoading ? 'Loading...' : 'Loading state' }
</Button>
此按钮接收一个click动作并将其传递给setLoading函数,如前所述代码所示。只要isLoading状态设置为false,它将有一个disabled属性并显示文本加载中…:
<h5> Groups and Toolbar </h5>
<ButtonToolbar>
<ButtonGroup>
<Button> 1 </Button>
<Button> 2 </Button>
<Button> 3 </Button>
</ButtonGroup>
<ButtonGroup>
<Button> 4 </Button>
<Button> 5 </Button>
</ButtonGroup>
</ButtonToolbar>
此部分展示了您如何结合ButtonToolbar和ButtonGroup来保持两套或多套视觉上分组的按钮。您还可以添加到ButtonGroup的一个引人注目的效果是vertical属性,它将按钮堆叠而不是并排显示:
<h5> Dropdown buttons </h5>
<ButtonToolbar>
<DropdownButton
title = "Dropdown"
id = "bg-nested-dropdown">
<MenuItem
bsStyle = "link"
eventKey = "1">
Dropdown link
</MenuItem>
<MenuItem
bsStyle = "link"
eventKey = "2">
Dropdown link
</MenuItem>
</DropdownButton>
我们最终的按钮集合展示了您可以通过哪些方式添加下拉和分割按钮效果。此前的代码是最简单的下拉按钮集合,您只需将它们包裹在DropdownButton组件内部即可:
<DropdownButton
noCaret
title = "Dropdown noCaret"
id = "bg-nested-dropdown-nocaret">
<MenuItem
bsStyle="link"
eventKey="1">
Dropdown link
</MenuItem>
<MenuItem
bsStyle = "link"
eventKey = "2">
Dropdown link
</MenuItem>
</DropdownButton>
下一个集合添加了noCaret属性,以展示您如何创建一个点击时不会显示任何视觉提示的下拉按钮:
<DropdownButton
dropup
title = "Dropup"
id="bg-nested-dropup">
<MenuItem
bsStyle = "link"
eventKey = "1">
Dropdown link
</MenuItem>
<MenuItem
bsStyle = "link"
eventKey = "2">
Dropdown link
</MenuItem>
</DropdownButton>
您可以通过添加dropup属性将下拉菜单转换为上拉菜单:
<SplitButton
bsStyle = "success"
title="Splitbutton down"
id="successbutton">
<MenuItem eventKey = "1"> Action </MenuItem>
<MenuItem eventKey = "2"> Another action </MenuItem>
</SplitButton>
<SplitButton
dropup
bsStyle = "success"
title = "Splitbutton up"
id = "successbutton">
<MenuItem eventKey = "1"> Action </MenuItem>
<MenuItem eventKey = "2"> Another action </MenuItem>
</SplitButton>
同样,您可以通过将按钮包裹在SplitButton组件内部而不是DropdownButton组件内部来创建分割按钮效果:
</ButtonToolbar>
</div>
);
}
});
module.exports = Buttons;
下面的截图显示了此代码的输出:

创建动态进度条
进度条可以用来显示用户进程的状态以及完成前还有多少工作要做。
创建一个名为examples/progressbars.jsx的文件,并添加此代码:
'use strict';
import React from 'react';
import { ProgressBar } from 'react-bootstrap';
let tickInterval;
在此组件中,我们想要为进度条创建一个间隔。我们创建一个变量来保存间隔,因为我们希望在unmount方法中稍后访问它:
const ProgressBars = React.createClass ({
getInitialState() {
return {
progress: 0
}
},
componentDidMount() {
tickInterval = setInterval(this.tick, 500);
},
componentWillUnmount() {
clearInterval(tickInterval);
},
当我们挂载组件时,我们创建一个间隔,告诉它每 500 毫秒执行一次我们的 tick 方法:
tick() {
this.setState({ progress: this.state.progress < 100 ?
++this.state.progress : 0 })
},
tick() 方法通过向内部 progress 变量添加 1 来更新它,如果它小于 100,或者如果它不是,则重置为 0:
render() {
return (
<div>
<h2> ProgressBars </h2>
<ProgressBar
active
now = { this.state.progress } />
<ProgressBar
striped
bsStyle = "success"
now = { this.state.progress } />
<ProgressBar
now = { this.state.progress }
label = "%(percent)s%" />
所有的进度条现在将更新并显示一个不断增加的进度,直到完全填满,然后重置为 empty。
如果您应用 active 属性,进度条将动画化。您还可以通过添加 striped 属性来提供条纹。
您可以添加自己的自定义标签或使用以下之一来插值当前值:
-
%(percent)s%: 这添加了一个百分比值 -
%(bsStyle)s: 这显示了当前的样式 -
%(now)s: 这显示了当前值 -
%(max)s: 这显示了最大值(通过设置max={x}来配合,其中 x 是任何数字) -
%(min)s: 这表示最小值(通过设置min={x}来配合,其中 x 是任何数字)
让我们看看下面的代码片段:
<ProgressBar>
<ProgressBar
bsStyle = "warning"
now = { 20 }
key = { 1 }
label = "System Files" />
<ProgressBar
bsStyle="danger"
active
striped
now = { 40 }
key = { 3 }
label = "Crunching" />
</ProgressBar>
可以通过将它们包裹在 ProgressBar 中来嵌套多个进度条:
</div>
);
}
});
module.exports = ProgressBarExample;
创建流体轮播图
轮播图是一个用于循环显示元素(如幻灯片)的组件。其功能相当复杂,但可以用很少的代码实现。
让我们看看它。创建一个名为 examples/carousels.jsx 的新文件并添加此代码:
'use strict';
import React from 'react';
import {Carousel,CarouselItem} from 'react-bootstrap';
const Carousels = React.createClass({
getInitialState() {
return {
index: 0,
direction: null
};
},
handleSelect(selectedIndex, selectedDirection) {
this.setState({
index: selectedIndex,
direction: selectedDirection
});
},
方向可以是 prev 或 next:
render() {
return (
<div>
<h2>Uncontrolled Carousel</h2>
<Carousel>
<CarouselItem>
<img
width = "100%"
height = { 150 }
alt = "600x150"
src = "http://placehold.it/600x150"/>
<div className = "carousel-caption">
<h3> Slide label 1 </h3>
<p> Lorem ipsum dolor sit amet </p>
</div>
</CarouselItem>
<CarouselItem>
<img
width = "100%"
height = { 150 }
alt = "600x150"
src = "http://placehold.it/600x150"/>
<div className = "carousel-caption">
<h3> Slide label 2 </h3>
<p> Nulla vitae elit libero, a pharetra augue. </p>
</div>
</CarouselItem>
</Carousel>
我们创建的第一个轮播图是不受控的。也就是说,它自动动画,但可以被用户手动触发:
<h2>Controlled Carousel</h2>
<Carousel activeIndex = {this.state.index}
direction = {this.state.direction}
onSelect = {this.handleSelect}>
第二个轮播图是受控的,并且不会自动动画,直到用户点击左侧或右侧的箭头。当用户点击其中一个箭头时,handleSelect 函数会接收到期望的方向并动画化轮播图。
默认情况下,轮播图使用包含的 Glyphicon 集中的左右箭头图标。您可以使用 nextIcon 和 prevIcon 属性指定自己的箭头:
<CarouselItem>
<img
width = "100%"
height = {150}
alt = "600x150"
src = "http://placehold.it/600x150"/>
<div className = "carousel-caption">
<h3> Slide label 1 </h3>
<p> Lorem ipsum dolor sit amet </p>
</div>
</CarouselItem>
<CarouselItem>
<img
width = "100%"
height = {150}
alt = "600x150"
src = "http://placehold.it/600x150"/>
<div className = "carousel-caption">
<h3> Slide label 2 </h3>
<p> Nulla vitae elit libero, a pharetra augue. </p>
</div>
</CarouselItem>
</Carousel
</div>
);
}
});
module.exports = CarouselExample;

与流体图像和 picture 元素一起工作
响应式图像的主题是一个充满困难的话题。一方面,有简单缩放和以响应方式呈现图像的问题。另一方面,您通常会希望为小型设备下载较小的图像,并为桌面设备提供较大的图像。
让我们先看看如何设置响应式代码。
创建一个名为 examples/images.jsx 的文件,并添加以下代码:
'use strict';
import React from 'react';
import { Image, Thumbnail, Button, Grid, Row, Col }
from 'react-bootstrap';
const Images = React.createClass ({
render() {
return (
<div>
<h2> Images </h2>
<Grid fluid = { true }>
<Row>
<Col xs={ 12 } sm={ 4 }>
<Image src="img/140x180" portrait />
</Col>
<Col xs={ 12 } sm={ 4 }>
<Image src="img/140x180" circle />
</Col>
<Col xs={ 12 } sm={ 4 }>
<Image src="img/140x180" rounded />
</Col>
</Row>
我们将首先定义 Grid,然后创建一组三列(在小型移动设备上为两列)。在列中,我们添加三张图片,有三种可用的属性:portrait、circle 和 rounded。
这将很好地适应任何视口。
接下来,我们创建另一行,这次使用一个名为Thumbnail的组件而不是Image组件。这个组件使我们能够轻松地添加与你的图片一起的任何 HTML 数据,例如标题、描述和操作按钮:
<Row>
<Col xs={ 12 } sm={ 4 }>
<Thumbnail
src = "http://placehold.it/140x180">
<h3> Thumbnail label </h3>
<p> Description </p>
<p>
<Button
bsSize = "large"
bsStyle = "danger">
Button
</Button>
</p>
</Thumbnail>
</Col>
<Col xs={ 12 } sm={ 4 }>
<Thumbnail
src="img/140x180">
<h3> Thumbnail label </h3>
<p> Description </p>
<p>
<Button
bsSize = "large"
bsStyle = "warning">
Button
</Button>
</p>
</Thumbnail>
</Col>
<Col xs={ 12 } sm={ 4 }>
<Thumbnail
src="img/140x180">
<h3> Thumbnail label </h3>
<p> Description </p>
<p>
<Button
bsSize = "large"
bsStyle = "info">
Button
</Button>
</p>
</Thumbnail>
</Col>
</Row>
</Grid>
</div>
);
}
});
module.exports = Images;
要在你的应用中显示此组件,打开app.jsx并添加以下导入:
import Images from './examples/images.jsx';
然后,将<Images />添加到render()方法中。

减少你的足迹
当为小型设备提供服务时,限制它们需要下载以查看应用内容的数据量是一个好主意。毕竟,如果你的目标受众是手机用户,提供可能需要几秒钟才能下载的高分辨率图片可能不是一个好主意。
目前还没有针对这个问题的通用解决方案,但有一些相当不错的处理方法。让我们看看一些你可以用来解决这个问题的方式。
一种选择是查看用户查看你的应用的设备。这被称为嗅探,通常意味着识别用户代理和视口大小等指标,以便为桌面和手机提供不同的图片。这种解决方案的问题在于它并不非常可靠。用户代理可以被伪造,而小的视口大小并不自动意味着用户在小型设备上浏览你的应用。
另一种选择是媒体查询(我们将在稍后更深入地讨论)。这对于静态元素效果很好,例如你可以将其放置在菜单、工具栏和其他固定内容中的图片,但对于动态元素则不然。
最近出现的一个相当不错的解决方案是使用一个名为<picture>的新元素。此元素允许你动态地使用媒体查询的概念,并根据你指定的要求加载不同的图片。
让我们看看这在 HTML 中是如何工作的:
<picture>
<source
media="(min-width: 750px)"
srcSet="http://placehold.it/500x300" />
<source
media="(min-width: 375px)"
srcSet="http://placehold.it/250x150" />
<img
src="img/100x100"
alt="The default image" />
</picture>
如果浏览器视口至少为 750 像素,此块将下载并显示大图片;如果视口至少为 375 像素,则显示中等图片;如果不符合这些条件,则显示小图片。此元素可以优雅地缩放,如果用户使用的浏览器不支持此元素,它将显示<img>元素中命名的图片。
此处的媒体查询相对简单。你可以用你的查询和include属性相当有创意,例如方向和像素比。以下是一个匹配竖直模式下的智能手机的媒体查询:
only screen and (max-device-width: 721px) and (orientation: portrait) and (-webkit-min-device-pixel-ratio: 1.5), only screen and (max-device-width: 721px) and (orientation: portrait) and (min-device-pixel-ratio: 1.5), only screen and (max-width: 359px)
这个匹配在竖直模式下的视网膜显示屏的表格:
only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: portrait) and (-webkit-min-device-pixel-ratio: 2)
创建一个 React 化的图片元素
我们想在 ReactJS 的范围内工作,所以我们不想使用像之前那样的段,我们跳出常规使用纯 HTML 而不是 ReactJS 组件来显示我们的图片。然而,由于它不存在,我们需要创建一个。
对于这个模块,我们需要另一个依赖项,所以请继续在你的终端中执行以下命令(如果你还没有这样做的话):
npm install --save classnames
接下来,在你的components文件夹中创建一个新文件,命名为picture.jsx。让我们从以下代码开始:
'use strict';
import React from 'react';
import ClassNames from 'classnames';
const Picture = React.createClass ({
propTypes: {
imgSet: React.PropTypes.arrayOf(
React.PropTypes.shape({
media: React.PropTypes.string.isRequired,
src: React.PropTypes.string.isRequired
}).isRequired
),
defaultImage: React.PropTypes.shape ({
src: React.PropTypes.string.isRequired,
alt: React.PropTypes.string.isRequired
}).isRequired,
rounded: React.PropTypes.bool,
circle: React.PropTypes.bool,
thumbnail: React.PropTypes.bool,
portrait: React.PropTypes.bool,
width: React.PropTypes.any,
height: React.PropTypes.any
},
getDefaultProps() {
return {
imgSet: [],
defaultImage: {},
rounded: false,
circle: false,
thumbnail: false,
portrait: false,
width: "auto",
height: "auto"
}
},
我们将首先添加一组property类型及其默认值。请注意,其中两个值,imgSet和defaultImage,被定义为形状。这是因为我们想在对象内部定义property类型,并指导 ReactJS 在忘记某些值或传递错误值类型时通知我们。
我们还需要一些特定于 Bootstrap 的值,你可能从之前的Image示例中认出了它们。由于我们正在创建自己的图片组件,我们希望能够添加诸如rounded和portrait之类的属性,这就是我们确保这样做的方式:
render() {
let classes = ClassNames ({
'img-responsive': this.props.responsive,
'img-portrait': this.props.portrait,
'img-rounded': this.props.rounded,
'img-circle': this.props.circle,
'img-thumbnail': this.props.thumbnail
});
在这里,我们使用ClassNames组件添加正确的 Bootstrap 类,如果我们传递之前提到的属性:
return (
<picture>
{ this.props.imgSet.map((img, idx)=> {
return <source key={ idx }
media={ img.media }
srcSet={ img.src } />
}) }
对于imgSet中的每个元素,我们添加一个source项:
{ <img className={ classes }
src={ this.props.defaultImage.src }
width={ this.props.width }
height={ this.props.height }
alt={ this.props.defaultImage.alt }/> }
然后,我们添加默认图片以及width和height属性。如果您没有指定宽度和高度,它将设置为auto。通常设置宽度和高度是一个好主意,因为这使浏览器更容易在最初布局页面,并防止在文档在图片完全下载之前提供时发生跳动:
</picture>
)
}
});
module.exports = Picture;
让我们在examples/images.jsx中使用这个新组件。打开文件并添加此导入:
import Picture from './../components/picture';
在导入行之后立即添加以下变量:
let imgSet = [
{media: "only screen and (min-width: 650px) and (orientation: landscape)", src: "http://placehold.it/500x300"},
{media: "only screen and (min-width: 465px) and (orientation: portrait)", src: "http://placehold.it/200x500"},
{media: "only screen and (min-width: 465px) and (orientation: landscape)", src: "http://placehold.it/250x150"}
];
let defaultImage = {src: "http://placehold.it/100x100",
alt: "The default image"};
最后,在render()方法中</Grid>标签之前添加此代码:
<Row>
<Col xs={12}>
<Picture
imgSet={ imgSet }
defaultImage={ defaultImage }
circle />
</Col>
</Row>
当你在浏览器中重新加载应用程序时,你会在浏览器中看到一个圆形图片,并且根据你的视口大小,你会看到一个尺寸为 500 x 300、200 x 500、250 x 150 或 100 x 100 的图片。调整浏览器大小并尝试不同的设置,以查看它在实际中的工作情况。
创建响应式表单字段
表单很棘手,因为你通常会需要验证输入并在用户做了你没有预料到的事情时提供一些反馈。我们将在这里探讨这两个问题,创建响应式表单并向用户展示反馈。
创建一个新文件,命名为examples/formfields.jsx,并添加此代码:
'use strict';
import React from 'react';
import ClassNames from 'classnames';
import { FormGroup, FormControl, InputGroup, ButtonInput }
from 'react-bootstrap';
const Formfields = React.createClass ({
getInitialState() {
return {
name: '',
email: '',
password: ''
};
},
validateEmail() {
let length = this.state.email.length;
let validEmail = this.state.email
.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
if (validEmail) return 'success';
else if (length > 5) return 'warning';
else if (length > 0) return 'error';
},
当这个函数执行时,它会从状态中获取电子邮件字符串,然后使用一个相当复杂的regex查询来检查电子邮件是否以正确的格式编写。它几乎不是万无一失的,但已经足够好了。如果电子邮件被认为是有效的,函数将返回'success'。如果不是,它将返回'error'或'warning',两者都向用户提供视觉线索,表明电子邮件输入不正确:
validatePassword() {
let pw = this.state.password;
if (pw.length < 5) return null;
let containsNumber = pw.match(/[0-9]/);
let hasCapitalLetter = pw.toLowerCase() !== pw;
return containsNumber && hasCapitalLetter ? 'success' : 'error';
},
这个简单的验证函数检查密码是否包含数字和大小写字母。如果包含并且长度为五个字符或更多,它将返回'success'。如果不包含,它将返回'error':
handlePasswordChange() {
this.setState({password: this.refs.inputPassword.getValue()})
},
handleEmailChange() {
this.setState({email: this.refs.inputEmail.getValue()})
},
这两个函数通过this.refs获取输入值并将它们存储为状态变量。如果你想了解更多关于 refs 的信息,请回到第一章,ReactJS 初探:
validateForm() {
return (this.validateEmail() === this.validatePassword());
},
如果两个验证函数都返回'success'字符串,此函数将返回true:
render() {
return (
<form>
<Input type="text" label="Name"
placeholder="Enter your name"/>
<Input type="email" label="Email Address"
placeholder="Enter your email"
onChange={this.handleEmailChange}
ref="inputEmail"
bsStyle={this.validateEmail()}/>
第二个输入字段有几个有趣的属性。它有一个onChange属性,确保在字段中输入新内容时调用一个函数。它有一个ref属性,这样就可以稍后通过this.refs找到它。最后,它有一个bsStyle属性,可以接收null、'success'、'warning'或'error'。它将在'success'时将边框变为绿色,在'warning'时变为黄色,在'error'时变为红色:
<Input type="password"
label="Password"
onChange={ this.handlePasswordChange }
ref="inputPassword"
bsStyle={ this.validatePassword() }/>
<ButtonInput type="submit"
value="Submit this form"
disabled={ !(this.validateForm()) }
/>
只要验证函数不返回'success',此按钮就会禁用。当它们这样做时,用户被允许继续并按下按钮:
</form>
);
}
});
module.exports = Forms;
要在你的应用中显示此组件,打开app.jsx并添加此导入:
import Formfields from './examples/formfields.jsx';
然后,将<Formfields />添加到render()方法中。
我们在这里创建的Formfields组件可以通过添加更多输入字段和验证器进行扩展。让我们简要地看看你可以使用的不同输入类型:
选择框:
<Input type="select"
label="Select"
placeholder="select"
ref="inputSelect">
<option value="1">First select</option>
<option value="2">Second select</option>
</Input>
<Input type="select"
label="Multiple Select"
multiple
ref="inputMultipleSelect">
<option value="1">First select</option>
<option value="2">Second select</option>
</Input>
这两个选择框允许用户一次选择一个或多个项目,通过添加multiple属性。
文件:
<Input type="file" label="File" help="Instructions"/>
help中的文本将在文件上传框下方显示。你可以添加一个onChange处理程序来立即上传文件。
复选框:
<Input type="checkbox"
label="Checkbox"
checked={ this.state.inputCheckBoxOne }
onChange={ this.handleCheckboxChange }
ref={ CheckBoxOne }
readOnly={ false }
ref="inputCheckboxOne"/>
由于 ReactJS 会逐字渲染一切,你需要明确控制你的复选框的选中状态,或者完全将其省略。在上面的代码片段中,我们通过在handleCheckboxChange中设置CheckBoxOne的状态来控制选中状态。
备注
注意,如果你提供了checked属性,你必须提供一个onChange处理程序;否则,ReactJS 将在你的控制台中抛出一个警告。如果你想向复选框提供一个已选值而不控制它,请使用defaultChecked属性代替。
单选按钮:
<Input type="radio"
label="Radio"
checked={ this.state.checkedRadioButton=="RadioOne" }
onChange={ this.handleRadioChange.bind(null,"RadioOne") }
readOnly={ false }/>
<Input type="radio"
label="Radio"
checked={ this.state.checkedRadioButton=="RadioTwo" }
onChange={ this.handleRadioChange.bind(null,"RadioTwo") }
readOnly={ false } />
在表单中,只能选择一个单选按钮。与复选框一样,你可以通过添加checked属性和onChange处理程序来控制选中状态,或者如果你想预先选中单选按钮,可以使用defaultChecked。
在前面的代码片段中,我们使用了bind而不是refs来将值传递给函数。在 JavaScript 中,bind()产生一个新的函数,其this将设置为传递给bind()的第一个参数。我们对此不感兴趣;然而,那只是合成的鼠标点击事件,因此我们将this设置为null,并使用部分函数应用修复绑定到bind的另一个参数。简单来说,我们将单选按钮名称提供给handleRadioChange。
handleRadioChange()函数看起来像这样:
handleRadioChange(val) {
this.setState({ checkedRadioButton: val });
}
我们之所以这样做,是因为除非为每个单选按钮创建一个唯一的onChange处理程序,否则很难知道你需要哪个单选按钮的引用来获取数据。尽管这种情况并不少见,但两种方式都可行。
文本区域:
<Input type="textarea"
label="Text Area"
placeholder="textarea" />
文本区域是输入字段,你可以在此处输入较长的文本段落。如果你需要在文本输入时应用函数,可以添加一个onChange处理程序。

使用 Glyphicons 和 font-awesome 图标
Glyphicons是 Bootstrap 附带的大约 200 个符号集合。我们在本章开头将它们添加到index.html文件中,当时我们从 CDN 获取 Bootstrap,因此它们已经包含在内,并准备好在你的应用程序中使用。
你可以在任何使用文本字符串的地方使用 Glyphicons,因为它们提供的是字体集而不是图像集。
你可以通过以下代码行将它们添加到你的代码中:
import { Glyphicon } from "react-bootstrap";
你可以通过编写<Glyphicon glyph="cloud"/>来添加云图标,或者通过编写<Glyphicon glyph="envelope"/>来添加信封图标。
你可以使用一组特殊属性轻松地将符号添加到输入元素中:addonBefore、addonAfter、buttonBefore或buttonAfter。
例如,如果你想在输入字段前添加美元或欧元符号,该字段作为输入参数接受货币,可以使用如下代码块:
const euro = <Glyphicon glyph = "euro" />;
const usd = <Glyphicon glyph = "usd" />;
<Input type = "text"
addonBefore={ usd }
addonAfter = ".00" />
<Input type = "text"
addonBefore={ euro }
addonAfter = ".00" />
书中附带的所有符号及其外观的完整集合可在本书提供的代码中找到。它在examples文件夹中,文件名为glyphicons.jsx。如果你导入此文件并将其添加到app.jsx中,整个符号集将在你的浏览器中显示。
Bootstrap 还提供了一套名为font awesome的图标。我们在本章开头除了 Glyphicons 外还包含了此库。在构建你的应用程序之前,决定使用 font-awesome 或 Glyphicons 图标是有用的,这样你的用户就少下载一个库。
Font-awesome 库没有与 Glyphicons 相当的组件,所以让我们创建一个。在你的components文件夹中创建一个名为fontawesome的文件,并添加以下代码:
import React from 'react';
const FontAwesome = React.createClass ({
propTypes: {
icon: React.PropTypes.string
},
getDefaultProps() {
return {
icon: ""
}
},
render() {
if(this.props.icon){
return (<i className={ "fa fa-" + this.props.icon } />);
} else {
return null;
}
}
});
module.exports = FontAwesome;
上述代码应该非常熟悉。它的作用是获取一个名为icon的单个属性,并返回一个 font-awesome 图标元素。它不会验证图标是否存在,因此你需要提前熟悉集合中的 500 多个图标。
要使用此组件,请在app.jsx中添加import FontAwesome from './components/fontawesome.jsx';导入语句,然后在你的渲染代码中添加<FontAwesome icon="facebook"/>以显示 Facebook 图标。你可以像使用 Glyphicon 组件一样使用此组件,包括前面的输入元素示例。
创建响应式着陆页
在开发响应式 Web 应用时,您需要在代码中区分小设备和大型设备。让我们创建一个着陆页,并演示您如何使用代码中的视口大小来展示您的应用内容。
这个应用将完全包含在app.jsx中。删除app.jsx中的现有代码(或者如果您想保留所做的副本,可以将其重命名为example.jsx),并且删除app.css中的所有代码。将以下内容添加到app.jsx中:
'use strict';
import React from 'react';
import ReactDom from 'react-dom';
import { Grid, Row, Col, Button, Carousel, CarouselItem,
FormGroup, FormControl, InputGroup } from "react-bootstrap";
import FontAwesome from './components/fontawesome.jsx';
我们将依赖于我们之前创建的FontAwesome组件:
const App = React.createClass ({
getInitialState() {
return {
vHeight: 320,
vWidth: 480
}
},
我们将视口的高度和宽度存储为状态变量:
componentDidMount() {
window.addEventListener('resize', (e) => {
this.calculateViewport();
}, true);
this.calculateViewport();
},
状态变量最初将被设置为 320 x 480,但一旦应用挂载,我们将计算实际值。首先,我们将添加一个事件监听器,该监听器将在视口发生变化时执行一个函数。其次,我们将运行该函数第一次:
calculateViewport() {
let vHeight = Math.max(document.documentElement.clientHeight,
window.innerHeight || 0);
let vWidth = Math.max(document.documentElement.clientWidth,
window.innerWidth || 0);
this.setState({
vHeight: vHeight,
vWidth: vWidth
})
},
视口计算将使用最合适的值并将其存储为组件的状态:
renderSmallForm() {
return (
<form style={{ paddingTop: 15 }}>
<div
style={{
width: (this.state.vWidth/2),
textAlign:'center',
margin:'0 auto'
}}>
<FormGroup>
<FormControl
type="text"
bsSize="large"
placeholder="Enter your email address" />
<br/>
<Button
bsSize="large"
bsStyle="primary"
onClick={ this.handleClick }>
Sign up
</Button>
</FormGroup>
</div>
</form>);
},
我们将为着陆页上的表单创建两个render函数。请注意,我们将在双大括号内设置所有 CSS,并且宽度将自动设置为视口宽度的一半:
renderLargeForm() {
return (
<form style={{ paddingTop:30 }}>
<div
style = {{ width:(this.state.vWidth/2),
textAlign:'center',
margin:'0 auto' }}>
<FormGroup>
<FormControl
type="text"
bsSize="large"
placeholder = "Enter your email address" /><InputGroup.Button>
<Button
bsSize = "large"
bsStyle = "primary"
onClick = { this.handleClick }>
Sign up
</Button>
</InputGroup.Button>
</FormGroup>
</div>
</form>);
},
小表单和大表单之间的主要区别在于,大表单使用输入组在同一水平线上显示输入字段和提交按钮。小表单将按钮放在输入字段下方。
我们在我们的表单中添加了一个onClick处理程序,所以让我们继续添加这个函数:
handleClick(event){
// process the input any way you like
console.log(event.target.form[0].value);
},
我们实际上不会处理点击事件以外的日志记录值,但这个函数展示了如何根据用户点击提交按钮时发生的事件从表单中获取值。
接下来,我们将编写用于社交图标的功能。
renderSocialIcons() {
return (<Row>
<Col xs={12} style={{fontSize:32,paddingTop:35,position:'fixed',
bottom:10,textAlign:'center'}}>
<a href="#" style={{color:'#eee'}}><FontAwesome icon="google-plus"/></a>
<a href="#" style={{paddingLeft:15,color:'#eee'}}><FontAwesome icon="facebook"/></a>
<a href="#" style={{paddingLeft:15,color:'#eee'}}><FontAwesome icon="twitter"/></a>
<a href="#" style={{paddingLeft:15,color:'#eee'}}><FontAwesome icon="github"/></a>
<a href="#" style={{paddingLeft:15,color:'#eee'}}><FontAwesome icon="pinterest"/></a>
</Col>
</Row>)
},
社交图标使用font-awesome库中的图像。字体大小设置为 32 像素,以便在智能手机上显示大而清晰的按钮,便于用手指点击:
render() {
let vWidth = this.state.vWidth;
let vHeight = this.state.vHeight;
let formCode = vWidth <= 480 ?
this.renderSmallForm() : this.renderLargeForm();
let socialIcons = vHeight >= 320 ?
this.renderSocialIcons() : null;
这个简单的代码片段在视口高度小于 320 像素时切换小表单和大表单的渲染,并隐藏社交图标:
return (<div>
<Grid fluid style = {{
margin: '0 auto',
width: '100%',
minHeight: '100%',
background: '#114',
color: '#eee',
overflow: 'hidden'
}}>
<Row style = {{ height: vHeight }}>
<Col
sm = {12}
style = {{ marginTop: (vHeight/20) }}>
页眉的顶部将设置为等于视口高度的 1/20 的动态像素值:
<h1 style = {{ textAlign: 'center' }}>
Welcome!
</h1>
<div style = {{maxHeight: 250,
maxWidth: 500,
margin: '0 auto' }}>
<Carousel>
<CarouselItem
style = {{ maxHeight: 250,
maxWidth: 500 }}>
<img
width = "100%"
alt = "500x200" src="img/008800?text=It+will+amaze+you"/>
</CarouselItem>
<CarouselItem
style = {{ maxHeight: 250,
maxWidth: 500 }}>
<img
width="100%"
alt="500x200" src="img/f0f0f0?text=It+will+excite+you"/>
</CarouselItem>
<CarouselItem
style = {{ maxHeight: 250,
maxWidth: 500 }}>
<img
width = "100%"
alt = "500x200" src="img/eeeeee?text=Sign+up+now!"/>
</CarouselItem>
</Carousel>
</div>
</Col>
<Col xs = { 12 }>
{ formCode }
</Col>
<Col xs = { 12 } >
<p style = {{ textAlign:'center',
paddingTop: 15 }}>
Your email will not be shared and will only be
used once to notify you when the app
launches.
</p>
</Col>
</Row>
{ socialIcons }
这就是我们添加socialIcons变量的方式。它将是一个 ReactJS 元素或null:
</Grid>
</div>)
}
});
ReactDom.render ((
<App />),
document.getElementById( 'container' )
);
我们在这个简单的应用中重用了本章的一些组件,并添加了一些新技术。您可以使用媒体查询和 CSS 得到相同的结果,但您将需要编写更多的代码,并在 JavaScript 和 CSS 之间分割逻辑。在代码中内联编写样式代码看起来可能有些奇怪,但这种方法的主要好处之一是它允许您使用与您的应用其他部分相同的编程语言编写非常高级的样式规则。
摘要
在本章中,我们讨论了创建一个适用于任何设备的响应式 Web 应用的各个方面。我们探讨了可用于 ReactJS 的一些不同框架,并深入研究了如何使用 react-bootstrap 来满足我们的需求。在大多数情况下,我们可以通过使用 React-Bootstrap 的组件来完成任务,但在某些情况下,例如图片和媒体,我们也创建了自定义组件。
最后,我们将之前创建的一些组件和一些新技术结合起来,例如程序化内联样式和事件监听器来处理视口调整大小,从而制作了一个简单、响应式的着陆页。
在下一章中,我们将着手开发一个实时搜索应用。我们将介绍数据存储和高效查询的概念,并为用户提供流畅、响应式的体验。翻到下一页开始工作。
第四章:构建实时搜索应用程序
搜索是大多数应用程序中的一个重要功能。根据您正在开发的应用程序类型,您可能只需设置一个用于查找简单关键词的字段,或者您可能需要深入研究模糊算法和查找表的世界。在本章中,我们将创建一个实时搜索应用程序,该应用程序模仿了网络搜索引擎。我们将处理您输入时出现的快速搜索,显示搜索结果并提供无限滚动功能。我们还将创建自己的搜索 API 来处理我们的请求。
这些技术的应用仅限于您的想象力。在这方面,让我们开始吧。
这些是我们将在本章中介绍的主要主题:
-
创建您自己的搜索 API
-
将您的 API 连接到 MongoDB
-
设置 API 路由
-
基于正则表达式的搜索
-
保护您的 API
-
创建 ReactJS 搜索应用程序
-
设置 react-router 以处理非哈希路由
-
监听事件处理器
-
创建服务层
-
连接到您的 API
-
分页
-
无限滚动
创建您自己的搜索 API
数据获取是一个充满不确定性的话题,实际上并不存在一种让所有人都觉得合理的推荐方法来处理它。
您可以在以下两种主要策略之间进行搜索:直接查询数据源或查询 API。哪一个更具可扩展性和未来性?让我们从您的搜索控制器角度来探讨这个问题。直接查询数据源意味着在您的应用程序内部设置连接器和相关逻辑。您需要构建一个合适的搜索查询,然后通常需要解析结果。您的数据获取逻辑现在与数据源紧密相连。
查询 API 意味着发送一个搜索查询并检索预格式化的结果。现在,您的应用程序与 API 的联系仅是松散的,更换它通常只是更改 API URL 的问题。
通常,建立松散的联系比建立紧密的联系更可取,因此我们将从创建一个 Node.js API 开始,然后再转向将显示搜索结果给用户的 ReactJS 应用程序。
开始使用您的 API
让我们从创建一个空项目开始。创建一个文件夹来存储您的文件,打开终端,并将目录更改为该文件夹。运行 npm init。安装程序将向您提出许多问题,但默认值都是可以接受的,所以请继续按下 Enter 键,直到命令完成。您将留下一个仅包含 package.json 文件的裸骨项目,npm 将使用它来存储您的依赖配置。接下来,通过执行以下命令安装 express、mongoose、cors、morgan 和 body-parser:
npm install --save express@4.12.3 mongoose@4.0.2 body-parser@1.12.3 cors@2.7.1 morgan@1.7.0
Morgan 是一个为自动记录请求和响应而设计的中间件工具。
Mongoose 是一个连接到 MongoDB 的实用工具,MongoDB 是一个非常简单且流行的面向文档的非关系型数据库。它非常适合我们想要创建的 API 类型,因为它在查询速度上表现出色,并且默认输出 JSON 数据。
在继续之前,请确保您已经在系统上安装了 MongoDB。您可以在终端中输入 mongo 来完成此操作。如果已安装,它将显示类似以下内容:
MongoDB shell version: 3.0.7
connecting to: test
>
如果它显示错误或 命令未找到,您在继续之前需要安装 MongoDB。根据您计算机上安装的操作系统,有不同方法可以完成此操作。如果您使用的是 Mac,可以通过发出 brew install mongodb 命令使用 Homebrew 安装 MongoDB。如果您没有 Homebrew,您可以访问 brew.sh/ 获取有关如何安装它的说明。Windows 用户以及不想使用 Homebrew 的 Mac 用户可以通过从 www.mongodb.org/downloads 下载可执行文件来安装 MongoDB。
创建 API
在 root 文件夹中创建一个名为 server.js 的文件,并添加以下代码:
'use strict';
var express = require('express');
var bodyparser = require('body-parser');
var app = express();
var morgan = require('morgan');
var cors = require('cors');
app.use(cors({credentials: true, origin: true}));
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/websearchapi/sites');
这将设置我们的依赖项并使其准备好使用。我们正在使用 cors 库来打开我们的应用以支持跨源请求。当我们不在与应用程序相同的域名和端口上运行 API 时,这是必要的。
我们将创建一个描述我们将要处理的数据类型的模式。在 Mongoose 中,模式映射到 MongoDB 集合,并定义了该集合中文档的形状。
注意
注意,这是 Mongoose 的习惯用法,因为 MongoDB 默认是无模式的。
将此模式添加到 server.js:
var siteSchema = new mongoose.Schema({
title: String,
link: String,
desc: String
});
如您所见,这是一个非常简单的模式,并且所有属性都共享相同的 SchemaType 对象。允许的类型有 String、Number、Date、Buffer、Boolean、Mixed、ObjectId 和 Array。
要使用我们的模式定义,我们需要将我们的 siteSchema 对象转换为我们可以工作的模型。为此,我们将它传递给 mongoose.model(modelName, schema):
var searchDb = mongoose.model('sites', siteSchema);
接下来,我们需要定义我们的路由。我们将从一个简单的搜索路由开始,该路由接受一个标题作为查询并返回一组匹配的结果:
var routes = function (app) {
app.use(bodyparser.json());
app.get('/search/:title', function (req, res) {
searchDb.find({title: req.params.title}, function (err, data) {
if (err) return res.status(500)
.send({
'msg': 'couldn\'t find anything'
});
res.json(data);
});
});
};
让我们通过启动服务器来完成它:
var router = express.Router();
routes(router);
app.use('/v1', router);
var port = process.env.PORT || 5000;
app.listen(port, function () {
console.log('server listening on port ' + (process.env.PORT || port));
});
在这里,我们告诉 express 使用我们定义的路由,并使用 v1 作为前缀。API 的完整路径将是 http://localhost:5000/v1/search/title。您现在可以通过执行 node server.js 来启动 API。
我们已经将 process.env 添加到一些变量中。这样做是为了在启动应用程序时轻松覆盖这些值。如果我们想以端口 2999 启动应用程序,我们需要使用 PORT=2999 node server.js 来启动应用程序。
导入文档
将文档插入到 MongoDB 集合中并不复杂。您通过终端登录 MongoDB,选择数据库,然后运行 db.collection.insert({})。手动插入文档看起来是这样的:
$ mongo
MongoDB shell version: 3.0.7
connecting to: test
> use websearchapi
switched to db websearchapi
> db.sites.insert({"title": ["Algorithm Design Paradigms"], "link": ["http://www.csc.liv.ac.uk/~ped/teachadmin/algor/algor.html"], "desc": ["A course by Paul Dunne at the University of Liverpool. Slides and notes in HTML and PS.\r"]})
WriteResult({ "nInserted" : 1 })
>
这当然会花费很多时间,而且制作一组标题、链接和描述并不是一项特别有成效的工作。幸运的是,有许多免费和开源的集合可供我们使用。其中一个数据库是dmoz.org,我已经下载了数据库的一个样本选择,并以 JSON 格式在websearchapi.herokuapp.com/v1/sites.json上提供。下载这个集合,并使用mongoimport工具导入,如下所示:
mongoimport --host localhost --db websearchapi --collection sites < sites.json
执行时,它将在你的 API 数据库中放置 598 个文档。
查询 API
一个get查询可以通过你的浏览器执行。只需输入地址和样本 JSON 文件中的一个标题,例如,http://localhost:5000/v1/search/CoreChain。
你也可以使用命令行和像cURL或HTTPie这样的工具。后者旨在使与 Web 服务的命令行交互比 cURL 等人性化,因此绝对值得检查,我们将在本章中使用它来测试我们的 API。
这是先前的查询使用 HTTPie 的输出:
$ http http://localhost:5000/v1/search/CoreChain
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 144
Content-Type: application/json; charset=utf-8
Date: Thu, 05 May 2016 11:09:48 GMT
ETag: W/"90-+q3XcPaDzte23IiyDJxmow"
X-Powered-By: Express
[
{
"_id": "56336529aed5e6116a772bb0",
"desc": "JavaScript library for displaying graphs.\r",
"link": "http://www.corechain.com/",
"title": "CoreChain"
}
]
这很好,但请注意,我们创建的路由要求标题完全匹配。搜索corechain或Corechain将不会返回任何结果。查询Cubism.js将返回一个结果,但*Cubism*将不会返回任何结果。
显然,这不是一个非常友好的查询 API。
创建通配符搜索
引入通配符搜索可以使 API 更易于使用,但你不能使用传统的基于 SQL 的方法,例如LIKE,因为 MongoDB 不支持这些类型的操作。
另一方面,MongoDB 完全支持正则表达式,因此可以构建一个模仿LIKE的查询。
在 MongoDB 中,你可以使用正则表达式对象创建正则表达式:
{ <field>: /pattern/<options> }
你也可以使用以下任何一种语法创建正则表达式:
{ <field>: { $regex: /pattern/, $options: '<options>' } }
{ <field>: { $regex: 'pattern', $options: '<options>' } }
{ <field>: { $regex: /pattern/<options> } }
以下<options>可用于与正则表达式一起使用:
-
i:这是为了对大小写不敏感,以匹配大写和小写字符。 -
m:对于包含锚点(即,^表示开始和$表示结束)的模式,对于多行值的字符串,在每行的开始或结束处匹配锚点。如果没有这个选项,这些锚点将只匹配字符串的开始或结束。 -
x:这是“扩展”功能,可以忽略$regex模式中的所有空白字符,除非它们被转义或包含在character类中。 -
s:这允许点字符(.)匹配所有字符,包括换行符。
使用x和s需要与$options语法一起使用$regex。
现在我们知道了这些,让我们先创建一个通配符查询:
app.get('/search/:title', function (req, res) {
searchDb.find({title:
{ $regex: '^' + req.params.title + '*', $options: 'i' } },
function (err, data) {
res.json(data);
});
});
注意
记住每次更改查询逻辑时都要重新启动您的节点服务器实例。您可以通过使用键盘快捷键(如 CTRL + C(Mac))中断实例,然后再次运行 node server.js 来完成此操作。
此查询返回任何以搜索词开头的标题,并且它将执行不区分大小写的搜索。
如果您移除第一个锚点(^),它将匹配字符串中该词的所有出现:
app.get('/search/:title', function (req, res) {
searchDb.find({title:
{ $regex: req.params.title +'*', $options: 'ix' } },
function (err, data) {
res.json(data);
});
});
这是我们将用于快速搜索的查询。它将返回 立体主义、cubism 和甚至 ubi 的命中:
$ http http://localhost:5000/v1/search/ubi
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Connection: keep-alive
Content-Length: 1235
Content-Type: application/json; charset=utf-8
Date: Thu, 05 May 2016 11:07:00 GMT
ETag: W/"4d3-Pr1JAiSI46vMRz2ogRCF0Q"
Vary: Origin
X-Powered-By: Express
[
{
"_id": "572b29507d406be7852e8279",
"desc": "The component oriented simple scripting language with a robust component composition model.\r",
"link": "http://www.lubankit.org/",
"title": "Luban"
},
{
"_id": "572b29507d406be7852e82a4",
"desc": "A specification of a new 'bubble sort' in three or more dimensions, with illustrative images.\r",
"link": "http://www.tropicalcoder.com/3dBubbleSort.htm",
"title": "Three Dimensional Bubble Sort"
},
{
"_id": "572b29507d406be7852e82ab",
"desc": "Comprehensive list of publications by L. Barthe on modelling from sketching, point based modelling, subdivision surfaces and implicit modelling.\r",
"link": "http://www.irit.fr/~Loic.Barthe/",
"title": "Publications by Loic Barthe"
},
{
"_id": "572b29507d406be7852e8315",
"desc": "D3 plugin for visualizing time series.\r",
"link": "http://square.github.io/cubism/",
"title": "Cubism.js"
},
{
"_id": "572b29507d406be7852e848a",
"desc": "Browserling and Node modules.\r",
"link": "http://substack.net/",
"title": "Substack"
},
{
"_id": "572b29507d406be7852e848d",
"desc": "Google tech talk presented by Ryan Dahl creator of the node.js. Explains its design and how to get started with it.\r",
"link": "https://www.youtube.com/watch?v=F6k8lTrAE2g",
"title": "Youtube : Node.js: JavaScript on the Server"
}
]
这对我们现在正在构建的应用类型来说已经足够了。构建正则表达式有许多方法,您可以根据需要进一步细化它。通过实现 soundex、模糊匹配 或 Levenshtein 距离,可以实现更高级的匹配,尽管 MongoDB 都不支持这些。
Soundex 是一种音位算法,用于通过英语中的发音来索引名称。当您想要进行名称查找并允许用户在拼写略有差异的情况下找到正确结果时,它是非常合适的。
模糊匹配 是一种寻找与字符串近似匹配而不是精确匹配的字符串的技术。匹配的接近程度是通过将字符串转换为精确匹配所需的操作来衡量的。一个众所周知且经常使用的算法是 Levenshtein。这是一个简单的算法,可以提供良好的结果,但它不受 MongoDB 支持。因此,必须通过检索整个结果集然后对搜索查询中的所有字符串应用算法来测量 Levenshtein 距离。操作的速度会随着数据库中文档数量的线性增长而增长,所以除非您有非常小的文档集,否则这很可能不值得做。
如果您想要这些功能,您需要另寻他处。Elasticsearch (www.elastic.co/) 是一个值得考虑的良好替代品。您可以将我们刚刚创建的节点 API 与后端的 Elasticsearch 实例轻松结合,而不是使用 MongoDB,或者两者的组合。
保护您的 API
目前,如果您将其上线,您的 API 对任何人都是可访问的。这不是一个理想的情况,尽管您可以争辩说,由于您只支持 GET 请求,这并不比建立一个简单的网站有太大的不同。
假设您在某个时候添加了 PUT 和 DELETE。您肯定希望保护它,防止任何人完全访问。
让我们看看通过向我们的应用程序添加令牌来简单保护它的方法。我们将使用 Node.js 身份验证模块 Passport 来保护我们的 API。Passport 有超过 300 种不同适用性的策略。我们将选择令牌策略,因此请安装以下两个模块:
npm install --save passport@0.3.0 passport-http-bearer@1.0.1
在 index.js 文件开头添加以下导入语句:
var passport = require('passport');
var Strategy = require('passport-http-bearer').Strategy;
接下来,在 mongoose.connect 行下面添加以下代码:
var appToken = '1234567890';
passport.use(new Strategy(
function (token, cb) {
console.log(token);
if (token === appToken) {
return cb(null, true);
}
return cb(null, false);
})
);
你还需要更改路由,所以将搜索路由替换为以下内容:
app.get('/search/:title',
passport.authenticate('bearer', {session: false}),
function (req, res) {
searchDb.find({title: { $regex: '^' + req.params.title + '*', $options: 'i' } },
function (err, data) {
if(err) return console.log('find error:', err);
if(!data.length)
return res.status(500)
.send({
'msg': 'No results'
})
res.json(data);
});
});
当你重新启动应用时,现在请求将需要用户发送一个包含内容为 1234567890 的 bearer token。如果令牌正确,应用将返回 true 并执行查询;如果不正确,它将返回一个简单的消息说 未授权:
$ http http://localhost:5000/v1/search/react 'Authorization:Bearer 1234567890'
Access-Control-Allow-Credentials: true
Connection: keep-alive
Content-Length: 290
Content-Type: application/json; charset=utf-8
Date: Thu, 05 May 2016 11:15:32 GMT
ETag: W/"122-7QHSA2Gb7qRseLzxE1QBhg"
Vary: Origin
X-Powered-By: Express
[
{
"_id": "572b29507d406be7852e8388",
"desc": "A JavaScript library for building user interfaces.\r",
"link": "http://facebook.github.io/react/",
"title": "React"
},
{
"_id": "572b29507d406be7852e8479",
"desc": "Node.js humour.\r",
"link": "http://nodejsreactions.tumblr.com/",
"title": "Node.js Reactions"
}
]
诚然,bearer tokens 提供了一个非常薄弱的安全层。仍然有可能让潜在的攻击者嗅探你的 API 请求并重用你的令牌,但使令牌短暂有效并时不时地更改它们可以帮助提高安全性。为了使其真正安全,它通常与用户身份验证结合使用。
创建你的 ReactJS 搜索应用
通过复制 第一章,ReactJS 深入浅出,中的脚手架来启动这个项目,你将在 Packt Publishing 网站上找到这个代码文件以及这本书的代码包),然后将 React-Bootstrap 添加到你的项目中。打开终端,转到项目根目录,并运行 npm install 命令来安装 React-Bootstrap:
npm install --save react-bootstrap@0.29.3 classnames@2.2.5 history@2.1.1 react-router@2.4.0 react-router-bootstrap@0.23.0 superagent@1.8.3 reflux@0.4.1
package.json 中的 dependencies 部分现在应该看起来像这样:
"dependencies": {
"babel-preset-es2015": "⁶.6.0",
"babel-preset-react": "⁶.5.0",
"babel-tape-runner": "².0.0",
"babelify": "⁷.3.0",
"browser-sync": "².12.5",
"browserify": "¹³.0.0",
"browserify-middleware": "⁷.0.0",
"classnames": "².2.5",
"easescroll": "0.0.10",
"eslint": "².9.0",
"history": "².1.1",
"lodash": "⁴.11.2",
"react": "¹⁵.0.2",
"react-bootstrap": "⁰.29.3",
"react-dom": "¹⁵.0.2",
"react-router": "².4.0",
"react-router-bootstrap": "⁰.23.0",
"reactify": "¹.1.1",
"reflux": "⁰.4.1",
"serve-favicon": "².3.0",
"superagent": "¹.8.3",
"tape": "⁴.5.1",
"url": "⁰.11.0",
"basic-auth": "¹.0.3"
}
如果 package.json 不是这个样子,请更新它,然后在项目根目录下从终端运行 npm install。你还需要将 Bootstrap CSS 文件添加到你的 index.html 文件的 <head> 部分中:
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/font-awesome/3.2.1/css/font-awesome.min.css">
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
将上述代码放在带有 app.css 的行之上,这样你就可以覆盖 Bootstrap 的样式。
最后,在 source 文件夹内创建一个 components 文件夹,然后将来自 第三章,使用 ReactJS 进行响应式网页开发,的组件 fontawesome.jsx 和 picture.jsx 复制到这个文件夹中。
设置你的应用
让我们从应用程序的根目录开始,source/app.jsx。将内容替换为以下代码:
'use strict';
import React from 'react';
import { Router, Route, DefaultRoute }
from 'react-router';
import { render } from 'react-dom'
import Search from './components/search.jsx';
import Results from './components/results.jsx';
import Layout from './components/layout.jsx';
import SearchActions from './actions/search.js';
为了使应用能够编译,你需要在你 components 文件夹中创建这四个文件。我们很快就会这样做。现在,参考以下内容:
import { browserHistory } from 'react-router'
上述代码使用浏览器历史库设置了一个路由。这个库的主要优点之一是你可以避免在 URL 中使用哈希标签,因此应用可以引用绝对路径,例如 http://localhost:3000/search 和 http://localhost:3000/search/term:
render((
<Router history={ browserHistory }>
<Route component={Layout}>
<Route path="/" component={Search}>
<Route path="search" component={Results}/>
</Route>
</Route>
</Router>
), document.getElementById('container'));
让我们为 SearchActions、Search、Results 和完整的 Layout 文件创建骨架文件。
创建 source/actions/search.js 并添加以下内容:
'use strict';
import Reflux from "reflux";
let actions = {
performSearch: Reflux.createAction("performSearch"),
emitSearchData: Reflux.createAction("emitSearchData")
};
export default actions;
这设置了我们在 search.jsx 中将使用的两个操作。
创建 source/components/search.jsx 并添加以下内容:
'use strict';
import React from 'react';
const Search = React.createClass({
render() {
return <div/>;
}
});
export default Search;
创建 source/components/results.jsx 并添加以下内容:
'use strict';
import React from 'react';
const Results = React.createClass({
render() {
return <div/>;
}
});
export default Results;
创建 source/components/layout.jsx 并添加以下内容:
'use strict';
import React from 'react';
import Reflux from 'reflux';
import {Row} from "react-bootstrap";
import Footer from "./footer.jsx";
const Layout = React.createClass({
render() {
return (<div>
{this.props.children}
此代码传播了我们在 app.jsx 中设置的路由层次结构中的页面:
<Footer />
我们还将为我们的应用创建一个基本的固定页脚,如下所示:
</div>);
}
});
export default Layout;
创建 source/components/footer.jsx 并添加以下内容:
'use strict';
import React from 'react';
const Footer = React.createClass({
render(){
return (<footer className="footer text-center">
<div className="container">
<p className="text-muted">The Web Searcher</p>
</div>
</footer>);
}
});
export default Footer;
应用程序现在应该可以编译,您将看到一个页脚消息。我们需要应用一些样式来将其固定在页面底部。打开public/app.css并替换其内容为以下样式:
html {
position: relative;
min-height: 100%;
}
body {
margin-top: 60px;
margin-bottom: 60px;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
height: 60px;
background-color: #f5f5f5;
}
将页面设置为 100%最小高度,并将页脚设置为绝对位置在底部,这将确保它保持固定。现在,看看这个:
*:focus {
outline: none
}
上述代码是为了避免在点击焦点部分时出现轮廓边框。接下来,使用以下样式完成public/app.css,以使搜索结果突出显示:
.header {
background-color: transparent;
border-color: transparent;
}
.quicksearch {
padding-left: 0;
margin-bottom: 20px;
width: 95.5%;
background: white;
z-index: 1;
}
.fullsearch .list-group-item{
border:0;
z-index: 0;
}
ul.fullsearch li:hover, ul.quicksearch li:active, ul.quicksearch li:focus {
color: #3c763d;
background-color: #dff0d8;
outline: 0;
border: 0;
}
ul.quicksearch li:hover, ul.quicksearch li:active, ul.quicksearch li:focus {
color: #3c763d;
background-color: #dff0d8;
outline: 0;
border: 0;
}
.container {
width: auto;
max-width: 680px;
padding: 0 15px;
}
.container .text-muted {
margin: 20px 0;
}
创建搜索服务
在您继续为搜索创建视图层之前,您需要一种连接到您的 API 的方法。您可以通过多种方式来完成这项工作,而且这将是您找不到权威答案的情况之一。有些人喜欢将其放在动作层,有些人喜欢放在存储层,有些人可能会非常乐意将其添加到视图层。
我们将借鉴 MVC 架构并创建一个服务层。我们将从您之前创建的action文件中访问服务。我们这样做的原因很简单,因为它将搜索分离成我们代码中的一个较小且易于测试的子部分。为了简化开发和便于测试,您总是希望使您的组件尽可能小。
在您的source文件夹中创建一个名为in service的文件夹,并添加以下三个文件:index.js、request.js和search.js。
让我们从添加request.js的代码开始:
'use strict';
import Agent from 'superagent';
SuperAgent是一个轻量级的客户端 HTTP 请求库,它使得使用 AJAX 比通常要容易得多。它也与node完全兼容,这在执行服务器端渲染时是一个巨大的好处。我们将在第九章创建共享应用程序中深入探讨服务器端渲染。查看以下示例:
class Request {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
get(query, params) {
return this.httpAgent(query, 'get', params, null);
}
post(url, params, data, options) {
return this.httpAgent(url, 'post', params, data)
}
put(url, params, data) {
return this.httpAgent(url, 'put', params, data)
}
我们实际上只会在我们的应用程序中使用get函数。其他方法已添加为示例。您可以在其中添加或删除内容,甚至将它们合并到一个公共函数中(尽管这会增加使用该函数的复杂性)。
所有操作都发送到httpAgent函数:
httpAgent(url, httpMethod, params, data) {
const absoluteUrl = this.baseUrl + url;
let req = AgenthttpMethod
.timeout(5000);
let token = '1234567890';
req.set('Authorization', 'Bearer ' + token);
req.withCredentials();
我们将添加我们在 API 中早期开发的 bearer token 方案。如果您跳过了那部分,您可以删除前面的两行,尽管如果 API 收到 bearer token 但没有处理它的方法,这对 API 来说并不重要。在这种情况下,它将简单地丢弃信息。
值得注意的是,在服务中硬编码 token 非常不安全。为了使其更安全,例如,您可以设置一个方案,在浏览器会话存储中定期创建新的 token,并用查找替换硬编码的变量。让我们看看以下代码片段:
if (data)
req.send(data);
if (params)
req.query(params);
return this.sendAgent(req);
}
在我们添加完参数后,我们需要通过sendAgent函数发送请求。这个函数返回一个我们可以监听的 promise,它最终要么被拒绝要么被解决。promise是一个用于同步的构造。它是对最初未知的结果的代理。当我们在代码中返回一个 promise 时,我们得到一个最终将包含我们想要的数据的对象:
sendAgent(req) {
return new Promise(function (resolve, reject) {
req.end(function (err, res) {
if (err) {
reject(err);
} else if (res.error) {
reject(res.error);
}
else {
resolve(JSON.parse(res.text));
}
});
});
}
}
export default Request;
我们将要编写的下一个文件是search.js:
'use strict';
import Request from './request.js';
}
export default SearchService;
这只是导入并扩展我们在request.js中创建的代码。由于我们不需要扩展或修改任何请求代码,我们将保持原样。
最后一个文件是index.js:
'use strict';
import SearchService from './search.js';
exports.searchService = new SearchService('http://localhost:5000/v1/search/');
这是我们指定连接到我们的 API 的端点的地方。前面的设置指定了运行在 localhost 上的 API。如果您想使用外部服务测试您的代码,可以将此替换为websearchapi.herokuapp.com/v1/search/的示例接口。
通常,将端点和其他配置细节存储在单独的configuration文件中是个好主意。让我们创建一个config.js文件,并将其放置在source文件夹中:
'use strict';
export const Config = {
'urls':{
'search' : 'http://localhost:5000/v1/search/'
}
};
然后,将service/index.js的内容更改为以下内容:
import {Config} from '../config.js';
import SearchService from './search.js';
exports.searchService = new SearchService(Config.urls.search);
注意,我们需要从config.js中取消对配置名称的引用。这是因为我们使用exports而不是module.exports作为命名导出。如果我们首先声明变量并使用module.exports导出它,我们就不需要取消引用。
差别在于exports仅仅是module的一个辅助工具。最终,模块将使用module.exports,并且Config将作为模块的一个命名属性可用。
您也可以使用以下命令导入它:const Config = require('../config.js') 或 import * as Config from '../config.js'。这两种方式都将设置一个Config变量,您可以通过Config.Config来访问它。
测试服务
我们已经创建了服务,但它是否工作呢?让我们来看看。我们将使用一个小巧而高效的测试框架Tape。使用以下命令安装它:
npm install --save babel-tape-runner@2.0.0 tape@4.5.1
我们添加babel-tape-runner,因为我们整个应用程序都在使用 ECMAScript 6,并且我们希望在测试脚本中也使用它。
在项目的根目录中创建test/service文件夹,并添加一个名为search.js的文件,并添加以下代码:
import test from 'tape';
import {searchService} from '../../source/service/index.js';
test('A passing test', (assert) => {
searchService.get('Understanding SoundEx Algorithms')
.then((result)=> {
assert.equals(result[0].title,
"Understanding SoundEx Algorithms","Exact match found for \"Understanding SoundEx Algorithms\"");
assert.end();
});
});
这个测试将导入搜索服务并在数据库中搜索特定的标题。如果找到精确匹配,它将返回pass。您可以通过在终端中进入根文件夹并执行./.bin/babel-tape-runner test/service/search.js来运行它。
注意
注意,在您开始测试之前,API 服务器必须处于运行状态。
结果应该看起来像这样:
$ ./.bin/babel-tape-runner test/service/search.js
TAP version 13
# A passing test
ok 1 Exact match found for "Understanding SoundEx Algorithms"
1..1
# tests 1
# pass 1
# ok
注意
注意,如果您使用-g标志全局安装tape和babel-tape-runner,那么您不需要从node_modules指定二进制版本,只需使用babel-tape-runner test/service/search.js运行测试即可。为了使运行测试更加容易,您可以在package.json文件的scripts部分添加一个脚本。如果您将测试命令添加到tests脚本中,只需执行npm test即可执行测试。
设置存储
存储将非常简单。我们将在动作中执行服务调用,所以存储将简单地持有服务调用的结果并将它们传递给组件。
在source文件夹中,创建一个新的文件夹并命名为store。然后创建一个新的文件,命名为search.js并添加以下代码:
"use strict";
import Reflux from "reflux";
import SearchActions from "../actions/search";
import {searchService} from "../service/index.js";
let _history = {};
这是存储状态。在存储定义之外设置变量会自动使其成为一个私有变量,只能由存储本身访问,而不能由存储的实例访问。请参考以下代码:
const SearchStore = Reflux.createStore ({
init() {
this.listenTo(SearchActions.emitSearchData, this.emitSearchResults)
},
init()中的行设置了一个监听器,用于监听emitSearchData动作。每当这个动作被调用时,emitSearchResults函数就会被执行:
emitSearchResults(results) {
if (!_history[JSON.stringify(results.query)])
_history[JSON.stringify(results.query)] = results.response;
this.trigger(_history[JSON.stringify(results.query)]);
}
这些行看起来有点复杂,所以让我们从最后一行开始检查逻辑。触发动作在results.query键下发出_history变量的结果,这是正在使用的搜索词。搜索词被JSON.stringify包裹,这是一个将 JSON 数据转换为字符串的方法。这允许我们保留带有空格的查询并将其用作_history变量的对象键。
在触发检查之前的两行代码检查搜索词是否已存储在_history中,如果没有则添加它。我们目前没有处理历史记录的方法,但可以设想将来可能通过扩展存储添加这样的功能:
});
export default SearchStore;
创建搜索视图
我们终于准备好开始处理视图组件了。让我们打开search.jsx并添加一些内容。我们会添加很多代码,所以我们将一步一步来。
首先,将内容替换为以下代码:
import React, { Component, PropTypes } from 'react';
import {Grid,Col,Row,Button,Input,Panel,ListGroup,ListGroupItem} from 'react-bootstrap';
import FontAwesome from '../components/fontawesome.jsx';
import Picture from '../components/picture.jsx';
记得将FontAwesome和Picture组件从第三章,使用 ReactJS 进行响应式 Web 开发,复制到source/components文件夹中,让我们看看以下代码片段:
import SearchActions from '../actions/search.js';
import Reflux from 'reflux';
import { findDOMNode } from 'react-dom';
import { Router, Link } from 'react-router'
import Footer from "./footer.jsx";
import SearchStore from "../store/search.js";
const Search = React.createClass ({
contextTypes: {
router: React.PropTypes.object.isRequired
},
getInitialState() {
return {
showQuickSearch: false
}
},
QuickSearch会在你输入时弹出搜索结果集。我们希望最初将其隐藏,让我们看看以下代码:
renderQuickSearch() {
},
快速搜索目前没有任何作用,让我们看看以下代码片段:
renderImages() {
const searchIcon = <FontAwesome style={{fontSize:20}} icon="search"/>;
const imgSet = [
{
media: "only screen and (min-width: 601px)",
src: " http://websearchapp.herokuapp.com/large.png"
},
{
media: "only screen and (max-width: 600px)",
src: "http://websearchapp.herokuapp.com/small.png"
}
];
const defaultImage = {
src: "http://websearchapp.herokuapp.com/default.png",
alt: "SearchTheWeb logo"
};
return {
searchIcon: searchIcon,
logoSet: imgSet,
defaultImage: defaultImage
}
},
使用Picture组件意味着我们可以为桌面和平板用户提供一个高分辨率版本,为移动用户提供一个较小版本。该组件的完整描述可以在第三章,使用 ReactJS 进行响应式 Web 开发中找到。现在请参考以下代码:
render() {
return (<Grid>
<Row>
<Col xs={ 12 } style={{ textAlign:"center" }}>
<Picture
imgSet={ this.renderImages().logoSet }
defaultImage={ this.renderImages().defaultImage }/>
</Col>
</Row>
<Row>
<Col xs={12}>
<form>
<FormGroup>
<InputGroup>
<InputGroup.Addon>
{ this.renderImages().searchIcon }
</InputGroup.Addon>
<FormControl
ref="searchInput"
type="text" />
<InputGroup.Button>
<Button onClick={ this.handleSearchButton }>
Search
</Button>
</InputGroup.Button>
</InputGroup>
</FormGroup>
</form>
<ListGroup style={{display:this.state.showQuickSearch ?
'block':'none'}}
className="quicksearch">
{this.renderQuickSearch()}
</ListGroup>
</Col>
</Row>
<Row>
<Col xs={12}>
{this.props.children}
这将从一个名为 app.jsx 的路由设置中传播一个子页面:
</Col>
</Row>
</Grid>);
}
});
export default Search;
屏幕上的事情终于开始了。如果你现在打开你的网络浏览器,你会在屏幕上看到一个标志;在其下方,你会找到一个带有左侧放大镜和右侧搜索按钮的搜索字段。
然而,当你开始输入时,没有任何反应,点击搜索按钮时也没有出现结果。显然,还有更多工作要做。
让我们具体实现 QuickSearch 方法。用以下代码替换空块:
renderQuickSearch(){
return this.state.results.map((result, idx)=> {
if (idx < 5) {
return (<ListGroupItem key={"f"+idx}
onClick={this.handleClick.bind(null,idx)}
header={result.title}>{result.desc}
<br/>
<a bsStyle="link" style={{padding:0}}
href={result.link} target="_blank">{result.link}
</a>
</ListGroupItem>)
}
})
},
此外,用以下代码替换初始状态块:
getInitialState(){
return {
showQuickSearch: false,
results: [],
numResults: 0
}
},
QuickSearch 方法现在遍历状态中的结果,并添加一个带有 onClick 处理器、标题、描述和链接的 ListGroupItem 项目。我们将 results 变量添加到初始状态中,以避免应用程序因为未定义的 state 变量而停止。
接下来,我们需要在代码中添加 onClick 处理器。为此,添加以下代码:
handleClick(targetIndex) {
if (this.state.numResults >= targetIndex) {
window.open(this.state.results[targetIndex].link, "_blank");
}
},
这段代码将强制浏览器加载目标索引中包含的 URL,这对应于 targetIndex。
然而,在输入字段中输入任何内容仍然没有任何反应。让我们来解决这个问题。
进行搜索
现在的想法是在用户在搜索输入中输入时展示实时搜索。我们已经为这种情况创建了设置;我们只需要将输入动作与行动连接起来。
第一个想法是在输入字段本身添加一个 onChange 处理器。这是实现第一个里程碑、展示搜索的最简单方法。它看起来像这样:
<form>
<FormGroup>
<InputGroup>
<InputGroup.Addon>
{ this.renderImages().searchIcon }
</InputGroup.Addon>
<FormControl
ref="searchInput"
type="text" />
<InputGroup.Button>
<Button onClick={ this.handleSearchButton }>
Search
</Button>
</InputGroup.Button>
</InputGroup>
</FormGroup>
</form>
接下来,你需要在代码中添加一个 performSearch 方法,如下所示:
performSearch() {
console.log(findDOMNode(this.refs.searchInput).value);
},
当你开始输入时,控制台日志将立即开始填充值:

这相当不错,但对于一个只包含单个输入字段而没有其他内容的搜索页面,最好不需要手动将焦点放在搜索字段上以便输入值。
让我们删除 onChange 处理器,并在用户输入数据时立即开始搜索过程。
将以下两个方法添加到 search.jsx 中:
componentDidMount() {
document.getElementById("container")
.addEventListener('keypress', this. handleKeypress);
document.getElementById("container")
.addEventListener('keydown', this.handleKeypress);
},
componentWillUnmount() {
document.getElementById("container")
.removeEventListener('keypress', this.handleKeypress);
document.getElementById("container").removeEventListener('keydown', this.handleKeypress);
},
这将在组件挂载时设置两个事件监听器。keypress 事件监听器负责处理普通按键事件,而 keydown 事件监听器确保我们可以捕获箭头键输入。
handleKeypress 方法相当复杂,所以让我们添加代码并逐步检查它。
当你注册了这些事件监听器后,你将能够捕获用户的每一个按键事件。如果用户按下键 A,一个包含大量关于事件信息的对象将被发送到 handleKeypress 函数。以下是事件对象中对我们特别感兴趣的属性的一部分:
altKey: false
charCode: 97
ctrlKey: false
keyCode: 97
shiftKey: false
metaKey: false
type: "keypress"
它告诉我们这是一个 keypress 事件(箭头键将注册为 keydown 事件)。charCode 参数是 97,并且没有使用 Alt 键、Meta 键、Ctrl 键或 Shift 键与事件一起使用。
我们可以使用原生的 JavaScript 函数解码 charCode。如果您执行 String.fromCharCode(97),您将得到一个包含小写字母 a 的字符串。
基于数字处理按键事件是可行的,但将数字映射到更友好的字符串会更好,因此我们将添加一个对象来保存我们的 charCode 参数。将此添加到文件顶部,位于导入语句下方但 createClass 定义之上:
const keys = {
"BACKSPACE": 8,
"ESCAPE": 27,
"UP": 38,
"LEFT": 37,
"RIGHT": 39,
"DOWN": 40,
"ENTER": 13
};
现在,我们可以输入 keys.BACKSPACE 并发送数字 8,依此类推。
让我们添加 handleKeypress 函数:
handleKeypress (e) {
if (e.ctrlKey || e.metaKey) {
return;
}
如果我们检测到用户正在使用 Ctrl 或 Meta 键(在 Mac 上为 CMD),我们将终止函数。这允许用户使用常规的操作系统方法,例如复制/粘贴或 Ctrl + A 选择所有文本,让我们看一下以下代码片段:
const inputField = findDOMNode(this.refs.searchInput);
const charCode = (typeof e.which == "number") ?
e.which : e.keyCode
我们定义一个变量来保存输入字段,这样我们就不必多次查找它。出于兼容性原因,我们还确保通过检查传递给我们的字符类型是否为数字来获取有效的字符代码。请参阅以下内容:
switch (charCode) {
case keys.BACKSPACE:
inputField.value.length <= 0 ?
this.closeSearchField(e) : null;
break;
我们添加一个 closeSearchField 函数,以便即使在搜索结果已填充的情况下也能隐藏搜索结果。我们这样做是因为我们不希望当用户清除所有文本并准备开始新的搜索时,它仍然保持打开状态,让我们看一下以下代码片段:
case keys.ESCAPE:
this.closeSearchField(e);
break;
如果用户按下 Esc 键,我们还将隐藏搜索结果,让我们看一下以下代码片段:
case keys.LEFT:
case keys.RIGHT:
// allow left and right but don't perform search
break;
这些检查没有任何作用,但它们将防止开关触达 default 并因此触发搜索,让我们看一下以下代码片段:
case keys.UP:
if (this.state.activeIndex > -1) {
this.setState(
{activeIndex: this.state.activeIndex - 1}
);
}
if (this.state.activeIndex < 0) {
inputField.focus();
e.preventDefault();
}
break;
我们为箭头键添加了特殊处理。当用户按下上箭头键时,只要 activeIndex 为零或更高,它就会递减。这将确保我们永远不会处理无效的 activeIndex 参数(小于 -1):
case keys.DOWN:
if (this.state.activeIndex < 5
&& this.state.numResults > (1 + this.state.activeIndex)) {
this.setState({activeIndex: this.state.activeIndex + 1});
}
e.preventDefault();
break;
我们已定义快速搜索的最大结果数为 5。此代码片段将确保 activeIndex 永远不会超过 5:
case keys.ENTER:
e.preventDefault();
if (this.state.activeIndex === -1 ||
inputField === document.activeElement) {
if (inputField.value.length > 1) {
this.context.router.push(null,
`/search?q=${inputField.value}`, null);
this.closeSearchField(e);
SearchActions.showResults();
}
}
else {
if (this.state.numResults >= this.state.activeIndex) {
window.open( this.state.results[this.state.activeIndex].link, '_blank');
}
}
break;
此开关执行两种操作之一。首先,如果 activeIndex 为 -1,则表示用户尚未导航到任何快速搜索结果,我们将直接跳转到所有匹配项的结果页面。如果 activeIndex 不是 -1 但 inputfield 仍然具有焦点(inputField === document.activeElement),也会发生相同的情况。
其次,如果 activeIndex 不是 -1,则表示用户已导航到输入字段下方并做出了选择。在这种情况下,我们将用户发送到所需的 URL:
default:
inputField.focus();
this.performSearch();
if (!this.state.showQuickSearch) {
this.setState({showQuickSearch: true});
}
SearchActions.hideResults();
break;
}
},
最后,如果没有一个开关有效,例如,按下了常规键,那么我们将执行搜索。我们还将使用 SearchActions.hideResults() 动作隐藏任何潜在的不完整结果。
此代码在添加hideResults到我们的操作之前无法编译,所以打开actions/search.js并在操作对象中添加这些行:
hideResults: Reflux.createAction("hideResults"),showResults: Reflux.createAction("showResults"),
代码将编译,并且当您在浏览器中开始输入时,输入字段将获得焦点并接收输入。现在是时候将我们的搜索服务连接起来,我们将在您刚刚编辑的actions文件中这样做。在文件顶部,在第一个导入下面添加这两行:
import {searchService} from "../service/index.js";
let _history = {};
我们将创建一个私有的_history变量来保存我们的搜索历史。这并不是严格必要的,但我们将使用它来减少我们将要进行的 API 调用次数。
接下来,添加此片段:
actions.performSearch.listen( (query) => {
if(_history[JSON.stringify(query)]){
actions.emitSearchData({query:query,response:
_history[JSON.stringify(query)]});
}
else {
searchService.get(query)
.then( (response) => {
_history[JSON.stringify(query)]=response;
actions.emitSearchData({query:query,response:response});
}).catch( (err) => {
// do some error handling
})
}
});
此代码将确保每当触发performSearch时,我们都会调用我们的 API。每当搜索服务返回结果时,我们将它存储在我们的_history对象中,并在我们向搜索服务发送新查询之前,确保有结果准备好。这将节省我们一次 API 调用,并且用户将获得更快的响应。
接下来,添加当我们在文本框中输入或点击按钮时实际执行搜索的代码。用以下代码替换performSearch()内部的代码:
performSearch(){
const val = findDOMNode(this.refs.searchInput).value;
val.length > 1 ?
SearchActions.performSearch(val) :
this.setState({results: []});
},
在我们能够在浏览器中看到结果之前,我们还需要做一件事,但您可以通过输入搜索查询并在开发者工具中检查网络流量来验证它是否工作:

要在浏览器中显示我们的结果,我们需要添加一个监听器,它可以对存储中的变化做出反应。
打开components/search.jsx并在getInitialState之前添加此代码:
mixins: [
Reflux.listenTo(SearchStore, "getSearchResults")
],
getSearchResults(res) {
this.setState({results: res, numResults:
res.length < 5 ? res.length : 5});
},
此代码的作用是告诉 React 在SearchStore发出新数据时调用getSearchResults。它调用的函数将最多存储五个结果在组件状态中。现在,当您输入某些内容时,一个列表组将出现在搜索字段下方,显示结果。
您可以使用鼠标悬停在任何结果上,然后点击它以访问它所引用的链接。
使用箭头键导航搜索结果
由于我们已经对键盘事件做了很多工作,不进一步利用它将是一件遗憾的事情。您在搜索时已经在使用键盘,所以能够使用箭头键导航搜索结果似乎很自然,然后按Enter键访问您选择的那一页。
打开search.jsx。在getInitialState中添加此键:
activeIndex: -1,
然后,在renderQuickSearch函数中,添加带有className的高亮行:
renderQuickSearch() {
return this.state.results.map((result, idx)=> {
if (idx < 5) {
return (<ListGroupItem key={ "f" + idx }
className={ this.state.activeIndex === idx ?
"list-group-item-success":""}
onClick={this.handleClick.bind(null,idx)}
header={result.title}>{ result.desc }
<br/>
<a bsStyle="link" style={{padding:0}}
href={ result.link } target="_blank">
{ result.link }
</a>
</ListGroupItem>)
}
})
},
现在,你将能够使用箭头键上下移动,并按Enter键访问活动链接。然而,这个解决方案有几个小问题让人感到有些烦恼。首先,当你上下导航时,输入字段保持聚焦。如果你输入其他内容,你会得到一组新的搜索结果,但活动索引将保持不变,如果新结果返回的结果少于上一个结果,可能会超出范围。其次,上下动作将光标移动到输入字段中,这会让人感到不安。
第一个问题很容易解决;只需将activeIndex:-1添加到getSearchResults函数中即可,但第二个问题需要我们求助于一个老牌的网页开发者技巧。简单地来说,没有方法可以“取消聚焦”输入字段,因此,我们将创建一个隐藏且不可见的输入字段,并将焦点发送到该字段。
在render方法中,将此代码添加到输入字段上方:
<input type="text" ref="hiddeninput"
style={{left:-100000,top:-100000,position: 'absolute',
display:'block',height:0,width:0,zIndex:0,
padding:0,margin:0}}/>
然后转到switch方法,并将高亮行添加到向下箭头动作中:
case keys.DOWN:
if (this.state.activeIndex < 5
&& this.state.numResults > (1 + this.state.activeIndex)) {
this.setState({activeIndex: this.state.activeIndex + 1});
}
findDOMNode(this.refs.hiddeninput).focus();
e.preventDefault();
break;
当应用重新编译时,你将能够使用箭头键上下导航,并且只有当你导航到顶部时,正确的输入字段才会激活。其余时间,隐藏的输入字段将拥有焦点,但由于它放置在视口之外,没有人会看到它或能够使用它。让我们看看下面的截图:

搜索防抖
每个keypress类都会向 API 提交一个新的搜索。即使我们有实现的历史变量系统,这也给我们的 API 带来了很大的压力。这对用户来说也不是最好的选择,因为它可能会引发一系列不相关的搜索结果。想象一下,你想要搜索 JavaScript。你可能对 j、ja、jav、Java、javas、javasc、javascri、javascri 和 JavaScript 的结果不感兴趣,但当前的情况就是这样。
幸运的是,通过简单地延迟搜索,我们可以很容易地提高用户体验。转到switch语句,并用以下内容替换内容:
default:
inputField.focus();
delay(() => {
if (inputField.value.length >= 2) {
this.performSearch();
}
}, 400);
if (!this.state.showQuickSearch) {
this.setState({showQuickSearch: true});
}
SearchActions.hideResults();
break;
你还需要delay函数,所以将其添加到文件顶部,紧接在导入之后:
let delay = (() => {
var timer = 0;
return function (callback, ms) {
clearTimeout(timer);
timer = setTimeout(callback, ms);
};
})();
这段代码将确保结果延迟足够长,以便用户在退出前可以输入查询,但不会感觉迟缓。你应该根据需要调整毫秒设置。
从快速搜索到结果页面的过渡
现在我们几乎完成了组件的开发。接下来,我们将向search.jsx添加最后一段代码,用于处理搜索按钮和准备进入下一页。为此,请添加以下代码:
handleSearchButton(e) {
const val = findDOMNode(this.refs.searchInput).value;
if (val.length > 1) {
this.context.router.push(`/search?q=${val}`);
this.closeSearchField(e);
SearchActions.showResults();
}
},
closeSearchField(e) {
e.preventDefault();
this.setState({showQuickSearch: false});
},
这段代码将关闭搜索字段,并使用push从 react-router 导航到新的路由。
push参数由 react-router 的 2.0 分支支持,所以我们只需要在我们的组件中添加一个上下文类型。我们可以通过在组件顶部添加以下行(在React.createClass行下方)来完成此操作:
contextTypes: {
router: React.PropTypes.object.isRequired
},
childContextTypes: {
location: React.PropTypes.object
},
getChildContext() {
return { location: this.props.location }
},
设置结果页面
结果页面的目的是显示所有搜索结果。传统上,你显示 10-20 个结果和分页功能,这允许你显示更多结果,直到达到末尾。
让我们设置结果页面,并从传统的分页器开始。
打开components/results.jsx,并用以下内容替换其内容:
import React, { Component, PropTypes } from 'react';
import Reflux from 'reflux';
import {Router, Link, Lifecycle } from 'react-router'
import SearchActions from '../actions/search.js';
import SearchStore from "../store/search.js";
import {Button,ListGroup,ListGroupItem} from 'react-bootstrap';
import {findDOMNode} from 'react-dom';
const Results = React.createClass ({
contextTypes: {
location: React.PropTypes.object
},
设置contextType对象是为了从 URL 中检索query参数。现在看看以下内容:
getInitialState() {
return {
results: [],
resultsToShow: 10,
numResults: 0,
showResults: true
}
},
在这里我们定义结果默认是可见的。这对于直接访问搜索页面的用户是必要的。我们还定义了每页显示 10 个结果,让我们看看以下代码:
componentWillMount() {
SearchActions.performSearch(this.context.location.query.q);
},
我们希望尽可能快地启动搜索,以便向用户显示一些内容。如果我们是从首页继续,结果已经在_history变量中准备好了,并且将在组件挂载之前可用。参看以下代码:
mixins: [
Reflux.listenTo(SearchStore, "getSearchResults"),
Reflux.listenTo(SearchActions.hideResults, "hideResults"),
Reflux.listenTo(SearchActions.showResults, "showResults")
],
hideResults和showResults方法是在用户开始新的查询时使用的操作。我们不是将结果向下推送或在上面的结果上方显示快速搜索,而是简单地隐藏现有的结果:
hideResults() {
this.setState({showResults: false});
},
showResults() {
this.setState({showResults: true});
},
这些setState函数响应前面的操作,如下所示:
getSearchResults(res) {
let resultsToShow = this.state.resultsToShow;
if (res.length < resultsToShow) {
resultsToShow = res.length;
}
this.setState({results: res, numResults: res.length,
resultsToShow: resultsToShow});
},
当我们检索的结果少于this.state.resultsToShow时,我们将状态变量调整为集合中的结果数量,让我们看看以下代码片段:
renderSearch(){
return this.state.results.map((result, idx)=> {
if (idx < this.state.resultsToShow) {
return <ListGroupItem key={"f"+idx}
header={result.title}>{result.desc}<br/>
<Button bsStyle="link" style={{padding:0}}>
<a href={result.link}
target="_blank">{result.link}</a>
</Button>
</ListGroupItem>
}
})
},
这个渲染器几乎与search.jsx中的那个相同。主要区别在于我们返回一个具有link样式的按钮,并且我们没有检查activeIndex属性,让我们看看剩余的代码:
render() {
return (this.state.showResults) ? (
<div>
<div style={{textAlign:"center"}}>
Showing {this.state.resultsToShow} out of {this.state.numResults} hits
</div>
<ListGroup className="fullsearch">
{this.renderSearch()}
</ListGroup>
</div>
): null;
}
});
export default Results;
设置分页
让我们先给getInitialState添加一个属性和一个resetState函数:
getInitialState() {
return {
results: [],
resultsToShow: 10,
numResults: 0,
showResults: true,
activePage: 1
}
},
resetState() {
this.setState({
resultsToShow: 10,
showResults: true,
activePage: 1
})
},
需要在getSearchResults中添加resetState函数:
getSearchResults(res) {
this.resetState();
let resultsToShow = this.state.resultsToShow;
if (res.length < resultsToShow) {
resultsToShow = res.length;
}
this.setState({results: res, numResults: res.length,
resultsToShow: resultsToShow});
},
依次运行两个setStates对象完全没有问题。它们将简单地按先来先服务的顺序排队。
接下来,添加一个分页器:
renderPager() {
return (<Pagination
prev
next
items={Math.ceil(this.state.results.length/this.state.resultsToShow)}
maxButtons={10}
activePage={this.state.activePage}
onSelect={this.handleSelect}/>)
},
这个分页器将自动在页面上填充一定数量的按钮,在这个例子中是 10 个。项目的数量由结果数量除以每页显示的项目数量确定。Math.ceil向上取整到最接近的整数,所以如果你得到 54 个结果,页数将从 5.4 向上取整到 6。前五页将显示十个结果,最后一页将显示剩余的四个结果。
为了使用分页组件,我们需要将其添加到导入部分,所以用以下内容替换react-bootstrap导入:
import {Button,ListGroup,ListGroupItem,Pagination} from 'react-bootstrap';
要显示分页器,用以下内容替换render:
render() {
let start = -this.state.resultsToShow +
(this.state.activePage*this.state.resultsToShow);
let end=this.state.activePage*this.state.resultsToShow;
return (this.state.showResults) ? (
<div>
<div style={{textAlign:"center"}}>
Showing {start}-{end} out of {this.state.numResults} hits
</div>
<ListGroup className="fullsearch">
{this.renderSearch()}
</ListGroup>
<div style={{textAlign:"center"}}>
{this.renderPager()}
</div>
</div>
) : null;
}
然后,添加handleSelect函数:
handleSelect(eventKey) {
this.setState ({
activePage: eventKey
});
},
这就是你需要设置分页器的所有内容。只有一个问题。当你点击下一步时,你会被留在底部位置,作为一个用户,这感觉并不对。让我们用这个依赖项添加一个漂亮的滚动效果:
npm install --save easescroll@0.0.10
我们将它添加到导入部分:
import Scroller from 'easescroll';
将以下内容添加到handleSelect函数中:
handleSelect(event, selectedEvent) {
this.setState({
activePage: selectedEvent.eventKey
});
Scroller(220, 50, 'easeOutSine');
},
有很多滚动变体可供选择。以下是一些你可以尝试的其他设置:
Scroller(220, 500, 'elastic');
Scroller(220, 500, easeInOutQuint);
Scroller(220, 50, 'bouncePast');
让我们看看下面的截图:

设置无限滚动
无限滚动是一个非常受欢迎的功能,而且它很容易在 ReactJS 中实现。让我们回到添加分页器之前代码的状态,并实现无限滚动。
无限滚动通过简单地在你到达页面底部时加载更多项目来工作。没有分页器参与。你只需滚动,然后继续滚动。
让我们看看如何将这个功能添加到我们的代码中。
首先,我们需要向getInitialState添加几个属性:
getInitialState() {
return {
results: [],
resultsToShow: 10,
numResults: 0,
threshold: -60,
increase: 3,
showResults: true
}
},
threshold变量以像素为单位给出,当达到底部 60 像素时激活。increase变量是我们一次将加载多少个项目的数量。它通常与resultToShow变量相同,但在这个例子中,三个看起来非常直观。
我们将添加一个事件监听器来挂载(并在我们完成时移除它):
componentDidMount: function () {
this.attachScrollListener();
},
componentWillUnmount: function () {
this.detachScrollListener();
},
attachScrollListener: function () {
window.addEventListener('scroll', this.scrollListener);
this.scrollListener();
},
detachScrollListener: function () {
window.removeEventListener('scroll', this.scrollListener);
},
这些事件监听器将监听滚动事件。它也会在组件挂载后立即启动scrollListener。
接下来,我们将添加实际的功能:
scrollListener: function () {
const component = findDOMNode(this);
if(!component) return;
let scrollTop;
if((window.pageYOffset != 'undefined')) {
scrollTop = window.pageYOffset;
} else {
scrollTop = (document.documentElement ||
document.body.parentNode || document.body).scrollTop;
}
const reachedTreshold = (this.topPosition(self) +
self.offsetHeight - scrollTop -
window.innerHeight < Number(this.state.threshold));
const hasMore = (this.state.resultsToShow +
this.state.increase < this.state.numResults);
if(reachedTreshold && hasMore) {
当我们还有更多结果时,通过this.state.increase中的数字增加要显示的结果数量,让我们看看以下代码:
this.setState ({
resultsToShow: (this.state.increase +
this.state.resultsToShow <= this.state.numResults) ?
this.state.increase + this.state.resultsToShow :
this.state.numResults
});
} else {
this.setState({resultsToShow: this.state.numResults});
当我们不能再增加时,我们将resultsToShow设置为与接收到的结果数量相同,让我们看看以下代码片段:
}
},
topPosition: function (el) {
if (!el) {
return 0;
}
return el.offsetTop + this.topPosition(el.offsetParent);
},
这个函数只是找到组件在视口中的顶部位置。
现在当你向下滚动时,页面将加载新的片段,直到没有更多结果。这绝对可以被认为是一个简单的无限滚动,它既不是无限的,实际上也没有加载更多内容。
然而,很容易修改它,使其不是立即设置新状态,而是发送一个触发服务调用以加载更多数据的动作调用。在这种情况下,监听器需要在新的数据集到达之前断开连接,然后重新连接监听器并设置新的状态,就像我们之前做的那样。如果你确实有无限多的数据要获取,这种方法不会让你失望。
我们接近完成。只剩下一件事要添加。当你直接访问结果页面时,输入字段不会被填充。这不是至关重要,但这是一个很好的功能,所以让我们添加它。
在results.jsx中的componentWillMount函数中添加以下行:
SearchActions.setInputText(this.context.location.query.q);
然后,再次打开search.jsx并添加以下行到 mixins 中:
Reflux.listenTo(SearchActions.setInputText, "setInputText")
在同一文件中,添加设置输入文本的函数:
setInputText(val) {
findDOMNode(this.refs.searchInput).value = val;
},
最后,在actions/search.js中,将以下内容添加到actions对象中:
setInputText: Reflux.createAction("setInputText")
如果你现在直接导航到结果页面,例如,通过本地访问你的测试站点http://localhost:3001/search?q=javascript或远程访问示例应用程序websearchapp.herokuapp.com/search?q=javascript,你会发现输入字段被设置为你要添加到q变量中的任何内容。
摘要
在本章中,我们创建了一个可工作的 API,并将其连接到 MongoDB 实例,然后着手制作一个快速搜索应用程序,该应用程序可以实时显示搜索结果。此外,我们还研究了键盘动作和滚动动作的事件监听器,并将它们投入使用。
恭喜!这是一项艰巨的工作。
注意
完成的项目可以在网上查看,地址为reactjsblueprints-chapter4.herokuapp.com。
你可以通过许多方式改进项目。例如,搜索组件相当长,难以维护。将其拆分为多个较小的组件是个好主意。
你还可以实现一个update方法,以便将每个搜索结果的点击都存储在你的 MongoDB 实例中。这使得你能够在你用户中查看热门点击。
在下一章中,我们将走出室内,探讨如何制作基于地图的应用程序,并使用 HTML5 地理位置 API。
第五章:使用 HTML5 API 创建地图应用
在本章中,我们将使用 ReactJS 介绍各种 HTML5 API,并生成一个可以在您的桌面浏览器以及移动设备上运行的基于地图的应用程序。
简而言之,这些是我们将要讨论的主题:
-
有用 HTML5 API 概述
-
高精度时间 API
-
震动 API
-
电池状态 API
-
页面可见性 API
-
地理位置 API
-
-
反向地理定位
-
静态和交互式地图
HTML5 API 的状态
HTML5 规范增加了一些有用的 API,您可能还没有尝试过。原因很可能是缺乏浏览器支持和知道它们的存在。自从 HTML5 诞生以来,已经引入了许多 API。一些已经达到稳定状态;一些仍在发展中;遗憾的是,一些已经落伍,即将被弃用——比如非常有前途的 getUserMedia API——或者无法获得足够的支持以在所有浏览器上运行。
让我们看看目前最有趣的 API 以及如何使用它们来创建强大的 Web 应用程序。我们将在本章后面创建的地图应用程序中使用其中的一些。
高精度时间 API
如果您的网站加载速度过慢,用户会感到沮丧并离开。因此,测量执行时间和页面加载时间是用户体验最重要的方面之一,但遗憾的是,这也是最难调试的问题之一。
由于历史原因,测量页面加载最常用的方法是使用 Date API 来比较时间戳。这在很长一段时间内是最好的工具,但这种方法存在许多问题。
JavaScript 时间因其不准确而臭名昭著(例如,某些版本的 Internet Explorer 如果结果小于某个阈值,就会简单地向下取整时间表示,这使得获取正确测量值几乎成为不可能)。Date API 只能在代码在浏览器中运行时使用,这意味着您无法测量涉及服务器或网络的进程。它还引入了开销和代码的杂乱。
简而言之,您值得一个更好的工具,一个原生的浏览器工具,提供高精度,并且不会使您的代码库变得杂乱。幸运的是,所有这些都已经以高精度时间 API的形式提供给您。它提供了以亚毫秒为分辨率的当前时间。与 Date API 不同,它不受系统时钟偏移或调整的影响,并且由于它是原生的,不会创建额外的开销。
该 API 仅公开一个名为now()的方法。它返回一个精确到千分之一的毫秒级的时间戳,允许您对代码进行精确的性能测试。
用高分辨率时间 API 替换你代码中使用日期 API 的实例非常简单。例如,以下代码使用了日期 API(可能会记录一个正数、负数或零):
var mark_start = Date.now();
doSomething();
var duration = (Date.now() - mark_start);
使用performance.now()的类似操作看起来像下一个部分,不仅会更准确,而且始终是正数:
var mark_start = performance.now();
doSomething();
var duration = (performance.now() - mark_start);
正如所述,高分辨率时间 API 最初只公开了一个方法,但通过用户时间 API,你可以访问更多方法,让你可以测量性能而不会在代码库中留下过多的变量:
performance.mark('startTask')
doSomething();
performance.mark('endTask');
performance.measure('taskDuration','startTask','endTask');
你可以通过调用performance.getEntriesByType('measure')或performance.getEntriesByType('mark')来按类型或名称获取现有的标记。你也可以通过调用performance.getEntries()来获取所有条目的列表:
performance.getEntriesByName('taskDuration')
你可以通过调用performance.clearMarks()轻松地移除你设置的任何标记。不带值调用它将清除所有标记,但你也可以通过调用clearMarks()并指定要删除的标记来移除单个标记。对于度量也是如此,使用performance.clearMeasure()。
使用performance.mark()和performance.measure()来测量代码的执行时间非常棒,但用它们来测量页面加载仍然相当笨拙。为了帮助调试页面加载,已经开发了一个第三个 API,它进一步扩展了高分辨率时间 API。这被称为导航时间 API,它提供了与 DNS 查找、TCP 连接、重定向、DOM 构建等相关度量的信息。
它通过记录页面加载过程中里程碑的时间来实现。有许多以毫秒为单位的事件被给出,可以通过PerformanceTiming接口访问。你可以轻松地使用这些记录来计算围绕页面加载时间的许多因素。例如,你可以通过从timing.navigationStart减去timing.loadEventEnd来测量页面对用户可见的时间,或者通过从timing.domainLookupStart减去timing.domainLookupEnd来测量 DNS 查找所需的时间。
performance.navigation对象还存储了两个属性,可以用来确定页面加载是由重定向、后退/前进按钮还是正常 URL 加载触发的。
所有这些方法结合起来,使你能够找到应用程序中的瓶颈。我们将使用 API 来获取调试信息和突出显示应用程序中加载时间最长的部分。
这些 API 的浏览器支持情况各不相同。高分辨率时间 API和导航时间 API都受到现代浏览器的支持,但资源时间 API不受 Safari 或 Safari Mobile 的支持,因此你需要练习防御性编程,以避免TypeErrors阻止你的页面工作。
震动 API
振动 API 提供了与移动设备内置振动硬件组件交互的能力。如果 API 不受支持,则不会发生任何操作;因此,在设备不支持它的情况下使用是安全的:
API 通过调用 navigator.vibrate 方法来激活。它可以接受一个单独的数字来振动一次,或者一个值数组来交替振动、暂停,然后再振动。传递 0、一个空数组或包含所有零的数组将取消任何当前正在进行的振动模式:
// Vibrate for one second
navigator.vibrate(1000);
// Vibrate for two seconds, wait one second,
// then vibrate for two seconds
navigator.vibrate([2000, 1000, 2000]);
// Any of these will terminate the vibration early
navigator.vibrate();
navigator.vibrate(0);
navigator.vibrate([]);
该 API 针对移动设备,自 2012 年以来一直存在。运行 Chrome 或 Firefox 的 Android 设备支持该 API,但在 Safari 或移动设备上没有支持,而且似乎永远不会支持:
这很遗憾,因为振动有许多有效的用例,例如,在用户与按钮或表单控件交互时提供触觉反馈,或者提醒用户有通知:
当然,您也可以用它来娱乐,例如,通过播放流行的旋律:
// Super Mario Theme Intro
navigator.vibrate([125,75,125,275,200,275,125,75,125,275,200,600,200,600]);
// The Darth Vader Themenavigator.vibrate([500,110,500,110,450,110,200,110,170,40,450,110,200,110,170,40,500]);
// James Bond 007
navigator.vibrate([200,100,200,275,425,100,200,100,200,275,425,100,75,25,75,125,75,125,75,25,75,125,100,100]);
一份有趣的振动 API 调音列表可以在 gearside.com/custom-vibration-patterns-mobile-devices/ 找到:
我们将在我们的地图应用中使用振动 API 来响应用户的按钮点击:
电池状态 API
电池状态 API 允许您检查设备电池的状态,并触发有关电池电量和状态变化的的事件。这非常有用,因为我们可以使用这些信息来禁用耗电操作,并在电池电量低时推迟 AJAX 请求和其他网络相关流量:
该 API 提供了四种方法和四种事件。方法包括 charging、chargingTime、dischargingTime 和 level,事件包括 chargingchange、levelchange、chargingtimechange 和 dischargingtimechange:
您可以向您的 mount 方法添加事件监听器,以响应电池状态的变化:
componentWillMount() {
if("battery" in navigator) {
navigator.getBattery().then( (battery)=> {
battery.addEventListener('chargingchange',
this.onChargingchange);
battery.addEventListener('levelchange',
this.onLevelchange);
battery.addEventListener('chargingtimechange',
this.onChargingtimechange);
battery.addEventListener('dischargingtimechange',
this.onDischargingtimechange);
});
}
}
如果浏览器不支持电池 API,则不需要添加事件监听器,所以在添加任何事件监听器之前检查 navigator 对象是否包含 battery 是一个好主意:
onChargingchange(){
console.log("Battery charging? " +
(navigator.battery.charging ? "Yes" : "No"));
},
onLevelchange() {
console.log("Battery level: " +
navigator.battery.level * 100 + "%");
},
onChargingtimechange() {
console.log("Battery charging time: " +
navigator.battery.chargingTime + " seconds");
},
onDischargingtimechange() {
console.log("Battery discharging time: " +
navigator.battery.dischargingTime + " seconds");
}
这些函数将在您的电池状态发生变化时随时触发:
电池 API 由 Firefox、Chrome 和 Android 浏览器支持。Safari 和 IE 都不支持它。
我们将在我们的地图应用中使用这个功能来警告用户,如果电池电量低,将切换到静态地图:
页面可见性 API
页面可见性 API 允许我们检测我们的页面是否可见或聚焦,隐藏,或者不在聚焦(即,最小化或标签页):
该 API 没有任何方法,但它暴露了visibilitychange事件,我们可以使用它来检测页面可见状态的变化以及两个只读属性,hidden和visibilityState。当用户最小化网页或切换到另一个标签页时,API 会发送一个关于页面可见性的visibilitychange事件。
它可以轻松地添加到你的 React 组件的挂载方法中:
componentWillMount(){
document.addEventListener('visibilitychange',
this.onVisibilityChange);
}
然后,你可以在onVisibilityChange函数中监控页面可见性的任何变化:
onVisibilityChange(event){
console.log(document.hidden);
console.log(document.visibilityState);
}
你可以使用这个功能在用户没有积极使用你的页面时停止执行任何不必要的网络活动。如果你正在显示内容,如不应在没有用户查看页面时切换到下一张幻灯片的图片轮播,或者如果你正在提供视频或游戏内容,你可能也想暂停执行。当用户重新访问你的页面时,你可以无缝地继续执行。
我们在地图应用中不会使用这个 API,但当我们制作一个应该在玩家最小化或切换窗口时暂停的游戏时,我们一定会使用它,在第九章创建共享应用中。
浏览器支持非常出色。该 API 被所有主流浏览器支持。
地理位置 API
地理位置 API 定义了一个高级接口来定位信息,如纬度和经度,这些信息与托管它的设备相关联。
了解用户的位置是一个强大的工具,可以用来提供本地化内容、个性化广告或搜索结果,以及绘制你周围环境的地图。
该 API 不关心位置来源,因此设备完全决定其信息来源。常见来源包括 GPS、从网络信号推断出的位置、Wi-Fi、蓝牙、MAC 地址、RFID、GSM 小区 ID 等等;它还包括手动用户输入。因为它可以从这么多来源中获取信息,所以该 API 可以在包括手机和桌面电脑在内的多种设备上使用。
该 API 暴露了属于navigator.geolocation对象的三种方法:getCurrentPosition、watchPosition和clearWatch。getCurrentPosition和watchPosition执行相同的任务。区别在于第一个方法执行一次性请求,而后者持续监控设备的变化。
坐标包含以下属性:latitude、longitude、altitude、accuracy、altitudeAccuracy、heading和speed。桌面浏览器通常不会报告除latitude和longitude之外的其他值。
获取位置返回一个包含时间戳和一组坐标的对象。时间戳让你知道位置是在何时被检测到的,这在需要知道数据的新鲜程度时可能很有用:
// Retrieves your current location with all options
var options = {
enableHighAccuracy: true,
timeout: 1000,
maximumAge: 0
};
var success = (pos) => {
var coords = pos.coords;
console.log('Your current position is: ' +
'\nLatitude : ' + coords.latitude +
'\nLongitude: ' + coords.longitude +
'\nAccuracy is more or less ' + coords.accuracy + ' meters.'+
'\nLocation detected: '+new Date(pos.timestamp));
};
var error = (err) => {
console.warn('ERROR(' + err.code + '): ' + err.message);
};
navigator.geolocation.getCurrentPosition(success, error, options);
如果你已经启动了watchPosition,可以调用clearWatch函数来停止监控:
// Sets up a basic watcher
let watcher=navigator.geolocation.watchPosition(
(pos) =>{console.log(pos.coords)},
(err)=> {console.warn('ERROR(' + err.code + '): ' + err.message)},
null);
// Removes the watcher
navigator.geolocation.clearWatch(watcher)
这个 API 将成为我们地图应用的核心。实际上,除非我们能够获取当前位置,否则我们不会向用户显示任何内容。幸运的是,浏览器支持非常好,因为它被所有主要应用程序支持。
创建我们的地图应用
让我们从第一章的基本设置开始。像往常一样,我们将通过添加一些额外的包来扩展脚手架:
npm install --save-dev classnames@2.2.1 react-bootstrap@0.29.3 reflux@0.4.1 url@0.11.0 lodash.pick@3.1.0 lodash.identiy@3.0.0 leaflet@0.7.7
这些包中的大多数你应该都很熟悉。我们之前章节中没有使用的是 url、来自 lodash 库的两个实用函数和 leaflet 地图库。我们将使用 url 函数进行 URL 解析和解析。当我们需要组合一个指向我们选择的地图服务的 URL 时,lodash 函数将很有用。Leaflet 是一个用于交互式地图的开源 JavaScript 库。当我们向应用中添加交互式地图时,我们将回到它。
package.json 中的 devDependencies 部分现在应该看起来像这样:
"devDependencies": {
"babel-preset-es2015": "⁶.3.13",
"babel-preset-react": "⁶.3.13",
"babelify": "⁷.2.0",
"browser-sync": "².10.0",
"browserify": "¹³.0.0",
"browserify-middleware": "⁷.0.0",
"classnames": "².2.1",
"lodash": "⁴.11.2",
"react": "¹⁵.0.2",
"react-bootstrap": "⁰.29.3",
"react-dom": "¹⁵.0.2",
"reactify": "¹.1.1",
"reflux": "⁰.4.1",
"serve-favicon": "².3.0",
"superagent": "¹.5.0",
"url": "⁰.11.0",
"watchify": "³.6.1"
}
让我们打开 public/index.html 并添加一些代码:
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"/>
<link rel="stylesheet"
href="//cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/leaflet.css"/>
我们需要 Bootstrap CSS 和 Leaflet CSS 来正确显示我们的地图。
我们还需要应用一些样式,所以打开 public/app.css 并将其内容替换为以下样式:
/** SPINNER **/
.spinner {
width: 40px;
height: 40px;
position: relative;
margin: 100px auto;
}
.double-bounce1, .double-bounce2 {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #333;
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2.0s infinite ease-in-out;
animation: sk-bounce 2.0s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
@-webkit-keyframes sk-bounce {
0%, 100% { -webkit-transform: scale(0.0) }
50% { -webkit-transform: scale(1.0) }
}
@keyframes sk-bounce {
0%, 100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
} 50% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}
我们添加的第一组样式是一组弹跳球。这些将在我们首次加载应用内容时显示,因此它们看起来很重要,并且它们向用户传达了正在发生某事。这组代码由 tobiasahlin.com/spinkit/ 提供。在这个网站上,你还可以找到一些使用硬件加速 CSS 动画简单加载旋转器的更多示例。
我们将创建两种不同类型的地图,一种静态的,一种交互式的。我们还将设置缩放和退出按钮,并确保它们在小设备上看起来不错:
/** MAPS **/
.static-map{
margin: 20px 0 0 0;
}
.map-title {
color: #DDD;
position: absolute;
bottom: 10px;
margin: 0;
padding: 0;
left: 35%;
font-size: 18px;
text-shadow: 3px 3px 8px rgba(200, 200, 200, 1);
}
.map-button{
height: 100px;
margin-bottom: 20px;
}
.map {
position: absolute;
left: 15px;
right: 0;
top: 30px;
bottom: 0;
}
.buttonBack {
position: absolute;
padding: 10px;
width:55px;
height:60px;
top: -80px;
right: 25px;
z-index: 10;
}
.buttonMinus {
position: absolute;
padding: 10px;
width:40px;
height:60px;
top: 25px;
right: 25px;
}
.buttonPlus {
position: absolute;
padding: 10px;
width:40px;
height:60px;
top: 100px;
right: 25px;
}
这些按钮让我们在使用静态地图时可以放大和缩小。它们位于屏幕的右上角,并模仿交互式地图的功能:
@media screen and (max-width: 600px) {
.container {
padding: 0;
margin: 0
}
h1{
font-size:18px;
}
.container-fluid{
padding: 0;
margin: 0 0 0 20px;
}
.map-title {
left: 15%;
z-index:10;
top: 20px;
color: #666;
}
}
媒体查询对样式进行了一些小的调整,以确保在小设备上地图可见并且有适当的边距。
当你使用 node server.js 启动你的服务器时,你应该在浏览器中看到一个空白屏幕。我们准备好继续我们的应用开发。
设置地理位置
我们将首先创建一个服务来获取我们的反向地理位置。
在源文件夹中创建一个名为 service 的文件夹,并将其命名为 geo.js。向其中添加以下内容:
'use strict';
import config from '../config.json';
import utils from 'url';
const lodash = {
pick: require('lodash.pick'),
identity: require('lodash.identity')
};
import request from 'superagent';
我们需要作为 Bootstrap 过程的一部分安装的实用工具。url utils 参数将根据一组键和属性为我们创建一个 URL 字符串。Lodash pick 创建一个由所选对象属性组成的对象,而 identity 返回提供给它的第一个参数。
我们还需要创建一个 config.json 文件,其中包含我们将用于构造 URL 字符串的参数,让我们看一下以下代码片段:
class Geo {
reverseGeo(coords) {
const url = utils.format({
protocol: config.openstreetmap.protocol,
hostname: config.openstreetmap.host,
pathname: config.openstreetmap.path,
query: lodash.pick({
format: config.openstreetmap.format,
zoom: config.openstreetmap.zoom,
addressdetails: config.openstreetmap.addressdetails,
lat: coords.latitude,
lon: coords.longitude
}, lodash.identity)
});
const req = request.get(url)
.timeout(config.timeout)
我们使用超时构造我们的请求。Superagent 还有一些其他选项可以设置,例如接受头、查询参数等,让我们看一下以下代码片段:
const promise = new Promise(function (resolve, reject) {
req.end(function (err, res) {
if (err) {
reject(err);
} else if (res.error) {
reject(res.error);
在 Superagent 中有一个长期存在的 bug,其中一些错误(4xx 和 5xx)没有按照文档设置在err对象中,因此我们需要检查err和res.error以捕获所有错误,让我们看一下以下代码片段:
}
else {
try {
resolve(res.text);
} catch (e) {
reject(e);
}
}
});
});
return promise;
}
}
export default Geo;
我们将通过一个Promise实例返回我们的请求。Promise是一个用于延迟和异步计算的对象。它表示一个尚未完成但预计将来会完成的操作。
接下来,创建一个名为config.json的文件,并将其放置在您的源文件夹中,并添加以下内容:
{
timeout: 10000,
"openstreetmap": {
"name": "OpenStreetMap",
"protocol": "https",
"host": "nominatim.openstreetmap.org",
"path": "reverse",
"format": "json",
"zoom": "18",
"addressdetails": "1"
}
}
OpenStreetMap 是一个由志愿者使用当地知识、GPS 轨迹和捐赠的资源创建的开放许可的世界地图。据报道,有超过 200 万用户使用手动调查、GPS 设备、航空摄影和其他免费资源收集了数据。
我们将在本章的后面部分使用该服务来获取反向地理编码,并将其与 Leaflet 结合使用,以创建一个交互式地图。
让我们确保我们可以检索我们的当前位置和反向地理编码。打开app.jsx文件,并用以下代码替换内容:
'use strict';
import React from 'react';
import { render } from 'react-dom';
import { Grid, Row, Col, Button, ButtonGroup,
Alert, FormGroup, ControlLabel, FormControl }
from 'react-bootstrap';
import GeoService from './service/geo';
const Geo = new GeoService();
const App = React.createClass({
getInitialState(){
return {
locationFetched: false,
provider: null,
providerKey: null,
mapType: 'static',
lon: false,
lat: false,
display_name: "",
address: {},
zoom: 8,
serviceStatus:{up:true, e:""},
alertVisible: false
}
},
我们最终将在我们的应用中使用所有这些状态变量,但我们现在将更新并使用的是locationFetched、lon和lat。第一个变量的状态将决定我们是否会显示加载动画或地理查找的结果,让我们看一下以下代码片段:
componentDidMount(){
if ("mark" in performance) performance.mark('fetch_start');
this.fetchLocation();
},
在调用获取当前位置和反向地理编码的函数之前,我们设置了一个标记:
fetchLocation(){
navigator.geolocation.getCurrentPosition(
(res)=> {
const coords = res.coords;
this.setState({
lat: coords.latitude,
lon: coords.longitude
});
this.fetchReverseGeo(coords);
},
(err)=> {
console.warn(err)
},
null);
},
我们使用navigator.geolocation的一次性请求来获取用户的当前位置。然后我们将这个位置存储在我们的组件状态中。我们还调用fetchReverseGeo函数,并传递坐标:
fetchReverseGeo(coords){
Geo.reverseGeo(coords)
.then((data)=> {
if(data === undefined){
this.setState({alertVisible: true})
}
这将在稍后用于显示一个警告:
else {
let json = JSON.parse(data);
if (json.error) {
this.setState({ alertVisible: true })
} else {
if ("mark" in performance)
performance.mark("fetch_end");
if ("measure" in performance)
performance.measure("fetch_geo_time",
"fetch_start","fetch_end");
我们已经完成了数据获取,所以让我们测量它花费了多长时间。我们可以通过使用fetch_geo_time关键字在任何时候获取时间,正如它在前面的代码中所示。现在,考虑以下情况:
this.setState({
address: json.address,
display_name: json.display_name,
lat: json.lat,
lon: json.lon,
locationFetched: true
});
if ("vibrate" in navigator) navigator.vibrate(500);
在我们收到位置后,我们将其存储在我们的组件状态中,对于具有振动支持的网络浏览器和设备,我们发送一个短暂的振动,让用户知道应用已准备好使用。请参阅以下内容:
}
}
}).catch((e) => {
let message;
if( e.message ) message = e.message;
else message = e;
this.setState({
serviceStatus: {
up: false,
error: message}
})
});
},
当我们捕获到一个错误时,我们将错误消息作为我们组件状态的一部分存储。我们可能会以包含消息属性的对象或字符串的形式接收错误,所以我们确保在存储之前检查这一点。接下来是下一部分:
renderError(){
return (<Row>
<Col xs={ 12 }>
<h1>Error</h1>
Sorry, but I could not serve any content.
<br/>Error message: <strong>
{ this.state.serviceStatus.error }
</strong>
</Col>
</Row>)
},
如果我们依赖的任何第三方服务出现故障或不可用,我们将短路应用并显示错误消息,正如前面代码所示:
renderBouncingBalls(){
return (<Row>
<Col xs= { 12 }>
<div className = "spinner">
<div className = "double-bounce1"></div>
<div className = "double-bounce2"></div>
</div>
</Col>
</Row>)
},
我们在这个块中展示了 SpinKit 弹跳球。它总是在所有必要的数据完全加载之前显示,让我们看一下以下代码片段:
renderContent(){
return (<div>
<Row>
<Col xs = { 12 }>
<h1>Your coordinates</h1>
</Col>
<Col xs = { 12 }>
<small>Longitude:</small>
{ " " }{ this.state.lon }
{ " " }
<small>Latitude:</small>
{ " " }{ this.state.lat }
</Col>
<Col xs={12}>
<small>Address: </small>
我们让用户知道我们得到了一组坐标和现实世界的地址:
{ this.state.address.county?
this.state.address.county + ", " : "" }
{ this.state.address.state?
this.state.address.state + ", " : "" }
{ this.state.address.country ?
this.state.address.country: "" }
</Col>
</Row>
<Row>
<Col xs={12}>
{this.state.provider ?
this.renderMapView() :
this.renderButtons()}
这个if-else块将根据用户的选择显示世界地图,静态或交互式;或者,它将显示一组按钮和选择新位置的选择项。
我们也可以使用路由在这些选择之间切换。但这意味着需要设置地图路由、主页路由等等。这通常是一个好主意,但并不总是必要的,这个应用展示了如何在不使用路由的情况下构建一个简单的应用,让我们看一下以下代码片段:
</Col>
</Row>
<Row>
<Col xs={12}>
{this.state.provider ? <div/> : <div>
<h3>Debug information</h3>
{this.debugDNSLookup()}
{this.debugConnectionLookup()}
{this.debugAPIDelay()}
{this.debugPageLoadDelay()}
{this.debugFetchTime()}
我们在这里显示来自高分辨率时间 API 的调试信息。我们将每个部分委托给一个函数。这被称为关注点分离。目的是封装代码部分以增加模块化和简化开发。在阅读代码时,当程序请求{this.debugDNSLookup()}时,它返回有关 DNS 查找时间的一些信息。如果我们内联函数,那么理解代码块的目的就会更困难:
</div>}
</Col>
</Row>
</div>);
},
debugPageLoadDelay(){
return "timing" in performance ?
<div>Page load delay experienced
from page load start to navigation start:{" "}
{Math.round(((performance.timing.loadEventEnd -
performance.timing.navigationStart) / 1000)
* 100) / 100} seconds.</div> : <div/>
},
在每个调试函数中,我们检查性能对象是否支持我们想要使用的方法。大多数现代浏览器支持高分辨率时间 API,但用户时间 API 的支持则更加零散。
数学运算将毫秒时间转换为秒:
debugAPIDelay(){
return "getEntriesByName" in performance ?
(<div>Delay experienced fetching reverse geo
(after navigation start):{" "}
{Math.round((performance.getEntriesByName(
"fetch_geo_time")[0].duration / 1000) * 100) / 100}
{" seconds"}.</div>) : <div/>
},
debugFetchTime(){
return "timing" in performance ?
<div>Fetch Time: {performance.timing.responseEnd -
performance.timing.fetchStart} ms.</div> : null
},
debugDNSLookup(){
return "timing" in performance ?
<div> DNS lookup: {performance.timing.domainLookupEnd -
performance.timing.domainLookupStart} ms.</div> : null
},
debugConnectionLookup(){
return "timing" in performance ?
<div>Connection lookup: {performance.timing.connectEnd -
performance.timing.connectStart} ms. </div> : null
},
renderGrid(content){
return <Grid>
{content}
</Grid>
},
render() {
if(!this.state.serviceStatus.up){
return this.renderGrid(this.renderError());
如果发生错误,例如,如果 SuperAgent 请求调用失败,我们将显示错误消息而不是提供任何内容,让我们看一下以下代码:
}
else if( !this.state.locationFetched ){
return this.renderGrid(this.renderBouncingBalls());
我们将显示一组弹跳球,直到我们有一个位置和位置,让我们看一下以下代码:
}
else {
return this.renderGrid(this.renderContent());
如果一切顺利,我们将渲染内容:
}
}
});
render(
<App greeting="Chapter 5"/>,
document.getElementById('app')
);
当您添加了这段代码后,您应该看到应用以一组弹跳球开始。然后,在它获取了您的位置之后,您应该看到经纬度值以及您的真实位置地址。在此之下,您应该看到一些调试信息。
关于这个组件的慷慨之处有一点要注意:在编写组件或任何代码时,随着你花费的时间增加,重构的需求也会大致增加。这个组件是一个很好的例子,因为它现在包含了很多不同的逻辑。它执行地理位置、调试以及渲染。明智的做法是将它拆分成几个不同的组件,以实现关注点的分离,正如在renderContent()方法的注释中所讨论的那样。让我们看一下以下截图:

位置应该相当准确,多亏了 OpenStreetMap 中详尽的现实世界地址列表,将您的当前位置转换为的位置也应该相当接近您所在的位置。
调试信息会告诉您从应用程序加载到视图准备就绪所需的时间。当在本地主机上运行时,DNS 和 连接查找总是以 0 毫秒的速度加载,瞬间完成。当您在外部服务器上运行应用程序时,这些数字将会增加,并反映查找您的服务器并连接到它所需的时间。
在前面的屏幕截图中,您会注意到页面加载并准备就绪所需的时间并不长。真正慢的部分是您等待应用程序从反向地理定位获取位置数据所需的时间。根据屏幕截图,大约需要 1.5 秒。这个数字通常会在 1-10 秒之间波动,除非您找到一种方法来缓存请求,否则您无法减少它。
现在我们知道我们能够获取用户的位置和地址,让我们创建一些地图。
显示静态地图
静态地图只是您选择位置的图像快照。使用静态地图比交互式地图有许多优点,例如:
-
无额外开销。它是一个纯图像,因此它既快又轻量。
-
您可以预先渲染和缓存地图。这意味着对地图提供商的访问次数更少,您可能可以使用更小的数据计划。
-
静态还意味着您对地图有完全的控制权。使用第三方服务通常意味着将一些控制权交给服务。
除了 OpenStreetMap 之外,我们还可以使用许多地图提供商来显示世界地图。其中还包括 Yahoo! 地图、Bing 地图、Google 地图、MapQuest 等。
我们将设置我们的应用程序以连接到这些服务中的一小部分,这样您就可以比较并决定您更喜欢哪一个。
让我们再次打开 config.json 并添加更多端点。在文件的结束括号之前添加以下内容(确保在 openstreetmap 后面添加逗号):
"google": {
"name": "google",
"providerKey": "",
"url": "http://maps.googleapis.com/maps/api/staticmap",
"mapType": "roadmap",
"pushpin": false,
"query": {
"markerColor": "color:purple",
"markerLabel": "label:A"
},
"join": "x"
},
"bing": {
"name": "bing",
"providerKey": "",
"url": "https://dev.virtualearth.net/REST/V1/Imagery/Map/Road/",
"query": {},
"pushpin": true,
"join": ","
},
"mapQuest": {
"name": "mapQuest",
"url": "https://www.mapquestapi.com/staticmap/v4/getmap",
"providerKey": "",
"mapType": "map",
"icon": "red_1-1",
"query": {},
"pushpin": false
}
对于 Bing 和 mapQuest,在使用它们之前,您需要设置 providerKey 键。对于 Bing 地图,请访问 www.bingmapsportal.com/ 上的 Bing Maps Dev Center,登录,在 我的账户 下选择 密钥,并添加一个应用程序以接收一个密钥。
对于 mapQuest,请访问 developer.mapquest.com/plan_purchase/steps/business_edition/business_edition_free 并创建一个免费账户。创建一个应用程序并获取您的密钥。
对于 Google,请访问 developers.google.com/maps/documentation/static-maps/get-api-key 并注册一个免费的 API 密钥。
为了使用端点,我们需要设置一个服务和工厂。创建 source/service/map-factory.js 并添加以下代码:
'use strict';
import MapService from './map-service';
const mapService = new MapService();
export default class MapFactory {
getMap(params) {
return mapService.getMap(params);
}
}
然后,创建 source/service/map-service.js 并添加以下代码:
'use strict';
import config from '../config.json';
import utils from 'url';
export default class MapService {
getMap( params ) {
let url;
let c = config[ params.provider ];
let size = [ params.width, params.height ].join(c.join);
let loc = [ params.lat, params.lon ].join(",");
我们将在提供者的名称中发送 param,我们将根据这个获取配置数据。
地图提供者对如何连接大小参数有不同的要求,因此我们根据配置中的值将宽度和高度连接起来。
所有提供者都同意纬度和经度应该用逗号连接,因此我们以这种格式设置了一个位置变量。请参考以下代码:
let markers = Object.keys(c.query).length ?
Object.keys(c.query).map((param)=> {
return c.query[param];
}).reduce((a, b)=> {
return [a, b].join("|") + "|" + loc;
}) : "";
此代码片段将添加您在config.json中配置的任何标记。我们只有在有配置标记的情况下才会使用此变量:
let key = c.providerKey ? "key=" + c.providerKey : "";
let maptype = c.mapType ? "maptype=" + c.mapType : "";
let pushpin = c.pushpin ? "pp=" + loc + ";4;A": "";
if (markers.length) markers = "markers=" + markers;
我们将添加键并从配置中设置地图类型。必应将标记称为推针,因此这个变量仅在必应地图中使用:
if(params.provider === "bing"){
url = `${c.url}/${loc}/${params.zoom}?${maptype}¢er=${loc}&size=${size}&${pushpin}&${markers}&${key}`;
}
else {
url = `${c.url}?${maptype}¢er=${loc}&zoom=${params.zoom}&size=${size}&${pushpin}&${markers}&${key}`;
}
我们将根据是提供必应地图还是其他提供者的地图来设置两个不同的 URL。请注意,我们正在使用 ES6 模板字符串来构建我们的 URL。这些字符串使用反引号组成,并使用${ }语法进行字符串替换。
这是一个不同于我们在source/service/geo.js中使用的方法,实际上我们也可以在这里采用相同的方法。最后,我们将从params中传递id变量和完成的地图 URL 到我们的返回函数:
return {
id: params.id,
data: {
mapSrc: url
}
};
}
}
接下来,我们需要为静态地图创建一个视图。我们将创建三个按钮,这将使我们能够使用所有三个地图提供者打开当前位置的地图。您的应用程序应该看起来像以下截图中的那样:

在source文件夹下创建一个名为views的文件夹,添加一个名为static-map.jsx的文件,并添加以下代码:
'use strict';
import React from 'react';
import { render } from 'react-dom';
import { Button } from 'react-bootstrap';
import Map from '../components/static-map.jsx';
const StaticMapView = React.createClass({
propTypes: {
provider: React.PropTypes.string.isRequired,
providerKey: React.PropTypes.string,
mapType: React.PropTypes.string,
lon: React.PropTypes.number.isRequired,
lat: React.PropTypes.number.isRequired,
display_name: React.PropTypes.string,
address: React.PropTypes.object.isRequired
},
getDefaultProps(){
return {
provider: 'google',
providerKey: '',
mapType: 'static',
lon: 0,
lat: 0,
display_name: "",
address: {}
}
},
getInitialState(){
return {
zoom: 8
}
},
lessZoom(){
this.setState({
zoom: this.state.zoom > 1 ?
this.state.zoom -1 : 1
});
},
moreZoom(){
this.setState({
zoom: this.state.zoom < 18 ?
this.state.zoom + 1 : 18
});
},
如前述代码所示,我们将允许在 1 到 18 之间进行缩放。我们将使用设备的当前高度和宽度来设置我们的地图画布:
getHeightWidth(){
const w = window.innerWidth
|| document.documentElement.clientWidth
|| document.body.clientWidth;
const h = window.innerHeight
|| document.documentElement.clientHeight
|| document.body.clientHeight;
return { w, h };
},
这些按钮将允许我们增加或减少缩放,或者退出回到主菜单:
render: function () {
return (<div>
<Button
onClick = { this.lessZoom }
bsStyle = "primary"
className = "buttonMinus">
-</Button>
<Button
onClick = { this.moreZoom }
bsStyle = "primary"
className = "buttonPlus">
+</Button>
<Button
onClick = { this.props.goBack }
bsStyle = "success"
className = "buttonBack">
Exit</Button>
请参考以下代码:
<div className="map-title" >
{ this.props.address.road }{ ", " }
{ this.props.address.county }
</div>
<Map provider = { this.props.provider }
providerKey = { this.props.providerKey }
id = { this.props.provider + "-map" }
lon = { this.props.lon }
lat = { this.props.lat }
zoom = { this.state.zoom }
height = { this.getHeightWidth().h-150 }
width = { this.getHeightWidth().w-150 }
/>
</div>)
}
});
export default StaticMapView;
您可能会想知道为什么我们把文件放在view文件夹里,而其他文件放在component文件夹里。这没有程序上的原因。所有文件都可以放在组件文件夹中,React 也不会介意。目的是为程序员提供有关数据结构的线索,希望这有助于在返回和编辑项目时更容易理解。
接下来,我们需要创建一个名为static-map的组件,它将接受地图属性并服务一个有效的图像。
在components文件夹中创建一个新文件夹,添加一个名为static-map.jsx的新文件,并添加以下代码:
'use strict';
import React from 'react';
import MapFactory from '../service/map-factory';
const factory = new MapFactory();
const StaticMap = React.createClass({
propTypes: {
provider: React.PropTypes.string.isRequired,
providerKey: React.PropTypes.string,
id: React.PropTypes.string.isRequired,
lon: React.PropTypes.string.isRequired,
lat: React.PropTypes.string.isRequired,
height: React.PropTypes.number.isRequired,
width: React.PropTypes.number.isRequired,
zoom: React.PropTypes.number
},
getDefaultProps(){
return {
provider: '',
providerKey: '',
id: 'map',
lat: "0",
lon: "0",
height: 0,
width: 0,
zoom: 8
}
},
getLocation () {
return factory.getMap({
providerKey: this.props.providerKey,
provider: this.props.provider,
id: this.props.id,
lon: this.props.lon,
lat: this.props.lat,
height: this.props.height,
width: this.props.width,
zoom: this.props.zoom
});
},
render () {
const location = this.getLocation();
location对象包含我们的地图 URL 和map-factory参数生成的所有相关数据:
let mapSrc;
let style;
if (!location.data || !location.data.mapSrc) {
return null;
}
mapSrc = location.data.mapSrc;
style = {
width: '100%',
height: this.props.height
};
return (
<div style = { style }
className = "map-container">
<img style={ style }
src={ mapSrc }
className = "static-map" />
</div>
);
}
});
export default StaticMap;
这是我们展示静态地图所需的所有管道。让我们打开app.jsx并添加将把这些文件连接在一起的代码。
在render方法的上下两行之间,添加一行带有以下代码的新行:
<Row>
<Col xs = { 12 }>
{ this.state.provider ?
this.renderMapView() :
this.renderButtons() }
</Col>
</Row>
在我们之前的应用程序中,我们使用路由来导航前后,但这次我们将完全跳过路由,并使用这些变量来显示应用程序的不同状态。
我们还需要添加两个引用的函数,所以添加以下内容:
renderButtons(){
return (<div>
<h2>Static maps</h2>
<ButtonGroup block vertical>
<Button
className = "map-button"
bsStyle = "info"
onClick = { this.setProvider.bind(null,'google','static') }>
Open static Google Map for { this.state.address.state }
{ ", " }
{ this.state.address.country }</Button>
<Button
className = "map-button"
bsStyle = "info"
onClick = { this.setProvider.bind(null,'bing','static') }>
Open Bing map for { this.state.address.state }{ ", " }
{ this.state.address.country }</Button>
<Button
className = "map-button"
bsStyle = "info"
onClick = { this.setProvider.bind(null,'mapQuest','static') }>
Open MapQuest map for { this.state.address.state }{ ", " }
{ this.state.address.country }</Button>
</ButtonGroup>
</div>)
},
setProvider(provider, mapType){
let providerKey = "";
if (hasOwnProperty.call(config[provider], 'providerKey')) {
providerKey = config[provider].providerKey;
}
this.setState({
provider: provider,
providerKey: providerKey,
mapType: mapType});
// provide tactile feedback if vibration is supported
if ("vibrate" in navigator) navigator.vibrate(50);
},
在文件顶部添加这两个导入:
import StaticMapView from './views/static-map.jsx';
import config from './config.json';
最后,添加前面代码中引用的两个函数:
renderMapView(){
return (<StaticMapView { ...this.state }
goBack={ this.goBack }/>);
},
goBack(){
this.setState({ provider: null });
},
goBack 方法只是简单地使提供者变为空。这将切换主视图渲染中是否显示按钮或地图。
当你现在打开应用程序时,你会看到三个不同的按钮,允许你使用 Google Maps、Bing Maps 或 MapQuest 打开你当前位置的地图。图片将显示 Bing Maps 中的当前位置,如下面的截图所示:

没有一些巧妙的硬编码,你不能打开除你自己的位置之外的任何位置。让我们创建一个输入框,让你可以根据经度和纬度选择不同的位置,以及一个选择框,它将方便地将位置设置为预定义的世界上任何城市之一。
将这些函数添加到 app.jsx:
validateLongitude(){
const val = this.state.lon;
if (val > -180 && val <= 180) {
return "success"
} else {
return "error";
}
},
如前述代码所示,有效的经度值介于负 180 度和正 180 度之间。我们将从 event 处理器获取传递给我们的当前值:
handleLongitudeChange(event){
this.setState({ lon: event.target.value });
},
有效的纬度值介于负 90 度和正 90 度之间:
validateLatitude(){
const val = this.state.lat;
if (val > -90 && val <= 90) {
return "success"
} else {
return "error";
}
},
当用户点击 Fetch 按钮时,我们将执行一个新的反向地理位置搜索:
handleLatitudeChange(event){
this.setState({ lat: event.target.value });
},
handleFetchClick(){
this.fetchReverseGeo({
latitude: this.state.lat,
longitude: this.state.lon
});
},
这是新的地理位置搜索:
handleAlertDismiss() {
this.setState({
alertVisible: false
});
},
handleAlertShow() {
this.setState({
alertVisible: true
});
},
handleSelect(e){
switch(e.target.value){
case "london":
this.fetchReverseGeo({
latitude: 51.50722,
longitude:-0.12750
});
case "dublin":
this.fetchReverseGeo({
latitude: 53.347205,
longitude:-6.259113
});
case "barcelona":
this.fetchReverseGeo({
latitude: 41.386964,
longitude: 2.170036
});
case "newyork":
this.fetchReverseGeo({
latitude: 40.723189,
longitude:-74.003340
});
case "tokyo":
this.fetchReverseGeo({
latitude: 35.707743,
longitude:139.733580
});
case "beijing":
this.fetchReverseGeo({
latitude: 39.895591,
longitude:116.413371
});
}
},
在 render() 中静态地图的标题上方添加以下内容:
<h2>Try a different location</h2>
<FormGroup>
<ControlLabel>Longitude</ControlLabel>
<FormControl
type="text"
onChange={ this.handleLongitudeChange }
defaultValue={this.state.lon}
placeholder="Enter longitude"
label="Longitude"
help="Longitude measures how far east or west of the prime
meridian a place is located. A valid longitude is
between -180 and +180 degrees."
validationState={this.validateLongitude()}
/>
<FormControl.Feedback />
</FormGroup>
<FormGroup>
<ControlLabel>Latitude</ControlLabel>
<FormControl type="text"
onChange={ this.handleLatitudeChange }
defaultValue={this.state.lat}
placeholder="Enter latitude"
label="Latitude"
help="Latitude measures how far north or south of the equator
a place is located. A valid longitude is between -90
and +90 degrees."
validationState={this.validateLongitude()}
/>
<FormControl.Feedback />
</FormGroup>
{this.state.alertVisible ?
<Alert bsStyle="danger"
onDismiss={this.handleAlertDismiss}
dismissAfter={2500}>
<h4>Error!</h4>
<p>Couldn't geocode this coordinates...</p>
</Alert> : <div/>}
如果用户尝试获取一组无效的坐标,将只显示此警报。它将在 2,500 毫秒后自动消失,让我们看一下以下代码:
<Button bsStyle="primary"
onClick={this.handleFetchClick}>
Fetch new geolocation
</Button>
<p>(note, this will fetch the closest location based on the new
input values)</p>
<FormGroup>
<FormControl
componentClass="select"
onChange={this.handleSelect}
placeholder="select location">
<option defaultSelected value="">
Choose a location
</option>
<option value="london">London</option>
<option value="dublin">Dublin</option>
<option value="tokyo">Tokyo</option>
<option value="beijing">Bejing</option>
<option value="newyork">New York</option>
</FormControl>
</FormGroup>
让我们看一下以下截图:

创建交互式地图
交互式地图为在网站上展示地图的用户提供了通常期望的交互级别。
与显示普通图像相比,显示交互式地图有许多好处:
-
你可以设置位于当前视口之外的位置标记。当你想显示一个小地图,但提供可以通过移动或缩放地图发现的位置信息时,这非常完美。
-
交互式地图为用户提供了一个游乐场,这使得他们更有可能在你的网站上花费时间。
-
与静态内容相比,交互式内容通常使应用程序感觉更好。
对于我们的交互式地图,我们将使用 Leaflet 和 OpenStreetMap 的组合。它们都是开源且免费的资源,这使得它们成为我们新兴地图应用的绝佳选择。
在 source/views 中创建一个新文件,并将其命名为 interactive-map.jsx。向其中添加以下代码:
'use strict';
import React from 'react';
import {Button} from 'react-bootstrap';
import L from 'leaflet';
L.Icon.Default.imagePath =
" https://reactjsblueprints-chapter5.herokuapp.com/images";
const DynamicMapView = React.createClass({
propTypes: {
createMap: React.PropTypes.func,
goBack: React.PropTypes.func.isRequired,
center: React.PropTypes.array.isRequired,
lon: React.PropTypes.string.isRequired,
lat: React.PropTypes.string.isRequired,
zoom: React.PropTypes.number
},
map:{},
getDefaultProps(){
return {
center: [0, 0],
zoom: 8
}
},
createMap: function (element) {
this.map = L.map(element);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{attribution: '© <a href="http://osm.org/copyright">
OpenStreetMap</a> contributors'}).addTo(this.map);
return this.map;
},
Leaflet 包使用 x、y 和 zoom 参数从 openstreetmap.org 获取图像瓦片。请参考以下代码:
setupMap: function () {
this.map.setView([this.props.lat, this.props.lon],
this.props.zoom);
this.setMarker(this.props.lat, this.props.lon);
},
这是我们在创建地图时使用的函数。我们使用选择的纬度、经度和缩放设置视图,并在视图中间添加一个标记。
可以通过向内部 setMarker 函数传递 location 对象来添加更多标记:
setMarker(lat,lon){
L.marker([lat, lon]).addTo(this.map);
},
componentDidMount: function () {
if (this.props.createMap) {
this.map = this.props.createMap(this.refs.map);
} else {
this.map = this.createMap(this.refs.map);
}
this.setupMap();
},
在挂载时,我们通过内部函数 createMap 创建地图,除非我们通过 props 传递外部函数,如下所示:
getHeightWidth(){
const w = window.innerWidth
|| document.documentElement.clientWidth
|| document.body.clientWidth;
const h = window.innerHeight
|| document.documentElement.clientHeight
|| document.body.clientHeight;
return { w, h };
},
render: function () {
const style = {
width: '95%',
height: this.getHeightWidth().h - 200
};
我们使用内联样式将地图的高度设置为视口高度减去 200 像素:
return (<div>
<Button
onClick={this.props.goBack}
className="buttonBack">
Exit</Button>
<div style={style} ref="map" className="map"></div>
{navigator.battery ?
navigator.battery.level<0.3 ?
<div><strong>
Note: Your battery is running low
({navigator.battery.level*100}% remaining).
You may want to exit to the main menu and
use the static maps instead.</strong></div>
:<div/>
:<div/>
}
如果我们注意到设备上的电池电量低,我们将通知用户。为了持续监控电池状态,我们需要设置本章前面描述的事件监听器:
</div>);
}
});
export default DynamicMapView;
接下来,打开 app.jsx 文件,在 renderButtons() 函数的末尾添加以下代码片段,位于关闭 <div /> 标签之上,如下所示:
<h1>Interactive maps</h1>
<ButtonGroup block vertical>
<Button
className="map-button"
bsStyle="primary"
onClick={this.setProvider.bind(null,
'openstreetmap','interactive')}>
Open interactive Open Street Map for
{this.state.address.state? this.state.address.state+", ":""}
{this.state.address.country}
</Button>
</ButtonGroup>
接下来,将 renderMapView() 中的代码替换为以下代码:
renderMapView(){
return this.state.mapType === 'static' ?
(<StaticMapView {...this.state} goBack={this.goBack}/>) :
<DynamicMapView {...this.state} goBack={this.goBack}/>;
},
最后,将交互式地图视图添加到 import 部分:
import DynamicMapView from './views/interactive-map.jsx';
让我们看一下以下截图:

现在,您应该能够加载应用程序并点击 交互式地图 按钮,然后显示您位置的交互式地图。您可以缩放、移动地图,它同样适用于智能手机、平板电脑以及桌面浏览器。
您可以通过添加新的标记甚至不同的瓦片来扩展此地图。我们在此应用程序中使用了 OpenStreetMap,但切换起来非常容易。查看 leaflet-extras.github.io/leaflet-providers/preview/ 了解您可以使用哪些类型的瓦片。
您可以选择众多插件,这些插件可以在 leafletjs.com/plugins.html 找到。
摘要
在本章中,我们检查了几个有用的 HTML5 API 的状态。然后,我们在创建一个同时提供静态和交互式地图的应用程序时,充分利用了它们。
静态地图配置为使用各种不同的专有服务,而交互式地图配置为使用免费和开源的地图服务 OpenStreetMap,使用名为 Leaflet 的流行库。
您可以通过添加一组查询的标记来扩展交互式地图。例如,您可以使用 Google Maps 这样的服务获取餐厅列表(例如寿司餐厅),并使用 Leaflet 库在每个位置添加鱼标记。可能性是无限的。
注意
完成的项目可以在 reactjsblueprints-chapter5.herokuapp.com 上在线查看。
在下一章中,我们将创建一个需要用户创建账户并登录才能充分利用应用程序所有功能的应用程序。
第六章. 高级 React
在本章的第一部分,我们将探讨Webpack、Redux以及如何使用 JavaScript 2015 中引入的新类语法编写组件。使用类语法编写 ReactJS 组件与使用React.createClass略有不同,所以我们将探讨这些差异以及它们的优缺点。
在本章的第二部分,我们将编写一个使用 Redux 处理认证的应用程序。
这就是我们将在本章中要讨论的内容:
-
新的打包策略:
-
Browserify 是如何工作的
-
Webpack 是如何工作的
-
一个艰难的选择
-
-
使用 Webpack 创建一个新的脚手架
-
Babel 配置
-
Webpack 配置
-
添加资源
-
创建一个 Express 服务器
-
将 ReactJS 加入其中
-
启动服务器
-
-
介绍 Redux
-
单一存储
-
Redux 中的 actions
-
理解 reducers
-
添加 Devtools
-
-
创建一个登录 API
一种新的打包策略
到目前为止,我们一直在使用 Browserify,但从现在开始,我们将切换到 Webpack。你可能想知道为什么我们要进行这种切换,以及这些技术之间的区别是什么。
让我们更仔细地看看它们两个。
Browserify 是如何工作的
Browserify通过检查你指定的入口点,根据你代码中需要的所有文件和模块构建一个依赖树。每个依赖都被封装在一个closure代码中,其中包含模块的源代码、模块依赖的映射和一个键。它注入了原生于node但不在 JavaScript 中存在的特性,例如模块处理。
简而言之,它能够分析你的源代码,找到并封装所有你的依赖项,并将它们编译成一个单一的包。它性能非常好,是新建项目的优秀启动工具。
在实践中使用它就像编写一组代码并将其发送到 Browserify 一样简单。让我们编写两个相互需要的文件。
让我们将第一个文件命名为helloworld.js,并将以下代码放入其中:
module.exports = function () {
return 'Hello world!';
}
让我们将第二个文件命名为entry.js,并将以下代码放入其中:
var Hello = require("./helloworld");
console.log(Hello());
然后,从命令行将这两个文件传递给 Browserify,如下所示:
browserify entry.js
结果将是一个立即调用的函数表达式(简称 IIFE),其中包含你的"hello world"代码。IIFE 也被称为匿名自执行函数或简单地指一个在加载时立即执行的代码块。
生成的代码看起来相当难以理解,但让我们试着理解它:
(function e(t, n, r) {
function s(o, u) {
if (!n[o]) {
if (!t[o]) {
var a = typeof require == "function" && require;
if (!u && a) return a(o, !0);
if (i) return i(o, !0);
var f = new Error("Cannot find module '" + o + "'");
throw f.code = "MODULE_NOT_FOUND", f
}
var l = n[o] = {
exports: {}
};
t[o][0].call(l.exports, function(e) {
var n = t[o][1][e];
return s(n ? n : e)
}, l, l.exports, e, t, n, r)
}
return n[o].exports
}
var i = typeof require == "function" && require;
for (var o = 0; o < r.length; o++) s(r[o]);
return s
})
整个第一个块传递模块源代码并执行它。第一个参数接受我们的源代码,第二个是一个缓存(通常是空的),第三个是一个键,将其映射到所需的模块。
内部函数是一个内部cache函数。它在函数的末尾使用,用于从缓存中检索函数,或者存储它以便下次请求时可用。在这里,列出了一个所需的模块,以及整个源代码:
({
1: [function(require, module, exports) {
var Hello = require("./helloworld");
console.log(Hello());
}, {
"./helloworld": 2
}],
2: [function(require, module, exports) {
module.exports = (function() {
return 'Hello world!';
})
}, {}]
}, {}, [1]);
注意
注意,这是以三个参数的形式传递到括号中,与 IIFE 函数匹配。
并非你必须完全理解它是如何工作的。重要的是要记住,Browserify 将生成一个包含所有代码的完整静态包,并且还会处理它们之间的关系。
到目前为止,Browserify 看起来非常出色。然而,美中不足的是,如果你想要对你的代码做更多的事情——例如,压缩它或者将JavaScript 2015转换为ECMAScript 5或者将ReactJS JSX代码转换为纯 JavaScript——你需要向它传递额外的转换。
Browserify 有一个庞大的转换生态系统,你可以使用它来转换你的代码。知道如何连接起来是难点,而 Browserify 本身在这个问题上并不完全有意见,这意味着你将不得不自己处理。
让我们添加一个 JavaScript 2015 转换来展示如何使用转换运行 Browserify。将helloworld.js更改为以下代码:
import Hello from "./helloworld";
console.log(Hello());
现在运行标准的browserify命令将导致解析错误。让我们尝试使用我们在我们的脚手架中使用的 Babel 转换器:
browserify entry.js --transform [babelify --presets [es2015]]
代码现在将被解析。
注意
如果你比较生成的代码,你会注意到 Babel 生成的 JavaScript 2015 代码与 Browserify 使用纯 ECMAScript 5 生成的代码相当不同。它稍微大一点(在这个例子中,它大约大 25%,但这是一个非常小的代码集样本,所以与更现实的代码集相比,差异不会那么显著)。
你可以通过几种方式运行代码。你可以创建一个 HTML 文件并在脚本标签中引用它,或者你只需简单地打开浏览器,将其粘贴到 Chrome 的控制台窗口或 Firefox 的 Scratchpad 中。在任何情况下,结果都是相同的;文本Hello world!将出现在你的控制台日志中。
Webpack 的工作原理
与 Browserify 类似,Webpack 是一个模块打包器。它在操作上与 Browserify 相似,但在底层却大不相同。有很多差异,但关键的区别在于 Webpack 可以动态使用,而 Browserify 则是严格静态的。我们将探讨 Webpack 的工作原理,并展示如何在使用 Webpack 编写代码时从中获得巨大益处。
与 Browserify 一样,使用 Webpack 生成代码是从一个entry文件开始的。让我们使用上一个例子中的"Hello World"代码(ECMAScript 5 版本)。Webpack 要求你指定一个output文件,所以让我们将其写入bundle.js,如下所示:
webpack helloworld.js --output-filename bundle.js
默认情况下,生成的代码比 Browserify 更冗长,实际上相当易于阅读(添加-p参数将生成一个压缩版本)。
运行前面的代码将生成以下代码:
(function(modules) { // webpackBootstrap
var installedModules = {};
function __webpack_require__(moduleId) {
if(installedModules[moduleId])
return installedModules[moduleId].exports;
var module = installedModules[moduleId] = {
exports: {},
id: moduleId,
loaded: false
};
与 Browserify 一样,Webpack 生成一个 IIFE。它首先做的事情是设置一个模块缓存,然后检查模块是否已缓存。如果没有,模块将被放入缓存,让我们看看以下代码片段:
modules[moduleId].call(module.exports, module, module.exports,
__webpack_require__);
module.loaded = true;
return module.exports;
}
接下来,它执行 module 函数,将其标记为已加载,并返回模块的导出,让我们看一下下面的代码片段:
__webpack_require__.m = modules;
__webpack_require__.c = installedModules;
__webpack_require__.p = "";
return __webpack_require__(0);
})
然后,它暴露了模块的对象、缓存以及公共路径,然后返回入口模块,让我们看一下下面的代码片段:
([
/* 0 */
/***/ function(module, exports, __webpack_require__) {
var Hello = __webpack_require__(1);
console.log(Hello());
Hello 现在赋值给 __webpack_require__(1)。数字指的是下一个模块(因为它从 0 开始计数)。现在请参考以下内容:
/***/ },
/* 1 */
/***/ function(module, exports) {
module.exports = (function () {
return 'Hello world!';
})
}
]);
两个模块源本身都作为 IIFE 的参数执行。
到目前为止,Webpack 和 Browserify 看起来非常相似。它们都分析你的入口文件,并将源代码包裹在一个自执行的闭包中。它们还包括缓存策略,并维护一个关系树,以便它可以告诉模块是如何相互依赖的。
实际上,仅通过查看生成的代码,很难看出它们之间有什么不同,除了代码风格不同之外。
然而,有一个很大的不同,那就是 Webpack 如何组织其生态系统和配置策略。虽然配置确实很复杂,稍微难以理解,但很难否认你可以实现的结果。
你可以配置 Webpack 来做(几乎)你想要做的任何事情,包括在保留应用状态的同时,用更新的代码替换浏览器中当前加载的代码。这被称为 热模块替换 或简称为 hmr。
Webpack 通过编写一个特殊的配置文件来配置,通常称为 webpack.config.js。在这个文件中,你指定入口和输出参数、插件、模块加载器和各种其他配置参数。
一个非常基本的 config 文件看起来像这样:
var webpack = require('webpack');
module.exports = {
entry: [
'./entry'
],
output: {
path: './',
filename: 'bundle.js'
}
};
它通过从命令行执行以下命令来执行:
webpack --config webpack.config.js
或者,如果没有 config 参数,Webpack 将自动查找 webpack.config.js 的存在。
为了在打包之前转换 source 文件,你使用模块加载器。将此部分添加到 Webpack 配置文件中,将确保 Babel 将 JavaScript 2015 代码转换为 ECMAScript 5:
module: {
loaders: [{
test: /.js?$/',
loader: 'babel',
exclude: /node_modules/,
query: {
presets: ['es2015','react']
}
}]
}
让我们详细回顾一下选项:
-
第一个选项(必需),
test,是一个正则表达式匹配,告诉 Webpack 这个加载器操作哪些文件。正则表达式告诉 Webpack 查找以 点 开头,后跟字母 js,然后是任意可选字母(?),直到末尾($)的文件。这确保加载器可以读取普通的 JavaScript 文件和 JSX 文件。 -
第二个选项(必需),
loader,是我们将用于转换代码的包的名称。 -
第三个选项(可选),
exclude,是另一个正则表达式,用于显式忽略一组文件夹或文件。 -
最后一个选项(可选),
query,包含为你的加载器设置的特殊配置选项。在我们的例子中,它包含 Babel 加载器的选项。对于 Babel,实际上推荐的方式是将它们设置在一个特殊的文件中,称为.babelrc。我们将在稍后开发的脚手架中这样做。
一个艰难的选择——Browserify 或 Webpack
Browserify 因其易于上手而得分,但由于在需要添加转换时复杂性增加,并且总体上比 Webpack 更有限,因此它失去了分数。
Webpack 一开始比较难以掌握,但随着你解开复杂性,它将变得越来越有用。使用 Webpack 的一个重大优势是其能够使用其热重载工具生态系统在运行时替换代码,以及它以强大的、有见地的扩展方式来满足每一个需求。值得注意的是,目前正在努力为 Browserify 开发一个hmr模块。您可以在github.com/AgentME/browserify-hmr上预览该项目。
它们都是出色的工具,值得学习使用两者。对于某些类型的项目,使用 Browserify 最有意义,而对于其他项目,Webpack 显然是最佳选择。
接下来,我们将创建一个新的基本设置,一个脚手架,我们将在本章后面开发带有 Redux 的登录应用时使用它。
这将会非常有趣!
使用 Webpack 创建一个新的脚手架
创建一个新的文件夹,并使用npm init初始化它,然后添加以下依赖项:
npm i --save-dev babel-core@6.8.0 babel-loader@6.2.4 babel-plugin-react-transform@2.0.2 babel-preset-es2015@6.6.0 babel-preset-react@6.5.0 react@15.0.2 react-dom@15.0.2 react-transform-catch-errors@1.0.2 react-transform-hmr@1.0.4 redbox-react@1.2.4 webpack@1.13.0 webpack-dev-middleware@1.6.1 webpack-hot-middleware@2.10.0 && npm i --save express@4.13.4
除了一个依赖项之外,所有依赖项都将保存为devDependencies。当您稍后执行npm install命令时,dependencies部分和devDependencies部分中的所有模块都将被安装。
您可以通过向npm提供dev或production标志来指定要安装的哪个部分。例如,这将仅安装依赖项部分中的包:
npm install --production
您的package.json文件现在应该看起来像这样:
{
"name": "chapter6",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"babel-core": "⁶.8.0",
"babel-loader": "⁶.2.4",
"babel-plugin-react-transform": "².0.2",
"babel-preset-es2015": "⁶.6.0",
"babel-preset-react": "⁶.5.0",
"react": "¹⁵.0.2",
"react-dom": "¹⁵.0.2",
"react-transform-catch-errors": "¹.0.2",
"react-transform-hmr": "¹.0.4",
"redbox-react": "¹.2.4",
"webpack": "¹.13.0",
"webpack-dev-middleware": "¹.6.1",
"webpack-hot-middleware": "².10.0"
},
"dependencies": {
"express": "⁴.13.4"
}
}
Babel 配置
接下来,创建一个新文件,命名为.babelrc(点号前没有前缀),并将以下代码添加到其中:
{
"presets": ["react", "es2015"],
"env": {
"development": {
"plugins": [
["react-transform", {
"transforms": [{
"transform": "react-transform-hmr",
"imports": ["react"],
"locals": ["module"]
}, {
"transform": "react-transform-catch-errors",
"imports": ["react", "redbox-react"]
}]
}]
]
}
}
}
这个配置文件将由 Babel 使用,以便使用我们刚刚安装的预设(React 和 ES2015)。它还将指示 Babel 我们希望使用哪些转换。将转换放在env:development文件中可以确保它不会在生产环境中意外启用。
Webpack 配置
接下来,让我们添加 Webpack 配置模块。创建一个名为webpack.config.js的新文件,并将此代码添加到其中:
var path = require('path');
var webpack = require('webpack');
module.exports = {
devtool: 'cheap-module-eval-source-map',
entry: [
'webpack-hot-middleware/client',
'./source/index'
],
这将指示 Webpack 首先使用热模块替换作为初始入口点,然后是我们的源根。现在参考以下内容:
output: {
path: path.join(__dirname, 'public'),
filename: 'bundle.js',
publicPath: '/assets/'
},
我们将输出路径设置为public文件夹,这意味着任何被访问的内容都应该位于这个文件夹中。我们还将指示 Webpack 使用bundle.js文件名,并指定它应该从assets文件夹中访问。
在我们的index.html文件中,我们将通过一个指向assets/bundle.js的 script 标签来访问该文件,但我们实际上不会在assets文件夹中放置一个真实的bundle.js文件。
热中间件客户端将确保当我们尝试访问包时,将提供生成的包。
当我们准备好创建用于生产的真实包时,我们将使用生产 flag 参数生成一个 bundle.js 文件,并将其存储在 public/assets/bundle.js:
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.NoErrorsPlugin(),
new webpack.HotModuleReplacementPlugin()
],
我们将使用三个插件。第一个确保模块按顺序加载,第二个是为了防止在我们的控制台日志中报告不必要的错误,第三个是为了启用热模块加载器,如下所示:
module: {
loaders: [{
tests: /\.js?$/,
loaders: ['babel'],
include: path.join(__dirname, 'source')
}]
},
我们将添加 Babel 加载器,以便在打包之前将任何 JavaScript 或 JSX 文件进行转换:
resolve: {
extensions: ['', '.js', '.jsx']
}
};
最后,我们将告诉 Webpack 解析我们导入的文件,无论它们是否有 .js 或 .jsx 扩展名。这意味着我们不需要写 import foo from 'foo.jsx',而是可以写 import foo from 'foo'。
添加资产
接下来,让我们添加 assets 文件夹以及我们将引用的文件。我们将在 root 文件夹中创建它,而不是创建一个 public 文件夹。(实际上我们根本不需要这样做。在开发模式下,这个文件夹不是必须创建的)。
创建文件夹并添加两个文件:app.css 和 favicon.ico。
favicon.ico 并非绝对必要,因此你可以选择去掉它。你可能在电脑周围找到它,或者通过访问如 www.favicon.cc 这样的图标生成网站来创建一个。
它被包含在这里的原因是:如果它不存在,每次你重新加载你的网站时,你会在日志中看到对图标失败的请求,所以它代表了值得去除的日志噪音。
打开 assets/app.css 并添加以下代码:
body {
font-family: serif;
padding: 50px;
}
这只是简单地给主体周围添加了 50 像素的通用填充。
接下来,我们需要添加一个 index.html 文件。在应用程序的根目录中创建它,并添加以下内容:
<!DOCTYPE html>
<html>
<head>
<title>ReactJS + Webpack Scaffold</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,
initial-scale=1">
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="app"></div>
<script src="img/bundle.js"></script>
</body>
</html>
创建 Express 服务器
我们还需要创建一个 Express 应用程序来为我们的开发服务器提供动力。将 server.js 添加到您的根文件夹中,然后添加以下代码:
var path = require('path');
此模块让我们以更舒适和安全的方式连接路径字符串,而不是连接字符串。首先,它消除了我们是否知道目录路径是否有尾随斜杠的担忧。
小贴士
当你手动连接字符串时,你几乎总是会在第一次尝试时出错。
我们将使用 Express 网络服务器、Webpack 以及我们刚刚创建的 Webpack 配置:
var express = require('express');
var webpack = require('webpack');
var config = require('./webpack.config');
var port = process.env.PORT || 8080;
我们将预设我们将要使用的端口号为 8080,除非它被指定为节点的参数。要指定参数,例如端口号,以这种方式启动服务器:PORT=8081 node server.js:
var app = express();
var compiler = webpack(config);
我们将创建一个名为 app 的局部变量,并将其指向一个新的 Express 网络服务器实例。我们还将创建另一个名为 compiler 的变量,该变量将配置 Webpack 使用我们的 config 文件。这相当于在命令行上使用 webpack –config webpack.config.js 启动 Webpack:
app.use('/', express.static(path.join(__dirname, 'assets')));
我们将在 Express 中将 assets 文件夹定义为 static 文件夹。这是一个内置的中间件,用于配置 Express 在提供的文件夹中查找文件。中间件是一种软件,用于将应用程序粘合在一起或提供额外的功能。静态中间件允许我们在 index.html 文件中的链接标签中直接引用 app.css,而不是引用 assets 文件夹:
app.use(require('webpack-dev-middleware')(compiler, {
quiet: true,
noInfo: true,
publicPath: config.output.publicPath
}));
我们将告诉 Express 使用 webpack-dev-middleware 和 compiler 变量,以及一些额外的指令(noInfo 将防止控制台日志在每次重新编译时显示 Webpack 编译信息;publicPath 指示中间件使用我们在 config 文件中定义的路径,而 quiet 则抑制 noInfo 覆盖的任何其他调试信息):
app.use(require("webpack-hot-middleware")(compiler, {
log: console.log,
path: '/__webpack_hmr',
heartbeat: 10 * 1000
}));
这指示 Express 使用 hot 中间件包(而前面的一个指示它使用 dev 中间件)。dev 中间件是 Webpack 的包装器,它将 Webpack 发射到内存中的文件提供服务,而不是将它们捆绑为文件。当我们与 hot 中间件包结合使用时,我们获得了在浏览器中重新加载和执行任何代码更改的能力。heartbeat 参数告诉中间件应该多久更新一次。
你可以调整心跳频率以更频繁地更新,但所选的数字效果相当不错:
app.get('*', function(req, res) {
res.sendFile(path.join(__dirname, 'index.html'));
});
此部分将每个请求路由到 Express 应用程序的根文件夹:
app.listen(port, 'localhost', function(err) {
if (err) {
console.log(err);
return;
}
最后,我们在选择的端口上启动应用程序:
console.log('Listening at http://localhost:'+port);
});
服务器现在已准备就绪。你现在需要完成设置的所有步骤就是添加一个 ReactJS 组件。我们将使用新的 ES6 基于类的语法,而不是我们至今使用的 createClass 语法。
将 ReactJS 添加到混合中
添加一个名为 source 的新文件夹,并添加一个名为 index.jsx 的文件。然后,添加以下代码:
'use strict';
import React, { Component, PropTypes } from 'react';
import { render } from 'react-dom';
class App extends Component {
render() {
return <div>
<h1>ReactJS Blueprints Chapter 6 Webpack scaffold</h1>
<div>
To use:
<p>
1\. Run <strong>npm i</strong> to install
</p>
<p>
2\. Run <strong>npm start</strong> to run dev server
</p>
<p>
3\. View results in <strong>http://localhost:8080/
</strong>
</p>
<p>
4\. Success
</p>
</div>
</div>
}
render 函数看起来与之前相同。
注意
注意,我们也不再使用逗号来分隔我们的函数。在类内部它们不是必需的。
让我们看一下以下代码片段:
}
render(
<App />,
document.getElementById('app')
);
最后一个函数调用是调用 react-dom 的 render 方法,该方法负责用 app ID 和源文件的內容填充文档容器。
启动服务器
我们现在可以运行服务器并首次看到结果。在终端中执行 node server.js 启动应用程序,并在浏览器中打开 http://localhost:8080:

现在,你应该会看到你添加到 source/index.jsx 中的简介文本。
恭喜!你已经完成了所有必要的步骤,可以开始使用 Webpack 和热重载。
承认,与 Browserify 设置相比,这个设置稍微复杂一些,但随着你对源文件进行修改,你将明显感受到增加的复杂性带来的好处;你会在点击 保存 按钮后立即看到浏览器中更新的更改。
这比我们之前的方法更优越,因为应用能够保持其状态完整,即使在重新加载代码更改时也是如此。这意味着当你开发一个复杂的应用时,你不需要重复很多状态更改来达到你更改的代码。这将在长期内为你节省大量时间和挫败感。
介绍 Redux
到目前为止,我们使用 Reflux 来处理存储和状态交互,但向前看,我们将使用 Flux 架构的不同实现。它被称为 Redux,并且作为一个更优越的 Flux 实现正在迅速获得认可。
它也因其难以理解而臭名昭著,其简单与复杂性的双重性让新手和经验丰富的开发者都感到困惑。这部分的理由是因为它纯粹是 Flux 的函数式方法。
当 ReactJS 在 2013 年晚些时候/2014 年初首次向公众介绍时,你经常会听到它与函数编程一起被提及。
然而,在编写 React 时,并没有内在的要求必须编写函数式代码,并且 JavaScript 本身作为一个多范式语言,既不是严格函数式,也不是严格过程式、命令式,甚至不是面向对象的。
选择函数式方法有许多好处:
-
不允许有副作用,也就是说,操作是无状态的
-
对于给定的输入始终返回相同的输出
-
适合创建递归操作
-
适合并行执行
-
容易建立单一事实来源
-
容易调试
-
容易持久化存储状态以加快开发周期
-
容易创建诸如撤销和重做等功能
-
容易注入用于服务器渲染的存储状态
无状态操作的概念可能是最大的好处,因为它使得推理你应用的状态变得非常容易。我们已经在 第二章 的 Reflux 示例中使用了这种方法,在第一个应用中,存储状态只在主应用中更改,然后向下传播到所有应用的子组件。然而,这并不是典型的 Reflux 方法,因为它实际上是为了创建许多存储,让子组件分别监听更改而设计的。
应用状态是任何应用中最困难的部分,每个 Flux 的实现都试图解决这个问题。Redux 通过实际上并不做 Flux 来解决这个问题;它实际上使用了 Flux 和函数式编程语言 Elm 的想法的结合。
Redux 有三个部分:actions、reducers 和 全局存储。
全局存储
在 Redux 中,只有一个全局存储。它是一个对象,持有你整个应用的状态。你通过将你的 root-reducing 函数(或简称为 reducer)传递给名为 createStore 的方法来创建存储。
而不是创建更多的存储,你使用一个称为reducer 组合的概念来分割数据处理逻辑。然后你需要使用一个名为combineReducers的函数来创建一个单一的根 reducer。
createStore函数是从 Redux 派生出来的,通常在应用的根目录(或你的store文件)中调用一次。然后它被传递到你的应用中,并传播到应用的孩子。
改变存储状态的唯一方式是向其发送一个动作。这不同于 Flux dispatcher,因为 Redux 没有。你也可以订阅存储的变化,以便在存储状态改变时更新你的组件。
理解 actions
一个动作是一个表示意图改变状态的对象。它必须有一个类型字段,指示正在执行的动作类型。它们可以定义为常量并从其他模块导入。
除了这个要求之外,对象的结构设计完全取决于你。
一个基本的动作对象可以看起来像这样:
{
type: 'UPDATE',
payload: {
value: "some value"
}
}
payload属性是可选的,它可以像我们之前讨论过的对象一样工作,或者任何其他有效的 JavaScript 类型,例如函数或原始类型。
理解 reducers
reducer是一个接受累积值和一个值作为参数的函数,并返回一个新的累积值。换句话说,它根据前一个状态和动作返回下一个状态。
它必须是一个纯函数,没有副作用,并且不会修改现有的状态。
对于较小的应用,从一个单一的 reducer 开始是可以的,但随着你的应用增长,你需要将其拆分成管理状态树特定部分的较小的 reducers。
这就是所谓的reducer 组合,它是使用 Redux 构建应用的基石模式。
你从一个单一的 reducer 开始,但随着你的应用增长,你需要将其拆分成管理状态树特定部分的较小的 reducers。因为 reducers 只是函数,你可以控制它们被调用的顺序,传递额外的数据,甚至为常见的任务(如分页)创建可重用的 reducers。
有很多 reducers 是可以的。实际上,这是被鼓励的。
安装 Redux
让我们添加Redux到我们的脚手架中,看看它是如何工作的。当你开始使用 redux 时,你只需要两个包:redux和react-redux。我们将在我们的应用中添加一些其他包,以帮助我们在开发应用时进行调试。首先,安装以下依赖项:
npm install --save-dev redux@3.5.2 redux-devtools@3.3.1 react-redux@4.4.5 redux-thunk@2.1.0 isomorphic-fetch@2.2.0 react-bootstrap@0.29.4 redux-devtools-dock-monitor@1.1.1 redux-devtools-log-monitor@1.0.11
当完成这项操作后,你的package.json文件的devDepencies部分应该包含以下包:
"devDependencies": {
"react-redux": "⁴.4.5",
"redux": "³.5.2",
"redux-thunk": "².1.0",
"redux-devtools": "³.3.1",
"isomorphic-fetch": "².2.0",
"react-bootstrap": "⁰.29.4",
"redux-devtools-dock-monitor": "¹.1.1",
"redux-devtools-log-monitor": "¹.0.11"
}
注意
值得注意的是,新版本一直在发布,所以确保你安装的版本号与这些示例编写时的版本号相同是很好的。你可以在安装包时通过将版本号添加到install命令中来安装确切的版本号,就像我们在前面的代码片段中所做的那样。
创建登录应用
现在我们已经基于 Webpack 创建了一个新的脚手架,并添加了 Redux,让我们继续创建一个使用新库处理身份验证的 app。
创建一个动作
我们将从添加一个动作开始。我们将制作的 app 是一个登录 app,在进入时将提示输入用户名和密码。
让我们先创建一个文件夹结构来分离功能。在source文件夹内创建一个名为actions的文件夹,并添加一个名为login.js的文件;然后,添加以下代码:
'use strict';
import fetch from 'isomorphic-fetch';
Fetch是一个用于获取资源的全新接口。如果你以前使用过XMLHttpRequest或者像我们在前面的章节中使用的那样,与Promises一起使用Superagent,你会认识它。新的 API 支持开箱即用的 Promises,支持通用的请求和响应对象定义。它还提供了诸如跨源资源共享(CORS)和 HTTP 源头语义等概念的定义。
我们本可以使用 Babel 直接使用Fetch,但这个包更可取,因为它将Fetch作为一个全局函数添加,它具有一致的 API,可以在服务器和客户端代码中使用。这将在后面的章节中介绍,我们将创建一个同构应用。考虑以下代码:
export const LOGIN_USER = 'LOGIN_USER';
这定义了一个单个动作常量,我们可以在需要分发动作时使用。现在看看这个例子:
export function login(userData) {
通过这种方式,我们创建并导出一个名为login的单个函数,该函数接受一个userData对象。现在我们将创建一个名为body的变量,它包含用户名和密码:
const body = { username: userData.username,
password: userData.password };
这并不是严格必要的,因为我们可以轻松地传递userData对象,但我们的想法是通过明确地这样做,我们只发送用户名和密码,没有其他内容。当你查看下一部分代码时,这将很容易理解:
const options = {headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer 1234567890'
},
method: 'post',
body: JSON.stringify(body)
}
我们将使用带有Accept头和Content-Type的POST请求发送,两者都指定我们正在处理 JSON 数据。我们还将发送一个带有 bearer token 的授权头。
你之前在第四章中见过这个 bearer token,构建实时搜索应用。我们将引用的 API 与我们当时构建的非常相似。我们将在完成前端代码后查看 API。
主体通过JSON.stringify()方法传递,因为我们不能通过 HTTP 发送原始 JavaScript 对象。该方法将对象转换为适当的 JSON 表示形式,如果指定了替换函数,则可选地替换值。看看这个例子:
return dispatch => {
return fetch(`http://reactjsblueprints-useradmin.herokuapp.com/v1/login`, options)
.then(response => response.json())
.then(json => dispatch(setLoginDetails(json)))
}
}
这是我们的login函数的return部分。它首先通过fetch函数连接到我们的登录 API,该函数返回一个Promise。
注意
注意到我们正在使用 JavaScript 2015 通过新背引号提供的功能。
当 Promise 解决时,我们通过 fetch API 可用的 native json() 方法从对象中获取 JSON 响应。最后,我们通过向一个名为 setLoginDetails 的内部函数派发来返回 JSON 数据:
function setLoginDetails(json) {
if(json.length === 0 ) {
return {
type: LOGIN_FAIL,
timestamp: Date.now()
}
}
return {
type: LOGIN_USER,
loginResponse: json,
timestamp: Date.now()
}
}
如果 json 包含有效的响应,setLoginDetails 返回一个包含映射到 LOGIN_USER 字符串值的 action 对象和两个自定义值。记住,一个动作必须始终返回一个 type,而它返回的其他任何内容都是可选的,取决于你。如果 json 参数为空,函数返回 LOGIN_FAIL。
创建 reducer
我们接下来要添加的下一个文件是一个 reducer。我们将将其放在一个单独的文件夹中。所以,在 source 文件夹内创建一个名为 reducers 的文件夹,然后添加一个名为 login.js 的文件(与 action 相同),然后添加以下代码:
'use strict';
import {
LOGIN_USER,
LOGIN_FAIL
} from '../actions/login';
import { combineReducers } from 'redux'
我们将导入我们刚刚创建的文件以及来自 Redux 的 combineReducer() 方法。目前我们只创建一个 reducer,但我喜欢从一开始就添加它,因为随着应用的扩展,通常会增加更多的 reducer。当你的 reducer 数量增加时,通常有一个 root 文件来组合 reducer 是有意义的。接下来,我们将声明一个函数,该函数期望一个 state 对象和 action 作为其参数:
function user(state = {
message: "",
userData: {}
}, action){
当 action.type 返回一个成功状态时,我们返回状态并添加或更新 userData 和 timestamp 参数:
switch(action.type) {
case LOGIN_USER:
return {
...state,
userData: action.loginResponse[0],
timestamp: action.timestamp
};
注意,为了在我们的 reducer 中使用扩展运算符,我们需要在我们的 .babelrc 配置中添加一个新的 preset。这不是 EcmaScript 6 的一部分,而是作为语言扩展被提出的。打开你的终端并运行以下命令:
npm install –save-dev babel-preset-stage-2
接下来,修改 .babelrc 中的 presets 部分,使其看起来像这样:
"presets": ["react", "es2015", "stage-2"]
我们还会添加一个 case 以便在登录失败时记录用户:
case LOGIN_FAIL:
return {
...state,
userData: [],
error: "Invalid login",
timestamp: action.timestamp
};
最后,我们将添加一个 default case。这并不是严格必要的,但通常处理任何未预见的案例,如这种情况,是谨慎的:
default:
return state
}
}
const rootReducer = combineReducers({user});
export default rootReducer
创建 store
我们接下来要添加的下一个文件是一个 store。在你的 source 文件夹内创建一个名为 stores 的文件夹,添加 store.js 文件,然后添加以下代码:
'use strict';
import rootReducer from '../reducers/login';
我们将导入我们刚刚创建的 reducer:
import { persistState } from 'redux-devtools';
import { compose, createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import DevTools from '../devtools';
我们需要从 Redux 中获取一些方法。devtools 包仅用于开发,并且在进入生产时必须删除。
在计算机科学中,thunk 是一个没有自己的参数的匿名表达式,它被一个参数表达式包裹。redux-thunk 包允许你编写返回函数的动作创建器,而不是动作。thunk 包可以用来延迟动作的派发,或者只在满足某些条件时派发。内部函数接收 store 方法 dispatch 和 getState() 作为参数。
我们将使用它来向我们的登录 API 发送异步派发:
const configureStore = compose(
applyMiddleware(thunk),
DevTools.instrument()
)(createStore);
const store = configureStore(rootReducer);
export default store;
添加 devtools
Devtools 是你在应用中处理状态的主要方式。我们将安装默认的日志和坞栏监视器,但如果你觉得它们不适合你,你可以开发自己的。
在你的 source 文件夹中添加一个名为 devtools.js 的文件,并添加以下代码:
'use strict';
import React from 'react';
import { createDevTools } from 'redux-devtools';
import LogMonitor from 'redux-devtools-log-monitor';
import DockMonitor from 'redux-devtools-dock-monitor';
Monitors 是独立的包,你可以创建自定义的,让我们看看以下代码:
const DevTools = createDevTools(
Monitors 可以通过属性单独调整。查看 devtools 的源代码以了解更多关于它们如何构建的信息。在这里,我们将 LogMonitor 放在 DockMonitor 类内部:
<DockMonitor toggleVisibilityKey='ctrl-h'
changePositionKey='ctrl-q'>
<LogMonitor theme='tomorrow' />
</DockMonitor>
);
export default DevTools;
整合文件
是时候将应用整合在一起了。打开 index.jsx 并将现有内容替换为以下代码:
import React, { Component, PropTypes } from 'react'
import { Grid, Row, Col, Button, Input } from 'react-bootstrap';
import { render, findDOMNode } from 'react-dom';
import store from './stores/store';
import { login } from './actions/login'
import { Provider } from 'react-redux'
import { connect } from 'react-redux'
import DevTools from './devtools';
这添加了我们创建的所有文件以及从 ReactJS 中需要的所有方法。现在参考以下代码:
class App extends Component {
handleSelect() {
const { dispatch } = this.props;
dispatch(
login(
{
username: findDOMNode(this.refs.username).value,
password: findDOMNode(this.refs.password).value
}))
}
这个函数使用在 render() 方法中定义的 username 和 password 输入字段的内容,派发我们在 actions/login.js 中定义的 login 动作,如下所示:
renderWelcomeMessage() {
const { user } = this.props;
let response;
if(user.userData.name) {
response = "Welcome "+user.userData.name;
}
else {
response = user.error;
}
return (<div>
{ response }
</div>);
}
这是一小段 JSX 代码,我们用它来在登录尝试后显示欢迎信息或错误信息。现在查看以下代码:
renderInput() {
return <form>
<div>
<FormGroup>
<ControlLabel>Username</ControlLabel>
<FormControl type= "text"
ref = "username"
placeholder= "username"
/>
<FormControl.Feedback />
</FormGroup>
</div>
<div>
<FormGroup>
<ControlLabel>Password</ControlLabel>
<FormControl type= "password"
ref = "password"
placeholder= "password"
/>
<FormControl.Feedback />
</FormGroup>
</div>
<Button onClick={this.handleSelect.bind(this)}>Log in</Button>
</form>)
}
这些是用于登录用户的输入字段。
注意
注意,我们必须自己使用 .bind(this) 绑定上下文。
使用 createClass 时,绑定是自动创建的,但当你使用 JavaScript 2015 类时,没有这样的魔法存在。JavaScript 的下一个版本可能会带来一个建议的新语法糖(::),这意味着我们可以使用 this.handleSelect 而不必显式绑定它,但这还远未实现:
render () {
const { user } = this.props;
return (
<Grid>
<DevTools store={store} />
<Row>
<Col xs={ 12 }>
<h3> Please log in </h3>
</Col>
<Col xs={ 12 }>
{ this.renderInput() }
</Col>
<Col xs={ 12 }>
{ this.renderWelcomeMessage() }
</Col>
</Row>
</Grid>
);
}
};
这个 render 块只是向访客提供了一个登录选项。当用户点击 Enter 时,应用将尝试登录,并显示欢迎信息或 无效登录 信息。
这个函数将应用状态转换为我们可以传递给子组件的一系列属性:
function mapStateToProps(state) {
const { user } = state;
const {
message
} = user || {
message: ""
}
return {
user
}
}
这是我们使用 Redux 的 connect() 方法定义应用的地方,它将 React 组件连接到 Redux 存储。而不是就地修改组件,它返回一个新的 component 类,我们可以渲染它:
const LoginApp = connect(mapStateToProps)(App);
我们创建一个新的组件类,它将 LoginApp 组件包裹在 Provider 组件内部:
class Root extends Component {
render() {
return (
<Provider store={store}>
<LoginApp />
</Provider>
)
}
}
Provider 组件是特殊的,因为它负责将存储作为属性传递给子组件。建议你创建一个 root 组件,将应用包裹在 Provider 中,除非你想手动将存储传递给所有子组件。最后,我们将 Root 组件传递给渲染,并要求它显示 index.html 中 ID 为 App 的 div 中的内容:
render(
<Root />,
document.getElementById('app')
);
做这件事的结果在以下屏幕截图中有说明:

应用本身看起来很不起眼,但值得看看屏幕右侧的 devtools。这是 Redux dev tools,它告诉你有一个包含用户对象的 app 状态,该用户对象有两个键。如果你点击 user,它将展开并显示它由一个包含空 message 字符串和空 userData 对象的对象组成。
这正是我们在source/index.jsx中配置的方式,所以如果你看到这个,它就是按预期工作的。
注意
尝试通过输入用户名和密码来登录。提示:组合darth/vader或john/sarah可以让你登录。
注意现在你可以通过点击开发者工具栏中的动作按钮立即导航到你的应用状态。
处理刷新
你的应用已经准备好了,你可以登录,但如果刷新,你的登录信息就会消失。
虽然如果用户登录后永远不刷新你的页面会很好,但期望用户有这样的行为并不可行,你肯定会留下一些抱怨或离开你的网站的用户,他们永远不会回来。
我们需要做的是在初始化我们的存储时找到一种方法来注入先前状态。幸运的是,这并不难;我们只需要一个安全的地方来存储我们想要在刷新后存活的数据。
为了达到这个目的,我们将使用sessionStorage。它与localStorage类似,唯一的区别是,虽然存储在localStorage中的数据没有设置过期时间,但存储在sessionStorage中的任何数据在页面会话结束时都会被清除。
会话持续的时间与浏览器窗口打开的时间一样长,并且它可以在页面重新加载和恢复时存活。
它不支持在新标签页或窗口中打开相同的页面,这是它与,例如,会话 cookie 的主要区别。
我们首先要做的是更改actions/login.js并修改setLoginDetails函数。用以下代码替换该函数(并注意现在我们将导出它):
export function setLoginDetails(json) {
const loginData = {
type: LOGIN_USER,
loginResponse: json,
timestamp: Date.now()
};
sessionStorage.setItem('login',JSON.stringify(loginData));
return loginData;
}
然后,我们将进入index.jsx并添加函数到我们的导入中。将其添加到从actions/login导入的行中,如下所示:
import { login, setLoginDetails } from './actions/login'
然后,我们在App类中添加一个新函数:
componentWillMount() {
const { dispatch, } = this.props;
let storedSessionLogin = sessionStorage.getItem('login');
if(storedSessionLogin){
dispatch(
setLoginDetails(
JSON.parse(storedSessionLogin).loginResponse)
);
}
}
在组件挂载之前,它将检查sessionStorage中是否有存储的用户信息条目。如果有,它将分派一个调用setLoginDetails的动作,这将简单地设置状态为已登录并显示熟悉的欢迎信息。
这就是你需要做的全部。
除了简单地分派动作之外,还有其他方法可以注入状态。你可以在mapStateToProps函数中这样做,并基于sessionStorage、会话 cookie 或其他数据源设置初始状态(我们将在制作同构应用时回到这一点)。
登录 API
在我们刚刚创建的应用中,我们登录到一个现有的 API。你可能想知道 API 是如何构建的,那么让我们来看看。
要创建 API,开始一个新的项目并执行npm init来创建一个空的package.json文件。然后,安装以下包:
npm install --save body-parser@1.14.1 cors@2.7.1 crypto@0.0.3 express@4.13.3 mongoose@@4.3.0 passport@0.3.2 passport-http-bearer@1.0.1
你的package.json文件现在应该看起来像这样:
{
"name": "chapter6_login_api",
"version": "1.0.0",
"description": "Login API for Chapter 6 ReactJS Blueprints",
"main": "index.js",
"dependencies": {
"body-parser": "¹.14.1",
"cors": "².7.1",
"crypto": "0.0.3",
"express": "⁴.13.3",
"mongoose": "⁴.3.0",
"passport": "⁰.3.2",
"passport-http-bearer": "¹.0.1"
},
"scripts": {
"test": "echo \"Error: no test specified\" "
},
"author": "Your name <your@email>",
"license": "ISC"
}
我们将使用3来存储我们的用户数据,就像我们在第四章中做的那样,构建实时搜索应用,并请参考这一章在你的系统上设置它。
整个 API 是一个单独的 Express 应用程序。在您的应用程序根目录中创建一个名为index.js的文件,并添加以下代码:
'use strict';
var express = require('express');
var bodyparser = require('body-parser');
var mongoose = require('mongoose');
var cors = require('cors');
var passport = require('passport');
var Strategy = require('passport-http-bearer').Strategy;
var app = express();
app.use(cors({credentials: true, origin: true}));
跨源资源共享(CORS)定义了浏览器和服务器如何交互,以安全地确定是否允许跨源请求。它因让 API 开发者生活变得艰难而闻名,因此安装cors包并在您的 Express 应用程序中使用它以减轻痛苦是值得的:
mongoose.connect(process.env.MONGOLAB_URI ||
'mongodb://localhost/loginapp/users');
如果我们的配置文件中存在 MongoLab 实例,我们将使用免费的 MongoLab 实例,如果没有,则使用本地 MongoDB 数据库。我们将使用与第四章中相同的令牌,即构建实时搜索应用,但在后面的章节中我们将探讨使其更加安全的方法:
var appToken = '1234567890';
passport.use(new Strategy(
function (token, cb) {
//console.log(token);
if (token === appToken) {
return cb(null, true);
}
return cb(null, false);
})
);
数据库模型非常简单,但可以扩展以添加用户电子邮件地址和更多信息,如果认为值得获取的话。然而,您要求的信息越多,用户注册您服务的可能性就越小:
var userSchema = new mongoose.Schema({
id: String,
username: String,
password: String
});
var userDb = mongoose.model('users', userSchema);
我们将使用 AES 256 位加密对所有存储在数据库中的密码进行加密。这是一种非常强大的安全形式(实际上与用于互联网上安全通信的 TLS/SSL 加密相同):
var crypto = require('crypto'),
algorithm = 'aes-256-ctr',
password = '2vdbhs4Gttb2';
请参考以下代码行:
function encrypt(text) {
var cipher = crypto.createCipher(algorithm,password)
var crypted = cipher.update(text,'utf8','hex')
crypted += cipher.final('hex');
return crypted;
}
function decrypt(text) {
var decipher = crypto.createDecipher(algorithm,password)
var dec = decipher.update(text,'hex','utf8')
dec += decipher.final('utf8');
return dec;
}
这些是我们将用于加密和解密用户密码的函数。我们将接受用户密码作为文本,然后加密它并检查加密版本是否存在于我们的数据库中。现在看看这个:
var routes = function (app) {
app.use(bodyparser.json());
app.get('/',
function (req, res) {
res.json(({"message":"The current version of this API is v1.
Please access by sending a POST request to /v1/login."}));
});
app.get('/login',
passport.authenticate('bearer', {session: false}),
function (req, res) {
res.json(({"message":
"GET is not allowed. Please POST request with username
and password."}));
});
此 API 需要POST数据,因此我们将向尝试通过GET方法访问此 API 的人显示有用的信息,因为使用GET方法无法获取任何数据。
我们将查找用户名和密码,并确保将它们转换为小写,因为我们不支持可变大小写字符串:
app.post('/login',
passport.authenticate('bearer', {session: false}),
function (req, res) {
var username = req.body.username.toLowerCase();
var password = req.body.password.toLowerCase();
userDb.find({login: username,
password: encrypt(password)},
{password:0},
function (err, data) {
res.json(data);
});
});
}
此外,我们还将指定密码不应是结果集的一部分,通过将字段设置为0或false来实现。
我们将在数据库中搜索具有请求的用户名和提供的密码的用户(但我们需要确保查找加密版本)。这样,我们永远不知道用户的真实密码。API 将使用/v1作为路由前缀:
var router = express.Router();
routes(router);
app.use('/v1', router);
注意,您还可以使用accept头来区分 API 的版本:
var port = 5000;
app.listen(process.env.PORT || port, function () {
console.log('server listening on port ' + (process.env.PORT || port));
});
最后,我们可以启动 API。当我们尝试发送GET请求时,我们得到预期的错误响应,当我们发送带有正确用户名和密码的有效正文时,API 提供它拥有的数据。让我们看看下面的截图:

摘要
恭喜!通过这个,您刚刚完成了高级 ReactJS 章节。
您已经学会了 Browserify 和 Webpack 之间的区别,并使用 Webpack 和热模块替换创建了一个新的基本设置,这为您提供了出色的开发者体验。
你还学会了如何使用 JavaScript 2016 类创建 React 组件,以及如何添加流行的状态管理库:Redux。此外,你还编写了另一个 API,这次是用于使用用户名和密码进行用户登录的 API。
给自己点个赞吧,因为这是一章非常重的章节。
注意
完成的项目可以在网上查看:reactjsblueprints-chapter6.herokuapp.com。
在下一章中,我们将利用上一章学到的知识来编写一个高度依赖 Web APIs 和本章中提到的 Webpack/Redux 设置的 Web 应用程序。卷起袖子吧,因为我们将要制作一个围绕拍摄图片的社交网络。
第七章。Reactagram
在本章中,我们将应用在前几章中开发的技能,并围绕照片构建一个社交网络应用。该应用可在桌面浏览器以及原生手机和平板电脑上使用。
在本章中,我们将通过连接到名为 Firebase 的实时数据库解决方案来探索 Flux 架构的替代方案。我们将创建一个高阶函数,我们将将其实现为一个围绕我们的路由的单例包装。这种设置将使我们能够为我们用户提供实时流以及应用中的 点赞 功能,同时仍然遵循 单向数据流 的原则。
我们还将探索另一个名为 Cloudinary 的基于云的服务。这是一个用于上传和托管图片的云服务。它是一个付费服务,但有一个慷慨的免费层,足以满足我们的需求。我们将在 Express 服务器中创建一个上传服务,用于处理图片上传,我们还将探索画布中的图片处理。
这些是我们将要讨论的主题:
-
使用网络摄像头 API
-
捕获 HTML5 画布中的照片输入
-
通过操作画布像素应用图像过滤器
-
连接到 Firebase 并将图片上传到云端
-
实时查看所有提交照片的流
-
实时评论和点赞
入门
我们将首先使用我们在 第六章 中开发的 Webpack 框架,高级 React。这是我们需要的从 npm 安装的依赖项:
"devDependencies": {
"autoprefixer": "⁶.2.3",
"babel-core": "⁶.3.26",
"babel-loader": "⁶.2.0",
"babel-plugin-react-transform": "².0.0",
"babel-preset-es2015": "⁶.3.13",
"babel-preset-react": "⁶.3.13",
"babel-tape-runner": "².0.0",
"classnames": "².2.3",
"exif-component": "¹.0.1",
"exif-js": "².1.1",
"firebase": "².3.2",
"history": "¹.17.0",
"imagetocanvas": "¹.1.5",
"react": "⁰.14.5",
"react-bootstrap": "⁰.28.2",
"react-dom": "⁰.14.5",
"react-router": "¹.0.3",
"react-transform-catch-errors": "¹.0.1",
"react-transform-hmr": "¹.0.1",
"reactfire": "⁰.5.1",
"redbox-react": "¹.2.0",
"superagent": "¹.6.1",
"webpack": "¹.12.9",
"webpack-dev-middleware": "¹.4.0",
"webpack-hot-middleware": "².6.0"
},
"dependencies": {
"body-parser": "¹.14.2",
"cloudinary": "¹.3.0",
"cors": "².7.1",
"envs": "⁰.1.6",
"express": "⁴.13.3",
"path": "⁰.12.7"
}
我们将使用与上一章相同的设置,但我们将对 server.js 进行一些小的修改,向 index.html 添加几行,并添加一些内容到我们的 CSS 文件中。
这是我们在原始 Webpack 框架中的树结构:
├── assets
│ ├── app.css
│ ├── favicon.ico
│ └── index.html
├── package.json
├── server.js
├── source
│ └── index.jsx
└── webpack.config.js
确保你的结构与此相同是值得的。
我们需要对 server.js 文件进行一些修改。我们将设置一个上传服务,我们将从我们的应用中访问它,因此它需要支持跨源资源共享 (CORS) 以及除了我们正常的 GET 路由之外的一个 POST 路由。
打开 server.js 并将内容替换为以下内容:
'use strict';
var path = require('path');
var express = require('express');
var webpack = require('webpack');
var config = require('./webpack.config');
var port = process.env.PORT || 8080;
var app = express();
var cors = require('cors');
var compiler = webpack(config);
var cloudinary = require('cloudinary');
var bodyParser = require('body-parser');
app.use( bodyParser.json({limit:'50mb'}) );
我们的应用需要 body-parser 包来访问我们的 POST 路由中的请求数据。我们将向我们的路由发送图片,因此我们还需要确保数据限制高于默认值。请参考以下代码:
app.use(cors());
app.use(require('webpack-dev-middleware')(compiler, {
noInfo:true,
publicPath: config.output.publicPath,
stats: {
colors: true
}
}));
var isProduction = process.env.NODE_ENV === 'production';
app.use(require('webpack-hot-middleware')(compiler));
app.use(express.static(path.join(__dirname, "assets")));
cloudinary.config({
cloud_name: 'YOUR_CLOUD_NAME',
api_key: 'YOUR_API_KEY',
api_secret: 'YOUR_API_SECRET'
});
var routes = function (app) {
app.post('/upload', function(req, res) {
cloudinary.uploader.upload(req.body.image, function(result) {
res.send(JSON.stringify(result));
});
});
这个 POST 调用将处理我们应用中的图片上传。它将图片发送到 Cloudinary 并将其存储在我们的图像流中以便稍后检索。您需要在 cloudinary.com/ 创建一个账户,并用我们在用户管理部分看到的真实凭据替换我们刚才看到的 API 凭据。以下是我们所做的主要更改:
app.get('*', function(req, res) {
res.sendFile(path.join(__dirname, 'assets','index.html'));
});
}
这确保了对任何不属于静态 asset 文件夹的文件的请求都将路由到 index.html。这很重要,因为它将允许我们使用历史 API 而不是使用哈希路由来访问动态路由,让我们看一下以下代码片段:
var router = express.Router();
routes(router);
app.use(router);
app.listen(port, 'localhost', function(err) {
if (err) {
console.log(err);
return;
}
console.log('Listening at http://localhost:'+port);
});
接下来,打开 assets/index.html 并将内容替换为以下代码:
<!DOCTYPE html>
<html>
<head>
<title>Reactagram</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,
initial-scale=1, maximum-scale=1">
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css" />
<link rel="stylesheet" type="text/css"href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
<link href='https://fonts.googleapis.com/css?family=Bitter' rel='stylesheet' type='text/css'>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div id="app"></div>
<script src="img/bundle.js"></script>
</body>
</html>
我们将依赖于 Bootstrap 来实现网格布局,所以我们需要添加 Bootstrap CSS 文件。我们还将添加免费的 Bitter 字体家族作为应用的主要字体。
我们将要更改的最后一件事是 app.css。我们将添加一组样式,确保我们构建的应用在 Web、平板电脑和智能手机上都能正常工作。
打开 app.css 并将内容替换为以下样式:
body {
font-family: 'Bitter', serif;
padding: 15px;
margin-top: 60px;
}
这将 Bitter 字体设置为应用的主要字体,并为导航标题添加顶部边距:
body {
font-family: 'Bitter', serif;
padding: 15px;
margin-top: 60px;
}
.header {
padding: 10px;
font-size: 18px;
margin: 5px;
}
h1 {
font-size: 18px;
}
ul {
list-style-type: none;
}
#camera {
position: absolute;
opacity: 1;
}
.hidden {
display: none;
}
hidden 类将被应用于所有应该保持隐藏且不可见的元素。现在查看以下内容:
@media all and (max-width: 320px) {
.canvas {
padding: 0;
text-align: center;
margin: 0 auto;
display: block;
z-index: 10;
position: fixed;
left: 10px;
top: 60px;
}
}
这是我们唯一的 media 查询。它将确保画布在小智能手机上保持居中和固定位置。当用户上传图片时,imageCanvas 的高度和宽度将被覆盖,因此这些值仅是默认值:
#imageCanvas {
max-width: 300px;
height: 300px;
margin: 0px auto;
border: 1px solid #333;
}
以下是将左侧和右侧菜单按钮设置到我们标题中的代码。它们将成为我们的导航元素:
.menuButtonLeft {
position: fixed;
padding-right: 15px;
height: 50px;
border-right: 2px solid #999;
padding-top: 16px;
top: 0;
left: 30px;
color: #999;
z-index: 1;
}
.menuButtonRight {
padding-left: 15px;
height: 50px;
border-left: 2px solid #999;
padding-top: 16px;
top: 0;
position: fixed;
right: 30px;
color: #999;
z-index: 1;
}
查看以下代码行:
.nav a:visited, .nav a:link {
color: #999; }
.nav a:hover, a:focus {
color: #fff;
text-decoration: none;
}
.logo {
padding-top: 16px;
margin: 0 auto;
text-align: center;
}
.filterButtonGrayscale {
position: fixed;
bottom: 55px;
left: 40px;
z-index:2;
}
.filterButtonThreshold {
position: fixed;
bottom: 55px;
right: 40px;
z-index:2;
}
.filterButtonBrightness {
position: fixed;
bottom: 10px;
left: 40px;
z-index:2;
}
.filterButtonSave {
position: fixed;
bottom: 10px;
right: 40px;
z-index:2;
}
这关于 过滤器 按钮。它们将在捕获图片后、发送到应用之前显示,让我们看一下以下代码片段:
.stream {
transition: all .5s ease-in;
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
height: 480px;
margin-top: 10px;
padding: 0; }
.stream img {
border: 2px solid #333;
}
我们将重用前面章节中的 spinner。这将在用户上传图片时显示:
.spinner {
width: 40px;
height: 40px;
display: none;
position: relative;
margin: 100px auto;
}
.double-bounce1, .double-bounce2 {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #333;
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2.0s infinite ease-in-out;
animation: sk-bounce 2.0s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
@-webkit-keyframes sk-bounce {
0%, 100% {
-webkit-transform: scale(0);
}
50% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bounce {
0%, 100% {
transform: scale(0);
-webkit-transform: scale(0);
}
50% {
transform: scale(1);
-webkit-transform: scale(1);
}
}
}
我们的应用基本设置现在已经完成,你可以通过在终端中运行以下命令来运行它:
node server.js
你应该看到 Webpack 编译你的应用,当准备就绪时,在终端窗口中记录此信息(当然,带有不同的哈希值和毫秒计数):
Listening at http://localhost:8080
webpack built c870b4500e3efe8b5030 in 1462ms
你还需要在 Firebase 和 Cloudinary 上注册账户。这两个服务都适用于开发使用。你可以通过访问 www.firebase.com/ 并注册一个用于开发此应用的数据库名称来在 Firebase 上注册一个账户。
以下截图显示了完成代码后应用在 iPhone 上的外观:

设置路由
让我们从设置应用根目录的路由配置开始这个应用。
打开 index.jsx 并将内容替换为以下代码:
import React from 'react';
import {render} from 'react-dom';
import config from './config';
import RoutesConfig from './routes';
render(
RoutesConfig(config),
document.getElementById('app')
);
你会注意到我们引用了两个我们尚未创建的文件,所以让我们继续将它们添加到我们的应用中。
在 source 文件夹的根目录下创建 config.js 文件,然后添加以下代码:
var rootUrl = "https://YOURAPP.firebaseio.com/";
将 YOURAPP 替换为你注册的 Firebase 应用的名称,让我们看一下以下代码片段:
var rootDb = "imageStream";
var likesDb = "likes";
module.exports = {
rootUrl: rootUrl,
rootDb: rootDb,
fbImageStream: rootUrl + rootDb,
fbLikes: rootUrl + likesDb
}
然后,创建 routes.jsx 并添加以下代码:
import React from 'react';
import { Link,
Router,
Route,
NoMatch,
IndexRoute,
browserHistory
}
from 'react-router'
import App from './components/app';
import Welcome from './components/welcome';
import Camera from './components/camera';
import Stream from './components/stream';
import Item from './components/item';
import config from './config';
import FBConnect from './fbconnect';
在这里,我们正在导入一些尚未创建的组件。我们将逐个创建这些组件,从FBConnect开始。这个组件很特殊,因为它是一个高阶组件,它将确保被它包裹的组件能够获得正确的状态。它的工作方式与我们在第六章中探讨的 Redux 非常相似,即高级 React。现在添加以下代码:
function Routes(config) {
return <Router history={ browserHistory }>
<Route path="/" name="Reactagram"
component={ FBConnect( App, config)} >
<Route name="Stream" path="stream"
component={ FBConnect( Stream, config) } />
<Route name="ItemParent" path="item"
component={ FBConnect( Item, config) } >
<Route name="Item" path=":key"
component={ FBConnect( Item, config) } />
</Route>
<Route name="Camera" path="camera"
component={ FBConnect( Camera, config) } />
<IndexRoute name="Welcome"
component={ FBConnect( Welcome, config) } />
</Route>
<Route name="404: No Match for route" path="*" component={FBConnect(App,config)} />
</Router>
}
export default Routes;
在添加此代码后,Webpack 将抛出许多错误,并在浏览器中显示一个红色的错误屏幕。我们需要在应用再次可用之前添加所有我们将使用的组件,并且随着它们的添加,您将看到错误日志逐渐减少,直到应用可以正确显示为止。
创建一个高阶函数
高阶函数是一个接受一个或多个函数作为参数并返回一个函数作为结果的函数。所有其他函数都是一阶函数。
使用高阶函数是扩展你的组合技能的绝佳方式,也是使复杂应用更容易的简单方法。它与 mixins 的使用相辅相成,mixins 是另一种在组件中提供继承的方式。
我们将创建的是一个带有 mixins 的高阶组件。它将通过我们在config.js中提供的配置连接到 Firebase,并确保我们依赖的有状态数据能够实时同步。这是一个很高的要求,但通过使用 Firebase,我们将卸载提供此功能所需的大部分繁重工作。
如您之前所见,我们将使用此函数来包裹我们的路由组件,并为他们提供以 props 形式的状态。
在你的source文件夹根目录下创建fbconnect.jsx文件,并添加以下代码:
import React, { Component, PropTypes } from 'react';
import ReactFireMixin from 'reactfire';
import Firebase from 'firebase';
import FBFunc from './fbfunc';
import Userinfo from './userinfo';
function FBConnect(Component, config) {
const FirebaseConnection = React.createClass({
mixins:[ReactFireMixin, Userinfo],
getInitialState: function() {
return {
data: [],
imageStream: [],
fbImageStream: config.fbImageStream
};
},
componentDidMount() {
const firebaseRef = new Firebase(
this.state.fbImageStream, 'imageStream');
this.bindAsArray(firebaseRef.orderByChild("timestamp"),
"imageStream");
这将获取你的图像流内容并将其存储在this.state.imageStream中。状态将可用于所有包裹组件的this.props.imageStream。我们将设置它,使其按时间戳值排序。请参考以下代码:
},
render() {
return <Component {...this.props}
{...this.state} {...FBFunc} />;
在这里,我们返回传递给此函数的组件以及从 Firebase 获取的状态和FBFunc中的一组有状态函数,如下所示:
}
});
return FirebaseConnection;
};
export default FBConnect;
注意
使用 Firebase 排序
Firebase 将始终以升序返回数据,这意味着新图片将被插入到底部。如果你想按降序排序,请将bindAsArray函数替换为自定义循环,然后在将数组存储到setState()之前将其反转。
你还需要创建一个文件来保存你将用于向图像流添加内容的函数。在你的项目根目录下创建一个名为fbfunc.js的文件,并输入以下代码:
import Firebase from 'firebase';
const FbFunc = {
uploadImage(url: string, user: string) {
let firebaseRef = new Firebase(this.fbImageStream);
let object = JSON.stringify(
{
url:url,
user:user,
timestamp: new Date().getTime(),
likes:0
}
);
firebaseRef.push({
text: object
});
},
此函数将使用图像 URL、用户名、时间戳和零个赞数将新图像存储到 Cloudinary。以下是我们like功能:
like(key) {
var onComplete = function(error) {
if (error) {
console.log('Update failed');
}
else {
console.log('Update succeeded');
}
};
var firebaseRef = new Firebase(`${this.props.fbImageStream}/${key}/likes`);
firebaseRef.transaction(function(likes) {
return likes+1;
}, onComplete);
},
如你所见,每次点击“点赞”都会给图片添加一个 +1 点赞数。你可以扩展此功能,以防止当前用户对自己上传的图片进行投票,并防止他们重复投票。现在参考以下代码:
addComment(e,key) {
const comment = this.refs.comment.getValue();
var onComplete = function(error) {
if (error) {
console.log('Synchronization failed');
}
else {
console.log('Synchronization succeeded');
}
};
let object = JSON.stringify(
{
comment:comment,
user:this.props.username,
timestamp: new Date().getTime()
}
);
var firebaseRef = new Firebase(this.props.fbImageStream+`/${key}/comments`);
firebaseRef.push({
text: object
}, onComplete);
},
comment 功能将在 item.jsx 中可见,这是一个显示单个照片的页面。此功能将存储新的评论,包括提交者的用户名和时间戳。现在我们继续到两个 helper 函数:
removeItem(key) {
var firebaseRef = new Firebase(this.props.fbImageStream);
firebaseRef.child(key).remove();
},
resetDatabase() {
let stringsRef = new Firebase(this.props.fbImageStream);
stringsRef.set({});
}
};
export default FbFunc;
这些函数将允许你删除单个条目或清除整个数据库。后者在调试时特别有用,但如果你将应用程序上线,保留它是非常危险的。
创建随机用户名
为了区分传入的不同图片,你需要给用户一个名字。我们将以非常简单的方式来做这件事,所以请参考 第六章,高级 React,了解如何实现更安全的登录解决方案的详细信息。
我们将简单地从形容词列表中挑选一个单词,从名词列表中挑选另一个单词,然后从这两个单词中组合一个用户名。我们将把名字存储在 localStorage 中,如果找不到现有的名字,我们将生成一个新的名字。
注意
本地存储
所有主流浏览器现在都支持 localStorage,但如果你计划支持旧浏览器,特别是 Internet Explorer,那么考虑 polyfills 可能是明智的。关于 polyfill localStorage 的良好讨论可以在 gist.github.com/juliocesar/926500 找到。
让我们创建我们的 username 函数。创建一个名为 username.js 的文件,并将其放在 tools 文件夹中。添加以下代码:
export function username() {
const adjs = ["autumn", "hidden", "bitter", "misty", "silent",
"empty", "dry", "dark", "summer", "icy",
"delicate", "quiet", "ancient", "purple",
"lively", "nameless"];
const nouns = ["breeze", "moon", "rain", "wind", "sea",
"morning", "snow", "lake", "sunset", "pine",
"shadow", "leaf", "dawn", "frog", "smoke",
"star"];
const rnd = Math.floor(Math.random() * Math.pow(2, 12));
return `${adjs[rnd % (adjs.length-1)]}-
${nouns[rnd % (nouns.length-1)]}`;
};
为了简洁起见,形容词和名词的数量已经减少,但你可以添加更多单词来为你的用户名增添多样性。
生成的用户名将是以下形式的变体:autumn-breeze,misty-dawn,和 empty-smoke。
注意
如果你想要更深入地探索名称和句子生成,我强烈建议你查看 www.npmjs.com/package/rantjs。
接下来,你需要实现此功能并设置所需用户名的文件。这是 userinfo.js,它在 fbconnect.js 中被引用。将文件添加到你的 root 文件夹,然后添加以下代码:
module.exports = {
getInitialState() {
username: ""
},
componentDidMount() {
let username;
if(localStorage.getItem("username")) {
username = localStorage.getItem("username");
}
if(!username || username === undefined) {
localStorage.setItem("username",
require("./tools/username").username());
}
this.setState({username: username})
}
}
此文件是一个 mixin,它将在 fbconnect 中扩展 getInitialState 和 componentDidMount,并添加一个用户名状态变量,如果不存在,它将创建一个用户名并将其存储在 localStorage 中。
创建欢迎屏幕
让我们创建一个应用程序标题和欢迎屏幕。我们将使用两个不同的文件来完成,app.jsx 和 welcome.jsx,我们将它们放在 components 文件夹中。
添加 components/app.jsx 然后添加以下代码:
import React from 'react';
import { Grid, Col, Row, Nav, Navbar } from 'react-bootstrap';
import { Link } from 'react-router';
import Classnames from 'classnames';
module.exports = React.createClass({
goBack() {
return this.props.location.pathname.split("/")[1]
==="item" ? "/stream" : "/";
},
goBack() 函数会根据您的当前位置将您带回到正确的页面。如果您正在查看单个项目,按下返回按钮后,您将被带回到流中。如果您正在流中,您将被带到首页,让我们看一下下面的代码片段:
render() {
const BackStyle = Classnames({
hidden: this.props.location.pathname==="/",
"menuButtonLeft": true
});
const PhotoStyle = Classnames({
hidden: this.props.location.pathname==="/camera",
"menuButtonRight": true
});
这两种样式将防止在不需要显示链接时显示链接。返回按钮仅在您不在首页时可见,如果您在照片页面上,照片按钮将被隐藏。请参考以下代码:
return <Grid>
<Navbar
componentClass="header""
fixedTop
inverse>
<h1
center
style={{ color:"#fff" }}
className="logo">Reactagram
</h1>
<Nav
role="navigation"
eventKey={ 0 }
pullRight>
<Link
className={ BackStyle }
to={this.goBack()}>Back</Link>
<Link
className={ PhotoStyle }
to="/camera">Photo</Link>
</Nav>
</Navbar>
{ this.props.children }
</Grid>
}
});
在本节中,我们添加了一个带有固定导航栏的 Bootstrap 网格。这确保了导航栏始终存在。代码块 { this.props.children } 确保任何 React.js 组件都在网格内渲染。
接下来,创建 components/welcome.jsx 并添加以下代码:
import React from 'react';
import { Row, Col, Button } from 'react-bootstrap';
module.exports = React.createClass({
contextTypes: {
router: React.PropTypes.object.isRequired
},
historyPush(location) {
this.context.router.push(location);
},
我们将使用 react-router 内置的 push 功能将用户过渡到所需的位置。URL 将是 http://localhost:8080/stream 或 http://localhost:8080/camera。
注意
注意,这些路由是非哈希的。
让我们看一下下面的代码片段:
renderResetButton() {
return <Button bsStyle="danger"
onClick={this.props.resetDatabase.bind(null, this)}>
Reset database!
</Button>
},
renderPictureButton() {
return <Button bsStyle="default"
onClick={this.historyPush.bind(null, '/camera')}>
Take a picture
</Button>
},
我们将路由参数绑定到 historyPush 函数,作为方便用户点击进行过渡的一种方式。第一个参数是上下文,但由于我们不需要它,我们将其赋值为 null。第二个是我们希望用户过渡到的路由。让我们看一下以下代码片段:
renderStreamButton() {
return <Button bsStyle="default"
onClick={ this.historyPush.bind(null, '/stream') }>
Stream
</Button>
},
render() {
return <Row>
<Col md={12}>
<h1>Welcome { this.props.username }</h1>
<p>
Reactagram is social picture app. Take snapshots of
yourself and share with your friends.
</p>
<p>
{ this.renderPictureButton() }
</p>
<p>
{ this.renderStreamButton() }
</p>
<p>
<em>PS! The username has been automatically
generated for you.</em>
</p>
</Col>
<Col md={ 12 }>
<h3>Reset database</h3>
<p>
Click here to reset your database.
Note: This will completely
Clear all of your uploaded pictures.
There's no way to undo this.
</p>
<p>
{ this.renderResetButton() }
</p>
</Col>
</Row>
}
})
添加了前面的代码后,应用程序在浏览器中的外观将是这样。请注意,此时链接将无法工作,因为我们还没有创建组件。我们很快就会做到这一点:

拍照
我们将使用相机 API 为我们的图像应用程序拍照。通过此接口,可以使用原生相机设备拍照,也可以选择图片通过网页上传。
通过添加一个具有 type="file" 和 accept 属性的输入元素来设置 API,以通知我们的组件它接受图片。
ReactJS JSX 看起来像这样:
<Input type="file" label="Camera" onChange={this.takePhoto}
help="Click to snap a photo" accept="image/*" />
当用户激活元素时,他们会看到一个选项,可以选择文件或使用内置相机(如果可用)拍照。在图片发送到 <input type="file"> 元素并触发其 onchange 事件之前,用户必须接受该图片。
一旦您有了图片的引用,您就可以将其渲染到图像元素或画布元素中。我们将后者作为渲染到画布,因为它为图像处理打开了大量的可能性。
创建一个名为 camera.jsx 的新文件,并将其放入 components 文件夹中。将以下代码添加到其中:
import React from 'react';
import { Link } from 'react-router';
import classNames from 'classnames';
import { Input, Button } from 'react-bootstrap';
//import Filters from '../tools/filters';
在我们添加此函数的代码之前,先将其注释掉:
import request from 'superagent';
import ImageToCanvas from 'imagetocanvas';
ImageToCanvas模块包含大量最初为这一章节编写的代码,但由于它包含大量针对相机和画布的特定代码,所以有点过于狭窄,不适合包含。如果你想深入了解画布代码,请查看 GitHub 仓库中的代码:
module.exports = React.createClass({
getInitialState() {
return {
imageLoaded: false
};
},
我们将使用这个状态变量在显示输入字段或捕获的图片之间切换。当捕获到图片时,这个状态被设置为true。考虑以下代码:
componentDidMount() {
this.refs.imageCanvas.style.display="none";
this.refs.spinner.style.display="none";
},
如代码所示,我们将隐藏画布,直到我们有内容可以显示。旋转器应该在用户上传图片时才可见。参考以下代码中的辅助函数:
toImg(imageData) {
var imgElement = document.createElement('img');
imgElement.src = imageData;
return imgElement;
},
toPng(canvas) {
var img = document.createElement('img');
img.src = canvas.toDataURL('image/png');
return img;
},
这些函数在将最终图像渲染给用户时将非常有用。现在看看这个:
putImage(img, orientation) {
var canvas = this.refs.imageCanvas;
var ctx = canvas.getContext("2d");
let w = img.width;
let h = img.height;
const scaleH = h / 400;
const scaleW = w / 300;
let tempCanvas = document.createElement('canvas');
let tempCtx = tempCanvas.getContext('2d');
canvas.width = w/scaleW < 300 ? w/scaleW : 300;
canvas.height = h/scaleH < 400 ? h/scaleH : 400;
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
tempCtx.drawImage(img, 0, 0, w/scaleW, h/scaleH);
ImageToCanvas.drawCanvas(canvas, this.toPng(tempCanvas), orientation, scaleW, scaleH);
this.refs.imageCanvas.style.display="block";
this.refs.imageCanvas.style.width= w/scaleW + "px";
this.refs.imageCanvas.style.height= h/scaleH + "px";
},
这个函数负责处理所有必要的画布处理逻辑,以便以正确的比例显示图像。我们的默认比例是 4:3(竖幅图片),并将图像的高度和宽度缩放到大约 400 像素和 300 像素。减小图像大小会导致质量下降,但会使图像处理更快,并减小文件大小,从而提高上传速度和改善用户体验。
这确实意味着方形图片或横幅模式的照片将显得挤压。因此,这个函数可以被扩展以查找水平放置的方形或矩形照片,以便它们可以正确缩放,让我们看一下以下代码片段:
takePhoto(event) {
let camera = this.refs.camera,
files = event.target.files,
file, w, h, mpImg, orientation;
let canvas = this.refs.imageCanvas;
if (files && files.length > 0) {
file = files[0];
var fileReader = new FileReader();
var putImage = this.putImage;
fileReader.onload = (event)=> {
var img = new Image();
img.src=event.target.result;
try {
ImageToCanvas.getExifOrientation(
ImageToCanvas.toBlob(img.src),
(orientation)=> {
putImage(img, orientation);
});
原生设备上的相机将以不同的方向拍照。除非我们调整它,否则我们最终会得到左转、右转或颠倒的图片,让我们看一下以下代码片段:
}
catch (e) {
this.putImage(img, 1);
如果我们无法获取exif信息,我们将默认将方向设置为1,这意味着不需要转换,让我们看一下以下代码片段:
}
}
fileReader.readAsDataURL(file);
this.setState({imageLoaded:true});
}
},
applyGrayscale() {
let canvas = this.refs.imageCanvas;
let ctx=canvas.getContext("2d");
let pixels = Filters.grayscale(
ctx.getImageData(0,0,canvas.width,canvas.height), {});
ctx.putImageData(pixels, 0, 0);
},
我们将设置三个不同的过滤器:grayscale、threshold和brightness。当我们添加filters.js时,我们将更详细地介绍过滤器:
applyThreshold(threshold) {
let canvas = this.refs.imageCanvas;
let ctx=canvas.getContext("2d");
let pixels = Filters.threshold(
ctx.getImageData(0,0,canvas.width,canvas.height), threshold);
ctx.putImageData(pixels, 0, 0);
},
applyBrightness(adjustment) {
let canvas = this.refs.imageCanvas;
let ctx=canvas.getContext("2d");
let pixels = Filters.brightness(
ctx.getImageData(0,0,canvas.width,canvas.height), adjustment);
ctx.putImageData(pixels, 0, 0);
},
saveImage() {
let canvas = this.refs.imageCanvas;
document.body.style.opacity=0.4;
this.refs.spinner.style.display="block";
this.refs.imageCanvas.style.display="none";
当用户保存图片时,我们将降低整个页面的不透明度,并显示加载旋转器,如前一段代码的最后部分所示,让我们看一下以下代码片段:
var dataURL = canvas.toDataURL();
new Promise((resolve, reject)=> {
request
.post('/upload')
.send({ image: dataURL, username: this.props.username })
.set('Accept', 'application/json')
.end((err, res)=> {
console.log(err);
if(err) {
reject(err)
}
if(res.err) {
reject(res.err);
}
resolve(res);
});
}).then((res)=> {
const result = JSON.parse(res.text);
this.props.uploadImage(result.secure_url,this.props.username);
this.props.history.pushState(null,'stream');
document.body.style.opacity=1.0;
});
当图片上传到Cloudinary后,我们将使用fbfunc.js中的uploadImage函数将结果存储在 Firebase 中。请考虑以下代码:
},
render() {
const inputClass= classNames({
hidden: this.state.imageLoaded
});
const grayScaleButton= classNames({
hidden: !this.state.imageLoaded,
"filterButtonGrayscale": true
});
const thresholdButton= classNames({
hidden: !this.state.imageLoaded,
"filterButtonThreshold": true
});
const brightnessButton= classNames({
hidden: !this.state.imageLoaded,
"filterButtonBrightness": true
});
const saveButton= classNames({
hidden: !this.state.imageLoaded,
"filterButtonSave": true
});
在这里,classNames函数提供了一个简单的接口来切换我们 HTML 节点上的类,让我们看一下以下代码片段:
return <div>
<Button className={grayScaleButton} onClick={this.applyGrayscale}>Grayscale</Button>
<Button className={thresholdButton}
onClick={this.applyThreshold.bind(null,128)}>Threshold
</Button>
<Button className={brightnessButton}
onClick={this.applyBrightness.bind(null,40)}>Brighter
</Button>
<Button className={saveButton} bsStyle="success"
onClick={this.saveImage}>Save Image</Button>
<div className={inputClass}>
<Input type="file" label="Camera" onChange={this.takePhoto}
help="Click to snap a photo or select an image from your
photo roll" ref="camera" accept="image/*" />
</div>
<div className="spinner" ref="spinner">
<div className="double-bounce1"></div>
<div className="double-bounce2"></div>
</div>
<div className="canvas">
<canvas ref="imageCanvas" id="imageCanvas">
Your browser does not support the HTML5 canvas tag.
</canvas>
</div>
</div>
}
});
现在,你应该能够点击相机按钮,使用你的相机手机拍照,或者如果你在台式电脑上工作,从你的硬盘上选择一张图片。以下截图显示了使用文件浏览器和相机按钮选择的桌面图片:

滤镜现在还不能工作,但我们将要添加它们。一旦完成,请从 camera.jsx 中的 import 函数中移除注释。
添加滤镜
我们已经设置了一些用于在从图像上传器捕获图像后操作图像的过滤器按钮,但我们还没有设置实际的过滤器函数。
你通过读取画布像素,修改它们,然后将它们写回画布来应用滤镜。
我们首先获取图像像素。这是你这样做的方式:
let canvas = this.refs.imageCanvas;
let ctx= canvas.getContext("2d");
let pixels = ctx.getImageData(0,0,canvas.width,canvas.height)
在 camera.jsx 中,我们将 getImageData 的结果作为参数传递给 filter 函数,如下所示:
let pixels = Filters.grayscale(
ctx.getImageData(0,0,canvas.width,canvas.height), {});
现在你有了像素,你可以遍历它们并应用你的修改。
让我们来看看完整的灰度滤镜。添加一个名为 filters.js 的文件,并将其放入 tools 文件夹中。将以下代码添加到其中:
let Filters = {};
Filters.grayscale = function(pixels, args) {
var data = pixels.data;
for (let i=0; i < data.length; i+=4) {
let red = data[i];
let green = data[i+1];
let blue = data[i+2];
let variance = 0.2126*red + 0.7152*green + 0.0722*blue;
我们分别获取 red、green 和 blue 的值,然后应用 RGB 到 Luma 转换公式,这是一个将淡化颜色信息并产生灰度图像的权重集:
data[i] = data[i+1] = data[i+2] = variance
我们然后将原始颜色值替换为新的单色颜色值,让我们看看以下代码片段:
}
return pixels;
};
Filters.brightness = function(pixels, adjustment) {
var data = pixels.data;
for (let i=0; i<data.length; i+=4) {
data[i] += adjustment;
data[i+1] += adjustment;
data[i+2] += adjustment;
此滤镜通过简单地增加 RGB 值使像素更亮。这类似于将 CSS 中字体颜色设置为 #eeeeee (R: 238 G: 238 B: 238) 从 #999 (R: 153 G: 153 B: 153)。现在我们转到阈值:
}
return pixels;
};
Filters.threshold = function(pixels, threshold) {
var data = pixels.data;
for (let i=0; i<data.length; i+=4) {
let red = data[i];
let green = data[i+1];
let blue = data[i+2];
let variance = (0.2126*red + 0.7152*green + 0.0722*blue >= threshold) ? 255 : 0;
如你所见,阈值是通过比较像素的灰度值与阈值值来应用的。一旦完成,将颜色设置为黑色或白色,让我们看看以下代码片段:
data[i] = data[i+1] = data[i+2] = variance
}
return pixels;
};
module.exports = Filters;
这是一个非常基本的滤镜集,你可以通过调整值轻松创建更多。你还可以查看 github.com/kig/canvasfilters 以获取要添加的滤镜集,包括模糊、索贝尔、混合、亮度和反转。
以下截图显示了应用了亮度和阈值的图片:

添加流
现在是添加 stream 功能的时候了。这非常简单,因为数据流已经通过 fbconnect.js 可用,所以我们只需映射流数据并渲染 HTML。
在你的 components 文件夹中创建一个名为 stream.jsx 的文件,并添加以下代码:
import React from 'react';
import { Grid,Row, Col, Button } from 'react-bootstrap';
import { Link } from 'react-router';
module.exports = React.createClass({
renderStream(item, index, image, data){
return (
<Col
className="stream"
sm={ 12 }
md={ 6 }
lg={ 4 }
key={ index } >
<Link to={`/item/${item['.key']}`}>
<img style={{ margin:'0 auto',display:'block' }}
width="300"
height="400"
src={ image } />
</Link>
<strong style={{ display:'block', fontWeight:600,
textAlign:'center' }}>
{ data.user }
</strong>
<strong style={{ display:'block', fontWeight:600,
textAlign:'center' }}>
Likes: { item.likes || 0 }
</strong>
<div style={{ padding:0,display:'block', fontWeight:600,
textAlign:'center' }}>
<Button bsStyle="success"
onClick={ this.props.like.bind(this,item['.key']) }>
Like
</Button>
</div>
用户可以点击“喜欢”多少次都可以,计数器每次都会更新。喜欢计数器是基于事务的,所以如果有两个或更多用户同时点击“喜欢”按钮,操作将排队直到所有“喜欢”都被计数,让我们看看以下代码片段:
</Col>
);
},
render() {
let stream = this.props.imageStream.map((item, index) => {
const data = JSON.parse(item.text);
let image;
try {
image =
data.url.replace("upload/","upload/c_crop,g_center,h_300/");
}
catch(e) {
console.log(e);
}
try…catch 块将防止出现空白部分(或应用抛出错误),如果用户无意中上传了一个损坏的图片(或由于某些错误,图片上传失败)。如果捕获到错误,这将记录到控制台,并且图片将不会显示。
使用像 Cloudinary 这样的服务的好处之一是,您可以请求您图像文件的不同版本,并且无需在我们的端进行任何工作即可将其交付。
在这里,我们请求一个高度为 300、以中心为权重的裁剪图像。这确保了我们返回此页面的图像在高度上是一致的,尽管宽度可能变化几像素。
Cloudinary 提供了丰富的选项,您实际上可以用它来过滤图像而不是在 JavaScript 中进行。您可以修改应用,以便每当用户捕获图像时,您可以在进一步处理之前将其发送到 Cloudinary。所有过滤器都可以通过向 Cloudinary 提供的图像 URL 添加过滤器来应用,让我们看看以下代码片段:
return image ?
this.renderStream(item, index, image, data) : null;
});
return <Row>
{stream}
</Row>
}
});
如果添加了图像,或者点赞数已更新,更改将立即在流中可见。尝试同时在一个设备上打开应用和一个浏览器窗口,或者两个浏览器窗口,您会注意到所做的任何更改都将实时同步。
创建项目页面并添加评论
如果您点击流中的任何图片,您将被带到项目页面。我们不需要为此设置新的查询,因为我们已经拥有了显示所需的所有内容。我们将从路由中获取项目键,并应用一个过滤器到图像流中,最终我们将得到一个单一的项目。
在以下屏幕截图 中,请注意已添加评论部分,并且有两个随机用户添加了一些评论:

在 components 文件夹中创建一个名为 item.jsx 的新文件,并添加以下代码:
import React from 'react';
import { Grid,Row, Col, Button, Input } from 'react-bootstrap';
import { Link } from 'react-router';
import { pad } from '../tools/pad';
module.exports = React.createClass({
renderStream(item, index, image, data) {
return (
<Col className="stream" sm={12} md={6} lg={4} key={ index } >
<img style={{margin:'0 auto',display:'block'}}
width="300" height="400" src={ image } />
<strong style={{display:'block', fontWeight:600,
textAlign:'center'}}>{data.user}</strong>
<strong style={{display:'block', fontWeight:600,
textAlign:'center'}}>Likes: {item.likes||0}</strong>
<div style={{padding:0,display:'block', fontWeight:600,
textAlign:'center'}}>
<Button bsStyle="success"
onClick={this.props.like.bind(this,item['.key'])}>
Like</Button>
</div>
{this.renderComments(item.comments)}
{this.renderCommentField(item['.key'])}
</Col>
);
},
renderStream() 函数几乎与我们为 stream.jsx 创建的函数相同,除了我们在这里移除了链接并添加了显示和添加评论的方式。请参考以下代码:
renderComments(comments) {
if(!comments) return;
let data,text, commentStream=[];
const keys = Object.keys(comments);
keys.forEach((key)=>{
data = comments[key];
text = JSON.parse(data.text);
commentStream.push(text);
})
return <Col md={12}><h4>Comments</h4>
{commentStream.map((item,idx)=>{
const date = new Intl.DateTimeFormat().format(item.timestamp)
const utcdate = new Intl.DateTimeFormat('en-US').format(date);
const utcdate = new Intl.DateTimeFormat('en-US').format(date);
return <div
key={ ´comment${idx}` }
style={{ paddingTop:15 }}>
{ utcdate } <br/> { item.comment }
- <small>{ item.user }</small>
</div>
})}</Col>
},
首先,我们使用 Object.keys() 获取评论标识符,它返回一个键的数组。然后,我们遍历这个数组以找到并渲染每个单独的评论到 HTML 中。
我们还获取时间戳并将其转换为可读日期,使用的是国际日期格式化器。此外,在这个例子中,我们使用了 en-US 区域设置,但您可以轻松地将其与任何区域设置交换。请查看以下代码:
renderCommentField(key) {
return <Col md={12}>
<hr/>
<h4>Add your own comment</h4>
<Input type="textarea" ref="comment"></Input>
<Button bsStyle="info"
onClick={this.props.addComment.bind(this,
this.refs.comment, key)} >Comment</Button>
</Col>
},
在这里,我们使用 onclick 处理器将输入字段和提交按钮渲染到 fbfunc.js 中的 addComment() 函数。最后,我们返回到 render() 函数:
render() {
let { key } = this.props.params;
let stream = this.props.imageStream
.filter((item)=>{return item['.key']==key})
.map((item, index) => {
const data = JSON.parse(item.text);
let image;
try {
image = data.url.replace("upload/","upload/c_crop,g_center,h_300/");
} catch(e){
console.log(e);
}
return image ?
this.renderStream(item, index, image, data) : null;
});
return <Row>
{stream}
</Row>
}
});
如上图所示,我们从路由参数中获取键,并应用一个过滤器到图像流中,这样我们就只剩下一个包含我们想要从流数据中获取的单个项目的数组。
然后,我们对数组应用一个 map 函数,获取图像,并调用 renderStream() 函数。
您需要添加我们在 item.jsx 顶部导入的 padding 文件,因此请在 tools 文件夹中创建一个名为 pad.js 的文件并添加以下代码:
export const pad = (p = '00', s = '') => {
return p.toString().slice(s.toString().length)+s;
}
它会将 1 转换为 01,依此类推,但不会对 10、11 或 12 做任何处理。所以当你想要给字符串添加左填充时,这是一个安全的选择。
总结
你的社交照片分享应用现在可以投入使用。它现在应该能够在桌面浏览器和原生智能手机和平板电脑上完全编译并正常运行,没有任何问题。
在原生设备上,处理图像和画布可能会有些棘手。照片的文件大小常常成为一个问题,因为许多智能手机的内存非常有限,所以你可能会经常遇到渲染画布图像时的问题。
这是我们在本应用中使用缩小图像的一个原因。另一个原因当然是使将照片传输到云端时更快。这两个问题都是非常实际的,但可以或多或少地被归类为边缘情况,所以如果你们决定进一步开发这个应用,我就把这个留给你了。
在下面的屏幕截图中,你可以看到应用在 iPad 上的部署情况:

这是应用的最终文件结构:
├── assets
│ ├── app.css
│ ├── favicon.ico
│ └── index.html
├── package.json
├── server.js
├── source
│ ├── components
│ │ ├── app.jsx
│ │ ├── camera.jsx
│ │ ├── item.jsx
│ │ ├── stream.jsx
│ │ └── welcome.jsx
│ ├── config.js
│ ├── fbconnect.js
│ ├── fbfunc.js
│ ├── index.jsx
│ ├── routes.jsx
│ ├── tools
│ │ ├── filters.js
│ │ ├── pad.js
│ │ └── username.js
│ └── userinfo.js
└── webpack.config.js
对于一个已经相当强大的应用来说,这是一个非常简洁的文件结构。你可以争论说config文件和 Firebase 文件可以放在自己的文件夹中,而且我不会反对。
最后,你组织文件的方式通常取决于个人偏好。有些人可能喜欢将所有 JavaScript 文件放在一个文件夹中,而其他人则更喜欢按功能排序。
注意
完成的项目可以在reactjsblueprints-chapter7.herokuapp.com在线查看。
摘要
在本章中,你学习了如何使用 HTML5 canvas 通过相机/文件读取 API 来使用相机,以及如何通过修改像素来操作图像。你连接到了 Firebase 和 Cloudinary,这两个都是流行的基于云的工具,可以帮助你作为开发者专注于你的应用而不是你的基础设施。
你还体验了通过使用 Firebase 这样的工具,你可以完全避免使用 Flux。这不是一个常见的架构,但值得知道至少有这条路可行。
最后,你制作了一个可以轻松扩展并打上你品牌标志的实时社交照片应用。
在下一章中,我们将探讨如何使用 ReactJS 开发同构应用。同构应用意味着在服务器上预先渲染的应用,所以我们将探讨向那些在其浏览器中未启用 JavaScript 的用户提供 ReactJS 应用的技巧。
第八章。将你的应用部署到云上
在本章中,我们将为我们的应用创建一个生产级管道。这包括将你的配置文件分为开发和生产版本,以及创建一个为 Node.js 服务器准备好的实例。首先,我们将查看如何设置来自第一章的 Browserify 脚手架的生产级部署,即深入 React,然后我们将查看如何使用 Webpack 进行相同的操作。
使用云服务器是部署代码最经济的方式。在云服务成为可行的选择之前,你通常会不得不将代码部署到位于单个数据中心中的物理服务器。如果你要将代码部署到多个数据中心,你需要购买或租赁更多的物理服务器,这通常需要相当大的成本。
云服务改变了这一点,因为现在你可以将你的代码部署到全球拥有数据中心的所有云服务提供商。在美国、欧洲和亚洲部署你的应用的成本通常相同,而且相对便宜。
这些是我们将详细讨论的主题:
-
选择云服务提供商
-
为云服务准备 Browserify 应用
-
为云服务准备 Webpack 应用
选择云服务提供商
可供选择的大量云服务提供商中,最受欢迎和成熟的提供商包括Heroku、Microsoft Azure、Amazon、Google App Engine和Digital Ocean。它们各自都有其优点和缺点,因此在决定选择哪一个之前,调查每一个都是值得的。
在这本书中,我们一直使用 Heroku 来部署我们的应用,我们将设置我们的部署以针对这个平台。让我们简要地看看使用 Heroku 的优点和缺点。
优点如下:
-
易于使用。在初始注册后,你通常只需要发出一个单一的 Git push 来部署你的代码。
-
当你的应用流量增加时,易于扩展。
-
为第三方应用和云服务提供出色的插件支持。
-
提供免费的基本层。
-
没有基础设施管理。
现在,缺点如下:
-
可能会变得昂贵。Heroku 提供了慷慨的免费层,但价格阶梯的第一步相当陡峭。
-
供应商锁定问题;从 Heroku 迁移到另一个云服务提供商需要大量工作。
-
基本层曾满足了一段时间,但最近,Heroku 增加了一项政策,即免费实例每 24 小时必须保持 6 小时不活跃。
-
环境会不定期被清除。你无法登录实例并对环境进行本地更改,因为下一次实例刷新时它们将消失。
由于 Heroku 相对容易上手,我们将使用 Heroku 进行部署。
首先在 signup.heroku.com/ 注册一个免费账户。完成此操作后,从 toolbelt.heroku.com/ 下载 Heroku 工具包。你还需要上传你的 SSH 密钥。如果你需要生成 SSH 密钥的帮助,请访问 devcenter.heroku.com/articles/keys。
设置完成后,你可以在终端中输入以下命令来创建 Heroku 应用程序:
heroku create <name>
你可以省略名称,在这种情况下,Heroku 将为你提供一个随机的名称。请注意,Heroku 需要 Git。如果你已经有了 Git 仓库,Heroku 将自动将配置参数添加到你的 .git/config 文件中。如果没有,你稍后必须手动完成。参数看起来像这样:
[remote "heroku"]
url = https://git.heroku.com/*<name>*.git
fetch = +refs/heads/*:refs/remotes/heroku/*
你可以在 .git 文件夹(注意点号)内找到配置文件。文件名为 config,所以完整路径是 .git/config。
要部署你的应用程序,将文件添加到你的仓库并提交你的更改。然后,执行以下命令:
git push heroku master
然后,你的应用程序将基于主分支进行部署。你可以通过输入 git push heroku yourbranch:master 来部署其他分支。
使用 npm 设置云部署
如果我们立即尝试发布我们的脚手架,我们可能会遇到错误,因为我们没有告诉 Heroku 如何为我们提供应用程序。Heroku 将简单地尝试使用 npm start 运行应用程序。
npm 包是 Node.js 的基础。我们在前面的章节中简要介绍了它,但鉴于我们现在将严重依赖它,现在是时候更仔细地看看它能为你们做什么了。
你可能听说过或甚至使用过像 Grunt、Gulp 或 Broccoli 这样的任务运行器。它们擅长自动化任务,这样你就可以专注于编写代码,而不是执行重复性任务,例如压缩和打包你的代码、复制和连接样式表等。
然而,对于大多数任务,你最好让 npm 为你完成工作。使用 npm 脚本,你将拥有自动化常见任务所需的所有功能,而且开销和维护成本更低。
npm 包包含一些内置命令,其中之一是 npm run-script(简称 npm run)。此命令从 package.json 中提取脚本对象。传递给 npm run 的第一个参数指的是脚本对象中的一个属性。对于你自己创建的任何属性,你需要使用 npm run 来运行它们。一些属性名称已被保留,例如 start、stop、restart、install、publish、test 等。它们可以通过简单地执行 npm start 等命令来调用。
注意
有一点需要注意,如果定义了 prefoo 和 postfoo,则 npm run foo 也会运行它们。你可以通过执行 npm run prefoo 或 postfoo 来单独运行每个阶段。
执行 npm run 命令以查看可用的脚本;你将看到以下输出:
Lifecycle scripts included in webpack-scaffold:
test
echo "Error: no test specified" && exit 1
start
node server.js
这很有趣。我们还没有创建一个启动脚本,但 npm run 告诉我们 npm start 将运行 node server.js。这是 node 的另一个默认设置。如果你没有指定启动脚本,并且根目录中有一个 server.js 文件,那么它将被执行。
Heroku 仍然不会运行脚手架,因为 express 服务器配置为使用 Webpack 和热重载启动开发会话。你需要创建一个生产服务器,除了你的开发服务器之外。
你可以通过两种方式之一来处理这个问题:
-
一个选择是在你的服务器代码中引入
environment标志,例如这样:if(process.env.NODE_ENV !== "development"){ // production server code } -
另一个选择是创建一个独立的
server生产文件
两种方法都很好,但使用单独的文件可能更干净,所以我们选择这种方法。
准备你的 Browserify 应用以进行云部署
在本节中,我们将使用我们在 第二章 中开发的商店应用,创建一个网络商店。该应用使用 Browserify 打包代码并使用 node 运行开发服务器。我们将继续在生产中使用 node,但我们需要设置一个特定的 server 文件,以便制作一个生产就绪的应用。
提醒一下,这是我们开始之前商店应用的样子:
├── package.json
├── public
│ ├── app.css
│ ├── bundle.js
│ ├── heroku.js
│ ├── index.html
│ └── products.json
├── server.js
└── source
├── actions
│ ├── cart.js
│ ├── customer.js
│ └── products.js
├── app.jsx
├── components
│ ├── customerdata.jsx
│ ├── footer.jsx
│ └── menu.jsx
├── layout.jsx
├── pages
│ ├── checkout.jsx
│ ├── company.jsx
│ ├── home.jsx
│ ├── item.jsx
│ ├── products.jsx
│ └── receipt.jsx
├── routes.jsx
└── stores
├── cart.js
├── customer.js
└── products.js
我们将采取以下步骤使其准备好云部署:
-
创建生产服务器文件
-
安装生产依赖项
-
修改
package.json -
将我们的代码库转换为 EcmaScript 5
实际的过程
创建一个名为 server.prod.js 的新文件,并将其放在项目的根目录下。将以下代码添加到其中:
var express = require("express");
var app = express();
var port = process.env.PORT || 8080;
var host = process.env.HOST || '0.0.0.0';
我们正在定义一个 express 服务器并设置主机和 port 变量。默认情况下,在 0.0.0.0 上为端口 8080。当在本地机器上运行时,这个主机地址在功能上与 localhost 相同,但在服务器上运行时可能会有所不同。如果服务器主机有多个 IP 地址,将 0.0.0.0 作为主机将匹配任何请求。使用如 localhost 这样的参数可能会导致服务器无法绑定你的应用并失败启动:
var path = require("path");
var compression = require("compression");
app.use(compression());
由于我们将向公众提供文件,因此在提供服务之前用 GZIP 压缩它们是值得的。对于文本和脚本文件,节省的量可能非常显著,在许多情况下可达 80-90%。对于流量较低的网站,这种实现已经足够好。对于流量较高的网站,在反向代理级别实现压缩是最佳方式,例如,通过使用 nginx。我们将路由所有请求到我们的 public 文件夹和所需的文件名:
app.get("*", function (req, res) {
var file = path.join(__dirname, "public", req.path);
res.sendFile(file);
});
最后,服务器将以调试信息启动,告诉我们已部署应用的地址:
app.listen(port, host, function (err) {
console.log('Server started on http://'+host+':'+port)
});
下一步我们需要做的是创建一个build脚本来打包我们的 JavaScript 代码。在运行开发服务器时,代码会自动打包。这个包通常相当大。例如,商店应用的开发包是 1.4 MB。即使启用了压缩,这个文件也可能太大,不适合向用户展示。当部署到生产环境时,我们需要创建一个更小的包,以便您的应用可以更快地下载并准备好使用。幸运的是,这相当简单。
我们将使用 Browserify 和 UglifyJS 的 CLI 版本组合。后者是一个压缩工具,它会删除换行符、缩短变量名,并从我们的包中删除未使用的代码。我们将首先使用 Browserify 打包源文件,然后使用管道运算符(|)将输出发送到 UglifyJS。此操作的输出结果然后通过大于运算符(>)发送到一个bundle文件。
序列的第一部分如下:
./node_modules/.bin/browserify --extension=.jsx source/app.jsx -t [ babelify ]
当您运行此命令时,整个包将以字符串形式输出。您可以可选地指定-o bundle.js以将结果保存到包文件中。我们不希望这样做,因为我们不需要临时包。
序列的第二部分如下:
./node_modules/.bin/uglifyjs -p 5 -c drop_console=true -m --max-line-len --inline-script
我们已经指定了一些参数,让我们看看它们的作用。
-p参数跳过源文件名中出现的原始文件名前缀。这里的节省非常小,但仍然值得保留。参数后面的数字是删除的相对路径数。
-c选项代表压缩器。如果不指定任何压缩器选项,将使用默认的压缩选项。这可以节省很多字节。
接下来是drop_console=true。这告诉 UglifyJS 删除任何控制台日志。如果您在调试应用时使用了这种方法,并且忘记从代码中删除它,这将很有用。
下一个是-m,代表混淆。此选项更改并缩短了您的变量和函数名,并且是一个重要的字节节省因素。
最后两个参数不会节省任何字节,但它们仍然很有用。--max-line-len参数会在行长度超过给定值时(默认为 32,000 个字符)拆分丑化后的代码。当支持无法处理非常长行的旧浏览器时,这很有用。--inline-script参数会转义字符串中</script出现的斜杠。
仅运行此命令本身不会生成压缩包,因为我们没有指定输入。如果您将包存储在临时文件中,可以使用小于运算符和文件名(如:< bundle.js)将内容发送到前面的命令。
最后,我们将使用大于运算符将结果发送到我们想要的输出位置。
完整的命令序列如下:
NODE_ENV=production browserify --extension=.jsx source/app.jsx -t [ babelify ] | ./node_modules/.bin/uglifyjs -p 5 -c drop_console=true -m --max-line-len --inline-script > public/bundle.js
运行第一部分的结果是一个大约 1.4 MB 的包大小。通过 UglifyJS 处理后,包大小约为 548 KB。如果你去掉选项并使用纯 UglifyJS,最终的包大小大约为 871 KB。
在打包和压缩之后,我们现在可以准备好将我们的应用到云端部署。由于我们使用了压缩,最终的包大小大约为 130 KB。与原始文件大小 1.4 MB 相比,这是一个巨大的胜利。
在我们部署代码之前,我们需要告诉 Heroku 如何启动我们的应用。我们将通过添加一个名为 Procfile 的单个文件来完成这项工作。这是一个特殊的文件,如果存在,Heroku 将会读取并执行它。如果不存在,Heroku 将尝试执行 npm start;如果失败,则尝试运行 node server.js。
添加包含以下内容的 Procfile 文件:
web: node server.prod.js
完成这些后,提交你的代码并通过执行此命令将代码推送到 Heroku:
git push heroku master
最终结果应该与本地应用看起来完全相同,但现在你是在云端运行它。示例应用可在 reactjsblueprints-webshop.herokuapp.com/ 找到。以下截图显示了上述链接的网页:

记住生成压缩后的 Browserify 包的整个命令序列非常困难。我们将将其添加到 package.json 中,以便我们可以轻松执行。
打开 package.json 并将 scripts 部分的内文替换为以下代码:
"scripts": {
"bundle": "browserify --extension=.jsx source/app.jsx -t [ babelify ] | ./node_modules/.bin/uglifyjs -p 5 -c drop_console=true -m --max-line-len --inline-script > public/bundle.js",
"start": "node server.js"
},
现在你可以使用 npm run bundle 来运行打包操作。
将 Webpack 应用部署到云端
在本节中,我们将使用我们在 第六章 中开发的 Webpack 框架,高级 React。我们需要添加一些包并做一些修改。
作为提醒,这是我们开始之前的项目文件结构:
├── assets
│ ├── app.css
│ ├── favicon.ico
│ └── index.html
├── package.json
├── server.js
├── source
│ └── index.jsx
└── webpack.config.js
让我们先把我们名为 server.js 的文件重命名为 server-development.js。然后,在项目根目录中创建一个名为 server-production.js 的新文件,并添加以下代码:
'use strict';
var path = require('path');
var express = require('express');
var serveStatic = require('serve-static')
var compression = require('compression')
var port = process.env.PORT || 8080;
var host = process.env.HOST || '0.0.0.0';
在这里,我们指示服务器使用预配置的 PORT 和 HOST 变量或默认变量,就像我们在 Browserify 服务器中所做的那样。然后,我们添加了一个错误处理器,以便我们能够优雅地响应错误。这也可以添加到 Browserify 服务器中:
var http = require('http');
var errorHandler = require('express-error-handler');
我们还添加了压缩:
var app = express();
app.use(compression());
现在我们转向 assets 文件:
var cpFile = require('cp-file');
cpFile('assets/index.prod.html', 'public/assets/index.html').then(function() {
console.log('Copied index.html');
});
cpFile('assets/app.css', 'public/assets/app.css').then(function() {
console.log('Copied app.css');
});
我们将手动复制所需的 asset 文件。我们只有两个文件,所以手动操作是可以接受的。如果我们有很多文件要复制,另一种方法可能更有效。一个在不同环境中都兼容的选项是 ShellJS。使用这个扩展,你可以在 JavaScript 环境中设置普通 shell 命令并执行它们。我们在这个项目中不会这样做,但值得一看。现在参考以下代码行:
var envs = require('envs');
app.set('environment', envs('NODE_ENV', 'production'));
app.use(serveStatic(path.join(__dirname, 'public', 'assets')));
在这里,我们将 environment 设置为 production,并让 Express 知道我们的静态文件放置在 ./public/assets 文件夹中,使用 serve-static 中间件。这意味着我们可以在文件中引用 /app.css,Express 将知道在正确的 assets 文件夹中查找它。对于低流量应用,这是一个好的实现,但对于高流量应用,最好使用反向代理来提供静态文件。使用反向代理的主要好处是减轻动态服务器上的负载,将其转移到专门设计来处理资产的其它服务器。我们路由所有请求到 index.html。这不会应用于存在于 static 文件夹中的文件:
var routes = function (app) {
app.get('*', function(req, res) {
res.sendFile(path.join(__dirname, 'public', 'assets','index.html'));
});
}
我们创建 server 对象以便将其传递给错误处理器:
var router = express.Router();
routes(router);
app.use(router);
Var server = http.createServer(app);
在这里,我们响应错误并条件性地关闭服务器。server 对象作为参数传递,以便错误处理器可以优雅地关闭它:
app.use(function (err, req, res, next) {
console.log(err);
next(err);
});
app.use( errorHandler({server: server}) );
最后,我们启动应用:
app.listen(port, host, function() {
console.log('Server started at http://'+host+':'+port);
});
正如您所注意到的,我们添加了一些新的包。使用以下命令安装这些包:
npm install --save compression@1.6.1 envs@0.1.6 express-error-handler@1.0.1 serve-static@1.10.2 cp-file@3.1.0 rimraf@2.5.1
所有在 server.prod.js 中需要的模块都需要移动到 package.json 的 dependencies 部分中。您的依赖部分现在应该看起来像这样:
"devDependencies": {
"react-transform-catch-errors": "¹.0.1",
"react-transform-hmr": "¹.0.1",
"redbox-react": "¹.2.0",
"webpack-dev-middleware": "¹.4.0",
"webpack-hot-middleware": "².6.0",
"babel-core": "⁶.3.26",
"babel-loader": "⁶.2.0",
"babel-plugin-react-transform": "².0.0",
"babel-preset-es2015": "⁶.3.13",
"babel-preset-react": "⁶.3.13",
"babelify": "⁷.3.0",
"uglifyjs": "².4.10",
"webpack": "¹.12.9",
"rimraf": "².5.1",
"react": "¹⁵.1.0",
"react-dom": "¹⁵.1.0"
},
"dependencies": {
"compression": "¹.6.1",
"cp-file": "³.1.0",
"envs": "⁰.1.6",
"express": "⁴.13.3",
"express-error-handler": "¹.0.1",
"path": "⁰.12.7",
"serve-static": "¹.10.2"
}
Heroku 需要的所有依赖项都必须放在正常的 dependencies 部分中,因为 Heroku 会省略 devDependencies 中的所有包。
小贴士
云部署的依赖策略
由于从 npm 下载和安装包相当慢,将仅在开发时需要的包放在 devDependencies 中,反之亦然,是一种良好的实践。我们在整本书中都这样做,所以希望您已经遵循了这种模式。
我们几乎完成了,但在我们准备好之前,我们需要创建 webpack.config.js、index.html 的生产版本,并添加构建脚本。
将现有的 webpack.config.js 文件重命名为 Webpack-development.config.js,然后创建一个名为 Webpack-production.config.js 的文件。注意,这意味着您需要将 server-development.js 中的 Webpack 导入更改为反映这一更改。
添加以下代码:
'use strict';
var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: [
'./source/index'
],
output: {
path: path.join(__dirname, 'public', 'assets'),
filename: 'bundle.js',
publicPath: '/assets/'
},
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
此插件重新排序包,以便最常用的包放在顶部。这应该会减小文件大小并使包更高效。我们指定这是一个生产构建,以便 Webpack 利用它拥有的最节省字节的算法:
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify('production')
}
}),
我们还将告诉它使用 UglifyJS 压缩我们的代码:
new webpack.optimize.UglifyJsPlugin({
compressor: {
warnings: false
}
})
从 Webpack 的 production 配置中,我们移除了热加载插件,因为它仅在开发时才有意义:
],
module: {
loaders: [{
tests: /\.js?$/,
loaders: ['babel'],
include: path.join(__dirname, 'source')
}]
},
resolve: {
extensions: ['', '.js', '.jsx']
}
};
接下来,将一个名为 index-production.html 的文件添加到 assets 目录中,并添加以下代码:
<!DOCTYPE html>
<html>
<head>
<title>ReactJS + Webpack Scaffold</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,
initial-scale=1">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div id="app"></div>
<script src="img/bundle.js"></script>
</body>
</html>
最后,将这些脚本添加到 package.json 中:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prestart": "npm run build",
"start": "node server-production.js",
"dev": "node server-development.js",
"prebuild": "rimraf public",
"build": "NODE_ENV=production webpack --config Webpack-production.config.js",
"predeploy": "npm run build",
"deploy": "echo Ready to deploy. Commit your changes and run git push heroku master"
},
这些脚本让您可以构建和部署您的应用。我们暂时没有提交更改,以便让您知道部署过程已准备就绪。
注意,在构建参数中,我们添加了 NODE_ENV=production 以防止 Babel 在构建代码时尝试使用 hot 模块替换。控制此功能的配置在 .babelrc 文件中。
你的 Webpack 框架现在已准备好用于生产!
在开发时,执行 npm run dev 并享受一个具有热重载的流畅开发环境。
在 npm deploy 上,构建脚本将被执行,并会通知你何时准备好发布更改。你需要通过 git add 和 git commit 手动添加更改,然后运行 git push heroku master。当然,你可以在部署脚本中自动化这一过程。
构建脚本也可以通过执行 npm run build 来触发。在构建脚本之前,我们将首先执行 rimraf public。Rimraf 是一个环境安全的命令,用于删除 public 文件夹及其所有内容。它在 Mac/Linux 上等同于运行 rm -rf public。此命令在 Windows 上不存在,因此在那个平台上运行 rm 不会起作用,但运行 rimraf 将在任一平台上起作用。最后,脚本执行 webpack 并构建一个生产包,该包放在 public/assets/bundle.js 中。
通常情况下,Webpack 在移除未使用代码方面略有效率,因此最终生成的包大小将小于 Browserify 生成的包。本例中生成的包大约为 132 KB。
注意
注意,这并不是一个完全公平的比较,因为我们捆绑在 Browserify 部分的应用程序要大得多。
最终结果可在reactjsblueprints-wpdeploy.herokuapp.com/找到。

为了参考,我们的文件结构现在看起来是这样的:
├── .babelrc
├── assets
│ ├── app.css
│ ├── favicon.ico
│ ├── index.html
│ └── index-production.html
├── package.json
├── server-development.js
├── server-production.js
├── source
│ └── index.jsx
├── Webpack-development.config.js
└── Webpack-production.config.js
这仍然相当易于管理。诚然,在 prod 和 dev 中分离文件需要更多的人工干预,但与在文件内部使用 if…else 循环切换代码相比,这可能是更好的选择。然而,代码组织确实是一个棘手的问题,没有一种通用的设置能令每个人都满意。对于仅涉及几个文件的少量修改,if…else 语句可能更适合在生产版本和开发版本中分割文件。
摘要
在本章中,我们将云部署添加到了本书中开发的所有两个框架中。两个示例的预览现在可在网上找到。
生成可云部署的应用程序通常意味着尽可能紧密地捆绑我们的代码。随着 HTTP/2 时代的到来,这种策略可能需要重新审视,因为生成一组可以并行下载的文件可能更有益,而不是单个捆绑包,无论它有多小。值得注意的是,非常小的文件从 gzip 中获益不大。
使用 Webpack 也可以分割你的代码包。有关 Webpack 代码分割的更多信息,请参阅webpack.github.io/docs/code-splitting.html。
在下一章中,我们将基于本章中刚刚制作的生成式 Webpack 配置,开发一个流式服务器渲染的应用程序。
第九章。创建共享应用
同构应用是可以在客户端和服务器端运行的 JavaScript 应用程序。其理念是后端和前端应该尽可能共享代码。对于服务器渲染的应用,您也可以在不等待 JavaScript 代码初始化的情况下提前展示内容。
本章分为两个部分:
-
在第一部分,我们将扩展我们在 第八章 中创建的设置,即 将您的应用部署到云端,以便它支持组件的预渲染
-
在第二部分,我们将添加 Redux 并从服务器环境中填充您的应用数据
简而言之,以下是我们将要涉及的主题:
-
服务器渲染与客户端渲染
-
术语混淆
-
修改设置以启用服务器渲染
-
流式传输您的预渲染组件
-
将服务器渲染的应用部署到云端
服务器渲染与客户端渲染
Node.js 使得在您的后端和前端编写 JavaScript 变得非常容易。我们一直在编写服务器代码,但直到现在,我们的所有应用都是客户端渲染的。
将您的应用作为客户端渲染的应用渲染意味着捆绑您的 JavaScript 文件,并将其与您的图像、CSS 和 HTML 文件一起分发。它可以在任何操作系统上运行的任何类型的 Web 服务器上分发。
客户端渲染的应用通常分为两个步骤:
-
初始请求加载
index.html以及 CSS 和 JavaScript 文件,要么同步要么异步。 -
通常,应用随后会发出另一个请求,并根据服务器响应生成适当的 HTML。
对于服务器渲染的应用,第二步通常被省略。初始请求一次性加载 index.html、CSS、JavaScript 和内容。应用在内存中,准备提供服务,无需等待客户端解析和执行 JavaScript。
你有时会听到这样的论点,即服务器渲染的应用对于服务于那些设备上没有 JavaScript 或简单地将它关闭的用户是必要的。这不是一个有效的论点,因为我知道的所有调查和统计数据都将用户数量估计在约 1% 左右。
另一个论点是支持搜索引擎机器人,它们通常在解析基于 JavaScript 的内容时遇到困难。这个论点稍微更有道理,但像 Google 和 Bing 这样的主要玩家能够做到这一点,尽管您可能需要添加一个元标签,以便内容可以被索引。
注意
验证机器人是否可以读取您的网站
您可以使用 Google 的 Fetch as Googlebot 工具来验证您的内容是否被正确索引。该工具可在 www.google.com/webmasters/tools/googlebot-fetch 获取。或者,您可以参考 www.browseo.net/。
服务器渲染应用的优点如下:
-
拥有慢速计算机的用户不需要等待 JavaScript 代码解析。
-
它还为我们提供了可预测的性能。当你控制渲染过程时,你可以测量加载你的网页所需的时间。
-
不需要用户在他们的设备上安装 JavaScript 运行时。
-
使搜索引擎更容易爬取你的页面。
客户端渲染应用程序的好处如下:
-
需要处理的设置更少
-
服务器和客户端之间没有并发问题
-
通常更容易开发
制作一个服务器端渲染的应用程序比编写客户端渲染的应用程序更复杂,但它带来了实际的好处。我们首先将我们的脚手架准备好以适应云端,然后再添加服务器渲染功能。首先,我们需要澄清术语,因为在实际应用中,你会遇到几个不同的术语来描述服务器和客户端之间共享代码的应用程序。
术语混淆
术语同构由希腊语单词isos(意为“相等”)和morph(意为“形状”)组成。这个想法是通过使用同构这个术语,可以很容易地理解这是服务器和客户端之间共享的代码。
在数学中,同构是两个集合之间的一对一映射函数,它保留了集合之间的关系。
例如,一个同构的代码示例可能看起来像这样:
// Module A
function foo(x, y) {
return x * y;
}
// Module B
function bar(y, x) {
return y * x;
}
foo(10, 20) // 200
bar(20, 10) // 200
这两个函数不相同,但它们产生相同的结果,因此在乘法中是同构的。
在数学中,同构可能是一个好术语,但显然它并不适合开发 Web 应用程序。我们在这里使用这个术语作为本章的标题,因为它是 JavaScript 社区中目前公认的用于服务器端渲染应用程序的术语。然而,它并不是一个非常好的术语,我们正在寻找一个更好的术语。
在寻找替代品的过程中,术语通用已成为许多人的选择。然而,这并不完全理想。一方面,它很容易被误解。与 Web 应用程序相关的通用最接近的定义是:被所有人使用或理解。记住,目标是描述代码共享。但是,通用也可以被理解为描述可以在任何地方运行的 JavaScript 应用程序的术语。这包括不仅限于 Web,还包括原生设备和操作系统。这种混淆在 Web 开发领域普遍存在。
第三个术语是共享,即共享 JavaScript。这更合适,因为它暗示了你的代码有一定的意义。当你编写共享 JavaScript 时,意味着你编写的代码打算在多个环境中使用。
在网上搜索代码时,你会发现所有这些术语被交替使用来描述相同的开发 Web 应用的模式。正确的命名很重要,因为它使你的代码对外部观众更容易理解。流行词汇听起来不错,听起来很好听,但使用的流行词汇越多,你的代码库理解起来就越困难。
在本章中,我们将使用“服务器端渲染”一词来表示在将其提供给用户之前渲染 HTML 的代码。我们将使用“客户端渲染”一词来表示将 HTML 的渲染推迟到用户设备的代码。最后,我们将使用“共享代码”一词来描述在服务器和客户端都可以互换使用的代码。
开发服务器端渲染应用
在 ReactJS 中开发共享应用比仅仅构建客户端渲染应用需要更多的工作。它还要求你考虑你的数据流需求。
ReactJS 中编写服务器端渲染应用有两个组成部分,可以将其视为一个等式:
在服务器实例中预渲染组件 + 从服务器到组件的单向数据流 = 好的应用程序和快乐的用户
在本节中,我们将查看等式的第一部分。我们将在本章的最后部分解决数据流问题。
添加包
我们需要从 npm 获取更多包以将它们添加到依赖项部分。这是我们需要的依赖项列表:
"devDependencies": {
"react-transform-catch-errors": "¹.0.1",
"react-transform-hmr": "¹.0.1",
"redbox-react": "¹.2.0",
"webpack-dev-middleware": "¹.4.0",
"webpack-hot-middleware": "².6.0",
"babel-cli": "⁶.4.5",
"babel-core": "⁶.3.26",
"babel-loader": "⁶.2.0",
"babel-plugin-react-transform": "².0.0",
"babel-preset-es2015": "⁶.3.13",
"babel-preset-react": "⁶.3.13",
"compression": "¹.6.1",
"cp-file": "³.1.0",
"cross-env": "¹.0.7",
"exenv": "¹.2.0",
"webpack": "¹.12.9"
},
"dependencies": {
"express": "⁴.13.3",
"express-error-handler": "¹.0.1",
"path": "⁰.12.7",
"react": "¹⁵.1.0",
"react-bootstrap": "⁰.29.4",
"react-breadcrumbs": "¹.3.5",
"react-dom": "¹⁵.1.0",
"react-dom-stream": "⁰.5.1",
"react-router": "².4.1",
"rimraf": "².5.1",
"serve-static": "¹.11.0"
}
将任何缺少的包添加到 package.json 并通过执行 npm install 来更新它。
添加 CSS
我们需要为我们的页面添加样式,所以我们将使用我们在 第七章 开发 Reactagram 时使用的子集,Reactagram。将 assets/app.css 的内容替换为以下内容:
body { font-family: 'Bitter', serif; padding: 15px;
margin-top: 50px; padding-bottom: 50px }
.header { padding: 10px; font-size: 18px; margin: 5px; }
footer{ position:fixed; bottom:0; background: black; width:100%;
padding:10px; color:white; left:0; text-align:center; }
h1 { font-size: 24px; }
h2 { font-size: 20px; }
h3 { font-size: 17px; }
ul { padding:0; list-style-type: none; }
.nav a:visited, .nav a:link { color: #999; }
.nav a:hover { color: #fff; text-decoration: none; }
.logo { padding-top: 16px; margin: 0 auto; text-align: center; }
#calculator{ min-width:240px; }
.calc { margin:3px; width:50px; }
.calc.wide { width:163px; }
.calcInput { width: 221px; }
将 Bootstrap CDN 添加到 index.html
由于我们添加了 react-bootstrap,因此我们需要添加 Bootstrap CDN 文件。打开 assets/index.html 并将其替换为以下内容:
<!DOCTYPE html>
<html>
<head>
<title>Shared App</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,
initial-scale=1, maximum-scale=1, user-scalable=no">
<link async rel="stylesheet" type="text/css"
href="//maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">
<link async rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
<link async href='https://fonts.googleapis.com/css?family=Bitter'
rel='stylesheet' type='text/css'>
<link async rel="stylesheet" href="/app.css">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div id="app"></div>
<script src="img/bundle.js"></script>
</body>
</html>
创建组件
我们不能没有一些内容就制作应用程序,所以让我们添加一些页面和路由层次结构。首先,从 source 文件夹中删除 index.jsx,从 assets 文件夹中删除 index-production.html。完成这一部分章节后,树结构将如下所示:
├── .babelrc
├── assets
│ ├── app.css
│ ├── favicon.ico
│ └── index.html
├── config.js
├── package.json
├── server-development.js
├── server-production.es6
├── server-production.js
├── source
│ ├── client
│ │ └── index.jsx
│ ├── routes
│ │ └── index.jsx
│ ├── server
│ │ └── index.js
│ └── shared
│ ├── components
│ │ ├── back.jsx
│ │ └── fontawesome.jsx
│ ├── settings.js
│ └── views
│ ├── about.jsx
│ ├── app.jsx
│ ├── calculator.jsx
│ ├── error.jsx
│ └── layout.jsx
├── Webpack-development.config.js
└── Webpack-production.config.js
我们需要勤奋地构建我们的应用程序,以便使其易于理解,并注意到客户端和服务器端渲染如何相互配合。
让我们先添加 client/index.jsx 的源代码:
import React from 'react';
import { render } from 'react-dom';
import { Router, browserHistory } from 'react-router';
import { routes } from '../routes';
render(
<Router routes={routes} history={ browserHistory } />,
document.getElementById('app')
);
到目前为止,代码结构应该非常熟悉。
让我们添加我们的路由。创建 routes/index.jsx 并添加以下代码:
'use strict';
import React from 'react';
import { Router, Route, IndexRoute }
from 'react-router'
import App from '../shared/views/app';
import Error from '../shared/views/error';
import Layout from '../shared/views/layout';
import About from '../shared/views/about';
import Calculator from '../shared/views/calculator';
const routes= <Route path="/" name="Shared App" component={Layout} >
<Route name="About" path="about" component={About} />
<Route name="Calculator" path="calculator"
component={Calculator} />
<IndexRoute name="Welcome" component={App} />
<Route path="*" name="Error" component={Error} />
</Route>
export { routes };
路由将响应 /、/about 和 /calculator,其他所有内容都将路由到 error 组件。如果你访问应用程序而没有指定路由(例如,http://localhost:8080 没有结束斜杠),IndexRoute 函数将应用程序路由到 Welcome 组件。
路由被分配到我们将要创建的几个基本视图。
创建 shared/views/layout.jsx 并添加以下代码:
import React from 'react'
import { Grid, Row, Col, Nav, Navbar } from 'react-bootstrap';
import Breadcrumbs from 'react-breadcrumbs';
import Settings from '../settings';
export default class Layout extends React.Component {
render() {
return (
<Grid>
<Navbar componentClass="header"
fixedTop inverse>
<h1 center style={{color:"#fff"}} className="logo">
{Settings.title}
我们将从 Settings 组件中获取标题。以下组件将创建一个你可以用作导航元素的链接路径:
</h1>
<Nav role="navigation" eventKey={0}
pullRight>
</Nav>
</Navbar>
<Breadcrumbs {...this.props} setDocumentTitle={true} />
参数 setDocumentTitle 是一个将使组件更改窗口标签页的文档标题为你在的 child 组件名称的参数,让我们放置以下代码:
{this.props.children}
<footer>
Server-rendered Shared App
</footer>
</Grid>
)
}
}
创建 shared/views/app.jsx 并添加以下代码:
'use strict';
import React from 'react'
import { Row, Col } from 'react-bootstrap';
import { Link } from 'react-router'
export default class Index extends React.Component {
render() {
return (
<Row>
<Col md={6}>
<h2>Welcome to my server-rendered app</h2>
<h3>Check out these links</h3>
<ul>
<li><Link to="/calculator">Calculator</Link></li>
<li><Link to="/about">About</Link></li>
</ul>
</Col>
</Row>
)
}
}
此组件创建了一个包含两个链接的简单列表。第一个链接到 About 组件,第二个链接到 Calculator 组件。
创建 shared/views/error.jsx 并添加以下代码:
'use strict';
import React from 'react'
import { Grid, Row, Col } from 'react-bootstrap';
export default class Error extends React.Component {
render() {
return (
<Grid>
<Row>
<Col md={6}>
<h1>Error!</h1>
</Col>
</Row>
{this.props.children}
</Grid>
)
}
}
如果你在浏览器的 URL 定位器中手动输入错误路径,此组件将显示出来。
创建 shared/views/about.jsx 并添加以下代码:
'use strict';
import React from 'react'
import { Row, Col } from 'react-bootstrap';
export default class About extends React.Component {
render() {
return (
<Row>
<Col md={6}>
<h2>About</h2>
<p>
This app is designed to work as either a client- or
a server-rendered app. It's also designed to be
deployed to the cloud.
</p>
</Col>
</Row>
)
}
}
About 组件是我们应用中的一个简单占位符组件。你可以用它来展示一些关于你应用的信息。
创建 shared/views/calculator.jsx 并添加以下代码:
'use strict';
import React from 'react'
import { Row, Col, Button, Input, FormGroup, FormControl, InputGroup } from 'react-bootstrap';
export default class Calculator extends React.Component {
constructor() {
super();
this.state={};
this.state._input=0;
this.state.__prev=0;
this.state._toZero=false;
this.state._symbol=null;
}
当使用 ES6 类时,getInitialState 元素已被弃用,因此我们需要在构造函数中设置初始状态。我们可以通过首先将一个空的 state 变量附加到 this 上来完成此操作。然后,我们添加三个状态:_input 是计算器输入文本框,_prev 用于保存要计算的数字,_toZero 是一个在计算时用于将输入置零的标志,_symbol 是数学运算符号(加、减、除和乘),让我们看看以下代码:
handlePercentage(){
this.setState({_input:this.state._input/100, _toZero:true})
}
handleClear(){
this.setState({_input:"0"})
}
handlePlusMinus(e){
this.setState({_input:this.state._input>0 ?
-this.state._input:Math.abs(this.state._input)});
}
这三个函数直接修改输入的数字。让我们继续到下一个函数:
handleCalculate(e) {
const value = this.refs.calcInput.props.value;
if(this.state._symbol) {
switch(this.state._symbol) {
case "+":
this.setState({_input:(Number(this.state._prev)||0)
+Number(value),_symbol:null});
break;
case "-":
this.setState({_input:(Number(this.state._prev)||0)
-Number(value),_symbol:null});
break;
case "/":
this.setState({_input:(Number(this.state._prev)||0)
/Number(value),_symbol:null});
break;
case "*":
this.setState({_input:(Number(this.state._prev)||0)
*Number(value),_symbol:null});
break;
}
}
}
当你按下 计算 按钮(=)时,此函数会被调用。它将检查用户是否激活了数学符号,如果是,它将检查哪个符号是激活的,并在存储的数字和当前数字上执行计算,让我们看看以下代码片段:
handleClick(e) {
let input=Number(this.state._input)||"";
if(this.state._toZero) {
this.setState({_toZero: false});
input="";
}
如果输入的数字需要变为零,这个操作将完成这个任务并重置 _toZero 标志。现在我们转到 isNaN:
if(isNaN(e.target.value)) {
this.setState({_toZero:true,
_prev:this.state._input,
_symbol:e.target.value
})
使用 isNaN 是检查变量是否为数字的高效方法。如果不是,它是一个数学符号,我们通过将符号存储在状态中,要求输入的数字变为零(这样我们不会计算错误的数字),并将当前输入设置为 _prev 值(用于计算),让我们看看以下代码片段:
} else {
this.setState({_input:input+e.target.value})
如果它是一个数字,我们将其添加到 _input 状态中,让我们看看以下代码片段:
}
}
handleChange(e) {
this.setState({_input:e.target.value})
}
calc() {
return (
<div id="calculator">
<Col md={12}>
<FormGroup>
<InputGroup className="calcInput" >
<FormControl
ref="calcInput"
onChange={ this.handleChange.bind(this) }
value={this.state._input}
type="text" />
</InputGroup>
</FormGroup>
<Input type="text" className="calcInput"
ref="calcInput" defaultValue="0"
onChange={this.handleChange.bind(this)}
value={this.state._input}/>
当使用 React.createClass 时,所有函数都会自动绑定到组件上。由于我们正在使用 ES6 类,我们需要手动绑定我们的函数,让我们看看以下代码片段:
<Button className="calc"
onClick={this.handleClear.bind(this)}>C</Button>
<Button className="calc"
onClick={this.handlePlusMinus.bind(this)}>
{String.fromCharCode(177)}</Button>
<Button className="calc"
onClick={this.handlePercentage.bind(this)}>%</Button>
<Button className="calc" value="/"
onClick={this.handleClick.bind(this)}>
{String.fromCharCode(247)}</Button>
一些字符在标准键盘上难以定位。相反,我们可以使用 Unicode 字符代码来渲染它。字符代码及其相应图像的列表在互联网上很容易找到,让我们看看以下代码片段:
<br/>
<Button className="calc" value="7"
onClick={this.handleClick.bind(this)}>7</Button>
<Button className="calc" value="8"
onClick={this.handleClick.bind(this)}>8</Button>
<Button className="calc" value="9"
onClick={this.handleClick.bind(this)}>9</Button>
<Button className="calc" value="*"
onClick={this.handleClick.bind(this)}>
{String.fromCharCode(215)}</Button>
<br/>
<Button className="calc" value="4"
onClick={this.handleClick.bind(this)}>4</Button>
<Button className="calc" value="5"
onClick={this.handleClick.bind(this)}>5</Button>
<Button className="calc" value="6"
onClick={this.handleClick.bind(this)}>6</Button>
<Button className="calc" value="-"
onClick={this.handleClick.bind(this)}>-</Button>
<br/>
<Button className="calc" value="1"
onClick={this.handleClick.bind(this)}>1</Button>
<Button className="calc" value="2"
onClick={this.handleClick.bind(this)}>2</Button>
<Button className="calc" value="3"
onClick={this.handleClick.bind(this)}>3</Button>
<Button className="calc" value="+"
onClick={this.handleClick.bind(this)}>+</Button>
<br/>
<Button className="calc wide" value="0"
onClick={this.handleClick.bind(this)}>0</Button>
<Button className="calc"
onClick={this.handleCalculate.bind(this)}>=</Button>
</Col>
</div>
)
}
render() {
return (
<Row>
<Col md={12}>
<h2>Calculator</h2>
{this.calc()}
</Col>
</Row>
)
}
}
以下截图显示了刚刚创建的 计算器 页面:

接下来,添加两个文件:config.js 到 root 文件夹,settings.js 到 source/shared。
将此代码添加到 config.js:
'use strict';
const config = {
home: __dirname
};
module.exports = config;
然后,将此代码添加到 settings.js:
'use strict';
import config from '../../config.js';
const settings = Object.assign({}, config, {
title: 'Shared App'
});
module.exports = settings;
设置服务器端渲染的 Express React 服务器
我们现在已经完成了共享组件的制作,所以现在是时候设置服务器端渲染了。在前面的文件结构中,你可能已经注意到我们添加了一个名为 server-production.es6 的文件。我们将保留普通的 ES5 版本,但为了简化我们的代码,我们将使用现代 JavaScript 编写它,并使用 Babel 将其转换为 ES5。
使用 Babel 是我们不得不忍受的事情,直到 node 实现对 ES6/ECMAScript 2015 的完全支持。我们可以选择使用 babel-node 来运行我们的 express 服务器,但在生产环境中不建议这样做,因为它会给每个请求增加显著的开销。
让我们看看它应该是什么样子。创建 server-production.es6 并添加以下代码:
'use strict';
import path from 'path';
import express from 'express';
import compression from 'compression';
import cpFile from 'cp-file';
import errorHandler from 'express-error-handler';
import envs from 'envs';
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, match, RoutingContext } from 'react-router';
import { routes } from './build/routes';
我们将在 Express 服务器中使用客户端路由。我们将设置一个通配符 Express 路由,并在其中实现 react-router 路由,让我们看看以下代码片段:
import settings from './build/shared/settings';
import ReactDOMStream from 'react-dom-stream/server';
我们还将实现一个流式 DOM 工具,而不是使用 React 自身的 renderToString。renderToString 方法是同步的,在 React 网站的服务器端渲染中可能会成为性能瓶颈。流使这个过程变得更快,因为您在发送之前不需要预先渲染整个字符串。对于较大的页面,renderToString 可能会引入数百毫秒的延迟,并需要更多的内存,因为它需要为整个字符串分配内存。
ReactDOMStream 异步渲染到流中,并允许在响应完全完成之前,浏览器先渲染页面。请参考以下代码:
import serveStatic from 'serve-static';
const port = process.env.PORT || 8080;
const host = process.env.HOST || '0.0.0.0';
const app = express();
const http = require('http');
app.set('environment', envs('NODE_ENV', process.env.NODE_ENV || 'production'));
app.set('port', port);
app.use(compression());
cpFile('assets/app.css', 'public/assets/app.css').then(function() {
console.log('Copied app.css');
});
app.use(serveStatic(path.join(__dirname, 'public', 'assets')));
const appRoutes = (app) => {
app.get('*', (req, res) => {
match({ routes, location: req.url },
(err, redirectLocation, props) => {
if (err) {
res.status(500).send(err.message);
}
else if (redirectLocation) {
res.redirect(302,
redirectLocation.pathname + redirectLocation.search);
在进行服务器端渲染时,我们需要为错误发送 500 响应,为重定向发送 302 响应。我们可以通过匹配和检查响应状态来实现这一点。如果没有错误,我们继续进行渲染,让我们看看以下代码片段:
} else if (props) {
res.write(`<!DOCTYPE html>
<html>
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible"
content="IE=edge" />
<meta name="viewport" content="width=device-width,
initial-scale=1, maximum-scale=1, user-scalable=no"/>
<link rel="preload" as="stylesheet" type="text/css"
href="//maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css"/>
<link rel="preload" as="stylesheet" type="text/css"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
<link rel="preload" as="stylesheet" href='https://fonts.googleapis.com/css?family=Bitter' type='text/css'/>
<link rel="preload" as="stylesheet" href="/app.css" />
<title>${settings.title}</title>
</head>
<body><div id="app">`);
const stream = ReactDOMStream.renderToString(
React.createElement(RoutingContext, props));
stream.pipe(res, {end: false});
stream.on("end", ()=> {
res.write(`</div><script
src="img/bundle.js"></script></body></html>`);
res.end();
});''
}
else {
res.sendStatus(404);
最后,如果我们找不到任何属性或路由,我们需要发送一个 404 状态码,让我们看看剩余的代码:
}
});
});
}
当服务器开始渲染时,它将首先写入我们的头部信息。它将异步加载初始 CSS 文件,设置标题和主体,以及第一个 div。然后,我们切换到 ReactDOMStream,它将从 RoutingContext 开始渲染我们的应用。当流完成时,我们通过包裹我们的 div 和 HTML 页面来关闭响应。服务器渲染的内容现在位于 <div id="app"></div> 内。当 bundle.js 被加载时,它将接管并替换这个 div 的内容,除非渲染的设备不支持 JavaScript。
注意,尽管 CSS 文件是异步的,但它们仍然会在加载之前阻塞渲染。可以通过内联 CSS 来绕过这个问题,让我们看看以下代码片段:
const router = express.Router();
appRoutes(router);
app.use(router);
const server = http.createServer(app);
app.use((err, req, res, next) => {
console.log(err);
next(err);
});
app.use( errorHandler({server: server}) );
app.listen(port, host, () => {
console.log('Server started for '+settings.title+' at http://'+host+':'+port);
});
最后的部分与之前相同,只是修改为使用新的 JavaScript 语法。你注意到的一件事是我们从名为 build 的新文件夹中导入源组件,而不是 source。在开发时,我们可以通过在运行时使用 Babel 将源代码转换为 ES5 来避免这个问题;然而,在生产环境中这不可行。相反,我们需要手动将整个源代码转换为 ES5。
首先,让我们在 webpack.config.dev.js 中更改两行,并验证它是否在本地构建。
打开文件,将入口处的行 ./source/index 替换为 ./source/client/index,并将路径 path.join(__dirname, 'public', 'assets') 替换为 path.join(__dirname, 'assets')。然后,通过执行 npm run dev 来运行项目。
以下截图显示了应用的主页:

你的应用现在应该没有问题地运行,并且 http://localhost:8080 应该现在显示 共享应用 屏幕。你应该能够编辑 source 文件夹中的代码,并看到它在屏幕上实时更新。你也应该能够点击链接并使用计算器执行数学运算。
设置 Webpack 用于服务器渲染
打开 Webpack-production.config.js 并将其内容替换为以下内容:
'use strict';
var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: [
'./build/client/index'
],
output: {
path: path.join(__dirname, 'public', 'assets'),
filename: 'bundle.js',
publicPath: '/assets/'
},
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify('production')
}
}),
new webpack.optimize.UglifyJsPlugin({
compressor: {
warnings: false
}
})
]
};
我们不会依赖 Babel 在运行时转换我们的代码,因此我们可以删除 module 和 resolve 部分。
设置用于服务器渲染的 npm 脚本
打开 package.json 并将 scripts 部分替换为以下内容:
"scripts": {
"test": "echo \"Error: no test specified\"",
"convert": "babel server-production.es6 > server-production.js",
"prestart": "npm run build",
"start": "npm run convert",
"poststart": "cross-env NODE_ENV=production node server-production.js",
"dev": "cross-env NODE_ENV=development node server-development.js",
"prebuild": "rimraf public && rimraf build && NODE_ENV=production babel source/ --out-dir build",
"build": "cross-env NODE_ENV=production webpack --progress --config Webpack-production.config.js",
"predeploy": "npm run build",
"deploy": "npm run convert",
"postdeploy": "echo Ready to deploy. Commit your changes and run git push heroku master"
},
这里是一个运行 npm start 命令会执行的操作:
-
运行构建(在它开始之前)。
-
删除
public和build文件夹,并将 ES2015 源代码转换为 ES5,然后放入builder文件夹(预构建过程)。 -
运行 Webpack 并在
public/assets中创建一个 bundle(构建过程)。 -
运行转换,目的是将
server-production.es6转换为server-production.js(当它启动时)。 -
运行 Express 服务器(在它启动之后)。
呼呼!这是一个庞大的命令链。编译完成后,服务器启动,你可以访问 http://localhost:8080 来测试你的预渲染服务器。你一开始可能甚至不会注意到任何区别,但尝试在浏览器中关闭 JavaScript 并执行刷新操作。页面仍然会加载,你仍然可以导航。然而,计算器将无法工作,因为它需要客户端 JavaScript 才能运行。正如之前所述,目标不是支持无 JavaScript 的浏览器(因为它们很少见)。目标是提供预渲染的页面,这正是它所做到的。
我们还更改了 npm deploy。这是它所做的工作:
-
在部署之前运行构建。
-
运行 convert,将
server-production.es6转换为server-production.js(一旦部署)。 -
它会通知你已完成。这一步可以被替换为将其部署到云端(在部署后)。
注意
服务器端渲染的应用现在已经完成。你可以在 reactjsblueprints-srvdeploy.herokuapp.com/ 找到演示。
将 Redux 添加到你的服务器端渲染应用中
最后一部分是处理数据流。在客户端渲染的应用中,数据流通常是这样处理的:用户通过访问应用的主页来启动一个动作。然后应用路由到视图,渲染过程开始。在渲染后或渲染期间(异步),数据被获取并显示给用户。
在服务器端渲染的应用中,需要在渲染过程开始之前预先获取数据。获取数据的责任从客户端转移到服务器。这需要我们对如何构建我们的应用进行彻底的重新思考。在开始设计你的应用之前做出这个决定是很重要的,因为在你开始实现应用之后改变你的数据流架构是一项成本高昂的操作。
添加包
我们需要向我们的项目中添加许多新包。package.json 文件现在应该看起来像这样:
"babel-polyfill": "⁶.3.14",
"body-parser": "¹.14.2",
"isomorphic-fetch": "².2.1",
"react-redux": "⁴.2.1",
"redux": "³.2.1",
"redux-logger": "².5.0",
"redux-thunk": "¹.0.3",
我们将执行类似于第六章中所述的异构获取,因此我们需要添加 isomorphic-fetch 库。这个库将 fetch 添加为一个全局函数,使其 API 在客户端和服务器之间保持一致。
我们还将添加 Redux 和一个控制台记录器,而不是我们在第六章中使用的 devtools 记录器。
添加文件
我们将向我们的项目中添加许多新文件,并更改一些现有文件。我们将实现的功能是从一个离线新闻 API 异步获取一组新闻条目,该 API 位于 reactjsblueprints-newsapi.herokuapp.com/stories。它提供了一系列定期更新的新闻故事。
我们将从 client/index.jsx 开始。打开这个文件,并用以下代码替换其内容:
'use strict';
import 'babel-polyfill'
import React from 'react';
import ReactDOM from 'react-dom';
import { Router, browserHistory } from 'react-router';
import { Provider } from 'react-redux';
import { routes } from '../routes';
import configureStore from '../shared/store/configureStore';
const initialState = window.__INITIAL_STATE__;
const store = configureStore(initialState);
ReactDOM.render(
<Provider store={store}>
<Router routes={routes} history={ browserHistory } />
</Provider>,
document.getElementById('app')
)
在这里,我们添加 polyfill 和与 第六章 中相同的 Redux 设置,高级 React。我们还添加了对 window.__INITIAL_STATE__ 的检查,这是我们如何将服务器渲染的内容传输到我们的应用程序。
接下来,打开 routes/index.jsx 并用以下代码替换其内容:
import React from 'react';
import { Router, Route, IndexRoute } from 'react-router'
import App from '../shared/views/app';
import Error from '../shared/views/error';
import Layout from '../shared/views/layout';
import About from '../shared/views/about';
import Calculator from '../shared/views/calculator';
import News from '../shared/views/news';
import { connect } from 'react-redux';
import { fetchPostsIfNeeded } from '../shared/actions';
import { bindActionCreators } from 'redux';
function mapStateToProps(state) {
return {
receivePosts: {
posts: ('posts' in state) ? state.posts : [],
isFetching: ('isFetching' in state)
? state.isFetching : true,
lastUpdated: ('lastUpdated' in state)
? state.lastUpdated : null
}
}
}
function mapFncsToProps(dispatch) {
return { fetchPostsIfNeeded, dispatch }
}
这些函数负责将状态和函数传递给我们的 child 组件。我们将使用它们将新闻故事传递给 News 组件以及 dispatch 和 fetchPostsIfNeeded 函数。接下来,在 shared 中添加一个新的文件夹,命名为 actions:
const routes= <Route path="/"
name="Shared App" component={ Layout } >
<Route name="About"
path="about" component={ About } />
<Route name="Calculator"
path="calculator" component={ Calculator } />
<Route name="News" path="news" component={
connect(mapStateToProps, mapFncsToProps)(News) } />
<IndexRoute name="Welcome" component={ App } />
<Route path="*" name="Error" component={ Error } />
</Route>
export { routes };
在这个文件夹中,添加一个名为 index.js 的文件,并添加以下代码:
'use strict';
import { fetchPostsAsync } from '../api/fetch-posts';
export const RECEIVE_POSTS = 'RECEIVE_POSTS';
export function fetchPostsIfNeeded() {
return (dispatch, getState) => {
if(getState().receivePosts && getState().receivePosts.length {
let json=(getState().receivePosts.posts);
return dispatch(receivePosts(json));
}
else return dispatch(fetchPosts());
}
}
这个函数将检查存储的状态是否存在并且有内容;如果没有,它将调用 fetchPosts() 的调用。这将确保我们能够利用服务器渲染的状态,并在客户端没有这样的状态时获取内容。参考以下代码中的下一个函数:
export function fetchPosts() {
return dispatch => {
return fetchPostsAsync(json => dispatch(receivePosts(json)));
}
}
这个函数从我们的 API 文件中返回一个 fetch 操作。它调用 receivePosts() 函数,这是 Redux 函数,告诉 Redux 存储调用 RECEIVE_POSTS 减法器函数。让我们看看下面的代码片段:
export function receivePosts(json) {
const posts = {
type: RECEIVE_POSTS,
posts: json,
lastUpdated: Date.now()
};
return posts;
}
下一个我们将添加的文件是 fetch-posts.js。在 shared 中创建一个名为 api 的文件夹,然后添加文件,并添加以下代码:
'use strict';
import fetch from 'isomorphic-fetch'
export function fetchPostsAsync(callback) {
return fetch(`https://reactjsblueprints-newsapi.herokuapp.com/stories`)
.then(response => response.json())
.then(data => callback(data))
}
这个函数简单地使用 fetch API 返回一组故事。
接下来,在 shared 中添加一个名为 reducers 的文件夹,然后添加 index.js 文件,并添加以下代码:
'use strict';
import {
RECEIVE_POSTS
} from '../actions'
function receivePosts(state = { }, action) {
switch (action.type) {
case RECEIVE_POSTS:
return Object.assign({}, state, {
isFetching: false,
posts: action.posts,
lastUpdated: action.lastUpdated
})
default:
return state
}
}
export default receivePosts;
我们的减法器获取新的状态,并返回一个新的对象,其中包含我们获取的帖子集合。
接下来,在 shared 中创建一个名为 store 的文件夹,添加一个文件并命名为 configure-store.js,然后添加以下内容:
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import createLogger from 'redux-logger'
import rootReducer from '../reducers'
export default function configureStore(initialState) {
const store = createStore(
rootReducer,
initialState,
applyMiddleware(thunkMiddleware, createLogger())
)
return store
}
我们创建一个函数,它接受 initialState 并返回一个包含我们的减法器和添加异步操作和日志中间件的存储。日志显示在浏览器控制台窗口中的日志数据。
最后两个文件应该放在 views 文件夹中。第一个是 news.jsx。为此,添加以下代码:
'use strict';
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import Posts from './posts';
class App extends Component {
constructor(props) {
super(props)
this.state={};
this.state._activePost=-1;
}
我们通过将 _activePost 设置为 -1 来初始化状态。这将防止在用户有时间点击任何帖子之前,组件显示任何帖子的正文。参考以下内容:
componentDidMount() {
const { fetchPostsIfNeeded, dispatch } = this.props
dispatch(fetchPostsIfNeeded())
}
handleClickCallback(i) {
this.setState({_activePost:i});
}
这是 posts.jsx 中的回调处理函数。当用户点击新闻标题时,将设置一个新的状态,包含新闻项的 ID,让我们看看下面的代码片段:
render() {
const { posts, isFetching, lastUpdated } =
this.props.receivePosts
const { _activePost } = this.state;
return (
<div>
<p>
{lastUpdated &&
<span>
Last updated at {new Date(lastUpdated)
.toLocaleTimeString()}.
</span>
}
</p>
{posts && isFetching && posts.length === 0 &&
<h2>Loading...</h2>
}
{posts && !isFetching && posts.length === 0 &&
<h2>Empty.</h2>
}
{posts && posts.length > 0 &&
<div style={{ opacity: isFetching ? 0.5 : 1 }}>
<Posts posts={posts} activePost={_activePost}
onClickHandler={this.handleClickCallback.bind(this)} />
</div>
}
Posts 组件将获得一组帖子、一个活动帖子和一个 onClick 处理器。onClick 处理器需要绑定 App 上下文,否则它将无法使用内部方法,例如 setState。如果我们不绑定它,setState 将应用于 Posts 组件的上下文,让我们看看下面的代码片段:
</div>
)
}
}
App.propTypes = {
receivePosts: React.PropTypes.shape({
posts: PropTypes.array.isRequired,
isFetching: PropTypes.bool.isRequired,
lastUpdated: PropTypes.number
}),
dispatch: PropTypes.func.isRequired,
fetchPostsIfNeeded: PropTypes.func.isRequired
}
我们将使用propTypes,这样 React 开发者工具就可以告诉我们是否有任何传入的 props 缺失或类型错误:
function mapStateToProps(state) {
return {
receivePosts: {
posts: ('posts' in state) ? state.posts : [],
isFetching: ('isFetching' in state) ?
state.isFetching : true,
lastUpdated: ('lastUpdated' in state) ?
state.lastUpdated : null
}
}
}
我们导出应用状态,以便当前导入的组件可以使用:
export default connect(mapStateToProps)(App)
我们添加到views的第二个文件是posts.jsx:
'use strict';
import React, { PropTypes, Component } from 'react'
export default class Posts extends Component {
render() {
function createmarkup(html) { return {__html: html}; };
return (
<ul>
{this.props.posts.map((post, i) =>
<li key={i}>
<a onClick={this.props.onClickHandler.bind(this,i)}>
{post.title}</a>
{this.props.activePost===i ?
<div style={{marginBottom: 15}}
dangerouslySetInnerHTML= {createmarkup(post.body)} />:
<div/>
}
RSS 正文自带 HTML。我们必须明确允许渲染此 HTML,否则 ReactJS 将转义内容。当用户点击标题时,posts.jsx中的回调将执行handleClickCallback。它将在news.jsx中设置一个新的状态,并将此状态作为 prop 传递给posts.jsx,表示应该显示此标题的内容,让我们看看以下代码片段:
</li>
)}
</ul>
)
}
}
Posts.propTypes = {
posts: PropTypes.array.isRequired,
activePost: PropTypes.number.isRequired
}
我们还需要在app.jsx文件中添加新闻项目的链接。打开文件并添加以下行:
<li><Link to="/news">News</Link></li>
通过这些更改,您就可以运行您的应用了。使用npm run dev启动它。您应该能够访问http://localhost:8080上的首页,并点击新闻链接。它应该显示加载中,直到从服务器获取内容。以下是一个说明这一点的截图:

截图显示,即使在浏览器中阻止了 JavaScript,新闻数据也被加载并显示。
添加服务器端渲染
现在我们已经很接近了,但在完成之前我们还需要做一些工作。我们需要在我们的 Express 服务器中添加数据获取。让我们打开server-production.es6并添加必要的代码以预取数据。
在文件顶部添加以下导入:
import { Provider } from 'react-redux'
import configureStore from './build/shared/store/configure-store'
import { fetchPostsAsync } from './build/shared/api/fetch-posts'
然后,将const approutes替换为以下代码:
const appRoutes = (app) => {
app.get('*', (req, res) => {
match({ routes, location: req.url },
(err, redirectLocation, props) => {
if (err) {
res.status(500).send(err.message);
}
else if (redirectLocation) {
res.redirect(302, redirectLocation.pathname +
redirectLocation.search);
}
else if (props) {
fetchPostsAsync(posts => {
const isFetching = false;
const lastUpdated = Date.now()
const initialState = {
posts,
isFetching,
lastUpdated
}
const store = configureStore(initialState)
在这里,我们启动了fetchPostsAsync函数。当我们收到结果时,我们使用新闻项目创建一个初始状态,然后使用这个状态创建一个新的 Redux 存储实例,让我们看看以下代码片段:
res.write(`<!DOCTYPE html>
<html>
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,
initial-scale=1, maximum-scale=1, user-scalable=no"/>
<link async rel="stylesheet" type="text/css"
href="//maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css"/>
<link async rel="stylesheet" type="text/css"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
<link async href='https://fonts.googleapis.com/css?family=Bitter'
rel='stylesheet' type='text/css'/>
<link async rel="stylesheet" href="/app.css" />
<title>${settings.title}</title>
</head>
<script>
window.__INITIAL_STATE__ =
${JSON.stringify(initialState)}
</script>
我们将初始状态添加到全局 window 中,这样我们就可以在client/index.jsx中获取它,让我们看看剩余的代码:
<body><div id="app">`);
const stream = ReactDOMStream.renderToString(
<Provider store={store}>
<RoutingContext {...props} />
</Provider>);
stream.pipe(res, {end: false});
stream.on("end", ()=> {
res.write(`</div><script src="img/bundle.js"></script></body></html>`);
res.end();
});''
})
} else {
res.sendStatus(404);
}
});
});
}
预取数据所需的所有内容就是这些。现在您应该能够执行npm start,然后打开http://localhost:8080。尝试在浏览器中关闭 JavaScript,您仍然可以导航并查看新闻列表中的项目。
您还可以创建一个新的 Heroku 实例,运行npm deploy,然后推送它。
注意
您可以在网上查看一个演示reactjsblueprints-shared.herokuapp.com。
执行更快的云部署
当您现在推送到 Heroku 时,会发生的情况是 Heroku 将执行npm start,启动整个构建过程。这是有问题的,因为如果构建过程耗时过长或资源需求过高,它将失败。
您可以通过将build文件夹提交到您的仓库,然后在推送时简单地执行node server-production.js来防止这种情况。您可以通过向仓库添加一个特殊的启动文件Procfile来实现这一点。在项目根目录中创建此文件,并添加以下行:
web: NODE_ENV=production node server-production.js
注意
注意,这个文件是针对 Heroku 的。其他云服务提供商可能有一个不同的系统来指定启动程序。
最终结构
这就是我们的最终应用程序结构看起来像什么(不包括build文件夹,它基本上与source文件夹相同):
├── .babelrc
├── Procfile
├── assets
│ ├── app.css
│ ├── favicon.ico
│ └── index.html
├── config.js
├── package.json
├── server-development.js
├── server-production.es6
├── server-production.js
├── source
│ ├── client
│ │ └── index.jsx
│ ├── routes
│ │ └── index.jsx
│ └── shared
│ ├── actions
│ │ └── index.js
│ ├── api
│ │ └── fetch-posts.js
│ ├── reducers
│ │ └── index.js
│ ├── settings.js
│ ├── store
│ │ └── configure-store.js
│ └── views
│ ├── about.jsx
│ ├── app.jsx
│ ├── calculator.jsx
│ ├── error.jsx
│ ├── layout.jsx
│ ├── news.jsx
│ └── posts.jsx
├── Webpack-development.config.js
└── Webpack-production.config.js
服务器结构基本上保持不变,source文件夹是唯一一个不断增长的文件夹。这正是它应该的样子。
在开发应用程序时,值得查看结构。它提供了一个宏观视角,可以帮助你发现命名和其他结构问题的不一致性。例如,组件layout.jsx真的属于视图吗?posts.jsx又如何?它是一个视图组件,但可以争论说它是news.jsx的辅助工具,可能属于其他地方。
摘要
在本章中,我们修改了我们的 Webpack 脚手架以启用云部署。在章节的第二部分,我们添加了服务器渲染,在第三部分,我们添加了 Redux 和数据的异步预取。
通过这三个项目,你应该能够制作出任何类型的应用程序,无论大小。然而,正如你可能已经注意到的,编写支持服务器渲染的应用程序需要相当多的思考和规划。随着应用程序规模的增加,推理组织和数据获取策略变得更加困难。你将能够使用这种策略制作出非常高效的应用程序,但我建议你花时间思考如何构建你的应用程序。
本章的演示可在reactjsblueprints-srvdeploy.herokuapp.com/和reactjsblueprints-shared.herokuapp.com找到。第一个链接展示了添加服务器渲染后的应用程序状态。第二个链接展示了最终的应用程序,我们在服务器端获取数据并在向用户渲染应用程序之前填充 Redux 存储。
在下一章中,我们将使用 ReactJS 创建一个游戏。我们将使用 HTML5 canvas 技术,并添加 Flowtype 进行静态类型检查。
第十章. 制作游戏
在本章的最后,我们将创建迄今为止最复杂的蓝图。我们将创建一个游戏引擎和一个单屏动作游戏。当你完成本章后,你会理解为什么使用 ReactJS 进行开发经常被比作开发游戏。当我们用 HTML5 制作游戏时,我们使用 canvas。在 canvas 上绘制与我们在 ReactJS 中渲染浏览器的方式非常相似。我们持续更新 canvas,丢弃之前的内容,并立即渲染新内容。
我们将制作一个动作游戏,玩家将扮演一个可玩的角色,面对一群怪物,同时野餐。装备了火球法术的玩家必须击败所有敌人,才能放松并享受他的午餐。
本章我们将涵盖以下主题:
-
具有动态 SCSS 转换的最佳 Webpack 配置
-
使用 ShellJS 进行脚本编写
-
使用 Flow 进行静态类型检查
-
创建 HTML5 canvas 游戏引擎
-
响应键盘事件
-
创建和绘制图像实体
-
在屏幕上移动由计算机控制的实体
-
强制力碰撞检测
-
设置游戏标题和游戏结束场景
那么,让我们开始吧!
最佳 Webpack 配置
我们将实现一些较新的技术,并且再次修改我们的 Webpack 配置和构建过程。我们将添加使用 Flow 的类型检查,这是复制我们的资源和创建我们的生产index.html文件的一个更好的解决方案。最后,我们将添加对内联导入和即时转换 SCSS 的支持。
SCSS 是 CSS 的扩展,允许你使用在常规 CSS 中不存在的功能编写 CSS,例如嵌套、混入、继承和变量。它被称为预处理器,就像是一个编译器,你可以在其中用一种语言编写代码,并在使用之前将其转换为另一种语言。在这种情况下,我们将用 SCSS 编写代码,并在浏览器解析之前将其转换为常规 CSS。
为了完成所有这些,我们需要添加一些来自npm的新包,并修改我们的 Webpack 配置。注意,我们将从我们在第八章中制作的生成 Webpack 脚手架开始,将您的应用程序部署到云。这个脚手架具有以下结构:
├── assets
│ ├── app.css
│ ├── favicon.ico
│ ├── index.html
│ └── index.prod.html
├── package.json
├── public
│ └── assets
│ └── bundle.js
├── server-development.js
├── server-production.js
├── source
│ └── index.jsx
├── Webpack-development.config.js
└── Webpack-production.config.js
在Webpack-development.config.js和Webpack-production.config.js中,在loader部分(方括号之间)添加以下代码:
{
test: /\.scss$/,
loader: 'style!css!sass'
}
注意,我们将保留 Babel 加载器,然后在下面添加另一个加载器,以确保 Webpack 理解scss前缀。
在这两个配置文件中,添加以下导入:
var HtmlWebpackPlugin = require('html-webpack-plugin');
然后,将此插件代码添加到plugins部分:
new HtmlWebpackPlugin({
title: "A Wizard's Picnic",
template: 'index.ejs',
hash: true,
inject: 'body'
})
此插件将模板index.ejs文件复制到配置文件中之前定义的输出路径作为index.html。它还将插入 Webpack 生成的脚本文件。
对于Webpack-development.config.js,输出部分应如下所示:
output: {
path: path.join(__dirname, 'assets'),
filename: 'bundle.js'
},
对于 Webpack-production.config.js,它应该看起来像这样:
output: {
path: path.join(__dirname, 'public', 'assets'),
filename: 'bundle.js'
},
我们还需要添加 index.ejs 文件及其内容。使用以下代码添加它们:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="app"></div>
</body>
</html>
注意,我们已经跳过了 CSS 和脚本,并且通过注入 htmlWebpackPlugin 添加了页面标题。
这两个更改之后,我们可以移除 cpFile 插件以及 server-production.js 文件中的 cpFile 代码。cpFile 插件做了两件事:它将 assets/index.prod.html 复制到 public/assets/index.html,并将 assets 中的 app.css 复制到 public/assets。我们仍然需要复制资源内容,但由于我们将复制大量文件,我们需要一种比简单地逐个复制文件更智能的方法来做这件事。
使用 ShellJS 编写脚本
我们将使用 ShellJS 来复制我们的资源。这个 npm 包是普通 bash 脚本的替代品,并且增加了跨环境支持的好处。这意味着我们编写的脚本将适用于 Windows 用户以及 Mac/Linux 用户。
我们需要添加一个脚本来复制我们的文件,因此添加一个名为 scripts 的新文件夹,并在其中添加一个名为 assets.js 的文件。然后,添加以下代码:
require('shelljs/global');
rm('-rf','public/assets');
mkdir('-p','public/assets');
cp('-R', 'assets/', 'public/assets');
我们还需要更新我们的 package.json 文件,添加一个运行脚本,以便在打包我们的应用程序时运行 ShellJS。打开文件,并将 start 命令替换为以下三行:
"prestart": "shjs scripts/assets.js",
"start": "cross-env NODE_ENV=production npm run build",
"poststart": "cross-env NODE_ENV=production node server-production.js",
我们还需要更新我们的 server 文件,因此打开 server-production.js 并将其替换为以下内容:
"use strict";
var express = require("express");
var app = express();
var port = process.env.PORT || 8080;
var host = process.env.HOST|| "0.0.0.0";
var path = require("path");
var compression = require("compression");
var http = require("http");
var errorHandler = require('express-error-handler');
app.use(compression());
app.get("*", function (req, res) {
console.log(req.path);
var file = path.join(__dirname, "public", "assets", req.path);
res.sendFile(file);
});
server = http.createServer(app);
app.use(function (err, req, res, next) {
console.log(err);
next(err);
});
app.use( errorHandler({server: server}) );
app.listen(port, host, function() {
console.log('Server started at http://'+host+':'+port);
});
接下来,我们需要添加我们导入的所有包。通过执行以下命令来完成此操作:
npm i --save-dev shelljs@0.6.0 html-webpack-plugin@2.9.0 cross-env@1.0.8 sass-loader@3.1.2 node-sass@3.4.2 style-loader@0.13.0 css-loader@0.23.1
我们可以使用以下方法移除我们不再需要的两个包:
npm remove --save-dev cp-file rimraf
您还需要一套完整的游戏资源。您可以清空当前的 assets 文件夹,并使用在 reactjsblueprints-chapter10.herokuapp.com/assets.zip 可用的内容。我们游戏中的图形包含来自公共领域 rogue-like 图块集 RLTiles 的图块选择。您可以在 rltiles.sf.net 找到原始图块集。
呼呼!这有很多变化,但我们终于准备好开始编写游戏了。您应该能够运行 npm run dev 来运行开发服务器,以及 npm start 来构建和运行生产服务器。
使用 Flow 进行静态类型检查
我们将使用 Flow 对我们的代码进行类型检查。这不是我们代码库的一部分,但您将在我们的引擎和游戏代码的每个地方看到这种语法。
Flow 被设计用来在 JavaScript 程序中查找类型错误。与完全类型化的语言(如 TypeScript)相比,它有一个主要的好处。您可以在想使用时使用它。这意味着您可以将类型化代码与非类型化代码混合,并继续像以前一样编程,同时增加了能够自动检测类型错误的好处。
缺点是 Flow 二进制文件仅在 Mac 和 Linux 上可用。有一个实验性的 Windows 二进制文件可用,但它并不总是最新的。优点是,如果你在 Windows 上,你的代码仍然会执行,但你将无法找到任何潜在的错误。
注意
通过访问 flowtype.org/docs/getting-started.html 并按照说明进行操作来安装 Flow。
你需要在项目根目录中有一个特殊的配置文件,名为 .flowconfig(点号前没有名称)。添加文件并包含以下内容:
[include]
./source
[ignore]
.*/*.scss*
.*/node_modules/babel.*
.*/node_modules/babylon.*
.*/node_modules/redbox-react.*
.*/node_modules/invariant.*
.*/node_modules/fbjs.*
.*/node_modules/fsevents.*
.*/node_modules/is-my-json-valid.*
.*/node_modules/config-chain.*
.*/node_modules/json5.*
.*/node_modules/ua-parser-js.*
.*/node_modules/spdx.*
.*/node_modules/binary.*
.*/node_modules/resolve.*
.*/node_modules/npmconf.*
.*/node_modules/builtin.*
.*/node_modules/sha.*
[options]
module.name_mapper='.*\(.css\)' -> 'empty/object'
module.name_mapper='.*\(.scss\)' -> 'empty/object'
此配置告诉 Flow 检查 source 文件夹的内容,同时忽略 node_modules 中的一些选定的依赖项,这些依赖项是通过 source 文件夹中的引用获取的。
当 Flow 安装完成并添加了配置文件后,你可以通过在命令行中执行 flow 来开始检查你的代码。第一次运行时,它将初始化一个服务器,然后报告之后的每次运行的错误。
一个典型的错误看起来像这样:
source/engine/entity/randomMove.js:21
21: entity.direction = shuffle(direction)[0];
^^^^^^^^^^^^^^^^^^ function call
18: let direction = ["x","y"];
^^^ string. This type is incompatible with
3: array: Array<Object>
^^^^^^ object type. See: source/engine/math/shuffle.js:3
在这里,Flow 已经确定 shuffle 调用是用一个数组进行的,但 shuffle 函数被定义为期望一个包含对象的数组。这个错误很容易修复,因为 shuffle 应该期望一个包含一系列值的数组,而不是包含对象的数组。
通过使用注解,你用意图编写代码,Flow 使得检查你是否以你期望的方式使用函数变得容易,正如前面错误所见证的那样。
创建一个 HTML5 画布引擎
游戏分为两个部分:引擎和游戏。对于这类项目,制定一个关于应用最终外观的计划是值得的。将纯游戏引擎部分与游戏部分分开是很自然的,因为这使得它们在以后更容易重用,并用于其他游戏。
通常,当你制作一个游戏时,你会基于一个现成的引擎,但我们不会这么做。我们将完全自己制作一个引擎。我们将实现我们需要的所有功能,但在我们完成之后,你可以自由地扩展并添加自己的引擎组件。
应将引擎放置在 source 内部的子文件夹中。这是结构:
engine/
├── collision
│ └── bruteForce.js
├── entity
│ ├── createEntity.js
│ ├── drawEntity.js
│ ├── randomMove.js
│ └── targetEntity.js
├── index.js
├── input
│ ├── keyboard.js
├── math
│ ├── shuffle.js
│ ├── sign.js
└── video
├── clear.js
└── loadImage.js
主文件是 index.js,它简单地充当一个中央导入/导出中心。让我们首先创建 engine 文件夹和 index.js。它应该包含以下内容:
const loadImage = require('./video/loadImage');
const clear = require('./video/clear');
const drawEntity = require('./entity/drawEntity');
const createEntity = require('./entity/createEntity');
const targetEntity = require('./entity/targetEntity');
const sign = require('./math/sign');
const bruteForce = require('./collision/bruteForce');
const keyboard = require('./input/keyboard');
const shuffle = require('./math/shuffle');
const randomMove= require('./entity/randomMove');
module.exports = {
loadImage,
clear,
randomMove,
createEntity,
drawEntity,
targetEntity,
sign,
shuffle,
bruteForce,
keyboard
}
我们将在游戏中使用所有这些组件。让我们创建每一个,并看看它们的作用。
让我们从 video 文件夹和 loadImage.js 开始。将此代码添加到文件夹中:
// @flow
const setImage = (ctx: Object, image: Image) => {
ctx.drawImage(image, 0, 0);
}
const loadImage = (canvas: Object, image: string) => {
let bgImage = new Image();
bgImage.src = image;
bgImage.onload = () => {
setImage(canvas.getContext("2d"), bgImage)
};
}
module.exports = loadImage;
在代码中添加一行注释 @flow 告诉 Flow 在此文件上使用其类型检查功能。然后定义 setImage 函数,它有两个参数:ctx 和 image。ctx 参数被转换为对象,image 被转换为图像。如果我们把图像转换为字符串,Flow 会立即告诉我们类型与 setImage 函数调用不兼容。
不再使用 Flow;让我们看看这个文件做了什么。它有两个函数,但只有一个被导出。loadImage函数接受一个图像并将其获取到image变量,即bgImage。这是一个网络调用,因此模块不能立即返回图像,但我们将函数设置为在图像加载后立即执行setImage函数。然后该函数将在我们传入的画布上绘制图像。
下一个文件是clear.js,它还需要添加到source/engine/video文件夹中。向其中添加以下代码:
const clear = (canvas: Object) => {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
module.exports = clear;
当被调用时,这将完全清除画布。
下一个文件是shuffle.js,位于source/engine/math文件夹中。添加它并使用以下代码:
// @flow
const shuffle = (
array: Array<any>
): Array<any> => {
let count = array.length;
let rnd, temp;
while( count ) {
rnd = Math.random() * count-- | 0;
这行代码在0和剩余项目数之间获取一个随机数。单个管道是一个位运算符,在这种情况下用于去除小数部分。它的工作方式与Math.floor()非常相似,但速度更快,因为位运算符是原生的。它可能更复杂,更难理解,所以如果你想让代码更易读,用Math.floor()包装数学操作是个好主意。然后我们将按顺序将项目分配给temp变量,并将随机数处的当前项目移动到数组中的当前计数器:
temp = array[count];
array[count] = array[rnd];
array[rnd] = temp;
最后,我们将随机数设置到数组中的项目顺序。这确保了所有项目都被计算在内:
}
return array;
}
module.exports = shuffle;
如其名所示,shuffle函数接受一个数组集合,然后通过遍历输入数组中的所有项目来对其进行洗牌。
math文件夹中的第二个文件是sign.js。向其中添加以下代码:
//@flow
const sign = (n: number): number => {
return Math.sign(n) || (n = +n) == 0 || n != n ? n : n < 0 ? -1 : 1
};
module.exports = sign;
符号是一个数学表达式,它返回一个整数,表示数字的符号。如果可用,我们将使用内置的sign函数,否则将使用我们自己的。我们将使用它来设置针对玩家的敌人实体的移动。
接下来是input文件夹。向其中添加keyboard.js并添加以下代码:
// @flow
const keyboard = (keys: Array<bool>) => {
window.addEventListener("keydown", (e) => {
keys[e.keyCode] = true;
}, false);
window.addEventListener("keyup", (e) => {
delete keys[e.keyCode];
}, false);
}
module.exports = keyboard;
此文件添加了一个事件监听器,当玩家在键盘上按下任何键时注册键,并在事件监听器检测到键被释放(用户不再按下键)时从键数组中删除它们。
让我们添加entity文件夹。在这里我们将添加五个文件。第一个是targetEntity.js。向其中添加以下代码:
// @flow
import sign from '../math/sign';
const targetEntity = (
entityA: Object,
entityB: Object,
speed: number = 1
) => {
let posA = entityA.pos;
let posB = entityB.pos;
let velX = sign(posB.x - posA.x) * speed;
let velY = sign(posB.y - posA.y) * speed;
entityA.pos.x+=velX;
entityA.pos.y+=velY;
};
module.exports = targetEntity;
我们将使用此文件来设置一个实体在另一个实体位置路径上的一个实体。在我们制作的游戏中,我们将使用此代码来指导一个敌人实体指向玩家或反之亦然。实体是一个具有一定大小、位置和速度的对象,代码通过改变entityA对象的x和y位置来实现。
我们将使用sign方法设置正确的符号。如果我们不这样做,它很可能会远离实体而不是朝向它移动。
接下来是randomMove.js。添加文件并添加以下代码:
// @flow
import shuffle from '../math/shuffle';
const randomMove = (
entity: Object,
speed: number = 1,
Config: Object = {
height: 512,
width: 512,
tileSize: 32
}
) => {
let {pos, vel} = entity;
let speedX, speedY;
entity.tick-=1;
当entity.tick达到零时,将计算一个新的方向。现在看看这个:
let direction = ["x","y"];
if(entity.tick<=0) {
entity.direction = shuffle(direction)[0];
entity.tick=Math.random()*50;
}
为了使方向重新计算更加随机,新的 tick 值被设置为0到50之间的一个值。让我们继续到另一个函数:
if(pos.x + vel.x >Config.width - Config.tileSize *2) {
vel.x=-speed;
}
if(pos.x + vel.x < Config.tileSize/2) {
vel.x=speed;
}
if(pos.y + vel.y > Config.height- Config.tileSize * 2) {
vel.y=-speed;
}
if(pos.y + vel.y < Config.tileSize/2) {
vel.y=speed;
}
entity.pos.x+= entity.direction==="x" ? vel.x: 0;
entity.pos.y+= entity.direction==="y" ? vel.y: 0;
};
module.exports = randomMove;
这个函数实现了计算机控制实体的随机方向。
我们接下来要创建的文件是drawEntity.js。添加以下代码:
// @flow
import createEntity from './createEntity';
module.exports = (
canvas: Object,
entity: Object
) => {
if(entity._creating && !entity._sprite){
return 0;
}
else if(!entity._sprite) {
createEntity(canvas, entity);
}
else {
// draw the sprite as soon as the image
// is ready
var ctx = canvas.getContext("2d");
ctx.drawImage(
entity._sprite,
entity.pos.x,
entity.pos.y
);
}
}
这个文件与loadImage类似,但我们将通过设置两个变量_creating和_sprite为实体添加一个状态。我们将在游戏中稍后使用它,只实际绘制具有适当的ImageData对象(包含在_sprite中)的实体。
entity文件夹中的最后一个文件是createEntity.js。添加以下代码:
// @flow
import drawEntity from './drawEntity';
module.exports = (
entity: Object
) => {
entity.id=Math.random()*2;
这为实体提供了一个 ID,请看以下内容:
entity._creating=true;
标记它,这样我们就不尝试创建实体两次。让我们看一下下面的代码片段:
let entityImage = new Image();
entityImage.src = entity.image;
entityImage.onload = () => {
entity._sprite = entityImage;
};
}
我们几乎完成了引擎。我们需要添加一个额外的文件夹和文件,分别是collision和bruteForce.js。使用以下代码添加它:
// @flow
module.exports = (
entityA: Object = {pos: {x:0, y:0}},
entityB: Object = {pos: {x:0, y:0}},
size: number = 32
): bool => {
return (
entityA.pos.x <=
(entityB.pos.x + size)
&& entityB.pos.x <=
(entityA.pos.x + size)
&& entityA.pos.y <=
(entityB.pos.y + size)
&& entityB.pos.y <=
(entityA.pos.y + size)
)
}
这个函数将比较两个实体的位置并确定它们是否占据相同的空间。对于小画布和屏幕上有限的实体数量,这是你可以想象到的最快的碰撞检测实现。
现在你有一个小型的工作游戏引擎。让我们继续并开始实现游戏。
创建游戏
游戏本身将比引擎更大。这对 HTML5 游戏来说并不罕见,但请做好准备,因为我们将要添加很多文件。让我们看一下下面的截图:

这是游戏的文件结构(不包括引擎):
├── components
│ ├── addEntity.js
│ ├── addProjectile.js
│ ├── checkCollision.js
│ ├── debugBoard.js
│ ├── diceroll.js
│ ├── drawEntities.js
│ ├── drawGameOver.js
│ ├── drawGameWon.js
│ ├── drawHud.js
│ ├── clearCanvas.js
│ ├── keyInput.js
│ ├── keypress
│ │ ├── a.js
│ │ ├── d.js
│ │ ├── down.js
│ │ ├── index.js
│ │ ├── left.js
│ │ ├── right.js
│ │ ├── s.js
│ │ ├── space.js
│ │ ├── up.js
│ │ └── w.js
│ ├── outOfBounds.js
│ ├── removeEntity.js
│ └── setupGame.js
├── config
│ ├── beasts.js
│ ├── index.js
│ ├── players.js
│ └── spells.js
├── game.jsx
├── index.jsx
├── polyfills.js
├── style.scss
└── title.jsx
让我们从root源文件开始。
将以下内容添加到index.jsx:
import './style.scss';
import polyfill from './polyfills';
import Config from './config';
Config文件是我们将提供游戏所有内容的文件,如下所示:
import React, { Component, PropTypes } from 'react';
import MyGame from './game';
import Title from './title';
import {render} from 'react-dom';
class Index extends Component {
constructor() {
super();
this.state={};
this.state.scene="title";
}
callback(val: string) {
this.setState({scene: val})
}
render() {
switch(this.state.scene) {
case "title":
return <Title cb={this.callback.bind(this)} />
break;
case "game":
return <MyGame cb={this.callback.bind(this)} />
break;
}
}
}
render(
<Index />,
document.getElementById('app')
);
当玩家开始游戏时,我们显示标题或游戏屏幕。我们通过为组件提供一个setState回调来实现切换,这意味着任何时候我们想要切换到场景,我们都可以使用this.props.cb(scene)。
接下来,添加polyfills.js,代码如下:
// polyfill for requestAnimationFrame
var requestAnimFrame = (function() {
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function(callback) {
window.setTimeout(callback, 1000 / 60);
};
})();
这是一个垫片,用于为尚未支持requestAnimationFrame的浏览器提供支持。正如你在代码中所见,如果requestAnimationFrame不受支持,它将实现setTimeOut。我们想使用requestAnimationFrame因为它比setTimeOut更高效,setTimeOut不够准确,而且在不需要渲染时也会浪费很多周期。
让我们添加title.jsx:
import './style.scss';
import polyfill from './polyfills';
import Config from './config';
import React, { Component, PropTypes } from 'react';
import Game from './engine';
import keyboard from './components/keypress/index';
class Title extends Component {
constructor() {
super();
this.last = Date.now();
this.keys={};
}
keyInput( keys ) {
if (keyboard.space(keys)) {
this.props.cb("game");
}
}
如果玩家在键盘上按下空格键,则开始游戏。下面是下一步:
updateGame(modifier) {
if(typeof this.refs.canvas ==="undefined")
return;
这避免了在画布尚未初始化时更新游戏。以下代码告诉游戏监听键盘输入:
const { canvas } = this.refs;
const ctx = canvas.getContext("2d");
Game.loadImage(
canvas,
Config.backgrounds.title
);
// Keyboard
this.keyInput(this.keys);
}
componentDidMount() {
Game.keyboard(this.keys);
这是主游戏循环:
const gameLoop = () => {
var now = Date.now();
var delta = now - this.last;
this.updateGame(delta / 1000);
this.last = now;
window.requestAnimationFrame(gameLoop);
}
gameLoop();
尽管这是一个标题场景,但我们将其视为一个迷你游戏并相应地更新它。这使得我们能够轻松地使用实体和游戏逻辑来动画化标题屏幕,并使用与游戏中相同的输入方法,让我们看一下下面的代码片段:
}
render() {
return <div><canvas
ref="canvas"
id={ Config.id || "canvas" }
height={ Config.height }
width={ Config.width } >
Sorry, your browser doesn't
support canvas
</canvas>
<br/>
<div className="info">
You're a wizard. You're on a picnic.
<br/>
You hear a noise...
</div>
</div>
}
}
module.exports = Title;
接下来,添加style.scss并使用以下代码:
canvas {
margin: 0 auto;
display: block;
}
body {
background: black;
}
.info {
color:white;
text-align:center;
margin: 0 auto;
display: block;
a {
color:white;
}
}
最后,我们添加游戏本身。添加game.jsx并使用以下代码:
import './style.scss';
import polyfill from './polyfills';
import Config from './config/index';
import React, { Component, PropTypes } from 'react';
import Game from './engine';
import SetupGame from './components/setupGame';
import KeyInput from './components/keyInput';
import DrawHUD from './components/drawHud';
import DrawGameOver from './components/drawGameOver';
import DrawGameWon from './components/drawGameWon';
import DrawEntities from './components/drawEntities';
import ClearCanvas from './components/clearCanvas';
import CheckCollision from './components/checkCollision';
import OutOfBounds from './components/outOfBounds';
import AddProjectile from './components/addProjectile';
import AddEntity from './components/addEntity';
import RemoveEntity from './components/removeEntity';
我们需要创建所有这些组件,以便游戏能够正常运行。在开发游戏时,通常会将这些组件内联。在迭代游戏时,你会了解如何将它们分离成独立的组件,以及如何对它们进行修改以实现复用。让我们看一下以下代码片段:
class MyGame extends Component {
constructor() {
super();
this.lastTime = Date.now();
this.keys={};
this.gameOver=false;
this.gameWon=false;
this.maxMonsters=3;
this.level=0;
this.beast=Config.beasts[0],
this.state={};
this.returnToTitleScreen=150;
这是一个倒计时,将在游戏结束后,玩家返回到标题屏幕时使用。请看以下代码片段:
this.score = 0;
this.coolDown=0;
这又是一个倒计时。它用于玩家射击时,防止玩家在板上用弹射物进行垃圾邮件式攻击。让我们看一下以下代码片段:
this.entities= Config.entities;
this.current_player_no = 0;
this.current_player = this.entities.players[0];
this.state.player = this.current_player;
this.current_player.health=100;
this.current_player.pos= {x:8, y:8};
游戏板是 512 x 512 像素,每个单独的实体是 32 x 32 像素。通过将板大小除以实体大小,可以更容易地可视化板上的放置。通过查看这个值,很容易理解当前玩家被放置在板的中间。如果我们使用了精确的像素值,即 256 x 256,可能会稍微困难一些。现在,让我们看看下一步:
}
updateGame(modifier) {
if(typeof this.refs.canvas ==="undefined")
return;
const { canvas } = this.refs;
const ctx = canvas.getContext("2d");
if(this.gameOver) {
ClearCanvas(canvas, this.gameOverImage);
if(this.gameWon)
DrawGameWon(canvas);
else
DrawGameOver(canvas);
--this.returnToTitleScreen;
if(this.returnToTitleScreen<=0)
this.props.cb('title');
return;
}
当gameOver标志被设置为true时,我们告诉游戏暂停,并启动一个计数器,当计数器达到零时,将玩家返回到标题屏幕。请看以下代码:
const player = this.entities.players[
this.current_player_no
];
这是一个单人游戏,但你可以通过在config中的players数组中添加更多玩家来扩展游戏,并通过迭代current_player_no变量来切换他们。这个函数负责绘制玩家以及任何敌人和弹射物:
DrawEntities(Config, canvas, this.entities);
这个函数在屏幕顶部绘制得分和玩家生命值:
DrawHUD(canvas, this.score, player.health);
这是一个相当高级的函数,用于处理游戏中的所有键输入:
this.coolDown-=0.1;
KeyInput(
Config,
this.keys,
player,
1,
AddProjectile.bind(this),
(item) => this.entities.projectiles.push(item),
this.coolDown,
_ => this.coolDown = 1.5
);
它需要Config对象来计算弹射物的位置,处理移动和射击动作的键,player对象,以及一个可以用来加快或减慢移动速度的修饰符。它还要求你传递创建弹射物的函数和两个回调:第一个用于将弹射物添加到entities数组,第二个用于设置coolDown变量。这个最后值越高,玩家能发射的弹射物就越少。对于每一次迭代,弹射物都会根据其速度移动:
this.entities.projectiles.forEach((item)=> {
item.pos.x+= item.direction.xVel;
item.pos.y+= item.direction.yVel;
以下循环是必要的,用于检查是否有任何弹射物与任何怪物发生碰撞:
this.entities.monsters.forEach((monster)=> {
这是一个嵌套循环,我们通常应该小心处理,因为它可能是性能下降的主要原因。现在,看看以下代码:
if(Game.bruteForce(
item, monster, Config.tileSize/2
)) {
monster.health-=20;
this.entities.projectiles =
RemoveEntity(
this.entities.projectiles,
item,
_ => {}
);
如上图所示,如果弹射物与敌人碰撞,敌人会损失 20 点生命值,并且我们将弹射物从实体数组中移除。这确保了它不会在游戏循环的下一轮中被绘制。下一个检查会移除生命值小于零的敌人:
if(monster.health<=0) {
this.entities.monsters =
RemoveEntity(
this.entities.monsters,
monster,
_ => { this.score++}
);
}
}
})
if(OutOfBounds(
item,
{h:Config.height,w:Config.width},
Config.tileSize
)) {
this.entities.projectiles =
RemoveEntity(
this.entities.projectiles,
item,
_ => {}
);
}
这个函数负责移除逃离画布的弹丸。这很重要,因为我们不想保留我们计算出的元素列表尽可能小。我们继续到下一个循环:
})
this.entities.monsters.forEach((monster)=> {
这个循环检查敌人是否接近或与玩家发生碰撞。如果它们接近,它们应该直接向玩家移动。尝试增加范围以使游戏更具挑战性。
如果它们发生碰撞,玩家会失去生命值。
如果这些情况都没有发生,我们为实体提供一个随机的方向:
if(Game.bruteForce(monster, player, 32)) {
Game.targetEntity(
monster,
player,
monster.speed
)
}
Game.randomMove(
monster,
monster.speed,
Config
)
CheckCollision(
canvas, player,
monster,
_ => {player.health-=1},
_ => {}
);
})
if(!this.gameOver && this.level<=14) {
if(this.entities.monsters.length<=0) {
++this.level;
每当玩家清空当前的一组敌人时,他们就会进入下一级:
this.beast=Config.beasts[this.level-1];
this.setState({
level: this.level,
beast: this.beast
})
this.maxMonsters=this.level+3;
}
if(this.beast && this.maxMonsters>0) {
--this.maxMonsters;
每个级别都会带来更多的敌人。这个检查确保我们添加的 enemy 实体数量与当前级别的要求相符:
AddEntity(
this.beast,
{
x: Game.shuffle([64,256,480])[0],
y: Game.shuffle([-32,520])[0]
},
20+this.score,
1+Math.random()*this.score/10,
(item) => this.entities.monsters.push(item)
)
}
}
if(this.level>14) {
this.gameWon=true;
this.returnToTitleScreen = 400;
this.gameOver=true;
}
if(player.health<0 || this.gameWon) {
如果玩家的生命值耗尽,我们将当前画布的图像存储起来,并将其用作游戏结束屏幕。然后,我们清除实体并设置 gameOver 标志:
this.gameOverImage =
ctx.getImageData(
0, 0, canvas.width, canvas.height
);
this.entities.monsters=[];
this.entities.projectiles=[];
this.gameOver = true;
}
}
componentDidMount() {
const canvas = this.refs.canvas;
const ctx = canvas.getContext("2d");
this.level=0;
this.setState({
score: 0,
level: 0,
beast: Config.beasts[0]
})
当挂载游戏时,我们重置得分、级别和当前敌人。这样,当玩家击中游戏结束并按下空格键重新开始游戏时,我们可以从头开始:
SetupGame(
Config, this.keys, this.refs.canvas,
this.entities, this.positions
);
const gameLoop = () => {
var now = Date.now();
var delta = (now - this.lastTime) / 1000.0;
this.updateGame(delta);
this.last = now;
window.requestAnimationFrame(gameLoop);
}
gameLoop();
}
getCurrentplayer() {
return this.current_player.name
}
render() {
return <div>
<canvas
ref="canvas"
id={ Config.id || "canvas" }
height={ Config.height }
width={ Config.width } >
Sorry, your browser doesn't
support canvas
</canvas>
<br/>
<div className="info">
Player: {this.getCurrentplayer()}
Level: {this.level}
</div>
</div>
}
}
module.exports = MyGame;
以下截图显示了玩家可见的游戏画面,敌人实体正在蜂拥而至,标题栏中有一个得分和生命条。屏幕底部,你可以看到玩家的名字(从 name 数组中随机选择)和当前的难度级别:

这就是游戏文件的全部内容,但正如你所注意到的,我们还有更多文件要添加。我们需要添加两个新的文件夹:components 和 config。让我们从 config 开始。添加这个文件夹和 index.js 文件。然后,添加以下内容:
import { players, names } from './players';
import { beasts } from './beasts';
import Shuffle from '../engine/math/shuffle';
let config = {
tileSize: 32,
height: 512,
width: 512,
debug: true,
beasts: beasts,
backgrounds: {
title: '/title.png',
game: '/board512_grass.png'
},
entities: {
players : [],
projectiles: [],
monsters: [],
pickups: [],
enemies: []
}
我们还没有在游戏中添加任何拾取物,但这是一个好主意,并添加各种物品,如生命、不同的武器等等,让我们看一下以下代码:
}
config.entities.players.push({
我们将为玩家添加一个单独的玩家,并从玩家名单中随机选择一个名字,以及从玩家名单中随机选择一个图片,让我们看一下以下代码片段:
name: Shuffle(names).pop(),
image: Shuffle(players).pop(),
health: 100,
width: 32,
height: 32,
pos:{
x: 8,
y: 8
},
speed: 5
})
module.exports = config;
接下来,添加 config/players.js 并包含以下内容:
let names = [
"Striliyrin",
"Xijigast",
"Omonar",
"Egeor",
"Omakojamar",
"Eblokephior",
"Tegorim",
"Ugniforn",
"Igsior",
"Imvius",
"Pobabine",
"Oecodali",
"Baro",
"Trexaryl",
"Flahevys",
"Ugyritaris",
"Afafyne",
"Stayora",
"Ojgis",
"Ikgrith"
];
let players = [
'/deep_elf_knight.png',
'/deep_elf_death_mage.png',
'/deep_elf_demonologist.png',
'/deep_elf_fighter.png',
'/deep_elf_high_priest.png',
'/deep_elf_mage.png',
'/deep_elf_blademaster.png',
'/deep_elf_conjurer.png',
'/deep_elf_annihilator.png'
]
exports.players = players;
exports.names = names;
这个文件为游戏增添了多样性。如果我们添加更多玩家到游戏中,它也可能很有用。
最后,添加 config/beasts.js 并包含以下内容:
let beasts = [
"/beasts/acid_blob",
"/beasts/rat",
"/beasts/boring_beetle",
"/beasts/giant_mite",
"/beasts/orc_warrior",
"/beasts/demonspawn",
"/beasts/hydra",
"/beasts/ooze",
"/beasts/hobgoblin",
"/beasts/dragon",
"/beasts/harpy",
"/beasts/golden_dragon",
"/beasts/griffon",
"/beasts/hell_knight"
]
exports.beasts = beasts;
我们已经完成了配置,所以让我们添加所有的游戏组件。
添加 components/addEntity.js 并包含以下代码:
//@flow
import Game from '../engine';
let directions = [1, -1];
const addEntity = (
item: string,
pos: Object,
health: number = 60,
speed: number = 1,
callback: Function
) => {
let entity = {
name: item,
image: `${item}.png`,
width: 32,
height: 32,
health: health,
pos:{
x: pos.x,
y: pos.y
},
vel:{
x: Game.shuffle(directions)[0],
y: Game.shuffle(directions)[0]
},
tick: 50,
direction: Game.shuffle(["x","y"])[0],
speed: speed+(Math.random()*1)
};
Game.createEntity(entity);
callback(entity);
}
module.exports = addEntity;
我们通过 shuffle 和 Math.random 添加多样性。我们希望它们的移动是随机的,并且希望其中一些比其他移动得更快。
添加 components/addProjectile.js 并包含以下代码:
//@flow
import Game from '../engine';
const addProjectile = (
item: string,
player: Object,
direction: Object,
pushProjectile: Function
) => {
let projectile = {
name: item,
image: `${item}.png`,
width: 32,
height: 32,
pos:{
x: player.pos.x,
y: player.pos.y
},
direction: direction,
speed: 10
};
Game.createEntity(projectile);
pushProjectile(projectile);
}
module.exports = addProjectile;
这段代码与上一段非常相似,因此值得考虑是否可以将这两个文件合并。在计算机科学中有一个流行的缩写词 DRY,代表 Don't Repeat Yourself(不要重复自己)。其目的是识别概念上重复的代码,例如 addEntity 和 addProjectile,然后努力将其合并成一个单一的功能。
我们接下来要添加的文件是 checkCollision.js。添加它并包含以下代码:
import Game from '../engine';
const checkCollision = (
canvas,
player,
monster,
cb,
score
) => {
const collides = Game.bruteForce(
player, monster, 32
);
if(collides) {
score();
const ctx = canvas.getContext("2d");
ctx.fillStyle = "rgb(250, 250, 250)";
ctx.font = "12px Helvetica";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText("Ouch", player.pos.x, player.pos.y-24);
cb(monster, canvas);
}
}
module.exports = checkCollision;
我们将重用 bruteForce 碰撞检测,并在玩家实体与任何东西碰撞时在其上方显示一个小小的 Ouch。
接着,添加 components/drawEntities.js 并使用以下代码:
//@flow
import Game from '../engine';
const drawEntities = (
Config: Object,
canvas: Object,
entities: Object
) => {
// Draw all entities
Game.loadImage(
canvas,
Config.backgrounds.game
);
entities.projectiles.forEach((item)=> {
Game.drawEntity(canvas, item);
})
entities.monsters.forEach((monster)=> {
Game.drawEntity(canvas, monster);
})
entities.players.forEach((player)=> {
Game.drawEntity(canvas, player);
})
}
module.exports = drawEntities;
在游戏循环中,此代码用于绘制屏幕上的所有实体。顺序很重要,因为首先绘制的实体将被下一个绘制的实体覆盖。如果你先绘制玩家,投射物和敌人将出现在玩家上方,发生碰撞时。
接着,添加 components/drawGameOver.js 并使用以下代码:
//@flow
import Game from '../engine';
const drawGameOver = (
canvas: Object
) => {
const ctx = canvas.getContext("2d");
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.font = "24px Helvetica Neue";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText("Game Over", canvas.width/2, canvas.height/2-25);
}
module.exports = drawGameOver;
然后添加 components/drawGameWon.js 并使用以下代码:
//@flow
import Game from '../engine';
const drawGameWon = (
canvas: Object
) => {
const ctx = canvas.getContext("2d");
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.font = "24px Helvetica Neue";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText("You won!", canvas.width/2, canvas.height/2-25);
ctx.font = "20px Helvetica Neue";
ctx.fillText("You can finally enjoy your picnic!", canvas.width/2, canvas.height/2);
}
module.exports = drawGameWon;
它们都很相似,并且会根据是常规游戏结束事件还是玩家完成游戏而显示不同的文本。你可以添加颜色,使用不同的字体和字体大小来使文本更具吸引力。它的工作方式与 CSS 类似,通过向下级联。注意,胜利条件中的第二行文本的字体大小比第一行小,以及它是如何排列以实现这一点的。
接着,添加 components/drawHud.js 并添加以下代码:
//@flow
import Game from '../engine';
const drawHUD= (
canvas: Object,
score: number = 0,
health: number = 100
) => {
const ctx = canvas.getContext("2d");
ctx.fillStyle = "rgb(250, 250, 250)";
ctx.font = "20px Helvetica Neue";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText("SCORE: " + score, 25, 25);
ctx.textAlign = "right";
ctx.fillText("Health: " + health, canvas.width-35, 25);
}
module.exports = drawHUD;
注意,这与其他文本函数的主要区别是文本的位置。
使用以下代码添加 components/clearCanvas.js:
//@flow
import Game from '../engine';
const clearCanvas = (
canvas: Object,
gameOverImage: ImageData
) => {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(gameOverImage, 0, 0);
}
module.exports = clearCanvas;
此组件将用提供的图像替换当前画布。我们将使用游戏在 gameOver 标志设置后的快照作为游戏结束屏幕。
使用以下代码添加 components/outOfBunds.js:
//@flow
const outOfBounds = (
item: Object = {pos: {x: 160, y: 160}},
bounds: Object = {height: 16, width: 16},
tileSize: number = 32
): bool => {
if( item.pos.y< -tileSize ||
item.pos.x< -tileSize ||
item.pos.y > bounds.height+tileSize ||
item.pos.x > bounds.width+tileSize
) {
return true;
}
return false;
}
module.exports = outOfBounds;
如果一个实体在画布外,这将返回 true。
使用以下代码添加 components/removeEntity.js:
//@flow
const removeEntity = (
entities: Array<any>,
item: Object,
callback: Function
): Array<any> => {
callback();
return entities =
entities.filter((p)=> {
return p.id !== item.id
})
}
module.exports = removeEntity;
此文件将在返回过滤后的 entity 数组之前执行回调。在我们的代码中,回调要么是一个空函数,要么是一个更新得分的函数。
接着,添加 components/setupGame.js 并使用以下代码:
//@flow
import Config from '../config/index';
import Game from '../engine';
const setupGame = (
Config: Object,
keys: Object,
canvas: Object,
entities: Object,
positions: Object
) => {
// setup keyboard
Game.keyboard(keys);
entities.players.forEach((player)=> {
在这里,我们添加玩家实体。注意,我们通过乘以瓦片大小来设置位置,以在板上设置实际位置:
const tilePos = player.pos;
player.pos.x = tilePos.x * Config.tileSize;
player.pos.y = tilePos.y * Config.tileSize;
Game.createEntity(player);
})
}
module.exports = setupGame;
我们几乎完成了 components 文件夹。我们现在需要添加一个文件和一个包含几个 keypress 文件的子文件夹。
响应键盘事件
首先,添加 components/keyInput.js 并使用以下代码:
//@flow
import keypress from './keypress;
const keyInput = (
Config: Object,
keys: Object,
player: Object,
modifier: number = 1,
addProjectile: Function,
pushProjectile: Function,
coolDown: number,
setCoolDown: Function
) => {
const { pos, speed } = player;
let direction;
const Shoot = (coolDown, setCoolDown)=> {
if(coolDown<=0) {
addProjectile(
'fire',
player,
direction,
pushProjectile
)
setCoolDown();
}
}
此函数将确保添加一个投射物,但直到 coolDown 变量达到或低于零之前,它不会做任何事情:
if (keypress.up(keys)) {
direction = {
xVel: 0,
yVel: -20
}
Shoot(coolDown, setCoolDown);
}
if (keypress.down(keys)) {
direction = {
xVel: 0,
yVel: 20
}
Shoot(coolDown, setCoolDown);
}
if (keypress.left(keys)) {
direction = {
xVel: -20,
yVel: 0
}
Shoot(coolDown, setCoolDown);
}
if (keypress.right(keys)) {
direction = {
xVel: 20,
yVel: 0
}
Shoot(coolDown, setCoolDown);
}
if (keypress.w(keys)) {
if(pos.y>0) pos.y -= speed * modifier;
}
if (keypress.s(keys)) {
if(pos.y < Config.height-32) pos.y += speed * modifier;
}
if (keypress.a(keys)) {
if(pos.x>8) pos.x -= speed * modifier;
}
if (keypress.d(keys)) {
if(pos.x < Config.width-32)pos.x += speed * modifier;
}
}
module.exports = keyInput;
接着,将 keypress 文件夹添加到 components 文件夹中。
对于每个文件,添加相应的代码,如下所示:
-
a.js的代码如下://@flow const s = ( keys: Object ): bool => { return 65 in keys; } module.exports = s; -
对于
d.js,请参考以下内容://@flow const d = ( keys: Object ): bool => { return 68 in keys; } module.exports = d; -
这是
s.js的代码://@flow const s = ( keys: Object ): bool => { return 83 in keys; } module.exports = s; -
对于
w.js,请参考以下内容://@flow const w = ( keys: Object ): bool => { return 87 in keys; } module.exports = w; -
down.js的代码如下://@flow const down = ( keys: Object ): bool => { return 40 in keys; } module.exports = down; -
对于
up.js文件://@flow const up = ( keys: Object ): bool => { return 38 in keys; } module.exports = up; -
我们继续到
left.js文件://@flow const left = ( keys: Object ): bool => { return 37 in keys; } module.exports = left; -
现在,是
right.js文件://@flow const right = ( keys: Object ): bool => { return 39 in keys; } module.exports = right; -
对于
space.js,请参考以下内容://@flow const s = ( keys: Object ): bool => { return 32 in keys; } module.exports = s; -
最后,是
index.js文件:import w from './w'; import s from './s'; import a from './a'; import d from './d'; import up from './up'; import down from './down'; import left from './left'; import right from './right'; import space from './space'; module.exports = { w, s, a, d, up, down, left, right, space }
我们的游戏现在已完成,并准备好被玩。在当前设置下,游戏可能太难,但通过一点平衡,应该可以使玩家更容易获胜。让我们看看以下截图:

进一步改进
你可以通过多种方式改进游戏。以下是你可以添加的项目列表:
-
使用 WebAudio 添加声音
-
限制玩家一次可以发射的火球数量,或者限制玩家拥有的火球数量,并添加拾取物品来增加这个限制
-
利用资源缓存来预加载所有资产
-
精灵动画
-
增加游戏可玩性的奖励拾取物品,例如,增加生命值的心形或造成更多伤害的新武器
-
让敌人向玩家开火
-
为读者提供替代控制方式(使用箭头键移动,使用
wsad射击) -
添加更多屏幕和更好的关卡间进度
-
在关卡之间添加过渡效果,奖励玩家鼓励性的文字,说明已经取得了进步,然后介绍下一个敌人实体
-
添加暂停游戏的可能性
-
添加全屏选项
摘要
你已经制作了一个 ReactJS 的游戏引擎和游戏。这是一个相当大的成就。我们开始使用 Flowtype,并优化了我们使用 Webpack 创建React.js项目的方式。
如果你想查看我们刚刚创建的内容,请访问reactjsblueprints-chapter10.herokuapp.com/。
我真诚地希望你喜欢这一章和这本书,并且希望通过完成所有这些项目,你现在为在 ReactJS 中创建自己的项目打下了坚实的基础。



浙公网安备 33010602011771号