在我的Learn React Hooks工作坊材料中,我们有一个练习,使用React的useState 钩子建立一个井字游戏(基于React官方教程)。以下是该练习的完成版本的Github文件
我们有几个状态的变量。有一个squares 状态变量,通过React.useState 。还有nextValue,winner, 和status 都是通过调用函数calculateNextValue,calculateWinner, 和calculateStatus 来决定的。squares 是常规的组件状态,但是nextValue,winner, 和status 是所谓的 "派生状态"。这意味着它们的值可以根据其他值推导(或计算),而不是自己管理。
我这样写是有原因的。让我们通过用一种更天真的方法重新实现,来看看派生状态比状态同步的好处。事实上,这四个变量在技术上都是状态,所以你可能会自动认为你需要为它们使用useState 或useReducer 。
让我们从useState 开始:
function Board() {
const [squares, setSquares] = React.useState(Array(9).fill(null))
const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
const [winner, setWinner] = React.useState(calculateWinner(squares))
const [status, setStatus] = React.useState(calculateStatus(squares))
function selectSquare(square) {
if (winner || squares[square]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square] = nextValue
const newNextValue = calculateNextValue(squaresCopy)
const newWinner = calculateWinner(squaresCopy)
const newStatus = calculateStatus(newWinner, squaresCopy, newNextValue)
setSquares(squaresCopy)
setNextValue(newNextValue)
setWinner(newWinner)
setStatus(newStatus)
}
// return beautiful JSX
}
所以,这并不是什么坏事。如果我们在井字游戏中增加一个可以同时选择两个方块的功能,那就会成为一个真正的问题。 我们要怎么做才能使之发生呢?
function Board() {
const [squares, setSquares] = React.useState(Array(9).fill(null))
const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
const [winner, setWinner] = React.useState(calculateWinner(squares))
const [status, setStatus] = React.useState(calculateStatus(squares))
function selectSquare(square) {
if (winner || squares[square]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square] = nextValue
const newNextValue = calculateNextValue(squaresCopy)
const newWinner = calculateWinner(squaresCopy)
const newStatus = calculateStatus(newWinner, squaresCopy, newNextValue)
setSquares(squaresCopy)
setNextValue(newNextValue)
setWinner(newWinner)
setStatus(newStatus)
}
function selectTwoSquares(square1, square2) {
if (winner || squares[square1] || squares[square2]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square1] = nextValue
squaresCopy[square2] = nextValue
const newNextValue = calculateNextValue(squaresCopy)
const newWinner = calculateWinner(squaresCopy)
const newStatus = calculateStatus(newWinner, squaresCopy, newNextValue)
setSquares(squaresCopy)
setNextValue(newNextValue)
setWinner(newWinner)
setStatus(newStatus)
}
// return beautiful JSX
}
这方面最大的问题是有些状态可能会与真正的组件状态不同步(squares )。例如,它可能会因为我们忘记更新复杂的交互序列而失去同步性。如果你已经构建了一段时间的React应用,你就知道我在说什么。 东西不同步可不是什么好玩的事。
有一件事可以帮助你,那就是减少重复,让所有相关的状态更新发生在一个地方:
function Board() {
const [squares, setSquares] = React.useState(Array(9).fill(null))
const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
const [winner, setWinner] = React.useState(calculateWinner(squares))
const [status, setStatus] = React.useState(calculateStatus(squares))
function setNewState(newSquares) {
const newNextValue = calculateNextValue(newSquares)
const newWinner = calculateWinner(newSquares)
const newStatus = calculateStatus(newWinner, newSquares, newNextValue)
setSquares(newSquares)
setNextValue(newNextValue)
setWinner(newWinner)
setStatus(newStatus)
}
function selectSquare(square) {
if (winner || squares[square]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square] = nextValue
setNewState(squaresCopy)
}
function selectTwoSquares(square1, square2) {
if (winner || squares[square1] || squares[square2]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square1] = nextValue
squaresCopy[square2] = nextValue
setNewState(squaresCopy)
}
// return beautiful JSX
}
这确实改善了我们的代码重复,而且说实话,这并不是什么大问题。但这是一个相当简单的例子。有时候,派生状态是基于在不同情况下更新的多个状态变量,我们需要确保每当源状态被更新时,我们所有的状态都会被更新。
解决方案
如果我告诉你有更好的东西呢?如果你已经读过上面的codeandbox实现,你就知道这个解决方案是什么,但现在让我们把它放在这里。
function Board() {
const [squares, setSquares] = React.useState(Array(9).fill(null))
const nextValue = calculateNextValue(squares)
const winner = calculateWinner(squares)
const status = calculateStatus(winner, squares, nextValue)
function selectSquare(square) {
if (winner || squares[square]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square] = nextValue
setSquares(squaresCopy)
}
// return beautiful JSX
}
很好!我们不需要担心更新派生的状态值,因为它们只是在每次渲染时被计算出来。很好。让我们加入一次两个方块的功能。
function Board() {
const [squares, setSquares] = React.useState(Array(9).fill(null))
const nextValue = calculateNextValue(squares)
const winner = calculateWinner(squares)
const status = calculateStatus(winner, squares, nextValue)
function selectSquare(square) {
if (winner || squares[square]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square] = nextValue
setSquares(squaresCopy)
}
function selectTwoSquares(square1, square2) {
if (winner || squares[square1] || squares[square2]) {
return
}
const squaresCopy = [...squares]
squaresCopy[square1] = nextValue
squaresCopy[square2] = nextValue
setSquares(squaresCopy)
}
// return beautiful JSX
}
很好!以前,我们不得不关注每一次更新squares ,以确保我们也正确地更新了所有其他的状态。但现在我们根本不需要担心这个问题。它只是在工作。不需要一个花哨的函数来处理更新所有的衍生状态。我们只是即时计算。
useReducer 呢?
useReducer 就没有这么严重的问题了。下面是我如何使用 来实现这一点。useReducer
function calculateDerivedState(squares) {
const winner = calculateWinner(squares)
const nextValue = calculateNextValue(squares)
const status = calculateStatus(winner, squares, nextValue)
return {squares, nextValue, winner, status}
}
function ticTacToeReducer(state, square) {
if (state.winner || state.squares[square]) {
// no state change needed.
// (returning the same object allows React to bail out of a re-render)
return state
}
const squaresCopy = [...state.squares]
squaresCopy[square] = state.nextValue
return {...calculateDerivedState(squaresCopy), squares: squaresCopy}
}
function Board() {
const [{squares, status}, selectSquare] = React.useReducer(
ticTacToeReducer,
Array(9).fill(null),
calculateDerivedState,
)
// return beautiful JSX
}
这不是唯一的方法,但重点是,虽然我们仍然 "派生 "winner 、nextValue 和status 的状态,但我们在还原器中管理所有的状态,这是唯一可以发生状态更新的地方,所以不同步的可能性比较小。
尽管如此,我发现这比我们其他的解决方案要复杂一些(特别是如果我们想增加 "一次两格 "的功能)。因此,如果我在生产应用中构建和运送这个,我会选择我在codeandbox中得到的东西。
通过道具派生的状态
状态不一定要在内部管理,也不一定要受到状态同步问题的影响。如果我们有来自父级组件的squares 状态呢?我们将如何同步该状态?
function Board({squares, onSelectSquare}) {
const [nextValue, setNextValue] = React.useState(calculateNextValue(squares))
const [winner, setWinner] = React.useState(calculateWinner(squares))
const [status, setStatus] = React.useState(calculateStatus(squares))
// ... hmmm... we're no longer managing updating the squares state, so how
// do we keep these variables up to date? useEffect? useLayoutEffect?
// React.useEffect(() => {
// setNextValue... etc... eh...
// }, [squares])
//
// Just call the state updaters when squares change
// right in the render method?
// if (prevSquares !== squares) {
// setNextValue... etc... ugh...
// }
//
// I've seen people do all of these things... And none of them are great.
// return beautiful JSX
}
更好的方法是在运行中计算。
function Board({squares, onSelectSquare}) {
const nextValue = calculateNextValue(squares)
const winner = calculateWinner(squares)
const status = calculateStatus(squares)
// return beautiful JSX
}
这很简单,而且效果很好。
P.S. 还记得getDerivedStateFromProps 吗?你可能不需要它,但如果你需要,而且你想用钩子来做,那么在渲染过程中调用状态更新器函数实际上是正确的方法。从React钩子FAQ中了解更多。
性能如何?
我知道你一直在等我解决这个问题......情况是这样的。JavaScript真的很快。我在calculateWinner 函数上做了一个基准测试,结果是每秒有1500万个操作。所以,除非你的井字游戏玩家点击速度非常快,否则这不可能成为一个性能问题(即使他们能玩得那么快,我向你保证,你会有其他的性能问题,而这些问题对你来说是较低的悬念)。
好吧,我在我的手机上试了一下,每秒只有430万个操作,然后我在我的笔记本电脑上用CPU 6倍的速度试了一下,只有200万个...我想我们还是不错的。
这就是说,如果你碰巧有一个计算成本很高的函数,那么这就是useMemo 。
function Board() {
const [squares, setSquares] = React.useState(Array(9).fill(null))
const nextValue = React.useMemo(() => calculateNextValue(squares), [squares])
const winner = React.useMemo(() => calculateWinner(squares), [squares])
const status = React.useMemo(
() => calculateStatus(winner, squares, nextValue),
[winner, squares, nextValue],
)
// return beautiful JSX
}
所以,你去那里。一旦你确定某些代码对你的用户来说实际上是计算成本很高的,你就可以使用这个逃生舱口。请注意,这并不能神奇地使这些函数运行得更快。它所做的只是确保它们不会被不必要地调用。如果这是我们的整个应用程序,应用程序重新渲染的唯一方法是squares ,在这种情况下,所有这些函数都会被运行,所以我们实际上没有通过这种 "优化 "取得多少成就。这就是为什么我说。"先测量!"
哦,我想说的是,派生状态有时甚至可以比状态同步更快,因为它将导致更少的不必要的重读,这有时会成为一个问题。
那MobX/Reselect呢?
Reselect(如果你使用Redux,你绝对应该使用它)内置了memoization,这很酷。MobX也有这样的功能,但他们还通过"计算值 "更进一步,这基本上是一个API,可以为你提供记忆化和优化的衍生状态值。让它比我们已经有的更好的是,计算只在被访问时被处理。
对于(臆造的)例子。
function FavoriteNumber() {
const [name, setName] = React.useState('')
const [number, setNumber] = React.useState(0)
const numberWarning = getNumberWarning(number)
return (
<div>
<label>
Your name: <input onChange={e => setName(e.target.value)} />
</label>
<label>
Your favorite number:{' '}
<input
type="number"
onChange={e => setNumber(Number(e.target.value))}
/>
</label>
<div>
{name
? `${name}'s favorite number is ${number}`
: 'Please type your name'}
</div>
<div>{number > 10 ? numberWarning : null}</div>
<div>{number < 0 ? numberWarning : null}</div>
</div>
)
}
注意,我们正在调用getNumberWarning ,但我们只在数字过高或过低时才使用其结果,所以我们实际上可能根本不需要调用那个函数。现在,这不太可能有问题,但为了争论起见,我们假设调用getNumberWarning 是一个应用程序的瓶颈。 这就是计算值功能的用武之地。
如果你在你的应用程序中经常遇到这种情况,那么我建议你直接跳到使用MobX(MobX的人将告诉你还有很多其他的理由来使用它),但是我们可以很容易地自己解决这个特定的情况。
function FavoriteNumber() {
const [name, setName] = React.useState('')
const [number, setNumber] = React.useState(0)
const numberIsTooHigh = number > 10
const numberIsTooLow = number < 0
const numberWarning =
numberIsTooHigh || numberIsTooLow ? getNumberWarning(number) : null
return (
<div>
<label>
Your name: <input onChange={e => setName(e.target.value)} />
</label>
<label>
Your favorite number:{' '}
<input
type="number"
onChange={e => setNumber(Number(e.target.value))}
/>
</label>
<div>
{name
? `${name}'s favorite number is ${number}`
: 'Please type your name'}
</div>
<div>{numberIsTooHigh ? numberWarning : null}</div>
<div>{numberIsTooLow ? numberWarning : null}</div>
</div>
)
}
很好!现在我们不需要担心在不需要的时候调用numberWarning 。但是,如果这对你的情况来说效果不好,那么我们可以做一个自定义的钩子为我们做这个魔法。这并不完全简单,而且有点像黑客(说实话可能有更好的方法),所以我只是把这个放在codeandbox里,如果你想的话,让你去探索。
只要说自定义钩子允许我们这样做就可以了。
function FavoriteNumber() {
const [name, setName] = React.useState('')
const [number, setNumber] = React.useState(0)
const numberWarning = useComputedValue(
() => getNumberWarning(number),
[number],
)
return (
<div>
<label>
Your name: <input onChange={e => setName(e.target.value)} />
</label>
<label>
Your favorite number:{' '}
<input
type="number"
onChange={e => setNumber(Number(e.target.value))}
/>
</label>
<div>
{name
? `${name}'s favorite number is ${number}`
: 'Please type your name'}
</div>
<div>{number > 10 ? numberWarning.result : null}</div>
<div>{number < 0 ? numberWarning.result : null}</div>
</div>
)
}
而我们的getNumberWarning 函数只有在实际使用result 的时候才会被调用。把它想象成一个useMemo ,只有在返回值被渲染时才运行回调。
我认为这可能有完善和开源的空间。欢迎你这样做,然后给这篇博文做一个PR,在你发布的包上添加一个链接😉。
同样,在正常情况下,真的没有什么理由为这种事情担心。但是,如果你确实有性能瓶颈,而且useMemo对你来说是不够的,那么可以考虑做这样的事情或者使用MobX。
结论
好吧,我们有点分心,过度考虑了性能问题。 事实上,你可以通过考虑状态是否需要自己管理或者是否可以派生,来真正简化你的应用程序的状态。我们了解到,派生状态可以是单一状态变量的结果,也可以是由多个状态变量派生出来的(其中一些也可以是派生状态本身)。
因此,当你在维护你的应用程序的状态并试图找出一个同步错误时,想想你如何能让它在飞行中派生。而在你遇到性能问题的少数情况下,你可以通过一些优化策略来帮助减轻一些痛苦。祝您好运!