React 反模式和最佳实践 - 注意事项
React 似乎是狂野西部网络中最不固执己见的框架之一。尽管如此,它仍然有很多错误,而且为了编写干净易读的代码,你还可以做更多的事情。本文将解释 React 中 17 种常见的反模式和最佳实践。
在本文中
- 使用 useState 代替变量
- 在组件外部声明 CSS
- 使用 useCallback 防止函数重建
- 使用 useCallback 防止依赖项更改
- 使用 useCallback 来防止 useEffect 触发
- 当不需要依赖项时,向 useEffect 添加一个空的依赖项列表
- 始终将所有依赖项添加到 useEffects 和其他 React Hooks
- 不要使用 useEffect 来启动外部代码
- 不要将外部函数包装在 useCallback 中
- 不要在依赖列表为空的情况下使用 useMemo
- 不要在其他组件中声明组件
- 不要在 If 语句中使用钩子(无条件钩子)
- 返回后不要使用钩子(无条件钩子)
- 让子组件决定是否渲染
- 使用 useReducer 代替多个 useState
- 将初始状态写为函数而不是对象
- 当组件不需要重新渲染时,使用 useRef 而不是 useState
使用 useState 代替变量
第一个应该是基础的,但我仍然看到一些开发者这样做,有时甚至是资深开发者。要在 React 中存储状态,你应该始终使用 React hooks,例如 useState 或 useReducer。切勿在组件中将状态直接声明为变量。这样做会在每次渲染时重新声明变量,这意味着 React 无法记住它通常会记住的内容。
import AnotherComponent from 'components/AnotherComponent'
const Component = () => {
// Don't do this.
const value = { someKey: 'someValue' }
return <AnotherComponent value={value} />
}
在上述情况下,AnotherComponent 和所有依赖于值的内容将在每次渲染时重新渲染,即使它们使用 memo、useMemo 或 useCallback 进行记忆。
如果你将 useEffect 添加到组件中,并将value作为依赖项,它将在每次渲染时触发。这是因为value的 JavaScript 引用在每次渲染时都会不同。
通过使用 React 的 useState,React 将始终保持相同的值引用,直到你使用setValue更新它。这样,React 将能够检测何时触发效果以及何时不触发效果,并重新计算记忆。
import { useState } from 'react'
import AnotherComponent from 'components/AnotherComponent'
const Component = () => {
// Do this instead.
const [value, setValue] = useState({ someKey: 'someValue' })
return <AnotherComponent value={value} />
}
如果您只需要一个仅初始化一次且永不更新的状态,那么请在组件外部声明该变量。这样做时,JavaScript 引用将永远不会改变。
// Do this if you never need to update the value.
const value = { someKey: 'someValue' }
const Component = () => {
return <AnotherComponent value={value} />
}
在组件外部声明 CSS
如果您使用 JS 解决方案中的 CSS,请避免在组件内声明 CSS。
import makeCss from 'some/css/in/js/library'
const Component = () => {
// Don't do this.
return <div className={makeCss({ background: red, width: 100% })} />
}
不这样做的原因是每次渲染时都必须重新创建该对象。相反,应该将其从组件中移除。
import cssLibrary from 'some/css/in/js/library'
// Do this instead.
const someCssClass = makeCss({
background: red,
width: 100%
})
const Component = () => {
return <div className={someCssClass} />
}
使用 useCallback 防止函数重建
每当一个 React 函数式组件重新渲染时,它都会重新创建组件中所有常规函数。React 提供了一个 useCallback hook 来避免这种情况。只要依赖项没有发生变化,useCallback 就会在渲染之间保留该函数的旧实例。
import { useCallback } from 'react'
const Component = () => {
const [value, setValue] = useState(false)
// This function will be recreated on each render.
const handleClick = () => {
setValue(true)
}
return <button onClick={handleClick}>Click me</button>
}
import { useCallback } from 'react'
const Component = () => {
const [value, setValue] = useState(false)
// This function will only be recreated when the variable value updates.
const handleClick = useCallback(() => {
setValue(true)
}, [value])
return <button onClick={handleClick}>Click me</button>
}
这次,我不会再说该怎么做了。有些人会建议你用 useCallback hook 来优化每个函数,但我不会。对于像示例中这样的小函数,我无法保证用 useCallback 包装函数真的更好。
在底层,React 必须在每次渲染时检查依赖项,以确定是否需要创建新函数,而且有时依赖项会频繁更改。因此,useCallback 提供的优化可能并非总是必要的。
但是,如果对函数的依赖关系没有进行大量更新,则 useCallback 可以进行很好的优化,以避免在每次渲染时重新创建该函数。
React 难吗?快来加入这位创建全新 JS 框架的兄弟吧!
使用 useCallback 防止依赖项更改
useCallback 不仅可以用来避免函数实例化,它还可以用于更重要的事情。由于 useCallback 在渲染之间为包装函数保留相同的内存引用,因此它可以用于优化其他 useCallback 和 memoization 的使用。
import { memo, useCallback, useMemo } from 'react'
const MemoizedChildComponent = memo({ onTriggerFn }) => {
// Some component code...
})
const Component = ({ someProp }) => {
// Reference to onTrigger function will only change when someProp does.
const onTrigger = useCallback(() => {
// Some code...
}, [someProp])
// This memoized value will only update when onTrigger function updates.
// The value would be recalculated on every render if onTrigger wasn't wrapper in useCallback.
const memoizedValue = useMemo(() => {
// Some code...
}, [onTrigger])
// MemoizedChildComponent will only rerender when onTrigger function updates.
// If onTrigger wasn't wrapped in a useCallback, MemoizedChildComponent would rerender every time this component renders.
return (<>
<MemoizedChildComponent onTriggerFn={onTrigger} />
<button onClick={onTrigger}>Click me</button>
</>)
}
使用 useCallback 来防止 useEffect 触发
前面的例子展示了如何借助 useCallback 来优化渲染,同样,也可以避免不必要的 useEffect 触发。
import { useCallback, useEffect } from 'react'
const Component = ({ someProp }) => {
// Reference to onTrigger function will only change when someProp does.
const onTrigger = useCallback(() => {
// Some code...
}, [someProp])
// useEffect will only run when onTrigger function updates.
// If onTrigger wasn't wrapped in a useCallback, useEffect would run every time this function renders.
useEffect(() => {
// Some code...
}, [onTrigger])
return <button onClick={onTrigger}>Click me</button>
}
当不需要依赖项时,向 useEffect 添加一个空的依赖项列表
如果您有一个不依赖于任何变量的效果,请确保将空的依赖列表作为 useEffect 的第二个参数。如果不这样做,该效果将在每次渲染时运行。
import { useEffect } from 'react'
const Component = () => {
useEffect(() => {
// Some code.
// Do not do this.
})
return <div>Example</div>
}
import { useEffect } from 'react'
const Component = () => {
useEffect(() => {
// Some code.
// Do this.
}, [])
return <div>Example</div>
}
同样的逻辑也适用于其他 React hooks,例如 useCallback 和 useMemo。不过,正如本文后面所述,如果您没有任何依赖项,可能根本不需要使用这些 hooks。
始终将所有依赖项添加到 useEffects 和其他 React Hooks
处理 React 内置 hooks(例如 useEffects 和 useCallback)的依赖列表时,请务必将所有依赖项添加到依赖列表(hooks 的第二个参数)。如果省略了某个依赖项,effect 或回调可能会使用其旧值,这通常会导致难以检测的 bug。
添加所有变量可能是一个非常棘手的事情,有时您只是不希望在值更新时再次运行效果,但尝试找到解决方案不仅可以避免错误,而且通常还会导致更好的代码编写。
更重要的是,如果你为了避免 bug 而忽略了依赖项,那么升级到较新的 React 版本后,该 bug 还会再次出现。在 React 18 的严格模式下,更新钩子(例如 useEffect、useMemo)在开发环境中会被触发两次,而在未来 React 版本的生产环境中,这种情况可能会再次发生。
为了安全起见,最好将所有依赖项添加到 React Hooks 中
import { useEffect } from 'react'
const Component = () => {
const [value, setValue] = useState()
useEffect(() => {
// Some code.
// Don't neglect adding variables to dependency list.
// The value variable should be added here.
}, [])
return <div>{value}</div>
}
你可能会想,当 useEffects 触发次数超过预期时,该如何避免副作用?不幸的是,这个问题并没有万能的解决方案。不同的场景需要不同的解决方案。你可以尝试使用hooks 来只运行一次代码,这有时可能有用,但实际上并不推荐。
通常情况下,你可以使用 if 语句来解决问题。你可以查看当前状态,并从逻辑上判断是否真的需要运行这段代码。例如,如果你不想将变量值作为依赖项添加到上述 effect 中,是因为只在值未定义时运行代码,那么你只需在 effect 中添加一个 if 语句即可。
import { useEffect } from 'react'
const Component = () => {
const [value, setValue] = useState()
useEffect(() => {
if (!value) {
// Some code to run when value isn't set.
}
// Do this, always add all dependencies.
}, [value])
return <div>{value}</div>
}
其他场景可能更复杂,使用 if 语句来阻止 effect 多次发生可能不太可行。如果这不容易做到,你应该避免使用它来避免 bug。在这种情况下,你应该首先问问自己,你真的需要一个 effect 吗?很多情况下,开发人员在不应该使用 effect 的情况下使用了它。
然而,生活并非如此简单。假设你确实需要使用 useEffect,而你又无法用 if 条件语句轻松解决。你还有什么其他选择呢?实际上,在这种情况下,最简单的方法可能是最好的方法:只需添加所有依赖项,然后让 effect 运行比你预期更多的次数。
与其试图阻止代码执行,不如编写代码,使其无论是否被多次调用都无关紧要。这样的代码被称为幂等代码,非常适合函数式编程。这种行为可以通过使用缓存、节流阀和去抖动函数来实现。我以后可能会写一篇文章详细解释这个主题,但现在先就此打住。
不要使用 useEffect 来启动外部代码
假设你想运行一些代码来初始化一个库。我经常看到类似的初始化代码被放在 useEffect 中,并且依赖列表为空,这完全没有必要,而且容易出错。如果你调用的函数不依赖于组件的内部状态,那么它应该在组件外部初始化。
import { useEffect } from 'react'
import initLibrary from '/libraries/initLibrary'
const Component = () => {
// Do not do this.
useEffect(() => {
initLibrary()
}, [])
return <div>Example</div>
}
import initLibrary from '/libraries/initLibrary'
// Do this instead.
initLibrary()
const Component = () => {
return <div>Example</div>
}
如果初始化需要组件的内部状态,则可以将其放入 useEffect 中,但如果这样做,请确保将使用的所有依赖项添加到 useEffect 的依赖项列表中,如上一个标题所述。
不要将外部函数包装在 useCallback 中
就像上面在 useEffect 中触发 init 函数的情况一样,调用外部函数时不需要 useCallback。只需按原样调用外部函数即可。这样 React 就无需检查 useCallback 是否需要重新创建,从而使代码更简洁。
import { useCallback } from 'react'
import externalFunction from '/services/externalFunction'
const Component = () => {
// Do not do this.
const handleClick = useCallback(() => {
externalFunction()
}, [])
return <button onClick={handleClick}>Click me</button>
}
import externalFunction from '/services/externalFunction'
const Component = () => {
// Do this instead.
return <button onClick={externalFunction}>Click me</button>
}
使用 useCallback 的有效用例是当回调调用多个函数或读取或更新内部状态时,例如来自 useState 钩子的值或组件传入的 props 之一。
import { useCallback } from 'react'
import { externalFunction, anotherExternalFunction } from '/services'
const Component = ({ passedInProp }) => {
const [value, setValue] = useState()
// This is okay...
const handleClick = useCallback(() => {
// ...because we call multiple functions.
externalFunction()
anotherExternalFunction()
// ...because we read and/or set an internal value or prop.
setValue(passedInProp)
}, [passedInProp, value])
return <button onClick={handleClick}>Click me</button>
}
不要在依赖列表为空的情况下使用 useMemo
如果您添加了具有空依赖列表的 useMemo,请问自己为什么这样做。
是不是因为它依赖于组件的状态变量,而你不想添加它?如果是那样的话,我们已经讨论过了,你应该始终列出所有依赖变量!
是因为 useMemo 实际上没有任何依赖吗?好吧,那就把它从组件里移除吧,它不属于组件!
import { useMemo } from 'react'
const Component = () => {
// Do not do this.
const memoizedValue = useMemo(() => {
return 3 + 5
}, [])
return <div>{memoizedValue}</div>
}
// Do this instead.
const memoizedValue = 3 + 5
const Component = () => {
return <div>{memoizedValue}</div>
}
不要在其他组件中声明组件
我经常看到这种情况,请停止这样做。
const Component = () => {
// Don't do this.
const ChildComponent = () => {
return <div>I'm a child component</div>
}
return <div><ChildComponent /></div>
}
这有什么问题?问题在于你滥用了 React。如前所述,组件内声明的变量每次渲染时都会重新声明。在这种情况下,这意味着每次父组件重新渲染时,都必须重新创建函数式子组件。
由于多种原因,这是有问题的。
- 每次渲染时都必须实例化一个函数。
- React 无法决定何时进行任何类型的组件优化。
- 如果在 ChildComponent 中使用钩子,它们将在每次渲染时重新启动。
- 组件的代码行数越来越多,阅读起来越来越困难。我见过一个 React 组件里就包含几十个甚至二十个这样的子组件!
该怎么做呢?只需在父组件外部声明子组件即可。
// Do this instead.
const ChildComponent = () => {
return <div>I'm a child component</div>
}
const Component = () => {
return <div><ChildComponent /></div>
}
或者更好的是,放在单独的文件中。
// Do this instead.
import ChildComponent from 'components/ChildComponent'
const Component = () => {
return <div><ChildComponent /></div>
}
不要在 If 语句中使用钩子(无条件钩子)
React 文档中对此进行了解释。切勿简单地编写条件钩子。
import { useState } from 'react'
const Component = ({ propValue }) => {
if (!propValue) {
// Don't do this.
const [value, setValue] = useState(propValue)
}
return <div>{value}</div>
}
返回后不要使用钩子(无条件钩子)
如果语句根据定义是有条件的,那么在阅读React 的文档时很容易理解你不应该在其中放置钩子。
一个更隐蔽的关键字是“return”。很多人没有意识到“return”可以导致条件钩子渲染。请看这个例子。
import { useState } from 'react'
const Component = ({ propValue }) => {
if (!propValue) {
return null
}
// This hook is conditional, since it will only be called if propValue exists.
const [value, setValue] = useState(propValue)
return <div>{value}</div>
}
如你所见,条件返回语句会使后续的钩子也变成条件执行。为了避免这种情况,请将所有钩子放在组件第一个条件渲染的上方。或者,为了更容易记住,干脆始终将钩子放在组件的顶部。
import { useState } from 'react'
const Component = ({ propValue }) => {
// Do this instead, place hooks before conditional renderings.
const [value, setValue] = useState(propValue)
if (!propValue) {
return null
}
return <div>{value}</div>
}
让子组件决定是否渲染
这并不是你总是应该做的事情,但在很多情况下它是合适的。让我们考虑下面的代码。
import { useState } from 'react'
const ChildComponent = ({ shouldRender }) => {
return <div>Rendered: {shouldRender}</div>
}
const Component = () => {
const [shouldRender, setShouldRender] = useState(false)
return <>
{ !!shouldRender && <ChildComponent shouldRender={shouldRender} /> }
</>
}
上面是条件渲染子组件的常用方法。这段代码没什么问题,只是在子组件较多的情况下会稍微冗长一些。但根据 ChildComponent 的具体功能,可能存在更好的解决方案。让我们稍微重写一下代码。
import { useState } from 'react'
const ChildComponent = ({ shouldRender }) => {
if (!shouldRender) {
return null
}
return <div>Rendered: {shouldRender}</div>
}
const Component = () => {
const [shouldRender, setShouldRender] = useState(false)
return <ChildComponent shouldRender={shouldRender} />
}
在上面的示例中,我们重写了两个组件,将条件渲染移到了子组件中。你可能会想,将条件渲染移到子组件中有什么好处呢?
最大的好处是,即使 ChildComponent 不可见,React 也可以继续渲染它。这意味着,ChildComponent 在隐藏时可以保持其状态,之后再次渲染时也不会丢失其状态。它一直在那里,只是不可见而已。
如果组件停止渲染,就像第一个代码一样,则 useState 中保存的状态将被重置,并且 useEffects、useCallbacks 和 useMemos 都需要在组件再次渲染时重新运行并重新计算新值。
如果你的代码会触发一些网络请求或进行一些繁重的计算,这些操作也会在组件再次渲染时运行。同样,如果你在组件的内部状态中存储了一些表单数据,那么这些数据每次组件隐藏时都会被重置。
正如最初提到的,这并非总是你想要做的。有时你确实希望组件完全卸载。例如,如果你的子组件中有一个 useEffect,你可能不想在重新渲染时继续运行它。请参阅下面的示例。
const ChildComponent = ({ shouldRender, someOtherPropThatChanges }) => {
useEffect(() => {
// If we don't want this code to run when shouldRender is false,
// then don't keep render this component when shouldRender is false.
}, [someOtherPropThatChanges])
if (!shouldRender) {
return null
}
return <div>Rendered: {shouldRender}</div>
}
const Component = () => {
const [shouldRender, setShouldRender] = useState(false)
return <ChildComponent
shouldRender={shouldRender}
someOtherPropThatChanges={someOtherPropThatChanges} />
}
当然,我们可以在子组件内部使用条件逻辑来使上面的代码正常工作,但这样做很容易出错。另外,请记住,不允许使用条件钩子,因此您不能将 useEffect 放在 if 语句之后。
const ChildComponent = ({ shouldRender, someOtherPropThatChanges }) => {
if (!shouldRender) {
return null
}
useEffect(() => {
// We cannot avoid running this useEffect by putting it after the
// null-render. Conditional hook rendering is not allowed in React!
}, [someOtherPropThatChanges])
return <div>Rendered: {shouldRender}</div>
}
const Component = () => {
const [shouldRender, setShouldRender] = useState(false)
return <ChildComponent
shouldRender={shouldRender}
someOtherPropThatChanges={someOtherPropThatChanges} />
}
使用 useReducer 代替多个 useState
与其使用多个 useState 来增加组件的臃肿,不如使用一个 useReducer。虽然写起来可能比较繁琐,但它既可以避免不必要的渲染,又可以使逻辑更易于理解。有了 useReducer,向组件添加新的逻辑和状态就会容易得多。
在重构为 useReducer 之前,没有神奇的数字来规定要编写多少个 useState,但我个人认为大约三个。
import { useState } from 'react'
const Component = () => {
// Do not add a lot of useState.
const [text, setText] = useState(false)
const [error, setError] = useState('')
const [touched, setTouched] = useState(false)
const handleChange = (event) => {
const value = event.target.value
setText(value)
if (value.length < 6) {
setError('Too short')
} else {
setError('')
}
}
return <>
{!touched && <div>Write something...</div> }
<input type="text" value={text} onChange={handleChange} />
<div>Error: {error}</div>
</>
}
import { useReducers } from 'react'
const UPDATE_TEXT_ACTION = 'UPDATE_TEXT_ACTION'
const RESET_FORM = 'RESET_FORM'
const getInitialFormState = () => ({
text: '',
error: '',
touched: false
})
const formReducer = (state, action) => {
const { data, type } = action || {}
switch (type) {
case UPDATE_TEXT_ACTION:
const text = data?.text ?? ''
return {
...state,
text: text,
error: text.length < 6,
touched: true
}
case RESET_FORM:
return getInitialFormState()
default:
return state
}
}
const Component = () => {
const [state, dispatch] = useReducer(formReducer, getInitialFormState());
const { text, error, touched } = state
const handleChange = (event) => {
const value = event.target.value
dispatch({ type: UPDATE_TEXT_ACTION, text: value})
}
return <>
{!touched && <div>Write something...</div> }
<input type="text" value={text} onChange={handleChange} />
<div>Error: {error}</div>
</>
}
将初始状态写为函数而不是对象
注意本技巧中的代码。查看 getInitialFormState 函数。
// Code removed for brevity.
// Initial state is a function here.
const getInitialFormState = () => ({
text: '',
error: '',
touched: false
})
const formReducer = (state, action) => {
// Code removed for brevity.
}
const Component = () => {
const [state, dispatch] = useReducer(formReducer, getInitialFormState());
// Code removed for brevity.
}
注意到我把初始状态写成了一个函数。我其实可以直接用对象。
// Code removed for brevity.
// Initial state is an object here.
const initialFormState = {
text: '',
error: '',
touched: false
}
const formReducer = (state, action) => {
// Code removed for brevity.
}
const Component = () => {
const [state, dispatch] = useReducer(formReducer, initialFormState);
// Code removed for brevity.
}
我为什么不这么做呢?答案很简单,为了避免可变性。在上面的例子中,当 initialFormState 是一个对象时,我们可能会在代码的某个地方修改它。
如果是这样,当我们再次使用该变量时(例如重置表单时),我们将无法恢复初始状态。相反,我们会获取一个变异的对象,例如,当touched 的值可能为 true 时。
运行单元测试时也是如此。测试上述代码时,多个测试可以使用 initialFormState 并对其进行修改。这样,每个测试在单独运行时都可以正常工作,而当所有测试在一个测试套件中一起运行时,某些测试可能会失败。
因此,将初始状态转换为返回初始状态对象的 getter 函数是一个好习惯。或者更好的做法是,使用像Immer这样的库来避免编写可变代码。
当组件不需要重新渲染时,使用 useRef 而不是 useState
你知道吗?用 useRef 替换 useState 可以优化组件渲染。看看这段代码。
import { useEffect } from 'react'
const Component = () => {
const [triggered, setTriggered] = useState(false)
useEffect(() => {
if (!triggered) {
setTriggered(true)
// Some code to run here...
}
}, [triggered])
}
运行上述代码时,组件会在 setTriggered 调用时重新渲染。在这种情况下,触发状态变量可以确保效果只运行一次(这在 React 18 中实际上不起作用,请参阅这篇关于 useRunOnce hook 的文章了解原因)。
由于在这种情况下触发变量的唯一用途是跟踪函数是否已触发,因此我们不需要组件渲染任何新状态。因此,我们可以用 useRef 替换 useState,这样在更新时就不会触发组件重新渲染。
import { useRef } from 'react'
const Component = () => {
// Do this instead.
const triggeredRef = useRef(false)
useEffect(() => {
if (!triggeredRef.current) {
triggeredRef.current = true
// Some code to run here...
}
// Note missing dependency. This isn't optimal.
}, [])
}
请注意 useEffect 缺少依赖项,在使用 useRef 时修复该依赖项有点棘手,但 React 在其文档中对此进行了解释。
对于上面的情况,你可能会疑惑,为什么我们需要使用 useRef 呢?为什么我们不能简单地使用组件外部的变量呢?
// This does not work the same way!
const triggered = false
const Component = () => {
useEffect(() => {
if (!triggered) {
triggered = true
// Some code to run here...
}
}, [])
}
我们之所以需要 useRef 是因为上面的代码无法以相同的方式工作!上面的触发变量只会为 false 一次。如果组件卸载,当组件再次挂载时,触发变量仍然会被设置为 true,因为触发变量没有绑定到 React 的生命周期。
当使用 useRef 时,React 会在组件卸载并重新挂载时重置其值。在这种情况下,我们可能希望使用 useRef,但在其他情况下,我们可能要查找组件外部的变量。
文章来源:https://dev.to/perssondennis/react-anti-patterns-and-best-practices-dos-and-donts-3c2g