编写 SOLID React Hooks

2025-05-26

编写 SOLID React Hooks

SOLID 是比较常用的设计模式之一。它在许多语言和框架中都有广泛应用,也有一些文章介绍如何在 React 中使用它。

每篇关于 SOLID 的 React 文章都以略微不同的方式呈现该模型,一些将其应用于组件,另一些应用于 TypeScript,但很少有文章将这些原则应用于钩子。

由于钩子是 React 基础的一部分,我们将在这里研究 SOLID 原则如何应用于它们。

单一职责原则(SRP)

Solid 中的第一个字母 S 是最容易理解的。它的本质意思是,让一个钩子/组件做一件事。

// Single Responsibility Principle
A module should be responsible to one, and only one, actor
Enter fullscreen mode Exit fullscreen mode

例如,查看下面的 useUser 钩子,它获取用户和待办任务,并将任务合并到用户对象中。

import { useState } from 'react'
import { getUser, getTodoTasks } from 'somewhere'

const useUser = () => {
  const [user, setUser] = useState()
  const [todoTasks, setTodoTasks] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  useEffect(() => {
    const tasks = getTodoTasks()
    setTodoTasks(tasks)
  }, [])

  return { ...user, todoTasks }
}
Enter fullscreen mode Exit fullscreen mode

这个钩子不够稳固,它不符合单一职责原则。因为它既要负责获取用户数据,又要负责待办任务,这完全是两码事。

相反,上述代码应该分成两个不同的钩子,一个用于获取有关用户的数据,另一个用于获取任务。

import { useState } from 'react'
import { getUser, getTodoTasks } from 'somewhere'

// useUser hook is no longer responsible for the todo tasks.
const useUser = () => {
  const [user, setUser] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  return { user }
}

// Todo tasks do now have their own hook.
// The hook should actually be in its own file as well. Only one hook per file!
const useTodoTasks = () => {
  const [todoTasks, setTodoTasks] = useState()

  useEffect(() => {
    const tasks = getTodoTasks()
    setTodoTasks(tasks)
  }, [])

  return { todoTasks }
}
Enter fullscreen mode Exit fullscreen mode

这条原则适用于所有钩子和组件,它们都应该只做一件事。你需要问自己以下几点:

  1. 这是一个应该显示 UI(展示)还是处理数据(逻辑)的组件吗?
  2. 这个钩子应该处理哪种类型的数据?
  3. 这个钩子/组件属于哪一层?它是处理数据存储,还是 UI 的一部分?

如果您发现自己构建的钩子对上述每个问题都没有单一的答案,那么您就违反了单一责任原则。

这里需要注意一个有趣的问题,那就是第一个问题。这个问题的真正含义是,渲染 UI 的组件不应该同时处理数据。这意味着,为了严格遵循这一原则,每个显示数据的 React 组件都应该有一个钩子来处理其逻辑和数据。换句话说,数据不应该在显示它的组件中获取。

为什么在 React 中使用 SRP?

这种单一职责原则实际上与 React 非常契合。React 遵循基于组件的架构,这意味着它由组合在一起的小组件组成,这些小组件可以共同构建并形成一个应用程序。组件越小,它们就越有可能被复用。这适用于组件和钩子。

因此,React 或多或少地建立在单一职责原则之上。如果不遵循这一原则,你会发现自己总是在编写新的 hooks 和组件,却很少复用它们。

不遵循单一职责原则会使你的代码难以测试。如果不遵循这一原则,你通常会发现你的测试文件有几百行,甚至上千行代码。

开放/封闭原则(OCP)

我们继续讨论开闭原则,毕竟它是 SOLID 的下一个字母。OCP 和 SRP 一样,都是比较容易理解的原则之一,至少它的定义是这样的。

// Open/Closed Principle
Software entities (classes, modules, functions, etc.) should
be open for extension, but closed for modification 
Enter fullscreen mode Exit fullscreen mode

对于刚开始使用 React 的新手来说,这句话可以翻译为:

Write hooks/component which you never will have a reason to 
touch again, only re-use them in other hooks/components
Enter fullscreen mode Exit fullscreen mode

回想一下本文前面提到的单一职责原则;在 React 中,你需要编写小组件并将它们组合在一起。让我们看看为什么这样做是有帮助的。

import { useState } from 'react'
import { getUser, updateUser } from 'somewhere'

const useUser = ({ userType }) => {
  const [user, setUser] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  const updateEmail = (newEmail) => {
    if (user && userType === 'admin') {
      updateUser({ ...user, email: newEmail })
    } else {
      console.error('Cannot update email')
    }
  }

  return { user, updateEmail }
}
Enter fullscreen mode Exit fullscreen mode

上面的钩子获取并返回一个用户。如果用户类型是管理员,则允许该用户更新其电子邮件。普通用户不允许更新其电子邮件。

上面的代码绝对不会让你被炒鱿鱼。但它可能会惹恼你团队里的后端工程师,那个把设计模式书当做睡前故事读给孩子听的家伙。我们就叫他 Pete 吧。

Pete 会抱怨什么呢?他会要求你像下面这样重写组件。将管理功能移到它自己的 useAdmin 钩子中,而 useUser 钩子除了普通用户可以使用的功能外,不保留任何其他功能。

import { useState } from 'react'
import { getUser, updateUser } from 'somewhere'

// useUser does now only return the user, 
// without any function to update its email.
const useUser = () => {
  const [user, setUser] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  return { user }
}

// A new hook, useAdmin, extends useUser hook,
// with the additional feature to update its email.
const useAdmin = () => {
  const { user } = useUser()

  const updateEmail = (newEmail) => {
    if (user) {
      updateUser({ ...user, email: newEmail })
    } else {
      console.error('Cannot update email')
    }
  }

  return { user, updateEmail }
}
Enter fullscreen mode Exit fullscreen mode

Pete 为什么要要求更新?因为 Pete 这个不尊重人、吹毛求疵的家伙宁愿让你现在花时间重写那个钩子,明天再来重新审核代码,而不是等到将来有其他类型的用户时,再用一个小小的 if 语句更新代码。

嗯,这是消极的说法......乐观的说法是,有了这个新的 useAdmin 钩子,当您打算实现仅影响管理员用户的功能时,或者当您添加新类型的用户时,您不必在 useUser 钩子中更改任何内容。

当添加新的用户类型或更新 useAdmin 钩子时,无需修改 useUser 钩子或更新其任何测试。这意味着,您无需在添加新用户类型(例如虚假用户)时意外地将错误发送给普通用户。您只需添加一个新的 userFakeUser 钩子,您的老板就不会在周五晚上 9 点打电话给您,因为客户在发薪周末遇到了银行账户显示虚假数据的问题。

床下的前端开发人员
皮特的儿子知道要小心意大利面条代码开发人员

为什么在 React 中使用 OCP?

React 项目应该包含多少个钩子和组件,这还有待商榷。每个钩子和组件都需要一定的渲染成本。React 不像 Java,一个简单的 TODO 列表实现就需要 22 种设计模式,甚至需要 422 个类。这就是狂野西部网络 (www) 的魅力所在。

然而,开放/封闭原则在 React 中显然也是一种难以应用的模式。上面使用 hooks 的示例非常简洁,作用不大。随着 hooks 的增多和项目规模的扩大,这一原则变得尤为重要。

这可能需要你额外添加一些钩子,并且实现起来会稍微耗时一些,但你的钩子会变得更具扩展性,这意味着你可以更频繁地复用它们。你将减少重写测试的次数,从而使钩子更加稳固。最重要的是,即使你从未动过旧代码,也不会在旧代码中产生 bug。

不要碰没有破损的东西
上帝知道不要碰没有破损的东西

里氏替换原则(LSP)

啊啊,这名字……Liskov 到底是谁?谁会代替她?还有,这定义,难道一点道理都没有吗?

If S subtypes T, what holds for T holds for S
Enter fullscreen mode Exit fullscreen mode

这个原则显然与继承有关,但在 React 或 JavaScript 中,继承的实践并不像大多数后端语言那样普遍。JavaScript 直到 ES6 才有了类的概念,而 ES6 是在 2015/2016 年左右作为基于原型的继承的语法糖引入的。

考虑到这一点,此原则的用例实际上取决于你的代码是什么样子。类似于里氏原则的、在 React 中适用的原则可能是:

If a hook/component accepts some props, all hooks and components 
which extends that hook/component must accept all the props the 
hook/component it extends accepts. The same goes for return values.
Enter fullscreen mode Exit fullscreen mode

为了说明这一点,我们可以看看两个存储钩子,useLocalStorage 和 useLocalAndRemoteStorage。

import { useState } from 'react'
import { 
  getFromLocalStorage, saveToLocalStorage, getFromRemoteStorage 
} from 'somewhere'

// useLocalStorage gets data from local storage.
// When new data is stored, it calls saveToStorage callback.
const useLocalStorage = ({ onDataSaved }) => {
  const [data, setData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
    onDataSaved(newData)
  }

  return { data, saveToStorage }
}

// useLocalAndRemoteStorage gets data from local and remote storage.
// I doesn't have callback to trigger when data is stored.
const useLocalAndRemoteStorage = () => {
  const [localData, setLocalData] = useState()
  const [remoteData, setRemoteData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setLocalData(storageData)
  }, [])

  useEffect(() => {
    const storageData = getFromRemoteStorage()
    setRemoteData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
  }

  return { localData, remoteData, saveToStorage }
}
Enter fullscreen mode Exit fullscreen mode

通过上面的钩子,useLocalAndRemoteStorage 可以看作是 useLocalStorage 的子类型,因为它执行与 useLocalStorage 相同的操作(保存到本地存储),而且还通过将数据保存到其他位置扩展了 useLocalStorage 的功能。

这两个 hooks 有一些相同的 props 和返回值,但 useLocalAndRemoteStorage 缺少 useLocalStorage 可以接受的 onDataSaved 回调 prop。返回属性的名称也不同,本地数据在 useLocalStorage 中命名为 data,但在 useLocalAndRemoteStorage 中命名为 localData。

如果你问 Liskov,这违背了她的原则。实际上,当她尝试更新 Web 应用程序以将数据持久化到服务器端时,她会非常愤怒,因为她意识到自己不能简单地用 useLocalAndRemoteStorage 钩子替换 useLocalStorage,仅仅因为某个懒惰的开发人员从未实现 useLocalAndRemoteStorage 钩子的 onDataSaved 回调。

Liskov 会很不情愿地更新 hook 来支持这一点。同时,她还会更新 useLocalStorage hook 中本地数据的名称,使其与 useLocalAndRemoteStorage 中本地数据的名称匹配。

import { useState } from 'react'
import { 
  getFromLocalStorage, saveToLocalStorage, getFromRemoteStorage 
} from 'somewhere'

// Liskov has renamed data state variable to localData
// to match the interface (variable name) of useLocalAndRemoteStorage.
const useLocalStorage = ({ onDataSaved }) => {
  const [localData, setLocalData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setLocalData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
    onDataSaved(newData)
  }

  // This hook does now return "localData" instead of "data".
  return { localData, saveToStorage }
}

// Liskov also added onDataSaved callback to this hook,
// to match the props interface of useLocalStorage.
const useLocalAndRemoteStorage = ({ onDataSaved }) => {
  const [localData, setLocalData] = useState()
  const [remoteData, setRemoteData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setLocalData(storageData)
  }, [])

  useEffect(() => {
    const storageData = getFromRemoteStorage()
    setRemoteData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
    onDataSaved(newData)
  }

  return { localData, remoteData, saveToStorage }
}
Enter fullscreen mode Exit fullscreen mode

通过为 hooks 提供通用接口(传入 props,传出返回值),它们可以变得非常容易替换。如果我们遵循里氏替换原则,继承了另一个 hook/组件的 hook 和组件应该能够被它继承的 hook 或组件替换。

忧心忡忡的利斯科夫
当开发人员不遵循她的原则时,Liskov 会感到失望

为什么在 React 中使用 LSP?

尽管继承在 React 中并不常见,但它在幕后确实被广泛使用。Web 应用程序通常包含多个外观相似的组件。文本、标题、链接、图标链接等等都是类似的组件,并且可以通过继承获益。

IconLink 组件可能包装了 Link 组件,也可能没有。无论如何,使用相同的接口(使用相同的 props)来实现它们都会很有帮助。这样,就可以随时在应用程序中的任何地方轻松地将 Link 组件替换为 IconLink 组件,而无需编辑任何其他代码。

Hooks 也是如此。Web 应用从服务器获取数据。它们也可能使用本地存储或状态管理系统。这些最好能够共享 props,以便相互替换。

应用程序可能会从后端服务器获取用户、任务、产品或任何其他数据。此类功能也可以共享接口,从而更容易地复用代码和测试。

接口隔离原则(ISP)

另一个更清晰的原则是接口隔离原则。定义很简短。

No code should be forced to depend on methods it does not use
Enter fullscreen mode Exit fullscreen mode

顾名思义,它与接口有关,本质上意味着函数和类应该只实现其明确使用的接口。最简单的方法是保持接口简洁,让类自己挑选几个接口来实现,而不是被迫实现一个包含多个类不关心的方法的庞大接口。

例如,代表拥有某个网站的人的类应该实现两个接口,一个名为 Person 的接口用于描述该人的详细信息,另一个接口用于网站,其中包含有关其拥有的网站的元数据。

interface Person {
  firstname: string
  familyName: string
  age: number
}

interface Website {
  domain: string
  type: string
}
Enter fullscreen mode Exit fullscreen mode

相反,如果创建一个单一界面的网站,包括有关所有者和网站的信息,那就违反了界面隔离原则。

interface Website {
  ownerFirstname: string
  ownerFamilyName: number
  domain: string
  type: string
}
Enter fullscreen mode Exit fullscreen mode

你可能会想,上面的界面有什么问题?问题在于它降低了界面的可用性。想想看,如果公司不是人,而是一家公司,你会怎么做?公司实际上没有姓氏。你会修改界面,让它对人和公司都适用吗?还是你会创建一个新的界面,名为 CompanyOwnedWebsite?

最终,你会得到一个包含许多可选属性的接口,或者分别生成两个名为 PersonWebsite 和 CompanyWebsite 的接口。这两种方案都不是最佳方案。

// Alternative 1

// This interface has the problem that it includes 
// optional attributes, even though the attributes 
// are mandatory for some consumers of the interface.
interface Website {
  companyName?: string
  ownerFirstname?: string
  ownerFamilyName?: number
  domain: string
  type: string
}

// Alternative 2

// This is the original Website interface renamed for a person.
// Which means, we had to update old code and tests and 
// potentially introduce some bugs.
interface PersonWebsite {
  ownerFirstname: string
  ownerFamilyName: number
  domain: string
  type: string
}

// This is a new interface to work for a company.
interface CompanyOwnedWebsite {
  companyName: string
  domain: string
  type: string
}
Enter fullscreen mode Exit fullscreen mode

遵循 ISP 的解决方案将如下所示。

interface Person {
  firstname: string
  familyName: string
  age: number
}

interface Company {
  companyName: string
}

interface Website {
  domain: string
  type: string
}
Enter fullscreen mode Exit fullscreen mode

通过上述适当的接口,代表公司网站的类可以实现接口 Company 和 Website,但不需要考虑 Person 接口中的 firstname 和 familyName 属性。

React 中使用了 ISP 吗?

因此,这个原则显然适用于接口,这意味着只有在使用 TypeScript 编写 React 代码时它才有意义,不是吗?

当然不是!没有定义接口类型并不意味着它们不存在。它们到处都有,只是你没有明确地定义它们而已。

在 React 中,每个组件和钩子都有两个主要接口,即输入和输出。

// The input interface to a hook is its props.
const useMyHook = ({ prop1, prop2 }) => {

  // ...

  // The output interface of a hook is its return values.
  return { value1, value2, callback1 }
}
Enter fullscreen mode Exit fullscreen mode

使用 TypeScript,您通常会输入输入接口,但输出接口通常会被跳过,因为它是可选的。

// Input interface.
interface MyHookProps { 
  prop1: string
  prop2: number
}

// Output interface.
interface MyHookOutput { 
  value1: string
  value2: number
  callback1: () => void
}

const useMyHook = ({ prop1, prop2 }: MyHookProps): MyHookOutput => {

  // ...

  return { value1, value2, callback1 }
}
Enter fullscreen mode Exit fullscreen mode

如果钩子不会将 prop2 用于任何用途,那么它就不应该成为其 props 的一部分。对于单个 prop,很容易将其从 props 列表和界面中移除。但是,如果 prop2 是对象类型,例如上一章中不合适的 Website 界面示例,该怎么办?

interface Website {
  companyName?: string
  ownerFirstname?: string
  ownerFamilyName?: number
  domain: string
  type: string
}

interface MyHookProps { 
  prop1: string
  website: Website
}

const useMyCompanyWebsite = ({ prop1, website }: MyHookProps) => {

  // This hook uses domain, type and companyName,
  // but not ownerFirstname or ownerFamilyName.

  return { value1, value2, callback1 }
}
Enter fullscreen mode Exit fullscreen mode

现在我们有一个 useMyCompanyWebsite 钩子,它包含一个 website 属性。如果钩子中使用了 Website 接口的部分内容,我们就不能简单地移除整个 website 属性。我们必须保留 website 属性,从而也保留 ownerFirstname 和 ownerFamiliyName 的接口属性。这也意味着,这个原本打算用于公司的钩子,也可能被人类拥有的网站所有者使用,即使它可能不适合这种用途。

为什么在 React 中使用 ISP?

现在我们已经了解了 ISP 的含义,以及它如何应用于 React,即使不使用 TypeScript。仅通过查看上面的简单示例,我们就看到了不遵循 ISP 的一些问题。

在更复杂的项目中,可读性至关重要。接口隔离原则的目的之一就是避免代码混乱,避免不必要的代码破坏可读性。当然,还有可测试性。你是否应该关注那些你实际上没有用到的 props 的测试覆盖率?

实现大型接口还会迫使你将 props 设为可选。这会导致需要使用更多 if 语句来检查函数是否存在以及潜在的误用,因为接口上显示该函数会处理这些属性。

依赖倒置原则(DIP)

最后一个原则,依赖注入原则 (DIP),包含一些大家容易误解的术语。人们的困惑主要集中在依赖倒置、依赖注入和控制反转之间的区别上。所以我们先来解释一下这些概念。

依赖倒置

依赖倒置原则 (DIP) 规定,高级模块不应从低级模块导入任何内容,两者都应依赖于抽象。这意味着,任何高级模块如果自然地依赖于其所用模块的实现细节,就不应该具有这种依赖关系。

高级模块和低级模块的编写方式应确保它们都能在不了解对方模块内部实现细节的情况下使用。只要接口保持不变,每个模块都应该可以被替换为其他实现。

控制反转

控制反转 (IoC) 是用于解决依赖倒置问题的原则。它规定模块的依赖项应由外部实体或框架提供。这样,模块本身只需使用依赖项,而无需创建或以任何方式管理依赖项。

依赖注入

依赖注入 (DI) 是实现控制反转 (IoC) 的一种常用方法。它通过构造函数或 setter 方法将依赖项注入到模块中。这样,模块可以使用依赖项而无需负责创建它,这符合控制反转 (IoC) 原则。值得一提的是,依赖注入并非实现控制反转的唯一方法。

React 中使用了 DIP 吗?

澄清了术语,并知道 DIP 原则是关于依赖倒置的,我们可以再次看看这个定义是怎样的。

High-level modules should not import anything from low-level modules.
Both should depend on abstractions
Enter fullscreen mode Exit fullscreen mode

这如何应用于 React?React 通常不是一个与依赖注入相关的库,那么我们如何解决依赖反转的问题呢?

解决这个问题最常见的方法是使用钩子。钩子不能算作依赖注入,因为它们被硬编码到组件中,并且无法在不更改组件实现的情况下用另一个钩子替换它。在开发人员更新代码之前,同一个钩子会一直存在,并使用同一个钩子实例。

但请记住,依赖注入并非实现依赖反转的唯一方法。Hooks 可以被视为 React 组件的外部依赖,它通过一个接口(其 props)抽象出 Hooks 内部的代码。这样,Hooks 就某种程度上实现了依赖反转的原则,因为组件依赖于一个抽象接口,而无需了解 Hooks 的任何细节。

React 中另一个更直观的 DIP 实现(实际上使用了依赖注入)是使用 HOC 和上下文。请看下面的 withAuth HOC。

const withAuth = (Component) => {
  return (props) => {
    const { user } = useContext(AuthContext)

    if (!user) {
      return <LoginComponent>
    }

    return <Component {...props} user={user} />
  }
}

const Profile = () => { // Profile component... }

// Use the withAuth HOC to inject user to Profile component.
const ProfileWithAuth = withAuth(Profile)
Enter fullscreen mode Exit fullscreen mode

上面显示的 withAuth HOC 使用依赖注入的方式将用户引导至 Profile 组件。这个示例的有趣之处在于,它不仅展示了一种依赖注入的用法,实际上还包含了两种依赖注入。

将用户注入到 Profile 组件并不是本例中唯一的注入。withAuth 钩子实际上也通过 useContext 钩子通过依赖注入获取用户。在代码的某个地方,有人声明了一个提供程序,它将用户注入到上下文中。甚至可以在运行时通过更新上下文中的用户来更改该用户实例。

为什么在 React 中使用 DIP?

尽管依赖注入并非 React 中常见的模式,但它实际上存在于 HOC 和 context 中。而 hooks 已经从 HOC 和 context 中抢占了很大一部分市场份额,也很好地契合了依赖倒置原则。

因此,DIP 已经内置于 React 库中,理应被充分利用。它不仅易于使用,还具备诸多优势,例如模块间松耦合、钩子和组件的可重用性和可测试性。此外,它还能更轻松地实现其他设计模式,例如单一职责原则。

我不建议大家在有更简单的解决方案可用时,尝试实现一些更智能的解决方案,并过度使用这种模式。我在网上和书籍中看到过一些建议,建议使用 React 上下文来实现依赖注入。例如下面这样。

const User = () => { 
  const { role } = useContext(RoleContext)

  return <div>{`User has role ${role}`}</div>
}

const AdminUser = ({ children }) => {
  return (
    <RoleContext.Provider value={{ role: 'admin' }}>
      {children}
    </RoleContext.Provider>
  )
}

const NormalUser = ({ children }) => {
  return (
    <RoleContext.Provider value={{ role: 'normal' }}>
      {children}
    </RoleContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

虽然上面的示例确实将角色注入到了 User 组件中,但使用 context 来使用它实在是有些矫枉过正。React context 应该在合适的时候使用,尤其是在 context 本身有实际用途的情况下。在这种情况下,一个简单的 prop 或许是一个更好的解决方案。

const User = ({ role }) => { 
  return <div>{`User has role ${role}`}</div>
}

const AdminUser = () => <User role='admin' />

const NormalUser = () => <User role='normal' />
Enter fullscreen mode Exit fullscreen mode

订阅我的文章

文章来源:https://dev.to/perssondennis/write-solid-react-hooks-436o
PREV
六个月的远程工作教会了我十件事
NEXT
React 反模式和最佳实践 - 注意事项