跳到内容

c

组件状态,事件处理

让我们回到使用React的工作上来。

我们从一个新的例子开始:

const Hello = (props) => {
  return (
    <div>
      <p>
        Hello {props.name}, you are {props.age} years old
      </p>
    </div>
  )
}

const App = () => {
  const name = 'Peter'
  const age = 10

  return (
    <div>
      <h1>Greetings</h1>
      <Hello name="Maya" age={26 + 10} />
      <Hello name={name} age={age} />
    </div>
  )
}

组件的辅助函数

让我们扩展我们的Hello组件,让它猜测被问候者的出生年份:

const Hello = (props) => {
  const bornYear = () => {    const yearNow = new Date().getFullYear()    return yearNow - props.age  }
  return (
    <div>
      <p>
        Hello {props.name}, you are {props.age} years old
      </p>
      <p>So you were probably born in {bornYear()}</p>    </div>
  )
}

猜测出生年份的逻辑被包裹在自己的函数中,并在组件渲染时被调用。

人的年龄无需显式作为参数传给函数,因为函数可以直接访问传给组件的所有props。

如果我们仔细检查我们当前的代码,我们会注意到这个辅助函数实际上是定义在另一个定义我们组件行为的函数里面。在Java编程中,在一个函数中定义另一个函数比较复杂,所以不是那么常见。然而,在JavaScript中,在函数中定义函数是一种常规操作。

解构

在我们继续前进之前,我们将看一下JavaScript语言在ES6规范中添加的一个微小但有用的功能,它允许我们在赋值时从对象和数组中解构取值。

在我们之前的代码中,我们必须将传递给我们组件的数据引用为props.nameprops.age。在这两个表达式中,我们不得不在代码中重复props.age两次。

由于props是一个对象

props = {
  name: 'Arto Hellas',
  age: 35,
}

我们可以通过将属性值直接赋值给两个变量nameage来简化我们的组件,从而我们可以在代码中使用这两个变量:

const Hello = (props) => {
  const name = props.name  const age = props.age
  const bornYear = () => new Date().getFullYear() - age

  return (
    <div>
      <p>Hello {name}, you are {age} years old</p>      <p>So you were probably born in {bornYear()}</p>
    </div>
  )
}

注意,在定义bornYear函数时,我们也利用了箭头函数的更紧凑的语法。如前所述,如果一个箭头函数由单个表达式组成,那么函数体就不需要写在大括号里。在这种更紧凑的形式下,函数只是返回单个表达式的结果。

简而言之,下面显示的两个函数定义是等价的。

const bornYear = () => new Date().getFullYear() - age

const bornYear = () => {
  return new Date().getFullYear() - age
}

解构使得变量的赋值更加容易,因为我们可以用它来提取和收集一个对象的属性值到专门的变量中。

const Hello = (props) => {
  const { name, age } = props  const bornYear = () => new Date().getFullYear() - age

  return (
    <div>
      <p>Hello {name}, you are {age} years old</p>
      <p>So you were probably born in {bornYear()}</p>
    </div>
  )
}

当我们要解构的对象有以下值时

props = {
  name: 'Arto Hellas',
  age: 35,
}

表达式const { name, age } = props将值'Arto Hellas'赋给name,将35赋给age

我们可以进一步解构:

const Hello = ({ name, age }) => {  const bornYear = () => new Date().getFullYear() - age

  return (
    <div>
      <p>
        Hello {name}, you are {age} years old
      </p>
      <p>So you were probably born in {bornYear()}</p>
    </div>
  )
}

传递给组件的props现在被直接解构为变量nameage

这意味着我们不是把整个props对象赋值给一个叫做props的变量中,然后再把它的属性赋值给变量nameage

const Hello = (props) => {
  const { name, age } = props

而是通过对作为参数传递给组件函数的props对象进行解构,直接将属性值赋值给变量:

const Hello = ({ name, age }) => {

页面的重新渲染

到目前为止,我们所有的应用都是静态的——在最初的渲染之后,其外观保持不变。如果我们想创建一个计数器,其值随着时间的推移或点击按钮而增加呢?

让我们从将文件App.js变成下面的样子开始:

const App = (props) => {
  const {counter} = props
  return (
    <div>{counter}</div>
  )
}

export default App

将文件index.js改成:

import ReactDOM from 'react-dom/client'

import App from './App'

let counter = 1

ReactDOM.createRoot(document.getElementById('root')).render(
  <App counter={counter} />
)

App组件通过counter props得到了计数器的值。这个组件将该值渲染到屏幕上。当counter的值改变时,会发生什么?即使我们加入以下内容

counter += 1

组件也不会重新渲染。我们可以通过再次调用render方法来重新渲染组件,例如:

let counter = 1

const root = ReactDOM.createRoot(document.getElementById('root'))

const refresh = () => {
  root.render(
    <App counter={counter} />
  )
}

refresh()
counter += 1
refresh()
counter += 1
refresh()

重新渲染的命令已经被包裹在refresh函数中,以减少复制粘贴的代码量。

现在这个组件渲染了三次,首先是数值1,然后是2,最后是3。 然而,数值1和2在屏幕上显示的时间非常短,以至于它们无法被注意到。

我们可以通过使用setInterval来实现更有趣的功能,每秒钟重新渲染并增加计数器。

setInterval(() => {
  refresh()
  counter += 1
}, 1000)

重复调用render方法并不是重新渲染组件的推荐方式。接下来,我们将介绍实现这一效果的更好的方法。

带状态的组件

到目前为止,我们所有的组件都是简单的,它们所有的状态在组件生命周期中都不会发生变化。

接下来,让我们借助React的状态hook来给我们的应用的App组件添加状态。

我们将改变应用的内容如下。main.js回到:

import ReactDOM from 'react-dom/client'

import App from './App'

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

App.js则改为:

import { useState } from 'react'
const App = () => {
  const [ counter, setCounter ] = useState(0)
  setTimeout(    () => setCounter(counter + 1),    1000  )
  return (
    <div>{counter}</div>
  )
}

export default App

在第一行,文件导入了useState函数。

import { useState } from 'react'

该组件的函数体以函数调用开始:

const [ counter, setCounter ] = useState(0)

该函数调用将状态添加到组件中,并将其初始化为0。该函数返回一个包含两个项目的数组。我们通过使用之前提到过的解构赋值语法将这些项赋值给变量countersetCounter

counter变量被赋了状态的初始值,即0。变量setCounter被赋了一个用来修改状态的函数。

应用调用setTimeout函数并传递给它两个参数:一个用于增加计数器的状态的函数,和一个一秒钟的定时。

setTimeout(
  () => setCounter(counter + 1),
  1000
)

作为第一个参数传递给setTimeout函数的函数在调用setTimeout函数一秒钟后被调用

() => setCounter(counter + 1)

当修改状态的函数setCounter被调用时,React重新渲染组件,这意味着重新执行组件函数的函数体:

() => {
  const [ counter, setCounter ] = useState(0)

  setTimeout(
    () => setCounter(counter + 1),
    1000
  )

  return (
    <div>{counter}</div>
  )
}

第二次执行组件函数时,它会调用useState函数并返回状态的新值:1。再次执行函数体的时候,也会再一次调用setTimeout,等待一秒钟后再次增加状态counter的值。因为变量counter的值是1,将值增加1等价于将counter的值设为2。

() => setCounter(2)

同时,counter的旧值——“1”——被渲染在屏幕上。

每次setCounter修改状态都会导致组件被重新渲染。一秒钟后,状态的值将被再次增加,只要应用在运行,这个过程就会一直重复下去。

如果组件在你认为应该渲染的时候没有渲染,或者在“错误的时间”渲染,你可以通过将组件的变量值记录到控制台来调试应用。如果我们对我们的代码做如下补充:

const App = () => {
  const [ counter, setCounter ] = useState(0)

  setTimeout(
    () => setCounter(counter + 1),
    1000
  )

  console.log('rendering...', counter)
  return (
    <div>{counter}</div>
  )
}

就能轻易跟踪对App组件的渲染函数的调用:

fullstack content

浏览器的控制台开了吗?如果没开,保证这是你最后一次需要提醒了。

事件处理

我们在第0章节中已经提到了事件处理函数,也就是注册到程序中,让程序在特定事件发生时调用的函数。例如,用户与网页中不同元素的交互会触发一系列不同类型的事件。

让我们改变应用,让计数器在用户点击按钮时增加,这是用button元素实现的。

button元素支持所谓的鼠标事件,其中点击是最常见的一种。尽管名字叫鼠标事件,它也可以通过键盘或触摸屏来触发。

在React中,为点击事件注册一个事件处理函数是这样的:

const App = () => {
  const [ counter, setCounter ] = useState(0)

  const handleClick = () => {    console.log('clicked')  }
  return (
    <div>
      <div>{counter}</div>
      <button onClick={handleClick}>        plus      </button>    </div>
  )
}

我们将按钮的onClick属性的值设为对代码中定义的handleClick函数的引用。

现在每次点击plus按钮都会调用handleClick函数,这意味着每次点击事件都会向浏览器控制台记录一条clicked消息。

事件处理函数也可以直接在onClick属性的赋值中定义:

const App = () => {
  const [ counter, setCounter ] = useState(0)

  return (
    <div>
      <div>{counter}</div>
      <button onClick={() => console.log('clicked')}>        plus
      </button>
    </div>
  )
}

通过改变事件处理函数为以下形式

<button onClick={() => setCounter(counter + 1)}>
  plus
</button>

我们实现了期望的行为,也就是说,counter的值增加了1并且组件被重新渲染了。

我们还可以添加一个按钮来重置计数器:

const App = () => {
  const [ counter, setCounter ] = useState(0)

  return (
    <div>
      <div>{counter}</div>
      <button onClick={() => setCounter(counter + 1)}>
        plus
      </button>
      <button onClick={() => setCounter(0)}>        zero      </button>    </div>
  )
}

我们的应用现在已经准备好了!

事件处理函数是一个函数

我们为在声明我们的按钮的onClick属性的地方定义事件处理函数:

<button onClick={() => setCounter(counter + 1)}>
  plus
</button>

如果我们试图以更简单的形式来定义事件处理函数呢?

<button onClick={setCounter(counter + 1)}>
  plus
</button>

这将完全破坏我们的应用:

fullstack content

这是怎么回事?一个事件处理函数应该是一个函数或者一个函数引用,而当我们写:

<button onClick={setCounter(counter + 1)}>

事件处理函数实际上是一个函数调用。在很多情况下这是可以的,但在这种特殊情况下就不行了。在一开始,counter变量的值是0。当React第一次渲染组件时,它执行了函数调用setCounter(0+1),并将组件的状态值改为1。

这将导致该组件被重新渲染,React将再次执行setCounter函数调用,然后状态将改变,导致再次重新渲染……

让我们像之前那样定义事件处理函数:

<button onClick={() => setCounter(counter + 1)}>
  plus
</button>

现在按钮的属性定义了当按钮被点击时发生的事情 —— onClick —— 其值为 () => setCounter(counter + 1)

只有当用户点击按钮时,才会调用setCounter函数。

通常在JSX模板中定义事件处理函数并不是一个好主意。

这里可以,因为我们的事件处理函数非常简单。

无论如何,让我们把事件处理函数分离成专门的函数:

const App = () => {
  const [ counter, setCounter ] = useState(0)

  const increaseByOne = () => setCounter(counter + 1)  const setToZero = () => setCounter(0)
  return (
    <div>
      <div>{counter}</div>
      <button onClick={increaseByOne}>        plus
      </button>
      <button onClick={setToZero}>        zero
      </button>
    </div>
  )
}

在这里,事件处理函数已经被正确定义。onClick属性的值是一个包含对一个函数的引用的变量:

<button onClick={increaseByOne}>
  plus
</button>

向子组件传递状态

建议编写的React组件要小,并且可以在整个应用甚至项目中重用。让我们重构我们的应用,使其由三个小的组件组成,一个组件用于显示计数器,两个组件用于按钮。

让我们首先实现一个Display组件,负责显示计数器的值。

React的一个最佳做法是在组件结构中提升状态。文档中说:

通常,几个组件需要反映相同的变化数据。我们建议将共享状态提升到它们最接近的共同祖先。

所以让我们把应用的状态放在App组件中,并通过props把它传递给Display组件:

const Display = (props) => {
  return (
    <div>{props.counter}</div>
  )
}

组件的使用很简单,因为我们只需要将counter的状态传递给它:

const App = () => {
  const [ counter, setCounter ] = useState(0)

  const increaseByOne = () => setCounter(counter + 1)
  const setToZero = () => setCounter(0)

  return (
    <div>
      <Display counter={counter}/>      <button onClick={increaseByOne}>
        plus
      </button>
      <button onClick={setToZero}>
        zero
      </button>
    </div>
  )
}

一切仍在运作。当按钮被点击,App被重新渲染时,它所有的子节点包括Display组件也被重新渲染。

接下来,让我们为我们应用的按钮制作一个Button组件。我们必须通过组件的props来传递事件处理函数以及按钮的标题。

const Button = (props) => {
  return (
    <button onClick={props.onClick}>
      {props.text}
    </button>
  )
}

我们的App组件现在是这样:

const App = () => {
  const [ counter, setCounter ] = useState(0)

  const increaseByOne = () => setCounter(counter + 1)
  const decreaseByOne = () => setCounter(counter - 1)  const setToZero = () => setCounter(0)

  return (
    <div>
      <Display counter={counter}/>
      <Button        onClick={increaseByOne}        text='plus'      />      <Button        onClick={setToZero}        text='zero'      />      <Button        onClick={decreaseByOne}        text='minus'      />    </div>
  )
}

既然我们现在有了一个易于重用的按钮组件,我们也在我们的应用中实现了新的功能,增加了一个可以用来减少计数器值的按钮。

事件处理函数是通过onClick props传递给Button组件的。在构建自己的组件时,理论上你可以随意命名props的名称。但我们为事件处理函数的命名并不是随便选的。

React的官方教程是这样建议的:

“在React中,通常使用onSomething命名代表事件的props,使用handleSomething命名处理这些事件的函数。”

状态的改变会导致重新渲染

让我们再次回顾一下应用运行的主要原理。

当应用启动时,App中的代码被执行。这段代码使用一个useState hook来创建应用的状态,设置变量counter的初始值。

这个组件包含Display组件——用来显示计数器的值,0——和三个Button组件。所有按钮都有事件处理函数,用来改变计数器的状态。

当其中一个按钮被点击时,事件处理函数被执行。该事件处理函数通过setCounter函数改变App组件的状态。

调用一个改变状态的函数会导致组件重新渲染。

所以,如果用户点击了plus按钮,按钮的事件处理函数将counter的值改为1,然后App组件被重新渲染。

这会导致其子组件DisplayButton也被重新渲染。

Display接收计数器的新值,1,作为props。Button组件接收事件处理函数,用来改变计数器的状态。

为了更好地理解程序是怎么运行的,让我们向其中添加一些console.log语句

const App = () => {
  const [counter, setCounter] = useState(0)
  console.log('rendering with counter value', counter)
  const increaseByOne = () => {
    console.log('increasing, value before', counter)    setCounter(counter + 1)
  }

  const decreaseByOne = () => { 
    console.log('decreasing, value before', counter)    setCounter(counter - 1)
  }

  const setToZero = () => {
    console.log('resetting to zero, value before', counter)    setCounter(0)
  }

  return (
    <div>
      <Display counter={counter} />
      <Button onClick={increaseByOne} text="plus" />
      <Button onClick={setToZero} text="zero" />
      <Button onClick={decreaseByOne} text="minus" />
    </div>
  )
} 

让我们看看,当按下plus、zero和minus按钮时,控制台里显示了什么:

fullstack content

永远不要去猜你的代码做了什么。用console.log,然后用你自己的眼睛去看它做了些什么总是更好。

重构组件

显示计数器值的组件如下:

const Display = (props) => {
  return (
    <div>{props.counter}</div>
  )
}

该组件只使用其propscounter字段。

这意味着我们可以通过使用解构来简化组件,像这样:

const Display = ({ counter }) => {
  return (
    <div>{counter}</div>
  )
}

定义该组件的函数只包含返回语句,所以我们可以用箭头函数更紧凑的形式来定义这个函数:

const Display = ({ counter }) => <div>{counter}</div>

我们也可以简化Button组件。

const Button = (props) => {
  return (
    <button onClick={props.onClick}>
      {props.text}
    </button>
  )
}

我们可以使用解构来只取props中所需的字段,并使用更紧凑的箭头函数形式:

const Button = ({ onClick, text }) => <button onClick={onClick}>{text}</button>

这样处理也能运行,因为组件只包含一条return语句,所以可以使用更简洁的箭头函数语法。