ไทย

เจาะลึกสถาปัตยกรรมคอมโพเนนต์ของ React เปรียบเทียบระหว่าง Composition และ Inheritance เรียนรู้ว่าทำไม React ถึงนิยมใช้ Composition และสำรวจรูปแบบต่างๆ เช่น HOCs, Render Props และ Hooks เพื่อสร้างคอมโพเนนต์ที่ยืดหยุ่นและนำกลับมาใช้ใหม่ได้

สถาปัตยกรรมคอมโพเนนต์ของ React: ทำไม Composition ถึงดีกว่า Inheritance

ในโลกของการพัฒนาซอฟต์แวร์ สถาปัตยกรรมคือสิ่งสำคัญที่สุด วิธีที่เราจัดโครงสร้างโค้ดจะกำหนดความสามารถในการขยายระบบ (scalability), การบำรุงรักษา (maintainability) และการนำกลับมาใช้ใหม่ (reusability) สำหรับนักพัฒนาที่ทำงานกับ React หนึ่งในการตัดสินใจทางสถาปัตยกรรมที่สำคัญที่สุดคือการแชร์ Logic และ UI ระหว่างคอมโพเนนต์ ซึ่งนำเราไปสู่การถกเถียงสุดคลาสสิกในการเขียนโปรแกรมเชิงวัตถุ ที่ถูกนำมาตีความใหม่ในโลกของคอมโพเนนต์เบสของ React นั่นคือ: Composition vs. Inheritance

หากคุณมาจากสายภาษาโปรแกรมเชิงวัตถุแบบดั้งเดิมอย่าง Java หรือ C++ การสืบทอดคุณสมบัติ (Inheritance) อาจเป็นตัวเลือกแรกที่คุณนึกถึง มันเป็นแนวคิดที่ทรงพลังสำหรับการสร้างความสัมพันธ์แบบ 'is-a' (เป็น) อย่างไรก็ตาม เอกสารทางการของ React ได้ให้คำแนะนำที่ชัดเจนและหนักแน่นว่า: "ที่ Facebook เราใช้ React ในคอมโพเนนท์หลายพันตัว และเรายังไม่เคยเจอกรณีการใช้งานใดๆ ที่เราจะแนะนำให้สร้างลำดับชั้นการสืบทอดคอมโพเนนท์เลย"

โพสต์นี้จะสำรวจตัวเลือกทางสถาปัตยกรรมนี้อย่างละเอียด เราจะมาทำความเข้าใจว่า Inheritance และ Composition หมายถึงอะไรในบริบทของ React, แสดงให้เห็นว่าทำไม Composition ถึงเป็นแนวทางที่เป็นธรรมชาติและดีกว่า และสำรวจรูปแบบที่ทรงพลังต่างๆ ตั้งแต่ Higher-Order Components ไปจนถึง Hooks สมัยใหม่ ที่ทำให้ Composition กลายเป็นเพื่อนที่ดีที่สุดของนักพัฒนาในการสร้างแอปพลิเคชันที่แข็งแกร่งและยืดหยุ่นสำหรับผู้ใช้งานทั่วโลก

ทำความเข้าใจแนวทางดั้งเดิม: Inheritance คืออะไร?

Inheritance เป็นหลักการสำคัญของการเขียนโปรแกรมเชิงวัตถุ (Object-Oriented Programming - OOP) มันอนุญาตให้คลาสใหม่ (subclass หรือ child) ได้รับคุณสมบัติและเมธอดของคลาสที่มีอยู่แล้ว (superclass หรือ parent) ซึ่งสร้างความสัมพันธ์แบบ 'is-a' ที่ผูกมัดกันอย่างแน่นหนา ตัวอย่างเช่น GoldenRetriever เป็น Dog ซึ่ง เป็น Animal

Inheritance ในบริบทที่ไม่ใช่ React

เรามาดูตัวอย่างคลาส JavaScript ง่ายๆ เพื่อให้เข้าใจแนวคิดนี้มากขึ้น:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // Calls the parent constructor
    this.breed = breed;
  }

  speak() { // Overrides the parent method
    console.log(`${this.name} barks.`);
  }

  fetch() {
    console.log(`${this.name} is fetching the ball!`);
  }
}

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // Output: "Buddy barks."
myDog.fetch(); // Output: "Buddy is fetching the ball!"

ในโมเดลนี้ คลาส Dog จะได้รับ property name และเมธอด speak จาก Animal โดยอัตโนมัติ นอกจากนี้ยังสามารถเพิ่มเมธอดของตัวเอง (fetch) และเขียนทับ (override) เมธอดที่มีอยู่แล้วได้ ซึ่งสิ่งนี้สร้างลำดับชั้นที่ตายตัว

ทำไม Inheritance ถึงไม่เหมาะกับ React

แม้ว่าโมเดล 'is-a' นี้จะใช้ได้กับโครงสร้างข้อมูลบางประเภท แต่มันสร้างปัญหาสำคัญเมื่อนำมาใช้กับ UI components ใน React:

เนื่องจากปัญหาเหล่านี้ ทีม React จึงออกแบบไลบรารีโดยใช้กระบวนทัศน์ที่ยืดหยุ่นและทรงพลังกว่า นั่นคือ: composition

ยอมรับวิถีแห่ง React: พลังของ Composition

Composition เป็นหลักการออกแบบที่สนับสนุนความสัมพันธ์แบบ 'has-a' (มี) หรือ 'uses-a' (ใช้) แทนที่คอมโพเนนต์หนึ่งจะ เป็น อีกคอมโพเนนต์หนึ่ง แต่มันจะ มี คอมโพเนนต์อื่น ๆ หรือ ใช้ ฟังก์ชันการทำงานของคอมโพเนนต์เหล่านั้น คอมโพเนนต์จะถูกมองว่าเป็นส่วนประกอบ (building blocks) เหมือนตัวต่อ LEGO ที่สามารถนำมารวมกันในรูปแบบต่างๆ เพื่อสร้าง UI ที่ซับซ้อนโดยไม่ต้องถูกจำกัดอยู่ในลำดับชั้นที่ตายตัว

โมเดล Composition ของ React นั้นมีความหลากหลายอย่างไม่น่าเชื่อ และปรากฏในรูปแบบสำคัญหลายอย่าง เรามาสำรวจรูปแบบเหล่านั้นกัน ตั้งแต่แบบพื้นฐานที่สุดไปจนถึงแบบที่ทันสมัยและทรงพลังที่สุด

เทคนิคที่ 1: การครอบด้วย `props.children`

รูปแบบที่ตรงไปตรงมาที่สุดของ composition คือการครอบ (containment) ซึ่งคือการที่คอมโพเนนต์ทำหน้าที่เป็นภาชนะหรือ 'กล่อง' ทั่วไป และเนื้อหาของมันถูกส่งมาจากคอมโพเนนต์แม่ React มี prop พิเศษที่สร้างมาเพื่อการนี้โดยเฉพาะ นั่นคือ props.children

ลองจินตนาการว่าคุณต้องการคอมโพเนนต์ `Card` ที่สามารถครอบเนื้อหาใดๆ ก็ได้ด้วยเส้นขอบและเงาที่เหมือนกัน แทนที่จะสร้าง `TextCard`, `ImageCard` และ `ProfileCard` ผ่านการสืบทอด คุณสร้างคอมโพเนนต์ `Card` ทั่วไปเพียงอันเดียว

// Card.js - A generic container component
function Card(props) {
  return (
    <div className="card">
      {props.children}
    </div>
  );
}

// App.js - Using the Card component
function App() {
  return (
    <div>
      <Card>
        <h1>Welcome!</h1>
        <p>This content is inside a Card component.</p>
      </Card>

      <Card>
        <img src="/path/to/image.jpg" alt="An example image" />
        <p>This is an image card.</p>
      </Card>
    </div>
  );
}

ในที่นี้ คอมโพเนนต์ `Card` ไม่รู้หรือไม่สนใจว่าข้างในมีอะไร มันแค่ทำหน้าที่ให้สไตล์ของกรอบเท่านั้น เนื้อหาที่อยู่ระหว่างแท็กเปิดและปิด <Card> จะถูกส่งเป็น props.children โดยอัตโนมัติ นี่เป็นตัวอย่างที่สวยงามของการแยกส่วน (decoupling) และการนำกลับมาใช้ใหม่

เทคนิคที่ 2: การสร้างความเฉพาะเจาะจงด้วย Props

บางครั้ง คอมโพเนนต์ต้องการ 'ช่อง' หลายช่องเพื่อให้คอมโพเนนต์อื่นมาเติม แม้ว่าคุณจะใช้ `props.children` ได้ แต่วิธีที่ชัดเจนและมีโครงสร้างมากกว่าคือการส่งคอมโพเนนท์เป็น props ปกติ รูปแบบนี้มักเรียกว่าการสร้างความเฉพาะเจาะจง (specialization)

พิจารณาคอมโพเนนต์ `Modal` โดยทั่วไปแล้ว modal จะมีส่วนหัวข้อ (title), ส่วนเนื้อหา (content) และส่วนการกระทำ (actions) (เช่น ปุ่ม "Confirm" หรือ "Cancel") เราสามารถออกแบบ `Modal` ของเราให้รับส่วนต่างๆ เหล่านี้เป็น props ได้

// Modal.js - A more specialized container
function Modal(props) {
  return (
    <div className="modal-backdrop">
      <div className="modal-content">
        <div className="modal-header">{props.title}</div>
        <div className="modal-body">{props.body}</div>
        <div className="modal-footer">{props.actions}</div>
      </div>
    </div>
  );
}

// App.js - Using the Modal with specific components
function App() {
  const confirmationTitle = <h2>Confirm Action</h2>;
  const confirmationBody = <p>Are you sure you want to proceed with this action?</p>;
  const confirmationActions = (
    <div>
      <button>Confirm</button>
      <button>Cancel</button>
    </div>
  );

  return (
    <Modal
      title={confirmationTitle}
      body={confirmationBody}
      actions={confirmationActions}
    />
  );
}

ในตัวอย่างนี้ `Modal` เป็นคอมโพเนนต์ layout ที่สามารถนำกลับมาใช้ใหม่ได้อย่างสูง เราสร้างความเฉพาะเจาะจงให้มันโดยการส่ง JSX elements ที่ต้องการสำหรับ `title`, `body` และ `actions` ซึ่งยืดหยุ่นกว่าการสร้าง subclass อย่าง `ConfirmationModal` และ `WarningModal` มาก เราเพียงแค่ประกอบ `Modal` ด้วยเนื้อหาที่แตกต่างกันไปตามความต้องการ

เทคนิคที่ 3: Higher-Order Components (HOCs)

สำหรับการแชร์ logic ที่ไม่ใช่ UI เช่น การดึงข้อมูล, การยืนยันตัวตน หรือการบันทึก log ในอดีตนักพัฒนา React มักจะใช้รูปแบบที่เรียกว่า Higher-Order Components (HOCs) แม้ว่าใน React สมัยใหม่จะถูกแทนที่ด้วย Hooks ไปเป็นส่วนใหญ่แล้ว แต่การทำความเข้าใจ HOCs ก็ยังเป็นสิ่งสำคัญ เนื่องจากเป็นขั้นตอนวิวัฒนาการที่สำคัญในเรื่องราวของ Composition ใน React และยังคงมีอยู่ในโค้ดเบสจำนวนมาก

HOC คือฟังก์ชันที่รับคอมโพเนนต์เป็นอาร์กิวเมนต์และส่งคืนคอมโพเนนต์ใหม่ที่ถูกปรับปรุงแล้ว

ลองสร้าง HOC ที่ชื่อว่า `withLogger` ซึ่งจะ log props ของคอมโพเนนต์ทุกครั้งที่มีการอัปเดต ซึ่งมีประโยชน์สำหรับการดีบัก

// withLogger.js - The HOC
import React, { useEffect } from 'react';

function withLogger(WrappedComponent) {
  // It returns a new component...
  return function EnhancedComponent(props) {
    useEffect(() => {
      console.log('Component updated with new props:', props);
    }, [props]);

    // ... that renders the original component with the original props.
    return <WrappedComponent {...props} />;
  };
}

// MyComponent.js - A component to be enhanced
function MyComponent({ name, age }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>You are {age} years old.</p>
    </div>
  );
}

// Exporting the enhanced component
export default withLogger(MyComponent);

ฟังก์ชัน `withLogger` จะครอบ `MyComponent` เพื่อเพิ่มความสามารถในการ logging โดยไม่ต้องแก้ไขโค้ดภายในของ `MyComponent` เลย เราสามารถนำ HOC นี้ไปใช้กับคอมโพเนนต์อื่นใดก็ได้เพื่อเพิ่มฟีเจอร์ logging แบบเดียวกัน

ความท้าทายของ HOCs:

เทคนิคที่ 4: Render Props

รูปแบบ Render Prop เกิดขึ้นเพื่อแก้ปัญหาบางอย่างของ HOCs โดยเสนอวิธีการแชร์ logic ที่ชัดเจนกว่า

คอมโพเนนต์ที่มี render prop จะรับฟังก์ชันเป็น prop (โดยปกติจะชื่อ `render`) และเรียกใช้ฟังก์ชันนั้นเพื่อกำหนดว่าจะ render อะไร โดยส่ง state หรือ logic ใดๆ เป็นอาร์กิวเมนต์ไปยังฟังก์ชันนั้น

ลองสร้างคอมโพเนนต์ `MouseTracker` ที่ติดตามพิกัด X และ Y ของเมาส์ และทำให้พิกัดเหล่านั้นพร้อมใช้งานสำหรับคอมโพเนนต์ใดๆ ที่ต้องการใช้

// MouseTracker.js - Component with a render prop
import React, { useState, useEffect } from 'react';

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const handleMouseMove = (event) => {
    setPosition({ x: event.clientX, y: event.clientY });
  };

  useEffect(() => {
    window.addEventListener('mousemove', handleMouseMove);
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);

  // Call the render function with the state
  return render(position);
}

// App.js - Using the MouseTracker
function App() {
  return (
    <div>
      <h1>Move your mouse around!</h1>
      <MouseTracker
        render={mousePosition => (
          <p>The current mouse position is ({mousePosition.x}, {mousePosition.y})</p>
        )}
      />
    </div>
  );
}

ในที่นี้ `MouseTracker` จะห่อหุ้ม logic ทั้งหมดสำหรับการติดตามการเคลื่อนไหวของเมาส์ แต่มันไม่ได้ render อะไรด้วยตัวเอง แต่จะมอบหมาย logic การ render ให้กับ prop `render` ของมันแทน ซึ่งวิธีนี้ชัดเจนกว่า HOCs เพราะคุณสามารถเห็นได้ทันทีว่าข้อมูล `mousePosition` มาจากไหนภายใน JSX

prop `children` ก็สามารถใช้เป็นฟังก์ชันได้เช่นกัน ซึ่งเป็นรูปแบบที่นิยมและสวยงามของแพทเทิร์นนี้:

// Using children as a function
<MouseTracker>
  {mousePosition => (
    <p>The current mouse position is ({mousePosition.x}, {mousePosition.y})</p>
  )}
</MouseTracker>

เทคนิคที่ 5: Hooks (แนวทางสมัยใหม่ที่แนะนำ)

Hooks ซึ่งเปิดตัวใน React 16.8 ได้ปฏิวัติวิธีการเขียนคอมโพเนนต์ React ของเรา มันช่วยให้คุณสามารถใช้ state และฟีเจอร์อื่นๆ ของ React ใน functional components ได้ และที่สำคัญที่สุดคือ custom Hooks เป็นวิธีแก้ปัญหาที่สวยงามและตรงไปตรงมาที่สุดสำหรับการแชร์ stateful logic ระหว่างคอมโพเนนต์

Hooks แก้ปัญหาของ HOCs และ Render Props ด้วยวิธีที่สะอาดกว่ามาก เรามาแก้ไขตัวอย่าง `MouseTracker` ของเราให้เป็น custom hook ที่เรียกว่า `useMousePosition`

// hooks/useMousePosition.js - A custom Hook
import { useState, useEffect } from 'react';

export function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (event) => {
      setPosition({ x: event.clientX, y: event.clientY });
    };

    window.addEventListener('mousemove', handleMouseMove);
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    };
  }, []); // Empty dependency array means this effect runs only once

  return position;
}

// DisplayMousePosition.js - A component using the Hook
import { useMousePosition } from './hooks/useMousePosition';

function DisplayMousePosition() {
  const position = useMousePosition(); // Just call the hook!

  return (
    <p>
      The mouse position is ({position.x}, {position.y})
    </p>
  );
}

// Another component, maybe an interactive element
import { useMousePosition } from './hooks/useMousePosition';

function InteractiveBox() {
  const { x, y } = useMousePosition();

  const style = {
    position: 'absolute',
    top: y - 25, // Center the box on the cursor
    left: x - 25,
    width: '50px',
    height: '50px',
    backgroundColor: 'lightblue',
  };

  return <div style={style} />;
}

นี่คือการปรับปรุงครั้งใหญ่ ไม่มี 'wrapper hell', ไม่มีการชนกันของชื่อ prop และไม่มีฟังก์ชัน render prop ที่ซับซ้อน logic ถูกแยกออกจากกันอย่างสมบูรณ์เป็นฟังก์ชันที่นำกลับมาใช้ใหม่ได้ (`useMousePosition`) และคอมโพเนนต์ใดๆ ก็สามารถ 'hook' เข้ากับ stateful logic นั้นได้ด้วยโค้ดเพียงบรรทัดเดียวที่ชัดเจน Custom Hooks คือที่สุดของการแสดงออกถึง Composition ใน React สมัยใหม่ ซึ่งช่วยให้คุณสามารถสร้างคลังของ logic blocks ที่นำกลับมาใช้ใหม่ได้เอง

เปรียบเทียบสั้นๆ: Composition vs. Inheritance ใน React

เพื่อสรุปความแตกต่างที่สำคัญในบริบทของ React นี่คือตารางเปรียบเทียบโดยตรง:

แง่มุม Inheritance (รูปแบบที่ไม่ควรใช้ใน React) Composition (รูปแบบที่แนะนำใน React)
ความสัมพันธ์ ความสัมพันธ์แบบ 'is-a' (เป็น) คอมโพเนนต์ที่เฉพาะเจาะจง เป็น เวอร์ชันหนึ่งของคอมโพเนนต์พื้นฐาน ความสัมพันธ์แบบ 'has-a' (มี) หรือ 'uses-a' (ใช้) คอมโพเนนต์ที่ซับซ้อน มี คอมโพเนนต์ขนาดเล็กอยู่ภายใน หรือ ใช้ logic ที่แชร์ร่วมกัน
การผูกมัด (Coupling) สูง Child components ผูกติดกับการทำงานของ parent อย่างแน่นหนา ต่ำ คอมโพเนนต์เป็นอิสระต่อกันและสามารถนำกลับมาใช้ใหม่ในบริบทที่แตกต่างกันได้โดยไม่ต้องแก้ไข
ความยืดหยุ่น ต่ำ ลำดับชั้นแบบคลาสที่ตายตัวทำให้ยากต่อการแชร์ logic ข้ามไปยัง component tree อื่นๆ สูง Logic และ UI สามารถนำมารวมและใช้ซ้ำได้ในหลากหลายวิธี เหมือนตัวต่อ
การนำโค้ดกลับมาใช้ใหม่ จำกัดอยู่แค่ในลำดับชั้นที่กำหนดไว้ล่วงหน้า คุณได้ "กอริลลา" ทั้งตัว ทั้งๆ ที่ต้องการแค่ "กล้วย" ยอดเยี่ยม คอมโพเนนต์และ hooks ขนาดเล็กที่ทำงานเฉพาะทางสามารถนำไปใช้ได้ทั่วทั้งแอปพลิเคชัน
รูปแบบการเขียนของ React ไม่แนะนำโดยทีม React อย่างเป็นทางการ เป็นแนวทางที่แนะนำและเป็นธรรมชาติสำหรับการสร้างแอปพลิเคชัน React

สรุป: คิดแบบ Composition

การถกเถียงระหว่าง composition และ inheritance เป็นหัวข้อพื้นฐานในการออกแบบซอฟต์แวร์ แม้ว่า inheritance จะมีที่ยืนในโลก OOP แบบคลาสสิก แต่ธรรมชาติของการพัฒนา UI ที่เป็นแบบไดนามิกและใช้คอมโพเนนต์เป็นหลัก ทำให้มันไม่เหมาะกับ React ไลบรารีนี้ถูกออกแบบมาเพื่อรองรับ composition โดยพื้นฐาน

การเลือกใช้ composition จะทำให้คุณได้:

ในฐานะนักพัฒนา React ระดับโลก การเรียนรู้ Composition ไม่ใช่แค่การทำตามแนวทางปฏิบัติที่ดีที่สุด แต่มันคือการทำความเข้าใจปรัชญาหลักที่ทำให้ React เป็นเครื่องมือที่ทรงพลังและมีประสิทธิภาพ เริ่มต้นด้วยการสร้างคอมโพเนนต์ขนาดเล็กที่ทำงานเฉพาะทาง ใช้ `props.children` สำหรับ container ทั่วไปและใช้ props สำหรับการสร้างความเฉพาะเจาะจง สำหรับการแชร์ logic ให้เลือกใช้ custom Hooks เป็นอันดับแรก เมื่อคุณคิดแบบ composition คุณก็จะอยู่บนเส้นทางที่ถูกต้องสู่การสร้างแอปพลิเคชัน React ที่สวยงาม แข็งแกร่ง และขยายขนาดได้ ซึ่งจะคงอยู่ต่อไปอีกนาน