译:重构混乱的 React 组件

原文:https://alexkondov.com/refactoring-a-messy-react-component/
作者:Alex Kondov
译者:ChatGPT 4 Turbo

编者注:作者通过实际例子,介绍了重构 React 组件的通用思路,推荐阅读。

从我所做的所有咨询工作中,我学到了一件事,那就是重写很少会带来任何好处。几乎在所有情况下,当你有一个正在生产中运行的应用时,整理它总比将它拆除来得更好。

然而,多年来,我得到了一些混乱的代码库来进行修复,我想向你展示我整理它们的方法。在这篇文章中,我们将会讨论一个我在审核过程中需要重构的杂乱组件,以及我是如何做到的。

下面是这个组件。

function Form() {
  const [formLink, setFormLink] = useState('')
  const [userPersona, setUserPersona] = useState('')
  const [startDate, setStartDate] = useState('')
  const [endDate, setEndDate] = useState('')
  const [numberOfVisits, setNumberOfVisits] = useState('')
  const [companyNumber, setCompanyNumber] = useState('')
  const [numberIncorrect, setNumberIncorrect] = useState(0)
  const [isFormValid, setIsFormValid] = useState(false)
  const [buttonText, setButtonText] = useState('Next')
  const [isProcessing, setIsProcessing] = useState(false)
  const [estimatedTime, setEstimatedTime] = useState('Enter number')
  const [recentActions, setRecentActions] = useState([])
  const [abortController, setAbortController] = useState(null)

  useEffect(() => {
    fetchPreviousActions()
  }, [])

  const fetchPreviousActions = async () => {
    try {
      const response = await fetch('https://api.com/actions', {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      })

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const data = await response.json()
      data.sort(
        (a, b) => new Date(b.actiond_date) - new Date(a.actiond_date)
      )
      setRecentActions(data)
    } catch (error) {
      console.error('Failed to fetch recent actions', error)
    }
  }

  const [showOverlay, setShowOverlay] = useState(false)

  const renderLayout = () => (
    <div>
      <div>
        <div>正在分析...</div>
        <button onClick={handleCancelaction}>取消</button>
      </div>
    </div>
  )

  const formatDate = (dateStr) => {
    return dateStr.replace(/-/g, '')
  }

  const callBackendAPI = async (formData) => {
    const controller = new AbortController()
    setAbortController(controller)
    formData.startDate = formatDate(formData.startDate)
    formData.endDate = formatDate(formData.endDate)

    try {
      const response = await fetch('https://api.com/action', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(formData),
        signal: controller.signal,
      })

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const data = await response.json()
      setShowOverlay(false)
      window.open(
        'https://app.com/action/' + data.id,
        '_blank',
        'noopener,noreferrer'
      )
      window.location.reload()
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Scraping halted')
      } else {
        console.error('Failed to call the API', error)
      }
    } finally {
      setShowOverlay(false)
      setIsProcessing(false)
    }
  }

  const handleCancelaction = () => {
    if (abortController) {
      abortController.abort() // Abort the fetch request
    }
    setShowOverlay(false)
    setIsProcessing(false)
  }

  useEffect(() => {
    if (!recentActions) {
      fetchPreviousActions()
    }

    setIsFormValid(startDate && endDate && endDate > startDate)
  }, [numberOfVisits, startDate, endDate])

  const handleSubmit = async (event) => {
    event.preventDefault()
    if (!isFormValid) return

    setShowOverlay(true)
    setIsProcessing(true)

    // 构建表单数据对象
    const formData = {
      userPersona,
      startDate,
      endDate,
      numberOfVisits: parseInt(numberOfVisits, 10),
    }
    // 使用表单数据调用 API
    await callBackendAPI(formData)
    setIsProcessing(false)
  }

  const handleSubmitCompanyNumber = (number) => {
    // 这是不必要的,我们已经在状态中设置了值
    setCompanyNumber(number)
    if (number.length < 9) setNumberIncorrect(1)
    else setNumberIncorrect(0)
  }

  return !numberIncorrect ? (
    <div>
      <div>
        <img src={require('../imgs/LogoWhite.png')} alt="Logo" />
      </div>
      <div>
        <div>工具</div>
        <form onSubmit={handleSubmit}>
          <label htmlFor="company_number">
            输入您的证件号
          </label>
          <input
            type="text"
            name="company_number"
            id="company_number"
            placeholder="公司编号"
            value={companyNumber}
            onChange={(e) => setCompanyNumber(e.target.value)}
          />
          <button
            type="submit"
            onClick={(e) => handleSubmitCompanyNumber(companyNumber)}
          >
            <span>登录</span>
            <span>&gt;</span>
          </button>
          {numberIncorrect > 0 ? (
            <span>您输入的编号有误</span>
          ) : (
            ''
          )}
        </form>
      </div>
    </div>
  ) : (
    <div>
      <div>
        <img
          src={require('../imgs/LogoWhite.png')}
          style={{ width: '200px', marginTop: '50px' }}
          alt="Logo"
        />
      </div>
      <div>
        <div>
          <div>新操作</div>
          <form style={{ marginTop: '3vh' }} onSubmit={handleSubmit}>
            <div>
              <label>
                访问次数
                <span
                  style={{
                    color: 'gray',
                    fontWeight: 'lighter',
                  }}
                >
                  (可选)
                </span>
              </label>
              <input
                type="number"
                value={numberOfVisits}
                onChange={(e) => setNumberOfVisits(e.target.value)}
              />
              <label className="form-label">
                定义一个用户角色{' '}
                <span
                  style={{
                    color: 'gray',
                    fontWeight: 'lighter',
                  }}
                >
                  (可选)
                </span>
              </label>
              <input
                type="text"
                id="posts-input"
                value={userPersona}
                onChange={(e) => setUserPersona(e.target.value)}
              />
            </div>
            <label
              className="form-label"
              style={{ textAlign: 'left' }}
            >
              时间周期{' '}
              <span
                style={{
                  color: 'gray',
                  fontWeight: 'lighter',
                }}
              >
                (2023 年 6 月之前的日期有效)
              </span>
            </label>

            <div id="time-input">
              <input
                type="date"
                style={{ marginRight: '20px' }}
                value={startDate}
                onChange={(e) => setStartDate(e.target.value)}
              />
              <span style={{ fontSize: '15px' }}>到</span>
              <input
                type="date"
                style={{ marginLeft: '20px' }}
                value={endDate}
                onChange={(e) => setEndDate(e.target.value)}
              />
            </div>
            <button
              type="submit"
              className={`next-button ${isFormValid ? 'active' : ''}`}
              disabled={!isFormValid || isProcessing}
            >
              <span>开始</span>
              <span>→</span>
            </button>
          </form>
        </div>
        <div id="divider"></div>

        <div>
          <div>最近的</div>
          <div>
            <div>
              {recentActions.map((action, index) => (
                <div key={index}>
                  <a href={action.link} target="_blank">
                    <span>r/{action.obfuscated}</span>{' '}
                    <span>{action.actiond_date} (UTC)</span>
                  </a>
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
      {showOverlay ? renderLayout() : null}
    </div>
  )
}

注意:为了简化问题,这个组件故意省略了一些细节,我也移除了一些特定领域的细节

对于一个单一组件来说,这是大量的代码。阅读这些代码时,我脑海中首先冒出的想法是,为什么有人会这样编写一个组件呢?

但是我们需要理解的是,这可能不是一人之功,也不是一蹴而就的。这是许多人在同一个组件上工作,一次一次进行战术性更改,只是为了完成他们的功能。

最终,你得到了一大块设计留给了偶然的代码,不出所料,它的结果并不理想。每个人都发现将这个组件整理好的任务过于艰巨,但我们已经聚集了足够的勇气或挫败感来最终完成它。

从测试开始

当我打开 IDE 时,首先注意到的是一些未使用和被注释掉的代码行。但我抑制住了立即删除代码的冲动,不管它看起来多么微不足道。这是一个大型组件,我需要一段时间来理解它。与此同时,我想确保我没有破坏任何东西。

所以,如果没有测试,我首先做的事情就是写一些测试。记住,重构是改变代码设计的过程,而不改变其行为。我们不想走得太远。

此外,我们需要只关注那些将组件作为黑盒测试,并验证结果的测试。我们要确保它调用了作为 props 接收到的正确的回调函数,并且它正确渲染了一切。它具体如何实现这一点现在不是我们的关注点,因为它将会有所改变。

添加一个 Lint 规则

现在当我有了我的测试设置,我可以开始调整代码了。但是,我需要考虑为什么这些未使用的状态和代码首先被留在这里,而不是直接删除它们。需要有一个自动化的检查来捕捉这些问题,以便它们不会最终出现在代码库中。

理想情况下,这应该是一个在 CI 中运行的 lint 规则,以防止此类代码进入生产环境。现在,将代码注释掉并保留在那里始终是一个选项,但至少这是一个有意的决定。

然而,一个组件处于这种状态意味着我们的代码库中很可能还有其他组件也是这样。因此,在这里设置严格的 lint 规则会使我们的应用程序像圣诞树一样亮起来,我们的重构将变成整个代码库的大修。

我们不想让这次重构的范围蔓延。它花费的时间越长,合并冲突和错误发生的机会就越高。

所以我们应该至少添加一个规则,以便至少记录警告,这样我们可以在之后处理它们。

这是一个一般性的经验法则 – 如果你发现了你不希望出现在代码库中的做法,为它找到一个 lint 规则或自己制定一个。你能自动化并在代码审查之前处理的事情越多,就越好。

删除无用代码

在代码库中保留无用代码是没有理由的 – 注释掉的逻辑、未使用的状态、未使用的导入。如果你以后需要它,可以在版本控制中找到。所以我可以通过几个字段减少组件中的状态。

const [userPersona, setUserPersona] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [numberOfVisits, setNumberOfVisits] = useState('')
const [companyNumber, setCompanyNumber] = useState('')
const [numberIncorrect, setNumberIncorrect] = useState(0)
const [isFormValid, setIsFormValid] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [recentActions, setRecentActions] = useState([])
const [abortController, setAbortController] = useState(null)

状态臃肿

当我看到如此多的状态时,我立即知道这个组件做的太多了,尤其是因为其中一些值似乎是不相关的。它承担了太多的责任,至少我们可以将其分成更小的组件,这些组件可以通过 props 进行通信。

但我们如何决定如何拆分它呢?

臃肿的状态是一种代码异味,但它并不直接向我们展示潜在组件之间的“接缝”在哪里。为了做到这一点,我们需要探索 JSX。

大型条件判断

组件是 React 应用的主要单元,也是其复杂性背后的最大因素。将一个组件拆分为更小的组件是将复杂性分散到多个地方的最佳方式,这样更容易理解和处理。

这个组件根据一个值是否被设置返回两个代码块中的一个,我的直觉告诉我,我们可以将这些组件拆分为两个较小的组件,或者统一逻辑,使其更容易跟随。

return !numberIncorrect ? (
    // 许多 JSX...
) : (
    // 更多的 JSX...
)

如果两个标记块相似,最好将它们统一起来,并使用更细粒度的条件判断。这样我们可以传达变化的确切位置。如果它们不同,那么最好就是渲染两个不同的组件,并在它们之间分割逻辑。

当我继承这样一个组件并且不确定哪种方法最好时,我会进行一次并排的差异检查,以查看 JSX 的哪些部分是相似的。

在这种情况下,标记不能更不同了。这个组件本质上是渲染一个多步骤表单,两个块代表完全不同的表单。

我们可以使用现有的 Form 组件来只做出渲染决定,然后将其余工作留给子组件。我在父组件中留下的唯一东西是在两个地方都使用的 logo 图片,以进一步清理子表单。

function Form() {
  const [companyNumber, setCompanyNumber] = useState(undefined)

  return (
    <div>
      <div>
        <img
          src={require('../imgs/LogoWhite.png')}
          style={{ width: '200px', marginTop: '50px' }}
          alt="Logo"
        />
      </div>
      {!companyNumber ? (
        <CompanyNumberForm onSubmit={setCompanyNumber} />
      ) : (
        <ActionForm companyNumber={companyNumber} />
      )}
    </div>
  )
}

当我们把 JSX 移到子组件时,IDE 会立刻高亮显示所有缺失的函数和值,这让我们更容易地分离状态。

组件职责

观察这些表单时,我们注意到第一个表单只是将其数据传递给父组件,而第二个表单接受第一个表单的数据,并在此基础上提交它。这使得我们的子表单不一致,新开发者阅读这段代码时会想知道我们为什么会做出这样的决定。

在这个例子中,第二个表单之所以提交数据,只是因为它是链中的最后一个。但将来可能不是这样。可能在它之后还有另一个步骤,这将使我们不得不再次重构代码,将提交逻辑移到下一个表单。

为了避免这种情况,我们可以让父组件负责最终的提交。ActionForm 只需要验证其数据并将其传递给父组件。剩下的事情都可以由父组件完成。这样,即使我们添加了另一个步骤,只有主要的 Form 组件会受到影响。

function Form() {
  const [companyNumber, setCompanyNumber] = useState(undefined)

  const createAction = () => {
    // ...
  }

  return (
    <div>
      <div>
        <img
          src={require('../imgs/LogoWhite.png')}
          style={{ width: '200px', marginTop: '50px' }}
          alt="Logo"
        />
      </div>
      {!companyNumber ? (
        <CompanyNumberForm onSubmit={setCompanyNumber} />
      ) : (
        <ActionForm onSubmit={createAction} />
      )}
    </div>
  )
}

注意,我没有使用一个通用的函数名称作为实际的处理程序。我使用了 createAction,这个名称更具描述性,读起来像一个句子 onSubmit={createAction}。你不必跳转到函数就能看出它在做什么,名称就足够了。

将实用函数从组件中移出

我们经常使用小型实用函数来格式化数据,在显示或发送之前。在这个组件中,我们有一个日期格式化函数。

const formatDate = (dateStr) => {
  return dateStr.replace(/-/g, '')
}

我的经验法则是,只有当函数需要访问组件内的状态或其他值时,它们才应该存在于组件内。如果它们可以接收一个参数,我更愿意将功能放在组件外。

import { formatDate } from './utils'

如果它仅在那里使用,我们可以将其放在同一个文件中,或者将其移动到位于组件旁边的 utils 文件中。如果另一个组件需要相同的实用函数,我们可以将它移动到应用程序的顶层。

此外,组件实际上不应该真的意识到它发送的数据是如何构建的,但我们一会儿再讨论这个问题。

数据获取

当我们将逻辑移动到 CompanyNumberFormActionForm 组件时,我们注意到 ActionForm 正在获取需要显示的最近动作的列表。

useEffect(() => {
  if (!previousActions) {
    fetchPreviousActions()
  }

  setIsFormValid(startDate && endDate && endDate > startDate)
}, [numberOfObfuscated, startDate, endDate])

const fetchPreviousActions = async () => {
  try {
    const response = await fetch('https://api.com/actions', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    })

    if (!response.ok) {
      throw new Error(`HTTP 错误!状态:${response.status}`)
    }

    const data = await response.json()
    data.sort(
      (a, b) => new Date(b.actiond_date) - new Date(a.actiond_date)
    )
    setRecentActions(data)
  } catch (error) {
    console.error('获取最近动作失败', error)
  }
}

一般来说,最好避免广泛的 useEffect 调用,因为它们会变成复杂性的温床。负责调用数据获取函数的人也负责管理表单的状态。

让我们将数据获取逻辑转移出去。

一方面,将数据尽可能地靠近使用它的地方获取是好的。另一方面,如果我们在组件树的更高处获取它,以避免布局变动和加载指示器。让我们看看我们如何用一个解决方案解决这两个问题。

如果我们使用像 react-query 这样的数据获取库,将会节省我们大量的时间和降低复杂度。

const { data } = useQuery({
  queryKey: ['recentActions'],
  queryFn: fetchPreviousActions,
  select: (data) =>
    data.sort(
      (a, b) => new Date(b.actiond_date) - new Date(a.actiond_date)
    ),
})

这将替代我们的 useEffect 调用,并且我们甚至可以将排序从获取函数中移出到 useQuery 调用中,以仅保留获取函数负责通信。

此外,这消除了使用 recentActions 状态字段的需要,因为我们可以使用 useQuery 返回的数据。所以通过使用库来实现这个逻辑,我们卸下了许多我们将不再需要维护的复杂性。

我们应该使用 axiosky 而不是 fetch,因为如果请求失败它会自动产生一个错误,而且它们提供了一个更简单的 API。这样我们就可以利用 react-query 的内建错误处理,并且如果请求失败,我们不必手动抛出错误。

const fetchPreviousActions = async () => {
  const { data } = await axios.get('https://api.com/actions')
  return data
}

然后我们可以更进一步,将数据获取逻辑移动到一个自定义钩子中。

function useRecentActions() {
  const { data } = useQuery({
    queryKey: ['recentActions'],
    queryFn: fetchPreviousActions,
    select: (data) =>
      data.sort(
        (a, b) => new Date(b.actiond_date) - new Date(a.actiond_date)
      ),
  })

  return data
}

这样我们就替代了所有这些:

useEffect(() => {
  fetchPreviousActions()
}, [])

const fetchPreviousActions = async () => {
  try {
    const response = await fetch('https://api.com/actions', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    })

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }

    const data = await response.json()
    data.sort(
      (a, b) => new Date(b.actiond_date) - new Date(a.actiond_date)
    )
    setRecentActions(data)
  } catch (error) {
    console.error('Failed to fetch recent actions', error)
  }
}

通过单个自定义钩子调用:

const recentActions = useRecentActions()

使用更多自定义钩子

我想表达的是,我们的组件实际上需要的数据远比我们在它们中使用的要少。

它们只需要渲染我们返回并排序的最终数据,但要了解我们正在使用的库、数据的格式、我们正在发起的请求、请求的目的地等。

它们不需要那些信息。而且,说“它们”时,我指的是“你”——将来会扩展此组件的人。如果你有任务要改变表单错误在标记中显示的位置,你不需要了解所有这些细节。

这些小决定汇总起来。如果我们在设计中这里那里添加细微的改进,最终我们的组件将会更加简单易懂。

相反,如果我们不注意小的复杂度增长,它们随时间也会积累。

控制请求取消

我们知道我们希望我们的父组件处理最终的数据提交,让我们来看看它周围的一些细节。目前处理程序看起来是这样的。

const handleSubmit = async (event) => {
  event.preventDefault()
  if (!isFormValid) return

  setShowOverlay(true)
  setIsProcessing(true)

  // 构造表单数据对象
  const formData = {
    userPersona,
    startDate,
    endDate,
    numberOfVisits: parseInt(numberOfVisits, 10),
  }
  // 使用表单数据调用 API
  await callBackendAPI(formData)
  setIsProcessing(false)
}

const callBackendAPI = async (formData) => {
  const controller = new AbortController()
  setAbortController(controller)
  formData.startDate = formatDate(formData.startDate)
  formData.endDate = formatDate(formData.endDate)

  try {
    const response = await fetch('https://api.com/client', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(formData),
      signal: controller.signal,
    })

    if (!response.ok) {
      throw new Error(`HTTP 错误!状态: ${response.status}`)
    }

    const data = await response.json()
    setShowOverlay(false)
    window.open(
      'https://app.com/client/' + data.id,
      '_blank',
      'noopener,noreferrer'
    )
    window.location.reload()
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('中止抓取')
    } else {
      console.error('调用 API 失败', error)
    }
  } finally {
    setShowOverlay(false)
    setIsProcessing(false)
  }
}

这里有很多需要详细考虑的地方。我们看到 callBackendAPI 在 HTTP 逻辑、重定向以及在此之前的数据格式化之间承担了很多混杂的职责。它的名称也可以更具描述性,现在它的名字太通用了。

我们可以将 callBackendAPI 改为 storeActioncreateAction

这里的中断控制器没有任何用处,因为我们正在发起一个 POST 请求。如果这是一次查询,那么我们可以取消请求,因为我们不再对它返回的数据感兴趣了。

const controller = new AbortController()
setAbortController(controller)

但是查询通常是没有服务器副作用的 GET 请求。

对于变更操作,一旦请求被触发且服务器正在处理它,取消请求就没有意义了,副作用将会发生,并且记录会被存储。所以我相信我们可以安全地移除取消逻辑。

移除它将允许我们去除另一块组件状态和我们不再需要的额外逻辑。

我注意到的下一件事是,我们同时设置了两个布尔状态值来表示我们正在处理一个请求并且应该显示一个覆盖层。

setShowOverlay(true)
setIsProcessing(true)

但是在审查代码后,我们看到覆盖层只有在处理请求时才可见。因此,额外的状态值是多余的,只会增加 UI 的复杂性。

如果有人正在阅读 JSX,他们会想知道我们究竟何时设置 showOverlay 值。否则,如果我们使用 isProcessing 值,我们就明确了在等待响应时覆盖层出现。

所以在这里,我会完全从组件中删掉 showOverlay

另外,如果我们对这个逻辑使用 react-query,我们可以使用它为每次变更提供的加载标志,而不是自己管理另一个状态。

const { mutate, isLoading } = useMutation({
  mutationFn: callBackendAPI,
  onSuccess: (data) => {
    window.open(
      'https://app.com/client/' + data.id,
      '_blank',
      'noopener,noreferrer'
    )
    window.location.reload()
  },
})

调用变更的函数看起来是这样的:

const createAction = async (e) => {
  e.preventDefault()

  if (!isFormValid) {
    return
  }

  mutate({
    userPersona,
    startDate: formatDate(startDate),
    endDate: formatDate(endDate),
    numberOfVisits: parseInt(numberOfVisits, 10),
  })
}

该函数现在处理数据准备,将正确的数据结构传递给变更。

表单提交

当我们提取它们时,很容易注意到哪个组件在使用哪些状态值和逻辑,因为 IDE 会立即高亮它们。

然后我注意到两个表单都在重用同一个表单提交处理函数。这是一个明显的代码异味,因为最好的情况是我们有一个函数做两件事,这从来不利于维护性。

但在这个例子中,第一个表单调用提交函数似乎完全没有必要,因为得益于 isFormValid 检查,它总会提前退出。第一个表单有两个事件处理程序,一个用于提交它的按钮,另一个处理提交事件。

因此,我们可以用按钮上使用的事件替换表单提交,只让按钮实际提交表单。

<form onSubmit={handleSubmitCompanyNumber}>...</form>

我们还必须对实际的处理程序进行重构,因为表单将不得不使用通过 props 传递的回调将值发送到其父级。目前看起来是这样。

const handleSubmitCompanyNumber = (number) => {
  // 这是不需要的,我们已经在状态中设置了值
  setCompanyNumber(number)
  if (number.length < 9) setNumberIncorrect(1)
  else setNumberIncorrect(0)
}

我会将其重构为。

const handleSubmitCompanyNumber = (e) => {
  e.preventDefault()

  if (number.length < 9) {
    setNumberIncorrect(true)
    return
  }

  onSubmit(number)
}

我已经移除了不必要的状态设置,该值已经在状态中,这是一个不必要的重新渲染。当你因为引用传值担心重新渲染时,考虑一下你是否不必要地设置了状态。

然后,我还移除了 else 语句,以支持提前返回,这样我们就可以像使用守卫条款一样使用条件语句。这表明了函数的主逻辑路径是什么。

此外,我像使用适当的标志一样使用 numberIncorrect 值,而不是使用 01。我们还可以将这个值命名为更通用的东西,比如 isFormCorrect,以与我们的其他表单组件保持一致。

重新考虑 useEffect

我们讨论了 ActionForm 组件中广泛的 useEffect 调用,并得出结论,它必须被重构。但是一直困扰我的是,在每次敲击键盘时验证表单似乎不是一个好主意。

useEffect(() => {
  setIsFormValid(startDate && endDate && endDate > startDate)
}, [numberOfObfuscated, startDate, endDate])

如果我们在表单提交时或用户从其中一个字段点击离开时进行此操作,会更好。现在,当用户点击离开时进行验证是组件逻辑的一个变化,我们知道当我们在重构时,我们只想改变设计。

createAction 函数中,我们可以用一个验证数据的函数调用替换简单的检查。

const createAction = async (e) => {
  e.preventDefault()

  if (!formIsFilledOut()) {
    setIsFormValid(false)
    return
  }

  // ...
}

简化表单验证

通过找到替代命令逻辑与描述逻辑的方法,我们可以大大减少复杂性。我们已经看到了这一点在数据获取方面的应用,探索如何将同一高级思想应用于表单也是值得的。

我们可以结合使用表单库和模式库来使我们的验证更加简单,并且只在表单提交时进行。这样,我们只需要描述我们希望从表单中得到的数据的形状,并在表单成功提交时将其传回给父组件。然后我们渲染另一个组件并重复,等待成功提交。

const schema = z.object({
  userPersona: z.string().nonempty('用户角色是必须的'),
  startDate: z
    .date()
    .refine((date) => date instanceof Date, '开始日期是必须的'),
  endDate: z
    .date()
    .refine((date) => date instanceof Date, '结束日期是必须的')
    .refine(
      (date, ctx) => date > ctx.parent.startDate,
      '结束日期必须在开始日期之后'
    ),
})

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: zodResolver(schema),
})

接着,如果验证成功,我们的 createAction 函数将被调用,且该函数将仅处理为变更准备数据。

const createAction = async (e) => {
  mutate({
    userPersona,
    startDate: formatDate(startDate),
    endDate: formatDate(endDate),
    numberOfVisits: parseInt(numberOfVisits, 10),
  })
}

我为了简化而跳过了一些细节,但是为了进一步探索这种方法,请查看 react-hook-form 库。

我们只是在用库替代逻辑吗?

我们并没有对代码应用任何高超的重构技巧。我们只是将它分解成更小的组件,用库调用替换了一些逻辑。

事实是,高质量的代码往往正是这样的结果——明确的职责和使用恰当的工具。

在现代前端开发中,数据获取和表单可能是复杂性最大的两个来源,幸运的是我们有工具可以解决我们常见的问题。与其重新发明轮子,不如依靠它们,并使用经过验证、测试的解决方案,帮助使我们的代码库更加简单。

但是我选择技术的最大因素在于它们的 API。说到底,我在优化可维护性,我希望我留下的代码更易于阅读和理解。如果它们的 API 差,我就不会如此强烈地推荐它们。

将渲染函数重构为组件

我在许多代码库中看到的一种实践是在组件内部嵌套函数来渲染其 JSX 的一部分。ActionForm 组件中有一个函数用于渲染一个可以安全提取到自己的组件中的遮罩层。

function LoadingOverlay() {
  return (
    <div className="loading-overlay">
      <div className="loading-content">
        <div className="loading-bar">正在分析...</div>
        <button
          onClick={handleCancelaction}
          className="cancel-button"
        >
          取消
        </button>
      </div>
    </div>
  )
}

然后,我们可以在 JSX 中使用组件,而不是调用函数。

isLoading ? <LoadingOverlay /> : null

不必要的注释

我在许多代码库中发现的另一个反模式是只重复代码已经在做的事情的注释。这是我们正在重构的组件中的一个例子。

const handleCancelaction = () => {
  if (abortController) {
    abortController.abort() // 中止 fetch 请求
  }
  setShowOverlay(false)
  setIsProcessing(false)
}

这里的注释绝对是不必要的,因为它没有为实现添加任何上下文。它只是重申了代码已经描述的内容。我们可以安全地删除它而不会失去任何有价值的知识。

原始代码中的这个函数也是如此:

const handleSubmit = async (event) => {
  event.preventDefault()
  if (!isFormValid) return

  setShowOverlay(true)
  setIsProcessing(true)

  // 构造表单数据对象
  const formData = {
    userPersona,
    startDate,
    endDate,
    numberOfVisits: parseInt(numberOfVisits, 10),
  }
  // 使用表单数据调用 API
  await callBackendAPI(formData)
  setIsProcessing(false)
}

当你想要添加有关领域的上下文时才写注释,不要重复代码。

内联样式

内联样式表明有人急于完成工作,想要快速搞定。老实说,我也有过这种情况,我理解这一点。

但内联样式会损害代码库的可维护性,如果你想让它们靠近标记定义,我建议使用像 Tailwind 这样的工具类库。

缩写类可以节省你的时间,并且该库内置了设计系统,因此你可以拥有一致外观的 UI。如果你想了解更多,我在这篇文章中已经详细说明。

将循环重构为组件

我会做的最后一个小优化是将最近操作的列表提取到另一个组件中。JSX 中的循环会给标记添加很多缩进和噪声,我喜欢把它们提取出来,以保持组件的简单性。

<div>
  {recentActions.map((action) => (
    <div key={action.id}>
      <a href={action.link} target="_blank">
        <span>r/{action.obfuscated}</span>{' '}
        <span>{action.actiond_date} (UTC)</span>
      </a>
    </div>
  ))}
</div>

我们可以将其转换为一个组件:

<RecentActionsList />

总结

我们还可以做更多的事情来改善组件的设计,但我认为我们已经解决了复杂性的客观来源 – 臃肿的组件、网络、表单和状态。其他的一切开始带来边际效益。

如果你设法解决了这四个点,那么你的组件其余部分将会容易得多。