浅谈 SOLID 原则在前端的使用

设计模式JavaScriptTypeScript

简介

SOLID 原则是由 Robert C. Martin 在 2000 年提出的一套软件开发准则,最初用于面向对象编程(OOP),旨在解决软件开发中的复杂性和维护问题。随着时间推移,它不仅在传统 OOP 语言中广泛应用,也被引入到 JavaScript 和 TypeScript 等现代编程语言和框架中,如 ReactAngular

SOLID 原则包括以下五个方面:

  1. 单一职责原则(Single Responsibility Principle - SRP
  2. 开闭原则(Open/Closed Principle - OCP
  3. 里氏替换原则(Liskov Substitution Principle - LSP
  4. 接口隔离原则(Interface Segregation Principle - ISP
  5. 依赖倒置原则(Dependency Inversion Principle - DIP

JavaScriptTypeScript 中,尽管它们是动态语言且不以类为核心,但这些原则可融入组件化和模块化架构,开发者能借此确保代码简洁、可扩展、易维护和测试

一、 单一职责原则 (SRP)

原则

一个类或模块应只有一个发生变化的原因,仅负责一项特定功能。在前端开发中,尤其是在 React 等组件化框架中,我们经常会看到组件承担了太多职责——不仅负责 UI 渲染,还处理业务逻辑和数据请求。这种情况很容易导致代码难以维护和测试,违反了 SRP 原则。

反例(js-react)

1function UserProfile({ userId }) { 2 const [user, setUser] = useState(null); 3 4 useEffect(() => { 5 fetchUserData(); 6 }, [userId]); 7 8 async function fetchUserData() { 9 const response = await fetch(`/api/users/${userId}`); 10 const data = await response.json(); 11 setUser(data); 12 } 13 14 return <div>{user?.name}</div>; 15}

此例中,UserProfile 组件既负责 UI 渲染又负责数据获取,违反 SRP 原则,当修改数据获取或界面渲染逻辑时,可能影响组件其他部分,增加维护复杂性。

重构后代码

为了遵循 SRP 原则,我们可以将数据获取逻辑提取到一个自定义的 Hook 中,让组件 UserProfile 只关注 UI 渲染。

1// 自定义 Hook 用于获取用户数据 2function useUserData(userId) { 3 const [user, setUser] = useState(null); 4 useEffect(() => { 5 async function fetchUserData() { 6 const response = await fetch(`/api/users/${userId}`); 7 const data = await response.json(); 8 setUser(data); 9 } 10 fetchUserData(); 11 }, [userId]); 12 13 return user; 14} 15// UI 组件 16function UserProfile({ userId }) { 17 const user = useUserData(userId); // 将数据获取逻辑移到了 Hook 中 18 return <div>{user?.name}</div>; 19}

通过自定义 HookuseUserData)将数据获取逻辑与 UI 逻辑分离,符合 SRP 原则,提升了代码的可维护性和复用性。

反例(ts-angular)

1@Injectable() 2export class UserService { 3 constructor(private http: HttpClient) {} 4 5 getUser(userId: string) { 6 return this.http.get(`/api/users/${userId}`); 7 } 8 9 updateUserProfile(userId: string, data: any) { 10 // 更新用户信息并处理通知 11 return this.http.put(`/api/users/${userId}`, data).subscribe(() => { 12 console.log('User updated'); 13 alert('Profile updated successfully'); 14 }); 15 } 16}

UserService 类承担多个职责,包括获取和更新用户信息以及处理通知,违背 SRP 原则,导致维护困难。

重构后代码

1@Injectable() 2export class UserService { 3 constructor(private http: HttpClient) {} 4 5 getUser(userId: string) { 6 return this.http.get(`/api/users/${userId}`); 7 } 8 9 updateUserProfile(userId: string, data: any) { 10 return this.http.put(`/api/users/${userId}`, data); 11 } 12} 13 14// 独立的通知服务 15@Injectable() 16export class NotificationService { 17 notify(message: string) { 18 alert(message); 19 } 20}

通过将通知逻辑分离到一个独立的 NotificationService 中,我们遵循了 单一职责原则(SRP),将通知逻辑分离到 NotificationService 中,遵循 SRP 原则,每个类职责明确,带来诸多好处:

  1. 职责明确,增强可维护性。修改通知方式只需更改 NotificationService,不影响用户服务其他功能。
  2. 提高复用性。NotificationService 可在其他服务或组件中复用。
  3. 测试更加方便。可单独为 UserServiceNotificationService 编写测试。
  4. 代码扩展更加灵活。如需更改通知方式,只需修改或扩展 NotificationService
1// **职责明确,增强可维护性:**修改通知为弹出窗口通知 2@Injectable() 3export class NotificationService { 4 notify(message: string) { 5 showModal(message); // 假设我们有一个 showModal 函数用于展示弹窗 6 } 7}
1// 提高复用性。NotificationService 可在其他服务或组件中复用 2@Injectable() 3export class OrderService { 4 constructor(private notificationService: NotificationService) {} 5 placeOrder(orderData: any) { 6 // 订单处理逻辑 7 this.notificationService.notify('Order placed successfully'); 8 } 9}
1// 测试更加方便。可单独为 UserService 和 NotificationService 编写测试。 2it('should fetch user data', () => { 3 const userService = new UserService(httpClientMock); 4 userService.getUser('1').subscribe(data => { 5 expect(data).toEqual(mockUserData); 6 }); 7}); 8// NotificationService 测试 9it('should notify the user', () => { 10 const notificationService = new NotificationService(); 11 spyOn(window, 'alert'); 12 notificationService.notify('Test message'); 13 expect(window.alert).toHaveBeenCalledWith('Test message'); 14});
1//代码扩展更加灵活。如需更改通知方式,只需修改或扩展 NotificationService 2@Injectable() 3export class EmailNotificationService extends NotificationService { 4 notify(message: string) { 5 sendEmail(message); // 假设我们有一个 sendEmail 函数发送邮件 6 } 7}

二、开闭原则(OCP)

原则

软件实体应能在不修改模块源代码的情况下扩展其行为,即对扩展开放,对修改封闭。

反例(js-react)

假设我们有一个表单验证函数,它目前工作正常,但未来可能需要添加更多的验证逻辑。

1function validateForm(values) { 2 let errors = {}; 3 if (!values.name) { 4 errors.name = "Name is required"; 5 } 6 if (!values.email) { 7 errors.email = "Email is required"; 8 } else if (!/\S+@\S+\.\S+/.test(values.email)) { 9 errors.email = "Email is invalid"; 10 } 11 return errors; 12}

validateForm 函数包含所有验证逻辑,添加新验证规则需修改现有代码,违背 OCP 原则,增加维护难度和出错风险。

重构后代码

1// 基础验证器接口 2class Validator { 3 validate(value) { 4 throw new Error("validate method must be implemented"); 5 } 6} 7// 具体的验证器 8class RequiredValidator extends Validator { 9 validate(value) { 10 return value ? null : "This field is required"; 11 } 12} 13class EmailValidator extends Validator { 14 validate(value) { 15 return /\S+@\S+\.\S+/.test(value) ? null : "Email is invalid"; 16 } 17} 18// 验证表单函数 19function validateForm(values, validators) { 20 let errors = {}; 21 22 for (let field in validators) { 23 const error = validators[field].validate(values[field]); 24 if (error) { 25 errors[field] = error; 26 } 27 } 28 29 return errors; 30} 31// 使用示例 32const validators = { 33 name: new RequiredValidator(), 34 email: new EmailValidator(), 35}; 36const errors = validateForm({ name: "", email: "invalid email" }, validators); 37console.log(errors);

通过将验证逻辑封装到独立的类(如 RequiredValidatorEmailValidator)中,我们使得验证器符合 开放/封闭原则(OCP)。现在,如果需要添加新的验证规则(例如电话号码验证),只需创建一个新的验证器类,而无需修改现有的验证逻辑;换句话说,应该允许在不修改现有核心代码的情况下添加新功能。

反例(ts-angular)

Angular 中,服务和组件的设计应允许添加新功能,而无需修改核心逻辑。

1export class NotificationService { 2 send(type: 'email' | 'sms', message: string) { 3 if (type === 'email') { 4 // 发送电子邮件 5 } else if (type === 'sms') { 6 // 发送短信 7 } 8 } 9}

在这个例子中,NotificationService 类违反了 开放/封闭原则(OCP),因为每次需要支持新类型的通知(例如推送通知)时,必须修改 send 方法。这不仅会增加维护成本,还容易引发错误,尤其是当代码变得越来越复杂时。

重构后代码

1interface Notification { 2 send(message: string): void; 3} 4 5@Injectable() 6export class EmailNotification implements Notification { 7 send(message: string) { 8 // 发送电子邮件的逻辑 9 } 10} 11 12@Injectable() 13export class SMSNotification implements Notification { 14 send(message: string) { 15 // 发送短信的逻辑 16 } 17} 18 19@Injectable() 20export class NotificationService { 21 constructor(private notifications: Notification[]) {} 22 23 notify(message: string) { 24 this.notifications.forEach(n => n.send(message)); 25 } 26}

通过将通知发送逻辑封装到各自独立的类(EmailNotificationSMSNotification)中,我们实现了符合 开放/封闭原则(OCP) 的设计。这个设计的核心思想是,所有新功能(例如新的通知类型)都可以通过创建新的类来扩展,而不需要修改现有的 NotificationService 类。好处:对扩展开放,对修改封闭、提高复用性、测试更加简单、增强代码的灵活性与维护性。


三、 里氏替换原则 (LSP)

原则

子类型必须可以替换其基类型。派生类或组件应该能够替换基类,而不会影响程序的正确性。

反例(js-react)

当使用高阶组件 (HOC) 或有条件地渲染不同组件时,LSP 有助于确保所有组件的行为都可预测。

1function Button({ onClick }) { 2 return <button onClick={onClick}>Click me</button>; 3} 4function LinkButton({ href }) { 5 return <a href={href}>Click me</a>; 6} 7<Button onClick={() => {}} />; 8<LinkButton href="/home" />;

这里 ButtonLinkButton 不一致,一个用 onClick,一个用 href,替换起来比较困难。

重构后代码

1function Clickable({ children, onClick }) { 2 return <div onClick={onClick}>{children}</div>; 3} 4 5function Button({ onClick }) { 6 return <Clickable onClick={onClick}> 7 <button>Click me</button> 8 </Clickable>; 9} 10 11function LinkButton({ href }) { 12 return <Clickable onClick={() => window.location.href = href}> 13 <a href={href}>Click me</a> 14 </Clickable>; 15}

现在,ButtonLinkButton 的行为类似,均遵循 LSP

反例(ts-angular)

1class Rectangle { 2 constructor(protected width: number, protected height: number) {} 3 4 area() { 5 return this.width * this.height; 6 } 7} 8class Square extends Rectangle { 9 constructor(size: number) { 10 super(size, size); 11 } 12 13 setWidth(width: number) { 14 this.width = width; 15 this.height = width; // Breaks LSP 16 } 17}

修改 Square 中的 setWidth 违反了 LSP,因为 Square 的行为与 Rectangle 不同。

重构后代码

1class Shape { 2 area(): number { 3 throw new Error('Method not implemented'); 4 } 5} 6 7class Rectangle extends Shape { 8 constructor(private width: number, private height: number) { 9 super(); 10 } 11 12 area() { 13 return this.width * this.height; 14 } 15} 16 17class Square extends Shape { 18 constructor(private size: number) { 19 super(); 20 } 21 22 area() { 23 return this.size * this.size; 24 } 25}

现在,SquareRectangle 可以相互替代而不违反 LSP。


四、接口隔离原则 (ISP)

原则

客户端不应被迫依赖他们不使用的接口

反例(js-react)

React 组件有时会收到不必要的 props,导致代码紧密耦合且庞大。

1function MultiPurposeComponent({ user, posts, comments }) { 2 return ( 3 <div> 4 <UserProfile user={user} /> 5 <UserPosts posts={posts} /> 6 <UserComments comments={comments} /> 7 </div> 8 ); 9}

这里,组件依赖于多个 props,即使它可能并不总是使用它们。

重构后代码

1function UserProfileComponent({ user }) { 2 return <UserProfile user={user} />; 3} 4 5function UserPostsComponent({ posts }) { 6 return <UserPosts posts={posts} />; 7} 8 9function UserCommentsComponent({ comments }) { 10 return <UserComments comments={comments} />; 11}

通过将组件拆分成更小的组件,每个组件仅依赖于它实际使用的数据。

反例(ts-angular)

1interface Worker { 2 work(): void; 3 eat(): void; 4} 5 6class HumanWorker implements Worker { 7 work() { 8 console.log('Working'); 9 } 10 eat() { 11 console.log('Eating'); 12 } 13} 14 15class RobotWorker implements Worker { 16 work() { 17 console.log('Working'); 18 } 19 eat() { 20 throw new Error('Robots do not eat'); // Violates ISP 21 } 22}

这里,RobotWorker 被迫实现了不相关的 eat 方法。

重构后代码

1interface Worker { 2 work(): void; 3} 4interface Eater { 5 eat(): void; 6} 7class HumanWorker implements Worker, Eater { 8 work() { 9 console.log('Working'); 10 } 11 eat() { 12 console.log('Eating'); 13 } 14} 15class RobotWorker implements Worker { 16 work() { 17 console.log('Working'); 18 } 19}

通过分离 WorkerEater 接口,我们确保客户端只依赖于它们所需要的。


五、依赖倒置原则 (DIP)

原则

高级模块不应依赖于低级模块。两者都应依赖于抽象(例如接口)。

反例(js-react)

1function fetchUser(userId) { 2 return fetch(`/api/users/${userId}`).then(res => res.json()); 3} 4 5function UserComponent({ userId }) { 6 const [user, setUser] = useState(null); 7 8 useEffect(() => { 9 fetchUser(userId).then(setUser); 10 }, [userId]); 11 12 return <div>{user?.name}</div>; 13}

这里,UserComponentfetchUser 函数紧密耦合。

重构后代码

1function UserComponent({ userId, fetchUserData }) { 2 const [user, setUser] = useState(null); 3 4 useEffect(() => { 5 fetchUserData(userId).then(setUser); 6 }, [userId, fetchUserData]); 7 8 return <div>{user?.name}</div>; 9} 10 11// Usage 12<UserComponent userId={1} fetchUserData={fetchUser} />;

通过将 fetchUserData 注入组件,我们可以轻松地交换实现以进行测试或用于不同的用例。

反例(ts-angular)

1@Injectable() 2export class UserService { 3 constructor(private http: HttpClient) {} 4 5 getUser(userId: string) { 6 return this.http.get(`/api/users/${userId}`); 7 } 8} 9 10@Injectable() 11export class UserComponent { 12 constructor(private userService: UserService) {} 13 14 loadUser(userId: string) { 15 this.userService.getUser(userId).subscribe(user => console.log(user)); 16 } 17}

UserComponentUserService 紧密耦合,因此很难替换掉 UserService

重构后代码

1interface UserService { 2 getUser(userId: string): Observable<User>; 3} 4 5@Injectable() 6export class ApiUserService implements UserService { 7 constructor(private http: HttpClient) {} 8 9 getUser(userId: string) { 10 return this.http.get<User>(`/api/users/${userId}`); 11 } 12} 13@Injectable() 14export class UserComponent { 15 constructor(private userService: UserService) {} 16 17 loadUser(userId: string) { 18 this.userService.getUser(userId).subscribe(user => console.log(user)); 19 } 20}

通过依赖接口(UserService),UserComponent 现在与 ApiUserService 的具体实现分离。


结论

无论是前端的 ReactAngular 等框架,还是后端的 Node.jsSOLID 原则都能作为指南,让软件架构更加稳固。SOLID 原则能非常有效地确保代码干净、可维护且可扩展,在 JavaScriptTypeScript 框架(如 ReactAngular)中同样如此。应用这些原则,开发人员能编写灵活且可重复使用的代码,随着需求的发展,这些代码也能轻松扩展和重构。遵循 SOLID 原则,能让代码库变得强大,为未来的增长做好准备。