编写自己的 React Hooks - TDD 示例

2025-06-10

编写自己的 React Hooks - TDD 示例

在我的上一篇文章中,我讨论了如何编写自己的钩子将命令式代码封装在有用且可重用的对象中,从而使您的组件变得简单且完全声明性。

在这篇文章中,我会用一个更简单的例子和​​更少的代码来解释同样的概念。或许更重要的是,这将给我们提供空间来实际操作并体验 TDD 的优势。开始吧……


想象一下,我们希望能够在正在构建的应用中尝试各种字体。在实际使用之前,很难直观地了解字体的外观。因此,在实际使用中轻松切换字体会非常方便,就像这样:

可点击字体


编写测试

假设这不是一个(有点)人为的例子,而是我们应用中的一个实际功能。我们首先使用React 测试库编写一个测试。

// src/Title.spec.js

import Title from './Title'

test('Cycles through a list of fonts when clicked', () => {
  const text = 'Clickable Fonts'
  const { getByText } = render(<Title>{text}</Title>)

  const fontBefore = window.getComputedStyle(getByText(text)).fontFamily

  fireEvent.click(getByText(text))

  const fontAfter = window.getComputedStyle(getByText(text)).fontFamily

  expect(fontBefore).not.toEqual(fontAfter)
})
Enter fullscreen mode Exit fullscreen mode

这个测试有一些问题,其中最重要的一点是测试 CSS 不是一个好主意,但除了从用户角度来看,我们还不知道我们的组件将如何工作。点击时更改样式该组件的功能,所以这可以让我们继续前进。

正如预期的那样,我们的测试失败了。(红色,绿色,重构,对吧?)

测试失败


使测试通过

为了使测试通过,我们创建一个组件,添加一些 Google 字体,通过Styled-ComponentsTitle添加一些样式,添加一个钩子来跟踪当前显示的字体,以及一个用于更改字体的处理程序。最终效果如下:useStateonClick

// src/Title.js

function Title({ children }) {
  const [fontIndex, setFontIndex] = React.useState(0)

  const handleChangeFont = () =>
    setFontIndex(fontIndex >= fontList.length - 1 ? 0 : fontIndex + 1)

  const fontList = [
    'Indie Flower',
    'Sacramento',
    'Mansalva',
    'Emilys Candy',
    'Merienda One',
    'Pompiere',
  ]

  const fontFamily = fontList[fontIndex]

  const StyledTitle = styled.h1`
    font-size: 3rem;
    cursor: pointer;
    user-select: none;
    font-family: ${fontFamily};
  `

  return <StyledTitle onClick={handleChangeFont}>{children}</StyledTitle>
}
Enter fullscreen mode Exit fullscreen mode

这使得我们的测试通过了,耶。

通过测试


该组件的工作方式如 CodeSandbox 演示中所示。


我们可以做得更好

我们在这方面遇到了一些问题。我们希望组件更具声明性。目前,它显示了用户点击时字体如何变化的所有细节。

还有一个问题是,测试组件中的 CSS 感觉有点不对劲。不过,我们先解决第一个问题,因为这很容易。


我们只需将所有逻辑推送到我们自己的自定义钩子中。

我们的新钩子如下所示:

// src/useClickableFonts.js

const useClickableFonts = fontList => {
  const [fontIndex, setFontIndex] = React.useState(0)

  const handleChangeFont = () =>
    setFontIndex(fontIndex >= fontList.length - 1 ? 0 : fontIndex + 1)

  const fontFamily = fontList[fontIndex]

  return { fontFamily, handleChangeFont }
}
Enter fullscreen mode Exit fullscreen mode

我们的组件如下所示:

// src/Title.js

function Title({ children }) {
  const { fontFamily, handleChangeFont } = useClickableFonts([
    'Indie Flower',
    'Sacramento',
    'Mansalva',
    'Emilys Candy',
    'Merienda One',
    'Pompiere',
  ])

  const StyledTitle = styled.h1`
    font-size: 3rem;
    cursor: pointer;
    user-select: none;
    font-family: ${fontFamily};
  `

  return <StyledTitle onClick={handleChangeFont}>{children}</StyledTitle>
}
Enter fullscreen mode Exit fullscreen mode

注意,我们将字体的声明留在了组件中,并将它们传递给钩子。这很重要,因为这是我们想要组件做的事情的一部分,声明它们所有可能的状态。我们只是不想让它们知道它们是如何进入这些状态的。

Styled-Components API 也是完全声明式的,并且是组件实现的一部分。它会保留下来。


我们的测试仍然通过,所以我们知道没有破坏任何东西。有了测试的安全性,重构就变得有趣了。

通过测试


我们的组件仍然可以工作:(CodeSandbox 演示)。


将字体名称添加到页脚

当我们不断点击它时,我们意识到如果能知道当前显示的是哪种字体就好了。但是,我们希望这些信息远离组件Title,以免干扰我们正在进行的用户体验设计测试。现在,我们先把它巧妙地显示在页脚中。

但是,我们如何将字体信息从Title组件中获取到页面上的不同位置呢?

答案当然是提升状态。幸运的是,将逻辑和状态放入我们自己的 hook 中,使得这项任务变得非常简单,只需将useClickableFonts行向上移动并向下传递 props 即可。

// src/App.js

function App() {
  const { fontFamily, handleChangeFont } = useClickableFonts([
    'Indie Flower',
    'Sacramento',
    'Mansalva',
    'Emilys Candy',
    'Merienda One',
    'Pompiere',
  ])

  return (
    <>
      <Title fontFamily={fontFamily} handleChangeFont={handleChangeFont}>
        Clickable Fonts
      </Title>
      <Footer>{fontFamily}</Footer>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

太好了,我们将钩子移到了最近的共同祖先(在这个简单的例子中是App),并且我们将道具传递到Title组件中并在中显示字体的名称Footer


Title组件将成为纯粹的、确定性的组件:

// src/Title.js

function Title({ fontFamily, handleChangeFont, children }) {
  const StyledTitle = styled.h1`
    font-size: 3rem;
    cursor: pointer;
    user-select: none;
    font-family: ${fontFamily};
  `

  return <StyledTitle onClick={handleChangeFont}>{children}</StyledTitle>
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以在页脚看到字体的名称了。点击它:


然而,我们的测试现在失败了。(查看CodeSandbox中失败测试的demo 。)

测试失败


修复测试

这让我们明白了为什么我们总觉得测试出了点问题。当我们更新组件以接收 props 而不是useClickableFont直接使用 hooks 时,测试也需要更新。然而,这有点出乎意料,因为我们没有更改或重构任何逻辑。

我们的测试很脆弱,因为我们测试错了地方。我们需要测试的是字体切换的必要环节,而不是(现在)简单且声明式的 React 组件。React 和 Styled-Components 的具体细节已经经过了充分的测试。只要不添加自己的逻辑,我们就可以放心使用它们。

这并不意味着我们应该测试实现细节。当我们编写自己的 hooks 时,我们会向 React 组件添加将要使用的 API。我们需要测试这个新的 API,但需要从外部进行。


我们真正想要测试的是我们的hooks。我们可以使用react-hooks-testing-libraryuseClickableFont来实现。

我们的新测试如下所示:

// src/useClickableFonts.spec.js

import useClickableFonts from './useClickableFonts'

test('Cycles through a list of fonts', () => {
  const { result } = renderHook(() =>
    useClickableFonts(['Indie Flower', 'Sacramento', 'Mansalva']),
  )

  expect(result.current.fontFamily).toBe('Indie Flower')

  act(() => result.current.handleChangeFont())

  expect(result.current.fontFamily).toBe('Sacramento')

  act(() => result.current.handleChangeFont())

  expect(result.current.fontFamily).toBe('Mansalva')

  act(() => result.current.handleChangeFont())

  expect(result.current.fontFamily).toBe('Indie Flower')
})
Enter fullscreen mode Exit fullscreen mode

注意,我们是从外部测试它,就像用户使用它一样。测试应该类似于钩子的使用方式。在这种情况下,用户是一个 React 组件。我们可以对这个新测试充满信心,因为测试使用它的方式就像组件一样。

我们测试了钩子每次调用处理程序时是否按顺序返回第一、第二和第三个字体。我们还测试了它是否再次循环到第一个字体。


这是 CodeSandbox 上的最终组件:


结论

一开始就确定正确的设计或抽象并不总是那么容易。这就是为什么重构环节red, green, refactor如此重要,忽略这一步往往会导致代码质量下降和技术债务不断增加。

通常,将使代码能够运行和使代码正确运行的任务分开可以创造自由。自由地开始,然后自由地发现更好的实现。

我们测试了一个新组件,发现了一个初步的实现。将逻辑提取到钩子中,使我们的代码更容易修改。修改钩子帮助我们找到了更好的测试方法。

我们最终得到了干净的、声明性的组件,并且钩子为我们提供了一个方便的界面来测试和重用命令式代码。

鏂囩珷鏉ユ簮锛�https://dev.to/namick/writing-your-own-react-hooks-simplified-57li
PREV
#codeNewbie 需要开始/改进什么?
NEXT
像专业人士一样删除 Node 模块