前言:本文翻译自 Mastering React JS SOLID Principles
什么是 SOLID 原则?
SOLID 原则是五个设计原则,帮助我们保持应用程序的可重用性、可维护性、可扩展性和松散耦合性。
SOLID 原则是:
- [S] --- 单一职责原则
- [O] --- 开闭原则
- [L] --- 里氏替换原理
- [I] - 接口隔离原则
- [D] - 依赖倒置原则
单一职责原则
_"一个模块应该对一个且仅一个参与者负责。" _ ------维基百科。
单一职责原则规定组件应该有一个明确的目的或职责。
它应该专注于特定的功能或行为,并避免承担不相关的任务。遵循 SRP 使组件更加集中、模块化并且易于理解和修改。我们来看看实际的实现。
tsx
// 负责渲染用户个人资料信息的组件
const UserProfile = ( { user } ) => {
return (
<div>
<h1>User Profile</h1>
<p>Name: {user.name}</p>
< p>Email:{user.email}</p>
</div>
);
};
// 负责渲染用户头像的组件
const ProfilePicture = ( { user } ) => {
return (
<div>
<h1>头像</h1>
<img src={user.
profilePictureUrl} alt="个人资料" /> </div>
);
};
// 结合了 UserProfile 和 ProfilePicture 组件的父组件
const App = ( ) => {
const user = {
name : "John Doe" ,
email : "johndoe@example.com" ,
profilePictureUrl : "https://example. com/profile.jpg" ,
};
return (
<div>
<UserProfile user={user} />
<ProfilePicture user={user} />
</div>
);
};
export default App;
在此示例中,我们有两个独立的组件:UserProfile
和ProfilePicture
。该UserProfile
组件负责渲染用户的个人资料信息(姓名和电子邮件),而该ProfilePicture
组件则负责渲染用户的个人资料图片。每个组件都有单一的职责并且可以独立地重用。
通过遵守 SRP,单独管理和修改这些组件变得更加容易。例如,如果您想要更改用户的个人资料图片,您可以专注于该ProfilePicture
组件而不影响该UserProfile
组件。这种关注点分离改进了代码组织、可维护性和可重用性。
tsx
// 负责渲染用户个人资料信息和个人资料图片的组件
const UserProfile = ( { user } ) => {
return (
<div>
<h1>User Profile</h1>
<p>Name: {user.name}</p >
<p>Email: {user.email}</p>
<img src={user.profilePictureUrl} alt="个人资料" />
</div>
);
};
export default App;
在此示例中,我们有一个名为 的组件UserProfile
,负责呈现用户的个人资料信息及其个人资料图片。这违反了 SRP,因为该组件具有多重职责。
如果需要更改用户个人资料信息或个人资料图片,则需要修改此单个组件,这违背了"有理由更改"的原则。随着该组件变得越来越复杂,理解和维护它变得更加困难。
开闭原则
"软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭。" ------维基百科。
开闭原则强调组件应该对扩展开放(可以添加新的行为或功能),但对修改封闭(现有代码应保持不变)。
这一原则鼓励创建能够适应变化、模块化且易于维护的代码。
tsx
// Button.js
import React from 'react';
const Button = ({ onClick, children }) => (
<button onClick={onClick}>{children}</button>
);
export default Button;
tsx
// IconButton.js
import React from 'react';
import Button from './Button';
const IconButton = ({ onClick, children, icon }) => (
<Button onClick={onClick}>
<span className="icon">{icon}</span>
{children}
</Button>
);
export default IconButton;
在上面的示例中,我们有一个Button
呈现基本按钮的组件。然后,我们创建一个IconButton
组件来扩展该组件的功能Button
。它添加一个icon
道具并渲染图标以及按钮的子项。
通过使用这种方法,我们遵循了开闭原则。Button
我们通过创建一个新组件( )来扩展组件的功能IconButton
,而无需修改组件的现有代码Button
。这使我们能够添加新的按钮类型或变体,而不影响现有的按钮实现。
通过遵循开闭原则,我们的代码变得更加可维护、模块化,并且在未来更容易扩展。
tsx
// Button.js
import React from 'react';
const Button = ({ onClick, children, icon }) => {
if (icon) {
return (
<button onClick={onClick}>
<span className="icon">{icon}</span>
{children}
</button>
);
} else {
return (
<button onClick={onClick}>{children}</button>
);
}
};
export default Button;
在此示例中,组件已被修改以处理传递 prop 的Button
情况。icon
如果icon
提供了 prop,它会渲染带有图标的按钮;否则,它会呈现没有图标的按钮。
这种方法的问题在于它违反了开闭原则,因为我们修改了现有Button
组件而不是扩展它。从长远来看,这使得组件更脆弱且更难以维护。
将来,如果您想添加更多变体或类型的按钮,则需要Button
再次修改组件。这违反了封闭修改的原则。
里氏替换原则
"子类型对象应该可以替代父类型对象" ------维基百科。
里氏替换原则 (LSP) 是 SOLID 原则之一,它规定超类的对象应该可以用其子类的对象替换,而不影响程序的正确性。
在 React.js 的上下文中,让我们考虑一个示例,其中我们有一个名为 的基本组件Button
以及两个子类PrimaryButton
和SecondaryButton
。PrimaryButton
和SecondaryButton
继承自Button
组件。根据 LSP 的说法,我们应该能够在任何需要实例的地方使用 的PrimaryButton
和 SecondaryButton
实例,而不会造成任何问题。
tsx
class Button extends React.Component {
render() {
return (
<button>{this.props.text}</button>
);
}
}
class PrimaryButton extends Button {
render() {
return (
<button style={{ backgroundColor: 'blue', color: 'white' }}>{this.props.text}</button>
);
}
}
class SecondaryButton extends Button {
render() {
return (
<button style={{ backgroundColor: 'gray', color: 'black' }}>{this.props.text}</button>
);
}
}
// Usage of the components
function App() {
return (
<div>
<Button text="Regular Button" />
<PrimaryButton text="Primary Button" />
<SecondaryButton text="Secondary Button" />
</div>
);
}
在上面的例子中, PrimaryButton
和 SecondaryButton
是Button
的子类。我们可以看到,两个子类都继承了render()
基类的方法,并且它们重写了该方法以提供自己的渲染行为。
由于 PrimaryButton
和 SecondaryButton
是Button
的子类,因此我们可以在需要 实例的地方自由使用Button
的两个子类实例,例如在App
组件中。这演示了里氏替换原则的实际应用,因为子类可以无缝替换基类,而不会影响程序的功能。
请注意,这是一个简化的示例,旨在使用类组件说明 React.js 中的 LSP 概念。在现实应用程序中,您通常会使用函数组件和挂钩而不是类组件。但是,原则仍然是相同的:派生组件应该能够替换其基本组件,而不会引起任何问题。
tsx
class Button extends React.Component {
render() {
return (
<button>{this.props.text}</button>
);
}
}
class PrimaryButton extends Button {
render() {
return (
<button style={{ backgroundColor: 'blue', color: 'white' }}>{this.props.text}</button>
);
}
}
class SecondaryButton extends Button {
render() {
// Violation: Changing the behavior
return (
<a href="#" style={{ backgroundColor: 'gray', color: 'black' }}>{this.props.text}</a>
);
}
}
// Usage of the components
function App() {
return (
<div>
<Button text="Regular Button" />
<PrimaryButton text="Primary Button" />
<SecondaryButton text="Secondary Button" />
</div>
);
}
在此示例中,该类SecondaryButton
违反了里氏替换原则。<button>
它不像基类 和 那样渲染元素PrimaryButton
,而是渲染一个<a>
元素。这违反了原则,因为派生类 ( SecondaryButton
) 的行为与基类 ( Button
) 的行为不同。
当我们在组件SecondaryButton
中使用 时,与和App
相比,它不会像Button
和PrimaryButton
一样预期运行。这违反了原则,因为派生类不提供基类的兼容替代品。
应用里氏替换原则时,确保子类遵循与超类相同的行为非常重要。
接口隔离原则
"**任何代码都不应该被迫依赖于它不使用的方法。"------维基百科。
接口隔离原则(ISP)建议接口应该集中并根据特定的客户需求进行定制,而不是过于宽泛并迫使客户实现不必要的功能。我们来看看实际的实现。
tsx
// Interface for displaying user information
interface DisplayUser {
name: string;
email: string;
}
// UserProfile component implementing DisplayUser interface
const UserProfile: React.FC<DisplayUser> = ({ name, email }) => {
return (
<div>
<h2>User Profile</h2>
<p>Name: {name}</p>
<p>Email: {email}</p>
</div>
);
};
// Usage of the component
const App: React.FC = () => {
const user = {
name: 'John Doe',
email: 'johndoe@example.com',
};
return (
<div>
<UserProfile {...user} />
</div>
);
};
在这个较短的示例中,DisplayUser
界面定义了显示用户信息所需的属性。该UserProfile
组件是一个功能组件,通过 props 接收name
和email
属性并相应地渲染用户配置文件。
该App
组件UserProfile
通过传递name
和email
属性作为 props 来使用该组件来显示用户配置文件。
通过隔离接口,UserProfile
组件仅依赖于DisplayUser
接口,接口提供了呈现用户配置文件所需的属性。这促进了更加集中和模块化的设计,其中组件可以重复使用,而无需不必要的依赖。
这个较短的示例演示了接口隔离原则如何帮助保持接口简洁和相关,从而产生更易于维护和灵活的代码。
tsx
// Interface for user management
interface UserManagement {
addUser: (user: User) => void;
displayUser: (userId: number) => void;
}
// UserProfile component implementing UserManagement interface
const UserProfile: React.FC<UserManagement> = ({ addUser, displayUser }) => {
// ...
return (
// ...
);
};
// Usage of the component
const App: React.FC = () => {
const userManager: UserManagement = {
addUser: (user) => {
// Add user logic
},
displayUser: (userId) => {
// Display user logic
},
};
return (
<div>
<UserProfile {...userManager} />
</div>
);
};
在这个糟糕的例子中,UserManagement
接口最初有两个方法:addUser
和displayUser
。该UserProfile
组件应实现此接口。
然而,当我们尝试使用该组件时,问题就出现了UserProfile
。该UserProfile
组件接收UserManagement
接口作为 props,但它只需要displayUser
渲染用户配置文件的方法。它不使用或不需要该addUser
方法。
这违反了接口隔离原则,因为UserProfile
组件被迫依赖于包含UserManagement
它不需要的方法的接口 ( )。它引入了不必要的依赖关系,如果错误地调用或实现了未使用的方法,则可能会导致代码复杂性和潜在问题。
为了遵守接口隔离原则,UserManagement
接口应该被分成更有针对性和更具体的接口,允许组件只依赖于它们需要的接口。
依赖倒置原则
"一个实体应该依赖于抽象,而不是具体"------维基百科。
依赖倒置原则(DIP)强调高层组件不应该依赖于低层组件。这一原则促进了松散耦合和模块化,并有助于更轻松地维护软件系统。我们来看看实际的实现。
tsx
// Abstraction: Interface or contract
const DataService = () => {
return {
fetchData: () => {}
};
};
// High-level component
const App = ({ dataService }) => {
const [data, setData] = useState([]);
useEffect(() => {
dataService.fetchData().then((result) => {
setData(result);
});
}, [dataService]);
return (
<div>
<h1>Data:</h1>
<ul>
{data.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
};
// Dependency: Low-level component
const DatabaseService = () => {
const fetchData = () => {
// Simulated fetching of data from a database
return Promise.resolve(['item1', 'item2', 'item3']);
};
return {
fetchData
};
};
// Dependency Injection: Providing the implementation
const AppContainer = () => {
const dataService = DataService(); // Creating the abstraction
const databaseService = DatabaseService(); // Creating the low-level dependency
// 注入依赖
return <App dataService={dataService} />;
};
export default AppContainer;
在这个较短的示例中,我们有DataService
抽象,它表示获取数据的契约。组件App
通过 prop 依赖于这个抽象dataService
。
组件App
使用该方法获取数据dataService.fetchData
并相应地更新组件的状态。
它DatabaseService
是低级组件,提供从数据库获取数据的实现。
该AppContainer
组件负责创建抽象 ( dataService
) 和低级依赖 ( databaseService
)。然后它将dataService
依赖项注入到组件中App
。
通过遵循 DIP,App
组件依赖于抽象 ( DataService
) 而不是低级组件 ( DatabaseService
)。DataService
这允许在保持组件不变的同时更换不同实现时提供更好的模块化性、可测试性和灵活性App
。
tsx
// High-level component
const App = () => {
const [data, setData] = useState([]);
useEffect(() => {
// Violation: App depends directly on a specific low-level implementation
fetchDataFromDatabase().then((result) => {
setData(result);
});
}, []);
const fetchDataFromDatabase = () => {
// Simulated fetching of data from a specific database
return Promise.resolve(['item1', 'item2', 'item3']);
};
return (
<div>
<h1>Data:</h1>
<ul>
{data.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
};
export default App;;
在此示例中,App
组件直接依赖于特定的低级实现来fetchDataFromDatabase
从数据库获取数据。
这违反了依赖倒置原则,因为高级组件 ( App
) 与特定的低级组件 ( fetchDataFromDatabase
) 紧密耦合。低级实现中的任何更改或替换都需要修改高级组件。
为了遵守依赖倒置原则,高级组件(App
)应该依赖于抽象或接口,而不是具体的低级实现。通过这样做,高级组件与具体实现解耦,使其更加灵活且更易于维护。
结论
SOLID 原则提供了指导原则,使开发人员能够创建设计良好、可维护且可扩展的软件解决方案。通过遵循这些原则,开发人员可以实现模块化、代码可重用性、灵活性并降低代码复杂性。