React 组件中的 7 种代码异味
我认为 React 组件中的代码异味越来越多。
道具太多
将太多的 props 传递到单个组件可能表明该组件应该拆分。
你会问,多少才算太多?嗯……“视情况而定”。你可能会遇到这样的情况:一个组件有 20 个甚至更多的 props,但你仍然觉得它只做一件事。但是,当你偶然发现一个组件有很多 props,或者你很想在已经很长的 props 列表中再添加一个时,有几点需要考虑:
这个组件是否执行多项操作?
与函数类似,组件应该专注于做好一件事,因此最好检查是否可以将组件拆分成多个较小的组件。例如,如果组件具有不兼容的 props或从函数中返回 JSX。
我可以使用合成吗?
一个非常好但经常被忽视的模式是组合组件,而不是在一个组件中处理所有逻辑。假设我们有一个组件负责处理某个组织的用户应用程序:
<ApplicationForm
user={userData}
organization={organizationData}
categories={categoriesData}
locations={locationsData}
onSubmit={handleSubmit}
onCancel={handleCancel}
...
/>
查看此组件的 props,我们可以看到它们都与组件的功能相关,但仍有改进的空间,可以通过将一些组件的责任转移给其子组件来改进:
<ApplicationForm onSubmit={handleSubmit} onCancel={handleCancel}>
<ApplicationUserForm user={userData} />
<ApplicationOrganizationForm organization={organizationData} />
<ApplicationCategoryForm categories={categoriesData} />
<ApplicationLocationsForm locations={locationsData} />
</ApplicationForm>
现在我们已经确保它只ApplicationForm
处理其最狭窄的职责,即提交和取消表单。子组件可以处理与其整体职责相关的所有事务。这也是使用React Context进行子组件与其父组件之间通信的绝佳机会。
我是否传递了许多“配置”道具?
在某些情况下,将 props 分组到选项对象中是个好主意,例如,为了更容易地交换此配置。如果我们有一个显示某种网格或表格的组件:
<Grid
data={gridData}
pagination={false}
autoSize={true}
enableSort={true}
sortOrder="desc"
disableSelection={true}
infiniteScroll={true}
...
/>
除了 之外的所有这些 props 都data
可以被视为configuration。在这种情况下,有时最好将 改为Grid
接受options
props。
const options = {
pagination: false,
autoSize: true,
enableSort: true,
sortOrder: 'desc',
disableSelection: true,
infiniteScroll: true,
...
}
<Grid
data={gridData}
options={options}
/>
这也意味着,如果我们在不同的配置选项之间切换,可以更轻松地排除我们不想使用的配置选项options
。
不兼容的道具
避免传递彼此不兼容的道具。
例如,我们一开始可能只创建一个<Input />
用于处理文本的通用组件,但过一段时间后,我们也会添加处理电话号码的功能。具体实现可能如下所示:
function Input({ value, isPhoneNumberInput, autoCapitalize }) {
if (autoCapitalize) capitalize(value)
return <input value={value} type={isPhoneNumberInput ? 'tel' : 'text'} />
}
问题在于,propsisPhoneNumberInput
和autoCapitalize
放在一起没有意义。我们无法将电话号码大写。
在这种情况下,解决方案可能是将组件拆分成多个较小的组件。如果我们仍然希望它们之间共享一些逻辑,可以将其移至自定义钩子:
function TextInput({ value, autoCapitalize }) {
if (autoCapitalize) capitalize(value)
useSharedInputLogic()
return <input value={value} type="text" />
}
function PhoneNumberInput({ value }) {
useSharedInputLogic()
return <input value={value} type="tel" />
}
虽然这个例子有点牵强,但找到彼此不兼容的道具通常是一个很好的迹象,表明您应该检查组件是否需要分开。
将 props 复制到 state 中
不要通过将 props 复制到 state 中来停止数据流。
考虑这个组件:
function Button({ text }) {
const [buttonText] = useState(text)
return <button>{buttonText}</button>
}
通过将text
prop 作为 useState 的初始值传递,组件现在实际上会忽略所有更新的值text
。即使text
prop 已更新,组件仍会渲染其初始值。对于大多数 prop 来说,这是一种意料之外的行为,反而会使组件更容易出现 bug。
一个更实际的例子是,当我们想要从一个 prop 中获取一些新值时,尤其是在这需要一些缓慢的计算的情况下。在下面的例子中,我们运行函数slowlyFormatText
来格式化我们的text
-prop,这需要花费大量的时间来执行。
function Button({ text }) {
const [formattedText] = useState(() => slowlyFormatText(text))
return <button>{formattedText}</button>
}
通过将其置于状态,我们解决了它会不必要地重新运行的问题,但与上面一样,我们也阻止了组件的更新。解决这个问题的更好方法是使用useMemo hook来记忆结果:
function Button({ text }) {
const formattedText = useMemo(() => slowlyFormatText(text), [text])
return <button>{formattedText}</button>
}
现在slowlyFormatText
仅在发生变化时运行text
,并且我们还没有停止组件更新。
有时我们确实需要一个 prop,所有更新都会被忽略。例如,在颜色选择器中,我们需要一个选项来设置用户最初选择的颜色,但当用户选择颜色后,我们不希望更新覆盖用户的选择。在这种情况下,将 prop 复制到 state 中是完全可以的,但为了向用户表明这种行为,大多数开发人员会在 prop 前面加上 initial 或 default (
initialColor
/defaultColor
)。
进一步阅读:Dan Abramov 撰写的《编写弹性组件》。
从函数返回 JSX
不要从组件内的函数返回 JSX。
这种模式在函数组件流行起来后基本上已经消失了,但我仍然时不时地会遇到它。举个例子来解释一下:
function Component() {
const topSection = () => {
return (
<header>
<h1>Component header</h1>
</header>
)
}
const middleSection = () => {
return (
<main>
<p>Some text</p>
</main>
)
}
const bottomSection = () => {
return (
<footer>
<p>Some footer text</p>
</footer>
)
}
return (
<div>
{topSection()}
{middleSection()}
{bottomSection()}
</div>
)
}
虽然乍一看可能还行,但它会让代码推理变得困难,不利于良好的模式,应该避免。为了解决这个问题,我要么内联 JSX,因为较大的返回值问题不大,但更多时候,这是将这些部分拆分成独立组件的原因。
请记住,即使创建了新组件,也不必将其移动到新文件中。有时,如果多个组件紧密耦合,将它们放在同一个文件中是有意义的。
状态的多个布尔值
避免使用多个布尔值来表示组件状态。
当编写组件并随后扩展组件的功能时,很容易陷入这样一种情况:您有多个布尔值来指示组件处于哪种状态。对于一个在单击按钮时执行 Web 请求的小组件,您可能会有如下内容:
function Component() {
const [isLoading, setIsLoading] = useState(false)
const [isFinished, setIsFinished] = useState(false)
const [hasError, setHasError] = useState(false)
const fetchSomething = () => {
setIsLoading(true)
fetch(url)
.then(() => {
setIsLoading(false)
setIsFinished(true)
})
.catch(() => {
setHasError(true)
})
}
if (isLoading) return <Loader />
if (hasError) return <Error />
if (isFinished) return <Success />
return <button onClick={fetchSomething} />
}
当按钮被点击时,我们将其设置isLoading
为 true,并使用 fetch 发起 Web 请求。如果请求成功,我们将其设置isLoading
为 false,isFinished
否则设置为 true;hasError
如果出现错误,则设置为 true。
虽然从技术上来说,这种方法没问题,但很难推断组件处于什么状态,而且比其他方法更容易出错。我们甚至可能陷入“不可能状态”,比如我们意外地同时将和 设置为 true isLoading
。isFinished
处理此问题的更好方法是使用“枚举”来管理状态。在其他语言中,枚举是一种定义变量的方法,该变量只能设置为预定义的常量值集合。虽然 JavaScript 中严格来说不存在枚举,但我们可以使用字符串作为枚举,并且仍然有很多好处:
function Component() {
const [state, setState] = useState('idle')
const fetchSomething = () => {
setState('loading')
fetch(url)
.then(() => {
setState('finished')
})
.catch(() => {
setState('error')
})
}
if (state === 'loading') return <Loader />
if (state === 'error') return <Error />
if (state === 'finished') return <Success />
return <button onClick={fetchSomething} />
}
通过这种方式,我们消除了不可能状态的可能性,并使推理此组件变得更加容易。最后,如果您使用某种类型系统(例如 TypeScript),那就更好了,因为您可以指定可能的状态:
const [state, setState] = useState<'idle' | 'loading' | 'error' | 'finished'>('idle')
过多的 useState
useState
避免在同一个组件中使用太多的钩子。
具有许多钩子的组件useState
可能会做太多的事情™️并且可能是分解成多个组件的良好候选者,但也有一些复杂的情况,我们需要在单个组件中管理一些复杂的状态。
以下是自动完成输入组件中的某些状态和几个函数的示例:
function AutocompleteInput() {
const [isOpen, setIsOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [items, setItems] = useState([])
const [selectedItem, setSelectedItem] = useState(null)
const [activeIndex, setActiveIndex] = useState(-1)
const reset = () => {
setIsOpen(false)
setInputValue('')
setItems([])
setSelectedItem(null)
setActiveIndex(-1)
}
const selectItem = (item) => {
setIsOpen(false)
setInputValue(item.name)
setSelectedItem(item)
}
...
}
我们有一个reset
重置所有状态的函数和一个selectItem
更新部分状态的函数。这两个函数都需要使用我们所有useState
状态中的相当多的状态设置器来完成它们的预期任务。现在想象一下,我们有更多的操作需要更新状态,很容易看出,从长远来看,这很难保证没有错误。在这种情况下,使用useReducer
钩子来管理状态可能会更有益:
const initialState = {
isOpen: false,
inputValue: "",
items: [],
selectedItem: null,
activeIndex: -1
}
function reducer(state, action) {
switch (action.type) {
case "reset":
return {
...initialState
}
case "selectItem":
return {
...state,
isOpen: false,
inputValue: action.payload.name,
selectedItem: action.payload
}
default:
throw Error()
}
}
function AutocompleteInput() {
const [state, dispatch] = useReducer(reducer, initialState)
const reset = () => {
dispatch({ type: 'reset' })
}
const selectItem = (item) => {
dispatch({ type: 'selectItem', payload: item })
}
...
}
通过使用 Reducer,我们封装了状态管理的逻辑,并将复杂性从组件中移出。这使得我们能够分别思考状态和组件,从而更容易理解正在发生的事情。
两者
useState
都有useReducer
各自的优缺点和不同的用例(双关语)。我最喜欢的 Reducer 模式之一是Kent C. Dodds 的状态 Reducer 模式。
大使用效果
避免使用执行多项操作的大型useEffect
s。它们会使你的代码容易出错,并且更难推理。
在 Hooks 发布时,我经常犯的一个错误就是把太多东西都放进了一个 中useEffect
。为了说明这一点,下面是一个只有一个 的组件useEffect
:
function Post({ id, unlisted }) {
...
useEffect(() => {
fetch(`/posts/${id}`).then(/* do something */)
setVisibility(unlisted)
}, [id, unlisted])
...
}
虽然效果不大,但仍然起到了多种作用。当prop 发生变化时,即使没有变化,unlisted
我们也会获取帖子。id
为了捕捉这类错误,我尝试通过对自己说“当[dependencies]
发生更改时执行此操作”来描述我编写的效果。将其应用于上面的效果,我们得到“当id
或 unlisted
发生更改时,获取帖子并更新可见性”。如果这句话包含“或”或“和”等词,通常表明存在问题。
将此效果分解为两个效果:
function Post({ id, unlisted }) {
...
useEffect(() => { // when id changes fetch the post
fetch(`/posts/${id}`).then(/* ... */)
}, [id])
useEffect(() => { // when unlisted changes update visibility
setVisibility(unlisted)
}, [unlisted])
...
}
通过这样做,我们降低了组件的复杂性,使其更容易推理,并降低了产生错误的风险。
总结
好了,暂时就这些!记住,这些绝不是规则,而是某些事情可能“出错”的迹象。你肯定会遇到一些情况,出于正当理由,你也想做上面提到的一些事情。
您对我为什么错得这么离谱有什么反馈吗?您对组件中遇到的其他代码异味有什么建议吗?请留言或在Twitter上联系我!
文章来源:https://dev.to/awnton/7-code-smells-in-react-components-5f66