SOLID 使用规范
SOLID 原则是什么?
SOLID 原则是一组面向对象编程(OOP)中的设计原则,旨在帮助开发者编写更加可维护、易多人协作、易理解和可扩展的代码。这些原则的名称是这些原则的首字母缩写,每个字母代表一个不同的原则。
- 单一职责原则(SRP)(Single Responsibility Principle)
- 开放封闭原则(OCP)(Open/Closed Principle)
- 里氏替换原则(LSP)(Liskov Substitution Principle)
- 接口隔离原则(ISP)(Interface Segregation Principle)
- 依赖倒置原则(DIP)(Dependency Inversion Principle)
思考
- 我们为什么要在开发中遵循 SOLID 原则呢?
- SOLID 原则是一组面向对象编程的设计原则,旨在帮助开发人员编写更易于维护、扩展和理解的代码。这些原则有助于提高代码的质量,降低代码的复杂性,并增加代码的可重用性。--来自 ChatGPT 的回答
- 前端项目中使用 SOLID 原则的目的?
- 项目中使用 SOLID 原则的目的就是组件化,而组件化的最大目的就是松耦合、易扩展,遵守 SOLID 原则可以让我们的项目更加健壮和稳定。
目录结构
|-- ijiwei-xxx
|-- README.md
|-- package.json
|-- tsconfig.json
|-- yarn.lock
|-- src
|-- types.d.ts
|-- api
| |-- BaseModel.ts
| |-- index.ts
| |-- UserModel
| |-- user.ts
| |-- user.types.ts
|-- components
| |-- LoginModel
| | |-- LoginModel.tsx
| | |-- LoginModel.types.ts
|-- constants(全局constants)
| |-- index.ts
|-- hooks
| |-- hooks.types.ts?
| |-- useUserXxxx.ts
| |-- api(?功能hooks和API hooks)
| |-- useApi.ts
| |-- User
| |-- useGetWechatInfo.ts
|-- interface
| |-- xxxx.types.ts(全局ts定义)
|-- pages
| |-- App.tsx
| |-- User
| |-- User.tsx
| |-- User.types.ts
|-- store
| |-- User
| | |-- user.ts
| | |-- user.types.ts
|-- utils (全局utils)
|-- index.ts
使用(本文中所有的"例子"均在演示SOLID在前端项目(以React为例)中的应用)
1. 单一职责原则(SRP)
单一职责原则指一个类、模块或者函数只完成一个主要功能。
我们在前端项目中可以理解成“每个模块/组件都应该只做一件事”,为了确保每个组件只做一件事,我们需要:
- 拆分组件:将功能较多的大型组件拆分为较小的组件。
- 提取功能函数:将与组件功能无关的代码提取到单独的函数中。
- 封装 hooks:将有联系的功能提取到自定义 Hooks 中。
这里"拆分组件"就需要知道当前模块\组件是否需要拆分组件、拆到什么程度合适呢?
这里有个小 tips:如果比较难给类\组件\函数\hooks 起一个合适名字,很难用一个业务名词概括的,说明这个类\组件\函数\hooks 的职责不够明确,就需要拆成粒度更细的。
例子
下面来看一个显示活跃用户列表的组件:
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>
)
}
这个组件虽然代码不多,但是做了很多事情:获取数据、过滤数据、渲染数据。来看看如何分解它。
首先,只要同时使用了 useState
和 useEffect
,就可以将它们提取到自定义 Hook 中:
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
Hook只关心一件事——从API获取用户。它使我们的组件代码更具可读性。
接下来看一下组件渲染的 JSX。每当我们对对象数组进行遍历时,都应该注意它为每个数组项生成的 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>
)
}
但是从上面渲染jsx可以看到另一个问题,就是把过滤条件的计算逻辑内联在jsx中了,这部分逻辑可以看到是相对独立的,同时可以在其他地方使用,那么我们就可以把计算的逻辑提取到一个单独的hooks中:
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>
)
}
到现在为止,通过上面三步拆解,组件已经变得比较简单。但是,仔细观察会发现,这个组件还有优化的空间。目前,组件首先获取数据,然后需要对数据进行过滤。理想情况下,我们只想获取数据并渲染它,而不需要任何额外的操作。所以,可以将这个逻辑封装到一个新的自定义 Hook 中,最终的代码如下:
// 获取数据
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 UserItem = ({ user }) => {
return (
<li>
<img src={user.avatarUrl} />
<p>{user.fullName}</p>
<small>{user.role}</small>
</li>
)
}
// 列表过滤
const getOnlyActive = (users) => {
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo)
}
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
Hook 来处理获取和过滤数据的逻辑,而组件只做了最少的事情——渲染它从 Hook 中获取的数据。
现在,这个组件只剩下两个职责:获取数据和渲染数据,当然我们也可以在组件的父级获取数据,并通过 props 传入该组件,这样只需要渲染组件就可以了。当然,还是要视情况而定。我们可以将获取并渲染数据看做为“一件事”。
总而言之,遵循单一职责原则,我们有效地采用了大量独立的代码并使其更加模块化,模块化的代码更容易测试和维护。
开放封闭原则(OCP)*
开闭原则是指“对扩展开放,对修改关闭”,意思是添加一个新的功能应该是在已有代码基础上扩展代码(新增模块、类、方法等),而非修改代码。
更具体点说就是尽量让扩展可以通过增加类或者增加方法实现,而不是在方法中增加逻辑(如if else、switch case)以及在方法上增加参数的方式(因为增加参数意味着所有的调用方要做出改变,以及子类的该方法也要做出改变)实现。
开闭原则是所有设计原则中最重要的原则,没有之一,就如同可扩展性是可读性、复用性、可扩展性、可维护性中最重要的一个一样,而开闭原则直接影响可扩展性的大小。
要做到开闭原则,首先我们要判断代码的各个地方,哪个地方是之后可能会发生扩展的。对于可能发生扩展的地方编码时问自己是否有在这个地方预留好扩展点,预留到了哪,新代码是否能轻松的插入到这个扩展点上。
另外一点是扩展的功能尽可能是可封装的,可封装意味着可复用,你改一个地方就能让所有调用者生效,而不是要改多个相同的地方。if else、switch else的扩展就是不可封装的,因此增加了if else和switch case之后,可读性和复用性都会变差。而类和方法是就有封装性的,因此上面才说让扩展尽可能是通过增加类或者增加方法实现。
例子
根据我们所在的页面,Header 呈现略有不同的 UI
开放封闭原则主张以允许在不更改原始源代码的情况下扩展组件的方式构建组件。为了看到它的实际效果,让我们考虑以下场景 - 我们正在开发一个 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
组件,并修改其内部实现。这种方式使得 Header
组件与使用它的上下文紧密耦合,并且违背了开放封闭原则。
为了解决这个问题,我们可以使用组件组合。我们的 Header 组件不需要关心它将在内部渲染什么,相反,它可以使用 children属性将这个责任委托给将使用它的组件:
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
中,而无需修改组件本身。
遵循开放封闭原则,可以减少组件之间的耦合,使它们更具可扩展性和可重用性。
里氏替换原则(LSP)
里氏替换原则是指要做到子类对象的方法能够替换上层调用中任何父类对象方法出现的地方,并且保证原来程序的行为不被破坏,从而保证类继承和扩展后程序依旧稳定。
从更广泛的意义上讲,继承只是将一个对象基于另一个对象,同时保留类似的实现,这是我们在 React 中经常做的事情。
注意:里氏替换原则其实是一个用的比较少的或者用起来不怎么意识得到的一个原则。里氏替换原则的本质是希望子类在各方面完美继承父类,而实际开发中,多多少少子类会做不到完全不破坏父类原有行为,其实只要有这个意识能够做到大致不破坏就好。
例子
使用 styled-components 库构建组件
子类型/父类型关系的一个非常基本的示例可以通过使用 styled-components 库(或使用类似语法的任何其他 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} />;
};
上面的代码中,我们是 StyledButton
基于 Button
组件来创建的。这个新 StyledButton
组件添加了一些 CSS 类,但保留了原始 的实现 Button
,因此,在这种情况下,我们可以将 Button
和 StyledButton
视为超类型和子类型组件。
此外,StyledButton
还符合其所基于的组件的接口 - 它采用与 Button
自身相同的 props。因此,我们可以轻松地交换应用程序中的任何位置,而无需破坏它或需要进行任何其他更改 StyledButton
。Button
这就是我们遵守里氏替换原则所获得的好处。
另一个示例:
type Props = InputHTMLAttributes<HTMLInputElement>;
const Input = (props: Props) => {
/* ... */
};
const CharCountInput = (props: Props) => {
return (
<div>
<Input {...props} />
<span>Char count: {props.value.length}</span>
</div>
);
};
在上面的代码中,我们使用一个基本 Input
组件来创建它的增强版本,它还可以显示输入中的字符数。虽然我们为其添加了新的逻辑,但 CharCountInput
仍然保留了原始组件的功能 Input
。组件的接口也保持不变(两个输入采用相同的 props)。
里氏替换原则在共享共同特征的组件的上下文中特别有用,然而,我们应该承认,这一原则不能也不应始终得到遵守。通常,我们创建子组件的目的是添加其父组件所没有的新功能,这通常会破坏父组件的接口。这是一个完全有效的用例,我们不应该试图在任何地方都使用 LSP。
对于 LSP 确实有意义的组件,我们需要确保不会不必要地违反原则。让我们看一下发生这种情况的两种常见方式。
第一个是无缘无故地砍掉一部分内容:
type Props = { value: string; onChange: () => void };
const CustomInput = ({ value, onChange }: Props) => {
// ...some additional logic
return <input value={value} onChange={onChange} />;
};
在这里,我们重新定义 props,而 CustomInput
不是使用期望的 props <input />
。结果,我们丢失了很大一部分属性,这些属性<input />
可能会破坏其接口。为了解决这个问题,我们应该使用原始版本期望的 props<input />
并使用扩展运算符将它们全部传递下来:
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} />;
};
为了避免此类冲突,您需要为局部变量制定良好的命名约定。例如,handleSomething 每个 onSomething 属性都有一个匹配的本地函数是很常见的:
type Props = HTMLAttributes<HTMLInputElement>;
const CustomInput = ({ onChange, ...props }: Props) => {
const handleChange = (event) => {
/// ... some logic
onChange(event);
};
return <input {...props} onChange={handleChange} />;
};
接口隔离原则(ISP)
接口隔离原则的官方解释是接口的调用方不应该被强迫依赖它不需要的接口。
例子
为了 React 应用程序,我们将其翻译为“组件不应该依赖于它们不使用的 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} />;
};
该 Thumbnail
组件非常小且简单,但它有一个问题 - 它将完整的props作为道具传入,但是我们只是需要coverUrl
而已。
要了解为什么会出现问题,请想象一下,除了视频之外,我们还决定显示直播流的缩略图,并将两种媒体资源混合在同一列表中。
我们将引入一种定义直播流对象的新类型:
type Props = {
coverUrl: string;
};
const Thumbnail = ({ coverUrl }: Props) => {
return <img src={coverUrl} />;
};
这是我们更新的 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 coverUrl={item.coverUrl} />;
} else {
// it's a live stream
return <Thumbnail coverUrl={item.previewUrl} />;
}
})}
</ul>
);
};
接口隔离原则主张最大限度地减少系统组件之间的依赖关系,从而减少它们的耦合,从而提高可重用性。
依赖倒置原则(DIP)
在介绍依赖倒置原则之前,得先了解一下控制反转(IOC)和 依赖注入(DI)这两个概念。
控制反转IOC (Inversion Of Control)
控制反转是一种指导流程化逻辑处理的思想,“控制”是指对程序执行流程的控制,“反转”是指整个流程的执行不应该由功能类或业务类自己控制,而应该由上层的一个专门的控制类控制,使得流程控制的逻辑写在控制类中,控制类依赖功能类提供的接口完成整个流程。
控制反转是一种比较笼统的设计思想,一般用来指导框架层面的设计以及流程化逻辑。很多人会将控制反转和依赖注入搞混,或者认为控制反转就是依赖注入。而其实依赖注入只是控制反转实现的方式之一。
依赖注入DI(Dependency Injection)
依赖注入是指,不要在调用类中创建依赖对象,而是将依赖对象在外部创建好之后,通过传参的方式传给调用类使用。
依赖注入需要注意的是,尽可能依赖抽象而非实现。也就是说,声明注入的依赖对象时,要声明成它的接口类型或抽象类类型,而不要声明成它的具体类的类型或普通父类类型,这也遵循基于接口而非实现编程。
依赖注入的好处是做到类与类之间松耦合,避免紧耦合,有利于扩展。
例子
在React中一个组件不应该直接依赖于另一个组件,而是它们都应该依赖于一些共同的抽象。这里,“组件”是指应用程序的任何部分,可以是 React 组件、函数、模块或第三方库。这个原则可能很难理解,下面来看一个具体的例子。
有一个 LoginForm
组件,它在提交表单时将用户凭据发送到某些 API:
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
模块的直接引用,而是允许通过 props
传入所需的回调函数:
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
回调函数抽象出来的,现在由父组件负责提供该逻辑的具体实现。
为此,创建了一个 ConnectedLoginForm
组件来将表单提交逻辑委托给 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
之间的粘合剂,而它们本身保持完全独立。这样就可以对这两个组件进行单独的修改和维护,而不必担心修改会影响其他组件。
依赖倒置原则旨在最小化应用程序不同组件之间的耦合。 你可能已经注意到,最小化是所有 SOLID 原则中反复出现的关键词——从最小化单个组件的职责范围到最小化它们之间的依赖关系等等。
小结
完全地遵循这些原则可能会造成破坏并导致代码过度设计,因此我们应该学会识别对组件的进一步分解或解耦,思考何时会导致其复杂度增加而几乎没有任何好处。