跳到内容

d

修改服务端的数据

当在我们的应用中创建笔记时,我们自然希望将它们存储在某个后端服务端中。json-server包在文档中自称是一个所谓的REST或RESTful API:

30秒内零编码(认真的)获得一个完整的假REST API

json-server并不完全符合教科书对REST API的描述定义,但其他大多数自称是RESTful的API也不符合。

我们将在课程的下一章节中仔细研究REST。但是现在的重点是熟悉json-server和各种REST API通用的一些约定。特别是REST中路由,也就是URL和HTTP请求类型的常规使用方法。

REST

在REST术语中,我们把单个数据对象,比如我们应用中的笔记,称为资源。每个资源都有一个与之相关的独一无二的地址——它的URL。根据json-server使用的一般惯例,我们可以通过资源URL notes/3来定位某一条笔记,其中3是资源的id。另一方面,notes URL将指向一个包含所有笔记的资源集合。

资源是通过HTTP GET请求从服务端获取的。例如,对URL notes/3的HTTP GET请求将返回ID为3的笔记。对notes URL的HTTP GET请求将返回所有笔记的列表。

根据json-server所遵守的REST惯例,创建用于存储笔记的新资源是通过向notes URL发送HTTP POST请求来实现的。新笔记资源的数据在请求中发送。

json-server要求所有数据以JSON格式发送。这实际上意味着数据必须是格式正确的字符串,而且请求必须包含Content-Type标头,且Content-Type的值为application/json

向服务端发送数据

让我们对负责创建新笔记的事件处理函数做如下修改:

const addNote = event => {
  event.preventDefault()
  const noteObject = {
    content: newNote,
    important: Math.random() < 0.5,
  }

  axios    .post('http://localhost:3001/notes', noteObject)    .then(response => {      console.log(response)    })}

我们为笔记创建一个新的对象,但省略了id属性,因为id最好让服务端为我们的资源生成。

使用axios的post方法将对象发送到服务端。注册的事件处理函数记录了从服务端发回控制台的响应。

当我们尝试创建一条新的笔记时,控制台中会弹出以下输出:

fullstack content

新创建的笔记资源被存储在response对象的data属性值中。

在Chrome开发工具中的Network标签页中检查HTTP请求经常会很有用,第0章节的开头就大量使用了这个标签页。

我们可以使用检查器来检查POST请求中发送的标头是否是我们所期望的:

fullstack content

由于我们在POST请求中发送的数据是一个JavaScript对象,axios自动知道为将Content-Type标头设为正确的application/json值。

负载标签页可以用来查看请求的数据:

fullstack content

响应标签页也有用,它展示了服务端响应的数据是什么:

fullstack content

新笔记还没有渲染到屏幕上。这是因为我们在创建新笔记时没有更新App组件的状态。让我们来解决这个问题:

const addNote = event => {
  event.preventDefault()
  const noteObject = {
    content: newNote,
    important: Math.random() > 0.5,
  }

  axios
    .post('http://localhost:3001/notes', noteObject)
    .then(response => {
      setNotes(notes.concat(response.data))      setNewNote('')    })
}

通过惯常的方式,将后端服务端返回的新笔记添加到我们应用状态中的笔记列表中,使用setNotes函数,然后重置创建笔记的表单。一个需要记住的重要细节是,concat方法并不改变组件的状态本体,而是创建一个新的列表副本。

一旦服务端返回的数据开始影响我们Web应用的行为,我们就会立即面临一系列全新的挑战,例如,通信的异步性。这就需要新的调试策略,控制台记录和其他调试方法变得越来越重要。我们还必须对JavaScript运行时和React组件的原理有足够的理解。仅仅通过猜是不够的。

检查后端服务端的状态是有好处的,比如通过浏览器:

fullstack content

这让我们可以验证我们打算发送的所有数据是否真的被服务端收到。

在课程的下一部分,我们将学习如何在后端实现我们自己的逻辑。然后我们将仔细研究像Postman这些可以帮助我们调试我们的服务端应用的工具。不过目前而言,通过浏览器检查json-server的状态就足够满足我们的需要了。

我们应用当前状态的代码可以在GitHub上的part2-5分支找到。

改变笔记的重要性

让我们为每条笔记添加一个可以用来切换笔记的重要性的按钮。

我们对Note组件做如下修改:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li>
      {note.content}
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

我们给组件添加一个按钮,并将其事件处理函数赋值为组件的props中传递的toggleImportance函数。

App组件定义了一个初始版本的toggleImportanceOf事件处理函数,并将其传递给每个Note组件:

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

  // ...

  const toggleImportanceOf = (id) => {    console.log('importance of ' + id + ' needs to be toggled')  }
  // ...

  return (
    <div>
      <h1>Notes</h1>
      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all' }
        </button>
      </div>
      <ul>
        {notesToShow.map(note =>
          <Note
            key={note.id}
            note={note}
            toggleImportance={() => toggleImportanceOf(note.id)}          />
        )}
      </ul>
      // ...
    </div>
  )
}

注意每条笔记是如何得到它自己独有的事件处理函数的,因为每条笔记的id都是独一无二的。

例如,如果note.id是3,由toggleImportance(note.id)返回的事件处理函数将是:

() => { console.log('importance of 3 needs to be toggled') }

在此简单提醒一下。事件处理函数所打印的字符串是以类似Java的方式使用加号连接字符串来定义的:

console.log('importance of ' + id + ' needs to be toggled')

可以用ES6中添加的模板字符串语法来以更好的方式编写类似的字符串:

console.log(`importance of ${id} needs to be toggled`)

我们现在可以使用“${}”语法在字符串中添加要计算JavaScript表达式的部分,例如变量的值。注意,我们在模板字符串中使用的是反引号`,而非用于普通JavaScript字符串的引号'"

存储在json-server后端的每条笔记可以通过两种不同方式进行修改,两种方式都是对笔记的唯一URL进行HTTP请求。我们可以用HTTP PUT请求来替换整条笔记,也可以用HTTP PATCH请求只改变笔记的某些属性。

事件处理函数的最终形式是这样的:

const toggleImportanceOf = id => {
  const url = `http://localhost:3001/notes/${id}`
  const note = notes.find(n => n.id === id)
  const changedNote = { ...note, important: !note.important }

  axios.put(url, changedNote).then(response => {
    setNotes(notes.map(note => note.id === id ? response.data : note))
  })
}

函数体中的几乎每一行代码都包含了重要的细节。第一行定义了基于笔记id的每条笔记资源的唯一URL。

数组的find方法用来查找我们要修改的笔记,然后我们把要修改的笔记赋值给note变量。

在这之后,我们创建一个新对象,它是旧笔记的精确拷贝,除了important属性的值被翻转(从true变为false或从false变为true)。

创建新对象的代码使用了对象展开语法,一开始可能看起来有点奇怪:

const changedNote = { ...note, important: !note.important }

实际上,{ ...note }创建了一个复制了note对象所有属性的新对象。当我们在展开对象后面的大括号内添加属性时,比如{ ...note, important: true },那么新对象的important属性值将是true。在我们的例子中,important属性是原来对象中先前important值的相反值。

有几件事需要指出。为什么我们要创建一个我们要修改的笔记对象的拷贝,明明下面的代码看起来也能运行?

const note = notes.find(n => n.id === id)
note.important = !note.important

axios.put(url, note).then(response => {
  // ...

不建议这样做,因为变量note是对组件状态中notes数组中一项的引用,而我们记得我们在React中决不能直接改变状态

还有一点值得注意,新对象changedNote只是一个所谓的浅拷贝,意味着新对象中各属性的值与旧对象中各属性的值相同。如果旧对象中某属性的值也是对象,那么新对象中该属性的复制值将和旧对象中的该属性的原始值引用相同的对象。

然后,新的笔记会通过PUT请求发送到后端,在那里它将替换旧的对象。

回调函数将组件的notes状态设为一个新的数组,该数组包含了原先notes数组中的所有项,除了旧的笔记被替换为服务端返回的更新版本:

axios.put(url, changedNote).then(response => {
  setNotes(notes.map(note => note.id === id ? response.data : note))
})

这是用map方法完成的:

notes.map(note => note.id === id ? response.data : note)

map方法会通过将旧数组中的每一项映射到新数组的每一项来创建一个新数组。在我们的例子中,新数组是通过条件创建的,如果note.id === id为true,那么就会把服务端返回的笔记对象添加到数组中。如果条件为false,那么我们就只是简单把旧数组的项复制到新数组中。

这个map技巧一开始可能看起来有点奇怪,但值得花些时间去琢磨它。我们将在整个课程中多次使用这种方法。

把和后端的通信提取到单独的模块中

在添加了与后端服务端通信的代码后,App组件变得有些臃肿。本着单一职责原则,我们认为将与后端服务端的通信提取到自己的模块是明智的。

让我们创建一个src/services目录,并向其中添加一个名为notes.js的文件:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  return axios.get(baseUrl)
}

const create = newObject => {
  return axios.post(baseUrl, newObject)
}

const update = (id, newObject) => {
  return axios.put(`${baseUrl}/${id}`, newObject)
}

export default {
  getAll: getAll,
  create: create,
  update: update
}

该模块返回一个对象,该对象有三个处理笔记的函数(getAllcreateupdate)作为其属性。这些函数直接返回axios方法所返回的Promise。

App组件使用import来访问模块:

import noteService from './services/notes'
const App = () => {

可以直接通过导入的变量noteService使用该模块的函数,如下所示:

const App = () => {
  // ...

  useEffect(() => {
    noteService      .getAll()      .then(response => {        setNotes(response.data)      })  }, [])

  const toggleImportanceOf = id => {
    const note = notes.find(n => n.id === id)
    const changedNote = { ...note, important: !note.important }

    noteService      .update(id, changedNote)      .then(response => {        setNotes(notes.map(note => note.id === id ? response.data : note))      })  }

  const addNote = (event) => {
    event.preventDefault()
    const noteObject = {
      content: newNote,
      important: Math.random() > 0.5
    }

    noteService      .create(noteObject)      .then(response => {        setNotes(notes.concat(response.data))        setNewNote('')      })  }

  // ...
}

export default App

我们可以深入看一下我们的实现。当App组件调用这些函数时,它会收到一个包含HTTP请求全部响应的对象:

noteService
  .getAll()
  .then(response => {
    setNotes(response.data)
  })

App组件只使用响应对象的response.data属性。

如果只获得响应数据,而非整个HTTP响应,那么这个模块的就会明显更好用。于是使用这个模块就会像这样:

noteService
  .getAll()
  .then(initialNotes => {
    setNotes(initialNotes)
  })

要实现这个需求,我们可以将模块的代码改成这样(目前的代码包含一些复制粘贴的内容,但我们现在暂且不管):

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = newObject => {
  const request = axios.post(baseUrl, newObject)
  return request.then(response => response.data)
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default {
  getAll: getAll,
  create: create,
  update: update
}

我们不再直接返回axios返回的Promise。而是将Promise赋值给request变量并调用其then方法:

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

我们函数中的最后一行只是对下面相同代码的一个更紧凑的表达:

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => {    return response.data  })}

修改后的getAll函数仍然返回一个Promise,因为Promise的then方法也返回一个Promise

在定义then方法的参数来让getAll函数直接返回response.data之后,getAll已经函数满足了我们的需求。当HTTP请求成功时,Promise会返回后端响应发回的数据。

我们必须更新App组件以配合我们对模块的更改。我们必须更改传给noteService对象方法的参数的回调函数,让它们直接使用返回的响应数据:

const App = () => {
  // ...

  useEffect(() => {
    noteService
      .getAll()
      .then(initialNotes => {        setNotes(initialNotes)      })
  }, [])

  const toggleImportanceOf = id => {
    const note = notes.find(n => n.id === id)
    const changedNote = { ...note, important: !note.important }

    noteService
      .update(id, changedNote)
      .then(returnedNote => {        setNotes(notes.map(note => note.id === id ? returnedNote : note))      })
  }

  const addNote = (event) => {
    event.preventDefault()
    const noteObject = {
      content: newNote,
      important: Math.random() > 0.5
    }

    noteService
      .create(noteObject)
      .then(returnedNote => {        setNotes(notes.concat(returnedNote))        setNewNote('')
      })
  }

  // ...
}

这一切都很复杂,试图解释它可能只会让它更难理解。互联网上有很多讨论这个话题的资料,比如个。

You do not know JS丛书中的《Async and performance》一书很好地解释了这个话题,但解释的篇幅很长。

Promise是现代JavaScript开发的核心,强烈建议投入一定时间来理解它们。

更清晰地定义对象字面量的语法

定义笔记相关服务的模块目前导出了一个对象,其属性getAllcreateupdate被赋值了处理笔记的函数。

模块的定义是:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = newObject => {
  const request = axios.post(baseUrl, newObject)
  return request.then(response => response.data)
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default {
  getAll: getAll,
  create: create,
  update: update
}

该模块导出了下面看起来相当奇怪的对象:

{
  getAll: getAll,
  create: create,
  update: update
}

对象定义中,冒号左边的标签是对象的,而冒号右边的是模块中定义的变量

由于键名和赋值的变量名是一样的,我们可以使用更紧凑的语法来定义对象:

{
  getAll,
  create,
  update
}

于是,模块的定义被简化为:

import axios from 'axios'
const baseUrl = 'http://localhost:3001/notes'

const getAll = () => {
  const request = axios.get(baseUrl)
  return request.then(response => response.data)
}

const create = newObject => {
  const request = axios.post(baseUrl, newObject)
  return request.then(response => response.data)
}

const update = (id, newObject) => {
  const request = axios.put(`${baseUrl}/${id}`, newObject)
  return request.then(response => response.data)
}

export default { getAll, create, update }

在使用这种更简洁的符号定义对象时,我们利用了在ES6中引入到JavaScript中的新特性,它让使用变量定义对象的方式稍微更紧凑了一些。

为了演示这个特性,让我们考虑这种情况,我们给变量赋了以下的值:

const name = 'Leevi'
const age = 0

在旧版本的JavaScript中,我们必须这样定义一个对象:

const person = {
  name: name,
  age: age
}

然而,由于对象中的属性字段名和变量名都是一样的,所以在ES6 JavaScript中,只需这么写就够了:

const person = { name, age }

两个表达式的结果都是一样的。它们都创建了一个对象,其name属性值为Leeviage属性值为0

Promise和错误

如果我们的应用允许用户删除笔记,我们可能会出现这种情况:用户试图改变一条笔记的重要性,而这条笔记在系统中已经被删除了。

让我们模拟这种情况,让笔记服务的getAll函数返回一条“硬编码”的笔记,而这条笔记实际上并不存在于后端服务端上。

const getAll = () => {
  const request = axios.get(baseUrl)
  const nonExisting = {
    id: 10000,
    content: 'This note is not saved to server',
    important: true,
  }
  return request.then(response => response.data.concat(nonExisting))
}

当我们试图改变这条硬编码笔记的重要性时,我们在控制台中看到了以下错误信息。该错误说后端服务端对我们的HTTP PUT请求的响应是状态代码404 not found

fullstack content

应用应当能优雅地处理这些类型的错误情况。用户无法得知发生了错误,除非他们碰巧打开了他们的控制台。在应用中可以看到错误的唯一方法是,点击按钮没有影响笔记的重要性。

我们之前提到,一个Promise可能有三种不同的状态。当一个axios HTTP请求失败时,相关的Promise被拒绝。我们目前的代码没有以任何方式处理被拒绝的情况。

Promise被拒绝的情况是由为then方法提供的第二个回调函数来处理的,第二个回调函数在Promise被拒绝的情况下被调用。

为被拒绝的Promise添加处理函数的更常见的方式是使用catch方法。

在实践中,被拒绝的Promise的错误处理函数是像这样定义的:

axios
  .get('http://example.com/probably_will_fail')
  .then(response => {
    console.log('success!')
  })
  .catch(error => {
    console.log('fail')
  })

如果请求失败,catch方法注册的事件处理函数就会被调用。

catch方法经常用于放在Promise链中的更深处。

当将多个.then方法链接在一起时,我们实际上是在创建一个Promise链

axios
  .get('http://...')
  .then(response => response.data)
  .then(data => {
    // ...
  })

catch方法可以用来在Promise链的最后定义一个处理函数,一旦Promise链中的任何一个Promise抛出错误,整个Promise被拒绝时,就会调用catch方法。

axios
  .get('http://...')
  .then(response => response.data)
  .then(data => {
    // ...
  })
  .catch(error => {
    console.log('fail')
  })

让我们利用这个功能。我们将把我们应用的错误处理函数放在App组件中:

const toggleImportanceOf = id => {
  const note = notes.find(n => n.id === id)
  const changedNote = { ...note, important: !note.important }

  noteService
    .update(id, changedNote).then(returnedNote => {
      setNotes(notes.map(note => note.id === id ? returnedNote : note))
    })
    .catch(error => {      alert(        `the note '${note.content}' was already deleted from server`      )      setNotes(notes.filter(n => n.id !== id))    })}

错误信息会通过久经考验的alert对话框弹窗显示给用户,并且删除的笔记会从状态中筛除。

从应用的状态中删除一条已经删除的笔记是通过数组的filter方法完成的,它返回一个新数组,该数组只包括列表中,作为参数传递的函数返回true的项:

notes.filter(n => n.id !== id)

在更严肃的React应用中,使用alert很可能不是一个好主意。我们很快就会学到一种向用户显示消息和通知的更高级的方法。然而在有些情况下,像alert这样简单的、经过实践检验的方法可以作为一个起点。在时间和精力允许的情况下,总是可以在以后添加更高级的方法。

我们应用当前状态的代码可以在GitHubpart2-6分支中找到。

全栈开发者誓言

又到了练习的时间了。我们应用的复杂性正在增加,因为除了处理前端的React组件之外,我们还有一个后端来持久保存应用的数据。

为了应对日益增加的复杂性,我们应该将Web开发者誓言扩展为全栈开发者誓言,提醒我们确保前后端之间的通信如预期进行。

下面是更新后的誓言:

全栈开发非常难,所以我要尽一切可能方法让它变得更容易

  • 我会始终打开浏览器开发者控制台

  • 我会用浏览器开发工具的网络标签页确保前后端的通信如我所预期

  • 我会时刻关注服务端的状态,确保前端发送的数据如我所预期地保存在服务端

  • 我会小步前进

  • 我会写大量console.log语句来确保我理解代码的行为,并借助其定位问题

  • 如果我的代码无法运行,我不会写更多的代码。相反,我会开始删除代码,直到它起作用,或者直接回到一切正常的状态

  • 当我在课程的Discord频道或其他地方寻求帮助时,我会正确地表述我的问题,参见这里了解如何提问