+2

Nguyên tắc Solid: Nền tảng cho ứng dụng JavaScript và TypeScript bền vững

Nguyên tắc SOLID là nền tảng của việc phát triển phần mềm rõ ràng, có khả năng mở rộng và dễ bảo trì. Bài viết này sẽ giải thích chi tiết từng nguyên tắc SOLID với các ví dụ thực tế trong cả JavaScript (JS) và TypeScript (TS).

Nguyên tắc trách nhiệm duy nhất (SRP) - Single Responsibility Principle

Nội dung của nguyên tắc: Một lớp hoặc module chỉ nên có một lý do để thay đổi. Nó chỉ nên chịu trách nhiệm cho một phần chức năng duy nhất.

1. Ví dụ trong JavaScript (React):

Trong React, chúng ta thường thấy các component chịu trách nhiệm cho quá nhiều việc—chẳng hạn như quản lý cả UI và logic nghiệp vụ.

Ví dụ về một đoạn mã vi phạm nguyên tắc

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUserData();
  }, [userId]);

  async function fetchUserData() {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUser(data);
  }

  return <div>{user?.name}</div>;
}

Ở đây, component UserProfile vi phạm SRP vì nó xử lý cả việc render UI và lấy dữ liệu.

Đoạn mã sau khi sửa lại:

// Custom hook for fetching user data
function useUserData(userId) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    async function fetchUserData() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
    }
    fetchUserData();
  }, [userId]);

  return user;
}

// UI Component
function UserProfile({ userId }) {
  const user = useUserData(userId); // Moved data fetching logic to a hook

  return <div>{user?.name}</div>;
}

Bằng cách sử dụng custom hook (useUserData), chúng ta tách logic lấy dữ liệu khỏi UI, giữ cho mỗi phần chịu trách nhiệm cho một nhiệm vụ duy nhất.

2. Ví dụ trong TypeScript (Angular):

Trong Angular, các service và component có thể trở nên lộn xộn với nhiều trách nhiệm khác nhau.

Ví dụ về đoạn mã vi phạm nguyên tắc:

@Injectable()
export class UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get(`/api/users/${userId}`);
  }

  updateUserProfile(userId: string, data: any) {
    // Updating the profile and handling notifications
    return this.http.put(`/api/users/${userId}`, data).subscribe(() => {
      console.log('User updated');
      alert('Profile updated successfully');
    });
  }
}

UserService này có nhiều trách nhiệm: tìm nạp, cập nhật và xử lý thông báo.

Đoạn mã sau khi sửa lại:

@Injectable()
export class UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get(`/api/users/${userId}`);
  }

  updateUserProfile(userId: string, data: any) {
    return this.http.put(`/api/users/${userId}`, data);
  }
}

// Separate notification service
@Injectable()
export class NotificationService {
  notify(message: string) {
    alert(message);
  }
}

Bằng cách tách việc xử lý thông báo thành một service riêng biệt (NotificationService), chúng ta đảm bảo rằng mỗi lớp có một trách nhiệm duy nhất.

Nguyên tắc Mở/Đóng (OCP) - Open/Closed Principle

Nội dung của nguyên tắc: Các thực thể phần mềm nên mở để mở rộng nhưng đóng để sửa đổi. Điều này có nghĩa là bạn sẽ có thể mở rộng hành vi của một module mà không cần thay đổi mã nguồn của nó.

1. Ví dụ trong JavaScript (React):

Bạn có thể có một hàm xác validation form hoạt động tốt nhưng có thể yêu cầu logic xác thực bổ sung trong tương lai.

Ví dụ về đoạn mã vi phạm nguyên tắc:

function validate(input) {
  if (input.length < 5) {
    return 'Input is too short';
  }
  if (!input.includes('@')) {
    return 'Invalid email';
  }
  return 'Valid input';
}

Bất cứ khi nào bạn cần một quy tắc validation mới, bạn sẽ phải sửa đổi hàm này, vi phạm OCP.

Đoạn mã sau khi sửa lại:

function validate(input, rules) {
  return rules.map(rule => rule(input)).find(result => result !== 'Valid') || 'Valid input';
}

const lengthRule = input => input.length >= 5 ? 'Valid' : 'Input is too short';
const emailRule = input => input.includes('@') ? 'Valid' : 'Invalid email';

validate('test@domain.com', [lengthRule, emailRule]);

Bây giờ, chúng ta có thể mở rộng các quy tắc validation mà không cần sửa đổi hàm validate ban đầu, tuân thủ OCP.

2. Ví dụ trong TypeScript (Angular):

Trong Angular, các service và component nên được thiết kế để cho phép thêm các tính năng mới mà không cần sửa đổi logic cốt lõi.

Ví dụ về đoạn mã vi phạm nguyên tắc:

export class NotificationService {
  send(type: 'email' | 'sms', message: string) {
    if (type === 'email') {
      // Send email
    } else if (type === 'sms') {
      // Send SMS
    }
  }
}

Service này vi phạm OCP vì bạn sẽ cần sửa đổi phương thức send mỗi khi bạn thêm một loại thông báo mới (ví dụ: thông báo đẩy).

Đoạn mã sau khi sửa lại:

interface Notification {
  send(message: string): void;
}

@Injectable()
export class EmailNotification implements Notification {
  send(message: string) {
    // Send email logic
  }
}

@Injectable()
export class SMSNotification implements Notification {
  send(message: string) {
    // Send SMS logic
  }
}

@Injectable()
export class NotificationService {
  constructor(private notifications: Notification[]) {}

  notify(message: string) {
    this.notifications.forEach(n => n.send(message));
  }
}

Giờ đây, việc thêm các loại thông báo mới chỉ yêu cầu tạo các lớp mới mà không cần thay đổi chính NotificationService.

Nguyên tắc thay thế Liskov (LSP) - Liskov Substitution Principle

Nội dung của nguyên tắc: Các kiểu con phải có thể thay thế cho các kiểu cơ sở của chúng. Các lớp hoặc component dẫn xuất sẽ có thể thay thế các lớp cơ sở mà không ảnh hưởng đến tính chính xác của chương trình.

1. Ví dụ trong JavaScript (React):

Khi sử dụng các higher-order component (HOCs) hoặc render các component khác nhau một cách có điều kiện, LSP giúp đảm bảo rằng tất cả các component đều hoạt động theo cách có thể dự đoán được.

Ví dụ về đoạn mã vi phạm nguyên tắc:

function Button({ onClick }) {
  return <button onClick={onClick}>Click me</button>;
}

function LinkButton({ href }) {
  return <a href={href}>Click me</a>;
}

<Button onClick={() => {}} />;
<LinkButton href="/home" />;

Ở đây, Button và LinkButton không nhất quán. Một cái sử dụng onClick và cái còn lại sử dụng href, gây khó khăn cho việc thay thế.

Đoạn mã sau khi sửa lại:

function Clickable({ children, onClick }) {
  return <div onClick={onClick}>{children}</div>;
}

function Button({ onClick }) {
  return <Clickable onClick={onClick}>
    <button>Click me</button>
  </Clickable>;
}

function LinkButton({ href }) {
  return <Clickable onClick={() => window.location.href = href}>
    <a href={href}>Click me</a>
  </Clickable>;
}

Giờ đây, cả Button và LinkButton đều hoạt động tương tự nhau, tuân thủ LSP.

2. Ví dụ trong TypeScript (Angular)

Ví dụ về đoạn mã vi phạm nguyên tắc:

class Rectangle {
  constructor(protected width: number, protected height: number) {}

  area() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(size: number) {
    super(size, size);
  }

  setWidth(width: number) {
    this.width = width;
    this.height = width; // Breaks LSP
  }
}

Việc sửa đổi setWidth trong Square vi phạm LSP vì Square hoạt động khác với Rectangle.

Đoạn mã sau khi sửa lại:

class Shape {
  area(): number {
    throw new Error('Method not implemented');
  }
}

class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }

  area() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(private size: number) {
    super();
  }

  area() {
    return this.size * this.size;
  }
}

Bây giờ, Square và Rectangle có thể được thay thế cho nhau mà không vi phạm LSP.

Nguyên tắc phân tách giao diện (ISP) - Interface Segregation Principle

Nội dung của nguyên tắc: Clients không nên bị ép buộc phải phụ thuộc vào các giao diện mà chúng không sử dụng.

1. Ví dụ trong JavaScript (React):

Các component React đôi khi nhận được các props không cần thiết, dẫn đến code bị ràng buộc chặt chẽ và cồng kềnh.

Ví dụ về đoạn mã vi phạm nguyên tắc:

function MultiPurposeComponent({ user, posts, comments }) {
  return (
    <div>
      <UserProfile user={user} />
      <UserPosts posts={posts} />
      <UserComments comments={comments} />
    </div>
  );
}

Ở đây, component phụ thuộc vào nhiều props, mặc dù nó có thể không phải lúc nào cũng sử dụng chúng.

Đoạn mã sau khi sửa lại:

function UserProfileComponent({ user }) {
  return <UserProfile user={user} />;
}

function UserPostsComponent({ posts }) {
  return <UserPosts posts={posts} />;
}

function UserCommentsComponent({ comments }) {
  return <UserComments comments={comments} />;
}

Bằng cách chia component thành các component nhỏ hơn, mỗi component chỉ phụ thuộc vào dữ liệu mà nó thực sự sử dụng.

2. Ví dụ trong TypeScript (Angular):

Ví dụ về đoạn mã vi phạm nguyên tắc:

interface Worker {
  work(): void;
  eat(): void;
}

class HumanWorker implements Worker {
  work() {
    console.log('Working');
  }
  eat() {
    console.log('Eating');
  }
}

class RobotWorker implements Worker {
  work() {
    console.log('Working');
  }
  eat() {
    throw new Error('Robots do not eat'); // Violates ISP
  }
}

Ở đây, RobotWorker bị ép buộc phải triển khai một phương thức eat không liên quan.

Đoạn mã sau khi sửa lại:

interface Worker {
  work(): void;
}

interface Eater {
  eat(): void;
}

class HumanWorker implements Worker, Eater {
  work() {
    console.log('Working');
  }

  eat() {
    console.log('Eating');
  }
}

class RobotWorker implements Worker {
  work() {
    console.log('Working');
  }
}

Bằng cách tách các interface Worker và Eater, chúng ta đảm bảo rằng các clients chỉ phụ thuộc vào những gì chúng cần.

Nguyên tắc đảo ngược phụ thuộc (DIP) - Dependency Inversion Principle

Nội dung của nguyên tắc: Các module cấp cao không nên phụ thuộc vào các module cấp thấp. Cả hai nên phụ thuộc vào các lớp trừu tượng (ví dụ: interface).

1. Ví dụ trong JavaScript (React):

Ví dụ về đoạn mã vi phạm nguyên tắc:

function fetchUser(userId) {
  return fetch(`/api/users/${userId}`).then(res => res.json());
}

function UserComponent({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}

Ở đây, UserComponent được gắn chặt với hàm fetchUser.

Đoạn mã sau khi sửa lại:

function UserComponent({ userId, fetchUserData }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUserData(userId).then(setUser);
  }, [userId, fetchUserData]);

  return <div>{user?.name}</div>;
}

// Usage
<UserComponent userId={1} fetchUserData={fetchUser} />;

Bằng cách inject fetchUserData vào component, chúng ta có thể dễ dàng hoán đổi việc triển khai để test hoặc các trường hợp sử dụng khác nhau.

2. Ví dụ trong TypeScript (Angular):

Ví dụ về đoạn mã vi phạm nguyên tắc:

@Injectable()
export class UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get(`/api/users/${userId}`);
  }
}

@Injectable()
export class UserComponent {
  constructor(private userService: UserService) {}

  loadUser(userId: string) {
    this.userService.getUser(userId).subscribe(user => console.log(user));
  }
}

UserComponent được gắn chặt với UserService, gây khó khăn cho việc hoán đổi UserService.

Đoạn mã sau khi sửa lại:

interface UserService {
  getUser(userId: string): Observable<User>;
}

@Injectable()
export class ApiUserService implements UserService {
  constructor(private http: HttpClient) {}

  getUser(userId: string) {
    return this.http.get<User>(`/api/users/${userId}`);
  }
}

@Injectable()
export class UserComponent {
  constructor(private userService: UserService) {}

  loadUser(userId: string) {
    this.userService.getUser(userId).subscribe(user => console.log(user));
  }
}

Bằng cách phụ thuộc vào một interface (UserService), UserComponent hiện được tách rời khỏi việc triển khai cụ thể của ApiUserService.

Các bước tiếp theo

Cho dù bạn đang làm việc trên front-end với các framework như React hoặc Angular hay back-end với Node.js, các nguyên tắc SOLID đóng vai trò như một hướng dẫn để đảm bảo rằng kiến trúc phần mềm của bạn luôn vững chắc.

Để tích hợp đầy đủ các nguyên tắc này vào dự án của bạn:

  • Thực hành thường xuyên: Refactor các codebase hiện có để áp dụng các nguyên tắc SOLID và xem xét code để đảm bảo tuân thủ.
  • Phối hợp với nhóm của bạn: Khuyến khích các phương pháp hay nhất thông qua review code và thảo luận xung quanh clean architecture.
  • Luôn tò mò: Nguyên tắc SOLID chỉ là khởi đầu. Hãy khám phá các mẫu kiến trúc khác như MVC, MVVM hoặc CQRS được xây dựng dựa trên những nguyên tắc cơ bản này để cải thiện hơn nữa thiết kế của bạn.

Kết luận Nguyên tắc SOLID rất hiệu quả để đảm bảo rằng code của bạn rõ ràng, dễ bảo trì và có khả năng mở rộng, ngay cả trong các framework JavaScript và TypeScript như React và Angular. Việc áp dụng các nguyên tắc này cho phép các developer viết code linh hoạt và có thể tái sử dụng, dễ dàng mở rộng và refactor khi các yêu cầu phát triển. Bằng cách tuân theo SOLID, bạn có thể làm cho codebase của mình trở nên mạnh mẽ và sẵn sàng cho sự phát triển trong tương lai.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.