React中的SOLID原则实践指南

本文详细探讨了如何在React应用中应用SOLID设计原则,包括单一职责原则、开闭原则、里氏替换原则等,通过具体代码示例展示了如何构建更可维护和健壮的React组件架构。

在React中应用SOLID原则

随着软件行业的发展和在错误中学习,最佳实践和良好的软件设计原则应运而生,以避免将来重蹈覆辙。特别是面向对象编程(OOP)世界是这类最佳实践的金矿,而SOLID无疑是其中较有影响力的原则之一。

SOLID是一个缩写词,每个字母代表五个设计原则中的一个:

  • 单一职责原则(SRP)
  • 开闭原则(OCP)
  • 里氏替换原则(LSP)
  • 接口隔离原则(ISP)
  • 依赖倒置原则(DIP)

在本文中,我们将讨论每个原则的重要性,并了解如何在React应用中应用SOLID的学习成果。

单一职责原则(SRP)

原始定义指出"每个类应该只有一个职责",也就是只做一件事。我们可以简单地将定义推断为"每个函数/模块/组件应该只做一件事"。

内部视角: 为了确保我们的组件在内部只做一件事,我们可以:

  • 将做太多事情的大型组件拆分为更小的组件
  • 将与主要组件功能无关的代码提取到单独的实用函数中
  • 将相关功能封装到自定义Hook中

示例: 显示活跃用户列表的组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 原始组件
function ActiveUsersList() {
  const [users, setUsers] = useState([])
  
  useEffect(() => {
    const loadUsers = async () => {
      const response = await fetch('/users')
      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>
  )
}

通过提取自定义Hook、拆分子组件和工具函数,我们可以让组件更加专注。

外部视角: 组件从不孤立存在,它们是更大系统的一部分。外部视角的SRP关注组件可以用于多少事情。

示例:消息组件逐渐变得过于通用

1
2
3
4
5
6
7
8
9
// 开始时简单的Message组件
function Message({ text }) {
  return <div>{text}</div>
}

// 逐渐添加更多功能后
function Message({ type, text, imageUrl, audioUrl, videoUrl, stickers }) {
  // 大量条件渲染逻辑
}

解决方案是使用更专业、单一职责的组件:

1
2
3
function TextMessage({ text }) { /* ... */ }
function ImageMessage({ imageUrl, caption }) { /* ... */ }
function AudioMessage({ audioUrl }) { /* ... */ }

实践建议:

  • 如果组件有很多改变组件行为的if语句,可能是需要拆分的信号
  • 具有大量可选props的组件可能实际上是多个组件伪装成一个

开闭原则(OCP)

OCP指出"软件实体应该对扩展开放,对修改关闭"。

问题示例:

1
2
3
4
5
6
7
8
9
function Header({ page }) {
  return (
    <header>
      <Logo />
      {page === 'home' && <Link to="/about">About</Link>}
      {page === 'about' && <Link to="/home">Home</Link>}
    </header>
  )
}

每次添加新页面都需要修改Header组件。

解决方案:使用组件组合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function Header({ children }) {
  return (
    <header>
      <Logo />
      {children}
    </header>
  )
}

// 使用
<Header>
  <Link to="/about">About</Link>
</Header>

里氏替换原则(LSP)

LSP建议"子类型对象应该可以替换超类型对象"。

示例:styled-components

1
2
3
4
5
6
7
8
const Button = ({ onClick, children }) => (
  <button onClick={onClick} className="btn">{children}</button>
)

const StyledButton = styled(Button)`
  background: blue;
  color: white;
`

StyledButton保留了原始Button的实现,接受相同的props,因此可以在任何地方替换Button

字符计数输入组件示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function CharCountInput(props) {
  const [charCount, setCharCount] = useState(0)
  
  const handleChange = (e) => {
    setCharCount(e.target.value.length)
    props.onChange?.(e)
  }
  
  return (
    <div>
      <Input {...props} onChange={handleChange} />
      <small>Characters: {charCount}</small>
    </div>
  )
}

违反LSP的常见方式:

  1. 无理由地切断部分props:
1
2
3
4
5
6
7
8
9
// 错误做法
function CustomInput({ value, onChange }) {
  return <input value={value} onChange={onChange} />
}

// 正确做法
function CustomInput(props) {
  return <input {...props} />
}
  1. 为某些属性使用别名:
1
2
3
4
5
6
// 错误做法
function Input({ onChange }) {
  const onChange = () => {} // 命名冲突
  
  return <input onChange={onChange} />
}

接口隔离原则(ISP)

ISP指出"客户端不应该依赖它们不使用的接口"。对于React应用,我们将其转化为"组件不应该依赖它们不使用的props"。

问题示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface Video {
  id: string
  title: string
  duration: number
  coverUrl: string
}

function Thumbnail({ video }: { video: Video }) {
  return <img src={video.coverUrl} alt="Video thumbnail" />
}

Thumbnail组件期望传递完整的视频对象,但实际只使用其中一个属性。

解决方案:

1
2
3
4
5
6
7
function Thumbnail({ imageUrl, alt }: { imageUrl: string; alt?: string }) {
  return <img src={imageUrl} alt={alt} />
}

// 现在可以用于视频和直播流
<Thumbnail imageUrl={video.coverUrl} alt={video.title} />
<Thumbnail imageUrl={liveStream.previewUrl} alt={liveStream.title} />

依赖倒置原则(DIP)

DIP指出"应该依赖抽象,而不是具体实现"。换句话说,一个组件不应该直接依赖另一个组件,而是它们都应该依赖某种共同的抽象。

问题示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import api from 'api'

function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    await api.login(email, password)
  }
  
  return (
    <form onSubmit={handleSubmit}>
      {/* 表单字段 */}
    </form>
  )
}

LoginForm组件直接引用api模块,存在紧密耦合。

解决方案:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  
  const handleSubmit = async (e) => {
    e.preventDefault()
    onSubmit(email, password)
  }
  
  return (
    <form onSubmit={handleSubmit}>
      {/* 表单字段 */}
    </form>
  )
}

function ConnectedLoginForm(props) {
  const handleSubmit = async (email, password) => {
    await api.login(email, password)
  }
  
  return <LoginForm {...props} onSubmit={handleSubmit} />
}

结论

尽管SOLID原则诞生于OOP世界的问题,但它们的应用远远超出了这个范围。在本文中,我们看到了通过对这些原则的解释有一定的灵活性,我们成功地将它们应用到React代码中,使其更可维护和健壮。

但重要的是要记住,教条式和宗教式地遵循这些原则可能是有害的,并导致过度设计的代码,因此我们应该学会识别何时进一步的分解或解耦组件会引入复杂性而几乎没有什么好处。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计