MERN-技术栈高级教程-二-

75 阅读1小时+

MERN 技术栈高级教程(二)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

四、React 状态

直到现在,我们只看到了静态组件,也就是没有变化的组件。为了制作响应用户输入和其他事件的组件,React 在组件中使用了一个名为 state 的数据结构。在这一章中,我们将探讨如何使用 React 状态,以及如何操作它来改变组件的外观和它在屏幕上显示的内容。

状态本质上保存着数据,一些可以改变的东西,而不是你之前看到的不可变的props形式的属性。这个状态需要在构建视图的render()方法中使用。只有状态的改变才能改变观点。当数据或状态发生变化时,React 会自动重新呈现视图,以显示新更改的数据。

对于本章,目标是添加一个按钮,并在单击该按钮时向初始问题列表添加一行。我们将添加这个按钮来代替IssueAdd组件中的占位符文本。通过这样做,您将了解组件的状态,如何操作它,如何处理事件,以及如何在组件之间进行通信。

我们将从追加一行开始,无需用户交互。我们将使用计时器而不是按钮来完成这项工作,这样我们就可以专注于状态和修改,而不用处理像用户输入这样的事情。在本章的结尾,我们将用一个实际的按钮和一个用户输入的表单来代替计时器。

初态

组件的状态是在组件的类中一个名为this.state的变量中捕获的,该变量应该是一个由一个或多个键-值对组成的对象,其中每个键是一个状态变量名,值是该变量的当前值。React 并不指定什么需要进入状态,但是在状态中存储任何影响渲染视图并且可以由于任何事件而改变的内容是很有用的。这些通常是由于用户交互而生成的事件。

对于IssueTable组件,正在显示的问题列表肯定就是这样一段数据,它既影响呈现的视图,也可以在添加、编辑或删除问题时改变。因此,一系列问题是一个理想的状态变量。

其他事情,比如窗口的大小,也可以改变,但是这不会影响 DOM。即使显示发生了变化(例如,由于窗口变窄,一行可能会换行),浏览器也会根据相同的 DOM 直接处理这种变化。所以,我们不需要在组件的状态中捕获它。可能会有影响 DOM 的情况;例如,如果窗口的高度决定了我们显示多少个问题,我们可以将窗口的高度存储在一个状态变量中,并限制正在构建的IssueRow组件的数量。在这些情况下,窗口的高度或导出值(例如,正在显示的问题数量)也可以存储在状态中。

不改变的东西,比如表格的边框样式,也不需要进入状态。这是因为用户交互或其他事件不会影响边框的样式。

现在,让我们使用一个问题数组作为组件的唯一状态,并使用该数组构建问题表。因此,在IssueTablerender()方法中,让我们将创建IssueRows集合的循环改为使用名为issues的状态变量,而不是像这样的全局数组:

...
  const issueRows = this.state.issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
...

至于初始状态,让我们使用一组硬编码的问题,并将其设置为初始状态。我们已经有一系列全球性的问题;让我们将这个数组重命名为initialIssues,只是为了明确它只是一个初始集合。

...
const initialIssues = [
  ...
];
...

设置初始状态需要在组件的构造函数中完成。这可以通过简单地将变量this.state分配给一组状态变量及其值来实现。让我们使用变量initialIssues来初始化状态变量issues的值,如下所示:

...
    this.state = { issues: initialIssues };
...

注意,我们只使用了一个名为issues的状态变量。我们可以有其他状态变量,例如,如果我们在多个页面中显示问题列表,并且我们还希望将当前显示的页码作为另一个状态变量,我们可以通过向像page: 0这样的对象添加另一个键来实现。

清单 4-1 中显示了使用状态来呈现视图IssueTable的所有更改。

...

const issues = [

const initialIssues = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2018-08-15'), due: undefined,
  },
...
class IssueTable extends React.Component {
  constructor() {
    super();
    this.state = { issues: initialIssues };
  }

  render() {
    const issueRows = issues.map(issue =>
    const issueRows = this.state.issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
    );
...

Listing 4-1App.jsx: Initializing and Using State

运行和测试这段代码应该不会在应用中显示任何变化;您仍然会看到一个包含两行问题的表,就像以前一样。

练习:初始状态

  1. 如果您需要根据问题的状态以不同的背景颜色显示每一行,您会怎么做?您是否有一个对应于每个问题的颜色列表也存储在州中?为什么或为什么不?

本章末尾有答案。

异步状态初始化

尽管我们在构造函数中设置了初始状态,但是常规的 SPA 组件不太可能静态地拥有初始状态。这些通常是从服务器上获取的。在问题跟踪器应用的情况下,甚至要显示的初始问题列表也必须通过 API 调用来获取。

状态只能在构造函数中赋值。之后,可以修改状态,但是只能通过调用React.Componentthis.setState()方法。该方法接受一个参数,该参数是一个包含所有已更改的状态变量及其值的对象。我们拥有的唯一状态变量是一个叫做issues的变量,它可以在对this.setState()的调用中设置为任何问题列表,如下所示:

...
  this.setState({ issues: newIssues });
...

如果有额外的状态变量,只设置一个变量(issues)将导致与现有状态合并。例如,如果我们将当前页面存储为另一个状态变量,状态变量issues的新值将被合并到状态中,保持当前页面的值不变。

因为在构造组件的时候,我们没有初始数据,所以我们必须在构造函数中给状态变量issues分配一个空数组。

...
  constructor() {
    this.state = { issues: [] };
...

我们现在还不会从服务器获取数据,但是为了探究状态初始化的变化,让我们模拟这样一个调用。全局问题数组和对服务器的调用之间的关键区别在于,后者需要异步调用。让我们向IssueTable类添加一个方法,该方法异步返回一组问题。最终,我们将用一个对服务器的 API 调用来代替它,但是目前,我们将使用一个setTimeout()调用来使它异步。在对setTimeout()调用的回调中(最终将是一个 Ajax 调用),让我们用初始问题的静态数组调用this.setState(),如下所示:

...
  loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
  }
...

500 毫秒的超时值有些随意:期望一个真正的 API 调用在这段时间内获取初始问题列表是合理的。

现在,在IssueTable的构造函数内调用loadData()是非常诱人的。它甚至看起来可以工作,但事实是构造函数只构造了组件(也就是说,在内存中完成对象的所有初始化),并不呈现 UI。当组件需要显示在屏幕上时,渲染会在稍后进行。如果this.setState()在组件准备好呈现之前被调用,事情就会出错。在简单的页面中,您可能看不到这种情况,但是如果初始页面很复杂并且需要时间来呈现,并且如果 Ajax 调用在呈现完成之前返回,您将会得到一个错误。

React 提供了许多被称为生命周期方法的其他方法来迎合这种情况和其他需要根据阶段或组件状态的变化来做一些事情的情况。除了构造函数和render()方法之外,组件的以下生命周期方法可能会引起人们的兴趣:

  • componentDidMount():一旦组件的表示被转换并插入到 DOM 中,就调用这个方法。在这个方法中可以调用一个setState()

  • componentDidUpdate():这个方法在更新发生后立即被调用,但在初始渲染时不会被调用。this.setState()可以在这个方法内调用。还向该方法提供先前的属性和先前的状态作为参数,以便该函数有机会在采取动作之前检查先前的属性和状态与当前的属性和状态之间的差异。

  • componentWillUnmount() :这个方法对于清除比如取消定时器和挂起的网络请求很有用。

  • shouldComponentUpdate():此方法可用于优化和防止重新渲染,以防道具或状态发生变化,但实际上并不影响输出或视图。这种方法很少使用,因为当状态和属性设计良好时,很少会出现状态或属性改变但视图不需要更新的情况。

在这种情况下,启动数据加载的最佳位置是componentDidMount()方法。在这个时间点上,DOM 保证已经准备好了,并且可以调用setState()来重新呈现组件。componentDidUpdate()也是一个选项,但是因为初始渲染时可能不会调用它,所以我们不要使用它。让我们在IssueTable中添加componentDidMount()方法,并在该方法中加载数据:

...
  componentDidMount() {
    this.loadData();
  }
...

清单 4-2 中显示了IssueTable类的一整套变化。

...
class IssueTable extends React.Component {
  constructor() {
    super();
    this.state = { issues: initialIssues };
    this.state = { issues: [] };
  }

  componentDidMount() {
    this.loadData();
  }

  loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
  }
...

Listing 4-2App.jsx, IssueTable: Loading State Asynchronously

如果你刷新浏览器(假设你仍然在两个不同的控制台上运行npm run watchnpm start),你会发现问题列表会像以前一样显示。但是,你也会看到在页面加载后的一瞬间,表格是空的,如图 4-1 所示。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig1_HTML.jpg

图 4-1

显示几分之一秒的空表

它很快就被填满了,但仍然有闪烁。当我们在后面的章节中探索服务器端的渲染时,我们将摆脱这种笨拙的闪烁。目前,让我们忍受这个小的 UI 不愉快。

更新状态

在前面的小节中,您看到了如何设置初始状态,在构造函数中使用直接赋值,以及使用this.setState()在其他生命周期方法中设置值。在这一节中,让我们对状态做一个小的改变,而不是为它设置一个全新的值。让我们添加一个新的问题,从而改变,不是整个状态,而只是它的一部分。

首先,让我们在IssueTable中添加一个方法来添加一个新问题。这可以接受一个 issue 对象作为参数,我们将为它分配一个新的 ID 并设置创建日期。新的 ID 可以从数组的现有长度中计算出来。

...
  createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
  }
...

注意状态变量不能直接设置,也不能直接变异。也就是说,不允许将this.state.issues设置为新值或修改其元素。组件中的变量this.state应该总是被视为不可变的。例如,不应执行以下操作:

...
    this.state.issues.push(issue);    // incorrect!
...

原因是 React 不会自动识别这种状态变化,因为它是一个普通的 JavaScript 变量。让 React 知道事情发生了变化,并导致 rerender 的唯一方法是调用this.setState()。此外,this.setState()可能导致直接对状态变量所做的改变被覆盖。因此,也不应该执行以下操作:

...
    issues = this.state.issues;
    issues.push(issue);         // same as this.state.issues.push()!
    this.setState({ issues: issues });
...

这看似可行,但在这个组件及其派生组件的一些生命周期方法中会产生意想不到的后果。特别是在那些比较新旧属性的方法中,旧状态和新状态之间的差异不会被检测到。

setState()调用中需要的是一系列新的问题,比如状态变量的副本。如果任何现有的数组元素(比如某个问题本身)正在发生变化,那么不仅需要数组的副本,还需要正在变化的对象的副本。有称为*不变性助手、*的库,比如immutable.js ( http://facebook.github.io/immutable-js/ ),可以用来构造新的状态对象。当对象的属性被修改时,库会创建一个最佳副本。

但我们只会追加一个问题,而不会改变现有的问题。制作数组的浅层副本相当简单,目前这就足够了。因此,我们不会使用这个库——我们不需要编写太多额外的代码来处理它。如果在您的应用中,您发现由于状态中对象的深度嵌套,您必须制作大量副本,您可以考虑使用immutable.js

制作数组副本的简单方法是使用slice()方法。所以让我们像这样创建一个issues数组的副本:

...
    issues = this.state.issues.slice();
...

在本章的后面,我们将创建一个用户界面来添加新问题。但是现在,与其处理 UI 和事件处理的复杂性,不如让我们添加一个计时器,当计时器到期时,一个硬编码的样本问题将被追加到问题列表中。让我们首先在全局initialIssues之后全局声明这个硬编码的样本发布对象:

...
const sampleIssue = {
  status: 'New', owner: 'Pieta',
  title: 'Completion date should be optional',
};
...

让我们在IssueTable的构造函数中,在两秒钟的定时器到期后,在对createIssue()的调用中使用这个对象:

...
    setTimeout(() => {
      this.createIssue(sampleIssue);
    }, 2000);
...

这应该会在页面加载后自动将示例问题添加到问题列表中。清单 4-3 显示了最后一组更改——使用计时器将一个样本问题添加到问题列表中。

...
const initialIssues = [
  ...
];

const sampleIssue = {

  status: 'New', owner: 'Pieta',
  title: 'Completion date should be optional',

};

...

class IssueTable extends React.Component {
  constructor() {
    super();
    this.state = { issues: [] };
    setTimeout(() => {
      this.createIssue(sampleIssue);
    }, 2000);
  }

  ...

  createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
    const newIssueList = this.state.issues.slice();
    newIssueList.push(issue);
    this.setState({ issues: newIssueList });
  }
}
...

Listing 4-3App.jsx: Appending an Issue on a Timer

在运行这组更改并刷新浏览器时,您会看到有两行问题要开始处理。两秒钟后,添加第三行,其中包含新生成的 ID 和样本问题的内容。三排表截图如图 4-2 所示。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig2_HTML.jpg

图 4-2

将行追加到初始问题集

注意,我们没有在IssueRow组件上显式调用setState()。React 会自动将任何依赖于父组件状态的更改传播到子组件。此外,我们不必编写任何代码来将行插入 DOM。React 计算了对虚拟 DOM 的更改,并插入了一个新行。

此时,可以直观地描绘出组件的层次结构和数据流,如图 4-3 所示。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig3_HTML.jpg

图 4-3

设置状态并将数据作为道具传递

练习:更新状态

  1. 在第一个定时器之后设置另一个定时器,比如说三秒钟,根据sampleIssue添加另一个问题。当添加第二个新问题时,您是否注意到有些地方出错了?提示:看第一期新发行的 ID。你认为这是为什么?怎么能纠正呢?

  2. IssueRowrender()方法中添加一个console.log。你预计render()会被叫多少次?您看到多少控制台日志?(确保您撤消了在之前的练习中所做的更改!)

本章末尾有答案。

提升状态

在我们添加用户界面元素来创建新的问题之前,让我们将创建的开始移动到它真正属于的地方:在IssueAdd组件中。这将允许我们一步一步地处理变更,因为将添加新问题的计时器从IssueTable组件移动到IssueAdd组件并不像第一次出现时那么简单。

如果你真的试图移动它,你会立即意识到createIssue()方法也必须移动,或者我们需要在IssueAdd中有一个变体,它可以与IssueTable通信并调用继续保留在那里的createIssue()方法。但是在 React 中,兄弟姐妹之间没有直接的交流方式。只有父母才能把信息传递给孩子;横向交流似乎很难,如果不是不可能的话。

解决这个问题的方法是让公共父包含状态和所有处理这个状态的方法。通过将状态提升到级别IssueList,信息可以向下传播到IssueAdd以及IssueTable

让我们从将状态转移到IssueList和装载初始状态的方法开始。IssueTable的构造函数既有状态初始化又有定时器,其中只有状态初始化需要移动(定时器会移动到IssueAdd):

...
class IssueList extends React.Component {
  constructor() {
    super();
    this.state = { issues: [] };
  }
...

其他处理状态的方法有componentDidMount()loadData()createIssue()。让我们把这些也移到IssueList类:

...
class IssueList extends React.Component {
  ...
  componentDidMount() {
    ...
  }
  loadData() {
    ...
  }
  createIssue(issue) {
    ...
  }
  ...
}

现在,IssueTable没有状态来构造IssueRow组件。但是你已经看到了数据是如何以props的形式从父母传递给孩子的。让我们使用这个策略,通过 props 将一系列问题从IssueList内的状态传递给IssueTable:

...
        <IssueTable issues={this.state.issues} />
...

并且,在IssueTable中,我们需要从 props 中获取相同的数据,而不是引用状态变量issues:

...
    const issueRows = this.state.issues.map(issue =>
    const issueRows = this.props.issues.map(issue =>
...

至于IssueAdd,我们需要将计时器移入这个类的构造函数中,并从这个组件中触发一个新问题的添加。但是我们这里没有可用的createIssue()方法。幸运的是,由于父组件可以将信息传递给子组件,我们将把方法本身作为道具的一部分从IssueList传递给IssueAdd,这样就可以从IssueAdd调用它。下面是IssueListIssueAdd组件的实例化变化:

...
        <IssueAdd createIssue={this.createIssue} />
...

这让我们使用this.props.createIssue()作为计时器回调的一部分,从IssueAdd调用createIssue()。因此,让我们在IssueAdd中创建一个构造函数,并对设置的计时器进行一点小小的修改,以使用通过道具传入的createIssue回调,如下所示:

...
    setTimeout(() => {
      this.props.createIssue(sampleIssue);
    }, 2000);
...

在我们可以说我们已经完成了这一组更改之前,我们还需要处理另外一件事情。与此同时,我们一直使用 arrow 函数语法来设置计时器。在 ES2015 中,箭头功能具有将上下文(this的值)设置为词法范围的效果。这意味着回调中的this将引用词法范围内的this,也就是说,在匿名函数之外,代码存在的地方。

只要被调用的函数与计时器回调在同一个类中,这就可以工作。在loadData()方法中,它仍然有效,因为this指的是计时器被触发的IssueList组件,因此,this.state指的是IssueList本身的状态。

但是,当从IssueAdd内的定时器调用createIssue时,this将引用IssueAdd组件。但是我们真正想要的是createIssue总是用this指代IssueList组件。否则,this.state.issues将未定义。

实现这一点的方法是,在传递之前将方法绑定到IssueList组件。我们可以在像这样实例化IssueAdd时进行这样的更改:

...
        <IssueAdd createIssue={this.createIssue.bind(this)} />
...

但是,如果我们需要再次引用同一个方法并将其传递给其他子组件,我们就必须重复这段代码。此外,永远不会出现我们需要方法不被绑定的情况,所以最好用其自身的绑定版本替换createIssue的定义。推荐的方法是在实现此方法的类的构造函数中。

所以,与其在IssueAdd的实例化过程中绑定,不如在IssueList的构造函数中绑定。

...
    this.createIssue = this.createIssue.bind(this);
...

在做了所有这些更改之后,这些类的新版本如下面的清单所示。清单 4-4 显示了新的IssueTable类;清单 4-5 展示了新的IssueAdd类;清单 4-6 显示了新的IssueList类。

class IssueTable extends React.Component {
  render() {
    const issueRows = this.props.issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
    );

    return (
      <table className="bordered-table">
        <thead>
          <tr>
            <th>ID</th>
            <th>Status</th>
            <th>Owner</th>
            <th>Created</th>
            <th>Effort</th>
            <th>Due Date</th>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          {issueRows}
        </tbody>
      </table>
    );
  }
}

Listing 4-4App.jsx: New IssueTable Class

class IssueAdd extends React.Component {
  constructor() {
    super();
    setTimeout(() => {
      this.props.createIssue(sampleIssue);
    }, 2000);
  }
  render() {
    return (
      <div>This is a placeholder for a form to add an issue.</div>
    );
  }
}

Listing 4-5App.jsx, IssueAdd: New IssueAdd Class

class IssueList extends React.Component {
  constructor() {
    super();
    this.state = { issues: [] };
    this.createIssue = this.createIssue.bind(this);
  }

  componentDidMount() {
    this.loadData();
  }

  loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
  }

  createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
    const newIssueList = this.state.issues.slice();
    newIssueList.push(issue);
    this.setState({ issues: newIssueList });
  }

  render() {
    return (
      <React.Fragment>
        <h1>Issue Tracker</h1>
        <IssueFilter />
        <hr />
        <IssueTable issues={this.state.issues} />
        <hr />
        <IssueAdd createIssue={this.createIssue} />
      </React.Fragment>
    );
  }
}

Listing 4-6App.jsx, IssueList: New IssueList Class

这些更改的效果在用户界面中看不到。应用将像以前一样运行。在刷新浏览器时,您将看到一个空的表格,很快就会填充两个问题,两秒钟后,将添加另一个问题。

但是这为我们做了很好的准备,我们可以用一个按钮代替IssueAdd中的计时器,用户可以点击这个按钮来添加新的问题。

练习:提升状态

  1. 移除方法createIssue()的绑定。您在控制台中看到什么错误?它告诉你什么?

本章末尾有答案。

事件处理

现在让我们通过点击一个按钮来交互式地添加一个问题,而不是使用计时器来添加。我们将创建一个包含两个文本输入的表单,并使用用户在其中输入的值来添加一个新问题。添加按钮将触发添加。

让我们首先创建一个表单,用IssueAddrender()方法中的两个文本输入代替占位符div

...
      <div>This is a placeholder for a form to add an issue.</div>
      <form>
        <input type="text" name="owner" placeholder="Owner" />
        <input type="text" name="title" placeholder="Title" />
        <button>Add</button>
      </form>
...

此时,我们可以从构造函数中移除产生问题的计时器。

...
  constructor() {
    super();
    setTimeout(() => {
      this.props.createIssue(sampleIssue);
    }, 2000);
  }
...

如果您运行代码,您将看到一个表单代替了IssueAdd中的占位符。图 4-4 中显示了这个屏幕截图。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig4_HTML.jpg

图 4-4

IssueAdd 将占位符替换为表单

此时,单击 Add 将提交表单并再次获取相同的屏幕。这不是我们想要的。首先,我们希望它使用 owner 和 title 字段中的值来调用createIssue()。其次,我们希望阻止表单被提交,因为我们将自己处理该事件。

为了处理像onclickonsubmit这样的事件,我们需要提供给元素的属性简单来说就是onClickonSubmit。与普通的 HTML 和 JavaScript 一样,这些属性将函数作为值。我们将创建一个名为handleSubmit()的类方法,在点击添加按钮时接收表单中的提交事件。在这个方法中,我们需要一个表单的句柄,所以像在常规 HTML 中一样,让我们给表单一个名字,比如说,issueAdd,然后可以在 JavaScript 中使用document.forms.issueAdd来引用它。

所以,让我们像这样用一个名字和一个onSubmit处理程序重写表单声明。

...
            <form name="issueAdd" onSubmit={this.handleSubmit}>
...

现在,我们可以在IssueAdd中实现方法handleSubmit()。该方法接收触发提交的事件作为参数。为了防止点击添加按钮时表单被提交,我们需要在事件上调用preventDefault()函数。然后,通过documents.forms.issueAdd使用表单句柄,我们可以获得文本输入字段的值。利用这些,我们将通过调用createIssue()创建一个新的问题。在调用了createIssue()之后,让我们通过清除文本输入字段来为下一组输入准备好表单。

...
  handleSubmit(e) {
    e.preventDefault();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
    }
    this.props.createIssue(issue);
    form.owner.value = ""; form.title.value = "";
  }
...

注意

在这一点上,我们使用传统的方式获取用户输入,使用命名输入并使用 DOM 元素的value属性获取它们的值。React 有另一种处理用户输入的方式,通过控制的组件,输入的值被绑定到一个状态变量。我们将在后面的章节中探讨这一点。

因为handleSubmit将从一个事件中被调用,所以上下文或者说this将被设置为生成事件的对象,通常是window对象。正如您在上一节中看到的,要让这个方法通过this访问对象变量,我们需要在构造函数中将它绑定到this:

...
  constructor() {
    super();
    this.handleSubmit = this.handleSubmit.bind(this);
  }
...

清单 4-7 显示了经过这些修改后的IssueAdd类的新的完整代码。

class IssueAdd extends React.Component {
  constructor() {
    super();
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleSubmit(e) {
    e.preventDefault();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
    }
    this.props.createIssue(issue);
    form.owner.value = ""; form.title.value = "";
  }

  render() {
    return (
      <form name="issueAdd" onSubmit={this.handleSubmit}>
        <input type="text" name="owner" placeholder="Owner" />
        <input type="text" name="title" placeholder="Title" />
        <button>Add</button>
      </form>
    );
  }
}

Listing 4-7App.jsx, IssueList: New IssueAdd Class

不再需要全局对象sampleIssue,所以我们可以去掉它。这一变化如清单 4-8 所示。

...

const sampleIssue = {

  status: 'New', owner: 'Pieta',
  title: 'Completion date should be optional',

};

...

Listing 4-8App.jsx, Removal of sampleIssue

现在,您可以通过在 owner 和 title 字段中输入一些值并单击 Add 来测试这些更改。您可以添加任意多的行。如果您添加两个问题,您将得到如图 4-5 所示的屏幕。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig5_HTML.jpg

图 4-5

使用 IssueAdd 表单添加新问题

最后,我们已经能够从IssueAdd组件本身封装和发起一个新问题的创建。为了做到这一点,我们将状态“提升”到最不常见的祖先,这样所有的子 Node 都可以通过传入的属性或通过可以修改状态的回调直接访问它。图 4-6 中描述了这种新的用户界面层次数据和功能流程。与图 4-3 中状态保持的情况相比。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig6_HTML.jpg

图 4-6

提升状态后的组件层次结构和数据流

练习:事件处理

  1. 刷新浏览器;你会看到增加的问题都不见了。一个人如何保持变化?

  2. 移除e.preventDefault()。单击“添加”按钮,为“所有者”和“职位”添加一些值。会发生什么?你在地址栏里看到了什么?你能解释这个吗?

  3. 使用开发人员控制台检查该表,并在<tbody>元素上添加一个断点作为“在子树修改时中断”。现在,添加一个新问题。子树被修改了多少次?与“更新状态”中的练习#2 相比,在练习# 2 中,您跟踪了一个IssueRow中的render()调用的数量。

本章末尾有答案。

无状态组件

我们有三个起作用的 React 组件(IssueAddIssueRowIssueTable),它们被分层组合成IssueList(另一个组件IssueFilter,仍然是一个占位符)。但是这些功能组件类之间存在差异。

有很多方法,一个状态,状态的初始化,以及修改状态的函数。相比较而言,IssueAdd有一定的交互性,但没有状态 1 。但是,如果你注意到,IssueRowIssueTable除了一个render()方法之外什么都没有。出于性能和代码清晰的原因,建议将这些组件写成函数而不是类:一个接受props并基于它进行渲染的函数。就好像组件的视图是其props的纯函数,并且是无状态的。render()函数本身可以是组件。

如果一个组件不依赖于 props,可以写成一个简单的函数,函数的名字就是组件名。例如,考虑我们在第 2 (React 组件)一章开头写的 Hello World 类:

...
class HelloWorld extends React.Component {
  render() {
    return (
      <div title="Outer div">
        <h1>Hello World!</h1>
      </div>
    );
  }
}
...

这可以重写为一个像这样的纯函数:

...
function HelloWorld() {
  return (
    <div title="Outer div">
      <h1>Hello World!</h1>
    </div>
  );
}
...

如果渲染只依赖于道具(通常情况下,确实是这样),函数可以用一个参数作为道具来编写,可以在函数的 JSX 体中访问这些参数。假设 Hello World 组件接收一条消息作为道具的一部分。该组件可以重写如下:

...
function HelloWorld(props) {
  return (
    <div title="Outer div">
      <h1>{props.message}</h1>
    </div>
  );
}
...

当呈现的输出可以表示为 JavaScript 表达式时,可以使用使用箭头函数的更简洁的形式,也就是说,除了 return 语句之外没有其他语句的函数:

...
const HelloWorld = (props) => (
  <div title="Outer div">
    <h1>{props.message}</h1>
  </div>
);
...

这个HelloWorld组件可以这样实例化:

...
  <HelloWorld message="Hello World" />
...

既然IssueRowIssueTable是无状态组件,那我们就把它们改成纯函数吧。新部件分别如清单 4-9 和清单 4-10 所示。

function IssueRow(props) {
  const issue = props.issue;
  return (
    <tr>
      <td>{issue.id}</td>
      <td>{issue.status}</td>
      <td>{issue.owner}</td>
      <td>{issue.created.toDateString()}</td>
      <td>{issue.effort}</td>
      <td>{issue.due ? issue.due.toDateString() : ''}</td>
      <td>{issue.title}</td>
    </tr>
  );
}

Listing 4-9App.jsx, IssueRow as a Stateless Component

function IssueTable(props) {
  const issueRows = props.issues.map(issue =>
    <IssueRow key={issue.id} issue={issue} />
  );

  return (
    <table className="bordered-table">
      <thead>
        <tr>
          <th>ID</th>
          <th>Status</th>
          <th>Owner</th>
          <th>Created</th>
          <th>Effort</th>
          <th>Due Date</th>
          <th>Title</th>
        </tr>
      </thead>
      <tbody>
        {issueRows}
      </tbody>
    </table>
  );
}

Listing 4-10App.jsx, IssueTable as a Stateless Component

设计组件

大多数初学者会对 state 和props有点困惑,什么时候使用哪个,应该选择什么粒度的组件,以及如何完成这一切。本节专门讨论一些原则和最佳实践。

国家对道具

state 和props都保存模型信息,但它们是不同的。props是不可变的,而 state 不是。通常,状态变量作为props传递给子组件,因为子组件不会维护或修改它们。它们接收一个只读副本,并仅用它来渲染组件的视图。如果子 Node 中的任何事件影响了父 Node 的状态,子 Node 将调用父 Node 中定义的方法。对这个方法的访问应该已经通过props作为回调传递了。

由于组件层次结构中任何地方的事件,能够改变的任何东西都被认为是状态的一部分。避免将计算值保存在状态中;相反,只需在需要时计算它们,通常在render()方法中。

不要将props复制到状态中,因为props是不可变的。如果你觉得有必要这么做,考虑修改这些props的原始状态。一个例外是当props被用作状态的初始值,并且该状态在初始化后确实与原始状态脱节。

您可以使用表 4-1 作为差异的快速参考。

表 4-1

国家对道具

|

属性

|

状态

|

道具

| | --- | --- | --- | | 易变性 | 可以使用this.setState()进行更改 | 无法改变 | | 所有权 | 属于组件 | 属于祖先,则该组件将获得一个只读副本 | | 信息 | 模型数据 | 模型数据 | | 影响 | 组件的渲染 | 组件的渲染 |

组件层次结构

将应用分成组件和子组件。通常,这将反映数据模型本身。例如,在问题跟踪器中,问题数组由IssueTable组件表示,每个问题由IssueRow组件表示。

就像拆分函数和对象一样决定粒度。该组件应该是自包含的,具有到父组件的最少的逻辑接口。如果你发现它做了太多的事情,就像在函数中一样,它可能应该被拆分成多个组件,这样它就遵循了单一责任原则(即每个组件应该只负责一件事情)。如果您向一个组件传递了太多的props,这表明要么该组件需要被拆分,要么它不需要存在:父组件本身可以完成这项工作。

沟通

组件之间的通信取决于方向。父母通过props与孩子沟通;当状态改变时,props自动改变。孩子们通过回电与父母交流。

兄弟姐妹和表兄弟姐妹之间无法交流,所以如果有需要,信息必须向上传递,然后再向下传递。这被称为提升状态。这就是我们在处理添加新问题时所做的。IssueAdd组件必须在IssueTable中插入一行。这是通过保持国家在最少的共同祖先,IssueList。添加由IssueAdd发起,一个新的数组元素通过回调添加到IssueList的状态中。通过将issues数组从IssueList向下传递为props,可以在IssueTable中看到结果。

如果父母有必要知道孩子的状态,你可能做错了。虽然 React 确实提供了一种使用refs的方法,但是如果你严格遵循单向数据流,你应该不会觉得有必要:状态作为道具流入子 Node,事件引起状态变化,状态变化作为道具流回。

无状态组件

在设计良好的应用中,大多数组件都是其属性的无状态函数。所有的状态都将在层次结构顶部的几个组件中被捕获,所有后代的道具都是从这些组件中派生出来的。

我们对IssueList就是这么做的,在那里我们保持状态。我们将所有派生组件转换为无状态组件,只依赖于层次结构中传递的属性来呈现它们自己。我们将状态保留在IssueList中,因为这是依赖于该状态的所有后代中最不常见的组件。有时候,你可能会发现,没有逻辑上的共同祖先。在这种情况下,您可能不得不发明一个新的组件来保存状态,即使该组件在视觉上什么也没有。

摘要

在本章中,您学习了如何在用户交互或其他事件中使用状态并对其进行更改。更有趣的方面是状态值如何像props一样沿着组件层次结构向下传播。您还了解了用户交互:单击按钮添加新问题,以及这如何导致状态发生变化,进而,子组件中的道具如何发生变化,导致它们也重新呈现。此外,您还了解了孩子如何通过回调与父母交流。

我们使用模拟异步调用和浏览器本地数据来实现这一切。在下一章,我们将从服务器获取数据,而不是使用本地数据。当添加一个问题时,我们会将数据发送到服务器进行保存。

练习答案

练习:初始状态

  1. 您可以将每一行的背景色存储为状态的一部分,但是,这些值必须在某个时间点进行计算。什么时候是做这件事的好时机?就在设置状态之前?设置初始状态的时候呢?

因为这是一个从派生的值,所以在render()方法中计算这些值并随时使用它们比将它们保存在状态中更好也更有效。

练习:更新状态

  1. 当第二个计时器触发并添加另一个问题时,您会发现它的 ID 为 4,但是第三行的 ID 也变为 4。此外,在控制台中,您将看到一个错误,大意是找到了具有相同键的两个孩子。

    发生这种情况是因为我们使用与第一个相同的对象来创建第二个问题,并且将 ID 设置为 4 会将其设置在唯一的对象:sampleIssue中。为了避免这种情况,您必须在使用它创建新问题之前创建对象的副本,比如说,使用Object.assign()

  2. 初始化时,每行呈现一次(两次呈现,每行一次)。插入新行后,每一行都被渲染一次(三次渲染,每行一次)。虽然调用了 render,但这并不意味着 DOM 被更新了。每次渲染时只重新创建虚拟 DOM。真正的 DOM 更新只发生在有差异的地方。

练习:提升状态

  1. 在移除bind()调用时,在评估this.state.issues时,您会看到一个错误,指出 undefined 不是对象。这应该告诉您this.state是未定义的,并 Bootstrap 您思考this是否是这个调用序列中正确的this

    将来,如果您看到类似的错误,它应该会触发一个想法,可能是某个地方的bind()调用丢失了。

练习:事件处理

  1. 为了持久保存更改,我们可以将问题保存在浏览器的本地存储中,或者保存在服务器中。修改全局initialIssues变量将不起作用,因为当页面刷新时,这个变量将被重新创建。

  2. 页面被刷新,就好像对/提出了新的请求。在地址栏里可以看到类似?owner=&title=的拥有者和头衔的 URL 查询参数。这是因为表单的默认动作是一个带有表单值的 GET HTTP 请求,您在 URL 栏中看到的只是这个调用的结果。(URL 参数中的值为空,因为它们在handleSubmit()中被赋给了空字符串)。

  3. 您将看到<tbody>下的子树只被修改了一次。在修改的细节中,您可以看到添加了一个孩子,但是没有修改任何其他现有的孩子。如果您将它与render()方法调用的数量进行比较,您会发现,尽管每一行都调用了render(),但是只有新的一行被添加到 DOM 中。

Footnotes 1

这并不完全正确。事实上,这个组件中有状态:用户输入时输入字段的状态。但是我们没有将它们捕获为 React 状态,而是让浏览器的本机处理程序来维护它。所以我们并没有真的把它当成一种常规状态。

 

五、Express 和 GraphQL

现在,您已经学习了如何使用 React 创建组件和构建可工作的用户界面,在本章中,我们将花一些时间为数据集成后端服务器。

到目前为止,Express 和 Node.js 服务器提供的唯一资源是以index.html形式的静态内容。在本章中,除了静态 HTML 文件之外,我们将开始使用来自 Express 和 Node.js 服务器的 API 来获取和存储数据。这将取代浏览器内存中的硬编码问题数组。我们将对前端和后端代码进行更改,因为我们将实现和使用 API。

我们不会将数据保存在磁盘上;相反,我们将只使用服务器内存中的模拟数据库。我们将把真正的持久性留到下一章。

表达

我在 Hello World 一章中简要地提到了 Express 以及如何使用 Express 来服务静态文件。但是 Express 可以做的不仅仅是服务静态文件。Express 是一个最小但灵活的 web 应用框架。从某种意义上说,Express 本身做得很少。它依赖于被称为中间件的其他模块来提供大多数应用需要的功能。

选择途径

第一个概念是路由。Express 的核心是路由,它从本质上接受一个客户机请求,将它与任何存在的路由进行匹配,并执行与该路由相关联的处理程序功能。处理函数应该生成适当的响应。

路由规范由 HTTP 方法(GET、POST 等)组成。)、匹配请求 URI 的路径规范以及路由处理程序。处理程序在请求对象和响应对象中传递。可以检查请求对象以获得请求的各种细节,可以使用响应对象的方法将响应发送给客户端。所有这些可能看起来有点令人不知所措,所以让我们从一个简单的例子开始,并探索细节。

我们已经有了一个使用express()函数创建的 Express 应用。我们还安装了一个处理静态文件的中间件。中间件功能处理与路径规范匹配的任何请求,而不考虑 HTTP 方法。相反,路由可以用特定的 HTTP 方法匹配请求。因此,为了匹配 GET HTTP 方法,必须使用app.get()而不是app.use()。此外,处理函数(路由函数采用的第二个参数)可以将响应设置为发送回调用者,如下所示:

...
app.get('/hello', (req, res) => {
  res.send('Hello World!');
});
...

请求匹配

当收到一个请求时,Express 做的第一件事就是将请求与其中一个路由匹配。请求方法与路线的方法相匹配。在前面的例子中,路由的方法是get(),所以任何使用 GET 方法的 HTTP 请求都将匹配它。此外,请求 URL 与路径规范匹配,路径规范是路由中的第一个参数,即/ hello。当一个 HTTP 请求符合这个规范时,就会调用处理函数。在前面的例子中,我们只是用一条文本消息来响应。

路线的方法和路径不必是特定的。如果您想匹配所有的 HTTP 方法,您可以编写app.all()。如果需要匹配多个路径,可以传入一个路径数组,甚至像'/*.do'这样的正则表达式也可以匹配任何以扩展名.do结尾的请求。很少使用正则表达式,但是经常使用路由参数,所以我将对此进行更详细的讨论。

路线参数

路由参数是路径规范中与 URL 的一部分匹配的命名段。如果出现匹配,URL 中该部分的值将作为请求对象中的变量提供。

它以下列形式使用:

app.get('/customers/:customerId', ...)

URL /customers/1234将匹配路由规范,/customers/4567也是如此。在这两种情况下,客户 ID 将被捕获并作为请求的一部分提供给处理函数req.params,参数的名称作为关键字。因此,对于这些 URL 中的每一个,req.params.customerId将分别具有值12344567

注意

查询字符串不是路径规范的一部分,因此不能对查询字符串的不同参数或值使用不同的处理程序。

路线查找

可以设置多个路由来匹配不同的 URL 和模式。路由不会尝试寻找最佳匹配;相反,它会尝试按照安装顺序匹配所有路由。使用第一个匹配项。因此,如果两个路由可能匹配一个请求,它将使用第一个定义的路由。因此,必须按照优先级顺序定义路线。

因此,如果您添加模式而不是非常具体的路径,您应该注意在具体路径之后添加更通用的模式,以防请求同时匹配两者。例如,如果您想要匹配/api/下的所有内容,也就是说,像/api/*这样的模式,您应该只在处理路径的所有更具体的路由之后添加这个路由,例如/api/issues

处理函数

一旦匹配了路由,就调用处理函数,在前面的例子中,它是提供给路由设置函数的匿名函数。传递给处理程序的参数是请求对象和响应对象。处理函数不应该返回任何值。但是它可以检查请求对象,并根据请求参数发送响应作为响应对象的一部分。

让我们简单看一下请求和响应对象的重要属性和方法。

请求对象

使用请求对象的属性和方法可以检查请求的任何方面。下面列出了一些重要且有用的属性和方法:

  • req.params:这是一个包含映射到命名路由参数的属性的对象,正如您在使用:customerId的例子中看到的。属性的键将是路由参数的名称(在本例中是customerId),值将是作为 HTTP 请求的一部分发送的实际字符串。

  • req.query:保存解析后的查询字符串。它是一个以键作为查询字符串参数,以值作为查询字符串值的对象。多个同名的键被转换为数组,带有方括号符号的键导致嵌套对象(例如,order[status]=closed可以作为req.query.order.status访问)。

  • req.header, req.get(header):get方法可以访问请求中的任何头部。header 属性是一个对象,所有标题都存储为键值对。一些头被特殊处理(如 Accept ),并在请求对象中有专门的方法。这是因为依赖于这些标题的常见任务可以轻松处理。

  • req.path:这包含了 URL 的路径部分,也就是 everything up any?开始查询字符串。通常,路径是路由规范的一部分,但是如果路径是可以匹配不同 URL 的模式,则可以使用此属性来获取请求中接收到的实际路径。

  • req.url, req.originalURL:这些属性包含完整的 URL,包括查询字符串。注意,如果您有任何修改请求 URL 的中间件,originalURL将保存修改前收到的 URL。

  • req.body:包含请求体,对 POST、PUT 和 PATCH 请求有效。注意,主体是不可用的(req.body将是未定义的),除非安装了一个中间件来读取和选择性地解释或解析主体。

还有许多其他的方法和属性;完整的列表请参考 http://expressjs.com/en/api.html#req 的 Express 的请求文档以及 Node.js 的请求对象at https://nodejs.org/api/http.html#http_class_http_incomingmessage ,Express 请求是从该请求扩展而来的。

响应对象

response 对象用于构造和发送响应。请注意,如果没有发送响应,客户端将一直等待。

  • res.send(body):你已经简单看过了res.send()方法,它用一个字符串来响应。这个方法也可以接受一个缓冲区(在这种情况下,内容类型被设置为application/octet-stream,而不是字符串情况下的text/html)。如果主体是一个对象或数组,它会自动转换为具有适当内容类型的 JSON 字符串。

  • res.status(code):设置响应状态码。如果未设置,则默认为200 OK。一种常见的发送错误的方式是将status()send()方法组合在一个单独的调用中,就像res.status(403).send("Access Denied")一样。

  • res.json(object):这和res.send()一样,除了这个方法强制转换传入 JSON 的参数,而res.send()可能会不同地对待一些参数,比如null。它还使代码可读和明确,表明您确实在发送一个 JSON。

  • res.sendFile(path):以path的文件内容响应。使用文件的扩展名猜测响应的内容类型。

响应对象中还有许多其他方法和属性;在 http://expressjs.com/en/api.html#res 可以查看 Express 文档中的完整列表,在 https://nodejs.org/api/http.html#http_class_http_serverresponse 可以查看 HTTP 模块中 Node.js 的 Response 对象。但是对于最常见的使用,前面的方法应该足够了。

中间件

Express 是一个 web 框架,它本身的功能很少。Express 应用本质上是一系列中间件函数调用。其实路由本身无非就是一个中间件功能。区别在于,中间件通常对请求和/或需要为所有或大多数请求完成的事情进行一般处理,但不一定是发送响应的链中的最后一个。另一方面,路由旨在用于特定的路径+方法组合,并被期望发出响应。

中间件功能是那些可以访问请求对象(req)、响应对象(res)以及应用的请求-响应周期中的下一个中间件功能的功能。下一个中间件功能通常用一个名为next的变量来表示。我不会详细讨论如何编写自己的中间件函数,因为我们不会在应用中编写新的中间件。但是我们肯定会使用一些中间件,所以理解任何中间件如何在高层次上工作是很方便的。

在 Hello World 示例中,我们已经使用了一个名为express.static的中间件来服务静态文件。这是作为 Express 的一部分唯一可用的内置中间件(除了路由)。但是 Express 团队还支持其他非常有用的中间件,我们将在本章中使用 body-parser,尽管是间接使用。第三方中间件可通过 npm 获得。

中间件可以在应用级别(适用于所有请求)或特定的路径级别(适用于特定的请求路径模式)。在应用级别使用中间件的方法是简单地向应用提供功能,就像这样:

app.use(middlewareFunction);

在使用static中间件的情况下,我们通过调用express.static()方法构建了一个中间件功能。这不仅返回了一个中间件函数,还配置它使用名为public的目录来查找静态文件。

为了将相同的中间件仅用于匹配某个 URL 路径的请求,比如说,/public,调用app.use()方法时必须使用两个参数,第一个参数是路径,如下所示:

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

这将使在路径/public上安装静态中间件,所有静态文件都必须用前缀/public访问,例如/public/index.html

应用接口

REST(表述性状态转移的缩写)是应用编程接口(API)的架构模式。还有其他更老的模式,比如 SOAP 和 XMLRPC,但是最近,REST 模式越来越流行。

由于 Issue Tracker 应用中的 API 仅供内部使用,我们可以使用任何 API 模式,甚至可以发明自己的模式。但是我们不要这样做,因为使用现有的模式会迫使您更好地思考和组织 API 和模式,并鼓励一些好的实践。

虽然我们不会使用 REST 模式,但我会简单地讨论一下,因为由于它的简单性和少量的构造,它是更受欢迎的选择之一。它会让你体会到我最终选择使用 GraphQL 的区别和逻辑。

基于资源

API 是基于资源的(而不是基于动作的)。因此,像getSomethingsaveSomething这样的 API 名称在 REST APIs 中并不常见。事实上,没有传统意义上的 API 名称,因为 API 是由资源和动作组合而成的。实际上只有资源名叫做端点

基于统一资源标识符(URI,也称为端点)来访问资源。资源是名词(不是动词)。通常每个资源使用两个 URIs:一个用于集合(如/customers),一个用于单个对象(如/customers/1234),其中1234唯一地标识一个客户。

资源也可以形成层次结构。例如,客户的订单集合由/customers/1234/orders标识,该客户的订单由/customers/1234/orders/43标识。

作为操作的 HTTP 方法

要访问和操作资源,可以使用 HTTP 方法。资源是名词,而 HTTP 方法是操作它们的动词。它们映射到资源上的 CRUD(创建、读取、更新、删除)操作。表 5-1 显示了 CRUD 操作到 HTTP 方法和资源的常用映射。

表 5-1

HTTP 方法的 CRUD 映射

|

操作

|

方法

|

资源

|

例子

|

备注

| | --- | --- | --- | --- | --- | | 阅读列表 | 得到 | 募捐 | GET /customers | 列出对象(附加查询字符串可用于过滤和排序) | | 阅读 | 得到 | 目标 | GET /customers/1234 | 返回单个对象(查询字符串可用于指定哪些字段) | | 创造 | 邮政 | 募捐 | POST /customers | 使用主体中指定的值创建对象 | | 更新 | 放 | 目标 | PUT /customers/1234 | 用正文中指定的对象替换该对象 | | 更新 | 修补 | 目标 | PATCH /customers/1234 | 按照主体中的指定,修改对象的某些属性 | | 删除 | 删除 | 目标 | DELETE /customers/1234 | 删除对象 |

其他一些操作,比如 DELETE 和 PUT in 集合,也可以用来一次性删除和修改整个集合,但是这种用法并不常见。HEAD 和 OPTIONS 也是有效的动词,它们给出关于资源的信息,而不是实际的数据。它们主要用于向外公开并由许多不同客户端使用的 API。

尽管 HTTP 方法和操作映射被很好地映射和指定,REST 本身并没有为以下内容制定规则:

  • 对对象列表进行过滤、排序和分页。查询字符串通常以特定于实现的方式来指定这些内容。

  • 指定在读取操作中返回哪些字段。

  • 如果有嵌入对象,指定在读取操作中要扩展哪些对象。

  • 指定要在修补操作中修改的字段。

  • 对象的表示。在读取和写入操作中,您可以自由地使用 JSON、XML 或任何其他对象表示。

鉴于不同的 API 集使用不同的方式来处理这些问题,大多数 REST API 实现更像 REST- 而不是严格的 REST。这影响了普遍采用,因此,缺少工具来帮助完成实现基于 REST 的 API 所需的许多常见工作。

GraphQL(图形 SQL)

尽管 REST 范式在使 API 可预测方面非常有用,但是前面讨论的缺点使得当不同的客户端访问同一组 API 时很难使用它。例如,一个对象在移动应用中的显示方式和在桌面浏览器中的显示方式可能会有很大不同,因此,更细粒度的控制以及不同资源的聚合可能会更好。

GraphQL 正是为了解决这些问题而开发的。因此,GraphQL 是一个更加精细的规范,具有以下显著特征。

字段规格

与 REST APIss 不同,在 REST API 中,您很难控制服务器将什么作为对象的一部分返回,而在 GraphQL 中,必须指定需要返回的对象的属性。在 REST API 中,不指定对象的字段会返回整个对象。相反,在 GraphQL 查询中,不请求任何内容是无效的。

这使得客户端可以控制通过网络传输的数据量,从而提高效率,特别是对于移动应用等轻型前端。此外,添加新功能(字段或新 API)不需要您引入新版本的 API 集。给定一个查询,由于返回数据的形状是由其决定的,所以不管 API 如何变化,结果都是一样的。

不利的一面是 GraphQL 查询语言有一点学习曲线,任何 API 调用都必须使用它。幸运的是,这种语言的规范非常简单,很容易掌握。

基于图形

REST APIs 是基于资源的,而 GraphQL 是基于图形的。这意味着对象之间的关系在 GraphQL APIs 中被自然地处理。

在问题跟踪器应用中,您可以认为问题和用户有关系:问题分配给用户,用户有一个或多个分配给他们的问题。当查询用户的属性时,GraphQL 可以很自然地查询与分配给他们的所有问题相关的一些属性。

单端点

GraphQL API 服务器有一个端点,而 REST 中每个资源只有一个端点。被访问的资源或字段的名称作为查询本身的一部分提供。

这使得使用单个查询来查询客户端所需的所有数据成为可能。由于查询基于图形的性质,所有相关对象都可以作为一个对象的查询的一部分来检索。不仅如此,甚至不相关的对象也可以在对 API 服务器的一次调用中被查询。这消除了对“聚合”服务的需求,聚合服务的工作是将多个 API 结果放在一个包中。

强类型

GraphQL 是一种强类型查询语言。所有字段和参数都有一个类型,可以根据该类型来验证查询和结果,并给出描述性的错误消息。除了类型之外,还可以指定哪些字段和参数是必需的,哪些是可选的。所有这些都是使用 GraphQL 模式语言完成的。

强类型系统的优点是它可以防止错误。考虑到 API 是由不同的团队编写和使用的,因此必然会有沟通上的差距,这是一件很棒的事情。

GraphQL 的类型系统有自己的语言来指定您希望在 API 中支持的类型的细节。它支持整数和字符串等基本标量类型、由这些基本数据类型组成的对象以及自定义标量类型和枚举。

反省

可以向 GraphQL 服务器查询它所支持的类型。这为工具和客户端软件创建了一个强大的平台来构建这些信息。这包括静态类型语言中的代码生成工具和资源管理器,使开发人员能够快速测试和学习 API 集,而无需重新整理代码库或与 cURL 争论。

我们将使用一个这样的工具,叫做 Apollo Playground,来测试我们的 API,然后将它们集成到应用的 UI 中。

图书馆

单独解析和处理类型系统语言(也称为 GraphQL 模式语言)以及查询语言是很困难的。幸运的是,大多数语言中都有用于此目的的工具和库。

对于后端的 JavaScript,有一个名为 GraphQL.js 的 GraphQL 参考实现。为了将它与 Express 联系起来,并使 HTTP 请求成为 API 调用的传输机制,有一个名为express-graphql的包。

但是这些都是非常基本的工具,缺乏一些高级支持,比如模块化模式和定制标量类型的无缝处理。包graphql-tools和相关的apollo-server构建在 GraphQL.js 之上,以添加这些高级特性。在本章中,我们将使用问题跟踪器应用的高级包。

我将只介绍应用所需的 GraphQL 特性。对于您在自己的特定应用中可能需要的高级功能,请参考位于 https://graphql.org 的 GraphQL 的完整文档和位于 https://www.apollographql.com/docs/graphql-tools/ 的工具。

关于 API

让我们从一个简单的 API 开始,它返回一个字符串,叫做 About。在这一节中,我们将实现这个 API 以及另一个 API,它允许我们更改这个 API 返回的字符串。这将让您学习使用 GraphQL 进行简单读取和写入的基础知识。

在我们开始为它编写代码之前,我们需要用于graphql-toolsapollo-server的 npm 包,以及它们所依赖的基础包graphql。包graphql-toolsapollo-server-express的依赖项,所以我们不必明确指定,而graphql是对等依赖项,需要单独安装。以下是安装它们的命令:

$ npm install graphql@0 apollo-server-express@2

现在,让我们定义我们需要支持的 API 的模式。GraphQL 模式语言要求我们使用关键字type定义每种类型,后跟类型的名称,再加上花括号中的规范。例如,要定义一个包含用户名字符串的User类型,这是模式语言中的规范:

...
type User {
  name: String
}
...

对于 About API,我们不需要任何特殊的类型,只要基本的数据类型String就足够好了。但是 GraphQL 模式有两个特殊的类型,它们是类型系统的入口点,称为QueryMutation。所有其他 API 或字段都是在这两种类型下分层定义的,它们就像 API 的入口点。查询字段应该返回现有状态,而突变字段应该改变应用数据中的某些内容。

模式必须至少有Query类型。查询和变异类型之间的区别是概念上的:在一个查询或变异中没有什么是您在另一个查询或变异中不能做的。但是一个微妙的区别是,查询字段是并行执行的,而变异字段是串行执行的。所以,最好按照它们的本意来使用它们:在Query下实现读操作,在Mutation下实现修改系统的东西。

GraphQL 类型系统支持以下基本数据类型:

  • Int:有符号 32 位整数。

  • Float:有符号双精度浮点值。

  • String:UTF 8 字符序列。

  • Boolean : truefalse

  • ID:表示唯一的标识符,序列化为字符串。使用一个ID代替一个字符串表明它不适合人类阅读。

除了指定类型之外,模式语言还提供了一个指示值是可选的还是强制的条款。默认情况下,所有值都是可选的(也就是说,它们可以是 null),那些需要值的值是通过在类型后添加一个感叹号(!)来定义的。

在 About API 中,我们只需要在Query下有一个名为about的字段,这个字段是一个字符串,也是一个强制字段。注意,模式定义是 JavaScript 中的一个字符串。我们将使用模板字符串格式,这样我们可以在模式中平滑地添加新行。因此,可以查询的about字段的模式定义如下:

...
const typeDefs = `
  type Query {
    about: String!
  }
`;
...

我们将在初始化服务器时使用变量typeDefs,但在此之前,让我们定义另一个字段,让我们更改消息并将其称为setAboutMessage。但是这需要为我们将接收的新消息输入一个值。这种输入值的指定就像函数调用一样:使用括号。因此,为了表明这个字段需要一个名为message的强制字符串输入,我们需要编写:

...
  setAboutMessage(message: String!)
...

请注意,所有参数都必须命名。GraphQL 模式语言中没有位置参数。此外,所有字段都必须有一个类型,并且没有 void 或其他类型指示该字段不返回任何内容。为了克服这一点,我们可以使用任何数据类型,并使其可选,这样调用者就不需要一个值。

让我们使用字符串数据类型作为setAboutMessage字段的返回值,并将其添加到模式中的Mutation类型下。让我们将包含模式的变量命名为typeDefs,并在server.js中将它定义为一个字符串:

...
const typeDefs = `
  type Query {
    about: String!
  }
  type Mutation {
    setAboutMessage(message: String!): String
  }
`;
...

注意,我不再调用这些 API,而是调用类似于setAboutMessage字段的东西。这是因为所有的 GraphQL 都只有字段,访问字段会有副作用,比如设置一些值。

下一步是拥有在访问这些字段时可以调用的处理程序或函数。这样的函数被称为解析器,因为它们将查询解析为具有实值的字段。虽然模式定义是用特殊的模式语言完成的,但是解析器的实现依赖于我们使用的编程语言。例如,如果您要用 Python 定义 About API 集,那么模式字符串将与 JavaScript 中的相同。但是处理程序看起来与我们用 JavaScript 编写的完全不同。

在 Apollo 服务器和graphql-tools中,解析器被指定为遵循模式结构的嵌套对象。在每个叶级别,需要使用与字段同名的函数来解析字段。因此,在最顶层,我们将在解析器中有两个名为QueryMutation的属性。让我们开始定义它:

...
const resolvers = {
  Query: {
  },
  Mutation: {
  },
};
...

Query对象中,我们需要一个用于about的属性,这是一个返回 About 消息的函数。让我们首先将消息定义为文件顶部的一个变量。因为我们将在setAboutMessage字段中改变消息的值,所以我们需要使用let关键字而不是const

...
let aboutMessage = "Issue Tracker API v1.0";
...

现在,函数需要做的就是返回这个变量。一个简单的不带参数的箭头函数应该可以达到这个目的:

...
  Query: {
    about: () => aboutMessage,
  },
...

因为我们需要接收输入参数,所以setAboutMessage函数没有这么简单。所有解析器函数都有四个参数,如下所示:

fieldName(obj, args, context, info)

参数描述如下:

  • obj:包含父字段解析器返回结果的对象。该参数启用了 GraphQL 查询的嵌套性质。

  • args:一个对象,其参数被传递到查询中的字段中。例如,如果用setAboutMessage(message: "New Message")调用字段,args对象将是:{ "message": "New Message" }

  • context:这是一个由特定查询中的所有解析器共享的对象,用于包含每个请求的状态,包括身份验证信息、数据加载器实例以及解析查询时应该考虑的任何其他内容。

  • info:这个参数应该只在高级情况下使用,但是它包含了关于查询执行状态的信息。

返回值应该是架构中指定的类型。在字段setAboutMessage的情况下,由于返回值是可选的,所以它可以选择不返回任何内容。但是,返回某个值来指示该字段的成功执行是一个很好的实践,所以让我们只返回message输入值。在这种情况下,我们也不会使用父对象(Query)的任何属性,所以我们可以忽略第一个参数obj,只使用args中的属性。因此,setAboutMessage的函数定义如下:

...
function setAboutMessage(_, { message }) {
  return aboutMessage = message;
}
...

注意

我们使用 ES2015 析构赋值特性来访问第二个参数args中的message属性。这相当于将参数命名为args,并将属性访问为args.message,而不是简单的message

现在,我们可以将该函数指定为顶级字段MutationsetAboutMessage的解析器,如下所示:

...
  Mutation: {
    setAboutMessage,
  },
...

注意

我们使用 ES2015 对象属性简写来指定setAboutMessage属性的值。当属性名和赋给它的变量名相同时,变量名可以跳过。因此,{ setAboutMessage: setAboutMessage }可以简单地写成{ setAboutMessage }

既然我们已经定义了模式以及相应的解析器,我们就可以初始化 GraphQL 服务器了。方法是构造一个在apollo-server-express包中定义的ApolloServer对象。构造函数接受一个至少有两个属性的对象——typeDefsresolvers——并返回一个 GraphQL 服务器对象。下面是实现这一点的代码:

...
const { ApolloServer } = require('apollo-server-express');
...
const server = new ApolloServer({
  typeDefs,
  resolvers,
});
...

最后,我们需要在 Express 中安装 Apollo 服务器作为中间件。我们需要一个路径(单个端点)来安装中间件。但是,阿波罗服务器不是一个单一的中间件;事实上,有一组中间件功能以不同的方式处理不同的 HTTP 方法。ApolloServer对象为我们提供了一个方便的方法来完成所有这些工作,这个方法叫做applyMiddleware。它接受一个配置对象作为它配置服务器的参数,其中两个重要的属性是apppath。因此,要在 Express 应用中安装中间件,让我们添加以下代码:

...
server.applyMiddleware({ app, path: '/graphql' });
...

将所有这些放在一起,我们应该有一个工作的 API 服务器。清单 5-1 显示了server.js的新内容,其中包含了所有的代码片段。

const express = require('express');
const { ApolloServer } = require('apollo-server-express');

let aboutMessage = "Issue Tracker API v1.0";

const typeDefs = `
  type Query {
    about: String!
  }
  type Mutation {
    setAboutMessage(message: String!): String
  }
`;

const resolvers = {
  Query: {
    about: () => aboutMessage,
  },
  Mutation: {
    setAboutMessage,
  },
};

function setAboutMessage(_, { message }) {
  return aboutMessage = message;
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

const app = express();

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

server.applyMiddleware({ app, path: '/graphql' });

app.listen(3000, function () {
  console.log('App started on port 3000');
});

Listing 5-1server.js: Implementing the About API Set

注意

虽然我们不遗余力地确保所有代码清单的准确性,但可能会有打字错误、格式错误(如引号类型),甚至是在出版前没有出现在书中的更正。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

正如我前面指出的,GraphQL 模式和自省允许开发人员开发能够探索 API 的工具。默认情况下,名为 Playground 的工具是 Apollo 服务器的一部分,只需浏览 API 端点即可访问。因此,如果你在浏览器的地址栏中输入http://localhost:3000/graphql,你将会找到游乐场的用户界面。

游乐场的默认主题是黑色。使用设置功能(右上角的齿轮图标),我把它改成了浅色主题,同时把字体大小减小到了 12。如果您也进行这些更改,您可能会看到如图 5-1 所示的用户界面。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig1_HTML.jpg

图 5-1

图 QL 操场

在我们测试 API 之前,最好使用 UI 右侧的绿色 schema 按钮来探索模式。这样,您会发现模式中描述了aboutsetAboutMessage字段。要进行查询,您可以在左侧窗口中键入查询,并在单击 Play 按钮后在右侧看到结果,如 UI 中所述。

必须使用查询语言来编写查询。该语言类似于 JSON,但不是 JSON。查询需要遵循模式的相同层次结构,并且与之相似。但是我们不指定字段的类型,只指定它们的名称。对于输入字段,我们指定名称和值,用冒号(:)分隔。因此,要访问about字段,必须使用顶级的query,它只包含我们需要检索的字段,即about。以下是完整的查询:

query {
  about
}

请注意,Playground 中有一个自动完成功能,在您键入时可能会派上用场。操场还使用红色下划线显示查询中的错误。这些特性使用模式来了解可用的字段、参数及其类型。Playground 从服务器查询模式,因此每当模式改变时,如果您依赖自动完成,您需要刷新浏览器以便从服务器检索改变的模式。

因为默认情况下所有的查询都是类型Query(与Mutation相反),我们可以跳过关键字query,只输入{ about }。但是为了清楚起见,让我们始终包含query关键字。单击播放按钮,您会在右侧的结果窗口中看到以下输出:

{
  "data": {
    "about": "Issue Tracker API v1.0"
  }
}

与遵循查询语言语法的查询不同,输出是一个常规的 JSON 对象。它还反映了查询的结构,以“data”作为结果中的根对象。

现在为了测试setAboutMessage字段,您可以用一个突变来替换查询,或者更好的方法是,您可以在 UI 中使用+符号打开一个新的选项卡,然后像这样输入突变查询:

mutation {
  setAboutMessage(message: "Hello World!")
}

运行此查询应该会返回与结果相同的消息,如下所示:

{
  "data": {
    "setAboutMessage": "Hello World!"
  }
}

现在,运行最初的about查询(在第一个选项卡中)应该会返回新消息,"Hello World!"以证明新消息已经在服务器中成功设置。为了确保操场没有变魔术,让我们在命令行中使用 cURL 对about字段进行查询。

一个快速的方法是使用操场上的 COPY CURL 按钮复制命令,并将其粘贴到命令 shell 中。(在 Windows 系统上,shell 不接受单引号,因此您必须手动编辑单引号并将其更改为双引号,然后使用反斜杠对查询中的双引号进行转义。)该命令及其输出如下所示:

$ curl 'http://localhost:3000/graphql' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: http://localhost:3000' --data-binary '{"query":"query {\n  about\n}\n"}' –compressed
{"data":{"about":"Hello World!"}}

注意,cURL 查询是作为 JSON 发送的,实际的查询编码为属性query的字符串值。通过在浏览器中检查开发人员控制台的 Network 选项卡,您可以看到在使用 Playground 时会发生类似的事情。JSON 至少包含一个名为query的属性(如curl命令所示),以及可选的operationNamevariables属性。JSON 对象看起来像这样:

{
  "operationName":null,
  "variables":{},
  "query": "{\n  about\n}\n"
}

此外,如果您查看标题(或者理解curl命令),您还会发现对于setAboutMessage变异和about查询,使用的 HTTP 方法是相同的:POST。使用 POST 方法从服务器获取值可能会让人感到有些不安,所以如果您更喜欢 GET,可以使用它。GET URL 的查询字符串可以包含如下查询:

$ curl 'http://localhost:3000/graphql?query=query+\{+about+\}'

注意,这不是一个 JSON 对象,就像 POST 操作一样。该查询作为一个普通的 URL 编码字符串发送。我们必须避开花括号,因为它们对 cURL 有特殊的意义,所以在浏览器的常规 Ajax 调用中,您不需要这样做。如果您执行此命令,您应该会看到与之前的 POST 命令相同的结果:

$ curl 'http://localhost:3000/graphql?query=query+\{+about+\}'
{"data":{"about":"Hello World!"}}

练习:关于 API

  1. 对 cURL 在浏览器和命令行中使用相同的 URL。例如,键入curl http://localhost:3000/graphql,这与我们在浏览器中调用操场时使用的 URL 相同。或者,复制粘贴我们用于对about字段进行 GET 请求的curl命令。你看到了什么?你能解释一下区别吗?提示:比较请求头。

  2. 对于只读 API 调用,使用 GET 与 POST 的优缺点是什么?

本章末尾有答案。

GraphQL 架构文件

在上一节中,我们在 JavaScript 文件中指定了 GraphQL 模式。如果模式变得更大,将模式分离成自己的文件会很有用。这将有助于保持 JavaScript 源文件更小,ide 可能能够格式化这些文件并启用语法着色。

因此,让我们将模式定义移动到它自己的文件中,而不是源文件中的字符串。移动内容本身很简单;让我们创建一个名为schema.graphql的文件,并将字符串typeDefs的内容移入其中。新文件schema.graphql显示在清单 5-2 中。

type Query {
  about: String!
}

type Mutation {
  setAboutMessage(message: String!): String
}

Listing 5-2schema.graphql: New File for GraphQL Schema

现在,要使用这个变量代替字符串变量,这个文件的内容必须读入一个字符串。让我们使用fs模块和readFileSync函数来读取文件。然后,在创建阿波罗服务器时,我们可以使用readFileSync返回的字符串作为属性typeDefs的值。server.js文件中的变化如清单 5-3 所示。

const fs = require('fs');

const express = require('express');
...

const typeDefs = `

  type Query {
    about: String!
  }
  type Mutation {
    setAboutMessage(message: String!): String!
  }

`;

...

const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
  resolvers,
});
...

Listing 5-3server.js: Changes for Using the GraphQL Schema File

还有一件事需要更改:默认情况下,在检测到文件更改时重启服务器的nodemon工具只查找扩展名为.js的文件的更改。为了让它监视其他扩展的变化,我们需要添加一个-e选项,指定它需要监视的所有扩展。因为我们添加了一个扩展名为.graphql的文件,所以让我们将jsgraphql指定为该选项的两个扩展名。

清单 5-4 中显示了对package.json的更改。

...
  "scripts": {
    "start": "nodemon -w server -e js,graphql server/server.js",
    "compile": "babel src --out-dir public",
...

Listing 5-4package.json: Changes to nodemon to Watch GraphQL Files

如果您现在使用npm start重启服务器,您将能够使用 Playground 测试 API,并确保它们像以前一样运行。

列表 API

现在您已经学习了 GraphQL 的基础知识,让我们利用这些知识在构建问题跟踪器应用方面取得一些进展。我们要做的下一件事是实现一个 API 来获取问题列表。我们将使用 Playground 测试它,在下一节中,我们将更改前端以集成这个新的 API。

让我们从修改模式开始,定义一个名为Issue的自定义类型。它应该包含我们到目前为止一直在使用的 issue 对象的所有字段。但是由于 GraphQL 中没有标量类型来表示日期,所以我们暂时使用 string 类型。我们将在本章后面实现自定义标量类型。因此,该类型将有整数和字符串,其中一些是可选的。下面是新类型的部分模式代码:

...
type Issue {
  id: Int!
  ...
  due: String
}
...

现在,让我们在Query下添加一个新字段来返回问题列表。指定另一种类型的列表的 GraphQL 方法是用方括号将它括起来。我们可以使用[Issue]作为字段的类型,我们称之为issueList。但是我们需要说的是,不仅返回值是强制的,列表中的每个元素也不能为空。因此,我们必须在Issue和数组类型后面加上感叹号,就像在[Issue!]!中一样。

让我们使用注释将顶级的QueryMutation定义与定制类型分开。在模式中添加注释的方法是在行首使用#字符。所有这些变化都列在清单 5-5 中。

type Issue {

  id: Int!
  title: String!
  status: String!
  owner: String
  effort: Int
  created: String!
  due: String

}

##### Top level declarations

type Query {
  about: String!
  issueList: [Issue!]!
}

type Mutation {
  setAboutMessage(message: String!): String
}

Listing 5-5schema.graphql: Changes to Include Field issueList and New Issue Type

在服务器代码中,我们需要在新字段的Query下添加一个解析器,它指向一个函数。我们还会有一系列问题(我们在前端代码中的问题的副本),这些问题是数据库的替身。我们可以立即从解析器返回这个数组。该函数可以像对about字段那样就地使用,但是知道我们将扩展该函数来做不仅仅是返回一个硬编码的数组,让我们为它创建一个名为issueList的单独函数。

清单 5-6 显示了server.js中的这组变化。

...
let aboutMessage = "Issue Tracker API v1.0";

const issuesDB = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2019-01-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2019-01-16'), due: new Date('2019-02-01'),
    title: 'Missing bottom border on panel',
  },

];

const resolvers = {
  Query: {
    about: () => aboutMessage,
    issueList,
  },
  Mutation: {
    setAboutMessage,
  },
};

function setAboutMessage(_, { message }) {
  return aboutMessage = message;
}

function issueList() {

  return issuesDB;

}

...

Listing 5-6server.js: Changes for issueList Query Field

为了在操场上测试这一点,您需要运行一个查询来指定带有子字段的issueList字段。但是首先,需要刷新浏览器,以便 Playground 拥有最新的模式,并且在您键入查询时不会显示错误。

数组本身不需要在查询中展开。这是隐式的(由于模式规范),issueList返回一个数组,因此字段的子字段在数组中自动展开。

下面是一个这样的查询,您可以运行它来测试issueList字段:

query {
  issueList {
    id
    title
    created
  }
}

该查询将产生如下输出:

{
  "data": {
    "issueList": [
      {
        "id": 1,
        "title": "Error in console when clicking Add",
        "created": "Tue Jan 15 2019 05:30:00 GMT+0530 (India Standard Time)"
      },
      {
        "id": 2,
        "title": "Missing bottom border on panel",
        "created": "Wed Jan 16 2019 05:30:00 GMT+0530 (India Standard Time)"
      }
    ]
  }
}

如果在查询中添加更多的子字段,也将返回它们的值。如果您查看日期字段,您会看到它们已经使用Date JavaScript 对象的toString()方法从Date对象转换为字符串。

练习:列表 API

  1. 试着不为issueList字段指定子字段,比如query { issueList },就像我们为about字段所做的那样,然后单击 Play 按钮。你观察到的结果是什么?尝试使用查询{ issueList { } }指定一个空字段列表,并播放请求。你现在看到了什么?你能解释一下区别吗?

  2. issueList下的查询中添加一个无效的子字段(比如,test)。当您单击播放按钮时,会出现什么错误?特别是,Playground 会将请求发送到服务器吗?在开发人员控制台打开的情况下,在操场上尝试一下。

  3. 一个包括问题列表和about字段的聚合查询会是什么样子?

本章末尾有答案。

列表 API 集成

现在我们已经有了列表 API,让我们把它集成到 UI 中。在这一节中,我们将把 I ssueList React 组件中的loadData()方法的实现替换为从服务器获取数据的东西。

为了使用 API,我们需要进行异步 API 调用,或者 Ajax 调用。流行的库 jQuery 是使用$.ajax()函数的一种简单方法,但是仅仅为了这个目的而包含整个 jQuery 库似乎有些矫枉过正。幸运的是,有许多库提供这种功能。更好的是,现代浏览器通过 Fetch API 本地支持 Ajax 调用。对于 Internet Explorer 等较老的浏览器,可以从whatwg-fetch获得 Fetch API 的 polyfill。让我们直接从 CDN 中使用这个 polyfill,并将它包含在index.html中。为此,我们将使用之前使用的相同 CDN,unpkg.com。这些变化如清单 5-7 所示

...
  <script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
  <script src="https://unpkg.com/whatwg-fetch@3.0.0/dist/fetch.umd.js"></script>
  <style>
...

Listing 5-7index.html: Changes for Including whatwg-fetch Polyfill

注意

只有 Internet Explorer 和其他浏览器的旧版本才需要 polyfill。所有最新版本的流行浏览器——如 Chrome、Firefox、Safari、Edge 和 Opera——都原生支持fetch()

接下来,在loadData()方法中,我们需要构造一个 GraphQL 查询。这是一个简单的字符串,类似于我们在操场上用来测试issueList GraphQL 字段的字符串。但是我们必须确保我们查询的是问题的所有子字段,因此下面的查询可以获取所有问题和所有子字段:

...
    const query = `query {
      issueList {
        id title status owner
        created effort due
      }
    }`;
...

我们将这个查询字符串作为 JSON 中的query属性值发送,作为fetch请求主体的一部分。我们将使用的方法是 POST,我们将添加一个头,表明内容类型是 JSON。下面是完整的fetch请求:

...
    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
...

注意

我们使用了await关键字来处理异步调用。这是 ES2017 规范的一部分,受除 Internet Explorer 之外的所有浏览器的最新版本支持。它是由旧浏览器的 Babel transforms 自动处理的。另外,await只能在标有async的函数中使用。我们将不得不在loadData()函数中添加async关键字。如果不熟悉async/await构造,可以在 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function 了解一下。

一旦响应到达,我们就可以通过使用response.json()方法将 JSON 数据转换成 JavaScript 对象。最后,我们需要调用一个setState()来为名为issues的状态变量提供问题列表,如下所示:

...
    const result = await response.json();
    this.setState({ issues: result.data.issueList });
...

我们还需要为loadData()的函数定义添加关键字async,因为我们已经在这个函数中使用了await s。

此时,您将能够在浏览器中刷新问题跟踪器应用,但会看到一个错误。这是因为我们使用了一个字符串而不是Date对象,并且在IssueRow组件的render()方法中使用toDateString()将日期转换为字符串的调用抛出了一个错误。让我们删除转换,按原样使用字符串:

...
      <td>{issue.created}</td>
      ...
      <td>{issue.due}</td>
...

我们现在也可以删除全局变量initialIssues,因为我们不再需要它在loadData()中。清单 5-8 显示了App.jsx中的一整套变更。

const initialIssues = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2018-08-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2018-08-16'), due: new Date('2018-08-30'),
    title: 'Missing bottom border on panel',
  },

];

function IssueRow(props) {
  const issue = props.issue;
  return (
    <tr>
      ...
      <td>{issue.created.toDateString()}</td>
      <td>{issue.effort}</td>
      <td>{issue.due ? issue.due.toDateString() : ''}</td>
      ...
   );
}
...

  async loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
    const query = `query {
      issueList {
        id title status owner
        created effort due
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
    const result = await response.json();
    this.setState({ issues: result.data.issueList });
  }

Listing 5-8App.jsx: Changes for Integrating the List API

这就完成了集成 List API 所需的更改。现在,如果您通过刷新浏览器来测试应用,您会发现一个类似于图 5-2 所示的屏幕截图。你会注意到日期又长又难看,但除此之外,屏幕看起来和前一章结束时一样。添加操作将不起作用,因为它在添加新问题时使用Date对象而不是字符串。我们将在下一节讨论这两个问题。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig2_HTML.jpg

图 5-2

列表 API 集成后

自定义标量类型

将日期存储为字符串在大多数情况下似乎可行,但并非总是如此。首先,对日期进行排序和过滤变得更加困难,因为每次都必须将字符串转换成Date类型。此外,无论服务器在哪里,日期都应该以用户的时区和地区显示。不同的用户可能基于他们在哪里而不同地看到相同的日期,甚至看到“2 天前”等形式的日期。

为了实现这一切,我们需要将日期存储为 JavaScript 的原生Date对象。理想情况下,应该在仅向用户显示时将其转换为特定于地区的字符串。但不幸的是,JSON 没有Date类型,因此,在 API 调用中使用 JSON 传输数据也必须将日期与字符串相互转换。

在 JSON 中传输Date对象的推荐字符串格式是 ISO 8601 格式。它简明扼要,广为接受。这也是 JavaScript 的DatetoJSON()方法使用的相同格式。在这种格式中,诸如 2019 年 1 月 26 日下午 2:30 UTC 这样的日期将被写成2019-01-26T14:30:00.000Z。使用DatetoJSON()toISOString()方法将日期转换成这个字符串,以及使用new Date(dateString)将它转换回日期,都是简单明了的。

尽管 GraphQL 本身不支持日期,但它支持自定义标量类型,这可用于创建自定义标量类型日期。为了能够使用自定义标量类型,必须完成以下工作:

  1. 在模式中使用scalar关键字而不是type关键字定义标量的类型。

  2. 为所有标量类型添加一个顶级解析器,它通过类方法处理序列化(在输出时)和解析(在输入时)。

之后,新类型可以像任何本地标量类型一样使用,比如StringInt。让我们称这个新的标量类型为GraphQLDate。标量类型必须在模式中使用关键字scalar定义,后跟自定义类型的名称。让我们把它放在文件的开头:

...
scalar GraphQLDate
...

现在,我们可以用created替换String类型关联,用GraphQLDate替换due字段。清单 5-9 显示了标量定义的变化和日期字段的新数据类型。

scalar GraphQLDate

type Issue {
  id: Int!
  ...
  created: StringGraphQLDate!
  due: StringGraphQLDate
}
...

Listing 5-9schema.graphql: Changes in Schema for Scalar Date

标量类型解析器需要是包graphql-tools中定义的类GraphQLScalarType的对象。我们先在server.js导入这个类:

...
const { GraphQLScalarType } = require('graphql');
...

GraphQLScalarType的构造函数接受一个具有各种属性的对象。我们可以通过调用类型上的new()来创建这个解析器,如下所示:

...
const GraphQLDate = new GraphQLScalarType({ ... });
...

初始化器的两个属性——namedescription——在自省中使用,所以让我们将它们设置为适当的值:

...
  name: 'GraphQLDate',
  description: 'A Date() type in GraphQL as a scalar',
...

将调用类方法serialize()将日期值转换为字符串。此方法将值作为参数,并期望返回一个字符串。因此,我们所要做的就是对值调用toISOString()并返回它。下面是serialize()的方法:

...
  serialize(value) {
    return value.toISOString();
  },
...

需要另外两个方法parseValue()parseLiteral()来将字符串解析回日期。让我们把这种解析留到稍后阶段,当它确实需要接受输入值时,因为这些是可选的方法。

最后,我们需要将这个解析器设置为与QueryMutation(在顶层)相同的级别,作为标量类型GraphQLDate的值。清单 5-10 显示了server.js中的整套变化。

...
const { ApolloServer } = require('apollo-server-express');

const { GraphQLScalarType } = require('graphql');

...

const GraphQLDate = new GraphQLScalarType({

  name: 'GraphQLDate',
  description: 'A Date() type in GraphQL as a scalar',
  serialize(value) {
    return value.toISOString();
  },

});

const resolvers = {
  Query: {
    ...
  },
  Mutation: {
    ...
  },
  GraphQLDate,
};
...

Listing 5-10server.js: Changes for Adding a Resolver for GraphQLDate

此时,如果您切换到操场并刷新浏览器(由于模式更改),然后测试 List API。您将看到日期作为 ISO 字符串的等价物返回,而不是以前使用的特定于地区的长字符串。这里有一个在操场上测试的查询:

query {
  issueList {
    title
    created
    due
  }
}

以下是该查询的结果:

{
  "data": {
    "issueList": [
      {
        "title": "Error in console when clicking Add",
        "created": "2019-01-15T00:00:00.000Z",
        "due": null
      },
      {
        "title": "Missing bottom border on panel",
        "created": "2019-01-16T00:00:00.000Z",
        "due": "2019-02-01T00:00:00.000Z"
      }
    ]
  }
}

现在,在App.jsx中,我们可以将字符串转换为原生的Date类型。实现这一点的一种方法是在从服务器获取问题后遍历这些问题,并用它们的日期等价物替换字段duecreated。更好的方法是将一个 reviver 函数传递给 JSON parse()函数。reviver 函数是一个被调用来解析所有值的函数,JSON 解析器给它一个机会来修改默认解析器要做的事情。

因此,让我们创建这样一个函数,它在输入中寻找类似日期的模式,并将所有这样的值转换为日期。我们将使用一个正则表达式来检测这种模式,并使用new Date()进行简单的转换。下面是 reviver 的代码:

...
const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');

function jsonDateReviver(key, value) {
  if (dateRegex.test(value)) return new Date(value);
  return value;
}
...

转换函数response.json()不能让我们指定一个 reviver,所以我们必须使用response.text()获取正文的文本,并通过传入 reviver 使用JSON.parse()自己解析它,就像这样:

...
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);
...

现在,我们可以恢复我们的更改,将日期显示为之前的状态:使用toDateString()IssueRow中呈现日期。包括这一变化,在App.jsx中使用Date标量类型的一整套变化如清单 5-11 所示。

const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');

function jsonDateReviver(key, value) {

  if (dateRegex.test(value)) return new Date(value);
  return value;

}

function IssueRow(props) {
  const issue = props.issue;
  return (
    <tr>
      ...
      <td>{issue.created.toDateString()}</td>
      <td>{issue.effort}</td>
      <td>{issue.due ? issue.due.toDateString() : ' '}</td>
...
   );
}
...
class IssueList extends React.Component {
  async loadData() {
    ...
    const result = await response.json();
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);
    this.setState({ issues: result.data.issueList });
  }
  ...
}
...

Listing 5-11App.jsx: Changes for Receiving ISO Formatted Dates

经过这一系列的修改,应用应该像以前一样出现在上一章的末尾。日期的格式看起来会很好。即使添加一个问题也应该可以,但是在刷新浏览器时,添加的问题将会消失。这是因为我们没有将问题保存在服务器中,我们所做的只是在浏览器中更改了问题列表的本地状态,这将在刷新时重置为初始问题集。

练习:自定义标量类型

  1. server.js中,删除将类型GraphQLDate关联到解析器对象的解析器。发出调用issueList的 API 请求。输出有区别吗?你认为如何解释这种差异或缺乏差异?

  2. 你如何确定标量类型解析器确实被使用了?

本章末尾有答案。

创建 API

在本节中,我们将实现一个 API,用于在服务器中创建一个新问题,该问题将被附加到服务器内存中的问题列表中。

为此,我们必须首先在模式中的Mutation下定义一个名为issueAdd的字段。这个字段应该接受参数,就像setAboutMessage字段一样。但是这一次,我们需要多个参数,每个参数对应于要添加的问题的一个属性。或者,我们可以将一个新类型定义为一个对象,该对象具有我们输入所需的字段。这不能与Issue类型相同,因为它有一些必填字段(idcreated)不是输入的一部分。这些值仅由服务器设置。此外,GraphQL 在输入类型方面需要不同的规范。我们必须使用input关键字,而不是使用type关键字。

让我们首先在模式中定义这个名为IssueInputs的新输入类型:

...
input IssueInputs {
  # ... fields of Issue
}
...

我们讨论了如何在模式中添加注释。但是这些注释不是类型或子字段的正式描述。对于显示在 schema explorer 中的真实文档,需要在字段上方添加一个字符串。当向开发人员展示模式时,这些描述将作为有用的提示出现。因此,让我们为IssueInputs以及属性status添加一个描述,假设如果不提供,它将默认为值'New':

...
"Toned down Issue, used as inputs, without server generated values."
input IssueInputs {
  ...
  "Optional, if not supplied, will be set to 'New'"
  status: String
  ...
}
...

现在,我们可以使用类型IssueInputs作为Mutation下新的issueAdd字段的参数类型。该字段的返回值可以是任何值。返回在服务器上生成的值,通常是新对象的 ID,这是一种很好的做法。在这种情况下,因为 ID 和创建日期都是在服务器上设置的,所以让我们返回创建的整个问题对象。

清单 5-12 显示了对模式的一整套更改。

...

"Toned down Issue, used as inputs, without server generated values."

input IssueInputs {

  title: String!
  "Optional, if not supplied, will be set to 'New'"
  status: String
  owner: String
  effort: Int
  due: GraphQLDate

}

##### Top level declarations
...
type Mutation {
  setAboutMessage(message: String!): String
  issueAdd(issue: IssueInputs!): Issue!
}

Listing 5-12schema.graphql: Changes for New Type IssueInputs and New Field issueAdd

接下来,我们需要一个用于issueAdd的解析器,它接受一个IssueInput类型并在内存数据库中创建一个新问题。就像我们对setAboutMessage所做的一样,我们可以忽略第一个参数,使用一个析构赋值来访问问题对象,即输入:

...
function issueAdd(_, { issue }) {
  ...
}

在函数中,让我们像在浏览器中一样设置 ID 和创建日期:

...
  issue.created = new Date();
  issue.id = issuesDB.length + 1;
...

此外,如果没有提供状态(因为我们没有将其声明为必需的子字段),我们也将状态默认为值'New':

...
  if (issue.status == undefined) issue.status = 'New';
...

最后,我们可以将问题附加到全局变量issuesDB中,并按原样返回问题对象:

...
  issuesDB.push(issue);
  return issue;
...

该函数现在可以设置为MutationissueAdd字段的解析器:

...
  Mutation: {
    setAboutMessage,
    issueAdd,
  },
...

我们推迟了实现自定义标量类型GraphQLDate的解析器,因为那时我们不需要它。但是现在,因为类型IssueInputs有一个GraphQLDate类型,我们必须实现解析器来接收日期值。在GraphQLDate解析器中需要实现两种方法:parseValueparseLiteral

方法parseLiteral在正常情况下被调用,其中字段在查询中被就地指定。解析器用一个参数ast调用这个方法,这个参数包含一个kind属性和一个value属性。kind属性表示解析器找到的令牌的类型,可以是浮点、整数或字符串。对于GraphQLDate,我们唯一需要支持的令牌类型是字符串。我们可以使用graphql/language中的Kind包中定义的常量来检查这一点。如果令牌的类型是 string,我们将解析该值并返回一个日期。否则,我们就返回undefined。下面是parseLiteral的实现:

...
  parseLiteral(ast) {
    return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
  },
...

返回值undefined向 GraphQL 库表明该类型不能被转换,它将被视为一个错误。

如果输入作为变量提供,将调用方法parseValue。我将在本章后面的部分讨论查询输入中的变量,但是现在,把它看作 JavaScript 对象形式的输入,一个预先解析的 JSON 值。该方法的参数将直接是值,没有类型规范,所以我们需要做的就是用它构造一个日期,并像这样返回它:

...
  parseValue(value) {
    return new Date(value);
  },
...

清单 5-13 中显示了对server.js的一整套更改。

...
const { GraphQLScalarType } = require('graphql');

const { Kind } = require('graphql/language');

...

const GraphQLDate = new GraphQLScalarType({
  ...
  parseValue(value) {
    return new Date(value);
  },
  parseLiteral(ast) {
    return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
  },
});
...

const resolvers = {
  ...
  Mutation: {
    setAboutMessage,
    issueAdd,
  },
  GraphQLDate,
};
...

function issueAdd(_, { issue }) {

  issue.created = new Date();
  issue.id = issuesDB.length + 1;
  if (issue.status == undefined) issue.status = 'New';
  issuesDB.push(issue);
  return issue;

}

...

Listing 5-13server.js: Changes for the Create API

现在,我们准备使用操场测试 Create API。如果您在操场上浏览模式(可能需要刷新浏览器)并深入到IssueInputsstatus字段,您会发现我们在模式中提供的描述。其截图如图 5-3 所示。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig3_HTML.jpg

图 5-3

显示问题输入和状态描述的模式

要测试新问题的添加,您可以在 Playground 中使用以下查询:

mutation {
  issueAdd(issue:{
    title: "Completion date should be optional",
    owner: "Pieta",
    due: "2018-12-13",
  }) {
    id
    due
    created
    status
  }
}

运行这个查询应该会在操场的结果窗口中给出以下结果:

{
  "data": {
    "issueAdd": {
      "id": 4,
      "due": "2018-12-13T00:00:00.000Z",
      "created": "2018-10-03T14:48:10.551Z",
      "status": "New"
    }
  }
}

这表明已经正确解析和转换了到期日期。状态字段也被默认为'New'。您还可以通过在操场上运行对issueList的查询并检查结果来确认问题已经被创建。

练习:创建 API

  1. 我们使用了一个input复杂类型来为issueAdd提供值。与单独通过每个字段相比,像issueAdd(title: String!, owner: String ...)。每种方法的优缺点是什么?

  2. 尝试为该字段传递一个有效的整数,如due: 2018,而不是有效的日期字符串。你认为在parseLiteralast.kind的值会是多少?在parseLiteral中添加console.log信息并确认。你认为ast.kind还有哪些值是可能的?

  3. 传递一个字符串,但传递一个无效的日期,比如为due字段传递"abcdef"。会发生什么?如何解决这个问题?

  4. 有没有另一种方法来指定status字段的默认值?提示:在 http://graphql.github.io/learn/schema/#arguments 阅读 GraphQL 模式文档中的传递参数。

本章末尾有答案。

创建 API 集成

让我们开始集成 Create API,在 UI 中对新问题的默认设置做一点小小的改变。让我们取消将状态设置为'New'的操作,并将截止日期设置为当前日期的 10 天后。这种改变可以在App.jsx中的IssueAdd组件的handleSubmit()方法中完成,就像这样:

...
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
      due: new Date(new Date().getTime() + 1000*60*60*24*10),
    }
...

在进行 API 调用之前,我们需要一个填充了字段值的查询。让我们使用一个模板字符串在IssueListcreateIssue()方法中生成这样一个查询。我们可以使用传入的问题对象的标题和所有者属性,但是对于日期字段due,我们必须按照 ISO 格式将其显式转换为字符串,因为这是我们决定用于传递日期的格式。

在返回路径上,我们将不需要任何新问题的值,但是因为子字段不能为空,所以让我们只指定id字段。因此,让我们按如下方式形成查询字符串:

...
    const query = `mutation {
      issueAdd(issue:{
        title: "${issue.title}",
        owner: "${issue.owner}",
        due: "${issue.due.toISOString()}",
      }) {
        id
      }
    }`;
...

现在,让我们使用这个查询来异步执行fetch,就像我们对问题列表调用所做的那样:

...
    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
...

我们可以使用返回的完整问题对象,并像以前一样将其添加到状态数组中,但是更简单的方法是(尽管性能较差)在将新问题发送到服务器后调用loadData()来刷新问题列表。它也更准确,以防出现错误而无法添加问题,或者其他用户同时添加了更多问题。

...
    this.loadData();
...

清单 5-14 显示了集成 Create API 的一整套更改。

...
class IssueAdd extends React.Component {
  ...
  handleSubmit(e) {
    ...
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
      due: new Date(new Date().getTime() + 1000*60*60*24*10),
    }
    ...
  }
  ...
}
...
  async createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
    const newIssueList = this.state.issues.slice();
    newIssueList.push(issue);
    this.setState({ issues: newIssueList });
    const query = `mutation {
      issueAdd(issue:{
        title: "${issue.title}",
        owner: "${issue.owner}",
        due: "${issue.due.toISOString()}",
      }) {
        id
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
    this.loadData();
   }

Listing 5-14App.jsx: Changes for Integrating the Create API

在通过使用 UI 添加新问题来测试这组更改时,您会发现截止日期被设置为从当前日期起 10 天后。此外,如果刷新浏览器,您会发现添加的问题仍然存在,因为新问题现在已保存在服务器上。

练习:创建 API 集成

  1. 添加标题中带有引号的新问题,例如,Unable to create issue with status "New"。会发生什么?检查控制台以及浏览器的开发人员控制台中的请求和响应。你认为如何解决这个问题?

本章末尾有答案。

查询变量

对于这两个变异调用,我们都在查询字符串中指定了字段的参数。当在操场上测试一个 API 时,就像我们对setAboutMessage所做的那样,这非常有效。但是在大多数应用中,参数是动态的,基于用户输入。这正是issueAdd所发生的,我们必须使用字符串模板来构造查询字符串。

这不是一个好主意,首先是因为将模板转换成实际字符串的开销很小。一个更重要的原因是需要对引号和花括号等特殊字符进行转义。这很容易出错,也很容易被忽略。由于我们没有进行任何转义,如果您在此时通过添加一个在标题中有双引号的问题来测试问题跟踪器应用,您会发现它不能正常工作。

GraphQL 有一流的方法从查询中提取动态值,并将其作为单独的字典传递。这些值被称为*变量。*这种传递动态值的方式非常类似于 SQL 查询中的预处理语句。

要使用变量,我们必须先命名操作。这是通过在querymutation字段说明后指定一个名称来实现的。例如,要给一个setAboutMessage突变命名,必须完成以下工作:

mutation setNewMessage { setAboutMessage(message: "New About Message") }

接下来,必须用变量名替换输入值。变量名以$字符开始。让我们调用变量$message,并用这个变量替换字符串“New About Message”。最后,为了接受变量,我们需要将它声明为操作名的参数。因此,新的查询将是:

mutation setNewMessage($message: String!) { setAboutMessage(message: $message) }

现在,为了提供变量值,我们需要在一个 JSON 对象中发送它,这个 JSON 对象是与查询字符串分开的*。在游乐场中,右下角有一个名为查询变量的选项卡。点击这个按钮将会打开请求窗口,并允许您在下半部分输入查询变量。我们需要将变量作为一个 JSON 对象发送,将变量名(不带$)作为属性,变量值作为属性值。*

*操场截图如图 5-4 ,消息值为"Hello World!"

img/426054_2_En_5_Chapter/426054_2_En_5_Fig4_HTML.jpg

图 5-4

带有查询变量的操场

如果您在开发人员控制台中检查请求数据,您会发现请求 JSON 有三个属性— operationNamevariablesquery。虽然到目前为止我们只使用了query,但是为了利用变量,我们不得不同时使用另外两个。

注意

GraphQL 规范允许多个操作出现在同一个查询字符串中。但是在一次调用中只能执行其中的一个。operationName的值指定需要执行那些操作中的哪一个。

我们现在准备在查询中用常规字符串替换模板字符串,使用操作名和变量规范格式。新的查询字符串将如下所示:

...
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;
...

然后,在构造fetch()请求的主体时,除了query属性之外,我们还要指定variables属性,它将包含一个变量:issue。清单 5-15 显示了App.jsx中的一整套变化,包括这一点。

...
  async createIssue(issue) {
    const query = `mutation {
      issueAdd(issue:{
        title: "${issue.title}",
        owner: "${issue.owner}",
        due: "${issue.due.toISOString()}",
      }) {
        id
      }
    }`;
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query, variables: { issue } })
    });
    this.loadData();
  }
...

Listing 5-15App.jsx: Changes for Using Query Variables

在 Issue Tracker 应用中测试这些变化时,您会发现添加新问题的工作方式和以前一样。此外,您应该能够在新增加的问题的标题中使用双引号,而不会导致任何错误。

练习:查询变量

  1. 在自定义标量类型GraphQLDate中,现在我们在使用变量,你觉得会调用哪一种解析方法?会是parseLiteral还是parseValue?在这两个函数中添加一个临时的console.log语句,确认你的答案。

本章末尾有答案。

输入验证

到目前为止,我们已经忽略了验证。但是所有的应用都需要一些典型的验证,不仅是为了防止来自 UI 的无效输入,也是为了防止来自直接 API 调用的无效输入。在本节中,我们将添加一些对大多数应用来说很典型的验证。

一种常见的验证是限制允许值的集合,可以在下拉列表中显示。问题跟踪器应用中的status字段就是这样一个字段。实现这种验证的一种方法是在issueAdd解析器中添加对允许值数组的检查。但是 GraphQL 模式本身通过枚举类型或枚举为我们提供了一种自动的方式。模式中的一个enum定义如下:

...
enum Color {
  Red
  Green
  Blue
}
...

请注意,虽然该定义可以翻译成其他语言中的实际枚举类型,但由于 JavaScript 没有枚举类型,因此在客户端和服务器端都将它们作为字符串处理。让我们为名为StatusType的状态添加这个枚举类型:

...
enum StatusType {
  New
  Assigned
  Fixed
  Closed
}
...

现在,我们可以用Issue类型中的StatusType替换String类型:

...
type Issue {
  ...
  status: StatusType!
  ...
}

同样可以在IssueInput类型中完成。但是 GraphQL 模式的一个显著特性是,它允许我们在输入没有给定参数值的情况下提供默认值。这可以通过在类型说明后添加一个=符号和默认值来实现,比如owner: String = "Self"。在status的情况下,缺省值是一个 enum,所以可以不用引号来指定它,如下所示:

...
  status: StatusType = New
...

现在,我们可以移除server.jsissueAdd解析器内issue.status'New'的默认值。清单 5-16 显示了对schema.graphql文件的所有更改。

scalar GraphQLDate

enum StatusType {
  New
  Assigned
  Fixed
  Closed
}

type Issue {
  ...
  status: StringStatusType!
  ...
}
...
input IssueInputs {
  ...
  status: StringStatusType = New
  owner: String
  effort: Int
  due: GraphQLDate
}
...

Listing 5-16schema.graphql: Changes for Using Enums and Default Values

至于编程验证,我们必须在server.js中保存新问题之前进行。我们将在一个名为validateIssue()的独立函数中实现这一点。让我们首先创建一个数组来保存验证失败的错误消息。当我们发现多个验证失败时,数组中的每个验证失败消息都有一个字符串。

...
function validateIssue(_, { issue }) {
  const errors = [];
...

接下来,让我们为该期的标题添加一个最小长度。如果检查失败,我们将把一条消息推入到errors数组中。

...
  if (issue.title.length < 3) {
    errors.push('Field "title" must be at least 3 characters long.')
  }
...

让我们添加一个有条件的强制验证,当状态设置为Assigned时,检查所有者是否是必需的。UI 在这个阶段无法设置 status 字段,因此为了测试这一点,我们将使用 Playground。

...
  if (issue.status == 'Assigned' && !issue.owner) {
    errors.push('Field "owner" is required when status is "Assigned"');
  }
...

我们可以添加更多的验证,但是对于演示编程验证来说,这已经足够了。在检查结束时,如果我们发现 errors 数组不为空,我们将抛出一个错误。Apollo 服务器建议使用UserInputError类来生成用户错误。让我们用它来构造一个要抛出的错误:

...
  if (errors.length > 0) {
    throw new UserInputError('Invalid input(s)', { errors });
  }
...

现在,让我们再添加一个我们之前没有做的验证:在解析值的过程中捕获无效的日期字符串。当日期字符串无效时,new Date()构造函数不会抛出任何错误。相反,它创建一个 date 对象,但该对象包含一个无效的日期。检测输入错误的一种方法是检查构造的日期对象是否是有效值。在构建日期后,可以使用检查isNaN(date)来完成。让我们在parseValueparseLiteral实施这项检查:

...
  parseValue(value) {
    const dateValue = new Date(value);
    return isNaN(dateValue) ? undefined : dateValue;
  },
  parseLiteral(ast) {
    if (ast.kind == Kind.STRING) {
      const value = new Date(ast.value);
      return isNaN(value) ? undefined : value;
    }
  },
...

注意,返回undefined被库视为错误。如果提供的文字不是字符串,函数将不返回任何内容,这与返回undefined相同。

最后,您会发现,尽管所有的错误都被发送到客户机并显示给用户,但是没有办法在服务器上捕获这些错误以供以后分析。此外,如果能监控服务器的控制台,甚至在开发过程中就能看到这些错误,那就太好了。Apollo 服务器有一个名为formatError的配置选项,可以用来更改将错误发送回调用者的方式。我们也可以使用此选项在控制台上打印出错误:

...
  formatError: error => {
    console.log(error);
    return error;
  }
...

在清单 5-17 中显示了server.js中添加GraphQLDate类型的编程验证和适当验证的所有变化。

...
const { ApolloServer, UserInputError } = require('apollo-server-express');
...

const GraphQLDate = new GraphQLScalarType({
  ...
  parseValue(value) {
    return new Date(value);
    const dateValue = new Date(value);
    return isNaN(dateValue) ? undefined : dateValue;
  },
  parseLiteral(ast) {
    return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
    if (ast.kind == Kind.STRING) {
      const value = new Date(ast.value);
      return isNaN(value) ? undefined : value;
    }
  },
});
...

function validateIssue(_, { issue }) {
  const errors = [];
  if (issue.title.length < 3) {
    errors.push('Field "title" must be at least 3 characters long.')
  }
  if (issue.status == 'Assigned' && !issue.owner) {
    errors.push('Field "owner" is required when status is "Assigned"');
  }
  if (errors.length > 0) {
    throw new UserInputError('Invalid input(s)', { errors });
  }

}

function issueAdd(_, { issue }) {
  issueValidate(issue);
  issue.created = new Date();
  issue.id = issuesDB.length + 1;
  if (issue.status == undefined) issue.status = 'New';
  issuesDB.push(issue);
  return issue;
}
...

const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
  resolvers,
  formatError: error => {
    console.log(error);
    return error;
  },
});

Listing 5-17server.js: Programmatic Validations and Date Validations

使用应用测试这些更改将会很困难,需要临时更改代码,所以您可以使用 Playground 来测试验证。注意,由于status现在是一个枚举,该值应该作为一个文字提供,也就是说,在操场上没有引号。对issueAdd的有效调用如下所示:

mutation {
  issueAdd(issue:{
    title: "Completion date should be optional",
    status: New,
  }) {
    id
    status
  }
}

运行这段代码时,操场结果应该显示添加了以下新问题:

{
  "data": {
    "issueAdd": {
      "id": 5,
      "status": "New"
    }
  }
}

如果您将状态更改为像Unknown这样的无效枚举,您应该会得到如下错误:

{
  "error": {
    "errors": [
      {
        "message": "Expected type StatusType, found Unknown.",
...

如果您使用字符串"New"来代替,它应该会显示如下有用的错误消息:

{
  "error": {
    "errors": [
      {
        "message": "Expected type StatusType, found \"New\"; Did you mean the enum value New?",
...

最后,如果您完全删除状态,您会发现它确实将值默认为New,如结果窗口所示。

为了测试编程验证,您可以尝试创建一个两个检查都失败的问题。以下查询应该有所帮助:

mutation {
  issueAdd(issue:{
    title: "Co",
    status: Assigned,
  }) {
    id
    status
  }
}

运行此查询时,将返回以下错误,其中两条消息都列在 exception 部分下。

{
  "data": null,
  "errors": [
    {
      "message": "Invalid input(s)",
      ...
      "extensions": {
        "code": "BAD_USER_INPUT",
        "exception": {
          "errors": [
            "Field \"title\" must be at least 3 characters long.",
            "Field \"owner\" is required when status is \"Assigned\""
          ],
...

为了测试日期验证,您需要使用文字和查询变量进行测试。对于文字测试,可以使用以下查询:

mutation {
  issueAdd(issue:{
    title: "Completion data should be optional",
    due: "not-a-date"
  }) {
    id
  }
}

将返回以下错误:

{
  "error": {
    "errors": [
      {
        "message": "Expected type GraphQLDate, found \"not-a-date\".",
        ...
        "extensions": {
          "code": "GRAPHQL_VALIDATION_FAILED",

至于基于查询变量的测试,下面是可以使用的查询:

mutation issueAddOperation($issue: IssueInputs!) {
  issueAdd(issue: $issue) {
    id
    status
    due
  }
}

这是查询变量:

{"issue":{"title":"test", "due":"not-a-date"}}

运行此命令时,您应该会在结果窗口中看到以下错误:

{
  "error": {
    "errors": [
      {
        "message": "Variable \"$issue\" got invalid value {\"title\":\"test\",\"due\":\"not-a-date\"}; Expected type GraphQLDate at value.due.",
        ...
        "extensions": {
          "code": "INTERNAL_SERVER_ERROR",

显示错误

在本节中,我们将修改用户界面,以便向用户显示任何错误消息。我们将处理由于网络连接问题以及无效用户输入导致的传输错误。对于用户来说,通常不应该出现服务器错误和其他错误(这些很可能是 bug),如果出现了,让我们只显示收到的代码和消息。

这是创建一个公共实用函数来处理所有 API 调用和报告错误的好机会。我们可以用这个公共函数替换实际处理程序中的fetch调用,并将任何错误作为 API 调用的一部分显示给用户。我们称这个函数为graphQLFetch。这将是一个异步函数,因为我们将使用await调用fetch()。让我们让函数将query和变量作为两个参数:

...
async function graphQLFetch(query, variables = {}) {
...

注意

我们使用 ES2015 默认函数参数将{}分配给参数variables,以防调用者没有传入它。点击 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters 了解更多此功能。

所有的传输错误都将从对fetch()的调用中抛出,所以让我们将对fetch()的调用和随后对主体的检索包装起来,并在try-catch块中解析它。让我们使用catch块中的alert来显示错误:

...
  try {
    const response = await fetch('/graphql', {
      ...
    });
  ...
  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
  }
...

fetch操作与最初在issueAdd中执行的操作相同。一旦fetch完成,我们将在result.errors中寻找错误。

...
    if (result.errors) {
      const error = result.errors[0];
...

错误代码可以在error.extensions.code中找到。让我们使用这段代码,以不同的方式处理我们预期的每种类型的错误。对于BAD_USER_INPUT,我们需要将所有的验证错误结合在一起,并显示给用户:

...
      if (error.extensions.code == 'BAD_USER_INPUT') {
        const details = error.extensions.exception.errors.join('\n ');
        alert(`${error.message}:\n ${details}`);
...

对于所有其他错误代码,我们将显示收到的代码和消息。

...
      } else {
        alert(`${error.extensions.code}: ${error.message}`);
      }
...

最后,在这个新的效用函数中,让我们返回result.data。调用者可以检查是否有数据返回,如果有,就使用它。IssueList中的方法loadData()是第一个调用者。构建完查询后,所有获取数据的代码都可以替换为使用查询对graphQLFetch的简单调用。因为它是一个异步函数,我们可以使用await关键字,并将结果直接接收到一个名为data的变量中。如果数据为非空,我们可以用它来设置状态,如下所示:

...
  async loadData() {
    ...
    const data = await graphQLFetch(query);
    if (data) {
      this.setState({ issues: data.issueList });
    }
  }
...

让我们对同一个类中的createIssue方法进行类似的更改。在这里,我们还需要传递第二个参数 variables,它是一个包含变量issues的对象。在返回路径上,如果数据有效,我们知道操作成功了,因此我们可以调用this.loadData()。除了知道操作成功之外,我们不使用数据的返回值。

...
    const data = await graphQLFetch(query, { issue });
    if (data) {
      this.loadData();
    }
...

清单 5-18 中显示了App.jsx中显示错误的一整套更改。

...

async function graphQLFetch(query, variables = {}) {

  try {
    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query, variables })
    });
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);

    if (result.errors) {
      const error = result.errors[0];
      if (error.extensions.code == 'BAD_USER_INPUT') {
        const details = error.extensions.exception.errors.join('\n ');
        alert(`${error.message}:\n ${details}`);
      } else {
        alert(`${error.extensions.code}: ${error.message}`);
      }
    }
    return result.data;

  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
  }

}

...
class IssueList extends React.Component {
  ...

  async loadData() {
    const query = `query {
      ..
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);
    this.setState({ issues: result.data.issueList });
    const data = await graphQLFetch(query);
    if (data) {
      this.setState({ issues: data.issueList });
    }
  }

  async createIssue(issue) {
    const query = `mutation issueAdd($issue: IssueInputs!) {

      issueAdd(issue: $issue) {
        id
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query, variables: { issue } })
    });
    this.loadData();
    const data = await graphQLFetch(query, { issue });
    if (data) {
      this.loadData();
    }
  }

Listing 5-18App.jsx: Changes for Displaying Errors

要测试传输错误,您可以在刷新浏览器后停止服务器,然后尝试添加新问题。如果这样做,您将会发现如图 5-5 中的屏幕截图所示的错误消息。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig5_HTML.jpg

图 5-5

传输错误消息

至于其他消息,可以通过在用户输入中键入一个小标题来测试标题的长度。其他验证只能通过临时更改代码来测试,例如,将status设置为所需的值,将due字段设置为无效的日期字符串等。在IssueAdd组件的handleSubmit()方法中。

摘要

在本章中,我们比较了两个 API 标准:REST 和 GraphQL。尽管 REST 被广泛使用,但考虑到它的特性和易于实现,我们选择了 GraphQL,因为有工具和库可以帮助我们构建 API。

GraphQL 是一个非常结构化的 API 标准,并且非常广泛。我只介绍了 GraphQL 的基础知识,其中只包括问题跟踪应用在这个阶段所需的特性。我鼓励你在 https://graphql.org/ 阅读更多关于 GraphQL 的文章。还有一些高级特性,比如指令和片段,这些特性有助于重用代码来构建查询。这些在大型项目中可能非常方便,但我不会在本书中涉及这些,因为它们对于问题跟踪器应用来说并不是真正必需的。

在本章中,您已经看到了如何使用 GraphQL 构建 CRUD 的 C 和 R 部分。您还看到了一些验证是如何容易实现的,以及 GraphQL 的强类型系统如何帮助避免错误并使 API 自文档化。我们将在后面的章节中处理 CRUD 的 U 和 D 部分,当我们构建这些特性时。

同时,看看如何持久化数据将是一个好主意。我们将一系列问题从浏览器内存转移到服务器内存。在下一章,我们将进一步把它转移到一个真正的数据库,MongoDB。

练习答案

练习:关于 API

  1. 浏览器中的相同 URL 和 cURL 命令行会导致不同的结果。在浏览器中,返回操场 UI,而在命令行中,执行 API。Apollo 服务器通过查看Accept头来进行区分。如果它找到了"text/html"(这是浏览器发送的),它返回操场 UI。您可以通过在 cURL 命令行中添加--header "Accept: text/html"并执行它来检查这一点。

  2. 浏览器可以缓存 GET 请求,并从缓存本身返回响应。不同的浏览器行为不同,很难预测正确的行为。通常,您会希望 API 结果永远不被缓存,而是总是从服务器获取。在这种情况下,使用 POST 是安全的,因为浏览器不会缓存 POST 的结果。

    但是如果您真的希望浏览器尽可能缓存某些 API 响应,因为结果很大并且不会改变(例如,图像),GET 是唯一的选择。或者,您可以使用 POST,但自己处理缓存(例如,通过使用本地存储),而不是让浏览器来处理。

练习:列表 API

  1. 在第一种情况下,查询具有有效的语法,但是不符合模式。游乐场发送了请求,服务器对此响应了一个错误。

    在第二种情况下,Playground 没有将查询发送到服务器(您可以在控制台日志中看到使用开发人员控制台的错误),因为它发现查询没有有效的语法:花括号中应该有一个子字段名称。

    在这两种情况下,Playground 都在查询中将错误显示为红色下划线。将光标悬停在红色下划线上将显示实际的错误消息,而不管它是语法错误还是模式错误。

  2. 添加无效的子字段不会使查询在语法上无效。请求被发送到服务器,服务器验证查询并返回一个错误,指出子字段无效。

  3. 查询可以像query { about issueList { id title created } }一样。在结果中,您可以看到aboutissueList都作为data对象的属性返回。

练习:自定义标量类型

  1. 无论是否使用标量类型的解析器,输出都是相同的。将GraphQLDate定义为标量类型的模式使得Date对象的默认解析器使用toJSON()而不是toString()

  2. 可以在 serialize 函数中添加一条console.log()消息。或者,如果您临时更改转换以使用Date.toString()而不是Date.toISOString(),您可以看到转换正在以不同的方式进行。

练习:创建 API

  1. 就详细程度而言,两种方法是相同的,所有的公共属性都必须在IssueIssueInputs或参数列表之间重复。如果属性列表发生变化,例如,如果我们添加一个名为severity的新字段,那么必须在两个地方进行更改:在Issue类型中,在IssueInputs类型中,参数列表指向issueAdd

    定义输入类型的一个优点是可以重用相同的类型。例如,如果创建和更新操作可以接受相同的输入类型,这就很方便了。

  2. 传递一个整数会将ast.kind设置为Kind.INT(?? 被设置为字符串'IntValue',如控制台日志所示)。其他可能的值有Kind.FLOATKind.BOOLEANKind.ENUM

  3. 传递一个有效的字符串但传递一个无效的日期不会在创建问题的过程中抛出任何错误,但是问题将与一个无效的Date对象一起保存,这是new Date()使用无效的日期字符串的结果。当问题被返回时,将会看到这样的效果;会有错误显示date对象不能被转换成字符串。我们将在本章后面添加验证。

  4. 可以在模式中通过在类型规范后添加一个=符号和缺省值来指定缺省值,比如status: String = "New"。我们将在本章的后面切换到这种方法。

练习:创建 API 集成

  1. 问题没有产生,控制台将出现一个错误,指示请求不正确。您会发现请求的格式不正确,因为引号结束了字符串,这是标题的值,GraphQL 查询解析器无法识别其后的所有内容。

    解决这个问题的一种方法是在字符串值中查找引号,并使用反斜杠()字符对它们进行转义。但是正如您将在下一节中看到的,有一种更好的方法可以做到这一点。

练习:查询变量

  1. 由于这些值不是作为查询字符串中的文字传递的,现在将调用的是parseValue。*