React 蓝图(三)
原文:
zh.annas-archive.org/md5/a2bffca9c62012ab857fb3d1f735ef00译者:飞龙
第五章:使用 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 应用程序。卷起袖子吧,因为我们将要制作一个围绕拍摄图片的社交网络。