c

从服务器获取数据

到目前为止,我们一直致力于“前端” ,即客户端(浏览器)功能。 我们将在本课程的第三章节开始研究“后端” ,即服务器端功能。 尽管如此,我们现在将向这个方向迈出一步,熟悉在浏览器中执行的代码如何与后端通信。

让我们使用一个在开发过程中使用的工具,称为JSON 服务器 ,作为我们的服务器。

在项目的根目录中创建一个名为db.json 的文件,其内容如下:

{
  "notes": [
    {
      "id": 1,
      "content": "HTML is easy",
      "date": "2019-05-30T17:30:31.098Z",
      "important": true
    },
    {
      "id": 2,
      "content": "Browser can execute only JavaScript",
      "date": "2019-05-30T18:39:34.091Z",
      "important": false
    },
    {
      "id": 3,
      "content": "GET and POST are the most important methods of HTTP protocol",
      "date": "2019-05-30T19:20:14.298Z",
      "important": true
    }
  ]
}

您可以使用命令 npm install -g json-server在您的机器上安装 JSON 服务器。 global 安装需要管理员权限,这意味着它不可能在教学电脑或新生的笔记本电脑上安装。

但是,全局安装不是必须的。因为我们可以在应用的根目录使用 npx 命令运行json-server:

npx json-server --port 3001 --watch db.json

默认情况下,json-server在端口3000上启动; 但是由于 create-react-app 项目设置了3000端口,因此我们必须为 json-server 定义一个备用端口,比如端口3001。

让我们在浏览器中输入地址 http://localhost:3001/notes。 我们可以看到JSON-server 以 JSON 格式提供了我们之前写到文件的便笺:

fullstack content

如果你的浏览器无法格式化 json 数据的显示,那么安装一个合适的插件,例如JSONView ,这样会让你的生活更加轻松。

接下来,我们的想法是将便笺保存到服务器,这在本例中意味着将便笺保存到 json-server。 React代码从服务器获取便笺并将其渲染到屏幕上。 无论何时向应用添加新便笺,React 代码都会将其发送到服务器,以使新便笺保存在“内存”中。

Json-server 将所有数据存储在服务器上的db.json 文件中。 在现实世界中,数据会存储在某种数据库中。 然而,json-server 是一个方便的工具,可以在开发阶段使用服务器端功能,而不需要编写任何程序。

在本课程的第3章节中,我们将更详细地了解如何实现服务器端的功能。

The browser as a runtime environment

【浏览器作为一个运行时环境】

我们的第一个任务是从地址 http://localhost:3001/notes 获取已经存在的便笺到 React 应用。

在 part0示例 project中,我们已经学到了一种使用 JavaScript 从服务器获取数据的方法。 示例中的代码使用XMLHttpRequest获取数据,也称为使用 XHR 对象发出的 HTTP 请求。 这是1999年引入的一项技术,现在每个浏览器都已经支持很长时间了。

使用 XHR已经不再推荐了,而且浏览器已经广泛支持基于所谓的promisesfetch方法,而不是 XHR 使用的事件驱动模型。

作为第0章的提醒(实际上我应该记住在没有紧迫理由的情况下不要使用) ,使用 XHR 获取数据的方式如下:

const xhttp = new XMLHttpRequest()

xhttp.onreadystatechange = function() {
  if (this.readyState == 4 && this.status == 200) {
    const data = JSON.parse(this.responseText)
    // handle the response that is saved in variable data
  }
}

xhttp.open('GET', '/data.json', true)
xhttp.send()

在开始时,我们将一个事件处理程序 注册到表示 HTTP 请求的xhttp对象,当 xhttp对象的状态发生变化时,JavaScript 运行时将调用该对象。 如果状态的变化意味着对请求的响应已经到达,那么数据将得到相应的处理。

值得注意的是,事件处理中的代码是在请求发送到服务器之前定义的。 尽管如此,事件处理中的代码将在稍后的时间点执行。 因此,代码并不是“从顶部到底部”同步执行,而是异步执行。 JavaScript 调用了事件处理,而这个事件处理是在之前某个时刻注册的。

例如,一种在 Java 编程中常见的同步发出请求的方式,如下(注意,这实际上不是可运行的 Java 代码) :

HTTPRequest request = new HTTPRequest();

String url = "https://fullstack-exampleapp.herokuapp.com/data.json";
List<Note> notes = request.get(url);

notes.forEach(m => {
  System.out.println(m.content);
});

在 Java 中,代码逐行执行并停止等待 HTTP 请求,这意味着等待request.get(...) 命令完成。 命令返回的数据,在本例中是notes,然后存储在一个变量中,我们开始以所需的方式操作数据。

另一方面,JavaScript 引擎,或者运行时环境,遵循异步模型asynchronous model.。 原则上,这要求所有的IO-操作(除了一些例外)都以非阻塞方式执行。 这意味着代码执行在调用 IO 函数之后立即继续,而不需要等待它返回。

当一个异步操作完成时,或者更确切地说,在它完成之后的某个时刻,JavaScript 引擎才调用注册到该操作的事件处理。

目前,JavaScript 引擎是单线程的,这意味着它们不能并行执行代码。 因此,在实践中需要使用非阻塞模型来执行 IO 操作。 否则,浏览器将在从服务器获取数据时“冻结(卡住)”。

这种单线程的 JavaScript 引擎的另一个后果是,如果某些代码的执行占用了大量的时间,那么浏览器将在执行期间停滞不前。 如果我们在应用顶部添加如下代码:

setTimeout(() => {
  console.log('loop..')
  let i = 0
  while (i < 50000000000) {
    i++
  }
  console.log('end')
}, 5000)

一切正常运转5秒钟。 但是,当运行定义为 setTimeout 参数的函数时,浏览器将在长循环执行期间停止。 即使是浏览器的标签也不能在循环执行期间关闭,至少在 Chrome 中不能。

为了让浏览器保持responsive响应性,即能够以足够的速度连续地对用户操作作出反应,代码逻辑需要让任何单一的计算都不会花费太长的时间。

在互联网上可以找到大量关于这个议题的补充材料。 关于这个话题,一个特别清晰的演讲是 Philip Roberts 的议题演讲What the heck is the event loop anyway?

在当今的浏览器中,可以在所谓的 web workers 的帮助下运行并行化的代码。 然而,单个浏览器窗口的事件循环仍然是由一个单线程处理。

npm

让我们回到从服务器获取数据的议题。

我们可以使用前面提到的基于承诺promise的fetch函数从服务器中获取数据。 fetch是一个很好的工具。 它是标准化的,所有现代浏览器(不包括 IE,因为它不是)都支持它。

也就是说,我们将使用axios库来代替浏览器和服务器之间的通信。 它的功能类似于fetch,但是使用起来更友好。 使用 axios 的另一个很好的理由是,我们已经熟悉了为 React 项目添加外部库,即使用所谓的npm 包

现在,几乎所有的 JavaScript 项目都是使用node包管理器定义的,也就是npm。 使用 create-react-app 创建的项目也遵循 npm 格式。 项目使用 npm 的一个明确的说明是位于项目根目录的package.json 文件:

{
  "name": "notes",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.4.0",
    "@testing-library/user-event": "^7.2.1",
    "react": "^16.12.0",
    "react-dom": "^16.12.0",
    "react-scripts": "3.3.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

此时,我们对dependencies 部分最感兴趣,因为它定义了项目具有的依赖dependencies 或外部库。

我们现在要使用 axios。 理论上,我们可以在package.json 文件中直接定义它,但最好是从命令行安装它。

npm install axios --save

注意: npm-commands 应该始终在项目根目录中运行,在这个目录中可以找到package.json 文件。

Axios 现在被包含在依赖中了:

{
  "dependencies": {
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.4.0",
    "@testing-library/user-event": "^7.2.1",
    "axios": "^0.19.2",    "react": "^16.12.0",
    "react-dom": "^16.12.0",
    "react-scripts": "3.3.0"
  },
  // ...
}

除了将 axios 添加到依赖项之外,npm install 命令还下载了库代码。 与其他依赖项一样,代码可以在根目录中的node_modules 目录中找到。 人们可能已经注意到,node_modules 包含了大量有趣的内容。

让我们做另一个补充,通过执行如下命令将json-server 安装为开发依赖项(仅在开发过程中使用) :

npm install json-server --save-dev

package.json 文件的scripts部分添加一个小的修改:

{
  // ... 
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 --watch db.json"  },
}

现在,我们可以在没有参数定义的情况下方便地使用如下命令从项目根目录启动 json-server:

npm run server

我们将在课程的第三章节中更加熟悉 npm 工具。

注意: 在启动新服务器之前,以前启动的 json-server必须终止,否则会出现问题:

fullstack content

错误信息中的红色打印提示我们这个问题的原因:

Cannot bind to the port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file 不能绑定到3001端口。 请通过 -- port 参数或通过 json-server.json 配置文件指定另一个端口号。

正如我们所看到的,应用不能将自己绑定到端口。 原因是端口3001已经被先前启动的 json-server 占用了。

我们使用了两次 npm 安装命令,但是有一点不同:

npm install axios --save
npm install json-server --save-dev

参数之间有细微的差别。axios 被安装为应用的运行时依赖项(-- save) ,因为程序的执行需要库的存在。 而另一个, json-server 是作为开发依赖项(-- save-dev)安装的,因为程序本身并不需要它。 它用于在软件开发过程中提供帮助。 在课程的下一章节将会有更多关于不同依赖的内容。

Axios and promises

现在我们可以使用 axios 了。在开始之前,我已经假定你的json-server跑在3001端口了。

注意,为了同时运行 json-server和你的react 应用,你可能需要使用两个terminal 窗口。一个用来保持json-server 的运行,另一个来跑你的react应用。

可以像其他库一样使用这个库,就像 React那样,即使用 import 语句。

将如下内容添加到文件index.js 中:

import axios from 'axios'

const promise = axios.get('http://localhost:3001/notes')
console.log(promise)

const promise2 = axios.get('http://localhost:3001/foobar')
console.log(promise2)

此时如下信息会打印到控制台

fullstack content

Axios 的 get 方法会返回一个promise

Mozilla's 网站上的文档对promises 做了如下解释:

A Promise is an object representing the eventual completion or failure of an asynchronous operation. Promise承诺是一个对象,用来表示异步操作的最终完成或失败

换句话说,promise 是一个表示异步操作的对象,它可以有三种不同的状态:

  1. The promise is pending提交中: 这意味着最终值(下面两个中的一个)还不可用。

  2. The promise is fulfilled兑现: 这意味着操作已经完成,最终的值是可用的,这通常是一个成功的操作。 这种状态有时也被称为resolve

  3. The promise is rejected拒绝:它意味着一个错误阻止了最终值,这通常表示一个失败操作。

我们示例中的第一个承诺是fulfilled,表示一个成功的axios.get('http://localhost:3001/notes') 请求。 而第二个是rejected,控制台告诉我们原因。 看起来我们试图向一个不存在的地址发出了 HTTP GET 请求。

如果我们想要访问承诺表示的操作的结果,那么必须为承诺注册一个事件处理。 这是通过 then方法实现的:

const promise = axios.get('http://localhost:3001/notes')

promise.then(response => {
  console.log(response)
})

The following is printed to the console: 下面的代码打印到控制台:

fullstack content

JavaScript 运行时环境调用由 then 方法注册的回调函数,并提供一个response 对象作为参数。response 对象包含与 HTTP GET 请求响应相关的所有基本数据,也包括返回的datastatus codeheaders

通常没有必要将 promise 对象存储在一个变量中,而将 then方法调用链到 axios 方法调用是很常见的,因此它可以直接跟在 axios 方法调用后面:

axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  console.log(notes)
})

回调函数获取了响应中包含的数据,将其存储在一个变量中,并将便笺打印到控制台。

要格式化chained 方法调用,以一种更易读的方法是将每个调用放在独立的行上:

axios
  .get('http://localhost:3001/notes')
  .then(response => {
    const notes = response.data
    console.log(notes)
  })

服务器返回的数据是纯文本,基本上只有一个长字符串。 Axios 库仍然能够将数据解析为一个 JavaScript 数组,因为服务器使用content-type 头指定数据格式为application/json; charset=utf-8 (参见前面的图片)。

我们现在终于可以开始使用从服务器获取的数据了。

我们尝试从我们本地服务器请求 Notes 并渲染,就像App 组件开始那样。注意这种方法有许多问题,比如我们只有将整个App 渲染完成后才会得到成功的response :

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

import axios from 'axios'
axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  ReactDOM.render(
    <App notes={notes} />,
    document.getElementById('root')
  )
})

这种方法在某些情况下是可以接受的,但是有一些问题。 让我们将数据的fetch逻辑转移到App 组件中。

但是,命令 axios.get 应该放在组件中的哪个位置,这一点并不明显。

Effect-hooks

我们已经使用了与 React version 16.8.0一起引入的 state hooks,它为 React 组件提供了定义为函数的状态,也就是所谓的 函数式组件 。 16.8.0版本还引入了 effect hooks 新特性。 像文档里说的:

The Effect Hook lets you perform side effects in function components. Effect Hook 可以让你在函数组件中执行副作用 Data fetching, setting up a subscription, and manually changing the DOM in React components are all examples of side effects. 数据获取、设置订阅和手动更改 React 组件中的 DOM 都是副作用的例子。

因此,effect hooks正是从服务器获取数据时使用的正确工具。

让我们从index.js 中删除数据的获取逻辑。由于我们需要从服务端获取notes, 不再需要将数据作为props传递给App 组件。 所以我将 index.js 简化为:

ReactDOM.render(<App />, document.getElementById('root'))

App组件更改如下:

import React, { useState, useEffect } from 'react'import axios from 'axios' 
import Note from './components/Note'

const App = () => {
  const [notes, setNotes] = useState([]) 
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)

  useEffect(() => {    console.log('effect')    axios      .get('http://localhost:3001/notes')      .then(response => {        console.log('promise fulfilled')        setNotes(response.data)      })  }, [])  console.log('render', notes.length, 'notes')
  // ...
}

我们还添加了一些有用的打印,用来清晰执行的进程。

这是打印到控制台的内容


render 0 notes
effect
promise fulfilled
render 3 notes

首先执行定义组件的函数体,并首次渲染组件。 此时我打印了 render 0 notes ,这意味着还没有从服务器获取数据。

下面的函数,或者说React 的 effect:

() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}

在渲染完成后会立即执行。 函数的执行结果是effect 被打印到控制台,axios.get 命令从服务器获取到数据,并将如下函数注册为事件处理:

response => {
  console.log('promise fulfilled')
  setNotes(response.data)
})

当数据从服务器到达时,JavaScript 运行时会调用注册为事件处理的函数,该函数将promise fulfilled 输出到控制台,并使用函数setNotes(response.data) 将从服务器接收的便笺存储到状态中。

通常,对状态更新函数的调用会触发组件的重新渲染。 结果,render 3 notes 被打印到控制台,从服务器获取的便笺被显示到屏幕上。

最后,让我们来整体看一下 effect hook :

useEffect(() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes').then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}, [])

让我们用不同的方式重写一下代码。

const hook = () => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}

useEffect(hook, [])

现在我们可以更清楚地看到函数 useEffect 实际上需要两个参数 。第一个是函数本身。 根据文档描述:

By default, effects run after every completed render, but you can choose to fire it only when certain values have changed. 默认情况下,effects 在每次渲染完成后运行,但是你可以选择只在某些值发生变化时才调用。

因此,默认情况下,effect是总是 在组件渲染之后才运行。 然而,在我们的例子中,我们只想在第一次渲染的时候执行这个效果。

useEffect的第二个参数用于指定effect运行的频率。 如果第二个参数是一个空数组 [],那么这个effect只在组件的第一次渲染时运行。

除了从服务器获取数据之外,Effect-Hook还有许多用例。 但是目前已经足够,我们暂时只了解到这。

回想一下我们刚才讨论的事件顺序。 代码的哪些部分是运行的? 按什么顺序? 多久一次? 理解事件的顺序是至关重要的!

注意,我们也可以这样编写 effect 函数的代码:

useEffect(() => {
  console.log('effect')

  const eventHandler = response => {
    console.log('promise fulfilled')
    setNotes(response.data)
  }

  const promise = axios.get('http://localhost:3001/notes')
  promise.then(eventHandler)
}, [])

对事件处理函数的引用被分配给变量eventHandler。 Axios 的get方法返回的promise存储在变量 promise 中。 回调的注册是通过将 eventHandler变量作为参数 (事件处理函数的引用)传递给promise 的 then 方法的来实现的。 通常没有必要为函数和承诺分配变量,而是用更紧凑的表示方式,就像上面那样,就足够了。

useEffect(() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}, [])

我们的应用仍然有一个问题。当添加新的便笺时,它们不存储在服务器上。

到目前为止,应用的代码可以在分支part2-4 中的github上找到。

The development runtime environment

【开发的运行时环境】

我们整个应用的配置已经逐渐变得更加复杂。 让我们回顾一下发生了什么,在哪里发生的。 下图描述了应用的组成

fullstack content

构成我们的 React 应用的 JavaScript 代码在浏览器中运行。 浏览器从React dev server 获取 JavaScript,这是运行 npm start 命令后运行的应用。 dev-server 将 JavaScript 转换成浏览器可以理解的格式。 除此之外,它还将来自不同文件的 JavaScript 整合到一个文件中。 我们将在本课程的第7章节中更详细地讨论开发服务器。

在浏览器中运行的 React 应用从计算机3001端口上运行的JSON-server 获取 JSON 格式的数据。 Json-server 从db.json 文件中获取数据。

在开发的这个阶段,应用的所有部分都放在软件开发人员的机器上,也就是本地主机。 当应用被部署到互联网上时,情况发生了变化。 我们将在第三章节讨论这个。