应用SOLID原则于React开发
随着软件行业的发展和在错误中学习,最佳实践和良好的软件设计原则逐渐形成并概念化,以避免未来重蹈覆辙。特别是面向对象编程(OOP)世界是这类最佳实践的金矿,而SOLID无疑是其中较有影响力的原则之一。
SOLID是一个缩写词,每个字母代表五个设计原则之一:
- 单一职责原则(SRP)
- 开闭原则(OCP)
- 里氏替换原则(LSP)
- 接口隔离原则(ISP)
- 依赖倒置原则(DIP)
在本文中,我们将讨论每个原则的重要性,并了解如何在React应用中应用SOLID的学习成果。
单一职责原则(SRP)
原始定义指出"每个类应该只有一个职责",也就是只做一件事。我们可以简单地将定义推断为"每个函数/模块/组件应该只做一件事",但要理解"一件事"的含义,我们需要从两个不同的角度来检查我们的组件——内部(组件内部做什么)和外部(其他组件如何使用这个组件)。
内部视角
为了确保我们的组件在内部只做一件事,我们可以:
- 将做太多事情的大型组件拆分为更小的组件
- 将与主要组件功能无关的代码提取到单独的实用函数中
- 将连接的功能封装到自定义钩子中
让我们看一个显示活跃用户列表的示例组件:
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
|
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>
)
}
|
首先,将相关的useState和useEffect钩子提取到自定义钩子中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function useUsers() {
const [users, setUsers] = useState([])
useEffect(() => {
const loadUsers = async () => {
const response = await fetch('/users')
const data = await response.json()
setUsers(data)
}
loadUsers()
}, [])
return users
}
|
接下来,将JSX渲染逻辑提取到单独的组件中:
1
2
3
4
5
6
7
8
9
|
function UserItem({ user }) {
return (
<li>
<img src={user.avatarUrl} />
<p>{user.fullName}</p>
<small>{user.role}</small>
</li>
)
}
|
然后,将过滤逻辑提取到实用函数中:
1
2
3
4
5
6
|
function getOnlyActive(users) {
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo)
}
|
最后,将获取和过滤逻辑封装到新的自定义钩子中:
1
2
3
4
5
6
|
function useActiveUsers() {
const users = useUsers()
const activeUsers = useMemo(() => getOnlyActive(users), [users])
return activeUsers
}
|
外部视角
组件从不孤立存在,它们是更大系统的一部分,通过向其他组件提供功能或消费其他组件提供的功能进行交互。因此,SRP的外部视角关注组件可以用于多少事情。
考虑一个消息应用中的消息组件示例。最初它可能很简单:
1
2
3
|
function Message({ text }) {
return <div>{text}</div>
}
|
但随着功能增加(支持图片、语音消息等),组件会变得越来越复杂。解决方案是摒弃通用Message组件,转而使用更专业、单一职责的组件:
1
2
3
|
function TextMessage({ text }) { /* ... */ }
function ImageMessage({ url, caption }) { /* ... */ }
function VoiceMessage({ duration, url }) { /* ... */ }
|
开闭原则(OCP)
OCP指出"软件实体应该对扩展开放,对修改关闭"。开闭原则主张以允许扩展而不更改原始源代码的方式构建我们的组件。
考虑以下场景——我们在不同页面上使用共享Header组件,根据所在页面,Header应呈现略有不同的UI:
1
2
3
4
5
6
7
8
9
|
function Header({ page }) {
return (
<header>
<Logo />
{page === 'home' && <Link to="/news">News</Link>}
{page === 'news' && <Link to="/home">Home</Link>}
</header>
)
}
|
这种实现违反了OCP,因为每次添加新页面时都需要修改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="/news">News</Link>
</Header>
|
里氏替换原则(LSP)
LSP建议设计对象时,“子类型对象应该可以替换超类型对象”。在React中,这可以通过基于现有组件创建新组件来实现:
1
2
3
4
|
const StyledButton = styled(Button)`
background-color: blue;
color: white;
`
|
StyledButton保留了原始Button的实现,并符合其接口(接受相同的props),因此我们可以在应用程序的任何地方将StyledButton替换为Button而不会破坏任何东西。
另一个例子:
1
2
3
4
5
6
7
8
9
10
|
function CharCountInput(props) {
const [value, setValue] = useState('')
return (
<div>
<Input {...props} value={value} onChange={setValue} />
<div>Character count: {value.length}</div>
</div>
)
}
|
接口隔离原则(ISP)
根据ISP,“客户端不应该依赖它们不使用的接口”。对于React应用,我们将其转化为"组件不应该依赖它们不使用的props"。
考虑一个渲染视频列表的应用程序:
1
2
3
4
5
|
function VideoList({ videos }) {
return videos.map(video =>
<Thumbnail video={video} />
)
}
|
Thumbnail组件可能如下:
1
2
3
|
function Thumbnail({ video }) {
return <img src={video.coverUrl} />
}
|
问题是它期望传递完整的视频对象,而实际上只使用其一个属性。当我们想要显示直播流缩略图时会出现问题:
1
2
3
4
5
6
7
|
function Thumbnail({ coverUrl }) {
return <img src={coverUrl} />
}
// 现在可以用于视频和直播流
<Thumbnail coverUrl={video.coverUrl} />
<Thumbnail coverUrl={stream.previewUrl} />
|
依赖倒置原则(DIP)
依赖倒置原则指出"应该依赖抽象,而不是具体"。换句话说,一个组件不应该直接依赖另一个组件,而是它们都应该依赖某种共同的抽象。
考虑LoginForm组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = async (event) => {
event.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 (event) => {
event.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代码中,使其更易于维护和健壮。
但重要的是要记住,教条式和宗教式地遵循这些原则可能是有害的,并导致过度设计的代码,因此我们应该学会识别何时进一步的分解或解耦组件会引入复杂性而几乎没有什么好处。