d
深入React 应用调试
A note on React version
React的第18版在2022年3月底发布。材料中的代码应该可以在新的React版本中正常工作。然而,一些库可能还不兼容React 18。在写这篇文章的时候(4月4日),至少在第8章节中使用的Apollo客户端还不能与最新的React兼容。
如果你最终因为库的兼容性问题而导致你的应用崩溃,降级到旧版React,方法是改变文件package.json。
{
"dependencies": {
"react": "^17.0.2", "react-dom": "^17.0.2", "react-scripts": "5.0.0",
"web-vitals": "^2.1.4"
},
// ...
}
更改后,通过运行以下程序重新安装依赖关系
npm install
注意,文件index.js也需要做一些改变。对于React 17,它如下所示:
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(<App />, document.getElementById('root'))
但对于React 18,正确的形式是
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')).render(<App />)
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,它可以用来更新这两个状态片断。
组件的状态或其状态的一部分可以是任何类型。我们可以通过将left和right按钮的点击次数保存在一个对象中来实现同样的功能。
{
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,新对象中的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方法向数组中添加项。如果我们通过把项目推送到allClicks数组中,然后更新状态来添加项目,这个应用看起来仍然可以工作。
const handleLeftClick = () => {
allClicks.push('L')
setAll(allClicks)
setLeft(left + 1)
}
然而,不要样做。如前所述,像allClicks这样的React组件的状态是不能直接改变的。即使改变状态在某些情况下看起来是有效的,它也会导致很难调试的问题。
让我们仔细看一下点击的情况
是如何被渲染到页面上的。
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还提供了许多其他的方法来进行条件渲染。我们将在第二章节中仔细研究。
让我们对我们的应用做最后一次修改,使用我们先前定义的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
在本课程中,我们使用state hook来为我们的React组件添加状态,这是React较新版本的一部分,从16.8.0起就可以使用。在增加钩子之前,没有办法向功能组件添加状态。需要状态的组件必须被定义为class组件,使用JavaScript的类语法。
在这个课程中,我们做了一个略显激进的决定,从第一天开始就完全使用钩子,以确保我们学习React的当前和未来风格。即使功能组件是React的未来,学习类的语法仍然很重要,因为有数十亿行的React遗留代码,你有一天可能会维护它们。这同样适用于你在互联网上偶然发现的React的文档和例子。
我们将在课程的后面学习更多关于React类的组件。
Debugging React applications
一个典型的开发者的大部分时间都花在调试和阅读现有的代码上。偶尔我们也会写一两行新的代码,但我们的大部分时间都花在试图弄清楚为什么某个东西坏了或某个东西是如何工作的。因此,良好的调试实践和工具是非常重要的。
我们很幸运,在调试方面,React是一个对开发者极其友好的库。
在我们继续之前,让我们提醒自己网络开发中最重要的规则之一。
The first rule of web development
始终保持浏览器的开发者控制台是打开的。
特别是控制台标签应该一直打开,除非有特别的原因要查看其他标签。
保持你的代码和网页一起打开,同时打开,一直打开。
如果当你的代码无法编译,你的浏览器像圣诞树一样亮起来的时候。
不要写更多的代码,而是要立即找到并解决这个问题。在编码的历史上,还没有出现过这样的时刻:在编写了大量的额外代码之后,无法编译的代码会奇迹般地开始工作。我非常怀疑在这个课程中是否会发生这样的事情。
老式的、基于打印的调试总是一个好主意。如果该组件
const Button = ({ onClick, text }) => (
<button onClick={onClick}>
{text}
</button>
)
不能按预期工作,开始将其变量打印到控制台是很有用的。为了有效地做到这一点,我们必须将我们的函数转化为不太紧凑的形式,并接收整个props对象,而不是立即对其进行解构处理。
const Button = (props) => {
console.log(props) const { onClick, text } = props
return (
<button onClick={onClick}>
{text}
</button>
)
}
这将立即显示出,例如,在使用该组件时,其中一个属性被拼错了。
NB 当你使用console.log进行调试时,不要用加号运算符以类似Java的方式组合objects。不是写:
console.log('props value is ' + props)
而是用逗号把你想记录到控制台的东西分开。
console.log('props value is', props)
如果你使用类似Java的方式将一个字符串与一个对象连接起来,你最终会得到一个相当不可靠的日志信息。
props value is [Object object]
而用逗号分隔的项目将在浏览器控制台中全部可用,以供进一步检查。
记录到控制台决不是调试我们的应用的唯一方法。您可以在Chrome开发者控制台的调试器中暂停应用代码的执行,方法是在代码的任何地方写下debugger命令。
一旦执行到debugger命令被执行的地方,执行将暂停。
通过进入Console标签,很容易检查变量的当前状态。
一旦发现错误的原因,你就可以删除调试器命令并刷新页面。
调试器也使我们能够通过Sources标签右侧的控件来逐行执行我们的代码。
你也可以通过在Sources标签中添加断点来访问调试器,而不用debugger命令。检查组件的变量值可以在Scope部分完成。
强烈建议在Chrome浏览器中添加React开发者工具扩展。它在开发者工具中增加了一个新的Components标签。这个新的开发者工具标签可以用来检查应用中不同的React元素,以及它们的状态和prop。
App组件的状态是这样定义的。
const [left, setLeft] = useState(0)
const [right, setRight] = useState(0)
const [allClicks, setAll] = useState([])
Dev tools按照定义的顺序显示钩子的状态。
第一个State包含left状态的值,接下来包含right状态的值,最后包含allClicks状态的值。
Rules of Hooks
为了确保我们的应用正确使用基于钩子的状态函数,有一些限制和规则是我们必须遵循的。
useState函数(以及课程后面介绍的useEffect函数)不能从循环、条件表达式或任何不是定义组件的函数的地方调用。这样做是为了确保钩子总是以相同的顺序被调用,如果不是这样的话,应用将表现得不正常。
简而言之,钩子只能从定义了React组件的函数体内部调用。
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,这只是返回操作的结果。React会在控制台中善意地警告我们。
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时,这也不能工作?
这里的问题是我们的事件处理程序被定义为一个函数调用,这意味着事件处理程序实际上被分配了函数的返回值,在console.log的情况下是未定义。
当组件被渲染时,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
}
该函数的返回值是另一个函数,被分配给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")创建的。该函数调用返回函数。
() => {
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) => () => { console.log('value now', newValue) // print the new value to console 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)的返回值,它是以下函数。
() => {
console.log('value now', 1000)
setValue(1000)
}
增加按钮被声明如下。
<button onClick={setToValue(value + 1)}>increment</button>
事件处理程序由函数调用setToValue(value + 1)创建,该函数接收状态变量value的当前值作为其参数,增加了一个。如果value的值是10,那么创建的事件处理程序将是这个函数。
() => {
console.log('value now', 11)
setValue(11)
}
使用返回函数的函数不需要实现这个功能。让我们把负责更新状态的setToValue函数,返回成一个普通的函数。
const App = () => {
const [value, setValue] = useState(10)
const setToValue = (newValue) => {
console.log('value now', 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>
)
这个组件从handleClickprop中获得事件处理函数,从text prop中获得按钮的文本。
使用Button组件很简单,尽管我们必须确保在向组件传递prop时使用正确的属性名称。
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 => {
console.log('value now', 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 => {
console.log('value now', 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质量很高,最近更新的The Beginner's Guide to React也比较好;这两个课程介绍的概念也将在本课程的后面介绍。NB前者使用类组件,但后者使用新的功能组件。