十、JavaScript 函数式编程

学习目标

在本章结束时,你将能够:

  • 在 Redux 还原器和选择器中使用纯函数
  • 解决高级功能测试情况
  • 在现代 JavaScript 应用中应用 curry、局部应用和闭包
  • 实现一个 compose 函数,用于用微构建的后端为前端(BFF)
  • 在 Redux 应用中,应用 JavaScript 内置程序以不可变的样式编写
  • 在 BFF 的上下文中使用 GraphQL 实现查询和突变
  • 在 React/Redux 应用中选择三种处理副作用的方法

在本章中,您将学习函数式编程的概念,如何在 JavaScript 中应用它们,并在 React、Redux 等流行库和 GraphQL 查询语言等系统中“野外”识别它们。

简介

函数式编程严重依赖于函数的数学定义。 数学函数是通过声明表达式定义的。 函数式编程风格也是声明式的(与命令式编程相反),它提倡表达式而不是语句。

JavaScript 内置了函数式编程结构。 了解 JavaScript 中的函数式编程风格对于深入理解这门语言及其生态系统至关重要。

作为每一节的一部分,JavaScript 中的 React、Redux 和 DOM 访问和测试模式将用于说明 JavaScript 中的实用函数编程。 更近期的开发,如 GraphQL 和后端前端(BFFs)也将包括在内,以展示函数式编程是如何渗透到 JavaScript 编程语言的现在和未来的。

函数式编程的概念可以解释为什么 Redux reducer 和 React 渲染函数不能包含 API 调用。 许多 JavaScript 模式和最佳实践都是通过语言中的函数构造实现的; 利用函数式编程可以产生更有表现力、更简洁的 JavaScript 程序,更容易进行推理、修改和扩展。

职能——一流公民

函数是一级意味着语言认为它们与任何其他“值”类型类似。 这意味着,在 JavaScript 中,函数可以像数字、字符串、布尔值、数组、对象等一样使用。

请注意

现在可能是看看大家对 JavaScript 数据类型有多精通的好时机。 原语是 Boolean, Null, Undefined, Number, (BigInt), String, Symbol, Object à Array/Set/Map。 它们可以在 Object 数据类型下找到。

一流的功能-惯用的 JavaScript 构建块

定义一级支持的另一种方法是“如果函数是规则值,那么它们就是一级的。” 这意味着一个函数可以被赋值(作为值)给一个变量,作为参数传递给其他函数,并作为另一个函数的返回值。 让我们试着用代码示例来理解前面的概念。

在 JavaScript 中,函数可以赋值给变量,并应用于函数表达式(如下所示)和箭头函数。 变量可以保存对已经定义的函数或已内联声明的函数的引用。 函数可以被命名或匿名:

const fn = function run() {
  return 'Running...';
};
function fnExpression() {}
const otherFn = fnExpression;
const anonymousArrow = () => {};

函数可以设置为数组中的值:

const fn = () => {};
const operations = [
  fn,
  function() {
    console.log('Regular functions work');
  },
  () => console.log('Arrows work too')
];

函数可以设置为对象中的值。 本例使用了 ECMAScript 6/2015 简写属性和方法。 我们还断言Module.fn的输出与fn的输出是相同的:

const fn = () => 'Running...';
const Module = {
  fn,
  method1() {},
  arrow: () => console.log('works too')
};
console.assert(Module.fn() === 'Running...');

一个函数可以作为参数传递给另一个函数:

const fn = () => 'Running...';
function runner(fn) {
  return fn();
}
console.assert(runner(fn) === 'Running...');

使用一级函数的控制反转

在 JavaScript 中使用一级函数意味着注入依赖项可以像传递函数一样小。

在函数不是一级的语言中,我们可能必须将对象(类的实例)传递给构造函数,以便能够将依赖项注入到该依赖项的消费者中。 在 JavaScript 中,我们可以利用函数是一等公民这一事实,简单地注入函数实现。 最简单的例子来自前面的runner函数。 它调用作为参数传递给它的任何函数。

这种类型的依赖在 JavaScript 中非常有用。 类型是动态的,并且倾向于不检查。 类和类类型的好处,比如检查错误和方法重载,在 JavaScript 中不存在。

JavaScript 函数有一个简单的接口。 使用 0 或多个参数调用它们,并产生副作用(网络请求、文件 I/O)和/或输出一些数据。

在没有类型或类型检查的依赖项注入场景中,传递单个函数而不是整个实例对依赖项的使用者(注入依赖项的代码)非常有利。

下面的示例演示了一个场景,其中一个 JavaScript 应用可以同时在客户机和服务器上运行。 这被称为通用 JavaScript 应用,也就是在 Node.js 和浏览器中运行的 JavaScript 程序。 通用 JavaScript 通常是通过构建工具和模式(如依赖注入)的组合来实现的。

在本例中,当在服务器端进行 HTTP 调用时,将使用基于头的授权机制。 当从客户端发出 HTTP 调用时,将使用基于 cookie 的授权机制。

参见下面的函数定义:

function getData(transport) {
  return transport('https://hello-world-micro.glitch.me').then(res => res.text())
}

使用getData的服务器端代码如下所示,其中创建了一个axios函数实例来默认授权头。 然后将该函数实例作为transport传递给getData:

const axios = require('axios');
const axiosWithServerHeaders = axios.create({
  headers: { Authorization: 'Server-side allowed' }
});
getData(axiosWithServerHeaders);

使用getData的客户端代码如下所示。 再次,创建了一个axios函数实例,这次启用了withCredentials选项(用于发送/接收 cookie):

import axios from 'axios';
const axiosWithCookies = axios.create({
  withCredentials: true
})
getData(axiosWithCookies);

前面的示例展示了如何通过委托 HTTP 请求的传输机制实现,利用一级函数支持在运行在不同 JavaScript 环境中的应用之间共享代码。 将函数作为参数传递是执行依赖项注入的惯用 JavaScript 方法。

在 JavaScript 中启用异步 I/O 和事件驱动编程的函数

I/O(即无阻塞)和 JavaScript 事件循环是 JavaScript 在基于浏览器的应用和最近使用 Node.js 的服务器端应用中流行的核心。 JavaScript 是单线程的,这意味着它很容易推理。 在 JavaScript 程序中几乎不可能找到竞争条件和死锁。

JavaScript 的异步编程模型使用输入和输出机制进行非阻塞交互,这意味着如果一个程序是 I/ o 绑定的,JavaScript 是处理它的非常有效的方法。 JavaScript 不会等待 I/O 完成; 相反,它安排代码在 I/O 完成后使用事件循环继续执行。

对于事件驱动编程,该函数是一个轻量级的逻辑容器,需要在稍后的时间点执行。 JavaScript 中的函数和事件驱动编程已经导致了诸如addEventListenerWeb API、Node.js 错误优先回调以及随后在 ECMAScript 6/ECMAScript 2015 中转向 A+ promise 兼容规范等模式。

这里的所有模式都公开一个接受函数作为其参数之一的函数。

addEventListenerWeb API 允许 JavaScript 程序在浏览器中运行,当 DOM 元素上发生事件时执行函数; 例如,我们可以听scrollclick或键盘事件。 如果你滚动,下面的例子将打印Scrolling…。 它应该在浏览器的 JavaScript 环境中运行:

document.addEventListener('scroll', () => {
  console.log('Scrolling...');
});

Node.js 错误优先回调在它公开的任何 I/O API 中使用。 下面的示例展示了如何处理 Node.js 文件系统模块fs的错误。 传递的回调总是有一个 error 属性作为它的第一个参数。 如果没有错误,则该错误为nullundefined;如果发生错误,则该错误为Error值:

const fs = require('fs');
fs.readdir('.', (err, data) => {
  // Shouldn't error
  console.assert(Boolean(data));
  console.assert(!err);
});
fs.readdir('/tmp/nonexistent', (err, data) => {
  // Should error
  console.assert(!data);
  console.assert(Boolean(err));
});

Web Fetch API 公开了一个 A+ Promise 实现。 A+ Promise 是封装异步逻辑并具有.then.catch函数的对象,它们接受一个函数作为参数。 与错误优先的 Node.js 回调方法相比,承诺是 JavaScript 中抽象 I/O 的一种最新和先进的方法。 Fetch API 在 Node.js 中不可用; 但是,它可以作为一个 npm 模块在 Node.js 中使用。 这意味着以下代码可以在 Node.js 中工作:

const fetch = require('node-fetch');
fetch('https://google.com')
  .then(response => {
    console.assert(response.ok);
  })
  .catch(error => {
    // Shouldn't error
    console.assert(false);
    console.error(error.stack);
  });

Node.js 的最新版本(10+)公开了一些 api 的 Promise 接口。 下面的方法等价于前面的文件系统访问和错误处理,但是使用 Promise 接口而不是 error-first 回调:

const fs = require('fs').promises;
fs.readdir('.')
  .then(data => {
    console.assert(Boolean(data));
  })
  .catch(() => {
    // Shouldn't error
    console.assert(false);
  });
fs.readdir('/tmp/nonexistent')
  .then(() => {
    // Should error
    console.assert(false);
  })
  .catch(error => {
    // Should error
    console.assert(Boolean(error));
  });

JavaScript 内置数组方法,展示一流的功能支持

JavaScript 在 Array 对象上自带了几个内置方法。 这些方法中有很多都展示了一级函数支持。

Array#map函数返回传递的函数输出的数组,并应用于每个元素。 下面的示例展示了一个常见的用例,即通过为每个元素提取特定的对象键,将一个对象数组转换为一个基本值数组。 在这种情况下,对象的id属性以一个新数组的形式返回:

const assert = require('assert').strict
assert.deepStrictEqual(
  [{id: '1'}, {id: '2'}].map(el => el.id),
  ['1', '2']
);

Array#filter函数返回数组中的元素,函数将数组作为参数传递,并返回真值。 在下面的例子中,我们过滤掉任何小于或等于 2 的元素:

const assert = require('assert').strict
assert.deepStrictEqual(
 [1, 2, 3, 4, 5].filter(el => el > 2),
 [3, 4, 5]
);

Array#reduce函数接受一个函数参数,该函数为每个带有累加器和当前元素值的元素调用。 Reduce返回传递的函数参数的最后输出。 它用于改变数组的形状,例如,对数组中的每个元素求和:

console.assert([2, 4].reduce((acc, curr) => acc + curr) === 6);

Array#flatMap函数返回函数的扁平输出,作为参数传递,并应用于数组中的每个元素。 下面的例子中,新数组的长度是初始数组的两倍,因为我们为flatMap返回了一对值来扁平化成一个数组:

const assert = require('assert').strict
assert.deepStrictEqual(
  [1, 2, 3, 4, 5, 6].flatMap(el => [el, el + 1]),
  [ 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7 ]
);

请注意

flatMap是 Node.js 11+中的第 4 阶段特性,并在 Chrome 69+, Firefox 62+和 Safari 12+中得到原生支持。

Array#forEach函数调用数组中每个元素作为参数传递的函数。 它相当于一个 for 循环,除了它不能被破坏。 传递的函数总是会被每个元素调用:

let sum = 0;
[1, 2, 3].forEach(n => {
  sum += n;
});
console.assert(sum === 6);

Array#find函数调用的函数作为一个参数传递的数组的每个元素,直到函数返回这个值,此时它返回值或没有更多的元素称之为反对,此时它返回undefined:

console.assert(['a', 'b'].find(el => el === 'c') === undefined);

Array#findIndex函数调用的函数作为一个参数传递的数组的每个元素,直到函数返回这个值,这时它返回索引或没有更多的元素称之为反对,此时它返回-1:

console.assert(['a', 'b'].findIndex(el => el === 'b') === 1);

Array#every函数调用数组中每个元素作为参数传递的函数。 在每次迭代时,如果传入的函数返回一个false值,.every将中断并返回false。 如果.every到达数组的末尾,而没有返回false值的函数,则返回true:

console.assert([5, 6, 7, 8].every(el => el > 4));

Array#some函数调用数组中每个元素作为参数传递的函数。 在每次迭代时,如果传入的函数返回真值,.some将中断并返回true。 如果.some到达数组的末尾时没有返回真值的函数作为参数传递,则返回false:

console.assert([0, 1, 2, 5, 6, 7, 8].some(el => el < 4));

函数调用作为参数传递的函数来对数组进行排序。 传递的函数被调用时带有数组的两个元素(我们将调用ab)。 如果它返回一个大于 0 的值,那么在排序数组中,a将出现在b之前。 如果比较函数返回一个小于 0 的值,那么在排序数组中,b将出现在a之前。 如果比较函数返回值为 0,那么ab将以与原始数组相同的顺序出现,即彼此相对:

const assert = require('assert').strict
assert.deepStrictEqual(
  [3, 5, 1, 4].sort((a, b) => (a > b ? 1 : -1)),
  [1, 3, 4, 5]
);

还有其他的 Array 方法,特别是对非函数参数进行操作的方法。 这是显示支持传递函数的方法有多强大的好方法。

练习 70:重新实现包括,indexOf, and join with some, findIndex, and reduce

在本练习中,您将利用一级函数支持,使用数组方法Array#someArray#findIndexArray#reduce重新实现Array#includesArray#indexOfArray#join。 它们是仅基元版本的更强大版本。

npm run Exercise70的最终输出应该传递所有的断言。 这意味着我们现在有了符合下列断言的includesindexOfjoin函数:

  • includes应该返回 true 如果值是在数组中。
  • includes应该返回 false 如果值是在数组中。
  • indexOf应该返回索引,如果值在数组中。
  • 如果值不在数组中,indexOf应该返回-1
  • join应该在不传递分隔符的情况下工作。
  • join should work with a comma delimiter.

    请注意

    在本练习中,我们将对启动文件exercise-re-implement-array-methods-start.js中的方法进行测试和框架。 该文件可以与node exercise-re-implement-array-methods-start.js一起运行。 该命令已被 npm 脚本别名为npm run Exercise70

执行以下步骤来完成这个练习:

  1. Change the current directory to Lesson10. This allows us to use pre-mapped commands to run our code. Now, run the npm run Exercise70 command (or node exercise-re-implement-array-methods-start.js):

    请注意

    npm 脚本定义在package.jsonscripts部分。 这个练习的工作解决方案可以使用npm run``Exercise70.js运行。 The文件在 GitHub 上。

    Figure 10.1: Initial output of npm run exercise1

    图 10.1:npm 运行 Exercise70 的初始输出

    这些错误表明,提供的测试目前正在失败,因为实现没有按照预期工作(因为它们目前什么都不做)。

  2. Implement includes in exercise-re-implement-array-methods-start.js:

    js function includes(array, needle) {

    js   return array.some(el => el === needle);

    js }

    我们将替换一具骨骼。 我们可以用来实现 include 的函数是.some。 我们要做的是检查数组中的任何/某些元素是否等于参数needle

  3. Run npm run Exercise70. This should give the following output, which means that includes works as expected according to our two assertions (the assertion errors for includes are gone):

    Figure 10.2: Output after implementing includes

    图 10.2:实施后的输出包括

    needle是一个基本类型,所以如果我们需要比较一些东西,做el === needle就足够了。

  4. Use .findIndex to implement indexOf:

    js function indexOf(array, needle) {

    js   return array.findIndex(el => el === needle);

    js }

    在这一步之后,运行npm run Exercise70应该会得到如下输出,这意味着indexOf按照我们的两个断言的预期工作(indexOf的断言错误消失了):

    Figure 10.3: Output after implementing includes and indexOf

    图 10.3:实现 include 和 indexOf 后的输出

    最后,我们将使用.reduce实现join。 这个函数的实现比较复杂,因为reduce是一个非常通用的遍历/累积操作符。

  5. 首先将累加器与当前元素连接:

    js function join(array, delimiter = '') {

    js   return array.reduce((acc, curr) => acc + curr);

    js }

  6. Run npm run Exercise70. You will see that "should work with no delimiter passed" now passes:

    Figure 10.4: Implementing includes, indexOf, and a naïve join

    图 10.4:实现 include、indexOf 和 naïve 连接
  7. In addition to concatenating the accumulator with the current element, add the delimiter in between them:

    js function join(array, delimiter = '') {

    js   return array.reduce((acc, curr) => acc + delimiter + curr);

    js }

    下面是上述代码的输出:

Figure 10.5: Final output of npm after running the exercise

图 10.5:运行练习后 npm 的最终输出

这个练习展示了支持将另一个函数传递给它们的函数如何比只接收原始参数的等效函数更强大。 我们已经通过使用原始形参函数的函数形参对等物重新实现原始形参函数来证明这一点。

在下一个练习中,我们将展示支持函数参数的数组函数的另一个 JavaScript 用例。

练习 71:使用 Map and Reduce 计算篮子的价格

在本练习中,您将使用数组的mapfilterreduce函数来完成从行项目列表到购物篮总成本的简单转换。

请注意

在本练习中,您将对启动文件exercise-price-of-basket-start.js中的方法进行测试和框架。 该文件可以与node exercise-price-of-basket-start.js一起运行。 该命令已被 npm 脚本别名为npm run Exercise71。 这个练习的工作解决方案可以使用 GitHub 上的npm run Exercise71文件运行。

  1. Change the current directory to Lesson10. This allows us to use pre-mapped commands to run our code. Run npm run Exercise71 (or node exercise-price-of-basket-start.js). You will see the following:

    Figure 10.6: Initial output of npm run

    图 10.6:npm 运行的初始输出

    失败的断言表明我们的框架实现没有输出它应该输出的内容,因为basket1的内容应该符合5197basket2的内容应该符合897。 我们可以手动计算:1 * 199 + 2 * 249951972 * 199 + 1 * 499897

  2. First, get the line item price, which is done by mapping over each item in totalBasket and multiplying item.price by item.quantity:

    js function totalBasket(basket) {

    js   return basket.map(item => item.price * item.quantity);

    js }

    js console.log(totalBasket(basket1))

    js console.log(totalBasket(basket2))

    运行npm run Exercise71会得到以下输出:

    Figure 10.7: Output of npm run and totalBasket with line item calculation in a .map

    图 10.7:npm 运行的输出和 totalBasket 的行项目计算在。map

    注意,由于我们没有增加行项价格,断言仍然失败; 我们只是返回一行项目 price 的数组。

  3. Next, use reduce to sum the accumulator and current line item price, and remove the console.log:

    js function totalBasket(basket) {

    js   return basket

    js     .map(item => item.price * item.quantity)

    js     .reduce((acc, curr) => acc + curr);

    js }

    npm run Exercise71的最终输出不应该有断言错误:

Figure 10.8: Final output with totalBasket implemented

图 10.8:totalBasket 实现后的最终输出

reduce步长加到我们用初始map计算的行项目价格上。 现在totalBasket返回了basket1basket2的正确总价,分别是5197897。 因此,下面的断言现在是正确的:

  • basket1合计为5197
  • basket2合计为897

本练习演示如何使用 map 和 reduce 首先将对象数组转换为基本值数组,然后从中间数组聚合数据。

React 中的子-父组件通信

流行的 JavaScript 用户界面库 React 在其组件 API 接口中利用了 JavaScript 中函数的一级特性。

组件仅显式地从使用它的组件接收道具。 在 React 中,一个组件对另一个组件的消费通常被称为呈现,因为它自己的呈现是一个组件唯一可以使用另一个组件的地方。

在这种情况下,父组件(呈现的组件)可以将道具传递给子组件(正在呈现的组件),如下所示:

import React from 'react';
class Child extends React.Component {
  render() {
    return <div>Hello {this.props.who}</div>;
  }
}
class Parent extends React.Component {
  render() {
    return (
      <div>
        <Child who="JavaScript" />
      </div>
    );
  }
}

与其他流行的用户界面库(如 Vue.js 和 Angular)不同的是,在 Vue.js 中,道具是从父类传递到子类,而事件是从子类发送到父类。 在 Angular 中,输入绑定用于将数据从父节点传递给子节点。 父母倾听孩子发出的事件并对它们做出反应。

React 不公开允许将数据传递回父节点的构造; 只有道具。 为了实现子-父通信,React 支持一种模式,即将函数作为支柱传递给子。 传递的函数是在父组件的上下文中定义的,因此可以在父组件中做它想做的事情,比如更新状态,触发 Redux 操作,等等:

import React from 'react';
class Child extends React.Component {
  render() {
    return (
      <div>
        <button onClick={this.props.onDecrement}>-</button>
        <button onClick={this.props.onIncrement}>+</button>
      </div>
    );
  }
}
class Parent extends React.Component {
  constructor() {
    super();
    this.state = {
      count: 0
    };
  }
  increment() {
    this.setState({
      count: this.state.count + 1
    });
  }
  decrement() {
    this.setState({
      count: this.state.count - 1
    });
  }
  render() {
    return (
      <div>
        <p>{this.state.count}</p>
        <Child
          onIncrement={this.increment.bind(this)}
          onDecrement={this.decrement.bind(this)}
        />
      </div>
    );
  }
}

这种模式还暴露了 JavaScript 中一级函数的一个大问题。 在混合类/实例和一级函数时,默认情况下,类实例对象上的函数不会自动绑定到它。 换句话说,我们有以下几点:

import React from 'react';
class Child extends React.Component {
  render() {
    return <div>
      <p><button onClick={() => this.props.withInlineBind('inline-bind')}>inline bind</button></p>
      <p><button onClick={() => this.props.withConstructorBind('constructor-bind')}>constructor bind</button></p>
      <p><button onClick={() => this.props.withArrowProperty('arrow-property')}>arrow property</button></p>
    </div>;
  }
}
class Parent extends React.Component {
  constructor() {
    super();
    this.state = {
      display: 'default'
    };
    this.withConstructorBind = this.withConstructorBind.bind(this);
  }
  // check the render() function
  // for the .bind()
  withInlineBind(value) {
    this.setState({
      display: value
    })
  }
  // check the constructor() function
  // for the .bind()
  withConstructorBind(value) {
    this.setState({
      display: value
    })
  }
  // works as is but needs an
  // experimental JavaScript feature
  withArrowProperty = (value) => {
    this.setState({
      display: value
    })
  }
  render() {
    return (
      <div>
        <p>{this.state.display}</p>
        <Child
          withInlineBind={this.withInlineBind.bind(this)}
          withConstructorBind={this.withConstructorBind}
          withArrowProperty={this.withArrowProperty}
          />
      </div>
    );
  }
}

回调道具是 React 中任何类型的子-父通信的核心,因为它们的道具是父-子通信以及子-父通信的唯一方式。 下一个活动旨在实现一个onCheckout道具,当点击 Basket 的结帐按钮时,Basket组件的消费者可以使用它来作出反应。

活动 15:onCheckout 回调道具

在这个活动中,我们将实现一个onCheckout道具,在结账期间显示购物车中的商品数量。

请注意

活动 15 附带了一个预配置的开发服务器和启动文件中的方法框架,即activity-on-checkout-prop-start.jsactivity-on-checkout-prop-start.html。 开发服务器可以与npm run Activity15一起运行。 这个活动的工作解决方案可以在 GitHub 上使用 npm runActivity15文件运行。

  1. 将当前目录修改为Lesson10,并运行npm installnpm install下载运行此活动所需的依赖项(React 和 Parcel)。 该命令的别名为npx parcel serve activity-on-checkout-prop-start.html
  2. 转到http://localhost:1234(或启动脚本输出的任何 URL)查看 HTML 页面。
  3. Click on the Proceed to checkout button. You will notice that nothing happens.

    请注意

    这个活动的解决方案可以在 625 页找到。

下一个练习将向您展示如何利用状态和道具将产品添加到我们的篮子中。 这个练习的开始代码与我们在活动之后完成的代码并不完全相同。 例如,状态从 Basket 组件提升到App组件。

练习 72:往篮子里添加一种产品

在这个练习中,我们将修改addProduct方法,以在点击Add to basket选项时更新购物篮中的商品数量。

请注意

练习 72 附带了一个预配置的开发服务器和启动文件(即exercise-add-product-start.jsexercise-add-product-start.html)中的方法框架。 开发服务器可以与npm run Exercise72一起运行。 该命令的别名为npx parcel serve exercise-add-product-start.html。 本练习的工作解决方案可以使用 GitHub 上的npm run Exercise72文件运行。

  1. Change the current directory to Lesson10. Run npm install if you haven't done so in this directory before. Now, run npm run Exercise 72. You will see the application starting up, as follows:

    Figure 10.9: Output of npm run Exe

    图 10.9:npm 运行练习 72 的输出

    为了让开发服务器实时重新加载我们的更改并避免配置问题,可以直接编辑exercise-add-product-start.js文件。

  2. Go to http://localhost:1234 (or whichever URL the start script output). You should see the following HTML page:

    Figure 10.10: Initial application in the browser

    图 10.10:浏览器中的初始应用

    当点击Add to Basket时,应用崩溃并显示一个空白的 HTML 页面。

  3. Update App#addProduct to fix the crashes.

    js addProduct(product) {

    js     this.setState({

    js       basket: {

    js         items: this.state.basket.items.concat({

    js           name: product.name,

    js           price: product.price,

    js           quantity: 1

    js         })

    js       }

    js     });

    js   }

    不是将篮子的值设置为{},而是使用 JavaScript Array 的concatenate方法来获取篮子中的当前项(this.state.basket.items),并将传入的product参数与quantity: 1相加。

  4. To find out what happens when we click Add to Basket, we need to find the onClick handler for the Add to Basket button and then diagnose the issue with the this.addProduct() call (basket being set to {}):

    js <button onClick={() => this.addProduct(this.state.product)}>

    js   Add to Basket

    js </button>

    当我们点击Add to Basket按钮时,我们会看到:

Figure 10.11: Implemented Add to Basket after 1 click

图 10.11:一次点击后实现的 Add to Basket

当我们再次点击Add to Basket时,我们将看到以下内容:

Figure 10.12: Implemented Add to Basket after 2 clicks

图 10.12:点击两次后实现的 Add to Basket

React 渲染道具的一级功能

渲染道具是一个 React 组件模式,组件将整个区域的渲染委托给它的父组件。

渲染道具是一个返回 JSX 的函数(因为它需要可渲染)。 它往往使用特定于子的数据来调用。 然后,该数据被 prop 的实现使用来呈现 JSX。 这种模式在库作者中非常流行,因为它意味着他们可以专注于实现组件的逻辑,而不必担心如何允许用户重写呈现的输出(因为它全部都被委托给用户)。

渲染道具的一个非常简单的例子是将渲染委托给父组件,但是操作或数据来自公开渲染道具的组件。 ExitComponent封装window.close()功能,但将渲染委托给其renderExitprop:

class ExitComponent extends React.Component {
  exitPage() {
    window.close();
  }
  render() {
    return <div>{this.props.renderExit(this.exitPage.bind(this))}</div>;
  }
}

这意味着,例如,我们的ExitComponent可以用于退出页面上的链接和按钮。

这是什么ExitButton代码可能看起来像:

class ExitButton extends React.Component {
  render() {
    return (
      <ExitComponent
        renderExit={exit => (
          <button
            onClick={() => {
              exit();
            }}
          >
            Exit Page
          </button>
        )}
      />
    );
  }
}

请注意,组件中的任何地方都不会处理实际的页面退出逻辑; 这一切都留给了ExitComponent去实现。 按钮的呈现在这里完全处理; not have to know about it.不必知道。

下面是如何实现一个ExitLink组件。 再次注意,ExitComponent对链接一无所知,ExitLink对关闭窗口一无所知。

class ExitLink extends React.Component {
  render() {
    return (
      <ExitComponent
        renderExit={exit => (
          <a
            onClick={e => {
              e.preventDefault();
              exit();
            }}
          >
            Exit
          </a>
        )}
      />
    );
  }
}

练习 73:使用渲染道具渲染篮子内容

在这个练习中,我们将使用渲染道具将物品渲染到购物篮中,从而制作一个灵活的购物篮组件。

请注意

练习 73 附带了一个预配置的开发服务器和启动文件(即exercise-render-prop-start.jsexercise-render-prop-start.html)中的方法框架。 开发服务器可以与npm run Exercise73一起运行。 该命令的别名为npx parcel serve exercise-render-prop-start.html。 本练习的工作解决方案可以使用 GitHub 上的npm run Exercise73文件运行。

执行以下步骤来完成这个练习:

  1. Change the current directory to Lesson10 and run npm install if you haven't done so in this directory before. npm install downloads the dependencies that are required in order to run this activity (React and Parcel). Now, run npm run Exercise73. You will see the application starting up, as follows:

    Figure 10.13: Output after running the start file

    图 10.13:运行启动文件后的输出

    为了让开发服务器实时重新加载我们的更改并避免配置问题,可以直接编辑exercise-render-prop-start.js文件。

  2. Go to http://localhost:1234 (or whichever URL the starting script output). You should see the following HTML page:

    Figure 10.14: Initial application in the browser

    图 10.14:浏览器中的初始应用
  3. 找到Basket被渲染的地方,并添加一个renderItem道具,这是一个从项目到 JSX 的函数。 这是渲染的实现支持Basket将使用呈现每个篮子项:

    js {this.state.status === 'SHOPPING' && (

    【4】【5】

    js     renderItem={item => (

    js       <div>

    【显示】

    js         {(item.price / 100).toFixed(2)} each{' '}

    js       </div>

    js     )}

    【病人】

    js   />

    js )}

  4. Go to the Basket#render method and map over each this.props.items, using this.props.renderItem to render the item:

    js render() {

    js   return (

    js     <div>

    js       <p>You have {this.props.items.length} items in your basket</p>

    js       <div>{this.props.items.map(item => this.props.renderItem(item))}</div>

    js       <button onClick={() => this.props.onCheckout(this.props.items)}>

    js         Proceed to checkout

    js       </button>

    js     </div>

    js   );

    js }

    要查看我们的更改,我们可以进入浏览器,看看篮子项是如何呈现的:

Figure 10.15: Rendering the basket items

图 10.15:呈现篮子项

我们的Basket组件现在根据渲染组件定义的函数来渲染条目。 这使得Basket更强大(它可以渲染物品),但仍然高度可重用。 在不同的例子中,我们可以使用BasketrenderItem道具,但没有呈现任何内容,例如,道具的分解或购物篮道具的行价格。

我们所介绍的一级函数和模式对于编写惯用的 JavaScript 是至关重要的。 在 JavaScript 中利用函数式编程的另一种方法是使用纯函数。

纯函数

纯函数是没有副作用的函数,对于相同的输入,参数将返回相同的输出值。 副作用可以是任何东西,从改变引用传递的参数值(在 JavaScript 中会改变原始参数)到改变局部变量的值,或者做任何类型的 I/O。

一个纯函数可以被认为是一个数学函数。 它只使用输入操作,并且只影响它自己的输出。

这里有一个简单的纯函数,identity函数,它返回作为参数传递给它的任何东西:

const identity = i => i;

请注意,它没有副作用,也没有参数的变化或新变量的创建。 这个函数甚至没有主体。

纯函数的优点是易于推理。 它们也很容易测试; 通常不需要模拟任何依赖项,因为任何和所有依赖项都应该作为参数传递。 纯函数倾向于对数据进行操作,因为如果数据是它们唯一的依赖项,则不允许它们产生副作用。 这减少了测试表面积。

纯函数的缺点是,纯函数在技术上不能做任何有趣的事情,比如 I/O,这意味着不发送 HTTP 请求和不调用数据库。

请注意

纯函数定义中一个有趣的差距是 JavaScript 异步函数。 从技术上讲,如果不含副作用,它们仍然可以是纯的。 在实践中,async 函数很可能被用于使用await运行异步操作,例如访问文件系统、HTTP 或数据库请求。 一个好的经验法则是,如果一个函数是异步的,它很可能使用await来做一些 I/O,因此它不是纯的。

Redux reducer and Actions

Redux 是一个国家管理图书馆。 它对用户施加了一些约束,以提高状态更新的可预测性和代码库的长期可伸缩性。

让我们看看一个简单的 Redux 计数器实现来突出一些特性:

const {createStore} = require('redux');
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
};
const store = createStore(counterReducer);

商店初始化它的状态为 0:

console.assert(store.getState() === 0, 'initalises to 0');

商店的内部状态只能通过getState的只读接口暴露。 要更新状态,需要调度一个动作。 使用INCREMENTDECREMENT类型调用dispatch表明counterReducer按预期工作,并减少了存储中的操作:

store.dispatch({type: 'INCREMENT'});
console.assert(store.getState() === 1, 'incrementing works');
store.dispatch({type: 'DECREMENT'});
console.assert(store.getState() === 0, 'decrementing works');

请注意

根据 Redux 的文档,Redux 有三个支柱:https://redux.js.org/introduction/three-principles

前面的示例说明了 Redux 的三个支柱。 我们有一个只有一个存储的系统,状态是只读的(通过getState访问),并且由我们的 reducer 进行更改,这是一个纯函数。 counterReducer接受状态和动作,并返回一个新值,而不改变stateaction

作为遵循这些规则的交换,我们为 JavaScript 应用获得了一个可预测的性能状态容器。 单一存储意味着不存在状态存储在哪里的问题; 只读状态通过调度和减少操作强制执行更新。 由于 reducer 是纯函数,它们都很容易测试和推理,因为它们将为相同的输入提供相同的输出,并且不会造成副作用或不必要的突变。

Redux 用于管理状态。 到目前为止,我们一直将数据存储在 React 状态。

练习 74:Redux 分派行动并将其减少到状态

在本练习中,我们将把数据的状态移到 Redux 中,以便将数据操作和状态更新与将数据呈现到页面的代码分离开来。

请注意

练习 74 附带了一个预配置的开发服务器和启动文件(即exercise-redux-dispatch-start.jsexercise-redux-dispatch-start.html)中的方法框架。 开发服务器可以与npm run Exercise74一起运行。 这个练习的工作解决方案可以使用 GitHub 上的npm run Exercise74文件运行。

执行以下步骤来完成这个练习:

  1. Change the current directory to Lesson10 and run npm install if you haven't done so in this directory before. This command is an alias of npx parcel serve exercise-redux-dispatch-start.html. Now, run npm run Exercise74. You will see the application starting up, as follows:

    Figure 10.16: Output of npm run Exercise74

    图 10.16:npm 运行 Exercise74 的输出
  2. Go to http://localhost:1234 (or whichever URL the starting script output). You should see the following HTML page:

    Figure 10.17: Initial Exercise74 application in the browser

    图 10.17:浏览器中的 Initial Exercise74 应用

    注意点击按钮是如何不起作用的。

  3. js continueShopping() {

    js   this.props.dispatch({

    js     type: 'CONTINUE_SHOPPING'

    js   });

    js }

    通过调度CONTINUE_SHOPPING类型的操作实现App#continueShopping 4. 在appReducer中,实现相应的状态约简。 CONTINUE_SHOPPING,我们只需要改变status的状态,因为它是我们用来显示付款视图或主要产品和篮子视图:

    js switch(action.type) {

    【4】【5】

    js     return {

    js       ...state,

    【显示】

    js     };

    js   // other cases

    js }

  4. js finish() {

    js   this.props.dispatch({

    js     type: 'DONE'

    js   });

    js }

    通过调度DONE类型的操作实现App#finish 6. 在appReducer中,实现相应的状态约简。 我们只需要改变status状态,因为它是我们用来显示Done视图:

    js switch(action.type) {

    【4】【5】

    js     return {

    js       ...state,

    【显示】

    js     };

    js   // other cases

    js }

  5. js handleCheckout(items) {

    js   this.props.dispatch({

    js     type: 'START_CHECKOUT',

    js     basket: {

    js       items

    js     }

    js   });

    js }

  6. In appReducer, implement the corresponding state reduction. For START_CHECKOUT, we only need to change the status in the state since it is what we use to display the checkout view or the main product and basket view:

    js switch(action.type) {

    js   // other cases

    js   case 'START_CHECKOUT':

    js     return {

    js       ...state,

    js       status: 'CHECKING_OUT'

    js     };

    js   // other cases

    js }

    请注意

    basket对象没有被缩减,所以它可以在分派时从动作中省略。

  7. 通过如下方式调度一个动作来实现addProduct。 对于ADD_PRODUCT,除动作类型外,还需newProduct:

    js addProduct(product) {

    js   this.props.dispatch({

    js     type: 'ADD_PRODUCT',

    js     newProduct: {

    js       name: product.name,

    js       price: product.price,

    js       quantity: 1

    js     }

    js   });

    js }

  8. In appReducer, implement the corresponding state reduction, which takes the new product and adds it to the current basket of items:

    js switch(action.type) {

    js   // other cases

    js   case 'ADD_PRODUCT':

    js     return {

    js       ...state,

    js       basket: {

    js         items: state.basket.items.concat(action.newProduct)

    js       }

    js     };

    js   // other cases

    js }

    完整的appReducer应该如下所示:

    js const appReducer = (state = defaultState, action) => {

    js   switch (action.type) {

    js     case 'START_CHECKOUT':

    js       return {

    js         ...state,

    js         status: 'CHECKING_OUT'

    js       };

    js     case 'CONTINUE_SHOPPING':

    js       return {

    js         ...state,

    js         status: 'SHOPPING'

    js       };

    js     case 'DONE':

    js       return {

    js         ...state,

    js         status: 'DONE'

    js       };

    js     case 'ADD_PRODUCT':

    js       return {

    js         ...state,

    js         basket: {

    js           items: state.basket.items.concat(action.newProduct)

    js         }

    js       };

    js     default:

    js       return state;

    js   }

    js };

  9. 转到http://localhost:1234(或启动脚本输出的任何 URL)。 应用现在应该响应点击,如预期:

Figure 10.18: Application wo

图 10.18:响应单击的应用

向购物篮中添加商品和在应用中导航(继续签出、完成、继续购物)的行为应该与 Redux 商店实现之前的行为相同。

纯函数测试

纯函数很容易测试,因为它们是完全封装的。 唯一可以改变的是输出,也就是返回值。 唯一能影响输出的是参数/参数值。 更重要的是,对于相同的输入集合,纯函数的输出需要相同。

测试纯函数就像用不同的输入调用它们并断言输出一样简单:

const double = x => x * 2;
function test() {
  console.assert(double(1) === 2, '1 doubled should be 2');
  console.assert(double(-1) === -2, '-1 doubled should be -1');
  console.assert(double(0) === 0, '0 doubled should be 0');
  console.assert(double(500) === 1000, '500 doubled should be 1000');
}
test();

Redux reducers 是纯函数,这意味着要测试它们,我们可以使用前面示例中看到的方法。

练习 75:测试减速器

在本练习中,我们将为之前练习中使用的减速器的一部分编写测试,即appReducerADD_PRODUCTcase。

请注意

练习 75 附带了关于启动文件exercise-reducer-test-start.js中的方法的测试和框架。 该文件可以与node exercise-reducer-test-start.js一起运行。 该命令已被 npm 脚本别名为npm run Exercise75。 这个练习的工作解决方案可以使用 GitHub 上的 npm run exercise6 文件来运行。

执行以下步骤来完成这个练习:

  1. 将当前目录更改为Lesson10。 这允许我们使用预先映射的命令来运行代码。
  2. Now, run npm run Exercise75 (or node exercise-reducer-test-start.js). You will see the following output:

    Figure 10.19: Empty tests passing after running the start file

    图 10.19:运行启动文件后通过的空测试

    这个启动文件中有一个简化的appReducer,它只包含ADD_PRODUCT动作缩减,还有一个test函数,新测试将在这个函数中添加。 输出不包含错误,因为我们还没有创建任何测试。

    请注意

    为了获得appReducer的输出,应该使用一个state对象和相关的action来调用它。 在本例中,类型为'ADD_PRODUCT'

  3. As in the previous examples, we will use assert.deepStrictEqual, which checks for the deep equality of two objects. We can write a failing test like so. We're calling appReducer with state and the relevant action:

    js function test() {

    js   assert.deepStrictEqual(

    js     appReducer(

    js       {

    js         basket: {items: []}

    js       },

    js       {

    js         type: 'ADD_PRODUCT',

    js         newProduct: {

    js           price: 499,

    js           name: 'Biscuits',

    js           quantity: 1

    js         }

    js       }

    js     ),

    js     {}

    js   );

    js }

    如果我们运行npm run Exercise75,我们将看到以下错误。 这是预期的,因为appReducer没有返回一个空对象作为状态:

    Figure 10.20: Errors shown after executing the start file

    图 10.20:执行启动文件后显示的错误
  4. 我们应该使用assert.deepStrictEqual来确保appReducer按照预期添加新产品。 我们将期望值赋给一个expected变量,实际值赋给一个actual变量。 这将有助于保持测试更具可读性:

    js function test() {

    【5】

    js     basket: {

    js       items: [

    【显示】

    js           price: 499,

    js           name: 'Biscuits',

    js           quantity: 1

    【病人】

    js       ]

    js     }

    js   };

    【t16.1】

    js     {

    js       basket: {items: []}

    js     },

    js     {

    js       type: 'ADD_PRODUCT',

    js       newProduct: {

    js         price: 499,

    js         name: 'Biscuits',

    js         quantity: 1

    js       }

    js     }

    js   );

    js   assert.deepStrictEqual(actual, expected);

    js }

输出现在应该不会抛出任何错误:

Figure 10.21: Test passed as no errors were found

图 10.21:测试通过,没有发现错误

运行node exercise-reducer-test.js命令后的输出如下:

Figure 10.22: Output showing assertion failing

图 10.22:显示断言失败的输出

Redux 选择器

选择器是 Redux 的另一个概念,这意味着我们可以用选择器封装内部存储状态形状。 选择器的消费者询问它想要什么; 选择器需要通过存储状态和形状相关的知识来实现。 选择器是纯函数; 它们获取存储状态并返回其中的一个或多个部分。

因为选择器是纯函数,所以实现起来很简单。 下面的练习向我们展示了如何使用选择器,这样就不用在呈现函数或传递道具时将消息传递数据放入一个纯函数中。

练习 76:实现选择器

在本练习中,我们将使用选择器并利用其简单性将商品呈现到购物篮中。

请注意

练习 76 附带了一个预配置的开发服务器和启动文件(即exercise-items-selector-start.jsexercise-items-selector-start.html)中的方法框架。 开发服务器可以与npm run Exercise76一起运行。 这个练习的工作解决方案可以使用 GitHub 上的 npm run Exercise76 文件来运行。

  1. 将当前目录更改为Lesson10,并运行npm install(如果您以前未在此目录下运行)。
  2. Run npx parcel serve exercise-items-selector-start.html and execute npm run Exercise76. You will see the application starting up, as follows:

    Figure 10.23: Output after running the start html file

    图 10.23:运行 start html 文件后的输出

    为了让开发服务器实时重新加载我们的更改并避免配置问题,可以直接编辑exercise-items-selector-start.js文件。

  3. Go to http://localhost:1234 (or whichever URL the starting script output). You should see the following HTML page:

    Figure 10.24: Initial application in the browser

    图 10.24:浏览器中的初始应用

    注意,没有篮子条目是如何呈现的。 这是因为selectBasketItems的初步实施。 它返回一个空数组:

    js const selectBasketItems = state => [];

  4. Implement selectBasketItems by drilling down into the state with dot notation and short-circuiting. Default to [] if there is any issue with the state:

    js const selectBasketItems = state =>

    js   (state && state.basket && state.basket.items) || [];

    应用现在应该可以正常工作了; 项目将显示:

Figure 10.25: Application after implementing selectBasketItems

图 10.25:实现 selectBasketItems 后的应用

selectBasketItems选择器接受完整的状态,并返回它的一个切片(项目)。 选择器允许我们从 React 组件中使用状态的方式进一步抽象 Redux 存储中的状态内部形状。

选择器是 React/Redux 应用的关键部分。 正如我们所看到的,它们允许 React 组件从 Redux 的内部状态形状解耦。 下面的活动旨在让我们能够为选择器编写测试。 这是一个类似于测试减速器的场景,我们在前面的练习中做过。

活动 16:测试选择器

在这个活动中,我们将测试项目数组的各种状态的选择器,并确保选择器返回与篮子中的项目相对应的数组。 让我们开始:

  1. Change the current directory to Lesson10. This allows us to use pre-mapped commands to run our code.

    请注意

    Activity 16 附带了启动文件activity-items-selector-test-start.js中方法的测试和框架。 该文件可以与node activity-items-selector-test-start.js一起运行。 该命令已被 npm 脚本别名为npm run Activity16。 这个练习的工作解决方案可以使用 GitHub 上的 npm run Activity16 文件来运行。

    在测试函数内部,使用assert.deepStrictEqual,做以下工作:

  2. 测试,对于空状态,选择器返回[].

  3. 测试一下,对于空的篮子对象,选择器返回[].
  4. 测试,如果items数组设置为空,则选择器返回[].
  5. Test that, if the items array is not empty and set, the selector returns it.

    请注意

    这个活动的解决方案可以在 626 页找到。

纯函数是可预测的,易于测试,易于推理。 一级函数和纯函数都与下一个 JavaScript 函数编程概念有关:高阶函数。

高阶函数

高阶函数是将函数作为参数或将函数作为值返回的函数。

这是建立在 JavaScript 的一级函数支持之上的。 在不支持一级函数的语言中,很难实现高阶函数。

高阶函数支持功能组合模式。 在大多数情况下,我们使用高阶函数来扩充现有函数。

绑定、应用和调用

Function对象上有一些 JavaScript 内置的方法:bindapplycall

Function#bind允许设置函数的执行上下文。 当调用 bind 时,bind 返回一个新函数,该函数的第一个参数作为函数的this上下文。 当调用返回的函数时,将使用以下参数进行绑定。 当调用绑定的函数时,可以提供参数。 在调用 bind 期间设置参数之后,这些参数将出现在参数列表中。

bind 在 React 代码中广泛使用,当将函数作为道具传递时,仍然需要访问当前组件的this来执行setState之类的操作或调用其他组件方法:

import React from 'react';
class Parent extends React.Component {
  constructor() {
    super();
    this.state = {
      display: 'default'
    };
    this.withConstructorBind = this.withConstructorBind.bind(this);
  }
  // Check the render() function
  // for the .bind()
  withInlineBind(value) {
    this.setState({
      display: value
    });
  }
  // Check the constructor() function
  // for the .bind()
  withConstructorBind(value) {
    this.setState({
      display: value
    });
  }
  render() {
    return (
      <div>
        <p>{this.state.display}</p>
        <Child
          withInlineBind={this.withInlineBind.bind(this)}
          withConstructorBind={this.withConstructorBind}
        />
      </div>
    );
  }
}

当测试在测试中抛出函数时,还可以使用Function#bind方法。 例如,运行该函数意味着必须编写一个 try/catch,如果没有触发 catch,则该 try/catch 以某种方式无法通过测试。 使用 bind 和assert模块,可以将其写成更简短的形式:

// Node.js built-in
const assert = require('assert').strict;
function mightThrow(shouldBeSet) {
  if (!shouldBeSet) {
    throw new Error("Doesn't work without shouldBeSet parameter");
  }
  return shouldBeSet;
}
function test() {
  assert.throws(mightThrow.bind(null), 'should throw on empty parameter');
  assert.doesNotThrow(
    mightThrow.bind(null, 'some-value'),
    'should not throw if not empty'
  );
  assert.deepStrictEqual(
    mightThrow('some-value'),
    'some-value',
    'should return input if set'
  );
}
test();

Function#applyFunction#call允许您在不使用fn(param1, param2, [paramX])语法的情况下调用函数,并以类似Function#bind的方式设置this上下文。 Function#apply的第一个参数是this上下文,而第二个参数是一个数组或类数组,包含函数所需的参数。 同样,Function#call的第一个参数是thisconteext; Function#apply的区别在于参数的定义。 在Function#call中,它们是一个参数列表,就像使用Function#bind时一样,而不是Function#apply所期望的数组。

请注意

类数组对象,也称为索引集合,其中最常用的是函数中的参数对象,和 NodeList Web API,是遵循数组 API 的一部分(例如,实现.length)而没有完全实现它的对象。 数组函数仍然可以通过 JavaScript 的 apply/call 在它们上使用。

Function#applyFunction#call不完全符合高阶函数准则。 由于它们是函数对象上的方法,我们可以说它们是隐式的高阶函数。 调用它们的函数对象是 apply/call 方法调用的隐式参数。 通过读取函数原型,我们甚至可以像这样使用它们:

function identity(x) {
  return x;
}
const identityApplyBound = Function.prototype.bind.apply(identity, [
  null,
  'applyBound'
]);
const identityCallBound = Function.prototype.bind.call(
  identity,
  null,
  'callBound'
);
console.assert(
  identityApplyBound() === 'applyBound',
  'bind.apply should set parameter correctly'
);
console.assert(
  identityCallBound() === 'callBound',
  'bind.call should set parameter correctly'
);

在本例中,我们展示了 apply 和 call 是高阶函数,但仅限于它们可以与其他函数上的函数一起使用。

Function#applyFunction#call已经将类数组对象转换为数组。 在符合 ECMAScript 2015+的环境中,spread操作符可以以类似的方式使用。

以下三个函数允许您使用Function#applyFunction#call和数组扩展将类似数组转换为数组:

function toArrayApply(arrayLike) {
  return Array.prototype.slice.apply(arrayLike);
}
function toArrayCall(arrayLike) {
  return Array.prototype.slice.call(arrayLike);
}
function toArraySpread(arrayLike) {
  return [...arrayLike];
}

卷曲及部分应用

curry 过的函数不是一次获取所需的参数数量,而是一次只接受一个参数。

例如,如果一个函数有两个参数,它的 curry 等效函数将被调用两次,每次只调用一个参数。

因此,curry 可以表示为取一个有 n 个参数的函数,并将其转换为一个每次可以用一个参数调用 n 次的函数。 n 参数函数的经典命名方法是称它为 n 元。 记住这一点,curry 就是从一个 n 元函数到一个 n 长度的一元函数调用集的转换:

const sum = (x, y) => x + y;
const sumCurried = x => y => x + y;
console.assert(
  sum(1, 2) === sumCurried(1)(2),
  'curried version works the same for positive numbers'
);
console.assert(
  sum(10, -5) === sumCurried(10)(-5),
  'curried version works the same with a negative operand'
);

局部应用和 curry 通常是一起引入的,而且从概念上讲,它们是相辅相成的。

对于 curry 过的双参数函数,它接受两个带有一个参数的调用,每个调用都做与未 curry 过的双参数函数相同的工作。 当它被调用一次时,它将充分应用一半必要的参数。 第一次调用产生的函数是整个函数的部分应用:

const sum = (x, y) => x + y;
const sumCurried = x => y => x + y;
const add1Bind = sum.bind(null, 1);
const add1Curried = sumCurried(1);
console.assert(
  add1Bind(2) === add1Curried(2),
  'curried and bound versions behave the same'
);
console.assert(add1Bind(2) === 3, 'bound version behaves correctly');
console.assert(add1Curried(2) === 3, 'curried version behaves correctly');

换句话说,部分应用是一种表示从带 n 个参数的函数到带n-m参数的函数的转换方式,其中 m 是部分应用的参数个数。

如果我们希望能够重用泛型功能,curry 和局部应用是有用的。 局部应用不需要 curry; curry 是指将一个函数转换成一个可以部分应用的函数。 部分应用也可以使用 bind 完成。

curry 和局部应用允许您从一个非常通用的函数开始,并将其转换为每个应用的更专门化的函数。

curry 将每次调用的参数数量标准化。 部分应用没有这样的限制。 您可以同时部分应用多个参数。

一元函数比二元函数简单,二元函数比 N 元(有 N > 2)函数简单。

此外,如果我们在任何时候只允许应用一个参数,curry 也会更简单。 我们可以看到任意 n 参数部分应用具有更大的运行时复杂性,因为每个函数都需要运行一些逻辑来判断这个调用是否为最终调用。

通用的 n 元 curry 可以在 ES2015 中定义如下:

const curry = fn => {
  return function curried(...args) {
    if (fn.length === args.length) {
      return fn.apply(this, args);
    }
    return (...args2) => curried.apply(this, args.concat(args2));
  };
};

利用闭包反应功能组件

当定义一个函数时,在定义时在函数范围内的任何东西将在调用/执行时保持在范围内。 在历史上,闭包用于创建私有变量作用域。 闭包是这个函数和它记忆的定义时间作用域:

const counter = (function(startCount = 0) {
  let count = startCount;
  return {
    add(x) {
      count += x;
    },
    substract(x) {
      count -= x;
    },
    current() {
      return count;
    }
  };
})(0);

我们在 React 渲染函数中利用这一点来缓存本地渲染作用域中的道具和状态。

React 函数组件也利用闭包,特别是使用钩子:

import React from 'react';
function Hello({who}) {
  return <p>Hello {who}</p>;
}
const App = () => (
  <>
    <Hello who="Function Components!" />
  </>
);

函数组件非常强大,因为它们比类组件简单一些。

当使用状态管理解决方案(如 Redux)时,大多数重要的状态都在 Redux 存储库中。 这意味着我们可以主要编写无状态功能组件,因为商店管理应用的任何有状态部分。

高阶函数使我们能够有效地处理函数并扩充它们。 高阶函数建立在一级函数支持和纯函数之上。 同样,函数组合建立在高阶函数的基础上。

功能组成

函数组合是另一个从数学中泄露出来的概念。

给定两个函数 a 和 b, compose 返回一个新函数,该函数将 a 应用于 b 的输出,然后将 b 应用于给定的一组参数。

函数组合是一种从一组较小的函数创建一个复杂函数的方法。

这意味着你可能最终会得到一堆简单的函数来做一件事。 具有单一目的的函数更善于封装它们的功能,因此有助于关注点的分离。

组合函数与 curry 和函数的局部应用联系在一起,因为 curry /partial application 是一种允许你拥有特定版本的泛型函数的技术,比如:

const sum = x => y => x + y;
const multiply = x => y => x * y;
const compose = (f, g) => x => f(g(x));
const add1 = sum(1);
const add2 = sum(2);
const double = multiply(2);

为了解释下面的代码,我们有如下理由:

  • 把 2 翻倍再加 1 是 5(4 + 1)。
  • 2 加 1 然后翻倍是 6(3 * 2)。
  • 2 加 2,然后翻倍是 8(4 * 2)。
  • 2 乘以 2 再加 2 等于 6(4 + 2)。

下面使用我们已经定义的函数add1add2double,并展示如何使用compose来实现前面的例子。 注意,compose 首先应用最右边的参数:

console.assert(
  compose(add1, double)(2) === 5
);
console.assert(
  compose(double, add1)(2) === 6
);
console.assert(
  compose(double, add2)(2) === 8
);
console.assert(
  compose(add2, double)(2) === 6
);

定义compose的另一种方法是使用从左到右遍历(使用reduce)。 这样做的好处是允许我们在调用组合的输出时传递任意数量的参数。 为此,我们从第一个形参减到最后一个形参,但reducing的输出是一个支持任意数量实参的函数,并且在调用当前函数时在当前函数之后调用先前的输出。

下面的代码使用参数 rest 来允许任意数量的函数被组合:

const composeManyUnary = (...fns) => x =>
  fns.reduceRight((acc, curr) => curr(acc), x);

然后,它返回一个接受单个参数x的函数(因此,它是一元的)。 当第二个函数被调用时,它将从右到左调用所有作为参数传递给composeManyUnary的函数(最后一个参数的函数将首先被调用)。 reduceRight的第一次迭代将以x作为参数调用最右边的函数。 后续函数将在前一个函数调用的输出上调用。 使用应用于x的参数列表中最后一个函数的输出调用参数列表中倒数第二个函数。 用倒数第二个函数的输出调用形参列表中倒数第三个函数,以此类推,直到没有其他函数可调用。

练习 77:二进制到 n 元合成函数

在这个练习中,我们将实现一个 n 元compose函数,可以用来组合任意数量的函数。

请注意

练习 77 附带了关于启动文件exercise-2-to-n-compose-start.js中的方法的测试和框架。 该文件可以与node exercise-2-to-n-compose-start.js一起运行。 该命令已被 npm 脚本别名为npm run Exercise77。 这个练习的工作解决方案可以使用 GitHub 上的 npm run Exercise77 文件来运行。

  1. 将当前目录更改为Lesson10。 这允许我们使用预先映射的命令来运行代码。
  2. Now, run npm run Exercise77 (or node exercise-to-n-compose-start.js). You will see the following output:

    Figure 10.26: Running the start file of the exercise

    图 10.26:运行练习的开始文件

    compose3composeManyUnarycomposeManyReduce的断言都失败了,主要是因为它们目前被别名为compose2

  3. A compose for two functions is already implemented:

    js const compose2 = (f, g) => x => f(g(x));

    compose3是一个简单的三参数compose函数,它接受第三个参数,首先调用它,然后在第一次调用的输出上调用第二个参数。

  4. Finally, it calls the first parameter on the output of the second parameter, like so:

    js const compose3 = (f, g, h) => x => f(g(h(x)))

    请注意

    首先调用形参定义最右边的函数。

    考虑到参数是一个数组,并且 JavaScript 有一个reduceRight函数(它从右到左遍历数组,同时也保留一个累加器,很像reduce),这就形成了一条前进的路径。

  5. After implementing compose3, we can run npm run Exercise77 again and see that the assertion for compose3 is not failing anymore:

    Figure 10.27: Output after implementing compose3

    图 10.27:实现 composer 3 后的输出
  6. 使用参数 rest 允许任意数量的函数被组成:

  7. After implementing composeManyUnary, the corresponding failing assertion is now passing:

    Figure 10.28: Output after implementing compose3 and composeManyUnary

    图 10.28:实现 compose3 和 comemanyunary 后的输出
  8. Define that compose is using a left-to-right traversal (with reduce):

    js const composeManyReduce = (...fns) =>

    js   fns.reduce((acc, curr) => (...args) => acc(curr(...args)));

    我们可以用三个函数composeManyReduce,即fgh。 我们的实现将通过功能开始减少。 在第一次迭代时,它将返回一个接受任意多个参数的函数(args)。 当被调用时,它将调用f(g(args))。 在第二次迭代时,它将返回一个函数,该函数接受任意数量的参数并返回f(g(h(args))。 此时,没有更多的函数需要迭代,因此接受一组参数并返回f(g(h(arguments)))的函数的最终输出是composeManyReduce函数的输出。

    在实现了composeManyReduce之后,相应的失败断言现在正在传递:

Figure 10.29: Implementing compose3, composeManyUnary, and composeManyReduce

图 10.29:实现 compose3, comemanyunary,和 comemanyreduce

功能组合在现实世界与简单的 BFF

BFF 是一个服务器端组件,它以特定于它所服务的用户界面的方式包装(API)功能。 这与用于导出通用业务逻辑的 API 相反。 前端的后端可能直接使用上游 API 或支持服务,这取决于体系结构。 一个公司可能会有一组核心服务来实现业务逻辑,然后有一个 BFF 用于他们的移动应用,另一个 BFF 用于他们的 web 前端,最后一个 BFF 用于他们的内部仪表板。 每个 bff 都有不同的约束条件和数据形状,这对他们各自的消费者来说是最有意义的。

通用 API 往往有更大的表面积,由不同的团队维护,并有多个消费者,这反过来导致 API 的形状演变缓慢。 API 端点不是特定于用户界面的,因此前端应用可能必须发出大量 API 请求才能加载单个屏幕。 前端的后端可以缓解这些问题,因为每个页面或屏幕都可以使用自己的端点或数据集。 前端的后端将协调任何相关数据的获取。

为了实现后端为前端,将使用micro。 micro 是一个“异步 HTTP 微服务”的库,由 Zeit 创建。 与 Express 或 Hapi 相比,它是非常小的。 为了做到这一点,它利用了现代 JavaScript 特性,如异步/等待调用,它的组合模型是基于函数组合的。 也就是说,Express 或 Hapi 中的中间件是一个高阶函数,它将一个函数作为参数并返回一个新函数。 这是使用compose的一个很好的机会,因为正在编写的函数的接口是函数输入作为参数,函数输出作为返回值。

请注意

微的非常简短的文档可以在https://github.com/zeit/micro上找到。 这个库本身只有几百行 JavaScript 代码。

一个微型的“Hello world”可能如下所示。 micro 接受单个 HTTP 处理函数,可以是异步的,也可以不是。 无论哪种方式,它都有待等待。 它没有内置的路由器,而路由器是 Express 或 Hapi 公开的核心 api 之一。 处理程序的输出作为带有 200 状态码的 HTTP 响应体发回:

const micro = require('micro');
const server = micro(async () => {
  return '<p>Hello micro!</p>Run this with <code>node example-2-micro-hello.js</code>';
});
server.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

添加请求计时器日志可以通过内置的 JavaScriptconsole.timeconsole.timeEnd函数完成:

// handler and server.listen are unchanged
const timer = fn => async (req, res) => {
  console.time('request');
  const value = await fn(req, res);
  console.timeEnd('request');
  return value;
};
const server = micro(timer(hello));

函数组合是前端,而微观的中心是 API。 添加更复杂的操作(如 API 密钥验证)并不会增加集成的难度。

authenticate功能可以有尽可能多的复杂性,因为它想。 如果它接受一个函数参数,并返回一个接受req(请求)和res(响应)对象的函数,则它将与其他微包和处理程序兼容:

// handler, timer and server.listen are unchanged
const ALLOWED_API_KEYS = new Set(['api-key-1', 'key-2-for-api']);
const authenticate = fn => async (req, res) => {
  const {authorization} = req.headers;
  if (authorization && authorization.startsWith('ApiKey')) {
    const apiKey = authorization.replace('ApiKey', '').trim();
    if (ALLOWED_API_KEYS.has(apiKey)) {
      return fn(req, res);
    }
  }
  return sendError(
    req,
    res,
    createError(401, `Unauthorizsed: ${responseText}`)
  );
};
const server = micro(timer(authenticate(handler)));

微库利用了函数组合,使得请求处理的每个级别之间的依赖关系变得明显。

练习 78:利用 Compose 简化微服务器创建步骤

在本练习中,您将重构上一节中的计时器和身份验证示例,以使用compose

请注意

练习 78 在启动器文件中提供了一个预配置的服务器和一个 run 方法别名,即exercise-micro-compose-start.js。 服务器可以使用npm run Exercise78运行。 这个练习的工作解决方案可以使用 GitHub 上的 npm run Exercise78 文件来运行。

执行以下步骤来完成这个练习:

  1. 将当前目录更改为Lesson10,并运行npm install(如果您以前未在此目录下运行)。
  2. First, run the node exercise-micro-compose-start.js command. Then run npm run Exercise78. You will see the application starting up, as follows:

    Figure 10.30: Running the start file of this exercise

    图 10.30:运行这个练习的开始文件
  3. Accessing the application with the following curl should yield an unauthorized response:

    js curl http://localhost:3000

    下面是上述代码的输出:

    Figure 10.31: cURL of the micro application

    图 10.31:微应用的 cURL

    注意,在这个模块中预先填充了 compose 函数。

  4. Instead of calling each function on the output of the previous one, we will use compose and call its output to create the server. This will replace the server-creation step:

    js const server = compose(

    js   micro,

    js   timer,

    js   authenticate,

    js   handler

    js )();

    服务器创建步骤最初看起来如下所示,非常冗长,可能难以阅读。 compose版本清楚地显示了请求必须通过的管道:

    js const server = micro(timer(authenticate(handler)));

  5. Restart the application for the changes to take place. Once npm run Exercise78 is up and running, you should be able to curl:

    js curl http://localhost:3000

    下面是上述代码的输出:

Figure 10.32: cURL of the micro application with "compose"

图 10.32:使用 compose 的微应用的 cURL

在这个练习中,我们看到compose重构并没有影响应用的功能。 可以根据响应尝试不同的请求。

上述问题可以用以下代码进行排序:

curl http://localhost:3000 -H 'Authorization: ApiKey api-key-1' -I

下面的请求将以 401 错误失败,因为我们没有设置有效的授权头:

curl http://localhost:3000 -H 'Authorization: ApiKey bad-key' -I
curl http://localhost:3000 -H 'Authorization: Bearer bearer-token' -I

为了进行比较,下面是使用 Express 及其基于中间件的组合模型的等效 BFF 应用。 它实现了与我们在此练习中使用的微 BFF 相似的功能:

const express = require('express');
const app = express();
const responseText = `Hello authenticated Express!`;
const timerStart = (req, res, next) => {
  const timerName = `request_${(Math.random() * 100).toFixed(2)}`;
  console.time(timerName);
  req.on('end', () => {
    console.timeEnd(timerName);
  });
  next();
};
const ALLOWED_API_KEYS = new Set(['api-key-1', 'key-2-for-api']);
const authenticate = (req, res, next) => {
  const {authorization} = req.headers;
  if (authorization && authorization.startsWith('ApiKey')) {
    const apiKey = authorization.replace('ApiKey', '').trim();
    if (ALLOWED_API_KEYS.has(apiKey)) {
      return next();
    }
  }
  return res.status(401).send(`Unauthorized: <pre>${responseText}</pre>`);
};
const requestHandler = (req, res) => {  return res.send(responseText);
};
app.use(timerStart, authenticate, requestHandler);
app.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});

了解功能组合带来的可能性将意味着在功能接口(输入和输出)的设计中需要更多的考虑,以便能够利用compose。 下一节将介绍不可变性和副作用,这是必要的,以便我们可以组成一组部分应用或纯函数。

不变性和副作用

在纯函数上下文中,变量的变异被认为是一种副作用,因此发生变异的函数,特别是在函数执行之后存在的变量,就不是纯函数。

JavaScript 中的不可变性很难实施,但该语言为我们提供了良好的原语,可以以不可变的风格编写。 这种风格严重依赖于创建数据副本的操作符和函数,而不是在适当的地方进行修改。

可以编写应用的整个部分而不使用副作用。 任何数据操作都可能没有副作用。 然而,大多数应用需要加载数据,以便从某处显示数据,并可能在某处保存一些数据。 这些都是需要控制的副作用。

Redux Action creator 简介

动作创建者创建 Redux 动作。 它们对于抽象常量和集中 Redux 存储所支持的操作很有用。

动作创建者总是返回一个新的动作对象。 创建并返回一个新对象是保证返回值不变性的好方法,至少就操作创建者而言是这样。 如果动作创建者返回了他们的参数的某个版本,它可能会产生令人惊讶的输出:

const ADD_PRODUCT = 'ADD_PRODUCT';
function addProduct(newProduct) {
  return {
    type: ADD_PRODUCT,
    newProduct
  };
}

可以使用动作创建者的输出来调用dispatch,而不是使用手动编组的对象:

this.props.dispatch(addProduct(newProduct))

练习 79:Refactoring the React/Redux Application to Use Action creator

动作创建者是从 React 组件中抽象动作形状的好方法。

请注意

练习 79 附带了一个预配置的开发服务器和启动文件(即exercise--refactor-action-creators-start.jsexercise-refactor-action-creators-start.html)中的方法框架。 开发服务器可以与npm run Exercise79一起运行。 这个练习的工作解决方案可以使用 GitHub 上的 npm run exercise10 文件来运行。

在本练习中,您将从使用内联操作定义转向使用操作创建者。

执行以下步骤来完成这个练习:

  1. 将当前目录更改为Lesson10,并运行npm install(如果您以前未在此目录下运行)。 npm install下载运行此活动所需的依赖项(React, Redux, React - Redux 和 Parcel)。
  2. First, run npx parcel serve exercise-refactor-action-creators-start.html. To see the application during development, run npm run Exercise79. You will see the application starting up, as follows:

    Figure 10.33: Running the start file of this exercise

    图 10.33:运行这个练习的开始文件

    为了让开发服务器实时重新加载我们的更改并避免配置问题,可以直接编辑exercise-refactor-action-creators-start.js文件。

  3. Go to http://localhost:1234 (or whichever URL the starting script output). You should see the following HTML page:

    Figure 10.34: Initial application in the browser

    图 10.34:浏览器中的初始应用
  4. Implement the startCheckout, continueShopping, done, and addProduct action creators:

    js function startCheckout(items) {

    js   return {

    js     type: START_CHECKOUT,

    js     basket: {

    js       items

    js     }

    js   };

    js }

    js function continueShopping() {

    js   return {

    js     type: CONTINUE_SHOPPING

    js   };

    js }

    js function done() {

    js   return {

    js     type: DONE

    js   };

    js }

    js function addProduct(newProduct) {

    js   return {

    js     type: ADD_PRODUCT,

    js     newProduct: {

    js       ...newProduct,

    js       quantity: 1

    js     }

    js   };

    js }

    它们分别返回以下动作类型:START_CHECKOUTCONTINUE_SHOPPINGDONEADD_PRODUCT

  5. 更新handleCheckout以使用相应的startCheckout动作创建者:

    js handleCheckout(items) {

    js   this.props.dispatch(startCheckout(items));

    js }

  6. 更新continueShopping以使用相应的continueShopping动作创建者:

    js continueShopping() {

    js   this.props.dispatch(continueShopping());

    js }

  7. 更新finish以使用相应的done动作创建者:

    js finish() {

    js   this.props.dispatch(done());

    js }

  8. 更新addProduct以使用相应的addProduct动作创建者:

    js addProduct(product) {

    js   this.props.dispatch(addProduct(product));

    js }

  9. 检查应用是否仍然按照预期运行:

Figure 10.35: Application after refactoring the  creators

图 10.35:重构动作创建者后的应用

redux mapStateToProps 和 mapDispatchToProps

react-redux 的核心命题是连接功能,顾名思义,连接组件到商店。 它有一个connect(mapStateToProps, mapDispatchToProps) (component)的签名,并返回一个connect组件。

在大多数示例中,mapStateToProps函数是stated => state,这在所有状态都与连接组件相关的小型应用中是有意义的。 原则上,选择器应该在mapStateToProps中使用,以避免传递太多的道具,因此当数据甚至不使用更改时,组件将重新呈现。 下面是一个关于mapStateToProps函数的小示例:

const mapStateToProps = state => {
  return {
    items: selectBasketItems(state),
    status: selectStatus(state),
    product: selectProduct(state)
  };
};

让我们用mapStateToProps完成一个练习。

使用 mapDispatchToProps 函数抽象状态管理

在本练习中,您将使用mapDispatchToProps函数管理状态,该函数利用选择器抽象 redux 存储的内部形状。

执行以下步骤来完成这个练习:

  1. 将当前目录更改为Lesson10,并运行npm install(如果您以前未在此目录下运行)。
  2. First, run npx parcel serve exercise-map-to-props-start.html. Then, during development, run npm run Exercise80. You will see the application starting up, as follows:

    请注意

    练习 80 附带了一个预配置的开发服务器和启动文件(即exercise-map-to-props-start.jsexercise-map-to-props-start.html)中的方法框架。 开发服务器可以与npm run Exercise80一起运行。 这个练习的工作解决方案可以使用 GitHub 上的 npm run Exercise80 文件来运行。

    Figure 10.36: Output of npm run Exercise80

    图 10.36:npm 运行 Exercise80 的输出
  3. Go to http://localhost:1234 (or whichever URL the starting script output). You should see a blank HTML page. This is due to mapStateToProps returning an empty state object.

    请注意

    审计解释 App 组件使用哪些状态片段(来自商店),以及产品、项目和状态是如何使用状态片段的。

  4. 创建一个新的选择器status:

    js const selectStatus = state => state && state.status;

  5. 创建一个新的选择器product:

    js const selectProduct = state => state && state.product;

  6. mapStateToProps中,将itemsproductstatus映射到对应的选择器,应用于以下状态:

    js const mapStateToProps = state => {

    js   return {

    js     items: selectBasketItems(state),

    js     status: selectStatus(state),

    js     product: selectProduct(state)

    js   };

    js };

  7. 将 App 组件中调用dispatch的函数提取到mapDispatchToProps,注意从this.props.dispatch中删除this.props。 Dispatch 是到mapDispatchToProps的第一个参数。 我们的代码现在看起来应该如下:

    js const mapDispatchToProps = dispatch => {

    js   return {

    js     handleCheckout(items) {

    【显示】

    js     },

    js     continueShopping() {

    js       dispatch(continueShopping());

    【病人】

    js     finish() {

    js       dispatch(done());

    js     },

    【t16.1】

    js       dispatch(addProduct(product));

    js     }

    js   };

    js };

  8. App#render中的引用替换为this.handleCheckoutthis.props.handleCheckout:

    js {status === 'SHOPPING' && (

    js   <Basket

    js     items={items}

    js     renderItem={item => (

    js       <div>

    js         x{item.quantity} - {item.name} - $

    js         {(item.price / 100).toFixed(2)} each{' '}

    js       </div>

    js     )}

    js     onCheckout={this.props.handleCheckout}

    js     />

    js )}

  9. App#render中的引用替换为this.continueShoppingthis.finish。 反之,呼叫this.props.continueShoppingthis.props.finish,分别为:

    js {status === 'CHECKING_OUT' && (

    js   <div>

    js     <p>You have started checking out with {items.length} items.</p>

    js     <button onClick={this.props.continueShopping}>

    js       Continue shopping

    js     </button>

    js     <button onClick={this.props.finish}>Finish</button>

    js   </div>

    js )}

  10. App#render中的引用替换为this.addProductthis.props.addProduct:

    js {status === 'SHOPPING' && (

    js   <div style={{marginTop: 50}}>

    js     <h2>{product.name}</h2>

    js     <p>Price: ${product.price / 100}</p>

    js     <button onClick={() => this.props.addProduct(product)}>

    js       Add to Basket

    js     </button>

    js   </div>

    js )}

  11. 打开http://localhost:1234,查看应用现在的行为是否符合预期。 您可以添加产品,进入结帐,完成或继续购物:

Figure 10.37: Application after mapStateToProps/mapDispatchToProps refactor

图 10.37:mapStateToProps/mapDispatchToProps 重构后的应用

应用现在使用正确实现的mapStateToPropsmapDispatchToProps函数工作。 React 和 Redux 进一步相互抽象。 React 组件中没有更多的状态,也没有直接调用商店的dispatch方法。 这意味着,原则上,另一个状态管理库可以用来替代 Redux,而 React 代码不会改变; 只有状态管理器和 ReactApp组件之间的粘合代码会改变。

Redux Reducers In Depth

Redux 还原器不应该改变 Redux 存储状态。 与第一原理相比,纯函数更容易测试,其结果也更容易预测。 作为一个状态管理解决方案,Redux 有两个角色:保持预期状态和确保有效和及时地传播更新。

纯函数可以帮助我们通过记住不变性来实现这个目标。 返回副本有助于更改检测。 例如,检测对象中的一大部分键是否被更新的代价要比检测对象是否被自身的浅拷贝所替换的代价高。 在第一个实例中,必须进行昂贵的深度比较,遍历整个对象,以检测原始值和/或结构的差异。 在浅复制的情况下,对象引用不同这一事实就足以检测更改。 这很简单,需要使用===JavaScript 操作符,该操作符通过引用比较对象。

改变 javascript 本地方法为不可变的函数风格

Map/filter/reduce 不会改变它们所操作的初始数组。 在下面的代码片段中,initial的值保持不变。 Array#map返回数组的副本,这样它就不会改变它所操作的数组。 Array#reduceArray#filter也是如此; 它们都在数组中使用,但不会改变任何值。 相反,它们会创建新对象:

// Node.js built-in
const assert = require('assert').strict;
const initial = [
  {
    count: 1,
    name: 'Shampoo'
  },
  {
    count: 2,
    name: 'Soap'
  }
];
assert.deepStrictEqual(
  initial.map(item => item.name),
  ['Shampoo', 'Soap'],
  'demo map'
);
assert(
  initial.map(item => item.count).reduce((acc, curr) => acc + curr) === 3,
  'demo map reduce'
);
assert.deepStrictEqual(
  initial.filter(item => item.count > 1),
  [{count: 2, name: 'Soap'}],
  'demo filter'
);

对象restspread语法是在 ECMAScript 2018 中引入的,它是创建对象浅拷贝和排除/覆盖键的好方法。 下面的代码结合了Array#map和对象 rest/spread 来创建数组的浅副本(使用Array#map),但也使用 rest/spread 创建数组内对象的浅副本:

// Node.js built-in
const assert = require('assert').strict;
const initial = [
  {
    count: 1,
    name: 'Shampoo'
  },
  {
    count: 2,
    name: 'Soap'
  }
];
assert.deepStrictEqual(
  initial.map(item => {
    return {
      ...item,
      category: 'care'
    };
  }),
  [
    {
      category: 'care',
      count: 1,
      name: 'Shampoo'
    },
    {
      category: 'care',
      count: 2,
      name: 'Soap'
    }
  ],
  'demo of spread (creates copies)'
);
assert.deepStrictEqual(
  initial.map(({name, ...rest}) => {
    return {
      ...rest,
      name: `${name.toLowerCase()}-care`
    };
  }),
  [
    {
      count: 1,
      name: 'shampoo-care'
    },
    {
      count: 2,
      name: 'soap-care'
    }
  ],
  'demo of rest in parameter + spread'
);

数组的restspread语法先于对象的 spread/rest,因为它是 ECMAScript 2015(也称为 ES6)的一部分。 与它的对象副本非常相似,它对于创建浅拷贝非常有用。 我们已经看到的另一个用例是将类似数组的对象转换为成熟的数组。 同样的技巧也可以用于 iterrable,如Set

在以下示例中,数组分布用于在对数组进行排序之前创建数组的副本,并用于将 Set 转换为 Array。 数组扩展也被用于创建数组中除第一个元素外的所有元素的副本:

// Node.js built-in
const assert = require('assert').strict;
const initial = [
  {
    count: 1,
    name: 'Shampoo'
  },
  {
    count: 2,
    name: 'Soap'
  }
];
assert.deepStrictEqual(
  // Without the spread, reverse() mutates the array in-place
  [...initial].reverse(),
  [
    {
      count: 2,
      name: 'Soap'
    },
    {
      count: 1,
      name: 'Shampoo'
    }
  ],
  'demo of immutable reverse'
);
assert.deepStrictEqual(
  [...new Set([1, 2, 1, 2])],
  [1, 2],
  'demo of spread on Sets'
);
const [first, ...rest] = initial;
assert.deepStrictEqual(first, {count: 1, name: 'Shampoo'});
assert.deepStrictEqual(rest, [
  {
    count: 2,
    name: 'Soap'
  }
]);

Object.freeze使对象在严格模式下运行时为只读。

例如,下面的代码片段将使用 throw,因为我们试图在严格模式下向冻结对象添加属性:

// Node.js built-in
const assert = require('assert').strict;
const myProduct = Object.freeze({
  name: 'Special Sauce',
  price: 1999
});
assert.throws(() => {
  'use strict';
  myProduct.category = 'condiments';
}, 'writing to an existing property is an error in strict mode');
assert.throws(() => {
  'use strict';
  myProduct.name = 'Super Special Sauce';
}, 'writing a new property is an error in strict mode');

在实践中很少使用。 JavaScript 作为一种设计用于在浏览器中运行的语言,其构建非常宽松。 运行时错误是存在的,但应该避免,特别是对于某些必然是应用问题的情况:写入只读属性。

此外,Object.freeze只在非严格模式下抛出。 看一下下面的例子,其中允许访问和修改冻结对象的属性,因为默认情况下,JavaScript 运行在非严格模式下:

// Node.js built-in
const assert = require('assert').strict;
const myProduct = Object.freeze({
  name: 'Special Sauce',
  price: 1999
});
assert.doesNotThrow(() => {
  myProduct.category = 'condiments';
}, 'writing to an existing property is fine in non-strict mode');
assert.doesNotThrow(() => {
  myProduct.name = 'Super Special Sauce';
}, 'writing a new property is fine in non-strict mode');

工程团队通常选择遵循包含不可变风格的编码标准,而不是强制执行不可变。

请注意

也可以利用像 immutable .js 这样的库,它们提供了以一种高效的方式实现的持久的不可变数据结构。

React/Redux 应用 React 生命周期钩子的副作用处理

React 组件的render()方法应该是纯的,所以它不支持副作用。 能够根据组件的输入(道具和状态)预测组件是否需要重新呈现,意味着可以避免大量不必要的更新。 因为每个状态或道具更新都可能导致调用render,所以它可能不是放置 API 调用的最佳地点。

React 文档建议使用componentDidMount生命周期方法。 componentDidMount在组件安装后运行。 换句话说,如果组件没有在 React 应用的前一个状态中呈现,那么它将在组件第一次呈现在页面上时运行。

我们可以使用componentDidMount发送带有fetch的 HTTP 请求。 Promise 的fetch可以用来更新服务器响应的状态:

import React from 'react';
class App extends React.Component {
  constructor() {
    super();
    this.state = {};
  }
  componentDidMount() {
    fetch('https://hello-world-micro.glitch.me')
      .then(response => {
        if (response.ok) {
          return response.text();
        }
      })
      .then(data => {
        this.setState({
          message: data
        });
      });
  }
  render() {
    return (
      <div>
        <p>Message: {this.state.message}</p>
      </div>
    );
  }
}

在 React/Redux 应用中处理副作用

作为 React 最近添加的一个功能,钩子允许函数组件利用所有以前特定于类组件的特性。

可以将前面的示例重构为使用useStateuseEffect钩子的函数组件。 useState是一种我们可以使用钩子与 React 函数组件一起使用状态的方法。 当useState的状态改变时,React 将重新渲染函数组件。 useEffect是与componentDidMount相对应的,如果组件没有在应用的先前状态中呈现,则在组件呈现之前被调用:

import React, {useEffect, useState} from 'react';
const App = () => {
  const [message, setMessage] = useState(null);
  useEffect(() => {
    if (!message) {
      fetch('https://hello-world-micro.glitch.me')
        .then(response => {
          if (response.ok) {
            return response.text();
          }
        })
        .then(data => {
          setMessage(data);
        });
    }
  });
  return (
    <div>
      <p>Message: {message}</p>
    </div>
  );
};

处理 React/Redux 应用的副作用

坦克是一种延迟函数求值的方法。 这是一种在不支持的语言中进行惰性计算的一种方法:

let globalState;
function thunk() {
  return () => {
    globalState = 'updated';
  };
}
const lazy = thunk();
console.assert(!globalState, 'executing the thunk does nothing');
lazy();
console.assert(
  globalState === 'updated',
  'executing the output of the thunk runs the update'
);

这也是一种封装副作用的方法。 因为我们有一级函数,所以我们传递 thunk,这在纯函数中是允许的(thunk 只是一个函数),尽管调用 thunk 本身可能会有副作用。

Redux-thunk 非常简单; 不是传递一个返回对象(带有类型字段和可能的有效负载)的操作创建者,而是返回一个以商店的分派和getState作为参数的函数。

在坦克内部,可以访问当前的存储状态和分派操作,这些操作将被减少到存储中。 参见下面的 Redux 和 Redux -thunk 示例:

// store is set up, App is connected, redux-thunk middleware is applied
import React from 'react';
class App extends React.Component {
  componentDidMount() {
    // this looks like any action creator
    this.props.dispatch(requestHelloWorld());
  }
  render() {
    return (
      <div>
        <p>Message: {this.props.message}</p>
      </div>
    );
  }
}
function requestHelloWorld() {
  // this action creator returns a function
  return (dispatch, getState) => {
    fetch('https://hello-world-micro.glitch.me')
      .then(response => {
        if (response.ok) {
          return response.text();
        }
      })
      .then(data => {
        dispatch({
          type: 'REQUEST_HELLO_WORLD_SUCCESS',
          message: data
        });
      })
      .catch(error => {
        dispatch({
          type: 'REQUEST_HELLO_WORLD_ERROR',
          error
        });
      });
  };
}

GraphQL 语言模式和查询简介

GraphQL 是一种查询语言。 它公开了要对其运行查询的类型化模式。 GraphQL 的巨大好处是客户端可以请求它需要的信息。 这是类型化模式的直接影响。

我们将使用express-graphql将 GraphQL 添加到 BFF 中,它与 micro 兼容。 我们需要为 GraphQL 端点提供模式和解析器,以便它能够响应客户机请求。 在练习 12 的启动文件中提供了这样一个服务器(将工作目录更改为Lesson10,运行npm install,接着运行npm run Exercise81,然后导航到http://localhost:3000以查看其运行情况)。

返回篮子的样例 GraphQL 查询可以在以下 GraphQL 模式定义中工作。 注意我们有三种类型,即Querybasket,basketItembasket包含items属性下的basketItems列表。 query包含顶级的 GraphQL 查询字段,在本例中就是basket。 要查询basketItems,必须加载对应的basket,并扩展items字段:

type basket {
  items: [basketItem]
}
"""BasketItem"""
type basketItem {
  name: String
  price: Int
  quantity: Int
  id: String
}
"""Root query"""
type Query {
  basket: basket
}

在 Node.js 的 GraphQL 服务器组件中有一个工具是 GraphQL。 它是 GraphQL 的一个接口,允许用户浏览模式并提供模式的文档。

我们输入的查询如下:加载顶级查询字段basket,扩展其items字段,填充篮子items字段中basketItem元素的namequantityprice:

Figure 10.38: GraphiQL user interface and a GraphQL query that fetches  fully expanded basket items

图 10.38:GraphQL 用户界面和获取完全展开的篮子项的 GraphQL 查询

使用 GraphQL 突变和解析器运行更新

在查询和模式世界中,非常缺少的一件事是运行写操作的方法。 这就是 GraphQL 突变的由来。 突变是结构化的更新操作。

解析器是一个服务器端 GraphQL 实现细节。 解析器是用来解析 GraphQL 查询的。 解析器从模式链的顶部运行到底部。 当解析一个查询时,对象上的字段会并行执行; 当解决一个突变时,它们是按顺序解决的。 下面是一个正在使用的突变示例:

const mutation = new GraphQLObjectType({
  name: 'Mutation',
  fields() {
    return {};
  }
});

请注意

更多关于 GraphQL 的指南可以在https://graphql.org上找到。

练习 81:用 micro 和 GraphQL 实现 BFF 突变

在本练习中,我们将使用 micro 和 GraphQL 来实现 BFF 突变。

执行以下步骤来完成这个练习:

请注意

练习 81 附带了一个预配置的服务器和启动文件exercise-graphql-micro-start.js中的方法框架。 开发服务器可以使用npm run Exercise81运行。 这个练习的工作解决方案可以使用 GitHub 上的npm run Exercise81文件运行。

  1. 将当前目录更改为Lesson10,并运行npm install(如果您以前未在此目录下运行)。 npm install下载所需的依赖项,这样我们就可以运行这个活动(micro 和express-graphql)。
  2. Run node exercise-graphql-micro-start.js. Then, during development, run npm run Exercise81. You will see the application starting up, as follows:

    Figure 10.39: Running the start file of this exercise

    图 10.39:运行这个练习的开始文件
  3. Go to http://localhost:3000 (or whichever URL the starting script output). You should see the following GraphiQL page:

    Figure 10.40: Empty GraphiQL UI

    图 10.40:空的 graphhiql 用户界面
  4. Add a LineItemCost constant, which is a field definition (plain JavaScript object):

    js const LineItemCost:

    js = {

    js   type: GraphQLInt,

    js   args: {id: {type: GraphQLString}},

    js   resolve(root, args, context) {

    js     return 4;

    js   }

    js };

    我们的LineItemCost应该将type属性设置为GraphQLInt,因为LineItemCost计算的输出是一个整数。 LineItemCost还应该有一个args字段,该字段应该设置为{id: {type: GraphQLString}}。 换句话说,我们的突变采用了一个id参数,它是一个字符串(与我们拥有的样本数据一致)。 为了让突变返回一些东西,它需要一个resolve()方法。 目前,它可以返回任何整数。 突变的resolve方法以根为第一个参数,args为第二个参数。

  5. 现在让我们实现LineItemCost的实际resolve方法。 首先,我们需要使用.find(el => el.id === args.id)通过 ID 查找basketItems中的条目。

    js const LineItemCost = {

    js   type: GraphQLInt,

    js   args: {id: {type: GraphQLString}},

    js   resolve(root, args, context) {

    js     const item = basketItems.find(i => i.id === args.id);

    js     return item ? item.quantity * item.price : null;

    js   }

    js };

  6. 创造一个突变常数,即GraphQLObjectType。 查看查询是如何初始化的; 名称为Mutation:

    js const mutation = new GraphQLObjectType({

    js   name: 'Mutation',

    js   fields() {

    js     return {};

    js   }

    js });

  7. LineItemCost添加到fields()突变返回值。 这意味着LineItemCost现在是顶级突变。 如果 GraphQL 模式上存在mutation,则可以调用:

  8. 添加mutationGraphQLSchema模式:

    js const handler = graphqlServer({

    js   schema: new GraphQLSchema({query, mutation}),

    js   graphiql: true

    js });

  9. Send the following query to your server (through GraphiQL). Enter it on the left-hand side editor and click the Play button:

    js mutation {

    js   cost1: LineItemCost(id: "1")

    js   cost2: LineItemCost(id: "2")

    js }

    请注意

    这个突变使用了所谓的 GraphQL 别名,因为我们不能在相同的名称下运行两次突变。

输出结果如下:

Figure 10.41: GraphiQL with LineItemCost aliased mutation queries for IDs "1" and "2"

图 10.41:带有 LineItemCost 别名的 id“1”和“2”突变查询的 graphql

为了使篮子示例更真实,我们将从 GraphQL BFF 加载初始篮子数据,使用 GraphQL 查询 Redux -thunk 来处理副作用,并使用一个新的 reducer 来更新 Redux 存储状态。 下一个活动的目的是向您展示如何使用 Redux -thunk 将 GraphQL BFF 与 React/Redux 应用集成在一起。

活动 17:从最好的朋友那里拿当前的篮子

在此活动中,我们将从 GraphQL BFF 获取初始购物篮数据,以便将商品重新呈现到购物篮中,从而更新购物篮的初始状态。 让我们开始:

请注意

活动 17 附带了一个预配置的开发服务器和启动文件中的方法框架,即activity-app-start.jsactivity-app-start.html。 开发服务器可以与npm run Activity17一起运行。 这个活动的工作解决方案可以使用 GitHub 上的 npm run Activity17 文件来运行。

  1. 将当前目录更改为Lesson10,并运行npm install(如果您以前未在此目录下运行)。
  2. 运行 BFF 活动 17 和npx parcel serve activity-app-start.html。 在开发期间,运行npm run Activity17
  3. 转到http://localhost:1234(或启动脚本输出的任何 URL)来检查 HTML 页面。
  4. 编写一个查询,将从 BFF 获取篮子项。 您可以在http://localhost:3000上使用 GraphQL UI 来进行实验。
  5. 创建一个requestBasket(thunk)动作创建者,它将使用上一步的查询调用fetchFromBff
  6. .then连接到fetchFromBff()呼叫上,以使用正确的 basket有效载荷调度REQUEST_BASKET_SUCCESS行动。
  7. appReducer添加一个案例,将减少REQUEST_BASKET_SUCCESS行动与basket有效载荷进入状态。
  8. requestBasket加入mapDispatchToProps
  9. Call requestBasket, which is mapped to dispatch, in App#componentDidMount.

    请注意

    这个活动的解决方案可以在 628 页找到。

小结

一级函数是使用流行库(如 React 及其模式)的重要组成部分。 它们还支持任何和所有的委托实现,特别是内置组件,如 Array。 函数式编程的另一个核心原则是纯函数。 使用纯函数来处理复杂的数据操作逻辑或围绕数据结构的抽象层是流行的 Redux 状态管理解决方案提出的一种很棒的模式。 任何必须被嘲笑的副作用和/或依赖关系都使我们很难对复杂的数据操作进行推理。 在日常的 JavaScript 开发中,高阶函数和特定的技术(如 curry 和 partial application)非常普遍。 curry 和 partial 应用是一种使用接口设计函数的方法,该接口使专门化的每一步都“可保存”,因为它已经是一个已经用一定数量的参数应用的函数。

如果发现了一个函数应用管道,那么该组合可能具有真正的价值。 例如,将 HTTP 服务建模为管道非常有意义。 另一方面,Node.js HTTP 服务器生态系统的领导者使用了一个基于中间件的组合模型,micro,它公开了一个函数组合模型。 以不可变的样式编写 JavaScript 允许库有一种廉价的方法来检查某些内容是否已更改。 React 和 Redux 的副作用是在常规的纯函数流(即渲染函数和还原函数)之外处理的。 Redux-thunk 是一个相当实用的问题解决方案,尽管代价是使函数成为有效的操作。 纯 Redux 动作是带有属性类型的 JavaScript 对象。

在这本书中,我们学习了各种框架,包括 React、Angular 以及相关的工具和库。 它教会了我们构建现代应用所需了解的先进概念。 然后,我们学习了如何在文档对象模型(DOM)中表示 HTML 文档。 之后,我们结合 DOM 和 Node.js 的知识来创建一个实际情况下的 web scraper。

在下一部分中,我们使用 Node.js 的 Express 库创建了一个基于 Node.js 的 RESTful API。 我们了解了如何使用模块化设计来提高可重用性,以及如何与多个开发人员在单个项目上进行协作。 我们还学习了如何构建单元测试,以确保程序的核心功能不会随着时间的推移而被破坏。 我们看到了构造函数、异步/等待和事件如何以高速和性能加载应用。 本书的最后一部分介绍了函数式编程的概念,如不变性、纯函数和高阶函数。