รู้จัก Render Props อีกทางเลือกนอกเหนือจาก HOC
การเขียนโปรแกรมหลีกเลี่ยงไม่ได้ที่จะมีโค้ดซ้ำ ความท้าทายไม่ได้อยู่ที่เราพบเจอการซ้ำกันที่ตรงไหน แต่อยู่ที่เราจะจัดการกับสิ่งนั้นเพื่อแยกส่วนที่เหมือนกันนั้นออกมาเพื่อทำการ reuse ใหม่อีกหลาย ๆ ครั้งได้อย่างไร
โลกของ React เราแก้ปัญหาความซ้ำนี้ได้ด้วยหลักการของ Higher-Order Components (HOC) แต่การใช้ HOC นั้นจะไม่มีข้อเสียเชียวหรือ?
ปัญหาของ Higher-Order Components
เมื่อโปรเจคเริ่มโตขึ้น มีฟีเจอร์มากขึ้น เรามักพบบางสิ่งที่ซ้ำเยอะขึ้นตามไปด้วย
สมมติเรามีหน้าเพจ articles และ users ที่ล้วนต่างต้องการข้อมูลจาก API มาแสดงผล จึงเลี่ยงไม่ได้ที่จะต้องส่งการร้องขอไปที่ API เช่นข้างล่าง
1class Aticles extends Component {2 state = {3 articles: [],4 }56 componentDidMount() {7 fetch('/api/v1/articles')8 .then((res) => res.json())9 .then((articles) => this.setState({ articles }))10 }1112 render() {13 return (14 <ul>15 {this.state.articles.map(({ id, title }) => (16 <li key={id}>{title}</li>17 ))}18 </ul>19 )20 }21}2223class Users extends Component {24 state = {25 users: [],26 }2728 componentDidMount() {29 fetch('/api/v1/users')30 .then((res) => res.json())31 .then((articles) => this.setState({ users }))32 }3334 render() {35 return (36 <ul>37 {this.state.users.map(({ id, name }) => (38 <li key={id}>{name}</li>39 ))}40 </ul>41 )42 }43}
วิธีดังกล่าวสมเหตุผลในตัวมัน ทว่ามีส่วนของโปรแกรมที่ซ้ำกันอยู่ในทั้งสองคอมโพแนนท์นั่นคือส่วนของการดึงข้อมูล แหมซ้ำแบบนี้ช่างผลาญจำนวนบรรทัดได้พอ ๆ กับลุงแถวบ้านที่ผลาญงบประมาณโดยไม่ปรึกษาค่าแรงขั้นต่ำตูเลย ปัดโถ่!
เมื่อเราพิจารณาดูดี ๆ จะพบว่าคอมโพแนนท์ทั้งสองของเราล้วนมีหน้าที่หลักคือการแสดงผล เพียงแต่อาศัยการดึงข้อมูลเป็นส่วนเติมเต็มให้การทำงานสมบูรณ์ยิ่งขึ้นเท่านั้น หรือกล่าวอีกนัยคือเราขยายความสามารถของคอมโพแนนท์ปกติให้มีความสามารถมากขึ้นด้วยการห่อคุณสมบัติในการเข้าถึงข้อมูลจาก API นั่นเอง
Higher-Order Components (HOC) เป็นหนึ่งในเทคนิคที่ช่วยแก้ปัญหานี้ ด้วยการรับคอมโพแนนท์รากหญ้าเข้ามาแล้วจัดการชุบตัวด้วยการยัดเพชรยัดทองใส่ แล้วคืนคอมโพแนนท์ลูกคุณหนูที่ฟูลออฟชั่นออกมา เพียงเท่านี้คอมโพแนนท์รากหญ้าก็มีสกิลไฮโซเลเวล 99 เท้าเรืองแสงละ
ย้อนกลับมาที่เดอะคอมโพแนนท์ของเรา เมื่อเราต้องการชุบตัวพวกมัน เราก็แค่จับมันไปหุ้มด้วยความสามารถในการเข้าถึง API จากฟังก์ชัน withFetch นั่นเอง
1// articles.js2class Aticles extends Component {3 render() {4 return (5 <ul>6 {this.props.data.map(({ id, title }) => (7 <li key={id}>{title}</li>8 ))}9 </ul>10 )11 }12}1314export default withFetch('/api/v1/articles')(Articles)1516// users.js17class Users extends Component {18 render() {19 return (20 <ul>21 {this.props.data.map(({ id, name }) => (22 <li key={id}>{name}</li>23 ))}24 </ul>25 )26 }27}28export default withFetch('/api/v1/users')(Users)
ด้วยพลานุภาพระดับเง็กเซียนของ withFetch ทำให้เราละโค้ดส่วนการประกาศ state และการร้องขอข้อมูลจาก API ออกไปได้ เหลือเพียงการเรียกใช้งานฟังก์ชันดังกล่าวพร้อมระบุ URL ของทรัพยากรที่จะเข้าถึง ค่าข้อมูลจากเซิฟเวอร์ก็จะตอบกลับมาเป็นค่าของ data ภายใต้ props ของคอมโพแนนท์นั่นเอง
แล้วหน้าตาของ withFetch หละเป็นแบบไหน?
1const withFetch = (url) => (WrappedComponent) =>2 class extends Component {3 state = {4 data: null,5 }67 componentDidMount() {8 fetch(url)9 .then((res) => res.json())10 .then((data) => this.setState({ data }))11 }1213 render() {14 return <WrappedComponent {...this.props} {...this.state} />15 }16 }
เราพบว่าการใช้งาน HOC ช่วยลดการซ้ำของโค้ดด้วยการย้ายส่วนที่ใช้บ่อยแยกออกไปเป็นฟังก์ชันที่สามารถนำไปใช้ซ้ำได้บ่อยครั้ง ทุกอย่างดูดี ชีวิตก็ดูดี แต่ตังค์ในกระเป๋านี่ซิ...
ปัญหาของการใช้งาน HOC
เป็นธรรมดาที่เรามักเก็บข้อมูลบางอย่างของผู้ใช้งานระบบไว้ เช่น ผู้ใช้งานชื่ออะไร เป็นผู้ดูแลระบบหรือไม่ เป็นต้น ข้อมูลเหล่านี้เป็นของที่เรามักเรียกซ้ำบ่อยครั้งในคอมโพแนนท์อื่น เราอาจแยกส่วนนี้ออกมาด้วยเทคนิคของ HOC ในชื่อของ withProfile
คอมโพแนนท์ articles ของเราตอนนี้มีเงื่อนไขพิเศษ หากผู้ใช้งานเป็นผู้ดูแลระบบเราจะแสดงปุ่มสำหรับสร้างบทความใหม่และลบบทความได้ เราจึงประกอบร่างทั้ง withFetch และ withProfile เข้าด้วยกันดังนี้
1class Aticles extends Component {2 render() {3 const {4 profile: { isAdmin },5 data,6 } = this.props78 return (9 <Fragment>10 {isAdmin && <button>Create new article</button>}11 <ul>12 {data.map(({ id, title }) => (13 <li key={id}>14 {title}15 {isAdmin && <button>Delete</button>}16 </li>17 ))}18 </ul>19 </Fragment>20 )21 }22}2324export 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 จะทำการส่งค่าให้กับคอมโพแนนท์ต้นทางอีกทีในรูปแบบของพารามิเตอร์ของฟังก์ชัน
1class Users extends Component {2 render() {3 return (4 <ul>5 <Fetch6 url="/api/v1/artilces"7 render={(data) =>8 data.map(({ id, name }) => <li key={id}>{name}</li>)9 }10 />11 </ul>12 )13 }14}
แทนที่เราจะคาดเดาไม่ได้ว่า HOC ตัวไหนเป็นคนส่งค่า props อะไรบ้าง เรากลับเลือกว่าจะใช้ค่า props จากคอมโพแนนท์ Render Props ตัวไหนเพื่อแสดงผลสิ่งนั้น โปรดสังเกตว่า props ที่ชื่อ render นั้นเป็นส่วนสำคัญในการส่งผ่านค่าจากคอมโพแนนท์ของกลางมาสู่การแสดงผลของคอมโพแนนท์ปัจจุบัน
เราทราบกันดีว่าการเรียกคอมโพแนนท์ด้วยการซ้อนอยู่ใต้คอมโพแนนท์อื่น คอมโพแนนท์นั้นจะปรากฎในฐานะของ props ที่ชื่อ children ของคอมโพแนนท์แม่ อาศัยความจริงนี้เราสามารถสร้างการทำงานแบบ Render Props ผ่าน children ได้เช่นกัน ดังนี้
1class Users extends Component {2 render() {3 return (4 <ul>5 <Fetch url="/api/v1/artilces">6 data => ( data && data.map( ({(id, name)}) => <li key={id}>{name}</li>7 ) )8 </Fetch>9 </ul>10 )11 }12}
ทีนี้เรามาลองดูวิธีการสร้าง Fetch ในรูปแบบของ Render Props กันครับ
1class Fetch extends Component {2 state = {3 data: null,4 }56 componentDidMount() {7 fetch(this.props.url)8 .then((res) => res.json())9 .then((data) => this.setState({ data }))10 }1112 render() {13 // เนื่องจากคอมโพแนนท์ต้นทางรับ data14 // เราจึงส่ง data ไปให้15 return this.props.render(this.state.data)16 }17}
กลับมาที่ปัญหาเดิมของเราครับ withFetch และ withProfile ที่เป็น HOC นั้นหากส่งค่า props มาในชื่อเดียวกันคือ data ก็จะเกิดการชนกันของชื่อได้ แต่เมื่อเราใช้เทคนิคของ Render Props ปัญหานี้ก็จะหมดไป เราสามารถนิยามการรับชื่อของเราให้เป็น profile และ data ใต้คอมโพแนนท์ต้นทางได้ตามลำดับ ดังนี้
1class Aticles extends Component {2 render() {3 return (4 <Profile>5 profile => (6 <Fragment>7 {profile.isAdmin && <button>Create new article</button>}8 <ul>9 <Fetch url="/api/v1/articles">10 data => data.map( ({(id, title)}) => (11 <li key={id}>12 {title}13 {profile.isAdmin && <button>Delete</button>}14 </li>15 ) )16 </Fetch>17 </ul>18 </Fragment>19 )20 </Profile>21 )22 }23}
แก้ปัญหา Nested Render Props ด้วย React Composer
การใช้งาน Render Props ช่วยแก้ปัญหาของ HOC ก็จริง แต่เมื่อเรามีคอมโพแนนท์พวกนี้หลายตัวร้อยเรียงต่อกันเป็นพรืด ก็เลี่ยงไม่ได้ทีจะกดแท็บจนปุ่มหลุด
1<RenderPropComponent {...config}>2 {(resultOne) => (3 <RenderPropComponent {...configTwo}>4 {(resultTwo) => (5 <RenderPropComponent {...configThree}>6 {(resultThree) => (7 <MyComponent results={{ resultOne, resultTwo, resultThree }} />8 )}9 </RenderPropComponent>10 )}11 </RenderPropComponent>12 )}13</RenderPropComponent>
ไม่อยากแท็บเยอะ? เรามีทางเลือกด้วยไลบรารี่ react-composer ที่จะช่วยให้คอมโพแนนท์ Render Props ซ้อนกันในระดับชั้นเดียว
1import Composer from 'react-composer'2;<Composer3 components={[4 <RenderPropComponent {...configOne} />,5 <RenderPropComponent {...configTwo} />,6 <RenderPropComponent {...configThree} />,7 ]}8>9 {([resultOne, resultTwo, resultThree]) => (10 <MyComponent results={{ resultOne, resultTwo, resultThree }} />11 )}12</Composer>
หลากหลายไลบรารี่ที่ใช้ Render Props
awesome-react-render-props ได้รวบรวมไลบรารี่ต่าง ๆ ตามรูปแบบของ Render Props ไว้มากมาย คุ้ยไปใช้ได้ตามอัธยาศัยครับ
HOC จะตายไหม?
Render Props เป็นเพียงอีกหนึ่งเทคนิคเพื่อแบ่งแยกโค้ดในการนำกลับมาใช้ซ้ำใหม่ได้เช่นเดียวกับ HOC กรณีของ HOC นั้นแม้จะดูมีข้อเสียแต่สำหรับการใช้งานที่มี HOC จำนวนน้อยกลับทำให้เขียนแล้วเข้าใจในตัวโค้ดได้ไม่ยาก การมีอยู่ของเทคนิค Render Props จึงไม่ใช่ผู้ฆ่าหากแต่เป็นอีกหนึ่งทางเลือกต่างหาก
ปุกาศ ปุกาศ ตอนนี้ทางเพจ Babel Coder มีสอนพัฒนาโมบายแอพพลิเคชันด้วย React Native ด้วยหละ เรียนวันที่ 23 - 24 มิย 2561 ครับ
รายละเอียดเพิ่มเติม จิ้มลิงก์นี้จ้า
สารบัญ
- ปัญหาของ Higher-Order Components
- ปัญหาของการใช้งาน HOC
- รู้จักเทคนิคของ Render Props
- แก้ปัญหา Nested Render Props ด้วย React Composer
- หลากหลายไลบรารี่ที่ใช้ Render Props
- HOC จะตายไหม?