跳到内容

d

在服务端将数据Alert出来

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

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

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

我们将在课程的下一部分中仔细研究REST。但是,在这一点上,我们有必要熟悉json-server和REST APIs一般使用的一些约定。特别是,我们将看看REST中路由的常规使用,也就是URL和HTTP请求类型。

REST

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

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

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

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

Sending Data to the Server

让我们对负责创建一个新笔记的事件处理程序做如下修改。

addNote = event => {
  event.preventDefault()
  const noteObject = {
    content: newNote,
    date: new Date(),
    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章节的开头就大量使用了这个标签。

fullstack content

我们可以使用检查器来检查POST请求中发送的头信息是否是我们所期望的,以及它们的值是否正确。

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

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

addNote = event => {
  event.preventDefault()
  const noteObject = {
    content: newNote,
    date: new Date(),
    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的状态已经足够满足我们目前的需要。

NB:在我们应用的当前版本中,浏览器将创建日期属性添加到注释中。由于运行浏览器的机器的时钟可能被错误地配置,让后端服务器为我们生成这个时间戳是非常明智的。事实上,这就是我们在课程的下一部分要做的。

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

Changing the Importance of Notes

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

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

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

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

我们给该组件添加一个按钮,并将其事件处理程序指定为组件prop中传递的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>
  )
}

注意每个笔记都收到它自己的unique事件处理函数,因为每个笔记的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 ? note : response.data))
  })
}

函数主体中几乎每一行代码都包含重要的细节。第一行定义了基于每个注释资源的id的唯一url。

数组查找方法被用来查找我们要修改的笔记,然后我们把它分配给note变量。

在这之后,我们创建一个新对象,除了重要的属性之外,它是旧笔记的完全拷贝。

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

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

实际上,{ ...note }创建了一个新的对象,并复制了note对象的所有属性。当我们在传播对象后面的大括号内添加属性时,例如{ ...note, important: true },那么新对象的important属性的值将是true。在我们的例子中,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 ? note : response.data))
})

这是用map方法完成的。

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

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

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

Extracting Communication with the Backend into a Separate Module

在添加了与后端服务器通信的代码后,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 ? note : response.data))      })  }

  const addNote = (event) => {
    event.preventDefault()
    const noteObject = {
      content: newNote,
      date: new Date().toISOString(),
      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方法的参数以直接返回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 ? note : returnedNote))      })
  }

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

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

  // ...
}

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

你不懂JS系列书籍中的 "异步和性能 "一书很好地解释了这个话题,但解释的篇幅很多。

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

Cleaner Syntax for Defining Object Literals

定义笔记相关服务的模块目前导出了一个对象,其属性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

Promises and Errors

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

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

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

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

fullstack content

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

我们之前提到,一个 promise 可以处于三种不同的状态之一。当一个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 链的更深处。

当我们的应用发出一个HTTP请求时,我们实际上是在创建一个 promise 链

axios
  .put(`${baseUrl}/${id}`, newObject)
  .then(response => response.data)
  .then(changedNote => {
    // ...
  })

catch方法可以用来在 promise 链的末端定义一个处理函数,一旦 promise 链中的任何一个 promise 抛出错误, promise 就会被调用,成为拒绝

axios
  .put(`${baseUrl}/${id}`, newObject)
  .then(response => response.data)
  .then(changedNote => {
    // ...
  })
  .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 ? note : returnedNote))
    })
    .catch(error => {      alert(        `the note '${note.content}' was already deleted from server`      )      setNotes(notes.filter(n => n.id !== id))    })}

错误信息会通过可靠的老式警报对话框弹出显示给用户,并且删除的笔记会从状态中被过滤掉。

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

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

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

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