Babel Coder

รู้จัก Render Props อีกทางเลือกนอกเหนือจาก HOC

beginner

การเขียนโปรแกรมหลีกเลี่ยงไม่ได้ที่จะมีโค้ดซ้ำ ความท้าทายไม่ได้อยู่ที่เราพบเจอการซ้ำกันที่ตรงไหน แต่อยู่ที่เราจะจัดการกับสิ่งนั้นเพื่อแยกส่วนที่เหมือนกันนั้นออกมาเพื่อทำการ reuse ใหม่อีกหลาย ๆ ครั้งได้อย่างไร

โลกของ React เราแก้ปัญหาความซ้ำนี้ได้ด้วยหลักการของ Higher-Order Components (HOC) แต่การใช้ HOC นั้นจะไม่มีข้อเสียเชียวหรือ?

สารบัญ

ปัญหาของ Higher-Order Components

เมื่อโปรเจคเริ่มโตขึ้น มีฟีเจอร์มากขึ้น เรามักพบบางสิ่งที่ซ้ำเยอะขึ้นตามไปด้วย

สมมติเรามีหน้าเพจ articles และ users ที่ล้วนต่างต้องการข้อมูลจาก API มาแสดงผล จึงเลี่ยงไม่ได้ที่จะต้องส่งการร้องขอไปที่ API เช่นข้างล่าง

class Aticles extends Component {
  state = {
    articles: []
  }
  
  componentDidMount() {
    fetch('/api/v1/articles')
      .then(res => res.json())
      .then(articles => this.setState({ articles }))
  }
  
  render() {
    return (
      <ul>
        {
          this.state.articles.map(
            ({ id, title }) => <li key={id}>{title}</li>
          )
        }
      </ul>
    )
  }
}

class Users extends Component {
  state = {
    users: []
  }
  
  componentDidMount() {
    fetch('/api/v1/users')
      .then(res => res.json())
      .then(articles => this.setState({ users }))
  }
  
  render() {
    return (
      <ul>
        {
          this.state.users.map(
            ({ id, name }) => <li key={id}>{name}</li>
          )
        }
      </ul>
    )
  }
}

วิธีดังกล่าวสมเหตุผลในตัวมัน ทว่ามีส่วนของโปรแกรมที่ซ้ำกันอยู่ในทั้งสองคอมโพแนนท์นั่นคือส่วนของการดึงข้อมูล แหมซ้ำแบบนี้ช่างผลาญจำนวนบรรทัดได้พอ ๆ กับลุงแถวบ้านที่ผลาญงบประมาณโดยไม่ปรึกษาค่าแรงขั้นต่ำตูเลย ปัดโถ่!

เมื่อเราพิจารณาดูดี ๆ จะพบว่าคอมโพแนนท์ทั้งสองของเราล้วนมีหน้าที่หลักคือการแสดงผล เพียงแต่อาศัยการดึงข้อมูลเป็นส่วนเติมเต็มให้การทำงานสมบูรณ์ยิ่งขึ้นเท่านั้น หรือกล่าวอีกนัยคือเราขยายความสามารถของคอมโพแนนท์ปกติให้มีความสามารถมากขึ้นด้วยการห่อคุณสมบัติในการเข้าถึงข้อมูลจาก API นั่นเอง

Higher-Order Components (HOC) เป็นหนึ่งในเทคนิคที่ช่วยแก้ปัญหานี้ ด้วยการรับคอมโพแนนท์รากหญ้าเข้ามาแล้วจัดการชุบตัวด้วยการยัดเพชรยัดทองใส่ แล้วคืนคอมโพแนนท์ลูกคุณหนูที่ฟูลออฟชั่นออกมา เพียงเท่านี้คอมโพแนนท์รากหญ้าก็มีสกิลไฮโซเลเวล 99 เท้าเรืองแสงละ

ย้อนกลับมาที่เดอะคอมโพแนนท์ของเรา เมื่อเราต้องการชุบตัวพวกมัน เราก็แค่จับมันไปหุ้มด้วยความสามารถในการเข้าถึง API จากฟังก์ชัน withFetch นั่นเอง

// articles.js
class Aticles extends Component {  
  render() {
    return (
      <ul>
        {
          this.props.data.map(
            ({ id, title }) => <li key={id}>{title}</li>
          )
        }
      </ul>
    )
  }
}

export default withFetch('/api/v1/articles')(Articles)

// users.js
class Users extends Component {
  render() {
    return (
      <ul>
        {
          this.props.data.map(
            ({ id, name }) => <li key={id}>{name}</li>
          )
        }
      </ul>
    )
  }
}
export default withFetch('/api/v1/users')(Users)

ด้วยพลานุภาพระดับเง็กเซียนของ withFetch ทำให้เราละโค้ดส่วนการประกาศ state และการร้องขอข้อมูลจาก API ออกไปได้ เหลือเพียงการเรียกใช้งานฟังก์ชันดังกล่าวพร้อมระบุ URL ของทรัพยากรที่จะเข้าถึง ค่าข้อมูลจากเซิฟเวอร์ก็จะตอบกลับมาเป็นค่าของ data ภายใต้ props ของคอมโพแนนท์นั่นเอง

แล้วหน้าตาของ withFetch หละเป็นแบบไหน?

const withFetch = url => WrappedComponent => class extends Component {
  state = {
    data: null
  }
  
  componentDidMount() {
    fetch(url)
      .then(res => res.json())
      .then(data => this.setState({ data }))
  }
  
  render() {
    return <WrappedComponent {...this.props} {...this.state} />
  }
}

เราพบว่าการใช้งาน HOC ช่วยลดการซ้ำของโค้ดด้วยการย้ายส่วนที่ใช้บ่อยแยกออกไปเป็นฟังก์ชันที่สามารถนำไปใช้ซ้ำได้บ่อยครั้ง ทุกอย่างดูดี ชีวิตก็ดูดี แต่ตังค์ในกระเป๋านี่ซิ…

ปัญหาของการใช้งาน HOC

เป็นธรรมดาที่เรามักเก็บข้อมูลบางอย่างของผู้ใช้งานระบบไว้ เช่น ผู้ใช้งานชื่ออะไร เป็นผู้ดูแลระบบหรือไม่ เป็นต้น ข้อมูลเหล่านี้เป็นของที่เรามักเรียกซ้ำบ่อยครั้งในคอมโพแนนท์อื่น เราอาจแยกส่วนนี้ออกมาด้วยเทคนิคของ HOC ในชื่อของ withProfile

คอมโพแนนท์ articles ของเราตอนนี้มีเงื่อนไขพิเศษ หากผู้ใช้งานเป็นผู้ดูแลระบบเราจะแสดงปุ่มสำหรับสร้างบทความใหม่และลบบทความได้ เราจึงประกอบร่างทั้ง withFetch และ withProfile เข้าด้วยกันดังนี้

class Aticles extends Component {  
  render() {
    const { profile: { isAdmin }, data } = this.props
    
    return (
      <Fragment>
        {isAdmin && <button>Create new article</button>}
        <ul>
        {
          data.map(
            ({ id, title }) => (
              <li key={id}>
                {title}
                {isAdmin && <button>Delete</button>}
              </li>
            )
          )
        }
        </ul>
      </Fragment>
    )
  }
}

export default compose(
  withFetch('/api/v1/articles'),
  withProfile
)(Articles)

ข้อมูลรายละเอียดผู้ใช้งานจะส่งจาก withProfile ใน props ชื่อ profile เราจึงสามารถเข้าถึงเพื่อตรวจสอบความเป็นผู้ดูแลระบบได้ เนื่องจากฟังก์ชัน withProfile เราคือผู้สร้างจึงไม่มีปัญหาอะไร ทว่าเราจะโชคดีแบบนี้ได้ทุกครั้งหรือ หากสิ่งที่เรานำมาใช้เป็นของผู้อื่น จะเกิดอะไรขึ้นหาก withProfile และ withFetch ต่างส่งค่าข้อมูลให้กับคอมโพแนนท์ด้วย props ชื่อ data ทั้งคู่?

ปัญหาอีกอย่างนอกเหนือจากการชนกันของชื่อคือการคาดเดาได้ยากว่า props ที่คอมโพแนนท์รับเข้ามานั้นมาจาก HOC ตัวไหนกันแน่ จากตัวอย่างของเรามีแค่ 2 HOCs ยังปวดตับ หากมีเป็นสิบเราคงไม่เหลือตับให้ปวดแล้วหละ ไล่กันไม่ถูกเลยว่า HOC ตัวไหนปล่อยพลังอะไรออกมากันบ้าง

รู้จักเทคนิคของ Render Props

เมื่อเรากล่าวว่าการแอบซ่อนข้อมูลแบบลับ ๆ ที่เสมือนหนึ่งซ่อนเมียน้อยของ HOC ทำให้เราคาดเดาได้ยากว่าค่า props มาจากไหนแน่ เราก็แค่ทำทุกอย่างให้มันชัดเจนขึ้น

Render Props เป็นเทคนิคที่เข้ามาแก้ปัญหาของ HOC ด้วยการสร้างคอมโพแนนท์ของกลางเพื่อการนำไปใช้ซ้ำ แต่ต่างกับ HOC ตรงที่ Render Props จะนิยาม props ค่าหนึ่งขึ้นมา (เช่นชื่อ render) จากนั้นคอมโพแนนท์ต้นทางอยากแสดงผลอะไร (โดยอิงกับค่าที่มาจากคอมโพแนนท์ Render Props) ก็เขียนใส่ในค่า props นี้ โดย props ที่ชื่อว่า render จะทำการส่งค่าให้กับคอมโพแนนท์ต้นทางอีกทีในรูปแบบของพารามิเตอร์ของฟังก์ชัน

class Users extends Component {
  render() {
    return (
      <ul>
        <Fetch url='/api/v1/artilces' render={
          data => (
            data.map(
              ({ id, name }) => <li key={id}>{name}</li>
            )
          )
        } />
      </ul>
    )
  }
}

แทนที่เราจะคาดเดาไม่ได้ว่า HOC ตัวไหนเป็นคนส่งค่า props อะไรบ้าง เรากลับเลือกว่าจะใช้ค่า props จากคอมโพแนนท์ Render Props ตัวไหนเพื่อแสดงผลสิ่งนั้น โปรดสังเกตว่า props ที่ชื่อ render นั้นเป็นส่วนสำคัญในการส่งผ่านค่าจากคอมโพแนนท์ของกลางมาสู่การแสดงผลของคอมโพแนนท์ปัจจุบัน

เราทราบกันดีว่าการเรียกคอมโพแนนท์ด้วยการซ้อนอยู่ใต้คอมโพแนนท์อื่น คอมโพแนนท์นั้นจะปรากฎในฐานะของ props ที่ชื่อ children ของคอมโพแนนท์แม่ อาศัยความจริงนี้เราสามารถสร้างการทำงานแบบ Render Props ผ่าน children ได้เช่นกัน ดังนี้

class Users extends Component {
  render() {
    return (
      <ul>
        <Fetch url='/api/v1/artilces'>
          data => (
            data && data.map(
              ({ id, name }) => <li key={id}>{name}</li>
            )
          )
        </Fetch>
      </ul>
    )
  }
}

ทีนี้เรามาลองดูวิธีการสร้าง Fetch ในรูปแบบของ Render Props กันครับ

class Fetch extends Component {
  state = {
    data: null
  }
  
  componentDidMount() {
    fetch(this.props.url)
      .then(res => res.json())
      .then(data => this.setState({ data }))
  }
  
  render() {
    // เนื่องจากคอมโพแนนท์ต้นทางรับ data 
    // เราจึงส่ง data ไปให้
    return this.props.render(this.state.data)
  }
}

กลับมาที่ปัญหาเดิมของเราครับ withFetch และ withProfile ที่เป็น HOC นั้นหากส่งค่า props มาในชื่อเดียวกันคือ data ก็จะเกิดการชนกันของชื่อได้ แต่เมื่อเราใช้เทคนิคของ Render Props ปัญหานี้ก็จะหมดไป เราสามารถนิยามการรับชื่อของเราให้เป็น profile และ data ใต้คอมโพแนนท์ต้นทางได้ตามลำดับ ดังนี้

class Aticles extends Component {  
  render() {
    return (
      <Profile>
        profile => (
          <Fragment>
            {profile.isAdmin && <button>Create new article</button>}
            <ul>
              <Fetch url='/api/v1/articles'>
                data => data.map(
                  ({ id, title }) => (
                    <li key={id}>
                      {title}
                      {profile.isAdmin && <button>Delete</button>}
                    </li>
                  )
                )
               </Fetch>
            </ul>
          </Fragment>
        )
      </Profile>
    )
  }
}

แก้ปัญหา Nested Render Props ด้วย React Composer

การใช้งาน Render Props ช่วยแก้ปัญหาของ HOC ก็จริง แต่เมื่อเรามีคอมโพแนนท์พวกนี้หลายตัวร้อยเรียงต่อกันเป็นพรืด ก็เลี่ยงไม่ได้ทีจะกดแท็บจนปุ่มหลุด

<RenderPropComponent {...config}>
  {resultOne => (
    <RenderPropComponent {...configTwo}>
      {resultTwo => (
        <RenderPropComponent {...configThree}>
          {resultThree => (
            <MyComponent results={{ resultOne, resultTwo, resultThree }} />
          )}
        </RenderPropComponent>
      )}
    </RenderPropComponent>
  )}
</RenderPropComponent>

ไม่อยากแท็บเยอะ? เรามีทางเลือกด้วยไลบรารี่ react-composer ที่จะช่วยให้คอมโพแนนท์ Render Props ซ้อนกันในระดับชั้นเดียว

import Composer from 'react-composer'

<Composer
  components={[
    <RenderPropComponent {...configOne} />,
    <RenderPropComponent {...configTwo} />,
    <RenderPropComponent {...configThree} />
  ]}>
  {([resultOne, resultTwo, resultThree]) => (
    <MyComponent results={{ resultOne, resultTwo, resultThree }} />
  )}
</Composer>

หลากหลายไลบรารี่ที่ใช้ Render Props

awesome-react-render-props ได้รวบรวมไลบรารี่ต่าง ๆ ตามรูปแบบของ Render Props ไว้มากมาย คุ้ยไปใช้ได้ตามอัธยาศัยครับ

HOC จะตายไหม?

Render Props เป็นเพียงอีกหนึ่งเทคนิคเพื่อแบ่งแยกโค้ดในการนำกลับมาใช้ซ้ำใหม่ได้เช่นเดียวกับ HOC กรณีของ HOC นั้นแม้จะดูมีข้อเสียแต่สำหรับการใช้งานที่มี HOC จำนวนน้อยกลับทำให้เขียนแล้วเข้าใจในตัวโค้ดได้ไม่ยาก การมีอยู่ของเทคนิค Render Props จึงไม่ใช่ผู้ฆ่าหากแต่เป็นอีกหนึ่งทางเลือกต่างหาก

ปุกาศ ปุกาศ ตอนนี้ทางเพจ Babel Coder มีสอนพัฒนาโมบายแอพพลิเคชันด้วย React Native ด้วยหละ เรียนวันที่ 23 - 24 มิย 2561 ครับ

รายละเอียดเพิ่มเติม จิ้มลิงก์นี้จ้า

React Native Course


แสดงความคิดเห็นของคุณ


No any discussions