跳到内容

c

从服务器获取数据

一段时间以来,我们只致力于 "前端",即客户端(浏览器)功能。我们将在本课程的第三章节中开始研究 "后端",即服务器端的功能。尽管如此,我们现在将朝着这个方向迈出一步,熟悉在浏览器中执行的代码是如何与后端通信的。

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

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

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

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

然而,全局安装并不是必须的。 从你的应用的根目录,我们可以使用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是一个方便的工具,它能够在开发阶段使用服务器端的功能,而不需要对其进行任何编程。

我们将在本课程的第三章节中更详细地熟悉实现服务器端功能的原则。

The browser as a runtime environment

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

在第0章节示例项目中,我们已经学习了一种使用JavaScript从服务器获取数据的方法。例子中的代码是使用XMLHttpRequest来获取数据的,也就是使用XHR对象来进行HTTP请求。这是一种1999年引入的技术,现在每个浏览器都支持了很长时间。

不再推荐使用XHR,浏览器已经广泛支持fetch方法,该方法基于所谓的promises,而不是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://studies.cs.helsinki.fi/exampleapp/data.json";
List<Note> notes = request.get(url);

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

在Java中,代码逐行执行,然后停下来等待HTTP请求,也就是等待命令request.get(...)完成。由命令返回的数据,在本例中是笔记,然后被存储在一个变量中,我们开始以所需的方式操作数据。

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

当一个异步操作完成时,或者更确切地说,在完成后的某个时间点,JavaScript引擎会调用注册在该操作上的事件处理程序。

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

JavaScript引擎的这种单线程特性的另一个后果是,如果某些代码的执行占用了大量的时间,浏览器就会在执行的过程中卡住。如果我们在应用的顶部添加以下代码。

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

一切都会在5秒内正常工作。然而,当定义为setTimeout参数的函数运行时,浏览器将在长循环的执行过程中被卡住。甚至在执行循环的过程中也不能关闭浏览器标签,至少在Chrome中不能。

为了使浏览器保持响应性,即能够以足够的速度对用户的操作作出连续的反应,代码逻辑需要做到没有一个计算能够花费太长时间。

在互联网上可以找到大量关于这个主题的额外材料。菲利普-罗伯茨(Philip Roberts)对这一主题的一个特别清晰的介绍是名为事件循环到底是什么?的主题演讲。

在今天的浏览器中,可以借助所谓的网络工作者来运行并行化的代码。然而,单个浏览器窗口的事件循环仍然只能由一个单线程处理。

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": "^5.16.1",
    "@testing-library/react": "^12.1.2",
    "@testing-library/user-event": "^13.5.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-scripts": "5.0.0",
    "web-vitals": "^2.1.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "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

NB npm-commands应该总是在项目根目录下运行,也就是可以找到package.json文件的地方。

Axios现在被包含在其他依赖项中。

{
  "name": "notes",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.16.1",
    "@testing-library/react": "^12.1.2",
    "@testing-library/user-event": "^13.5.0",
    "axios": "^0.24.0",    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-scripts": "5.0.0",
    "web-vitals": "^2.1.3"
  },
  // ...
}

除了将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工具。

NB 在启动新的json-server之前,必须先终止之前启动的json-server;否则会有麻烦。

fullstack content

错误信息中的红色字体告诉我们这个问题。

Cannot bind to port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file

我们可以看到,应用无法将自己绑定到端口。原因是3001端口已经被先前启动的json-server占用。

我们用了两次npm install的命令,但略有不同。

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

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

Axios and promises

现在我们已经准备好使用axios了。今后,假定json-server在3001端口运行。

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

这个库可以像其他库(如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)

如果你在浏览器中打开http://localhost:3000,应该会在控制台中打印出以下内容

fullstack content

注意:当文件index.js的内容发生变化时,React并不总是自动注意到这一点,所以你可能需要刷新浏览器来看到你的变化!一个简单的解决方法是在项目的根目录下创建一个名为.env的文件,并添加这一行FAST_REFRESH=false,使React自动注意到变化。重新启动应用以使应用的变化生效。

'Axios' 方法 get 返回一个 promise

Mozilla网站上的文档对 promise 有如下说明。

A Promise是一个代表异步操作最终完成或失败的对象。

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

1.答应是pending:这意味着最终的值(以下两个中的一个)还不能用。

  1. promise 是fulfilled:它意味着操作已经完成,最终值可用,一般来说是一个成功的操作。这种状态有时也被称为resolved

  2. promise 被拒绝:这意味着一个错误阻止了最终值的确定,这一般代表一个失败的操作。

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

如果,以及何时,我们想访问 promise 所代表的操作的结果,我们必须为 promise 注册一个事件处理程序。这可以通过then方法实现。

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

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

以下内容将被打印到控制台。

fullstack content

JavaScript运行环境调用由then方法注册的回调函数,为其提供一个response对象作为参数。response对象包含与HTTP GET请求的响应相关的所有基本数据,其中包括返回的数据状态代码头信息

将 promise 对象存储在一个变量中通常是不必要的,而通常是将then方法调用链到axios方法调用中,这样它就直接跟随它。

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

回调函数现在接收响应中包含的数据,将其存储在一个变量中,并将注释打印到控制台。

格式化链式方法调用的一个更可读的方法是将每个调用放在自己的一行。

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

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

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

让我们尝试从本地服务器上请求笔记,并渲染它们,最初作为App组件。请注意,这种方法有很多问题,因为我们只有在成功检索到一个响应时才会渲染整个App组件。

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

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

这种方法在某些情况下是可以接受的,但它有些问题。让我们把获取数据的过程移到App组件中。

然而,不明显的是,axios.get命令应该放在组件中的什么地方。

Effect-hooks

我们已经使用了与React版本16.8.0一起引入的状态钩子,它为定义为函数的React组件--所谓的功能组件提供状态。16.8.0版本还引入了效果钩子这个新功能。按照官方文档的说法。

Effect 允许组件连接到外部系统并与之同步。

这包括处理网络、浏览器、DOM、动画、使用不同 UI 库编写的小部件以及其他非 React 代码。

因此,当从服务器获取数据时,效果钩子正是正确的工具。

让我们从index.js中移除数据的获取。既然我们要从服务器上获取笔记,就不再需要将数据作为prop传递给App组件。所以index.js可以简化为。

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

App组件的变化如下。

import { 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的说法就是效果。

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

在渲染后立即执行。该函数的执行结果是effect被打印到控制台,命令axios.get开始从服务器获取数据,并注册以下函数作为该操作的event handler

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

当数据从服务器到达时,JavaScript运行时调用注册为事件处理程序的函数,该函数将 promise 兑现打印到控制台,并使用函数setNotes(response.data)将从服务器收到的注释存储到状态中。

一如既往,对状态更新函数的调用会触发组件的重新渲染。结果,render 3 notes被打印到控制台,而从服务器上获取的笔记被渲染到屏幕上。

最后,让我们来看看效果钩子的整体定义。

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实际上需要两个参数。第一个是一个函数,即effect本身。根据文档的内容。

默认情况下,效果会在每次完成渲染后运行,但你可以选择只在某些值发生变化时启动它。

所以默认情况下,效果是总是在组件被渲染后运行。然而,在我们的例子中,我们只想在第一次渲染时执行效果。

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

除了从服务器上获取数据之外,效果钩子还有许多可能的使用情况。然而,目前这个用途对我们来说已经足够了。

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

注意,我们也可以这样写效果函数的代码。

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方法的一个参数。通常没有必要把函数和 promise 分配给变量,用更紧凑的方式来表示事情,如上面进一步看到的,就足够了。

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

在我们的应用中仍然有一个问题。当添加新的笔记时,它们没有被存储在服务器上。

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

The development runtime environment

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

fullstack content

构成我们React应用的JavaScript代码在浏览器中运行。浏览器从React开发服务器获得JavaScript,这是运行npm start命令后运行的应用。开发服务器将JavaScript转换为浏览器可以理解的格式。除其他事项外,它还将不同文件的JavaScript拼接成一个文件。我们将在课程的第七章节更详细地讨论dev-server。

浏览器中运行的React应用从机器上运行在3001端口的json-server取回JSON格式的数据。我们查询数据的服务器--json-server-从文件db.json中获取数据。

在开发的这一点上,应用的所有部分恰好都在软件开发者的机器上,也就是所谓的localhost。当应用被部署到互联网上时,情况会发生变化。我们将在第3章节做这个。