React中应用原则

73 阅读14分钟

React中应用原则

medium.com/dailyjs/app…

单一职责原则 Single responsibility principle (SRP)

最初的定义是“每个类应该只有一个职责”,也就是只做一件事。我们可以简单地将定义推断为“每个函数/模块/组件都应该只做一件事”,但要理解“一件事”意味着什么,我们需要从两个不同的角度来检查组件——内部(意味着组件在内部做什么)和外部(该组件如何被其他组件使用)。

我们先从内部开始,确保我们的组件只做了一件事情

  1. 把做太多的大组件拆分成更小的组件
  2. 把与主组件功能无关的代码提取到单独的实用程序函数中
  3. 将连接的功能封装到自定义钩子中

现在我们来看看如何应用这个原理。我们首先考虑下面这个显示活动用户列表的示例组件:


const ActiveUsersList = () => {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {  
      const response = await fetch('/some-api')
      const data = await response.json()
      setUsers(data)
    }

    loadUsers()
  }, [])
  
  const weekAgo = new Date();
  weekAgo.setDate(weekAgo.getDate() - 7);

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <li key={user.id}>
          <img src={user.avatarUrl} />
          <p>{user.fullName}</p>
          <small>{user.role}</small>
        </li>
      )}
    </ul>    
  )
}

尽管这个组件现在相对较短,但它已经在做很多事情了——它获取数据、过滤数据、呈现组件本身以及单个列表项。让我们看看如何分解它。

const useUsers = () => {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {  
      const response = await fetch('/some-api')
      const data = await response.json()
      setUsers(data)
    }

    loadUsers()
  }, [])
  
  return { users }
}


const ActiveUsersList = () => {
  const { users } = useUsers()
  
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <li key={user.id}>
          <img src={user.avatarUrl} />
          <p>{user.fullName}</p>
          <small>{user.role}</small>
        </li>
      )}
    </ul>    
  )
}

现在我们的useUsers钩子只关心一件事——从API获取用户。它还使我们的主要组件更具可读性,不仅因为它变得更短了,还因为我们用域钩子替换了结构钩子,而这些结构钩子是用来解码的目的,从它的名字就可以看出它的目的

接下来,让我们看看组件呈现的JSX。每当我们在对象数组上有一个循环映射时,我们应该注意它为单个数组项生成的JSX的复杂性。如果它是一行代码,没有附加任何事件处理程序,那么将它保持内联完全没问题,但对于更复杂的标记,将其提取到单独的组件可能是一个好主意

const UserItem = ({ user }) => {
  return (
    <li>
      <img src={user.avatarUrl} />
      <p>{user.fullName}</p>
      <small>{user.role}</small>
    </li>
  )
}


const ActiveUsersList = () => {
  const { users } = useUsers()
  
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)

  return (
    <ul>
      {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

最后,我们有了从API获得的所有用户列表中过滤不活动用户的逻辑。这个逻辑是相对独立的,它可以在应用程序的其他部分重用,所以我们可以很容易地将它提取到一个实用函数中:

const getOnlyActive = (users) => {
  const weekAgo = new Date()
  weekAgo.setDate(weekAgo.getDate() - 7)
  
  return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo)
}

const ActiveUsersList = () => {
  const { users } = useUsers()

  return (
    <ul>
      {getOnlyActive(users).map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

在这一点上,我们的主要组件足够简短和直接,我们可以停止分解并结束它。然而,如果我们仔细观察,我们会注意到它仍然比它应该做的更多。目前,我们的组件正在获取数据,然后对其应用筛选,但理想情况下,我们只想获取数据并呈现它,而不需要任何额外的操作。所以作为最后一个改进,我们可以将这个逻辑封装到一个新的自定义钩子中:

const useActiveUsers = () => {
  const { users } = useUsers()

  const activeUsers = useMemo(() => {
    return getOnlyActive(users)
  }, [users])

  return { activeUsers }
}

const ActiveUsersList = () => {
  const { activeUsers } = useActiveUsers()

  return (
    <ul>
      {activeUsers.map(user => 
        <UserItem key={user.id} user={user} />
      )}
    </ul>    
  )
}

在这里,我们创建了useActiveUsers钩子来处理获取和过滤逻辑(为了更好地度量,我们还记住了过滤后的数据),而我们的主组件只做最少的工作——呈现它从钩子获得的数据

现在,根据我们对“一件事”的解释,我们可以认为组件仍然是首先获取数据,然后渲染它,这不是“一件事”。我们可以进一步拆分它,在一个组件中调用一个钩子,然后将结果作为道具传递给另一个组件,但我发现这在现实应用程序中很少有真正有益的情况,所以让我们原谅定义并接受“呈现组件获得的数据”作为“一件事”。

现在是外部视角。我们的组件从来不是孤立存在的,相反,它们是一个更大系统的一部分,在这个系统中,它们通过向其他组件提供功能或使用其他组件提供的功能进行交互。因此,SRP的外部视图关注的是一个组件可以用于多少事情

开封闭原则 Open-closed principle (OCP)

OCP规定“软件实体应该对扩展开放,但对修改关闭”。因为React组件和函数是软件实体,所以我们根本不需要改变定义,相反,我们可以采用它的原始形式。 开闭原则提倡以一种允许在不更改原始源代码的情况下扩展组件的方式构建组件。为了看看它的实际效果,让我们考虑以下场景——我们正在开发一个在不同页面上使用共享Header组件的应用程序,并且根据我们所在的页面,Header应该呈现略有不同的UI:

const Header = () => {
  const { pathname } = useRouter()
  
  return (
    <header>
      <Logo />
      <Actions>
        {pathname === '/dashboard' && <Link to="/events/new">Create event</Link>}
        {pathname === '/' && <Link to="/dashboard">Go to dashboard</Link>}
      </Actions>
    </header>
  )
}

const HomePage = () => (
  <>
    <Header />
    <OtherHomeStuff />
  </>
)

const DashboardPage = () => (
  <>
    <Header />
    <OtherDashboardStuff />
  </>
)

在这里,我们根据所处的当前页面呈现到不同页面组件的链接。如果我们考虑在开始添加更多页面时会发生什么情况,就很容易意识到这种实现是糟糕的。每次创建新页面时,我们都需要返回Header组件并调整其实现,以确保它知道要呈现哪个操作链接。这样的方法使我们的Header组件变得脆弱,并且与使用它的上下文紧密耦合,这违背了开闭原则

const Header = ({ children }) => (
  <header>
    <Logo />
    <Actions>
      {children}
    </Actions>
  </header>
)

const HomePage = () => (
  <>
    <Header>
      <Link to="/dashboard">Go to dashboard</Link>
    </Header>
    <OtherHomeStuff />
  </>
)


const DashboardPage = () => (
  <>
    <Header>
      <Link to="/events/new">Create event</Link>
    </Header>
    <OtherDashboardStuff />
  </>
)

通过这种方法,我们完全删除了Header内部的变量逻辑,现在可以使用合成来放入任何我们想要的内容,而无需修改组件本身。考虑这个问题的一个好方法是,我们在组件中提供一个占位符,以便插入。而且我们也不局限于每个组件的一个占位符——如果我们需要多个扩展点(或者如果儿童道具已经用于不同的目的),我们可以使用任意数量的道具。如果我们需要将一些上下文从Header传递给使用它的组件,我们

遵循开闭原则,我们可以减少组件之间的耦合,并使它们更具可扩展性和可重用性。

里氏替换原则

在进行设计的时候,尽量从抽象类继承,而不是从具体类继承。如果从继承等级树来看,所有叶子节点应当是具体类,而所有的树枝节点应当是抽象类或者接口。当然这个只是一个一般性的指导原则,使用的时候还要具体情况具体分析。

简单的理解为一个软件实体如果使用的是一个父类,那么一定适用于其子类,而且它察觉不出父类对象和子类对象的区别。也就是说,软件里面,把父类都替换成它的子类,程序的行为没有变化。 子类型/超类型关系的一个非常基本的例子可以用一个用样式组件库(或任何其他使用类似语法的CSS-in-JS库)构建的组件来演示:

import styled from 'styled-components'

const Button = (props) => { /* ... */ }

const StyledButton = styled(Button)`
  border: 1px solid black;
  border-radius: 5px;
`

const App = () => {
  return <StyledButton onClick={handleClick} />
}

Liskov替换原则在组件共享公共特征的环境中特别有用,例如图标或输入—一个图标组件应该可以切换到另一个图标,更具体的DatePickerInput和AutocompleteInput组件应该可以切换到更通用的Input组件,等等。但是,我们应该承认,这一原则不能也不应该总是得到遵守。通常情况下,我们创建子组件的目的是添加它们的超级组件所没有的新功能,而这通常会破坏超级组件的接口 对于LSP有意义的组件,我们需要确保不会不必要地破坏原则。让我们来看看它可能发生的两种常见方式。

type Props = InputHTMLAttributes<HTMLInputElement>

const Input = (props: Props) => { /* ... */ }

const CharCountInput = (props: Props) => {
  return (
    <div>
      <Input {...props} />
      <span>Char count: {props.value.length}</span>
    </div>
  )
}

Liskov替换原则在组件共享公共特征的环境中特别有用,例如图标或输入—一个图标组件应该可以切换到另一个图标,更具体的DatePickerInput和AutocompleteInput组件应该可以切换到更通用的Input组件,等等。但是,我们应该承认,这一原则不能也不应该总是得到遵守。通常情况下,我们创建子组件的目的是添加它们的超级组件所没有的新功能,而这通常会破坏超级组件的接口 对于LSP有意义的组件,我们需要确保不会不必要地破坏原则。让我们来看看它可能发生的两种常见方式

第一种是无故剪掉一部分属性

type Props = { value: string; onChange: () => void }

const CustomInput = ({ value, onChange }: Props) => {
  // ...some additional logic

  return <input value={value} onChange={onChange} />
}

这里,我们为CustomInput重新定义了属性,而不是使用所期望的属性。结果,我们失去了可以获得的大量属性子集,从而破坏了它的接口。为了解决这个问题,我们应该使用原始所期望的属性,并使用扩散操作符将它们全部传递下去:

type Props = InputHTMLAttributes<HTMLInputElement>

const CustomInput = (props: Props) => {
  // ...some additional logic

  return <input {...props} />
}

另一种打破LSP的方法是对某些属性使用别名。当我们想要使用的属性与局部变量有命名冲突时,可能会发生这种情况:

type Props = HTMLAttributes<HTMLInputElement> & {
  onUpdate: (value: string) => void
}

const CustomInput = ({ onUpdate, ...props }: Props) => {
  const onChange = (event) => {
    /// ... some logic
    onUpdate(event.target.value)
  }

  return <input {...props} onChange={onChange} />
}

为了避免这种冲突,您需要为本地变量制定良好的命名约定。例如,对于每个onSomething属性都有一个匹配的handlessomething局部函数是很常见的:

type Props = HTMLAttributes<HTMLInputElement>

const CustomInput = ({ onChange, ...props }: Props) => {
  const handleChange = (event) => {
    /// ... some logic
    onChange(event)
  }

  return <input {...props} onChange={handleChange} />
}

接口隔离原则 Interface segregation principle (ISP)

根据ISP的说法,“客户不应该依赖他们不使用的接口。”为了React应用程序,我们将其翻译为“组件不应该依赖于它们不使用的props 我们在这里扩展了ISP的定义,但这不是很大的扩展props和接口都可以定义为对象(组件)和外部世界(使用它的上下文)之间的契约,因此我们可以在两者之间进行类比。最后,它不是关于严格和不妥协的定义,而是关于应用通用原则来解决问题。” 为了更好地说明ISP所针对的问题,我们将在下一个示例中使用TypeScript。让我们考虑一下呈现视频列表的应用程序:

type Video = {
  title: string
  duration: number
  coverUrl: string
}

type Props = {
  items: Array<Video>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => 
        <Thumbnail 
          key={item.title} 
          video={item} 
        />
      )}
    </ul>
  )
}

我们用于每个项目的Thumbnail组件可能看起来像这样:

type Props = {
  video: Video
}

const Thumbnail = ({ video }: Props) => {
  return <img src={video.coverUrl} />
}

The Thumbnail component is quite small and simple, but it has one problem - it expects a full video object to be passed in as props, while effectively using only one of its properties.

To see why that’s problematic, imagine that in addition to videos, we decide to display thumbnails for live streams as well, with both kinds of media resources mixed in the same list. 我们将引入一个定义直播流对象的新类型:

type LiveStream = {
  name: string
  previewUrl: string
}

这是我们更新的VideoList组件:

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // it's a video
          return <Thumbnail video={item} />
        } else {
          // it's a live stream, but what can we do with it?
        }
      })}
    </ul>
  )
}

如你所见,这里有个问题。我们可以很容易地区分视频和直播流对象,但是我们不能将后者传递给Thumbnail组件,因为视频和直播流是不兼容的。首先,它们有不同的类型,所以TypeScript会立即抱怨。其次,它们包含在不同属性下的缩略图URL——视频对象称其为coverUrl,直播对象称其为previewUrl。这就是组件依赖的道具多于它们实际需要的问题的症结所在——它们的可重用性降低了。我们来解决这个问题。

type Props = {
  coverUrl: string
}

const Thumbnail = ({ coverUrl }: Props) => {
  return <img src={coverUrl} />
}

有了这个改变,现在我们可以用它来渲染视频和直播流的缩略图:

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      {items.map(item => {
        if ('coverUrl' in item) {
          // it's a video
          return <Thumbnail coverUrl={item.coverUrl} />
        } else {
          // it's a live stream
          return <Thumbnail coverUrl={item.previewUrl} />
        }
      })}
    </ul>
  )
}

接口隔离原则提倡最小化系统组件之间的依赖关系,使它们的耦合度降低,从而提高可重用性。

依赖倒转原则 Dependency inversion principle (DIP)

依赖倒置原则指出“一个人应该依赖抽象,而不是具体”。换句话说,一个组件不应该直接依赖于另一个组件,而是它们都应该依赖于一些共同的抽象。在这里,“组件”指的是应用程序的任何部分,可以是React组件、实用函数、模块或第三方库。这个原理在抽象上可能很难理解,所以让我们直接跳到一个例子中

import api from '~/common/api'

const LoginForm = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (evt) => {
    evt.preventDefault()
    await api.login(email, password)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button type="submit">Log in</button>
    </form>
  )
}

在这段代码中,我们的LoginForm组件直接引用api模块,因此它们之间存在紧密耦合。这是不好的,因为这样的依赖使得在代码中进行更改更具挑战性,因为一个组件中的更改将影响其他组件。依赖倒置原则提倡打破这种耦合,所以让我们看看如何实现它。 首先,我们将从LoginForm内部删除对api模块的直接引用,取而代之的是,允许通过道具注入所需的功能:

type Props = {
  onSubmit: (email: string, password: string) => Promise<void>
}

const LoginForm = ({ onSubmit }: Props) => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (evt) => {
    evt.preventDefault()
    await onSubmit(email, password)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button type="submit">Log in</button>
    </form>
  )
}

通过这个更改,我们的LoginForm组件不再依赖于api模块。向API提交凭证的逻辑通过onSubmit回调抽象出来,现在父组件负责提供该逻辑的具体实现 为此,我们将创建一个连接版本的LoginForm,它将把表单提交逻辑委托给api模块:

import api from '~/common/api'

const ConnectedLoginForm = () => {
  const handleSubmit = async (email, password) => {
    await api.login(email, password)
  }

  return (
    <LoginForm onSubmit={handleSubmit} />
  )
}

ConnectedLoginForm组件充当api和LoginForm之间的粘合剂,而它们本身则彼此完全独立。我们可以在它们的基础上进行迭代并独立测试,而不必担心会破坏相互依赖的移动部件,因为根本就没有这些部件。只要LoginForm和api都坚持一致的公共抽象,应用程序作为一个整体将继续按预期工作。

在过去,这种创建“dumb”表示组件,然后向其中注入逻辑的方法也被许多第三方库使用。最著名的例子是Redux,它使用连接高阶组件(HOC)将组件中的回调道具绑定到分派函数。随着钩子的引入,这种方法变得不那么相关了,但是通过hoc注入逻辑在React应用程序中仍然有用

总之,依赖倒置原则的目的是最小化应用程序不同组件之间的耦合。您可能已经注意到,最小化是贯穿所有SOLID原则的一个重复主题——从最小化单个组件的职责范围到最小化跨组件的意识和它们之间的依赖关系 尽管出自面向对象世界的问题,但SOLID原则的应用远不止于此。在本文中,我们了解了如何通过灵活地解释这些原则,将它们应用到React代码中,使其更具可维护性和健壮性。
但是,重要的是要记住,教条地和虔诚地遵循这些原则可能是有害的,并导致过度工程的代码,所以我们应该学会认识到什么时候进一步分解或分离组件会带来很少甚至没有好处的复杂性。