React16-高级教程-三-

112 阅读43分钟

React16 高级教程(三)

原文:Pro React 16

协议:CC BY-NC-SA 4.0

六、SportsStore:REST 和结帐

在本章中,我继续向我在第五章中创建的 SportsStore 应用添加特性。我添加了对从 web 服务中检索数据、在页面中显示大量数据以及结账和下订单的支持。

为本章做准备

本章不需要任何准备,它使用了在第五章中创建的 SportsStore 项目。要启动 React 开发工具和 RESTful web 服务,打开命令提示符,导航到sportsstore文件夹,运行清单 6-1 中所示的命令。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

npm start

Listing 6-1Starting the Development Tools and Web Service

初始构建过程需要几秒钟,之后一个新的浏览器窗口或选项卡将会打开并显示 SportsStore 应用,如图 6-1 所示。

img/473159_1_En_6_Fig1_HTML.jpg

图 6-1

运行 SportsStore 应用

使用 RESTful Web 服务

SportsStore 应用的基本结构正在成形,我已经有了足够的功能来移除占位符数据并开始使用 RESTful web 服务。在第七章中,我使用 GraphQL,它是 REST web 服务的一个更灵活(也更复杂)的替代方案,但是常规的 web 服务也很常见,我将使用 REST web 服务向 SportsStore 应用提供其产品数据,并在结账过程结束时提交订单。

我在第二十三章中更详细地描述了 REST,但是对于本章,我只需要一个基本的 HTTP 请求就可以开始了。打开新的浏览器选项卡并请求http://localhost:3500/api/products。浏览器将向 web 服务发送一个 HTTP GET 请求,该服务是在第五章中创建的,并由清单 6-1 中的命令启动。与 URL 结合的 GET 方法告诉 web 服务需要一个产品列表,并产生以下结果:

...
[{"id":1,"name":"Kayak","category":"Watersports",
     "description":"A boat for one person","price":275},
 {"id":2,"name":"Lifejacket","category":"Watersports",
     "description":"Protective and fashionable","price":48.95},
 {"id":3,"name":"Soccer Ball","category":"Soccer",
     "description":"FIFA-approved size and weight","price":19.5},
 {"id":4,"name":"Corner Flags","category":"Soccer",
     "description":"Give your playing field a professional touch","price":34.95},
 {"id":5,"name":"Stadium","category":"Soccer",
     "description":"Flat-packed 35,000-seat stadium","price":79500},
 {"id":6,"name":"Thinking Cap","category":"Chess",
     "description":"Improve brain efficiency by 75%","price":16},
 {"id":7,"name":"Unsteady Chair","category":"Chess",
     "description":"Secretly give your opponent a disadvantage","price":29.95},
 {"id":8,"name":"Human Chess Board","category":"Chess",
     "description":"A fun game for the family","price":75},
 {"id":9,"name":"Bling Bling King","category":"Chess",
     "description":"Gold-plated, diamond-studded King","price":1200}]
...

web 服务使用 JSON 数据格式响应请求,这在 React 应用中很容易处理,因为它类似于第四章中描述的 JavaScript 对象文字格式。在接下来的小节中,我将创建一个使用 web 服务的基础,并使用它来替换当前由 SportsStore 应用显示的静态数据。

创建配置文件

项目在生产和开发中经常需要不同的 URL。为了避免将 URL 硬编码到单独的 JavaScript 文件中,我在src/data文件夹中添加了一个名为Urls.js的文件,并用它来定义清单 6-2 中所示的配置数据。

import { DataTypes } from "./Types";

const protocol = "http";
const hostname = "localhost";
const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `${protocol}://${hostname}:${port}/api/products`,
    [DataTypes.CATEGORIES]: `${protocol}://${hostname}:${port}/api/categories`
}

Listing 6-2The Contents of the Urls.js File in the src/data Folder

当我在第八章准备部署 SportsStore 应用时,我将能够在一个地方配置访问 web 服务所需的 URL。为了保持一致性,我使用了已经为数据存储定义的数据类型,这有助于保持对不同类型数据的引用的一致性,并降低了输入错误的风险。

创建数据源

我在src/data文件夹中添加了一个名为RestDataSource.js的文件,并添加了清单 6-3 中所示的代码。我想合并负责向 web 服务发送 HTTP 请求并处理结果的代码,这样我就可以将它放在项目的一个地方。

import Axios from "axios";
import { RestUrls } from "./Urls";

export class RestDataSource {

    GetData = (dataType) =>
        this.SendRequest("get", RestUrls[dataType]);

    SendRequest = (method, url) => Axios.request({ method, url });
}

Listing 6-3The Contents of the RestDataSource.js File in the src/data Folder

RestDataSource类使用 Axios 包向 web 服务发出 HTTP 请求。Axios 在第二十三章中有所描述,它是一个处理 HTTP 的流行包,因为它提供了一致的 API,并自动处理响应,将 JSON 转换成 JavaScript 对象。在清单 6-3 中,GetData方法使用 Axios 向 web 服务发送 HTTP 请求,以获取指定数据类型的所有可用对象。来自GetData方法的结果是一个Promise,当从 web 服务收到响应时,这个结果被解析。

扩展数据存储

JavaScript 代码发送的 HTTP 请求是异步执行的。这不太符合 Redux 数据存储的默认行为,Redux 数据存储只在 reducer 处理一个动作时才响应变化。

Redux 数据存储可以扩展为支持使用中间件功能的异步操作,该功能检查发送到数据存储的动作,并在处理它们之前更改它们。在第二十章中,我创建了数据存储中间件,它在执行异步请求以获取数据时拦截动作并延迟它们。

对于 SportsStore 应用,我将采用一种不同的方法,并添加对有效负载为 a Promise的动作的支持,我在第四章中简要描述了这一点。中间件将一直等到Promise被解析,然后使用Promise的结果作为有效负载来传递动作。我在src/data文件夹中添加了一个名为AsyncMiddleware.js的文件,并添加了清单 6-4 中所示的代码。

const isPromise = (payload) =>
    (typeof(payload) === "object" || typeof(payload) === "function")
        && typeof(payload.then) === "function";

export const asyncActions = () => (next) => (action) => {
    if (isPromise(action.payload)) {
        action.payload.then(result => next({...action, payload: result}));
    } else {
        next(action)
    }
}

Listing 6-4The Contents of the AsyncMiddleware.js File in the src/data Folder

清单 6-4 中的代码包含一个函数,它通过查找函数或具有then函数的对象来检查动作的有效负载是否为PromiseasyncAction函数将被用作数据存储中间件,它调用Promise上的then来等待它被解析,此时它使用结果来替换有效负载并传递它,使用next函数,它继续通过数据存储的正常路径。有效载荷不是Promise的动作会被立即传递。在清单 6-5 中,我已经将中间件添加到数据存储中。

import { createStore, applyMiddleware } from "redux";

import { ShopReducer } from "./ShopReducer";
import { CartReducer } from "./CartReducer";
import { CommonReducer } from "./CommonReducer";

import { asyncActions } from "./AsyncMiddleware";

export const SportsStoreDataStore

    = createStore(CommonReducer(ShopReducer, CartReducer),

        applyMiddleware(asyncActions));

Listing 6-5Adding Middleware in the DataStore.js File in the src/data Folder

applyMiddleware用于包装中间件,以便它接收动作,结果作为参数传递给创建数据存储的createStore函数。其效果是清单 6-4 中定义的asyncActions函数将能够检查发送到数据存储的所有动作,并无缝地处理那些带有Promise有效负载的动作。

更新活动创建者

在清单 6-6 中,我从 store action creator 中删除了占位符数据,并用一个使用数据源发送请求的Promise代替了它。

import { ActionTypes} from "./Types";

//import { data as phData} from "./placeholderData";

import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType) => ({
    type: ActionTypes.DATA_LOAD,
    payload: dataSource.GetData(dataType)

        .then(response => ({ dataType, data: response.data}))

});

Listing 6-6Using a Promise in the ActionCreators.js File in the src/data Folder

当数据存储接收到由loadData函数创建的动作对象时,清单 6-5 中定义的中间件将等待从 web 服务接收到响应,然后传递动作进行正常处理,结果是 SportsStore 应用显示远程获取的数据,如图 6-2 所示。

img/473159_1_En_6_Fig2_HTML.jpg

图 6-2

使用来自 web 服务的数据

将数据分页

SportsStore 应用现在从 web 服务接收数据,但是大多数应用必须处理大量的数据,这些数据必须以页面的形式呈现给用户。在清单 6-7 中,我已经使用 Faker.js 包生成了大量的产品来替换 web 服务提供的数据。

var faker = require("faker");
var data = [];
var categories = ["Watersports", "Soccer", "Chess", "Running"];
faker.seed(100);
for (let i = 1; i <= 503; i++) {
    var category = faker.helpers.randomize(categories);
    data.push({
        id: i,
        name: faker.commerce.productName(),
        category: category,
        description: `${category}: ${faker.lorem.sentence(3)}`,
        price: Number(faker.commerce.price())
    })
}

module.exports = function () {
    return {
        categories: categories,
        products: data,
        orders: []
    }
}

Listing 6-7Increasing the Amount of Data in the data.js File in the sportsstore Folder

Faker.js 包是一个很好的工具,可以很容易地为开发和测试生成数据,通过在 https://github.com/Marak/Faker.js 描述的 API 提供上下文数据。当您保存data.js文件时,在第五章中创建的服务器代码将会检测到该更改,并将其加载到 web 服务中。在浏览器窗口中重新加载 SportsStore 应用,您将看到所有新产品都显示在一个列表中,如图 6-3 所示。用户仍然可以使用类别按钮来过滤产品,但是一次显示的数据仍然太多。

img/473159_1_En_6_Fig3_HTML.jpg

图 6-3

为测试分页生成更多数据

小费

清单 6-7 中的代码创建了 503 个产品对象。使用不可被您打算支持的页面大小整除的对象数量是一个好主意,这样您就可以确保您的代码处理最后一页上的一些掉队者。

了解 Web 服务分页支持

分页需要服务器的支持,以便它为客户端提供请求可用数据子集和关于有多少数据可用的信息的方法。没有提供分页的标准方法,您应该查阅所使用的服务器或服务的文档。

为 SportsStore 应用提供 RESTful web 服务的json-server包支持通过查询字符串进行分页。打开一个新的浏览器窗口,请求清单 6-8 中显示的 URL,看看分页是如何工作的。

http://localhost:3500/api/products?category_like=watersports&_page=2&_limit=3&_sort=name

Listing 6-8Requesting a Page of Data

这个 URL 的查询字符串——跟在?字符后面的部分——要求 web 服务使用表 6-1 中描述的字段返回一个特定类别的产品页面。

表 6-1

分页所需的查询字符串字段

|

名字

|

描述

| | --- | --- | | category_like | 该字段过滤结果,只包括那些 category 属性与字段值匹配的对象,字段值在示例 URL 中是Watersports。如果省略类别字段,则所有类别的产品都将包含在结果中。 | | _page | 该字段选择页码。 | | _limit | 该字段选择页面尺寸。 | | _sort | 此字段指定对象在分页前排序所依据的属性。 |

清单 6-8 中的 URL 要求 web 服务返回第二个页面,该页面包含集合中的三个产品,这些产品的category值为Watersports,按name属性排序,产生以下结果:

...
[
 {"id":469,"name":"Awesome Fresh Pants","category":"Watersports",
    "description":"Watersports: Quia quam aut.","price":864},
 {"id":19,"name":"Awesome Frozen Car","category":"Watersports",
     "description":"Watersports: A rerum mollitia.","price":314},
  {"id":182,"name":"Awesome Granite Fish", "category":"Watersports",
      description":"Watersports: Hic omnis incidunt.","price":521}
]
...

web 服务响应包含帮助客户端发出未来请求的标头。使用浏览器请求清单 6-9 中显示的 URL。

http://localhost:3500/api/products?_page=2&_limit=3

Listing 6-9Making a Simpler Pagination Request

更简单的 URL 使得结果标题更容易理解。使用浏览器的 F12 开发工具检查响应,您将看到它包含以下标题:

...
X-Total-Count: 503
Link: <http://localhost:3500/api/products?_page=1&_limit=3>; rel="first",
      <http://localhost:3500/api/products?_page=1&_limit=3>; rel="prev",
      <http://localhost:3500/api/products?_page=3&_limit=3>; rel="next",
      <http://localhost:3500/api/products?_page=168&_limit=3>; rel="last"
...

这些并不是响应中仅有的头,但它们是专门添加来帮助客户端处理未来的分页请求的。X-Total-Count头提供了请求 URL 匹配的对象总数,这对于确定页面总数很有用。由于在清单 6-9 的 URL 中没有category字段,服务器报告有 503 个对象可用。

Link头提供了一组 URL,可用于查询第一页和最后一页,以及当前页面之前和之后的页面,尽管客户端不需要使用Link头来制定后续请求。

更改 HTTP 请求和操作

在清单 6-10 中,我修改了获取产品数据的请求的 URL 的公式,以包含请求参数,这些参数将用于请求页面和指定类别。Axios 包将使用参数向请求 URL 添加查询字符串。

import Axios from "axios";
import { RestUrls } from "./Urls";

export class RestDataSource {

    GetData = async(dataType, params) =>

        this.SendRequest("get", RestUrls[dataType], params);

    SendRequest = (method, url, params) => Axios.request({
                method, url, params

    });
}

Listing 6-10Adding URL Parameters in the RestDataSource.js File in the src/data/rest Folder

在清单 6-11 中,我已经更新了由loadData动作创建器创建的动作,这样它就包含了参数,并且将响应中的附加信息添加到数据存储中。

import { ActionTypes } from "./Types";
import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType, params) => (

    {
        type: ActionTypes.DATA_LOAD,
        payload: dataSource.GetData(dataType, params).then(response =>

             ({ dataType,
                data: response.data,
                total: Number(response.headers["x-total-count"]),

                params

             })
        )
    })

Listing 6-11Changing the Action in the ActionCreators.js File in the src/data Folder

当数据存储中间件解析了Promise之后,发送给 reducer 的动作对象将包含payload.totalpayload.params属性。total属性将包含X-Total-Count头的值,我将使用它来创建分页导航控件。params属性将包含用于发出请求的参数,我将使用这些参数来确定用户何时做出了需要更多数据的 HTTP 请求的更改。在清单 6-12 中,我已经更新了处理DATA_LOAD动作的 reducer,这样新的动作属性就被添加到了数据存储中。

import { ActionTypes } from "./Types";

export const ShopReducer = (storeData, action) => {
    switch(action.type) {
        case ActionTypes.DATA_LOAD:
            return {
                ...storeData,
                [action.payload.dataType]: action.payload.data,
                [`${action.payload.dataType}_total`]: action.payload.total,

                [`${action.payload.dataType}_params`]: action.payload.params

            };
        default:
            return storeData || {};
    }
}

Listing 6-12Adding Data Store Properties in the ShopReducer.js File in the src/data Folder

创建数据加载组件

为了创建一个负责获取产品数据的组件,我在src/data文件夹中添加了一个名为DataGetter.js的文件,并用它来定义清单 6-13 中所示的组件。

import React, { Component } from "react";
import { DataTypes } from "../data/Types";

export class DataGetter extends Component {

    render() {
        return <React.Fragment>{ this.props.children }</React.Fragment>
    }

    componentDidUpdate = () => this.getData();
    componentDidMount = () => this.getData();

    getData = () => {
        const dsData = this.props.products_params || {} ;
        const rtData = {
            _limit: this.props.pageSize || 5,
            _sort: this.props.sortKey || "name",
            _page: this.props.match.params.page || 1,
            category_like: (this.props.match.params.category || "") === "all"
                ? "" : this.props.match.params.category
        }

        if (Object.keys(rtData).find(key => dsData[key] !== rtData[key])) {
            this.props.loadData(DataTypes.PRODUCTS, rtData);
        }
    }
}

Listing 6-13The Contents of the DataGetter.js File in the src/data Folder

该组件使用children属性呈现其父组件在开始和结束标记之间提供的内容。这对于定义向应用提供服务但不向用户呈现内容的组件很有用。在这种情况下,我需要一个组件,它可以接收当前路线及其参数的详细信息,还可以访问数据存储。组件的componentDidMountcomponentDidUpdate方法都是第十三章中描述的组件生命周期的一部分,它们调用getData方法,该方法从 URL 获取参数,并将它们与上次请求后添加到数据存储中的参数进行比较。如果发生了变化,将会分派一个新的动作来加载用户所需的数据。

除了从 URL 获取的类别和页码之外,还使用参数_sort_limit来创建操作,这些参数对结果进行排序并设置数据大小。用于排序和设置页面大小的值将从数据存储中获得。

更新存储连接器组件

为了在应用中引入分页支持,我更新了ShopConnector组件,它负责将应用中的商店功能连接到数据存储和 URL 路由。清单 6-14 中的更改添加了DataGetter组件,并删除了产品数据的类别过滤器(因为产品已经被 web 服务过滤了)。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";
import { loadData } from "../data/ActionCreators";
import { DataTypes } from "../data/Types";
import { Shop } from "./Shop";
import { addToCart, updateCartQuantity, removeFromCart, clearCart }
    from "../data/CartActionCreators";
import { CartDetails } from "./CartDetails";

import { DataGetter } from "../data/DataGetter";

const mapStateToProps = (dataStore) => ({
    ...dataStore
})

const mapDispatchToProps = {
    loadData,
    addToCart, updateCartQuantity, removeFromCart, clearCart
}

// const filterProducts = (products = [], category) =>

//     (!category || category === "All")

//         ? products

//         : products.filter(p =>

//               p.category.toLowerCase() === category.toLowerCase());

export const ShopConnector = connect(mapStateToProps, mapDispatchToProps)(
    class extends Component {
        render() {
            return <Switch>
                <Redirect from="/shop/products/:category"

                    to="/shop/products/:category/1" exact={ true } />

                <Route path={ "/shop/products/:category/:page" }

                    render={ (routeProps) =>
                        <DataGetter { ...this.props } { ...routeProps }>

                            <Shop { ...this.props } { ...routeProps }  />
                        </DataGetter>

                    } />
                <Route path="/shop/cart" render={ (routeProps) =>
                        <CartDetails { ...this.props } { ...routeProps }  />} />
                <Redirect to="/shop/products/all/1" />

            </Switch>
        }

        componentDidMount() {
            this.props.loadData(DataTypes.CATEGORIES);
            //this.props.loadData(DataTypes.PRODUCTS);

        }
    }
)

Listing 6-14Adding Pagination in the ShopConnector.js File in the src/shop Folder

我已经更新了路由配置以支持分页。第一个路由更改是添加了一个Redirect,它匹配有类别但没有页面的 URL,并将它们重定向到所选类别的第一个页面的 URL。我还修改了现有的Redirect,使其将任何不匹配的 URL 重定向到/shop/products/all

结果是代码块看起来比实际更复杂。当ShopConnector组件被请求呈现其内容时,它使用一个Route来匹配 URL 并获得categoryparameters,如下所示:

...
<Route path={ "/shop/products/:category/:page" }
...

紧接在Route之前的是一个Redirect,它匹配只有一个段的 URL,并将浏览器重定向到一个将选择第一页的 URL:

...
<Redirect from="/shop/products/:category"
          to="/shop/products/:category/1" exact={ true } />
...

这种重定向确保了总是有类别和页面值可以使用。另一个Redirect匹配任何其他 URL,并将它们重定向到产品第一页的 URL,不按类别过滤。

...
<Redirect to="/shop/products/all/1" />
...

更新所有类别按钮

清单 6-14 中使用的路由组件需要对All类别按钮进行相应的更改,以便在没有选择类别时高亮显示,如清单 6-15 所示。

import React, { Component } from "react";
import { ToggleLink } from "../ToggleLink";

export class CategoryNavigation extends Component {

    render() {
        return <React.Fragment>
            <ToggleLink to={ `${this.props.baseUrl}/all` } exact={ false }>

                All
            </ToggleLink>
            { this.props.categories && this.props.categories.map(cat =>
                <ToggleLink key={ cat }
                    to={ `${this.props.baseUrl}/${cat.toLowerCase()}`}>
                    { cat }
                </ToggleLink>
            )}
        </React.Fragment>
    }
}

Listing 6-15Updating the All Button in the CategoryNavigation.js File in the src/shop Folder

我已经将/all添加到由ToggleLink组件匹配的 URL 中,并将exact属性设置为false,这样像/shop/products/all/1这样的 URL 就会被匹配。效果是应用从 web 服务请求单独的数据页面,web 服务也负责基于类别进行过滤。每当用户点击一个类别按钮,DataGetter组件就会请求新的数据,如图 6-4 所示。

img/473159_1_En_6_Fig4_HTML.jpg

图 6-4

从 web 服务请求数据页面

创建分页控件

下一步是创建一个允许用户导航到不同页面并更改页面大小的组件。清单 6-16 定义了新的数据存储动作类型,这些动作类型将用于更改页面大小并指定将用于排序结果的属性。

export const DataTypes = {
    PRODUCTS: "products",
    CATEGORIES: "categories"
}

export const ActionTypes = {
    DATA_LOAD: "data_load",
    DATA_SET_SORT_PROPERTY: "data_set_sort",

    DATA_SET_PAGESIZE: "data_set_pagesize",

    CART_ADD: "cart_add",
    CART_UPDATE: "cart_update",
    CART_REMOVE: "cart_delete",
    CART_CLEAR: "cart_clear"
}

Listing 6-16Adding New Action Types in the Types.js File in the src/data Folder

在清单 6-17 中,我添加了使用新类型创建动作的新动作创建器。

import { ActionTypes } from "./Types";
import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType, params) => (
    {
        type: ActionTypes.DATA_LOAD,
        payload: dataSource.GetData(dataType, params).then(response =>
             ({ dataType,
                data: response.data,
                total: Number(response.headers["x-total-count"]),
                params
             })
        )
    })

export const setPageSize = (newSize) =>

    ({ type: ActionTypes.DATA_SET_PAGESIZE, payload: newSize});

export const setSortProperty = (newProp) =>

    ({ type: ActionTypes.DATA_SET_SORT_PROPERTY, payload: newProp});

Listing 6-17Defining Creators in the ActionCreators.js File in the src/data Folder

在清单 6-18 中,我扩展了 reducer 来支持新的动作。

import { ActionTypes } from "./Types";

export const ShopReducer = (storeData, action) => {
    switch(action.type) {
        case ActionTypes.DATA_LOAD:
            return {
                ...storeData,
                [action.payload.dataType]: action.payload.data,
                [`${action.payload.dataType}_total`]: action.payload.total,
                [`${action.payload.dataType}_params`]: action.payload.params
            };
        case ActionTypes.DATA_SET_PAGESIZE:

            return { ...storeData, pageSize: action.payload }

        case ActionTypes.DATA_SET_SORT_PROPERTY:

            return { ...storeData, sortKey: action.payload }

        default:
            return storeData || {};
    }
}

Listing 6-18Supporting New Actions in the ShopReducer.js File in the src/data Folder

为了生成允许用户使用分页特性的 HTML 元素,我在src文件夹中添加了一个名为PaginationControls.js的文件,并用它来定义清单 6-19 中所示的组件。

import React, { Component } from "react";
import { PaginationButtons } from "./PaginationButtons";

export class PaginationControls extends Component {

    constructor(props) {
        super(props);
        this.pageSizes = this.props.sizes || [5, 10, 25, 100];
        this.sortKeys = this.props.keys || ["Name", "Price"];
    }

    handlePageSizeChange = (ev) => {
        this.props.setPageSize(ev.target.value);
    }

    handleSortPropertyChange = (ev) => {
        this.props.setSortProperty(ev.target.value);
    }

    render() {
        return <div className="m-2">
                <div className="text-center m-1">
                    <PaginationButtons currentPage={this.props.currentPage}
                        pageCount={this.props.pageCount}
                        navigate={ this.props.navigateToPage }/>
                </div>
                <div className="form-inline justify-content-center">
                    <select className="form-control"
                            onChange={ this.handlePageSizeChange }
                            value={ this.props.pageSize|| this.pageSizes[0] }>
                        { this.pageSizes.map(s =>
                            <option value={s} key={s}>{s} per page</option>
                        )}
                    </select>
                    <select className="form-control"
                            onChange={ this.handleSortPropertyChange }
                            value={ this.props.sortKey || this.sortKeys[0] }>
                        { this.sortKeys.map(k =>
                            <option value={k.toLowerCase()} key={k}>
                                Sort By { k }
                            </option>
                        )}
                    </select>
            </div>
        </div>
    }
}

Listing 6-19The Contents of the PaginationControls.js File in the src Folder

PaginationControls组件使用select元素来允许用户更改页面大小和用于对结果进行排序的属性。提供可选择的单个值的option元素可以使用 props 进行配置,这将允许我在第七章的管理特性中重用这个组件。如果没有提供 props,那么使用适合分页产品的缺省值。

onChange属性应用于select元素以响应用户更改,这些更改由接收由更改触发的事件的方法处理,并调用从父组件接收的函数属性。

生成允许页面间移动的按钮的过程已经委托给一个名为PaginationButtons的组件。为了创建这个组件,我在src文件夹中添加了一个名为PaginationButtons.js的文件,并添加了清单 6-20 中所示的代码。

import React, { Component } from "react";

export class PaginationButtons extends Component {

    getPageNumbers = () => {
        if (this.props.pageCount < 4) {
            return [...Array(this.props.pageCount + 1).keys()].slice(1);
        } else if (this.props.currentPage <= 4) {
            return [1, 2, 3, 4, 5];
        } else  if (this.props.currentPage > this.props.pageCount - 4) {
            return [...Array(5).keys()].reverse()
                .map(v => this.props.pageCount - v);
        } else {
            return [this.props.currentPage -1, this.props.currentPage,
                this.props.currentPage + 1];
        }
    }

    render() {
        const current = this.props.currentPage;
        const pageCount = this.props.pageCount;
        const navigate = this.props.navigate;
        return <React.Fragment>
            <button onClick={ () => navigate(current  - 1) }
                disabled={ current === 1 } className="btn btn-secondary mx-1">
                    Previous
            </button>
            { current > 4 &&
                <React.Fragment>
                    <button className="btn btn-secondary mx-1"
                        onClick={ () => navigate(1)}>1</button>
                    <span className="h4">...</span>
                </React.Fragment>
            }
            { this.getPageNumbers().map(num =>
                <button className={ `btn mx-1 ${num === current
                        ? "btn-primary": "btn-secondary"}`}
                    onClick={ () => navigate(num)} key={ num }>
                        { num }
                </button>)}
            { current <= (pageCount - 4) &&
                <React.Fragment>
                    <span className="h4">...</span>
                    <button className="btn btn-secondary mx-1"
                            onClick={ () => navigate(pageCount)}>
                        { pageCount }
                    </button>
                </React.Fragment>
            }
            <button onClick={ () => navigate(current + 1) }
                disabled={ current === pageCount }
                className="btn btn-secondary mx-1">
                    Next
            </button>
        </React.Fragment>
    }
}

Listing 6-20The Contents of the PaginationButtons.js File in the src Folder

创建分页按钮是一个复杂的过程,很容易陷入细节中。我在清单 6-20 中采用的方法旨在简单性和为用户提供足够的上下文来浏览大量数据之间取得平衡。

为了将分页控件连接到商店中的产品数据,我在src/shop文件夹中添加了一个名为ProductPageConnector.js的文件,并定义了清单 6-21 中所示的组件。

import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import { setPageSize, setSortProperty } from "../data/ActionCreators";

const mapStateToProps = dataStore => dataStore;
const mapDispatchToProps = { setPageSize, setSortProperty };

const mergeProps = (dataStore, actionCreators, router) => ({
    ...dataStore, ...router, ...actionCreators,
    currentPage: Number(router.match.params.page),
    pageCount: Math.ceil((dataStore.products_total
        | dataStore.pageSize || 5)/(dataStore.pageSize || 5)),
    navigateToPage: (page) => router.history
        .push(`/shop/products/${router.match.params.category}/${page}`),
})

export const ProductPageConnector = (PageComponent) =>
    withRouter(connect(mapStateToProps, mapDispatchToProps,
        mergeProps)(PageComponent))

Listing 6-21The Contents of the ProductPageConnector.js File in the src/shop Folder

正如我前面解释的那样,React 应用中的复杂性通常集中在不同功能组合的地方,这就是 SportsStore 应用中的连接器组件。清单 6-21 中的代码创建了一个高阶组件(称为 HOC 并在第十四章中描述),这是一个通过其 props 向另一个组件提供特性的函数。这个 HOC 被命名为ProductPageConnector,它结合了数据存储属性、动作创建器和路由参数,为分页控件组件提供了对它们所需特性的访问。connect函数与我在第五章中使用的用于将组件连接到数据存储的函数相同,它与withRouter函数一起使用,后者是 React 路由包中的对应函数,它为组件提供了来自最近的Route的路由细节。在清单 6-22 中,我将高阶组件应用于PaginationControls组件,并将结果添加到呈现给用户的内容中。

import React, { Component } from "react";
import { CategoryNavigation } from "./CategoryNavigation";
import { ProductList } from "./ProductList";
import { CartSummary } from "./CartSummary";

import { ProductPageConnector } from "./ProductPageConnector";

import { PaginationControls } from "../PaginationControls";

const ProductPages = ProductPageConnector(PaginationControls);

export class Shop extends Component {

    handleAddToCart = (...args) => {
        this.props.addToCart(...args);
        this.props.history.push("/shop/cart");
    }

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                    <CartSummary { ...this.props } />
                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">
                    <CategoryNavigation baseUrl="/shop/products"
                        categories={ this.props.categories } />
                </div>
                <div className="col-9 p-2">
                    <ProductPages />

                    <ProductList products={ this.props.products }
                        addToCart={ this.handleAddToCart } />
                </div>
            </div>
        </div>
    }
}

Listing 6-22Adding Pagination Controls in the Shop.js File in the src/shop Folder

结果是一系列允许用户在页面间移动的按钮,以及改变排序属性和页面大小的select元素,如图 6-5 所示。

img/473159_1_En_6_Fig5_HTML.jpg

图 6-5

添加对分页产品的支持

添加结账流程

该应用的核心功能已经就绪,允许用户过滤和浏览产品数据,并将商品添加到以摘要和详细视图显示的购物篮中。一旦用户完成了结帐过程,一个新的订单必须被发送到 web 服务,web 服务将完成购物,重置用户的购物车,并显示一条摘要消息。在接下来的小节中,我将添加对结账和下订单的支持。

扩展 REST 数据源和数据存储

正如我在第二十三章中解释的,当一个 RESTful web 服务接收到一个 HTTP 请求时,它使用请求方法(也称为动词)和 URL 的组合来决定应该执行什么操作。为了向 web 服务发送订单,我将向 web 服务的/orders URL 发送一个 POST 请求。为了使新特性与现有应用保持一致,我首先添加了一个标识订单数据类型的常数和一个存储订单的新动作,如清单 6-23 所示。

export const DataTypes = {
    PRODUCTS: "products",
    CATEGORIES: "categories",
    ORDERS: "orders"

}

export const ActionTypes = {
    DATA_LOAD: "data_load",
    DATA_STORE: "data_store",

    DATA_SET_SORT_PROPERTY: "data_set_sort",
    DATA_SET_PAGESIZE: "data_set_pagesize",
    CART_ADD: "cart_add",
    CART_UPDATE: "cart_update",
    CART_REMOVE: "cart_delete",
    CART_CLEAR: "cart_clear"
}

Listing 6-23Adding Types in the Types.js File in the src/data Folder

新的数据类型允许我定义下订单的 URL,如清单 6-24 所示。当我添加对管理特性的支持时,我也在第七章中使用它。

import { DataTypes } from "./Types";

const protocol = "http";
const hostname = "localhost";
const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `${protocol}://${hostname}:${port}/api/products`,
    [DataTypes.CATEGORIES]: `${protocol}://${hostname}:${port}/api/categories`,
    [DataTypes.ORDERS]: `${protocol}://${hostname}:${port}/api/orders`

}

Listing 6-24Adding a New URL in the Urls.js File in the src/data Folder

在清单 6-25 中,我向 REST 数据源添加了一个方法,该方法接收订单对象并将其发送给 web 服务。

import Axios from "axios";
import { RestUrls } from "./Urls";

export class RestDataSource {

    constructor(err_handler) {
        this.error_handler = err_handler || (() => {});
    }

    GetData = (dataType, params) =>
        this.SendRequest("get", RestUrls[dataType], params);

    StoreData = (dataType, data) =>

        this.SendRequest("post", RestUrls[dataType], {}, data);

    SendRequest = (method, url, params, data) =>

        Axios.request({ method, url, params, data });
}

Listing 6-25Adding a Method in the RestDataSource.js File in the src/data Folder

Axios 包将接收一个数据对象,并负责对其进行格式化,以便将其发送到 web 服务。在清单 6-26 中,我添加了一个新的动作创建器,它使用一个Promise向 web 服务发送订单。web 服务将返回存储的数据,其中包括一个唯一的标识符。

import { ActionTypes, DataTypes } from "./Types";

import { RestDataSource } from "./RestDataSource";

const dataSource = new RestDataSource();

export const loadData = (dataType, params) => (
    {
        type: ActionTypes.DATA_LOAD,
        payload: dataSource.GetData(dataType, params).then(response =>
             ({ dataType,
                data: response.data,
                total: Number(response.headers["x-total-count"]),
                params
             })
        )
    })

export const setPageSize = (newSize) => {
    return ({ type: ActionTypes.DATA_SET_PAGESIZE, payload: newSize});
}

export const setSortProperty = (newProp) =>
    ({ type: ActionTypes.DATA_SET_SORT_PROPERTY, payload: newProp});

export const placeOrder = (order) => ({

        type: ActionTypes.DATA_STORE,

        payload: dataSource.StoreData(DataTypes.ORDERS, order).then(response => ({

            dataType: DataTypes.ORDERS, data: response.data

        }))

    })

Listing 6-26Adding a Creator to the ActionCreators.js File in the src/data Folder

为了处理结果并将订单添加到数据存储中,我添加了清单 6-27 中所示的缩减器。

import { ActionTypes, DataTypes } from "./Types";

export const ShopReducer = (storeData, action) => {
    switch(action.type) {
        case ActionTypes.DATA_LOAD:
            return {
                ...storeData,
                [action.payload.dataType]: action.payload.data,
                [`${action.payload.dataType}_total`]: action.payload.total,
                [`${action.payload.dataType}_params`]: action.payload.params
            };
        case ActionTypes.DATA_SET_PAGESIZE:
            return { ...storeData, pageSize: action.payload }
        case ActionTypes.DATA_SET_SORT_PROPERTY:
            return { ...storeData, sortKey: action.payload }
        case ActionTypes.DATA_STORE:

            if (action.payload.dataType === DataTypes.ORDERS) {

                return { ...storeData, order: action.payload.data }

            }

            break;

        default:
            return storeData || {};
    }
}

Listing 6-27Storing an Order in the ShopReducer.js File in the src/data Folder

创建签出表单

要完成 SportsStore 订单,用户必须填写一个包含个人详细信息的表单,这意味着我必须向用户提供一个表单。React 支持两种使用表单元素的方式:受控非受控。对于受控元素,React 管理元素的内容并响应其更改事件。用于配置分页的select元素就属于这一类。对于 checkout 表单,我将使用不受控制的元素,这些元素不受 React 的严密管理,更多地依赖于浏览器的功能。使用不受控制的 for 元素的关键是一个名为 refs 的特性,在第十六章中有描述,它允许 React 组件在 HTML 元素显示给用户后,跟踪由它的render方法产生的 HTML 元素。对于结帐表单,使用refs的好处是我可以使用 HTML5 验证 API 来验证表单,我在第十五章中对此进行了描述。验证 API 要求直接访问表单元素,如果不使用 refs,这是不可能的。

注意

React 应用中有一些可用于创建和验证表单的包,但是它们可能很难使用,也很难对表单的外观或它生成的数据结构施加限制。使用第十五章和第十六章中描述的特性很容易创建定制表单和验证,这是我在 SportsStore 章节中采用的方法。

创建经过验证的表单

我将创建一个带有验证的可重用表单,它将通过编程生成所需的字段。我创建了src/forms文件夹,并在其中添加了一个名为ValidatedForm.js的文件,我用它来定义清单 6-28 中所示的组件。

import React, { Component } from "react";
import { ValidationError } from "./ValidationError";
import { GetMessages } from "./ValidationMessages";

export class ValidatedForm extends Component {

    constructor(props) {
        super(props);
        this.state = {
            validationErrors: {}
        }
        this.formElements = {};
    }

    handleSubmit = () => {
        this.setState(state => {
            const newState = { ...state, validationErrors: {} }
            Object.values(this.formElements).forEach(elem => {
                if (!elem.checkValidity()) {
                    newState.validationErrors[elem.name] = GetMessages(elem);
                }
            })
            return newState;
        }, () => {
            if (Object.keys(this.state.validationErrors).length === 0) {
                const data =  Object.assign(...Object.entries(this.formElements)
                    .map(e => ({[e[0]]: e[1].value})) )
                this.props.submitCallback(data);
            }
        });
    }

    registerRef = (element) => {
        if (element !== null) {
            this.formElements[element.name] = element;
        }
    }

    renderElement = (modelItem) => {
        const name = modelItem.name || modelItem.label.toLowerCase();
        return <div className="form-group" key={ modelItem.label }>
            <label>{ modelItem.label }</label>
            <ValidationError errors={ this.state.validationErrors[name] } />
            <input className="form-control" name={ name } ref={ this.registerRef }
                { ...this.props.defaultAttrs } { ...modelItem.attrs } />
        </div>
    }

    render() {
        return <React.Fragment>
            { this.props.formModel.map(m => this.renderElement(m))}
            <div className="text-center">
                <button className="btn btn-secondary m-1"
                        onClick={ this.props.cancelCallback }>
                    { this.props.cancelText || "Cancel" }
                </button>
                <button className="btn btn-primary m-1"
                        onClick={ this.handleSubmit }>
                    { this.props.submitText || "Submit"}
                </button>
            </div>
        </React.Fragment>
    }
}

Listing 6-28The Contents of the ValidatedForm.js File in the src/forms Folder

ValidatedForm组件接收一个数据模型并使用它创建一个表单,该表单使用 HTML5 API 进行验证。每个表单元素都有一个标签和一个向用户显示验证消息的ValidationError组件。该表单显示有按钮,这些按钮使用作为属性提供的回调函数来取消或提交表单。除非所有元素都满足验证约束,否则不会调用submit回调。

当调用提交回调时,它将接收一个对象,该对象的属性是表单元素的name属性值,其值是用户输入到每个字段中的数据。

定义表单

为了创建用于显示错误消息的组件,我在src/forms文件夹中添加了一个名为ValidationError.js的文件,并添加了清单 6-29 中所示的代码。

import React, { Component } from "react";

export class ValidationError extends Component {

    render() {
        if (this.props.errors) {
            return this.props.errors.map(err =>
                <h6 className="text-danger" key={err}>
                    { err }
                </h6>
            )
        }
        return null;
    }
}

Listing 6-29The Contents of the ValidationError.js File in the src/forms Folder

验证 API 以一种尴尬的方式呈现验证错误,如第十六章所述。为了创建可以显示给用户的消息,我在src/forms文件夹中添加了一个名为ValidationMessages.js的文件,并定义了清单 6-30 中所示的函数。

export const GetMessages = (elem) => {
    const messages = [];
    if (elem.validity.valueMissing) {
        messages.push("Value required");
    }
    if (elem.validity.typeMismatch) {
        messages.push(`Invalid ${elem.type}`);
    }
    return messages;
}

Listing 6-30The Contents of the ValidationMessages.js File in the src/forms Folder

为了使用验证过的表单进行签出,我在src/shop文件夹中添加了一个名为Checkout.js的文件,并定义了清单 6-31 中所示的组件。

import React, { Component } from "react";
import { ValidatedForm } from "../forms/ValidatedForm";

export class Checkout extends Component {

    constructor(props) {
        super(props);
        this.defaultAttrs = { type: "text", required: true };
        this.formModel = [
                { label: "Name"},
                { label: "Email", attrs: { type: "email" }},
                { label: "Address" },
                { label: "City"},
                { label: "Zip/Postal Code", name: "zip"},
                { label: "Country"}]
    }

    handleSubmit = (formData) => {
        const order = { ...formData, products: this.props.cart.map(item =>
            ({  quantity: item.quantity, product_id: item.product.id})) }
        this.props.placeOrder(order);
        this.props.clearCart();
        this.props.history.push("/shop/thanks");
    }

    handleCancel = () => {
        this.props.history.push("/shop/cart");
    }

    render() {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col m-2">
                    <ValidatedForm formModel={ this.formModel }
                        defaultAttrs={ this.defaultAttrs }
                        submitCallback={ this.handleSubmit }
                        cancelCallback={ this.handleCancel }
                        submitText="Place Order"
                        cancelText="Return to Cart" />
                </div>
            </div>
        </div>
    }
}

Listing 6-31The Contents of the Checkout.js File in the src/shop Folder

Checkout组件使用一个ValidatedForm向用户显示他们的姓名、电子邮件和地址。每个表单元素都将创建有required属性,电子邮件地址的input元素的type属性被设置为email。这些属性由 HTML5 约束验证 API 使用,并且将阻止用户下订单,除非他们为所有字段提供值并且在电子邮件字段中输入有效的电子邮件地址(尽管应当注意,仅验证电子邮件地址的格式)。

当用户提交有效的表单数据时,将调用handleSubmit方法。这个方法接收表单数据,并在调用placeOrderclearCart动作创建者之前,将表单数据与用户购物车的详细信息结合起来,然后导航到/shop/thanks URL。

创建感谢组件

为了向用户提供订单确认并完成结账过程,我在src/shop文件夹中添加了一个名为Thanks.js的文件,并定义了清单 6-32 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class Thanks extends Component {

    render() {
        return <div>
            <div className="col bg-dark text-white">
                <div className="navbar-brand">SPORTS STORE</div>
            </div>
            <div className="m-2 text-center">
                <h2>Thanks!</h2>
                <p>Thanks for placing your order.</p>
                <p>Your order is #{ this.props.order ? this.props.order.id : 0 }</p>
                <p>We'll ship your goods as soon as possible.</p>
                <Link to="/shop" className="btn btn-primary">
                    Return to Store
                </Link>
            </div>
        </div>
    }
}

Listing 6-32The Contents of the Thanks.js File in the src/shop Folder

Thanks组件显示一条简单的消息,并包含来自order对象的id属性的值,该值是通过其order属性获得的。这个组件将连接到数据存储,它包含的order对象将有一个由 RESTful web 服务分配的id值。

应用新组件

为了给应用添加新的组件,我修改了ShopConnector组件中的路由配置,如清单 6-33 所示。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";

import { loadData, placeOrder } from "../data/ActionCreators";

import { DataTypes } from "../data/Types";
import { Shop } from "./Shop";
import { addToCart, updateCartQuantity, removeFromCart, clearCart }
    from "../data/CartActionCreators";
import { CartDetails } from "./CartDetails";
import { DataGetter } from "../data/DataGetter";

import { Checkout } from "./Checkout";

import { Thanks } from "./Thanks";

const mapStateToProps = (dataStore) => ({
    ...dataStore
})

const mapDispatchToProps = {
    loadData,
    addToCart, updateCartQuantity, removeFromCart, clearCart,
    placeOrder

}

export const ShopConnector = connect(mapStateToProps, mapDispatchToProps)(
    class extends Component {
        render() {
            return <Switch>
                <Redirect from="/shop/products/:category"
                    to="/shop/products/:category/1" exact={ true } />
                <Route path={ "/shop/products/:category/:page" }
                    render={ (routeProps) =>
                        <DataGetter { ...this.props } { ...routeProps }>
                            <Shop { ...this.props } { ...routeProps } />
                        </DataGetter>
                    } />
                <Route path="/shop/cart" render={ (routeProps) =>
                        <CartDetails { ...this.props } { ...routeProps } />} />
                <Route path="/shop/checkout" render={ routeProps =>

                    <Checkout { ...this.props } { ...routeProps } /> } />

                <Route path="/shop/thanks" render={ routeProps =>

                    <Thanks { ...this.props } { ...routeProps } /> } />

                <Redirect to="/shop/products/all/1" />
            </Switch>
        }

        componentDidMount() {
            this.props.loadData(DataTypes.CATEGORIES);
        }
    }
)

Listing 6-33Adding New Routes in the ShopConnector.js File in the src/shop Folder

结果允许用户结帐。要测试新功能,请导航到http://localhost:3000,将一个或多个产品添加到购物车,然后单击结账按钮,这将显示如图 6-6 所示的表单。如果您在填写表单之前单击 Place Order 按钮,您将看到验证警告,如图所示。

img/473159_1_En_6_Fig6_HTML.jpg

图 6-6

签出时出现验证错误

注意

只有当用户单击按钮时,才会执行验证。参见第十五章和第十六章,查看每次击键后验证表单元素内容的示例。

如果您已经填写了所有字段并输入了有效的电子邮件地址,当您单击下单按钮时,您的订单将被下单,显示图 6-7 所示的摘要。

img/473159_1_En_6_Fig7_HTML.jpg

图 6-7

下订单

打开一个新的浏览器选项卡并请求http://localhost:3500/api/orders,响应将显示您所下订单的 JSON 表示,如下所示:

...
[{
  "name":"Bob Smith","email":"bob@example.com",
  "address":"123 Main Street","city":"New York","zip":"NY 10036",
  "country":"USA","products":[{"quantity":1,"product_id":318}],"id":1
 }]
...

每次下订单时,RESTful web 服务都会给它分配一个id,然后显示在订单摘要中。

小费

每次使用npm start命令启动开发工具时,web 服务使用的数据都会重新生成,这使得重置应用变得很容易。在第八章中,我将 SportsStore 应用切换到一个持久数据库,作为部署准备的一部分。

简化车间连接器组件

SportsStore 应用的购物部分所需的所有特性都已完成,但我将在本章中再做一处修改。

React 应用由其 props 驱动,props 为组件提供它们所需的数据和功能。当使用 URL 路由和数据存储等功能时,将它们的功能转化为属性的过程会变得复杂。对于 SportsStore 应用,这是ShopConnector组件,它包含数据存储属性、动作创建器和应用购物部分的 URL 路由。整合这些功能的好处是,其他购物组件的编写、维护和测试更简单。缺点是合并会导致代码难以阅读,并且很可能出现错误。

当我向应用添加特性时,我添加了一个新的Route,它选择了一个组件,并为它提供了从数据存储和 URL 路由访问 props 的权限。我本可以更具体地说明每个组件收到的属性,这是我在本书后面的许多示例中遵循的做法。然而,对于 SportsStore 项目,我让每个组件都可以访问所有的属性,这是一种使开发更容易的方法,并且允许在添加所有功能后整理路由代码。在清单 6-34 中,我简化了购物功能的连接器。

import React, { Component } from "react";
import { Switch, Route, Redirect }
    from "react-router-dom"
import { connect } from "react-redux";

import * as ShopActions from "../data/ActionCreators";

import { DataTypes } from "../data/Types";
import { Shop } from "../shop/Shop";

import  * as CartActions from "../data/CartActionCreators";

import { CartDetails } from "../shop/CartDetails";
import { DataGetter } from "../data/DataGetter";
import { Checkout } from "../shop/Checkout";
import { Thanks } from "../shop/Thanks";

const mapDispatchToProps = { ...ShopActions, ...CartActions};

export const ShopConnector = connect(ds => ds, mapDispatchToProps)(

    class extends Component {

        selectComponent = (routeProps) => {

            const wrap = (Component, Content) =>

                <Component { ...this.props}  { ...routeProps}>

                    { Content && wrap(Content)}

                </Component>

            switch (routeProps.match.params.section) {

                case "products":

                    return wrap(DataGetter, Shop);

                case "cart":

                    return wrap(CartDetails);

                case "checkout":

                    return wrap(Checkout);

                case "thanks":

                    return wrap(Thanks);

                default:

                    return <Redirect to="/shop/products/all/1" />

            }

        }

        render() {
            return <Switch>
                <Redirect from="/shop/products/:category"
                    to="/shop/products/:category/1" exact={ true } />
                <Route path={ "/shop/:section?/:category?/:page?"}

                    render = { routeProps => this.selectComponent(routeProps) } />

            </Switch>
        }

        componentDidMount = () => this.props.loadData(DataTypes.CATEGORIES);

    }
)

Listing 6-34Simplifying the Code in the ShopConnector.js File in the src/connectors Folder

在第九章中,我解释了 JSX 是如何被翻译成 JavaScript 的,但是很容易忘记,所有的组件都可以被重构,从而更少地依赖 HTML 元素的声明性,更多地依赖纯 JavaScript。在清单 6-34 中,我将多个Route组件合并成一个组件,其render函数选择应该向用户显示的组件,并从数据存储和 URL 路由为其提供属性。我还为动作创建者修改了import语句,并在将它们映射到功能属性时使用了 spread 操作符,我之前没有这么做,因为我想展示我是如何将每个数据存储特性连接到应用的其余部分的。

摘要

在本章中,我继续开发了 SportsStore 文件夹,添加了对使用 RESTful web 服务器的支持,增加了应用可以处理的数据量,并添加了对结账和下订单的支持。在下一章中,我将管理特性添加到 SportsStore 应用中。

七、SportsStore:管理

在本章中,我将管理特性添加到 SportsStore 应用中,提供管理订单和产品所需的工具。我在本章中使用 GraphQL,而不是扩展我在 SportsStore 的面向客户部分使用的 RESTful web 服务。GraphQL 是传统 web 服务的一种替代方案,它让客户端控制它接收的数据,尽管它需要更多的初始设置,并且使用起来可能更复杂。

为本章做准备

本章基于在第五章创建并在第六章修改的 SportsStore 项目。为了准备本章,我将生成一些假订单,这样就有数据可以处理,如清单 7-1 所示。

小费

你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。

var faker = require("faker");
faker.seed(100);
var categories = ["Watersports", "Soccer", "Chess"];
var products = [];
for (let i = 1; i <= 503; i++) {
    var category = faker.helpers.randomize(categories);
    products.push({
        id: i,
        name: faker.commerce.productName(),
        category: category,
        description: `${category}: ${faker.lorem.sentence(3)}`,
        price: Number(faker.commerce.price())
    })
}
var orders = [];
for (let i = 1; i <= 103; i++) {
    var fname = faker.name.firstName(); var sname = faker.name.lastName();
    var order = {
        id: i, name: `${fname} ${sname}`,
        email: faker.internet.email(fname, sname),
        address: faker.address.streetAddress(), city: faker.address.city(),
        zip: faker.address.zipCode(), country: faker.address.country(),
        shipped: faker.random.boolean(), products:[]
    }
    var productCount = faker.random.number({min: 1, max: 5});
    var product_ids = [];
    while (product_ids.length < productCount) {
        var candidateId = faker.random.number({ min: 1, max: products.length});
        if (product_ids.indexOf(candidateId) === -1) {
            product_ids.push(candidateId);
        }
    }
    for (let j = 0; j < productCount; j++) {
        order.products.push({
            quantity: faker.random.number({min: 1, max: 10}),
            product_id: product_ids[j]
        })
    }
    orders.push(order);
}

module.exports = () => ({ categories, products, orders })

Listing 7-1Altering the Application Data in the data.js File in the sportsstore Folder

运行示例应用

打开一个新的命令提示符,导航到sportsstore文件夹,运行清单 7-2 中所示的命令。

npm start

Listing 7-2Running the Example Application

React 开发工具和 RESTful web 服务将会启动。一旦开发工具编译了 SportsStore 应用,将会打开一个新的浏览器窗口,并显示图 7-1 中所示的内容。

img/473159_1_En_7_Fig1_HTML.jpg

图 7-1

运行示例应用

创建 GraphQL 服务

我在本章中添加到 SportsStore 应用的管理特性将使用 GraphQL,而不是 RESTful web 服务。很少有真正的应用需要混合 REST 和 GraphQL 来处理相同的数据,但是我想演示这两种远程服务方法。

GraphQL 并不特定于 React 开发,但它与 React 的关系如此密切,以至于我在第二十四章中介绍了 GraphQL,并在第二十五章中演示了 React 应用使用 GraphQL 服务的不同方式。

小费

我将为 SportsStore 应用创建一个定制的 GraphQL 服务器,这样我就可以与优秀的json-server包提供的 RESTful web 服务共享数据。正如我在第二十四章中解释的,有开源和商业 GraphQL 服务器可用。

定义 GraphQL 模式

GraphQL 要求它的所有操作都在一个模式中定义。为了定义服务将支持的查询模式,我在sportsstore文件夹中创建了一个名为serverQueriesSchema.graphql的文件,其内容如清单 7-3 所示。

type product { id: ID!, name: String!, description: String! category: String!
               price: Float! }

type productPage { totalSize: Int!, products(sort: String, page: Int, pageSize: Int): [product]}

type orderPage { totalSize: Int, orders(sort: String, page: Int, pageSize: Int): [order]}

type order {
    id: ID!, name: String!, email: String!, address: String!, city: String!,
    zip: String!, country: String!, shipped: Boolean, products: [productSelection]
}

type productSelection { quantity: Int!, product: product }

type Query {
    product(id: ID!): product
    products(category: String, sort: String, page: Int, pageSize: Int): productPage
    categories: [String]
    orders(onlyUnshipped: Boolean): orderPage
}

Listing 7-3The Contents of serverQueriesSchema.graphql in the sportsstore Folder

GraphQL 规范包括一种模式语言,用于定义服务提供的特性。清单 7-3 中的模式定义了对产品、类别和订单的查询。productorder查询支持分页,并返回包含一个totalSize属性的结果,该属性报告可用项目的数量,因此客户端可以向用户提供分页控件。可以按类别过滤产品,也可以过滤订单,以便只显示未发货的订单。

在 GraphQL 中,使用突变来执行更改,遵循分离读写数据操作的主题,这在 React 开发中很常见。我在sportsstore文件夹中添加了一个名为serverMutationsSchema.graphql的文件,并用它来定义清单 7-4 中显示的突变。

input productStore {
    name: String!, description: String!, category: String!, price: Float!
}

input productUpdate {
    id: ID!, name: String, description: String, category: String, price: Float
}

type Mutation {
    storeProduct(product: productStore): product
    updateProduct(product: productUpdate): product
    deleteProduct(id: ID!): product
    shipOrder(id: ID!, shipped: Boolean!): order
}

Listing 7-4The Contents of the serverMutationsSchema.graphql File in the sportsstore Folder

清单 7-4 中的模式定义了存储新产品、更新和删除现有产品以及将订单标记为已装运或未装运的变化。

定义 GraphQL 解析器

GraphQL 服务中的模式是由解析器实现的。为了给查询提供解析器,我在sportsstore文件夹中添加了一个名为serverQueriesResolver.js的文件,代码如清单 7-5 所示。

const paginateQuery = (query, page = 1, pageSize = 5) =>
    query.drop((page - 1) * pageSize).take(pageSize);

const product = ({id}, {db}) => db.get("products").getById(id).value();

const products = ({ category }, { db}) => ({
    totalSize: () => db.get("products")
        .filter(p => category ? new RegExp(category, "i").test(p.category) : p)
        .size().value(),
    products: ({page, pageSize, sort}) => {
        let query = db.get("products");
        if (category) {
            query = query.filter(item =>
                new RegExp(category, "i").test(item.category))
        }
        if (sort) { query = query.orderBy(sort) }
        return paginateQuery(query, page, pageSize).value();
    }
})

const categories = (args, {db}) => db.get("categories").value();

const resolveProducts = (products, db) =>
    products.map(p => ({
        quantity: p.quantity,
        product: product({ id: p.product_id} , {db})
    }))

const resolveOrders = (onlyUnshipped, { page, pageSize, sort}, { db }) => {
    let query = db.get("orders");
    if (onlyUnshipped) { query = query.filter({ shipped: false}) }
    if (sort) { query = query.orderBy(sort) }
    return paginateQuery(query, page, pageSize).value()
        .map(order => ({ ...order, products: () =>
            resolveProducts(order.products, db) }));
}

const orders = ({onlyUnshipped = false}, {db}) => ({
    totalSize: () => db.get("orders")
        .filter(o => onlyUnshipped ? o.shipped === false : o).size().value(),
    orders: (...args) => resolveOrders(onlyUnshipped, ...args)
})

module.exports = { product, products, categories, orders }

Listing 7-5The Contents of the serverQueriesResolver.js File in the sportsstore Folder

清单 7-5 中的代码实现了清单 7-3 中定义的查询。你可以在第二十四章看到一个独立的定制 GraphQL 服务器的例子,但是清单 7-5 中的代码依赖于 Lowdb 数据库,这个数据库是json-server包用来存储数据的,在 https://github.com/typicode/lowdb 中有详细描述。

当客户端请求特定字段时,使用一系列调用的函数来解析每个查询,确保服务器只加载和处理需要的数据。例如,对于orders查询,函数链确保服务器只需在客户机请求时查询数据库中相关的product对象,避免检索不需要的数据。

为了实现这些变化,我在sportsstore文件夹中添加了一个名为serverMutationsResolver.js的文件,并添加了清单 7-6 中所示的代码。

const storeProduct = ({ product}, {db }) =>
    db.get("products").insert(product).value();

const updateProduct = ({ product }, { db }) =>
    db.get("products").updateById(product.id, product).value();

const deleteProduct = ({ id }, { db }) => db.get("products").removeById(id).value();

const shipOrder = ({ id, shipped }, { db }) =>
    db.get("orders").updateById(id, { shipped: shipped}).value()

module.exports = {
    storeProduct, updateProduct, deleteProduct, shipOrder
}

Listing 7-6The Contents of the serverMutationsResolver.js File in the sportsstore Folder

清单 7-6 中定义的每个函数对应于清单 7-4 中定义的一个变异。实现变异所需的代码比查询简单,因为查询需要额外的语句来过滤和分页数据。

更新服务器

在第五章中,我向 SportsStore 项目添加了创建 GraphQL 服务器所需的包。在清单 7-7 中,我已经使用这些包为后端服务器添加了对 GraphQL 的支持,该服务器已经为 SportsStore 应用提供了 RESTful web 服务。

const express = require("express");
const jsonServer = require("json-server");
const chokidar = require('chokidar');
const cors = require("cors");

const fs = require("fs");

const { buildSchema } = require("graphql");

const graphqlHTTP = require("express-graphql");

const queryResolvers  = require("./serverQueriesResolver");

const mutationResolvers = require("./serverMutationsResolver");

const fileName = process.argv[2] || "./data.js"
const port = process.argv[3] || 3500;

let router = undefined;

let graph = undefined;

const app = express();

const createServer = () => {
    delete require.cache[require.resolve(fileName)];
    setTimeout(() => {
        router = jsonServer.router(fileName.endsWith(".js")
                ? require(fileName)() : fileName);
        let schema =  fs.readFileSync("./serverQueriesSchema.graphql", "utf-8")

            + fs.readFileSync("./serverMutationsSchema.graphql", "utf-8");

        let resolvers = { ...queryResolvers, ...mutationResolvers };

        graph = graphqlHTTP({

            schema: buildSchema(schema), rootValue: resolvers,

            graphiql: true, context: { db: router.db }

        })

    }, 100)
}

createServer();

app.use(cors());
app.use(jsonServer.bodyParser)
app.use("/api", (req, resp, next) => router(req, resp, next));

app.use("/graphql", (req, resp, next) => graph(req, resp, next));

chokidar.watch(fileName).on("change", () => {
    console.log("Reloading web service data...");
    createServer();
    console.log("Reloading web service data complete.");
});

app.listen(port, () => console.log(`Web service running on port ${port}`));

Listing 7-7Adding GraphQL in the server.js File in the sportsstore Folder

添加的内容加载模式和解析器,并使用它们创建一个与现有 RESTful web 服务共享数据库的 GraphQL 服务。停止开发工具,运行sportsstore文件夹中清单 7-8 所示的命令,再次启动它们,这也将启动 GraphQL 服务器。

npm start

Listing 7-8Starting the Development Tools and Services

为了确保 GraphQL 服务器正在运行,导航到http://localhost:3500/graphql,这将显示如图 7-2 所示的工具。

img/473159_1_En_7_Fig2_HTML.jpg

图 7-2

图形浏览器

我用来创建 GraphQL 服务器的包包括 GraphQL 浏览器,这使得探索 graph QL 服务变得很容易。用清单 7-9 中所示的 GraphQL 变体替换窗口左侧的欢迎消息。

注意

每次使用npm start命令 2 时,RESTful web 服务和 GraphQL 服务使用的数据都会被重置,这意味着当您下次启动服务器时,清单 7-9 中的变化所做的更改将会丢失。在第八章中,我将 SportsStore 应用转换为一个持久数据库,作为部署准备的一部分。

mutation {
    updateProduct(product: {
        id: 272, price: 100
    }) { id, name, category, price }
}

Listing 7-9A GraphQL Mutation

单击“执行查询”按钮将变异发送到 GraphQL 服务器,这将更新数据库中的产品并产生以下结果:

...
{
  "data": {
    "updateProduct": {
      "id": "272",
      "name": "Awesome Concrete Pizza",
      "category": "Soccer",
      "price": 100

    }
  }
}
...

导航回http://localhost:3000(或者重新加载浏览器标签,如果它仍然打开),你会看到列表中显示的第一个产品的价格已经改变,如图 7-3 所示。

img/473159_1_En_7_Fig3_HTML.jpg

图 7-3

GraphQL 突变的影响

创建订单管理功能

GraphQL 需要在服务器端做更多的工作来创建模式和编写解析器,但好处是客户端比使用 RESTful web 服务的客户端简单得多。在某种程度上,这是因为 GraphQL 使用定义良好但灵活的查询的方式,但也是因为 GraphQL 客户端包提供了许多有用的功能,我不得不在第 5 和 6 章手动创建这些功能。

注意

我在 SportsStore 一章中使用 GraphQL 的方式是最简单的方法,但是它隐藏了 GraphQL 如何工作的细节。在第二十五章中,我演示了如何通过 HTTP 直接使用 GraphQL,以及如何将 GraphQL 集成到使用数据存储的应用中。

定义订单表组件

我将从创建订单显示开始。为了定义显示订单数据的组件,我在src/admin文件夹中添加了一个名为OrdersTable.js的文件,并添加了清单 7-10 中所示的代码。

import React, { Component } from "react";
import { OrdersRow } from "./OrdersRow";
import { PaginationControls } from "../PaginationControls";

export class OrdersTable extends Component {

    render = () =>
        <div>
            <h4 className="bg-info text-white text-center p-2">
                { this.props.totalSize } Orders
            </h4>

            <PaginationControls keys={["ID", "Name"]}
                { ...this.props } />

            <table className="table table-sm table-striped">
                <thead>
                    <tr><th>ID</th>
                        <th>Name</th><th>Email</th>
                        <th className="text-right">Total</th>
                        <th className="text-center">Shipped</th>
                    </tr>
                </thead>
                <tbody>
                    { this.props.orders.map(order =>
                        <OrdersRow key={ order.id }
                            order={ order} toggleShipped={ () =>
                                this.props.toggleShipped(order.id, !order.shipped) }
                        />
                    )}
                </tbody>
            </table>
        </div>
}

Listing 7-10The Contents of the OrdersTable.js File in the src/admin Folder

OrdersTable组件显示订单总数,并呈现一个表格,其中每一行的责任都委托给了OrdersRow组件,我通过将一个名为OrdersRow.js的文件添加到src/admin文件夹中来定义该组件,代码如清单 7-11 所示。

import React, { Component } from "react";

export class OrdersRow extends Component {

    calcTotal = (products) => products.reduce((total, p) =>
        total += p.quantity * p.product.price, 0).toFixed(2)

    getShipping = (order) => order.shipped
        ? <i className="fa fa-shipping-fast text-success" />
        : <i className="fa fa-exclamation-circle text-danger" />

    render = () =>
        <tr>
            <td>{ this.props.order.id }</td>
            <td>{this.props.order.name}</td>
            <td>{ this.props.order.email }</td>
            <td className="text-right">
                ${ this.calcTotal(this.props.order.products) }
            </td>
            <td className="text-center">
                <button className="btn btn-sm btn-block bg-muted"
                        onClick={ this.props.toggleShipped }>
                    { this.getShipping(this.props.order )}
                    <span>
                        { this.props.order.shipped
                            ? " Shipped" : " Pending"}
                    </span>
                </button>
            </td>
        </tr>
}

Listing 7-11The Contents of the OrdersRow.js File in the src/admin Folder

定义连接器组件

当 GraphQL 客户机查询它的服务器时,它为查询定义的任何参数提供值,并指定它想要接收的数据字段。这是与大多数 RESTful web 服务的最大区别,这意味着 GraphQL 客户端只接收它们需要的数据值。但是,这意味着在从服务器检索数据之前,必须定义客户端查询。我喜欢将查询与组件分开定义,我在src/admin文件夹中添加了一个名为clientQueries.js的文件,其内容如清单 7-12 所示。

import gql from "graphql-tag";

export const ordersSummaryQuery = gql`
    query($onlyShipped: Boolean, $page:Int, $pageSize:Int, $sort:String) {
        orders(onlyUnshipped: $onlyShipped) {
            totalSize,
            orders(page: $page, pageSize: $pageSize, sort: $sort) {
                id, name, email, shipped
                products {
                    quantity, product { price }
                }
            }
        }
    }`

Listing 7-12The Contents of the clientQueries.js File in the src/admin Folder

GraphQL 查询在客户端应用中被定义为 JavaScript 字符串文字,但是必须使用来自graphql-tag包的gql函数进行处理。清单 7-12 中的查询以服务器的orders查询为目标,并将接受用于查询的onlyShippedpagepageSize,sort参数的变量。客户机查询只选择它需要的字段,并包含与每个订单相关的产品数据的详细信息,这些信息包含在服务器解析器为orders查询生成的查询结果中。

GraphQL 客户端包React-Apollo提供了graphql函数,它是前面使用的connectwithRouter函数的对应物,它通过创建一个高阶组件将一个组件连接到 GraphQL 特性,该组件是一个为组件提供特性的函数,如第十四章所述。为了在OrdersTable组件和清单 7-12 中定义的查询之间创建连接,我向src/admin文件夹添加了一个名为OrdersConnector.js的文件,并添加了清单 7-13 中所示的代码。

import { graphql } from "react-apollo";
import { ordersSummaryQuery } from "./clientQueries";
import { OrdersTable } from "./OrdersTable";

const vars = {
    onlyShipped: false, page: 1, pageSize: 10, sort: "id"
}

export const OrdersConnector = graphql(ordersSummaryQuery,
    {
        options: (props) => ({ variables: vars }),
        props: ({data: { loading, orders, refetch }}) => ({
            totalSize: loading ? 0 : orders.totalSize,
            orders: loading ? []: orders.orders,
            currentPage: vars.page,
            pageCount: loading ? 0 : Math.ceil(orders.totalSize / vars.pageSize),
            navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
            pageSize: vars.pageSize,
            setPageSize: (size) => { vars.pageSize = Number(size); refetch(vars)},
            sortKey: vars.sort,
            setSortProperty: (key) => { vars.sort = key; refetch(vars)},
        })
    }
)(OrdersTable)

Listing 7-13The Contents of the OrdersConnector.js File in the src/admin Folder

graphql函数接受查询和配置对象的参数,并返回一个用于包装组件并为其提供查询功能的函数。配置对象支持许多属性,但我只需要两个。第一个是options属性,它用于创建将应用于 GraphQL 查询的变量集,使用一个函数接收父组件应用的属性。

小费

Apollo GraphQL 客户机缓存查询结果,这样就不会向服务器发送重复的请求,例如,当使用带有路由的组件时,这就很有用。

第二个是props属性,用于创建将被传递给显示组件的属性,并提供有一个data对象,该对象组合了查询进度的详细信息、来自服务器的响应以及用于刷新查询的函数。

我从数据对象中选择了三个属性,并用它们来为OrdersTable组件创建属性。当查询被发送到服务器并等待响应时,loading属性是true,这允许我在收到 GraphQL 响应之前使用占位符值。查询的结果被分配给一个给定查询名的属性,在本例中是orders。来自查询的响应的结构如下:

...
{ "orders":
  { "totalSize":103,
    "orders":
      {"id":"1","name":"Velva Dietrich","email":"Velva_Dietrich@yahoo.com",
       "shipped":false, "products":[{"quantity":8,"product":{"price":84 },
      {"quantity":7,"product":{"price":125}, {"quantity":3,"product":{"price":352}
          ...other data values omitted for brevity...

  }
}
...

例如,为了获得可用订单的总数,我读取了orders.totalSize属性的值,如下所示:

...
totalSize: loading ? 0 : orders.totalSize,
...

在收到来自服务器的结果之前,totalSize属性的值为零,然后被赋予orders.totalSize的值。

我从data对象中选择的第三个属性是refetch,它是一个重新发送查询的函数,我用它来响应分页更改。

...
navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
...

为了简洁起见,我将所有的查询变量传递给了refetch函数,但是该函数接收到的任何值都与原始变量合并,这对于更复杂的查询来说非常有用。

小费

还有一个可用的fetchMore函数,可用于检索数据并将其与现有结果合并,这对于逐渐构建呈现给用户的数据的组件非常有用。我对 SportsStore 应用采用了一种更简单的方法,每一页数据都替换了之前的查询结果。

配置 GraphQL 客户端

对 GraphQL 客户端特性的访问是通过ApolloProvider组件提供的。为了配置 GraphQL 客户端并为其他管理特性创建一个方便的占位符,我创建了src/admin文件夹并向其中添加了一个名为Admin.js的文件,我用它来定义清单 [7-14 中所示的组件。

import React, { Component } from "react";
import  ApolloClient from "apollo-boost";
import { ApolloProvider} from "react-apollo";
import { GraphQlUrl } from "../data/Urls";
import { OrdersConnector } from "./OrdersConnector"

const graphQlClient = new ApolloClient({
    uri: GraphQlUrl
});

export class Admin extends Component {

    render() {
        return <ApolloProvider client={ graphQlClient }>
            <div className="container-fluid">
                <div className="row">
                <div className="col bg-info text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col p-2">
                    <OrdersConnector />
                </div>
            </div>
        </div>
        </ApolloProvider>
    }
}

Listing 7-14The Contents of the Admin.js File in the src/admin Folder

为了开始使用管理特性,我将显示一个OrdersTable组件,我将在下一节中创建它。我将返回到Admin并使用 URL 路由来显示附加功能。为了设置将用于与 GraphQL 服务器通信的 URL,我将清单 7-15 中所示的语句添加到了Urls.js文件中。

import { DataTypes } from "./Types";

const protocol = "http";
const hostname = "localhost";
const port = 3500;

export const RestUrls = {
    [DataTypes.PRODUCTS]: `${protocol}://${hostname}:${port}/api/products`,
    [DataTypes.CATEGORIES]: `${protocol}://${hostname}:${port}/api/categories`,
    [DataTypes.ORDERS]: `${protocol}://${hostname}:${port}/api/orders`
}

export const GraphQlUrl = `${protocol}://${hostname}:${port}/graphql`;

Listing 7-15Adding a URL in the Urls.js File in the src/data Folder

GraphQL 只需要一个 URL,因为与 REST 不同,它不使用 URL 或 HTTP 方法来描述操作。在第八章中,我将在准备项目部署时更改应用使用的 URL。

为了将新特性加入到应用中,我将清单 7-16 中所示的路线添加到了App组件中。

import React, { Component } from "react";
import { SportsStoreDataStore } from "./data/DataStore";
import { Provider } from "react-redux";
import { BrowserRouter as Router, Route, Switch, Redirect }
    from "react-router-dom";
import { ShopConnector } from "./shop/ShopConnector";

import { Admin } from "./admin/Admin";

export default class App extends Component {

    render() {
        return <Provider store={ SportsStoreDataStore }>
            <Router>
                <Switch>
                    <Route path="/shop" component={ ShopConnector } />
                    <Route path="/admin" component={ Admin } />

                    <Redirect to="/shop" />
                </Switch>
            </Router>
        </Provider>
    }
}

Listing 7-16Adding a Route in the App.js File in the src Folder

将更改保存到文件并导航到http://localhost:3000/admin,您将看到如图 7-4 所示的结果。

img/473159_1_En_7_Fig4_HTML.jpg

图 7-4

从组件生成 GraphQL 查询

配置突变

相同的查询基本方法可以应用于将突变集成到 React 应用中。为了允许管理员将订单标记为已发货,我在src/admin文件夹中添加了一个名为clientMutations.js的文件,其内容如清单 7-17 所示。

import gql from "graphql-tag";

export const shipOrder = gql`
    mutation($id: ID!, $shipped: Boolean!) {
        shipOrder(id: $id, shipped: $shipped) {
            id, shipped
        }
    }`

Listing 7-17The Contents of the clientMutations.js File in the src/admin Folder

GraphQL 的目标是shipOrder突变,它更新由订单的id属性的值指定的订单的shipped属性。在清单 7-18 中,我使用了graphql函数来提供对突变及其结果的访问。

import { graphql, compose } from "react-apollo";

import { ordersSummaryQuery } from "./clientQueries";
import { OrdersTable } from "./OrdersTable";

import { shipOrder } from "./clientMutations";

const vars = {
    onlyShipped: false, page: 1, pageSize: 10, sort: "id"
}

export const OrdersConnector = compose(

    graphql(ordersSummaryQuery,
        {
            options: (props) => ({ variables: vars }),
            props: ({data: { loading, orders, refetch }}) => ({
                totalSize: loading ? 0 : orders.totalSize,
                orders: loading ? []: orders.orders,
                currentPage: vars.page,
                pageCount: loading ? 0 : Math.ceil(orders.totalSize / vars.pageSize),
                navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
                pageSize: vars.pageSize,
                setPageSize: (size) =>
                    { vars.pageSize = Number(size); refetch(vars)},
                sortKey: vars.sort,
                setSortProperty: (key) => { vars.sort = key; refetch(vars)},
            })
        }
    ),
    graphql(shipOrder, {

        props: ({ mutate }) => ({

            toggleShipped: (id, shipped) => mutate({ variables: { id, shipped }})

        })

    })

)(OrdersTable);

Listing 7-18Applying a Mutation in the OrdersConnector.js File in the src/admin Folder

React-Apollo 包提供了简化查询和变异组合的compose函数。现有的查询与对graphql函数的另一个调用相结合,该函数被传递了清单 7-17 中的变异。当使用变异时,配置对象中的props属性接收一个名为mutate的函数,我用它来创建一个名为toggleShipped的属性,对应于OrdersRow组件用来更改订单状态的属性。要查看结果,单击表格中订单的已发货/待处理指示器,其状态将会改变,如图 7-5 所示。

img/473159_1_En_7_Fig5_HTML.jpg

图 7-5

利用突变

当有变化时,Apollo 客户机自动更新其数据缓存,这意味着 shipped 属性的值的变化会自动反映在由OrdersTable组件显示的数据中。

创建产品管理功能

为了管理呈现给用户的产品,我在src/admin文件夹中添加了一个名为ProductsTable.js的文件,并用它来定义清单 7-19 中所示的组件。

import React, { Component } from "react";
import { Link } from "react-router-dom";
import { PaginationControls } from "../PaginationControls";
import { ProductsRow } from "./ProductsRow";

export class ProductsTable extends Component {

    render = () =>
         <div>
            <h4 className="bg-info text-white text-center p-2">
                { this.props.totalSize } Products
            </h4>

        <PaginationControls keys={["ID", "Name", "Category"]}
            { ...this.props } />

        <table className="table table-sm table-striped">
            <thead>
                <tr><th>ID</th>
                    <th>Name</th><th>Category</th>
                    <th className="text-right">Price</th>
                    <th className="text-center"></th>
                </tr>
            </thead>
            <tbody>
                { this.props.products.map(prod =>
                    <ProductsRow key={ prod.id} product={ prod }
                        deleteProduct={ this.props.deleteProduct } />
                )}
            </tbody>
        </table>
        <div className="text-center">
            <Link to="/admin/products/create" className="btn btn-primary">
                Create Product
            </Link>
        </div>
    </div>
}

Listing 7-19The Contents of the ProductsTable.js File in the src/admin Folder

ProductsTable组件通过其products属性接收一个对象数组,并使用ProductsRow组件为每个对象生成一个表格行。还有一个Link按钮,用于导航到允许创建新产品的组件。

为了创建负责单个表格行的ProductsRow组件,我在src/admin文件夹中添加了一个名为ProductsRow.js的文件,并添加了清单 7-20 中所示的代码。

import React, { Component } from "react";
import { Link } from "react-router-dom";

export class ProductsRow extends Component {

    render = () =>
        <tr>
            <td>{ this.props.product.id }</td>
            <td>{this.props.product.name}</td>
            <td>{ this.props.product.category }</td>
            <td className="text-right">
                ${ this.props.product.price.toFixed(2) }
            </td>
            <td className="text-center">
                <button className="btn btn-sm btn-danger mx-1"
                    onClick={ () =>
                        this.props.deleteProduct(this.props.product.id) }>
                            Delete
                </button>
                <Link to={`/admin/products/${this.props.product.id}`}
                    className="btn btn-sm btn-warning">
                        Edit
                </Link>
            </td>
        </tr>
}

Listing 7-20The Contents of the ProductsRow.js File in the src/admin Folder

idnamecategoryprice属性呈现表格单元格。有一个button调用一个名为deleteProduct的函数 prop,它将从数据库中删除一个产品,还有一个Link将导航到用于编辑产品细节的组件。

连接产品表组件

为了将产品表组件连接到 GraphQL 数据,我将清单 7-21 中所示的查询添加到了clientQueries.js文件中,其中还包括编辑产品所需的查询。这些查询对应于本章开始时定义的服务器端 GraphQL。

import gql from "graphql-tag";

export const ordersSummaryQuery = gql`
    query($onlyShipped: Boolean, $page:Int, $pageSize:Int, $sort:String) {
        orders(onlyUnshipped: $onlyShipped) {
            totalSize,
            orders(page: $page, pageSize: $pageSize, sort: $sort) {
                id, name, email, shipped
                products {
                    quantity, product { price }
                }
            }
        }
    }`

export const productsList = gql`

    query($page: Int, $pageSize: Int, $sort: String) {

        products {

            totalSize,

            products(page: $page, pageSize: $pageSize, sort: $sort) {

                id, name, category, price

            }

        }

    }`

export const product = gql`

    query($id: ID!) {

        product(id: $id) {

            id, name, description, category, price

        }

    }`

Listing 7-21Adding Queries in the clientQueries.js File in the src/admin Folder

分配给名为productsList的常量的查询将检索一页产品的idnamecategoryprice属性。分配给名为product的常量的查询将检索单个product对象的idnamedescriptioncategoryprice属性。为了添加对删除、创建和编辑对象的支持,我将清单 7-22 中所示的变化添加到了clientMutations.js文件中。

import gql from "graphql-tag";

export const shipOrder = gql`
    mutation($id: ID!, $shipped: Boolean!) {
        shipOrder(id: $id, shipped: $shipped) {
            id, shipped
        }
    }`

export const storeProduct = gql`

    mutation($product: productStore) {

        storeProduct(product: $product) {

            id, name, category, description, price

        }

    }`

export const updateProduct = gql`

    mutation($product: productUpdate) {

        updateProduct(product: $product) {

            id, name, category, description, price

        }

    }`

export const deleteProduct = gql`

    mutation($id: ID!) {

        deleteProduct(id: $id) {

            id

        }

    }`

Listing 7-22Adding Mutations in the clientMutations.js File in the src/admin Folder

新的变化对应于本章开始时定义的服务器端 GraphQL,并允许客户端存储新产品、编辑现有产品和删除产品。

定义了查询和突变之后,我将一个名为ProductsConnector.js的文件添加到src/admin文件夹中,并定义了清单 7-23 中所示的高阶组件。

import { graphql, compose } from "react-apollo";
import { ProductsTable } from "./ProductsTable";
import { productsList } from "./clientQueries";
import { deleteProduct } from "./clientMutations";

const vars = {
    page: 1, pageSize: 10, sort: "id"
}

export const ConnectedProducts = compose(
    graphql(productsList,
        {
            options: (props) => ({ variables: vars }),
            props: ({data: { loading, products, refetch }}) => ({
                totalSize: loading ? 0 : products.totalSize,
                products: loading ? []: products.products,
                currentPage: vars.page,
                pageCount: loading ? 0
                    : Math.ceil(products.totalSize / vars.pageSize),
                navigateToPage: (page) => { vars.page = Number(page); refetch(vars)},
                pageSize: vars.pageSize,
                setPageSize: (size) =>
                    { vars.pageSize = Number(size); refetch(vars)},
                sortKey: vars.sort,
                setSortProperty: (key) => { vars.sort = key; refetch(vars)},
            })
        }
    ),
    graphql(deleteProduct,
        {
            options: {
                update: (cache, { data: { deleteProduct: { id }}}) => {
                    const queryDetails = { query: productsList, variables: vars };
                    const data = cache.readQuery(queryDetails)
                    data.products.products =
                        data.products.products.filter(p => p.id !== id);
                    data.products.totalSize = data.products.totalSize - 1;
                    cache.writeQuery({...queryDetails, data });
                }
        },
        props: ({ mutate }) => ({
            deleteProduct: (id) => mutate({ variables: { id }})
        })
    })
)(ProductsTable);

Listing 7-23The Contents of the ProductsConnector.js File in the src/admin Folder

清单 7-23 中的代码类似于订单管理特性的相应代码。一个关键的区别是删除对象的突变不会自动更新本地缓存的数据。对于这种类型的变异,必须定义一个直接修改缓存数据的update函数,如下所示:

...
update: (cache, { data: { deleteProduct: { id }}}) => {
    const queryDetails = { query: productsList, variables: vars };
    const data = cache.readQuery(queryDetails)
    data.products.products = data.products.products.filter(p => p.id !== id);
    data.products.totalSize = data.products.totalSize - 1;
    cache.writeQuery({...queryDetails, data });
}
...

这个函数读取缓存的数据,删除一个对象,减少totalSize以反映删除,然后将数据写回缓存,这样会有更新产品列表的效果,而不需要查询服务器。

小费

这种方法的缺点是,它不会对数据重新分页以反映删除,这意味着在用户导航到另一个页面之前,页面显示的项目较少。在下一节中,我将演示如何通过清除缓存数据来解决这个问题,这将导致一个额外的 GraphQL 查询,但可以确保应用的一致性。

创建编辑器组件

为了允许用户创建新产品,我在src/admin文件夹中添加了一个名为ProductEditor.js的文件,并定义了清单 7-24 中所示的组件。

import React, { Component } from "react";
import { Query } from "react-apollo";
import { ProductCreator } from "./ProductCreator";
import { product } from "./clientQueries";

export class ProductEditor extends Component {

    render = () =>
        <Query query={ product } variables={ {id: this.props.match.params.id} } >
            { ({ loading, data }) => {
                if (!loading) {
                    return <ProductCreator {...this.props } product={data.product}
                        mode="edit" />
                }
                return null;
            }}
        </Query>
}

Listing 7-24The Contents of the ProductEditor.js File in the src/admin Folder

Query组件是作为graphql函数的替代提供的,它允许以声明方式执行 GraphQL 查询,结果和其他客户端特性通过渲染属性函数呈现,这将在第十四章中描述。清单 7-24 中定义的ProductEditor组件将获取管理员想要编辑的产品的id,并使用Query组件获取,该组件使用其queryvariables属性进行配置。render prop 函数接收一个具有loadingdata属性的对象,其目的与我之前使用的graphql函数相同。当 loading 属性为true时,ProductEditor组件不呈现任何内容,然后显示一个ProductCreator组件,通过名为product的属性传递从查询中接收的数据。

ProductCreator组件将在 SportsStore 应用中执行双重任务。当单独使用时,它将向管理员呈现一个空表单,该表单将被发送到storeProduct突变。当被ProductEditor组件使用时,它将显示现有产品的详细信息,并将表单数据发送给updateProduct变异。为了定义组件,我用清单 7-25 所示的代码向src/admin文件夹添加了一个名为ProductCreator.js的文件。

import React, { Component } from "react";
import { ValidatedForm } from "../forms/ValidatedForm";
import { Mutation } from "react-apollo";
import { storeProduct, updateProduct } from "./clientMutations";

export class ProductCreator extends Component {

    constructor(props) {
        super(props);
        this.defaultAttrs = { type: "text", required: true };
        this.formModel = [
            { label: "Name" }, { label: "Description" },
            { label: "Category" },
            { label: "Price", attrs: { type: "number"}}
        ];
        this.mutation = storeProduct;
        if (this.props.mode === "edit" ) {
            this.mutation = updateProduct;
            this.formModel = [ { label: "Id", attrs: { disabled: true }},
                     ...this.formModel]
                .map(item => ({ ...item, attrs: { ...item.attrs,
                    defaultValue: this.props.product[item.label.toLowerCase()]} }));
        }
    }

    navigate = () => this.props.history.push("/admin/products");

    render = () => {
        return <div className="container-fluid">
            <div className="row">
                <div className="col bg-dark text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col m-2">
                    <Mutation mutation={ this.mutation }>
                        { (saveMutation, {client }) => {
                            return <ValidatedForm formModel={ this.formModel }
                                defaultAttrs={ this.defaultAttrs }
                                submitCallback={ data => {
                                    saveMutation({variables: { product:
                                        { ...data, price: Number(data.price) }}});
                                    if (this.props.mode !== "edit" ) {
                                        client.resetStore();
                                    }
                                    this.navigate();
                                }}
                                cancelCallback={ this.navigate }
                                submitText="Save" cancelText="Cancel" />
                        }}
                    </Mutation>
                </div>
            </div>
        </div>
    }
}

Listing 7-25The Contents of the ProductCreator.js File in the src/admin Folder

ProductCreator组件依赖于我在第六章中创建的ValidatedForm来处理应用购物部分的结账。该表单配置了编辑产品所需的字段,当通过product prop 提供时,这些字段将包括从 GraphQL 查询中获得的值。

Query组件相对应的是Mutation,它允许在render函数中使用变异。render prop 函数接收一个函数,该函数被调用以将突变发送到服务器,并接受一个为突变提供变量的对象,如下所示:

...
<Mutation mutation={ this.mutation }>
    { (saveMutation, {client }) => {
        return <ValidatedForm formModel={ this.formModel }
            defaultAttrs={ this.defaultAttrs }
            submitCallback={ data => {

                saveMutation({variables: { product:

                    { ...data, price: Number(data.price) }}});

                    if (this.props.mode !== "edit" ) {

                        client.resetStore();

                    }

                    this.navigate();

                }}

                cancelCallback={ this.navigate }
                submitText="Save" cancelText="Cancel" />
        }
    }
</Mutation>
...

我突出显示了设置函数 prop 的代码部分,该函数 prop 被传递给ValidatedForm组件,并在被调用时发送变异。当一个对象被更新时,Apollo 客户机自动更新它的缓存数据以反映这一变化,就像我在本章前面将订单标记为已发货一样。新对象不会被自动处理,这意味着应用必须负责管理缓存。我删除一个对象的方法是更新现有的缓存,但是对于一个新的条目来说,这是一个复杂得多的过程,因为这意味着要确定它是否应该显示在当前页面上,如果应该,它应该出现在排序顺序中的什么位置。作为一个更简单的选择,我从 render prop 函数接收了一个client参数,它允许我通过它的resetStore方法清除缓存的数据。当navigate函数将浏览器发送回产品列表时,一个新的 GraphQL 将被发送到服务器,这确保了数据被一致地分页和排序,尽管代价是额外的查询。

更新路由配置

最后一步是更新路由配置,添加允许选择订单和产品管理特性的导航按钮,如清单 7-26 所示。

import React, { Component } from "react";
import  ApolloClient from "apollo-boost";
import { ApolloProvider} from "react-apollo";
import { GraphQlUrl } from "../data/Urls";
import { OrdersConnector } from "./OrdersConnector"

import { Route, Redirect, Switch } from "react-router-dom";

import { ToggleLink } from "../ToggleLink";

import { ConnectedProducts } from "./ProductsConnector";

import { ProductEditor } from "./ProductEditor";

import { ProductCreator } from "./ProductCreator";

const graphQlClient = new ApolloClient({
    uri: GraphQlUrl
});

export class Admin extends Component {

    render() {
        return <ApolloProvider client={ graphQlClient }>
            <div className="container-fluid">
                <div className="row">
                <div className="col bg-info text-white">
                    <div className="navbar-brand">SPORTS STORE</div>
                </div>
            </div>
            <div className="row">
                <div className="col-3 p-2">

                    <ToggleLink to="/admin/orders">Orders</ToggleLink>

                    <ToggleLink to="/admin/products">Products</ToggleLink>

                </div>

                <div className="col-9 p-2">

                    <Switch>

                        <Route path="/admin/orders" component={ OrdersConnector } />

                        <Route path="/admin/products/create"

                            component={ ProductCreator} />

                        <Route path="/admin/products/:id"

                            component={ ProductEditor} />

                        <Route path="/admin/products"

                            component={ ConnectedProducts } />

                        <Redirect to="/admin/orders" />

                    </Switch>

                </div>
            </div>
        </div>
        </ApolloProvider>
    }
}

Listing 7-26Updating the Routing Configuration in the Admin.js File in the src/admin Folder

保存更改,您将看到如图 7-6 所示的布局。单击“产品”按钮将显示一个分页的产品表,可以使用每个表行中的按钮删除和编辑这些产品。

img/473159_1_En_7_Fig6_HTML.jpg

图 7-6

产品管理功能

点击创建产品按钮将显示一个编辑器,允许定义新产品,如图 7-7 所示。

img/473159_1_En_7_Fig7_HTML.jpg

图 7-7

创造新产品

摘要

在本章中,我向 SportsStore 应用添加了管理功能。我首先创建了一个 GraphQL 服务,其中包含管理订单和产品数据所需的查询和变化。我使用 GraphQL 服务来扩展应用特性,依靠 GraphQL 客户端来管理应用中的数据,这样我就不需要创建和管理数据存储。在下一章中,我将为管理特性添加身份验证,并为应用的部署做准备。