跳到内容

e

给React应用加点样式

我们目前的笔记应用的外观是相当简陋的。在练习0.2中,作业是阅读Mozilla的CSS教程

让我们看一下如何在React应用中添加样式。有几种不同的方法,我们将在后面看一下其他的方法。首先,我们将以传统的方式向我们的应用添加CSS;把CSS写入单个文件中,不使用CSS预处理器(尽管我们将在后面学习到,实际上我们并非完全没有使用)。

让我们在src目录下添加一个新的index.css文件,然后在main.jsx文件中导入它来将其添加到应用:

import './index.css'

让我们在index.css文件中添加以下CSS规则:

h1 {
  color: green;
}

CSS规则包括选择器声明。选择器定义了规则应该应用于哪些元素。上面的选择器是h1,因此选择器将匹配我们应用中所有h1标题的标签。

声明将color属性设为值green

一条CSS规则可以包含任意数量的属性。让我们修改前面的规则,通过定义字体样式为italic,把文字变成草体。

h1 {
  color: green;
  font-style: italic;}

通过使用不同类型的CSS选择器,可以实现许多匹配元素的方法。

如果我们想把我们的样式应用于,比方说,每一条笔记,我们可以使用选择器li,因为所有的笔记都被包裹在li标签里:

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

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

让我们在我们的样式表中加入以下规则(因为我对优雅的网页设计的知识接近于零,所以这些样式并没有什么意义):

li {
  color: grey;
  padding-top: 3px;
  font-size: 15px;
}

使用元素类型来定义CSS规则是有点问题的。如果我们的应用包含其他的li标签,它们也会应用同样的样式规则。

如果我们想把我们的样式专门应用于笔记,那么最好使用类选择器

在通常的HTML中,类的定义是class属性的值:

<li class="note">some text...</li>

在React中,我们必须使用className属性而不是class属性。考虑到这一点,让我们对我们的Note组件做如下修改:

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

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

类选择器是用.classname语法定义的:

.note {
  color: grey;
  padding-top: 5px;
  font-size: 15px;
}

现在如果你在应用中添加其他li元素,它们就不会受上述样式规则的影响了。

改进错误信息

我们之前用alert方法实现了当用户试图切换已删除笔记的重要性时显示的错误信息。让我们把这个错误信息实现为它自己的React组件。

组件很简单:

const Notification = ({ message }) => {
  if (message === null) {
    return null
  }

  return (
    <div className='error'>
      {message}
    </div>
  )
}

如果message props的值是null,那么就不会在屏幕上渲染信息,而在其他情况下,会把信息渲染到一个div元素中。

让我们向App组件中添加一个叫做errorMessage的新状态片段。让我们用一些错误信息来初始化它,这样我们就可以立即测试我们的组件:

const App = () => {
  const [notes, setNotes] = useState([])
  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)
  const [errorMessage, setErrorMessage] = useState('some error happened...')
  // ...

  return (
    <div>
      <h1>Notes</h1>
      <Notification message={errorMessage} />      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all' }
        </button>
      </div>
      // ...
    </div>
  )
}

然后让我们添加一个适合错误信息的样式规则:

.error {
  color: red;
  background: lightgrey;
  font-size: 20px;
  border-style: solid;
  border-radius: 5px;
  padding: 10px;
  margin-bottom: 10px;
}

现在我们准备添加显示错误信息的逻辑。让我们将toggleImportanceOf函数改成:

  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 => {
        setErrorMessage(          `Note '${note.content}' was already removed from server`        )        setTimeout(() => {          setErrorMessage(null)        }, 5000)        setNotes(notes.filter(n => n.id !== id))
      })
  }

当发生错误时,我们把错误信息的描述添加到errorMessage状态中。同时,我们启动一个定时器,在五秒后将errorMessage状态设为null

结果看起来是这样的:

fullstack content

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

内联样式

在React中也可以直接在代码中编写样式,即所谓的内联样式

定义内联样式的想法非常简单。可以将一组CSS属性作为JavaScript对象通过style属性提供给任何React组件或元素。

JavaScript中的CSS规则定义与普通的CSS文件略有不同。比方说,我们想给某个元素加上绿色和斜体。CSS中会是这样的:

{
  color: green;
  font-style: italic;
}

但定义成React内联样式的对象是这样的:

{
  color: 'green',
  fontStyle: 'italic'
}

每个CSS属性都被定义为JavaScript对象的一个单独的属性。像素的数字值可以简单地用整数定义。和普通CSS相比的一个主要区别是,CSS属性是用连字符-连接的(烤串命名法),JavaScript对象的属性是用驼峰式命名法(camelCase)的。

让我们向我们的应用中添加一个脚注组件,Footer,并为其定义内联样式。组件像下面定义在文件components/Footer.jsx中,并在App.jsx文件中使用:

const Footer = () => {
  const footerStyle = {
    color: 'green',
    fontStyle: 'italic'
  }

  return (
    <div style={footerStyle}>
      <br />
      <p>
        Note app, Department of Computer Science, University of Helsinki 2025
      </p>
    </div>
  )
}

export default Footer
import { useState, useEffect } from 'react'
import Footer from './components/Footer'import Note from './components/Note'
import Notification from './components/Notification'
import noteService from './services/notes'

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

  return (
    <div>
      <h1>Notes</h1>

      <Notification message={errorMessage} />

      // ...

      <Footer />    </div>
  )
}

内联样式有某些限制。例如,不能直接使用所谓的伪类

内联样式以及一些其他为React组件添加样式的方法完全违背了旧的惯例。传统上,人们认为最好的做法是将CSS与内容(HTML)和功能(JavaScript)完全分开。根据这一传统的思想,我们要将CSS、HTML和JavaScript分开写成单独的文件。

React的哲学事实上与此截然相反。由于将CSS、HTML和JavaScript分离到不同的文件中,似乎会使大型应用不能很好地扩展,所以React将应用的划分建立在其逻辑功能实体的基础上。

构成应用功能实体的结构单元是React组件。一个React组件定义了构造内容的HTML,决定功能的JavaScript函数,以及组件的样式;所有内容都在一个地方定义。这是为了创建尽可能独立和可重复使用的单个组件。

我们应用的最终版本的代码可以在GitHub上的part2-8分支中找到。

一些重要的注意事项

在本部分的最后,有一些更具挑战性的练习。如果这些练习让你感到头疼,可以先跳过,我们后面会再次回到这些主题。无论如何,这部分的材料都值得阅读。

在我们的应用中,我们做的一件事掩盖了一个非常典型的错误来源。

我们将状态notes的初始值设为一个空数组:

const App = () => {
  const [notes, setNotes] = useState([])

  // ...
}

这是一个非常自然的初始值,因为notes是一组笔记,也就是说,状态将存储许多笔记。

如果状态只保存“一个东西”,更合适的初始值是 null,表示一开始状态中什么都没有。让我们看看如果使用null为初始值会发生什么:

const App = () => {
  const [notes, setNotes] = useState(null)
  // ...
}

应用崩溃了:

fullstack content

错误信息给出了错误的原因和位置。导致问题的代码如下:

  // notesToShow gets the value of notes
  const notesToShow = showAll
    ? notes
    : notes.filter(note => note.important)

  // ...

  {notesToShow.map(note =>    <Note key={note.id} note={note} />
  )}

错误信息是

Cannot read properties of null (reading 'map')

变量notesToShow首先被赋值为状态notes的值,然后代码尝试对一个不存在的对象,也就是null,调用map方法。

这是什么原因呢?

Effect Hook使用函数setNotesnotes设为后端返回的笔记:

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

然而,问题在于Effect只在第一次渲染之后执行。

并且因为notes的初始值是null:

const App = () => {
  const [notes, setNotes] = useState(null)
  // ...

在第一次渲染时,以下代码被执行:

notesToShow = notes

// ...

notesToShow.map(note => ...)

这导致应用崩溃,因为我们不能对null值调用map方法。

当我们将notes的初始值设为空数组时,就不会出现错误,因为空数组是可以调用map的。

因此,状态的初始化“掩盖”了由于数据尚未从后端获取而导致的问题。

另一种解决问题的方法是使用条件渲染,如果组件状态未被正确初始化,则返回null:

const App = () => {
  const [notes, setNotes] = useState(null)  // ...

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

  // do not render anything if notes is still null
  if (!notes) {    return null  }
  // ...
}

因此,在第一次渲染时,不会渲染任何内容。当笔记从后端到达时,Effect使用函数setNotes来设状态notes的值。这会导致组件再次渲染,于是在第二次渲染时,笔记会被渲染到屏幕上。

基于条件渲染的方法适用于无法定义状态以致无法首次渲染的情况。

另一件我们还需要进一步观察的事情是useEffect的第二个参数:

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

useEffect的第二个参数用于指定Effect的运行频率。原则是Effect总是在组件第一次渲染后以及第二个参数的值发生变化时执行。

如果第二个参数是一个空数组[],它的内容永远不会改变,于是Effect只会在组件第一次渲染后运行。这正是我们在从服务端初始化应用状态时所需要的。

然而,有些情况下我们还希望在其他时候执行 Effect,例如当组件的状态以特定方式发生变化时。

考虑以下用于从Exchange rate API查询货币汇率的简单应用:

import { useState, useEffect } from 'react'
import axios from 'axios'

const App = () => {
  const [value, setValue] = useState('')
  const [rates, setRates] = useState({})
  const [currency, setCurrency] = useState(null)

  useEffect(() => {
    console.log('effect run, currency is now', currency)

    // skip if currency is not defined
    if (currency) {
      console.log('fetching exchange rates...')
      axios
        .get(`https://open.er-api.com/v6/latest/${currency}`)
        .then(response => {
          setRates(response.data.rates)
        })
    }
  }, [currency])

  const handleChange = (event) => {
    setValue(event.target.value)
  }

  const onSearch = (event) => {
    event.preventDefault()
    setCurrency(value)
  }

  return (
    <div>
      <form onSubmit={onSearch}>
        currency: <input value={value} onChange={handleChange} />
        <button type="submit">exchange rate</button>
      </form>
      <pre>
        {JSON.stringify(rates, null, 2)}
      </pre>
    </div>
  )
}

export default App

该应用的用户界面有一个表单,用户可以在输入框中输入想要查询的货币的名称。如果货币存在,应用会渲染该货币和其他货币的汇率:

fullstack content

在按下按钮时,应用将表单中输入的货币名称设为状态currency

currency得到新值时,应用会在Effect函数中从API获取其汇率:

const App = () => {
  // ...
  const [currency, setCurrency] = useState(null)

  useEffect(() => {
    console.log('effect run, currency is now', currency)

    // skip if currency is not defined
    if (currency) {
      console.log('fetching exchange rates...')
      axios
        .get(`https://open.er-api.com/v6/latest/${currency}`)
        .then(response => {
          setRates(response.data.rates)
        })
    }
  }, [currency])  // ...
}

现在useEffect Hook的第二个参数是[currency]。因此,Effect函数会在第一次渲染后执行,并且总是在表发生变化,也就是函数的第二个参数[currency]的值发生变化时执行。也就是说,每当状态currency获得新值时,表的内容发生变化,Effect函数被执行。

选择null作为变量currency的初始值是很自然的,因为currency只代表一件物品。初始值null表示状态中尚无任何内容,并且可以简单通过if语句检查变量是否已被赋值。Effect有以下条件:

if (currency) {
  // exchange rates are fetched
}

这可以防止首次渲染后在变量currency仍然是初始值null的时候立即请求汇率。

所以如果用户在搜索框中输入例如eur,应用会使用axios向地址https://open.er-api.com/v6/latest/eur发送HTTP GET请求,并将响应存储在rates状态中。

当用户随后在搜索框中输入另一个值,例如usd,Effect函数会再次执行,并通过API请求新货币的汇率。

这里展示的进行API请求的方法可能看起来有点不方便。

这个特定的应用完全可以不使用useEffect,而直接在表单的提交处理函数中进行API请求:

  const onSearch = (event) => {
    event.preventDefault()
    axios
      .get(`https://open.er-api.com/v6/latest/${value}`)
      .then(response => {
        setRates(response.data.rates)
      })
  }

然而在有些情况下,这种方法是行不通的。例如,你可能会在练习2.20中遇到这种行不通的情况,此时可以使用useEffect来解决。注意这在很大程度上取决于你选择的方法,例如,model solution就没有使用这一技巧。