d

深入React 应用调试

Complex state

【复杂状态】

在之前的示例中,应用状态很简单,因为它仅由单个整数组成。 如果我们的应用需要一个更复杂的状态怎么办?

在大多数情况下,实现这一点的最简单和最好的方法是多次使用 useState 函数来创建单独的状态“片段”。

在下面的代码中,我们为应用创建了两个名为 left 和 right 的初始值为0的状态:

const App = () => {
  const [left, setLeft] = useState(0)
  const [right, setRight] = useState(0)

  return (
    <div>
      {left}
      <button onClick={() => setLeft(left + 1)}>
        left
      </button>
      <button onClick={() => setRight(right + 1)}>
        right
      </button>
      {right}
    </div>
  )
}

组件获得对 setLeft 和 setRight 函数的访问权,可以使用这两个函数更新这两个状态。

组件的状态或其状态的一部分可以是任何类型。 我们可以通过将leftright 按钮的单击次数保存到一个对象中来实现相同的功能:

{
  left: 0,
  right: 0
}

在这种情况下,应用应该是这样的:

const App = () => {
  const [clicks, setClicks] = useState({
    left: 0, right: 0
  })

  const handleLeftClick = () => {
    const newClicks = { 
      left: clicks.left + 1, 
      right: clicks.right 
    }
    setClicks(newClicks)
  }

  const handleRightClick = () => {
    const newClicks = { 
      left: clicks.left, 
      right: clicks.right + 1 
    }
    setClicks(newClicks)
  }

  return (
    <div>
      {clicks.left}
      <button onClick={handleLeftClick}>left</button>
      <button onClick={handleRightClick}>right</button>
      {clicks.right}
    </div>
  )
}

现在组件只有一个状态片段,事件处理程序必须负责更改整个应用的状态

事件处理程序看起来有点凌乱。当单击左键时,会调用下面的函数:

const handleLeftClick = () => {
  const newClicks = { 
    left: clicks.left + 1, 
    right: clicks.right 
  }
  setClicks(newClicks)
}

下面的对象被设置为应用的新状态:

{
  left: clicks.left + 1,
  right: clicks.right
}

left 属性的新值现在与前一状态的left + 1 的值相同,而right 属性的值与前一状态的right 属性的值相同。

我们可以通过使用对象的展开语法更加整洁地定义新的状态对象

该语法在2018年夏天添加到了语言规范中的:

const handleLeftClick = () => {
  const newClicks = { 
    ...clicks, 
    left: clicks.left + 1 
  }
  setClicks(newClicks)
}

const handleRightClick = () => {
  const newClicks = { 
    ...clicks, 
    right: clicks.right + 1 
  }
  setClicks(newClicks)
}

语法一开始可能看起来有点奇怪。 实际上, { ...clicks } 创建了一个新对象,该对象是具有 clicks 对象的所有属性的副本。 当我们向对象添加新属性时,例如 { ...clicks, right: 1 },新对象中right属性的值将为1。

在上面的例子中,下面代码:

{ ...clicks, right: clicks.right + 1 }

创建了 clicks 对象的副本,其中 right 属性的值增加了1。

将对象分配给事件处理中的变量是没有必要的,我们可以将函数简化为如下形式:

const handleLeftClick = () =>
  setClicks({ ...clicks, left: clicks.left + 1 })

const handleRightClick = () =>
  setClicks({ ...clicks, right: clicks.right + 1 })

一些读者可能想知道为什么我们不直接更新状态,像这样:

const handleLeftClick = () => {
  clicks.left++
  setClicks(clicks)
}

这个应用似乎可以工作。 但是,这违反了React 中状态不可直接修改的原则,因为它会导致意想不到的副作用。 必须始终通过将状态设置为新对象来更改状态。 如果之前的状态没有变化,属性仅仅需要简单地复制,就是通过将这些属性复制到新的对象中,并将其设置为新状态。

对于这个特定的应用来说,将所有状态存储在单个状态对象中是一个糟糕的选择; 没有明显的好处,还会导致产生的应用要复杂得多。 在这种情况下,将点击计数器存储到单独的状态块中是一个更合适的选择。

在某些情况下,将一段应用状态存储在更复杂的数据结构中是有益的。 官方的React文档包含了一些关于这个话题的有用的指导。

Handling arrays

【处理数组】

让我们向应用添加一个状态,该状态包含一个数组 allClicks ,该数组记录应用中发生的每次单击记录。

const App = () => {
  const [left, setLeft] = useState(0)
  const [right, setRight] = useState(0)
  const [allClicks, setAll] = useState([])
  const handleLeftClick = () => {    setAll(allClicks.concat('L'))    setLeft(left + 1)  }
  const handleRightClick = () => {    setAll(allClicks.concat('R'))    setRight(right + 1)  }
  return (
    <div>
      {left}
      <button onClick={handleLeftClick}>left</button>
      <button onClick={handleRightClick}>right</button>
      {right}
      <p>{allClicks.join(' ')}</p>    </div>
  )
}

每次单击都会被存储到一个叫 allClicks 的单独的状态单元中,这个状态单元被初始化为一个空数组:

const [allClicks, setAll] = useState([])

当单击left 按钮时,我们将字母 L 添加到 allClicks 数组中:

const handleLeftClick = () => {
  setAll(allClicks.concat('L'))
  setLeft(left + 1)
}

存储在 allClicks 中的状态块现在被设置为一个数组,该数组包含前一个状态数组的所有项以及字母 L。 向数组中添加新元素是通过concat方法完成的,该方法不改变现有数组,而是返回数组 新副本,并将元素添加到该数组中。

正如前面提到的,在 JavaScript 中也可以使用push方法将元素添加到数组中。 如果我们通过将元素push到 allClicks 数组,然后更新状态这种方法来添加元素,应用看起来仍然可以工作:

const handleLeftClick = () => {
  allClicks.push('L')
  setAll(allClicks)
  setLeft(left + 1)
}

但是,不要这样做。 如前所述,React 组件(如 allClicks )的状态不能直接更改。 即使改变状态在某些情况下可以工作,也可能导致很难调试的问题。

让我们仔细看看点击历史是如何渲染在页面上的:

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

  return (
    <div>
      {left}
      <button onClick={handleLeftClick}>left</button>
      <button onClick={handleRightClick}>right</button>
      {right}
      <p>{allClicks.join(' ')}</p>    </div>
  )
}

我们为 allClicks 数组调用join方法,该数组将所有项目连接到一个字符串中,由作为函数参数传递的字符串分隔,在我们的例子中,该字符串是一个空格。

Conditional rendering

【条件渲染】

让我们修改我们的应用,使得单击历史的渲染由一个新的 History 组件处理:

const History = (props) => {  if (props.allClicks.length === 0) {    return (      <div>        the app is used by pressing the buttons      </div>    )  }  return (    <div>      button press history: {props.allClicks.join(' ')}    </div>  )}
const App = () => {
  // ...

  return (
    <div>
      {left}
      <button onClick={handleLeftClick}>left</button>
      <button onClick={handleRightClick}>right</button>
      {right}
      <History allClicks={allClicks} />    </div>
  )
}

现在,组件的行为取决于是否单击了任何按钮。 如果没有,这意味着 allClicks 数组是空的,那么该组件将渲染一个带有如下说明的 div 组件:

<div>the app is used by pressing the buttons</div>

在其他情况下,该组件渲染单击历史记录:

<div>
  button press history: {props.allClicks.join(' ')}
</div>

History 组件根据应用的状态渲染完全不同的 React-元素。

React 还提供了许多其他的方法来实现条件渲染。 我们将在第2章节中进一步研究这个问题。

让我们对我们的应用进行最后一次修改,重构它,用上我们前面定义的 Button 组件:

const History = (props) => {
  if (props.allClicks.length === 0) {
    return (
      <div>
        the app is used by pressing the buttons
      </div>
    )
  }

  return (
    <div>
      button press history: {props.allClicks.join(' ')}
    </div>
  )
}

const Button = ({ handleClick, text }) => (  <button onClick={handleClick}>    {text}  </button>)
const App = () => {
  const [left, setLeft] = useState(0)
  const [right, setRight] = useState(0)
  const [allClicks, setAll] = useState([])

  const handleLeftClick = () => {
    setAll(allClicks.concat('L'))
    setLeft(left + 1)
  }

  const handleRightClick = () => {
    setAll(allClicks.concat('R'))
    setRight(right + 1)
  }

  return (
    <div>
      {left}
      <Button handleClick={handleLeftClick} text='left' />      <Button handleClick={handleRightClick} text='right' />      {right}
      <History allClicks={allClicks} />
    </div>
  )
}

Old React

【老版本的React】

在这个过程中,我们使用了状态Hook state hook 来添加状态到我们的 React 组件,这是 React 的新版本的一部分,可以从版本16.8.0开始使用。 在添加Hook之前,没有办法将状态添加到函数组件。 需要状态的组件必须使用 JavaScript 类语法定义为 class 组件。

在这个课程中,我们做了一个稍微激进的决定,从第一天开始就完全使用Hook,以确保我们当前和未来的React风格。 尽管函数式组件是 React 的未来,但学习类语法仍然很重要,因为有数十亿行旧的 React 代码可能会在某一天需要维护。 同样的道理,你可能在互联网上偶然发现React的文档和例子也使用了这些旧代码。

我们将在稍后的课程中学习更多关于 React 类组件的知识。

Debugging React applications

【调试React应用】

典型的开发人员的大部分时间都花在调试和读取现有代码上。 我们时不时地会写一两行新代码,但是我们的大部分时间都花在试图弄明白为什么有些东西坏了,或者某些东西是如何工作的上面。 出于这个原因,良好的调试实践和工具非常重要。

幸运的是,在调试方面来说,React 对开发者是非常友好的。

在我们继续之前,让我们重新提醒自己 web 开发最重要的规则之一。

The first rule of web development web开发第一原则

Keep the browser's developer console open at all times.

始终打开浏览器的开发控制台

The Console tab in particular should always be open, unless there is a specific reason to view another tab. 尤其是Console 选项卡应该始终处于打开状态,除非有特定的原因需要查看另一个选项卡。

保持你的代码和网页同时打开,一直同时打开。

如果你的代码编译失败,你的浏览器就会像圣诞树一样亮起来:

fullstack content

不要继续编写更多的代码,而是立即找到并修复问题。 在编码的历史上,还没有哪一次编译失败的代码在编写了大量额外的代码之后奇迹般地开始工作。这样的事情在这个课程中也不会发生。

老派的,基于打印的调试总是一个好主意。如果组件如下所示

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

不能正常工作时,开始将其变量输出到控制台是很有用的。 为了有效地做到这一点,我们必须将我们的函数转换成不那么紧凑的形式,接收整个props对象而不是解构它:

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

这将立即揭示,例如,是否有一个属性在使用组件时拼写错误。

注意:当您使用 console.log 进行调试时,不要使用“加号”,像类似于 java 的方式组合对象。 即不要写:

console.log('props value is' + props)

而应用逗号分隔需要打印到控制台的内容:

console.log('props value is', props)

如果你使用类似于 java 的方式将一个字符串与一个对象结合起来,你最终会得到一个相当无用的日志消息:

props value is [Object object]

而用逗号分隔的项目都可以在浏览器控制台中进行进一步检查。

将日志记录到控制台绝不是调试应用的唯一方法。 你可以在 Chrome 开发者控制台的debugger 中暂停应用代码的执行,只需在代码中的任何地方写入命令debugger即可。

一旦到达调试器命令执行的地方,执行就会暂停:

fullstack content

通过访问Console 选项卡,可以很容易地检查变量的当前状态:

fullstack content

一旦发现 bug 的原因,您可以删除 debugger 命令并刷新页面。

debugger 还允许我们使用在Sources 选项卡右侧找到控件一行一行地执行代码。

通过在Sources 选项卡中添加断点,您还可以在不使用 debugger 命令的情况下访问调试器。 检查组件变量的值可以在 Scope-部分 中完成:

fullstack content

强烈建议在 Chrome 中添加 React developer tools扩展。 它为开发工具增加了一个新的 Components 选项卡。新的开发者工具页可以用来检查不同的React 元素,以及它的属性和状态:

fullstack content fullstack content

App 组件的状态定义如下:

const [left, setLeft] = useState(0)
const [right, setRight] = useState(0)
const [allClicks, setAll] = useState([])

开发工具按照定义顺序显示hook的状态:

fullstack content

第一个State包含left状态的值,下一个包含right 状态的值,最后一个包含allClicks 状态的值。

Rules of Hooks

【Hook的规则】

为了确保应用正确地使用基于Hook的状态函数,我们必须遵循一些限制和规则。

不能从循环、条件表达式或任何不是定义组件的函数的地方调用 useState (同样的还有 useEffect 函数,将在后面的课程中介绍)。 这样做是为了确保Hook总是以相同的顺序调用,如果不是这样,应用的行为就会不规则。

回顾一下,hook 只能从定义 React component 的函数体内部调用:

const App = () => {
  // these are ok
  const [age, setAge] = useState(0)
  const [name, setName] = useState('Juha Tauriainen')

  if ( age > 10 ) {
    // this does not work!
    const [foobar, setFoobar] = useState(null)
  }

  for ( let i = 0; i < age; i++ ) {
    // also this is not good
    const [rightWay, setRightWay] = useState(false)
  }

  const notGood = () => {
    // and this is also illegal
    const [x, setX] = useState(-1000)
  }

  return (
    //...
  )
}

Event Handling Revisited

【复习事件处理】

事件处理已被证明是本课程前面的迭代中比较难的一块。

出于这个原因,我们将再次讨论这个话题。

假设我们正在开发这个简单的应用, App 如下所示:

const App = () => {
  const [value, setValue] = useState(10)

  return (
    <div>
      {value}
      <button>reset to zero</button>
    </div>
  )
}

我们希望单击按钮来重置存储在 value 变量中的状态。

为了使按钮对单击事件作出反应,我们必须向其添加一个事件处理程序

事件处理程序必须始终是函数或对函数的引用。 如果将事件处理程序设置为任何其他类型的变量,则按钮将不起作用。

如果我们将事件处理程序定义为一个字符串:

<button onClick="crap...">button</button>

React会在控制台中警告我们:

index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type.
    in button (at index.js:20)
    in div (at index.js:18)
    in App (at index.js:27)

下列尝试也不会奏效:

<button onClick={value + 1}>button</button>

我们尝试将事件处理程序设置为 value + 1,它只返回操作的结果。 在控制台中会友好地警告我们:

index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type.

以下这种尝试也不会奏效:

<button onClick={value = 0}>button</button>

事件处理程序不是一个函数,而是一个变量赋值,React 将再次向控制台发出警告。 这种尝试也是有缺陷的,因为我们绝不能在React中直接改变状态。

下面的内容会发生什么:

<button onClick={console.log('clicked the button')}>
  button
</button>

当组件渲染出来时,消息会被打印到控制台一次,但是当我们点击按钮时什么也没有发生。 为什么即使我们的事件处理程序包含 console.log 函数依然不能工作呢?

这里的问题是,我们的事件处理被定义为function call,这意味着事件处理程序实际上被分配了函数返回的值,而 console.log 的返回值是undefined

console.log 函数调用在渲染组件时执行,因此它只在控制台中打印一次。

下面的尝试也是有缺陷的:

<button onClick={setValue(0)}>button</button>

我们再次尝试将函数调用设置为事件处理程序。 这行不通。 这种特殊的尝试也引起了另一个问题。 在渲染组件时,执行函数 setValue (0) ,从而导致重新渲染组件。 依次重新渲染将再次调用 setValue (0) ,从而导致无限递归。

当按钮被点击时,执行一个特定的函数调用可以这样完成:

<button onClick={() => console.log('clicked the button')}>
  button
</button>

现在,事件处理程序是一个使用箭头函数 () => console.log('clicked the button').定义的函数。 在渲染组件时,不调用任何函数,只将对箭头函数的引用设置为事件处理程序。 只有单击按钮时才调用该函数。

我们可以使用相同的技术在应用中实现重置状态:

<button onClick={() => setValue(0)}>button</button>

事件处理程序现在是函数 () => setValue(0)

在按钮的属性中直接定义事件处理程序不一定是最好的方法。

您经常会看到事件处理程序定义在一个单独的位置。 在下面的应用中,我们定义了一个函数,然后将其赋值给组件函数体中的 handleClick 变量:

const App = () => {
  const [value, setValue] = useState(10)

  const handleClick = () =>
    console.log('clicked the button')

  return (
    <div>
      {value}
      <button onClick={handleClick}>button</button>
    </div>
  )
}

现在, handleClick 变量被分配成对函数的引用。 引用作为onClick 属性传递给按钮:

<button onClick={handleClick}>button</button>

当然,我们的事件处理可以由多个命令组成。 在这种情况下,我们对箭头函数,使用较长的大括号语法:

const App = () => {
  const [value, setValue] = useState(10)

  const handleClick = () => {    console.log('clicked the button')    setValue(0)  }
  return (
    <div>
      {value}
      <button onClick={handleClick}>button</button>
    </div>
  )
}

Function that returns a function

【返回函数的函数】

定义事件处理程序的另一种方法是使用返回函数的函数。

在本课程的任何练习中,您可能不需要使用返回函数的函数。 如果这个议题看起来特别令人困惑,您可以跳过这一章节,稍后再回过头看它。

让我们对我们的代码进行如下修改:

const App = () => {
  const [value, setValue] = useState(10)

  const hello = () => {    const handler = () => console.log('hello world')    return handler  }
  return (
    <div>
      {value}
      <button onClick={hello()}>button</button>
    </div>
  )
}

尽管代码看起来很复杂,但它能正常工作。

事件处理程序现在设置为函数调用:

<button onClick={hello()}>button</button>

前面我们说过,事件处理程序不能是对函数的调用,它必须是函数或对函数的引用。 那么为什么函数调用在这种情况下会起作用呢?

在渲染组件时,执行如下函数:

const hello = () => {
  const handler = () => console.log('hello world')

  return handler
}

函数的返回值 是分配给处理程序变量的另一个函数。

当 React 渲染行时:

<button onClick={hello()}>button</button>

它将 hello ()的返回值赋给 onClick-属性,本质上该行被转换成:

<button onClick={() => console.log('hello world')}>
  button
</button>

因为 hello 函数返回一个函数,所以事件处理程序现在是一个函数。

这个概念的意义是什么?

让我们稍微修改一下代码:

const App = () => {
  const [value, setValue] = useState(10)

  const hello = (who) => {    const handler = () => {      console.log('hello', who)    }    return handler  }
  return (
    <div>
      {value}
      <button onClick={hello('world')}>button</button>      <button onClick={hello('react')}>button</button>      <button onClick={hello('function')}>button</button>    </div>
  )
}

现在,应用有三个按钮,事件处理程序由接受参数的 hello 函数定义。

第一个按钮定义为

<button onClick={hello('world')}>button</button>

事件处理程序由执行 函数调用 hello('world')创建,函数 call 返回函数:

() => {
  console.log('hello', 'world')
}

第二个按钮定义为:

<button onClick={hello('react')}>button</button>

创建事件处理程序的函数 hello('react')返回:

() => {
  console.log('hello', 'react')
}

两个按钮都有自己的单独事件处理程序。

返回函数的函数可用于定义可以使用参数自定义的通用函数。 可以将创建事件处理程序的 hello 函数视为一个生成用户的定制事件处理的工厂。

我们目前的定义有点冗长:

const hello = (who) => {
  const handler = () => {
    console.log('hello', who)
  }

  return handler
}

让我们消除辅助变量,直接返回创建的函数:

const hello = (who) => {
  return () => {
    console.log('hello', who)
  }
}

因为 hello 函数是由一个单独的返回命令组成的,所以我们可以省略大括号,对箭头函数使用更紧凑的语法:

const hello = (who) =>
  () => {
    console.log('hello', who)
  }

最后,让我们把所有的箭头写在同一行上:

const hello = (who) => () => {
  console.log('hello', who)
}

我们可以使用相同的技巧来定义将组件状态,设置为给定值的事件处理程序。 让我们对我们的代码进行如下修改:

const App = () => {
  const [value, setValue] = useState(10)
  
  const setToValue = (newValue) => () => {    setValue(newValue)  }  
  return (
    <div>
      {value}
      <button onClick={setToValue(1000)}>thousand</button>      <button onClick={setToValue(0)}>reset</button>      <button onClick={setToValue(value + 1)}>increment</button>    </div>
  )
}

在渲染组件时,创建thousand 按钮:

<button onClick={setToValue(1000)}>thousand</button>

事件处理程序设置为 setToValue (1000)的返回值,该返回值是如下函数:

() => {
  setValue(1000)
}

为 increase 按钮的代码行如下:

<button onClick={setToValue(value + 1)}>increment</button>

事件处理程序由函数调用setToValue(value + 1) 创建,该函数接收状态变量值的当前值,并将变量值增加1作为参数。 如果值为10,那么创建的事件处理程序就是函数:

() => {
  setValue(11)
}

使用返回函数的函数不是实现此功能所必需的。 让我们将负责更新状态的 setToValue 函数返回到一个普通函数:

const App = () => {
  const [value, setValue] = useState(10)

  const setToValue = (newValue) => {
    setValue(newValue)
  }

  return (
    <div>
      {value}
      <button onClick={() => setToValue(1000)}>
        thousand
      </button>
      <button onClick={() => setToValue(0)}>
        reset
      </button>
      <button onClick={() => setToValue(value + 1)}>
        increment
      </button>
    </div>
  )
}

现在,我们可以将事件处理程序定义为一个函数,该函数使用适当的参数调用 setToValue 函数。 用于重置应用状态的事件处理程序如下:

<button onClick={() => setToValue(0)}>reset</button>

可以在这两种定义事件处理程序的方式中进行选择,这主要取决于个人喜好。

Passing Event Handlers to Child Components

【将事件处理传递给子组件】

让我们将按钮提取到它自己的组件中:

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

该组件从 handleClick 属性获取事件处理函数,从text 属性获取按钮的文本。

使用Button 组件很简单,尽管我们必须确保在向组件传递props时使用正确的属性名。

fullstack content

Do Not Define Components Within Components

【不要在组件中定义组件】

让我们开始将应用的值显示到它自己的Display 组件中。

我们将通过在App-组件中定义一个新组件来更改应用。

// This is the right place to define a component
const Button = (props) => (
  <button onClick={props.handleClick}>
    {props.text}
  </button>
)

const App = () => {
  const [value, setValue] = useState(10)

  const setToValue = newValue => {
    setValue(newValue)
  }

  // Do not define components inside another component
  const Display = props => <div>{props.value}</div>
  return (
    <div>
      <Display value={value} />
      <Button handleClick={() => setToValue(1000)} text="thousand" />
      <Button handleClick={() => setToValue(0)} text="reset" />
      <Button handleClick={() => setToValue(value + 1)} text="increment" />
    </div>
  )
}

应用看起来仍然可以工作,但是 不要像这样实现组件!不要在其他组件内部定义组件。 这种方法没有任何好处,而且会导致许多不愉快的问题。最大的问题是React 在每次渲染时,会将内部的组件当作一个新的组件。这回导致React 无法去优化组件。

让我们把Display 组件函数移动到正确的位置,这个位置在App 组件函数之外:

const Display = props => <div>{props.value}</div>

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

const App = () => {
  const [value, setValue] = useState(10)

  const setToValue = newValue => {
    setValue(newValue)
  }

  return (
    <div>
      <Display value={value} />
      <Button handleClick={() => setToValue(1000)} text="thousand" />
      <Button handleClick={() => setToValue(0)} text="reset" />
      <Button handleClick={() => setToValue(value + 1)} text="increment" />
    </div>
  )
}

Useful Reading

【有用的阅读材料】

互联网上充满了React相关的材料。 然而,我们使用了新的React方式,以至于网上发现的绝大多数材料对我们的目的来说都已经过时了。

你可在如下链接中找到有用的资料:

  • React官方文档在某种程度上值得一读,尽管其中大部分只有在课程后期才会变得有意义。 此外,所有与类组件相关的内容都与我们无关。
  • 一些关于Egghead.io的课程,如开始学习React ,质量很高,稍新一点的初学者React指南也相对不错; 这两门课程都介绍了一些概念,这些概念也将在本课程后面介绍。 然而,这两门课程都使用了 Class 组件,而不是本课程中使用的新的函数式组件。