React 组件中的 7 种代码异味

2025-05-24

React 组件中的 7 种代码异味

我认为 React 组件中的代码异味越来越多。

道具太多

将太多的 props 传递到单个组件可能表明该组件应该拆分。

你会问,多少才算太多?嗯……“视情况而定”。你可能会遇到这样的情况:一个组件有 20 个甚至更多的 props,但你仍然觉得它只做一件事。但是,当你偶然发现一个组件有很多 props,或者你很想在已经很长的 props 列表中再添加一个时,有几点需要考虑:

这个组件是否执行多项操作?

与函数类似,组件应该专注于做好一件事,因此最好检查是否可以将组件拆分成多个较小的组件。例如,如果组件具有不兼容的 props从函数中返回 JSX

我可以使用合成吗?

一个非常好但经常被忽视的模式是组合组件,而不是在一个组件中处理所有逻辑。假设我们有一个组件负责处理某个组织的用户应用程序:

<ApplicationForm
  user={userData}
  organization={organizationData}
  categories={categoriesData}
  locations={locationsData}
  onSubmit={handleSubmit}
  onCancel={handleCancel}
  ...
/>
Enter fullscreen mode Exit fullscreen mode

查看此组件的 props,我们可以看到它们都与组件的功能相关,但仍有改进的空间,可以通过将一些组件的责任转移给其子组件来改进:

<ApplicationForm onSubmit={handleSubmit} onCancel={handleCancel}>
  <ApplicationUserForm user={userData} />
  <ApplicationOrganizationForm organization={organizationData} />
  <ApplicationCategoryForm categories={categoriesData} />
  <ApplicationLocationsForm locations={locationsData} />
</ApplicationForm>
Enter fullscreen mode Exit fullscreen mode

现在我们已经确保它只ApplicationForm处理其最狭窄的职责,即提交和取消表单。子组件可以处理与其整体职责相关的所有事务。这也是使用React Context进行子组件与其父组件之间通信的绝佳机会。

我是否传递了许多“配置”道具?

在某些情况下,将 props 分组到选项对象中是个好主意,例如,为了更容易地交换此配置。如果我们有一个显示某种网格或表格的组件:

<Grid
  data={gridData}
  pagination={false}
  autoSize={true}
  enableSort={true}
  sortOrder="desc"
  disableSelection={true}
  infiniteScroll={true}
  ...
/>
Enter fullscreen mode Exit fullscreen mode

除了 之外的所有这些 props 都data可以被视为configuration。在这种情况下,有时最好将 改为Grid接受optionsprops。

const options = {
  pagination: false,
  autoSize: true,
  enableSort: true,
  sortOrder: 'desc',
  disableSelection: true,
  infiniteScroll: true,
  ...
}

<Grid
  data={gridData}
  options={options}
/>
Enter fullscreen mode Exit fullscreen mode

这也意味着,如果我们在不同的配置选项之间切换,可以更轻松地排除我们不想使用的配置选项options

不兼容的道具

避免传递彼此不兼容的道具。

例如,我们一开始可能只创建一个<Input />用于处理文本的通用组件,但过一段时间后,我们也会添加处理电话号码的功能。具体实现可能如下所示:

function Input({ value, isPhoneNumberInput, autoCapitalize }) {
  if (autoCapitalize) capitalize(value)

  return <input value={value} type={isPhoneNumberInput ? 'tel' : 'text'} />
}
Enter fullscreen mode Exit fullscreen mode

问题在于,propsisPhoneNumberInputautoCapitalize放在一起没有意义。我们无法将电话号码大写。

在这种情况下,解决方案可能是将组件拆分成多个较小的组件。如果我们仍然希望它们之间共享一些逻辑,可以将其移至自定义钩子

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" />
}
Enter fullscreen mode Exit fullscreen mode

虽然这个例子有点牵强,但找到彼此不兼容的道具通常是一个很好的迹象,表明您应该检查组件是否需要分开。

将 props 复制到 state 中

不要通过将 props 复制到 state 中来停止数据流。

考虑这个组件:

function Button({ text }) {
  const [buttonText] = useState(text)

  return <button>{buttonText}</button>
}
Enter fullscreen mode Exit fullscreen mode

通过将textprop 作为 useState 的初始值传递,组件现在实际上会忽略所有更新的值text。即使textprop 已更新,组件仍会渲染其初始值。对于大多数 prop 来说,这是一种意料之外的行为,反而会使组件更容易出现 bug。

一个更实际的例子是,当我们想要从一个 prop 中获取一些新值时,尤其是在这需要一些缓慢的计算的情况下。在下面的例子中,我们运行函数slowlyFormatText来格式化我们的text-prop,这需要花费大量的时间来执行。

function Button({ text }) {
  const [formattedText] = useState(() => slowlyFormatText(text))

  return <button>{formattedText}</button>
}
Enter fullscreen mode Exit fullscreen mode

通过将其置于状态,我们解决了它会不必要地重新运行的问题,但与上面一样,我们也阻止了组件的更新。解决这个问题的更好方法是使用useMemo hook记忆结果:

function Button({ text }) {
  const formattedText = useMemo(() => slowlyFormatText(text), [text])

  return <button>{formattedText}</button>
}
Enter fullscreen mode Exit fullscreen mode

现在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>
  )
}
Enter fullscreen mode Exit fullscreen mode

虽然乍一看可能还行,但它会让代码推理变得困难,不利于良好的模式,应该避免。为了解决这个问题,我要么内联 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} />
}
Enter fullscreen mode Exit fullscreen mode

当按钮被点击时,我们将其设置isLoading为 true,并使用 fetch 发起 Web 请求。如果请求成功,我们将其设置isLoading为 false,isFinished否则设置为 true;hasError如果出现错误,则设置为 true。

虽然从技术上来说,这种方法没问题,但很难推断组件处于什么状态,而且比其他方法更容易出错。我们甚至可能陷入“不可能状态”,比如我们意外地同时将和 设置为 true isLoadingisFinished

处理此问题的更好方法是使用“枚举”来管理状态。在其他语言中,枚举是一种定义变量的方法,该变量只能设置为预定义的常量值集合。虽然 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} />
}
Enter fullscreen mode Exit fullscreen mode

通过这种方式,我们消除了不可能状态的可能性,并使推理此组件变得更加容易。最后,如果您使用某种类型系统(例如 TypeScript),那就更好了,因为您可以指定可能的状态:

const [state, setState] = useState<'idle' | 'loading' | 'error' | 'finished'>('idle')
Enter fullscreen mode Exit fullscreen mode

过多的 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)
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

我们有一个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 })
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

通过使用 Reducer,我们封装了状态管理的逻辑,并将复杂性从组件中移出。这使得我们能够分别思考状态和组件,从而更容易理解正在发生的事情。

两者useState都有useReducer各自的优缺点和不同的用例(双关语)。我最喜欢的 Reducer 模式之一是Kent C. Dodds 的状态 Reducer 模式

大使用效果

避免使用执行多项操作的大型useEffects。它们会使你的代码容易出错,并且更难推理。

在 Hooks 发布时,我经常犯的一个错误就是把太多东西都放进了一个 中useEffect。为了说明这一点,下面是一个只有一个 的组件useEffect

function Post({ id, unlisted }) {
  ...

  useEffect(() => {
    fetch(`/posts/${id}`).then(/* do something */)

    setVisibility(unlisted)
  }, [id, unlisted])

  ...
}
Enter fullscreen mode Exit fullscreen mode

虽然效果不大,但仍然起到了多种作用。当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])

  ...
}
Enter fullscreen mode Exit fullscreen mode

通过这样做,我们降低了组件的复杂性,使其更容易推理,并降低了产生错误的风险。

总结

好了,暂时就这些!记住,这些绝不是规则,而是某些事情可能“出错”的迹象。你肯定会遇到一些情况,出于正当理由,你也想做上面提到的一些事情。

您对我为什么错得这么离谱有什么反馈吗?您对组件中遇到的其他代码异味有什么建议吗?请留言或在Twitter上联系我!

文章来源:https://dev.to/awnton/7-code-smells-in-react-components-5f66
PREV
我如何使用 AWS Serverless 创建一个门铃 简介 架构 工作原理 输出代码 一些经验教训 可能的改进
NEXT
Web 控制台终极指南🔥