[Day #2] สอนการใช้งาน React.js และการเรียกใช้งาน RESTful API ด้วย React

Nuttavut Thongjor

บทความก่อนหน้านี้ผมได้แนะนำการใช้งาน Webpack2 กับ React แนะนำการตั้งค่า Loader ต่างๆ รวมไปถึงการใช้งาน Webpack อย่างง่าย สำหรับบทความนี้เราจะเริ่มใช้งาน React สร้าง Web Application กันโดยเริ่มจากการสร้างโค๊ดที่แย่ที่สุดแต่ทำงานได้ แล้วค่อยปรับปรุงให้ดีขึ้นด้วยการผสานรูปแบบการเขียนโปรแกรมที่ดีเข้าไป ก่อนอื่นเราคงต้องท้าวความถึงปรัชญาของ React กันก่อน

บทความนี้ค่อนข้างยาวครับ เพื่อให้ได้ประสิทธิภาพสูงสุดผมจึงอยากทำข้อตกลงกับเพื่อนๆก่อน ดังนี้ฮะ

  • ในกรณีที่เพื่อนๆไม่มีประสบการณ์ในการใช้งาน Webpack มาก่อน แนะนำให้อ่านแนะนำ Webpack2 และการใช้งานร่วมกับ React ก่อนเริ่มบทความนี้
  • เพื่อนๆควรมีความเข้าใจในพื้นฐาน JavaScript เป็นอย่างดี รู้จักว่า AJAX คืออะไร Rest API คืออะไร และเข้าใจเสมอว่า React ไม่ใช่เทพเจ้าแต่เป็นเพียงไลบรารี่ตัวหนึ่ง
  • สำหรับเพื่อนๆที่ยังไม่ชำนาญใน ES2015 ให้เปิดพื้นฐาน ES2015 สำหรับการเขียน JavaScript สมัยใหม่ ควบคู่ไปกับการอ่านบทความนี้ syntax ตรงไหนเห็นว่าแปลกให้ย้อนกลับไปคุ้ยๆดูในบทความ ES2015 เพราะผมจะพยายามไม่เขียนเรื่องที่ซ้ำซ้อนกันในบทความนี้
  • ในทุกๆหัวข้อที่สำคัญ ผมจะทิ้งคำถามชวนคิดไว้ให้ เพื่อนๆควรพยายามคิดและตอบด้วยตนเอง อย่าลอกเพื่อนข้างๆนะครับ เราไม่มีคะแนนให้ในส่วนนี้!

เมื่อตกลงกันเข้าใจแล้วก็ลุยกันเลย สัญญานะว่าจะไม่หลับก่อนอ่านจบ Zzzz

ปัญหาของการจัดการ View แบบเก่า

สมัยก่อนเราสร้างเว็บแอพพลิเคชันกันอย่างไรบ้าง? เมื่อเว็บบราวเซอร์ร้องขอหน้าเพจ เซิร์ฟเวอร์ของเราก็ไปหยิบ view ที่สัมพันธ์คืนกลับให้บราวเซอร์ เช่น ผู้ใช้งานร้องขอผ่าน /articles/1 เราก็หยิบ view ของหน้าเพจ article ที่ประกอบด้วยเนื้อหาบทความ ผู้เขียนบทความ บทความที่เกี่ยวข้อง และคอมเมนต์ส่งกลับไปให้ ทั้งนี้ส่วนต่างๆที่พูดถึงล้วนยำเละกันอยู่ใน view ของ article เช่น

JavaScript
1// Article.js
2// /articles/1 แสดง view ของบทความที่มี ID เป็น 1
3<article>
4 <header>
5 <h1 class='article__title'>article.title</h1>
6 <a href={author.name} rel='author'>
7 {author.email}
8 </a>
9 </header>
10 {article.content}
11</article>
12
13<aside>
14 <ul>
15 {
16 relatedArticles.forEach(relatedArticle => {
17 <li class='article__title'>{relatedArticle.title}</li>
18 })
19 }
20 </ul>
21</aside>

คุณคิดว่าปัญหาของการใส่ทุกสรรพสิ่งแบบนี้คืออะไร? ทันทีที่อ่านโค๊ดจะพบว่าโค๊ดนี้นำกลับมาใช้ใหม่ไม่ได้ ถ้าเรามี view อื่นที่ต้องการแสดงบทความที่เกี่ยวข้อง (related articles) เช่นกัน เราก็ต้องเขียนบรรทัดที่ 15-17 ใหม่อีกครั้ง?

จินตนาการให้ล้ำลึกกว่านั้นครับ สมมติเว็บของเรานั้นเคร่งเครียดเรื่องการเทสมาก ทุกๆ view ต้องเขียนเทส ตอนนี้ระบบของเรามีทั้งหน้า article และหน้า news ที่ล้วนแสดงบทความที่เกี่ยวข้องทั้งคู่ นั่นหมายความว่า ถ้าวันหนึ่งเราไม่ต้องการแสดง title ของบทความที่เกี่ยวข้องอีกต่อไป (บรรทัดที่17) แต่ให้แสดง excerpt หรือเนื้อหาคัดย่อแทน เราก็ต้องเปลี่ยนทั้งสองเพจ และเทสใหม่ทั้งสองเพจ นั่นคือแก้ไขและแก้เทสในทุกจุดที่เขียนซ้ำ!

งั้นเราลองใหม่ แยกส่วนที่ทับซ้อนกันออกมาเป็นอีกไฟล์ และเรียกใช้งานใน view อื่นๆที่ต้องการใช้มัน แบบนี้

JavaScript
1// _RelatedArticles.js
2// โค๊ดชุดนี้ซ้ำซ้อนยิ่งนัก เตะมันออกมาอยู่ในไฟล์ใหม่เลย
3<aside>
4 <ul>
5 {
6 relatedArticles.forEach(relatedArticle => {
7 <li class='article__title'>{relatedArticle.title}</li>
8 })
9 }
10 </ul>
11</aside>
12
13// Article.js
14<article>
15 <header>
16 <h1 class='article__title'>article.title</h1>
17 <a href={author.name} rel='author'>
18 {author.email}
19 </a>
20 </header>
21 {article.content}
22</article>
23
24// นำบทความที่เกี่ยวข้องมาแปะตรงนี้
25// โดยส่ง relatedArticle เข้าไปใน _RelatedArticles.js
26{render('RelatedArticles', { relatedArticle })}

ว้าว วิธีนี้คือสิ่งที่เราทำกันอยู่ทั่วไปในปัจจุบัน แต่... มันยังไม่ดีพอ เพราะแต่ละส่วนของโค๊ด(ไฟล์) ไม่ได้เป็นอิสระต่อกัน ลองจินตนาการเล่นๆนะครับ ถ้าเราต้องการให้แสดงชื่อผู้เขียนบทความเมื่อผู้ใช้งาน hover เหนือชื่อบทความที่เกี่ยวข้อง (บรรทัดที่7) อะไรจะเกิดขึ้น?

ทั้งบรรทัดที่7 และ บรรทัดที่16 ต่างใช้ชื่อคลาส article__title ทั้งคู่ ถ้าเราดันไปผูกอีเวนต์ของการ hover ไว้กับชื่อคลาสนี้ ไม่ว่าเราจะวางเม้าส์ไว้เหนือชื่อบทความปกติหรือชื่อบทความที่เกี่ยวข้อง ทั้งคู่ล้วนจะแสดงชื้อผู้เขียนบทความออกมาเสมอ ที่เป็นเช่นนี้เพราะทั้ง Article และ RelatedArticles ไม่ได้แบ่งแยกกันอย่างชัดเจน แบ่งแยกชัดเจนเพียงแค่ระดับของการแยกไฟล์

แนะนำ Component-based Web UI

จะดีกว่านี้ไหมถ้าเรามองการเขียน UI เหมือนการต่อ LEGO ผมเชื่อว่าหลายคนคงเคยเล่นตัวต่อเลโก้นะครับ แต่จริงๆนะผมไม่เคยเล่นเลย จำได้ว่ามันแพงเลยไม่ได้ซื้อมาเล่น

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

กลับมาที่โลกของ UI บ้าง เราลองเปลี่ยนวิธีคิดใหม่ ถ้าเราแยกชิ้นส่วนที่ไม่ใช่สิ่งเดียวกัน ให้ออกจากกัน นั่นคือในหน้า article ของเราให้แยก article, author และ relatedArticles ออกจากกัน เสมือนเป็นตัวต่อเลโก้สามชิ้น จะเกิดอะไรขึ้นบ้าง???

ประการแรกโค๊ดของเราจะนำกลับมาใช้ซ้ำ (reuse) ได้แล้ว ทั้งหน้า article และ news ไม่ต้องเขียนโค๊ดของบทความที่เกี่ยวข้องซ้ำ แต่เรียกมันมาใช้เลยเสมือนหยิบเลโก้บทความที่เกี่ยวข้องมาประกบของที่มี

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

แนวความคิดนี้เรามองทุกส่วนของซอฟต์แวร์เราเป็น component หรือตัวต่อเลโก้ที่เป็นอิสระจากตัวอื่น มีเฉพาะตัวต่อที่มีร่องประกบตรงกันเท่านั้นถึงจะต่อกันได้ กล่าวคือคอมโพแนนท์ของเราจะคุยกันได้ต้องมี interface การสื่อสารผ่านอินเตอร์เฟสนี้ต้องเข้าใจตรงกัน ถ้าไม่แล้วการเชื่อมต่อระหว่างคอมโพแนนท์ก็จะล้มเหลว เราเรียกการนำคอมโพแนนท์มาประกอบกันว่า composition คุ้นๆแล้วหละซิ!

จุดที่ต้องการเน้นในเรื่องการสร้างคอมโพแนนท์ก็คือ โค๊ดที่เรียกใช้คอมโพแนนท์ ไม่จำเป็นต้องรู้ว่าคอมโพแนนท์ทำงานอย่างไร ผู้เรียกใช้รู้เพียงแค่ว่าจะเรียกใช้มันผ่านอินเตอร์เฟสอย่างไร เช่น ถ้าต้องการเรียกใช้คอมโพแนนท์ Currency ที่จัดการด้านการเงิน เราต้องส่งสกุลเงินให้มันผ่านอินเตอร์เฟสของมัน เป็นต้น นอกจากนี้คอมโพแนนท์ต้องสตรอง สตรอง สตรอง พอตัวด้วยครับ นั่นคือถ้าผู้ใช้เรียกใช้ผิดพลาด เช่นลืมส่งสกุลเงินเข้าไป คอมโพแนนท์ไม่ควรทำให้ระบบพัง เราอาจให้คอมโพแนนท์มีค่าตั้งต้นของสกุลเงินเป็นเงินบาทเพื่อกันการพังของมัน ที่คอมโพแนนท์ต้องสตรองนั้นเป็นเพราะผู้เรียกใช้พร้อมจะส่งความผิดพลาดให้คอมโพแนนท์ได้เสมอ เนื่องจากไม่รู้การทำงานที่แท้จริงของมัน อย่าลืมนะครับเราต้องออกแบบไม่ให้ภายนอกรู้การทำงานไส้ในของคอมโพแนนท์ รู้เพียงแค่อินเตอร์เฟสของมัน

จากที่กล่าวมาทั้งหมดนำไปสู่ข้อสรุปที่ว่าคอมโพแนนท์ควรมีอิสระและเกิดมาเพื่อบรรลุเป้าหมายเพียงสิ่งเดียว เมื่อไหร่ที่เรารู้สึกว่าคอมโพแนนท์ของเราแสดงผลหรือมีพฤติกรรมหลายอย่าง ให้เราแบ่งคอมโพแนนท์นั้นออกเป็น subcomponents ทั้งหมดนี้เพื่อให้เป็นไปตามหลักการที่เรียกว่า single responsibility principle ขยายความให้ยาวออกไปได้ว่า ทำหน้าที่ของตนเองให้ดีซะ อย่าไปแบกรับภาระด้วยการเอางานของชาวบ้านมาทำ หรือตรงกับสุภาษิตคำพังเพยที่ว่า อย่าหาเหาใส่หัวนั่นเอง ดูรูปประกอบที่เอามาจากVue.js ด้านล่าง เราแบ่งส่วนของเพจเป็นคอมโพแนนท์ แต่ละคอมโพแนนท์ถ้าเริ่มรู้สึกว่าทำหลายอย่างเกินให้แบ่งย่อยไปอีก

Subcomponents


คำถามชวนคิด

  • คุณคิดว่าจุดไหนคือจุดที่เหมาะสมของการแบ่งคอมโพแนนท์ให้ย่อยลง (subcomponents)? ถ้าในส่วนของ header ประกอบด้วย logo และปุ่ม login จำเป็นต้องแยก logo และ login ออกเป็นคอมโพแนนท์หรือไม่เพราะเหตุใด?
  • ถ้า subcomponent แยกออกมาแล้วไม่ได้นำไปใช้ที่อื่นอีก (reuse) กล่าวคือใช้แค่กับคอมโพแนนท์ที่เราแยกออกมาแค่จุดเดียว ยังมีความจำเป็นหรือไม่ที่ต้องแยก subcomponent เพราะเหตุใด?

สวัสดีเรา React Component เอง

ย้อนกลับไปดูโค๊ดสวัสดีชาวโลกของเราในบทความที่แล้วกันครับ

JavaScript
1// Component เป็นคลาสใน React ใช้สำหรับเป็นคลาสแม่สำหรับการสร้างคอมโพแนนท์อื่น
2import React, { Component } from 'react'
3import { render } from 'react-dom'
4import styles from './styles.scss'
5
6export default class HelloWorld extends Component {
7 render() {
8 return (
9 <div>
10 <h1 className={styles.greeting}>Hello World</h1>
11 </div>
12 )
13 }
14}
15
16// ทำการ render ผลลัพธ์ของคอมโพแนนท์ HelloWorld ไปที่ element ที่มี ID เป็น app
17// (ดู index.html ประกอบ)
18render(<HelloWorld />, document.getElementById('app'))

เราสร้างคอมโพแนนท์ของ React โดยการ inherite คลาสสวัสดีชาวโลกของเราจาก Component เพื่อให้มันมีคุณสมบัติตามแบบฉบับของ React Component ให้สังเกตตรงนี้นะครับว่าสิ่งที่จะเป็นคอมโพแนนท์นั้นต้องสมบูรณ์ในตัวมันเอง HelloWorld นี้สมบูรณ์ในตัวเองเพราะมันรวมทั้งส่วนแสดงผลคือ h1 รวมพฤติกรรมและรวมสไตล์ (CSS) ไว้ในตัวมันเองเลย นั่นคือเมื่อเพจไหนเอาคอมโพแนนท์นี้ไปใช้ จะใช้งานได้ทันทีโดยไม่ต้องรู้ว่ามันคืออะไร ทำงานอย่างไร

คอมโพแนนท์ของ React นั้นจะเอาสิ่งที่อยู่ภายใต้เมธอด render ไปแสดงผลในขั้นตอนของการ mount ที่จะกล่าวต่อไป เราสามารถใช้ HTML tag ใดๆก็ได้ใน render แต่มีข้อแม้อยู่ไม่กี่ข้อ ข้อแรกคือในเมธอด render ต้องคืนค่าออกมาเป็น element ตัวเดียว ฉะนั้นแล้วเมธอด render ข้างล่างนี้จึงผิด

JavaScript
1render() {
2 return (
3 {/* return 2 elements คือ h1 และ div จึงผิด*/}
4 <h1>Hello World</h1>
5 <div>Extra content</div>
6 )
7}

เพื่อให้คืนค่ากลับเป็น element ตัวเดียว เราจึงต้องครอบมันทั้งสองด้วยอะไรซักอย่าง ในที่นี้ผมใช้ div ครับดังนี้

JavaScript
1render() {
2 return (
3 <div>
4 <h1>Hello World</h1>
5 <div>Extra content</div>
6 </div>
7 )
8}

React ใช้ไวยากรณ์ที่เรียกว่า JSX (JavaScript Syntax eXtension) ที่อนุญาตให้เขียนสิ่งที่มีหน้าตาเป็น XML ลงไปได้ จึงไม่แปลกใจที่คุณจะเขียน XHTML tag ที่เป็น XML ลงไปใน React Component ได้ด้วย ฉะนั้นแล้วคุณต้องห้ามลืมปิด tagครับไม่งั้นจะผิดกฎ XML มีผลเป็นกรรมหนักมาก เช่น <hr /> <br /> <Component /> หรือ <Component></Component> ล้วนปิดแท็กทั้งคู่ ทั้งนี้ JSX จะได้รับการแปลงเป็น JavaScript ธรรมด๊าธรรมดาอีกทีนึง เรียกได้ว่าเป็นการผสาน XML เข้ากับ JavaScript อย่างมีศิลปะเลยทีเดียว

เรื่องถัดมาที่อยากพูดถึงคือ ไม่ใช่ทุกๆ attribute ของ HTML element ที่คุณจะใช้ได้ ตัวอย่างเช่นคุณไม่สามารถใช้ class ได้ อย่าลืมนะครับว่า React เป็น JavaScript-centric คือใช้วิธีการสร้างไวยากรณ์ใหม่บน JavaScript มันจึงไม่อนุญาตให้เราใช้คำสงวนต่างๆของ JavaScript ได้ คำว่า class ไว้ใช้สร้างคลาสใน JavaScript ดังนั้นเราจึงต้องใช้ className แทน

ProTips! เพื่อนๆคนใดที่เคยใช้ React มาก่อน และยังคงใช้ React.createClass เพื่อสร้างคอมโพแนนท์อยู่ ถ้าหลีกเลี่ยงได้ควรยุติการใช้ครับ เพราะประสิทธิภาพของมันนั้นต่ำกว่า ES2015 class หรือ React functional component (กล่าวถึงในภายหลังของบทความนี้) มาก!

เอาหละต่อไปเราลองมาดู HelloWorld ที่ซับซ้อนมากขึ้น

JavaScript
1class HelloWorld extends Component {
2 render() {
3 const { fullName, birthday } = this.props
4
5 return (
6 <div>
7 <h1>สวัสดีชาวโลก ผมชื่อ {fullName}</h1>
8 <time datetime={birthday.toISOString()}>
9 {birthday.toLocaleDateString()}
10 </time>
11 </div>
12 )
13 }
14}
15
16// จ้างให้ก็ไม่บอกหรอกว่าเกิดวันไหน อิอิ เอาเป็นว่าเกิดวันนี้แล้วกัน
17;<HelloWorld fullName="Nuttavut Thongjor" birthday={new Date()} />

และนี่คือวิธีการเรียกใช้คอมโพแนนท์ของเราครับ เรามีคอมโพแนนท์ HelloWorld ตามที่กล่าวข้างต้นว่า JSX นั้นรัก XML เราจึงต้องเรียกคอมโพแนนท์เหมือนการเขียน XML tag (บรรทัดที่ 17) ถึงตรงนี้เพื่อนๆต้องสงสัยแน่ว่า fullName และ birthday นั้นคืออะไร? นี่หละครับเป็นวิธีการส่งค่าเข้าไปในคอมโพแนนท์ของ React โดยวิธีการส่งค่านั้นจะใช้ไวยากรณ์ว่า [PROPERTY_NAME]={value} ฉะนั้นแล้วในที่นี้จะเป็นการส่ง fullName ที่มีค่าเป็น 'Nuttavut Thongjor' และส่ง birthday ที่มีค่าเป็น new Date() เข้าไปเป็น property/attribute ของคอมโพแนนท์ HelloWorld

สังเกตให้ลึกไปอีกนิด เพื่อนๆจะเริ่มพบว่าทำไม fullName นั้นใช้ single-quote (') แต่ birthday กลับใช้ {} ใน JSX นั้นอะไรก็ตามที่เป็นส่วนของโค๊ดหรือ expression ที่ต้องประมวลผลด้วย JavaScript เราจะครอบด้วย {} ในที่นี้ new Date() เป็นคำสั่งจาวาสคริปต์ที่คุณต้องประมวลผลมัน คุณจึงจำเป็นต้องครอบด้วย {} ตัวอย่างอื่นๆที่ต้องครอบและไม่ต้องครอบด้วย {}

  • property={true} // boolean ก็ต้องครอบนะครับ
  • property='string' // string ไม่ต้องประมวลผลอะไรต่ออีก จึงไม่ต้องครอบ
  • property=`string-${1+1}` // string interpolation ก็ต้องครอบนะ เพราะต้องประมวลผล 1+1 ก่อน

เราจบขั้นตอนการเรียกใช้งานคอมโพแนนท์แล้ว หายใจเข้าให้เต็มปอดแล้วย้อนไปดูไส้ในของคอมโพแนนท์เรากัน!

this.props เป็นหัวใจหลักในการอ้างถึงของที่คุณโยนเข้าไปในคอมโพแนนท์ เมื่อเรายัดเยียด fullName และ birthday เข้าไปใน HelloWorld มันจึงปรากฎตัวเป็นทายาทของ this.props ทำให้เราสามารถอ้างถึงมันได้ผ่าน this.props.fullName และ this.props.birthday ตามลำดับ ทั้งนี้เพื่อความสะดวกเราใช้ Destructuring feature ของ ES2015 ในบรรทัดที่3 เพื่อดึงแอททริบิวต์ทั้งคู่ออกมาจาก this.props ในครั้งเดียว

ข้อควรรู้! ทุกส่วนของโค๊ดที่เราเห็นในคอมโพแนนท์เป็น JavaScript เราจึงเขียนอะไรก็ได้ตราบที่มันเป็น JavaScript โค๊ดสองส่วนด้านล่างนี้จึงเท่ากัน

JavaScript
1render() {
2 // มี () ครอบ
3 return (
4 <div>
5 <h1>สวัสดีชาวโลก ผมชื่อ {fullName}</h1>
6 <time datetime={birthday.toISOString()}>
7 {birthday.toLocaleDateString()}
8 </time>
9 </div>
10 )
11}
12
13{/* หรือ */}
14
15render() {
16 // ไม่มี () ครอบ
17 return <div>
18 <h1>สวัสดีชาวโลก ผมชื่อ {fullName}</h1>
19 <time datetime={birthday.toISOString()}>
20 {birthday.toLocaleDateString()}
21 </time>
22 </div>
23}

ทั้งนี้ผมแนะนำให้เขียนแบบแรกคือมี () ครอบระหว่าง return เพราะมันจะทำให้โค๊ดคุณดูอ่านง่ายขึ้นและที่สำคัญคุณจะใช้ editor เพื่อย่อหรือหุบส่วนของโค๊ดได้ง่ายขึ้น

Collapse Return

เริ่มใช้ React สร้างโปรเจคจริงกันเถอะ

บทความนี้เราจะสร้าง Wiki อย่างง่ายกัน วิกิที่อนุญาตให้ใครก็ได้มาสร้าง ดูและแก้ไขเนื้อหา โดยเราจะเก็บข้อมูลไว้กับ RESTful API Server ของเรา ในที่นี้ผมไม่ได้สอนสร้างเซิฟเวอร์ แต่จะจำลองมันขึ้นมาด้วย json-server ผู้อ่านที่สนใจสามารถอ่านเพิ่มเติมได้ที่ มาจำลอง REST API ไว้ใช้งานกันเถอะ

เรายังทำงานต่อเนื่องบน directory เดิมจากบทความที่แล้วนะครับ ผู้อ่านคนไหนยังไม่มีโปรเจคตั้งต้นจากบทความที่แล้ว เชิญเข้าไปดูได้ที่ Github ครับ

เอาหละ เราเริ่มจากติดตั้ง json-server กันก่อนผ่านคำสั่งนี้

Code
1npm i --save-dev json-server

จากนั้นสร้างไดเร็กทอรี่ชื่อ api ไว้แทนโค๊ดของ REST API ของเรา และสร้างอีกไดเร็กทอรี่ชื่อ ui ไว้สำหรับโค๊ดฝั่ง front-end

ภายใต้โฟลเดอร์ api ให้สร้างไฟล์ชื่อ db.json ตัวนี้เราจะใช้แทนฐานข้อมูลของเราครับ ใส่เนื้อหาลงไปตามนี้เลย

Code
1{
2 "pages": [
3 {
4 "id": 1,
5 "title": "test page#1",
6 "content": "TEST PAGE CONTENT"
7 }
8 ]
9}

เท่านี้เราก็จะได้ REST API ที่มีข้อมูลพร้อมใช้ในฐานข้อมูลคือ page ที่มี ID เป็น 1

เราต้องการมากกว่านั้น อยากให้ URL path ของเราดูสวยงามเป็น /api/v1 เราจึงต้องสร้างอีกไฟล์ชื่อ routes.json พร้อมใส่เนื้อหาตามข้างล่าง เพื่อบอก json-server ว่าทุกครั้งที่ร้องขอผ่าน /api/v1 ให้ดึงข้อมูลจาก / ธรรมดามาใช้

Code
1{
2 "/api/v1/": "/"
3}

เอาหละ เราเตรียม json-server เรียบร้อยแล้ว ต่อไปเพิ่มสคริปต์ลงใน package.json เพื่อให้เราเรียกใช้งานมันได้สะดวกขึ้นดังนี้

Code
1"scripts": {
2 "start-dev-api": "json-server --watch api/db.json --routes api/routes.json --port 5000",
3 "start-dev-ui": "webpack-dev-server --hot --inline"
4},

ถ้าเราใส่สคริปต์แบบนี้ เวลาเราเรียกใช้งานเราอาจต้องเรียก npm run start-dev-api && npm run start-dev-ui เพื่อให้ทั้ง ui และ api รันขึ้นมาซึ่งเป็นคำสั่งที่ยาว เราจึงใช้ npm-run-all ช่วยรันสคริปต์แบบขนาน หรือจะใช้ concurrently ก็ได้เช่นกันครับตามสะดวกเลย เริ่มติดตั้งก่อนด้วยคำสั่ง

Code
1npm i --save-dev npm-run-all

จากนั้นก็เพิ่มสคริปต์เข้าไปดังข้างล่าง ถึงเวลานี้เพียงแค่เรียก npm start ทั้ง ui และ api ก็จะได้รับการเรียกขึ้นมาใช้งานแบบขนาน

Code
1"scripts": {
2 "start": "npm-run-all --parallel start-dev-api start-dev-ui",
3 "start-dev-api": "json-server --watch api/db.json --routes api/routes.json --port 5000",
4 "start-dev-ui": "webpack-dev-server --hot --inline"
5}

ออกคำสั่ง npm start แล้วเข้าเว็บที่ http://127.0.0.1:5000/api/v1/pages คุณควรจะพบก้อน json ดังนี้

Code
1[
2 {
3 "id": 1,
4 "title": "test page#1",
5 "content": "TEST PAGE CONTENT"
6 }
7]

ในการออกแบบ REST API ที่ดีนั้น เราควรส่งชื่อ resource กลับมาเป็น root key ด้วยนะครับ เช่น

Code
1{
2 "pages": [
3 ...
4 ]
5}

ผู้อ่านที่สนใจอ่านเพิ่มเติมได้ที่ ออกแบบ REST API ยังไงดี? แนะนำ jsonapi

ตอนนี้เรามีทั้งโฟลเดอร์ api และ ui แล้ว แต่เรายังลืมทำสิ่งหนึ่ง คือการย้ายโค๊ดของ ui ไปไว้ให้ถูกที่ถูกทาง ให้เพื่อนๆย้ายไฟล์ index.js ไปไว้ภายใต้โฟลเดอร์ ui และเปลี่ยนแปลง webpack.config.js ดังนี้

JavaScript
1...
2...
3module.exports = {
4 devtool: 'eval',
5 // ตอนนี้ index.js ของเราย้ายไปอยู่ใต้ ui แล้ว
6 // จุดเริ่มต้นของโปรแกรมเราเปลี่ยน ต้องอัพเดท!
7 entry: './ui/index.js',
8 ...
9 ...
10};

วิเคราะห์ภาพรวมระบบ

ก่อนลงมือทำอะไรซักอย่างลองออกแบบคร่าวๆหน่อยไหมครับ จะได้เข้าใจระบบมากขึ้น

Wireframe

Wiki ของเราจะมีส่วนหลักๆคือหน้า Homepage หน้า Page และหน้า About ครับ ในส่วนของ Page นั้นเราต้องสามารถทำการดู Wiki Page ได้ทั้งหมด สามารถสร้าง Page ใหม่ได้รวมถึงสามารถแก้ไขได้ด้วย แต่ไม่อนุญาตให้ลบ

ถ้าผู้อ่านสังเกตดูดีๆจะเห็นว่าในแต่ละเพจนั้นเราใช้ URL ที่อ้างถึงไม่เหมือนกันดังนี้

Code
1/ แสดงหน้า Homepage
2/pages ดู wiki page ทั้งหมด
3/pages/:id ดู wiki หน้าที่มี ID ตามที่ระบุ
4/pages/:id/new สร้าง wiki หน้าใหม่
5/pages/:id/edit แสดงหน้าแก้ไข wiki ที่มี ID ตามที่ระบุ
6/about แสดงหน้า about

จะเห็นว่าแอพพลิเคชันของเราเต็มไปด้วยเส้นทาง (Route) ในการวิ่งไปมาระหว่างหน้าเพจ เราจึงต้องอาศัยตัวควบคุมเส้นทาง (Router) ในการจัดการการเปลี่ยนหน้าเพจ และพระเอกของเราที่จะมาช่วยจัดการเรื่องนี้คือ react-router

จัดการเส้นทางเพื่อเข้าถึงหน้าเพจด้วย react-router

เนื่องจากใน React นั้นเรามองทุกอย่างเป็น component หากเรากล่าวว่าเรามี /pages เพื่อแสดงผลเพจทั้งหมด ความหมายในบริบทของ React จึงเป็นการเข้าถึงคอมโพแนนท์ Pages นั่นเอง ในลักษณะนี้เราจึงอาจกล่าวได้ว่าทุกๆเส้นทางหรือ route จะมีปลายทางชี้ไปที่คอมโพแนนท์ที่ทำหน้าที่เสมือน view ใน MVC เสมอ

ลงมือติดตั้ง react-router ก่อนเริ่มใช้งานครับ พิมพ์คำสั่งดังนี้

Code
1npm install --save react-router

เราจะเริ่มกันที่ route ที่ง่ายที่สุดก่อนคือ / หรือการแสดงผลหน้า Homepage เปิดไฟล์ index.js ที่เราทำค้างไว้จากตอนที่แล้วครับ จากนั้นทำการลบคอมโพแนนท์ HelloWorld ทิ้งซะ เสียใจด้วยคุณไม่ได้ไปต่อครับ จากนั้นใส่โค๊ดด้านล่างนี้ไปแทน

JavaScript
1import React, { Component } from 'react'
2import { render } from 'react-dom'
3import { Router, Route, IndexRoute, browserHistory } from 'react-router'
4
5render(
6 <Router history={browserHistory}>
7 <Route path="/" component={App}>
8 <IndexRoute component={Home} />
9 <Route path="pages" component={Pages} />
10 </Route>
11 </Router>,
12 document.getElementById('app')
13)

เมื่อเพิ่มโค๊ดเสร็จเรียบร้อยแล้วอย่าพึ่งรันนะครับ ขอให้อ่านคำอธิบายก่อนนิดนึง

Router Route และ IndexRoute ในบรรทัดที่ 6-8 นั้นคือคอมโพแนนท์ที่อยู่ในแพคเกจของ react-router เพียงแต่ทั้งสามตัวนี้เป็นคอมโพแนนท์ที่มีพฤติกรรมแตกต่างกัน

Router กับ Route ไม่ได้ต่างเพราะอีกตัวไม่มีอักษร r นะครับ แต่จุดต่างคือ Router คือผู้คุมเส้นทางเสมือนเป็นตำรวจจราจร ส่วน Route เป็นเส้นทางแต่ละเส้นที่สามารถเข้าถึงได้ ถ้าเราเปรียบบราวเซอร์เป็นรถยนต์ เรากำลังจะไป / แต่เราไม่รู้ว่าจะไปทางไหน(route) เราจึงต้องไปถามตำรวจจราจร (router) สุดท้ายตำรวจจราจรจะเป็นคนบอกเราว่า เห้ยคุณต้องไปเส้นทางนู้นนะ จุดสังเกตคือเส้นทางนั้นมีได้หลายสาย แต่คนบอกทางมีเพียงคนเดียวก็พอ นั่นคือเรามีหนึ่ง Router แต่มีได้หลาย Route และนี่หละครับคือหน้าที่ของ Route และ Router ครับ

บรรทัดที่6 ทำไมเราต้องส่ง history เข้าไปใน Router ด้วย? ย้อนกลับไปที่ตำรวจจราจรกันอีกครั้ง ตำรวจจราจรจะบอกคุณได้ว่าคุณควรเดินรถไปตามเส้นทางไหนก็ต่อเมื่อคุณบอกเป้าหมายสถานที่ที่คุณจะไป เช่นเดียวกัน history เป็นเสมือนการบอกว่าเราจะไปไหน history นั้นคอยสอดส่องคุณว่าตอนนี้คุณอยู่ที่ URL ไหน เพจก่อนหน้าที่คุณไปคืออะไร เป็นต้น จึงเป็นข้อมูลสำคัญต่อการตัดสินใจของ Router ว่าจะหยิบ Route ไหนมาต้อนรับคุณ

แล้ว IndexRoute หละคืออะไร? ลองดูรูปภาพด้านล่างนะครับ ทุกๆเพจของเราจะประกอบด้วยสองส่วนคือ ส่วนที่ใช้ร่วมกันได้แก่ส่วนหัวด้านบน และส่วนที่ไม่ได้ใช้ร่วมกันได้แก่ส่วนของเนื้อหาที่เปลี่ยนแปลงไปในแต่ละเพจ ด้วยเหตุนี้บรรทัดที่7เราจึงบอกว่า เมื่อผู้ใช้ระบบเข้ามาที่ / ให้ไปเรียกคอมโพแนนท์ชื่อ App มาแสดงผล App ตัวนี้หละครับที่เราจะรวมทุกสรรพสิ่งที่ใช้ซ้ำกันไว้ในนี้ เพราะมันคือต้นทางของทุกๆ Route ย่อยให้เข้าใจง่ายขึ้นอีกนิดก็คือ ไม่ว่าคุณจะเข้า /pages หรือ /about คุณเห็นไหมครับว่าหน้าสุดมันคือ / ฉะนั้นแล้ว App จึงเป็นคอมโพแนนท์ต้นทางและโดนปลุกขึ้นมาใช้งานเสมอโดย route อื่นๆจะแสดงผลในฐานะเป็นลูกของ App อีกทีนึง ตรงนี้สำคัญขีดเส้นใต้ไว้สองเส้นเลยครับ

Index Route

ความจริงแล้วมันก็ไม่ถูกซะทีเดียวหรอกแม้ / จะเป็นต้นทางของทุก route แต่คอมโพแนนท์ App ไม่จำเป็นต้องโดนเรียกขึ้นมาแสดงเสมอ แต่สิ่งที่ทำให้ App ต้องมาเสนอหน้าบนบราวเซอร์เราเพราะเราระบุว่าทุกๆการเข้ามาที่ / ต้องให้แสดง App ด้วยดังนี้

JavaScript
1<Router history={browserHistory}>
2 {/* เราแปะคำว่า component เข้าไป ทุกๆ route ที่อยู่ภายใต้ Route ตัวนี้จึงแสดงผล App ด้วย*/}
3 <Route path="/" component={App}>
4 <Route path="pages" component={Pages} />
5 </Route>
6</Router>

เอาหละลองเปลี่ยนใหม่ ถ้าเราไม่อยากให้เว็บของเราต้องมี layout ของ App เราก็แค่ไม่เรียกมันซะ ทุกอย่างก็ไปได้สวย

JavaScript
1<Router history={browserHistory}>
2 {/* ลาก่อยน้องแอ๊บ */}
3 <Route path="/">
4 <Route path="pages" component={Pages} />
5 </Route>
6</Router>

กลับขึ้นไปดูไฟล์ index.js ของเราอีกครั้ง บรรทัดที่9เราบอกว่าเมื่อไหร่ก็ตามที่ผู้ใช้งานเข้าถึง /pages ให้เอาคอมโพแนนท์ชื่อ Pages มาแสดง แน่นอนว่าคอมโพแนนท์ App ก็โดนเรียกมาแสดงผลเช่นกันเพราะมันเป็นต้นทาง

ตอนนี้เพื่อนๆอาจสงสัย ถ้าเราเข้า / เฉยๆหละ อย่างนี้มันก็หยิบเอา App มาแสดงผลเฉยๆซิ แต่ถ้าดูตามที่เราออกแบบไว้น้องแอ๊บของเรามีเพียงส่วน header เองนะ แล้วข้อความ Welcome to BabelCoder wiki ที่ตั้งใจจะแสดงก็หายไปหนะซิ! จะทำอย่างไรให้แสดงข้อความนี้? เราจะยัดข้อความนี้ใส่ลงไปในน้องแอ๊บหรอ? ไม่ดีมั้ง ถ้าทำอย่างนั้นทุกๆเพจก็จะมีข้อความนี้ไปหมดซิ?

เพื่อให้สามารถแสดงผลส่วนอื่นภายใต้ / ได้เราจึงมี IndexRoute ไว้แก้ปัญหา index route เป็นสิ่งบ่งบอกว่าคอมโพแนนท์ตัวไหนที่จะใช้แสดงผลไปพร้อมกับ route ที่ครอบมันอยู่ (parent route) เช่น บรรทัดที่8เป็นการบอกว่าให้แสดงคอมโพแนนท์ Home ซึ่งเป็น index route ไปพร้อมกับคอมโพแนนท์ App ที่เป็นคอมโพแนนท์ที่ครอบมันอยู่ อ่านแล้วงงใช่ไหมครับ คนเขียนยังงงเลยว่าพิมพ์อะไรไป เอาเป็นว่าย่อยข้อความแล้วก็คือ IndexRoute ทำให้เมื่อเข้า / แล้วหยิบคอมโพแนนท์ทั้ง App และ Home มาแสดงผล

เอาหละทุกส่วนได้รับการอธิบายแล้ว แต่ไหนละ App Home หรือ Pages มันโผล่มาจากไหน โผล่มาได้ไง ตรงไหนหละที่นิยามมัน? คำตอบคือ เศร้าจังเรายังไม่ได้สร้างมันเลยครับ ฉะนั้นแล้วมาทำพวกมันให้ปรากฎกันเถอะ!

เราสร้างโฟลเดอร์ชื่อ components ไว้ภายใต้โฟลเดอร์ ui ครับเพื่อใช้สำหรับเก็บคอมโพแนนท์ จากนั้นสร้างไฟล์ชื่อ App.js ไว้ภายใต้โฟลเดอร์ components ที่เราสร้างไว้พร้อมใส่ข้อมูลตามนี้

JavaScript
1// App.js
2import React, { Component } from 'react'
3
4class App extends Component {
5 render() {
6 return (
7 <div>
8 <header>Navbar</header>
9 {this.props.children}
10 </div>
11 )
12 }
13}
14
15export default App

นี่คือคอมโพแนนท์ App ที่เราตั้งไว้เป็น route บนสุด จำได้ไหมครับที่ผมบอกว่า App นั้นเป็นคอมโพแนนท์ของ route ที่ครอบ route อื่นอีกที มันจึงโดนเอาไปแสดงผลในทุกๆครั้งที่เราเข้าเพจในฐานะเป็น layout ของเพจ เมื่อเป็นเช่นนี้เราจึงเอาส่วนหัวของเพจที่ไปใส่ไว้ใน App เพื่อการันตีว่าเจ้าแถบ navbar นี้จะปรากฎในทุกๆหน้า และนั่นแหละครับเมื่อเราเข้า route อื่นใด คอมโพแนนท์ที่เป็นตัวแทนของ route นั้นจะโดนดูดเข้ามาเป็นลูกของ App อีกทีนึง โดยเราสามารถเข้าถึงมันได้จาก this.props.children ตัวอย่างเช่น ถ้าเพื่อนๆเข้าถึง /pages แล้วคอมโพแนนท์ Pages จะเป็นลูกของ App และเรียกใช้งานได้ด้วย this.props.children การแสดงผลเมื่อเข้าแต่ละ path เป็นดังนี้

pathcomponent
/App (parent) -> Home (เป็นลูกของApp)
/pagesApp (parent) -> Pages (เป็นลูกของApp)

ถึงตอนนี้เมื่อคุณเข้าถึง / App ก็จะแสดงผลในฐานะเป็น layout หรือ master page แต่ยังไม่มีเนื้อหาที่แท้จริงใดๆมาแสดง อย่าลืมนะครับเราบอกว่าเนื้อหาที่จะเอามาแสดงเมื่อเข้า / คือ Home ที่มีฐานะเป็น index route

App Route

เพื่อให้ route ต่างๆของเราสมบูรณ์ สร้างไฟล์ชื่อ Home.js และ Pages.js พร้อมทั้งสร้างไฟล์ Home.scss เพื่อใส่สไตล์ให้คอมโพแนนท์ Home ของเรา แล้วใส่โค๊ดตามนี้

JavaScript
1// Home.js
2import React, { Component } from 'react'
3import styles from './Home.scss'
4
5class Home extends Component {
6 render() {
7 return <h2 className={styles['title']}>Welcome to BabelCoder Wiki!</h2>
8 }
9}
10
11export default Home
12
13// Pages.js
14import React, { Component } from 'react'
15
16class Pages extends Component {
17 render() {
18 return (
19 <table className="table">
20 <thead>
21 <tr>
22 <th>ID</th>
23 <th>Title</th>
24 <th>Action</th>
25 </tr>
26 </thead>
27 <tbody>
28 <tr>
29 <th>1</th>
30 <td>Title Page#1</td>
31 <td>
32 <a href="javascript:void(0)">Show</a>
33 </td>
34 </tr>
35 </tbody>
36 </table>
37 )
38 }
39}
40
41export default Pages

และ Home.scss

SASS
1// Home.scss
2.title {
3 position: fixed;
4 top: 50%;
5 left: 50%;
6 transform: translate(-50%, -50%);
7}

เมื่อเราสร้างคอมโพแนนท์เสร็จแล้ว สุดท้ายจึงถึงเวลาประกบร่าง! ปรับปรุง index.js ของเราตามนี้ครับ

JavaScript
1import React, { Component } from 'react'
2import { render } from 'react-dom'
3import { Router, Route, IndexRoute, browserHistory } from 'react-router'
4import App from './components/App'
5import Home from './components/Home'
6import Pages from './components/Pages'
7
8render(
9 <Router history={browserHistory}>
10 <Route path="/" component={App}>
11 <IndexRoute component={Home} />
12 <Route path="pages" component={Pages} />
13 </Route>
14 </Router>,
15 document.getElementById('app')
16)

หมายเหตุ ไม่ต้องตกใจนะครับถ้าตอนนี้เข้า /pages แล้วจะเจอ error อดใจรออีกนิดนึงเรากำลังจะพูดถึงเรื่องนี้ในหัวข้อถัดไปครับ


คำถามชวนคิด : ลองออกแบบ Router และ Route ให้มีคุณสมบัติดังนี้

  • มี 2 layout
  • layout แรกใช้คอมโพแนนท์ชื่อ App เป็นตัวกำหนด
  • layout หลังใช้คอมโพแนนท์ชื่อ Static เป็นตัวกำหนด
  • เมื่อผู้ใช้งานระบบเข้ามาที่ /app/pages ให้แสดงคอมโพแนนท์ Pages ภายใต้ layout ชื่อ App
  • เมื่อผู้ใช้งานระบบเข้ามาที่ /static/about-me ให้แสดงคอมโพแนนท์ AboutMe ภายใต้ layout ชื่อ Static

จมให้ลึกไปกับ Modular Pattern

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

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

ย้อนกลับไปดูไฟล์ index.js ของเรากัน เพื่อนๆพบสามบรรทัดนี้ไหมครับ

JavaScript
1import App from './components/App'
2import Home from './components/Home'
3import Pages from './components/Pages'

นั่นหละครับ การที่เรา import คอมโพแนนท์เหล่านี้่ด้วยการอ้างถึงตำแหน่งในโฟลเดอร์ components ทำให้โมดูล components ของเราสูญเสียการปกป้องการเข้าถึงตำแหน่ง เพื่อเป็นการหลีกเลี่ยง เราจึงควรเขียนโค๊ดแบบต่อไปนี้แทนซึ่งเป็นการระบุว่าต้องการคอมโพแนนท์ใดบ้างจากโมดูล components โดยไม่สนใจว่ามันวางอยู่ตำแหน่งไหน

JavaScript
1import { App, Home, Pages } from './components'

ถึงตรงนี้เพื่อนๆคงเกิดคำถามว่า โมดูล components ของเราจะจัดการอย่างไรเพื่อส่ง App Home และ Pages กลับมาให้โค๊ดต้นทางได้อย่างถูกต้อง? นั่นจึงเป็นเหตุผลที่ว่าโมดูล components ของเราต้องมีไฟล์ที่ทำหน้าที่เป็นเหมือนต้นทางคอยจำหน่ายและเป็นหน้าด่านของโมดูล ไฟล์นั้นคือ index.js ที่เพื่อนๆต้องสร้างไว้ใต้โฟลเดอร์ components ดังนี้

JavaScript
1// components/index.js
2export App from './App'
3export Home from './Home'
4export Pages from './Pages'

ตอนนี้โค๊ดของเราดูเป็นระเบียบมากขึ้น แต่เชื่อเถอะครับบนโลกไปนี้ไม่มีอะไรที่ดีโดยปราศจาก trade-off (ได้อย่างเสียอย่าง) ผมจะกล่าวถึงเรื่องนี้อีกครั้งในบทความสุดท้ายของชุดบทความนี้

กลับมาที่ไฟล์ index.js ของเราอีกครั้ง ตอนนี้หน้าตาของมันควรเป็นแบบนี้ครับ

JavaScript
1import React, { Component } from 'react'
2import { render } from 'react-dom'
3import { Router, Route, IndexRoute, browserHistory } from 'react-router'
4import { App, Home, Pages } from './components'
5
6render(
7 <Router history={browserHistory}>
8 <Route path="/" component={App}>
9 <IndexRoute component={Home} />
10 <route path="pages" component={Pages} />
11 </Route>
12 </Router>,
13 document.getElementById('app')
14)

ไฟล์ index.js เป็นดังทางเข้าถึงโมดูล เนื่องจากไฟล์นี้อยู่ในโฟลเดอร์ ui มันจึงเป็นทางเข้าถึงโมดูล ui นั่นเอง เพื่อนๆช่วยผมคิดหน่อยครับ ถ้าผมบอกว่านี่คือไฟล์ที่เป็นตัวแทนของ ui มันควรจะทำอะไรได้? ให้เวลาคิด 3 วินาทีครับ.... เอาหละหมดเวลา!

เมื่อนึกถึง ui เรานึกถึงการแสดงผล ถ้าพูดเป็นภาษาโปรแกรมก็คือการ render ข้อมูล ฉะนั้นแล้วโค๊ดนี้จึงอยู่ได้ถูกที่และเหมาะสมแล้ว

JavaScript
1render((
2 ...
3), document.getElementById('app'))

เมื่อมองให้ลึกลงไปพบว่าไม่ใช่แค่ render เท่านั้น แต่ไฟล์นี้ทำหน้าที่เกินตัว กล่าวคือมันจัดการเส้นทางด้วย Router/Route เป็นส่วนที่ index.js ไม่ควรจะรับรู้ เพราะไม่ใช่หน้าที่ของมันต้องรู้ว่าขณะนี้เราเข้าเว็บมาที่ URL หรือ path อะไร หน้าที่ของมันมีหนึ่งเดียวคือแสดงผล เราจึงควรเอาโค๊ดของการจัดการเส้นทางออกไป เราเรียกการแบ่งแยกสิ่งที่ไม่เกี่ยวข้องออกไปเช่นนี้ว่า Separation of Concern

สร้างไฟล์ขึ้นมาใหม่ภายใต้โฟลเดอร์ ui ชื่อ routes.js แล้วย้ายโค๊ดจัดการเส้นทางของเรามาไว้ในนี้ ดังนี้

JavaScript
1import React from 'react'
2import { Router, Route, IndexRoute, browserHistory } from 'react-router'
3import { App, Home, Pages } from './components'
4
5export default () => {
6 return (
7 <Router history={browserHistory}>
8 <Route path="/" component={App}>
9 <IndexRoute component={Home} />
10 <route path="pages" component={Pages} />
11 </Route>
12 </Router>
13 )
14}

ท้ายสุดนี้ ปรับปรุงไฟล์ index.js ของเราให้สะอาดสุดๆดังนี้

JavaScript
1import React, { Component } from 'react'
2import { render } from 'react-dom'
3import routes from './routes'
4
5render(routes(), document.getElementById('app'))

อย่ารอช้า เข้าไปดูผลงานกัน สั่ง npm start แล้วเข้าไปที่ http://127.0.0.1:8080/ ได้เลย! ถ้าทุกอย่างถูกต้องหน้าจอของเพื่อนๆต้องเป็นแบบนี้

Homepage

ลองเข้าไปดูหน้า /pages กันหน่อยที่ http://127.0.0.1:8080/pages

นั่นไงขึ้น error ใช่ไหมครับ มันบอกว่าหาหน้า pages ไม่เจอใช่ไหม ให้เพื่อนๆเข้าไปที่ webpack.config.js แล้วใส่บรรทัดต่อไปนี้ครับ

JavaScript
1...
2...
3postcss: function () {
4 return [autoprefixer];
5},
6devServer: {
7 historyApiFallback: true
8}
9...
10...

ที่เราต้องใส่ historyApiFallback เข้าไปนั่นเป็นเพราะว่า เราต้องการบอก webpack-dev-server ถ้าเมื่อไหร่มันหาหน้าไม่เจอหรือได้ 404 ให้วิ่งกลับไปที่ / เมื่อมันวิ่งกลับไปที่ / ความหมายคือมันจะเรียกไฟล์ index.html ขึ้นมา ในที่สุดแล้ว react-router ที่แอบซ่อนอยู่ใน <script src='bundle.js'></script> ของ index.html จะเข้ามาจัดการกับ URL ต่อไป เอาหละเมื่อเพิ่มเสร็จแล้ว สั่งรัน npm start ใหม่อีกรอบแล้วเข้าไปดูผลลัพธ์กัน

Pages Unstyle

ห่วยชะมัด! ไม่มีความสวยเอาซะเลย เรามาเพิ่มสไตล์ให้ตารางกันเถอะ

Global CSS ใครว่าไม่จำเป็น

Pages.js ของเราใช้ table ในการจัดเรียงข้อมูล ถ้าเราจะจัดสไตล์ให้ตารางเพื่อให้มีสีสันที่ดูเก๋ไก๋ด้วย Local CSS ตามที่อธิบายไปในตอนก่อนหน้า ดังข้างล่างนี้ จะดีไหม?

JavaScript
1// Pages.js
2import React, { Component } from 'react'
3// แยกสไตล์ไว้ที่ไฟล์ Pages.scss แล้วดึงเข้ามาใช้
4import styles from './Pages.scss'
5
6class Pages extends Component {
7 render() {
8 // ใส่สไตล์ไว้กับคลาส .table นำมาใช้กัน table tag
9 return <table className={styles['table']}>...</table>
10 }
11}
12
13export default Pages

คำตอบคืออยู่ที่การใช้งานครับ ถ้าตารางของหน้า Pages ไม่เหมือนกับตารางในหน้าอื่น วิธีนี้สมเหตุสมผลเพราะ Local CSS จะประยุกต์ใช้กับแค่คอมโพแนนท์เดียว แต่ถ้าคุณอยากให้ CSS ที่มีคลาสชื่อ .table มีผลเหมือนกันในทุกๆหน้าเพจหละ? ถ้าคุณทำวิธีนี้ไม่สมเหตุสมผลแน่ เพราะคุณต้องมานั่งสร้างไฟล์และกำหนดสไตล์ของคลาส .table ให้กับทุกๆคอมโพแนนท์ที่มีการใช้ตาราง จริงไหม?

ProTips! ทำให้มั่นใจว่าคุณใส่ module: true เพื่อเปิดใช้งาน Local CSS แล้ว มิเช่นนั้นการสั่ง import สไตล์เข้ามาโดยปราศจากออฟชันนี้ จะมีผลทำให้สไตล์ของคุณประยุกต์ใช้กับทุกๆเพจ

ทางออกของเราคือใช้ Global CSS คือสร้าง CSS ไว้แชร์ร่วมกันในทุกๆคอมโพแนนท์ เพื่อที่จะแบ่งแยกให้ชัดเจนผมจึงสร้างโฟลเดอร์ชื่อ theme ขึ้นมาภายใต้โฟลเดอร์ ui เพื่อไว้จัดเก็บ CSS โดยเฉพาะ จากนั้นจึงสร้างไฟล์ elements.scss แล้วใส่สไตล์สำหรับทุกสรรพสิ่งที่อยากใช้ร่วมกันในทุกคอมโพแนนท์ดังนี้

SASS
1// ui/theme/elements.scss
2
3$border-style: 1px solid #cbcbcb;
4
5:global {
6 body {
7 font-size: 16px;
8 }
9
10 .container {
11 width: 50%;
12 margin: 0 auto;
13 }
14
15 // Table
16 .table {
17 border-collapse: collapse;
18 border-spacing: 0;
19 empty-cells: show;
20 border: $border-style;
21
22 td,
23 th {
24 border-left: $border-style;
25 border-width: 0 0 0 1px;
26 font-size: inherit;
27 margin: 0;
28 overflow: visible;
29 padding: 0.5rem 1rem;
30 }
31
32 thead {
33 background-color: #e0e0e0;
34 color: #000;
35 text-align: left;
36 vertical-align: bottom;
37 }
38 }
39}

จุดสังเกตคือเราครอบ selector ทุกตัวที่ต้องการแชร์ด้วย :global ครับเพื่อทำให้มันเป็น Global CSS นั่นเอง

บทความนี้ผมจะไม่อธิบายถึงการเขียน SCSS นะครับ เพื่อนๆคนไหนอ่านแล้วงง ข้ามไปได้เลยครับ เพราะเราต้องการพุ่งจุดสนใจไปที่ React เท่านั้น แต่ไหนๆก็ไหนๆแล้ว เมื่อเห็นอะไรมันขัดตาก็ขอแก้ให้มันดูดีอีกซักนิดนึง

เพื่อนๆน่าจะเห็นนะครับว่าเรามีการใช้สีคือ #e0e0e0 #000 และ #cbcbcb ซึ่งคนเขียนโค๊ดอย่างเรากลับมาอ่านทีหลังก็งงอยู่ดี เห้ยไอ้ #e0e0e0 นี่สีอะไรวะ นอกจากนี้แล้วเชื่อผมเถอะ ถ้าคุณทำเว็บคุณควรคุมโทนสีทั้งเว็บให้เหมือนกัน ฉะนั้นแล้วไม่ว่าจะเป็น #e0e0e0 #000 หรือ #cbcbcb ย่อมมีโอกาสนำไปใช้ซ้ำอีกหลายๆที่ในโค๊ดของคุณ

เมื่อเป็นเช่นนี้ผมจึงแนะนำให้เพื่อนๆแยกสีออกเป็นตัวแปรในไฟล์ _variables.scss ภายใต้โฟลเดอร์ ui/theme ดังนี้

SASS
1$gray1-color: #cbcbcb;
2$gray2-color: #e0e0e0;
3
4$dark-gray1-color: #2d3e50;
5
6$green1-color: #18d8a9;
7
8$white-color: #fff;
9$black-color: #000;

ProTips! เมื่อโปรเจคคุณใหญ่ขึ้น คุณไม่ควรประกาศตัวแปรทั้งจักรวาลมาไว้ในไฟล์ _variables.scss ตัวเดียว คุณควรแยกไฟล์ให้เหมาะกับหน้าที่ เช่น _colors.scss สำหรับเก็บสี _fonts.scss สำหรับจัดการกับฟอนต์

สุดท้ายนี้ elements.scss ของคุณยังไม่ได้รับการประยุกต์ใช้เป็น Global CSS หรอกครับ จนกว่าคุณจะสั่งให้มันเป็น จำเรื่อง entry ของ Webpack กันได้ไหมเอ่ย นั่นหละฮะท่านผู้ชม ถ้าคุณอยากให้มันประยุกต์ใช้โดยตรง แค่เพิ่มมันไปใน entry ทุกอย่างก็เรียบร้อย แก้ไขไฟล์ webpack.config.js ดังนี้

JavaScript
1...
2module.exports = {
3 devtool: 'eval',
4 // สังเกตนะครับ ตอนนี้เราใช้ array แทน './ui/theme/elements.scss'
5 // เพราะต้องการให้ entry ของเรามาจากไฟล์มากกว่า 1 ตัว
6 // ยังจำกันได้ไหมเอ่ย entry ตัวนี้แม้เราไม่ใส่ชื่อ แต่จริงๆแล้วมันมีชื่อว่า main
7 // ห๊ะ ผมยังไม่ได้พูดถึงหรอ! ไม่เป็นไรครับเจอกันบทความสุดท้ายของชุดบทความนี้
8 entry: [
9 './ui/theme/elements.scss',
10 './ui/index.js'
11 ]
12 ...
13}

นอกจากแบ่งแยกสไตล์ที่ใช้ร่วมกันออกเป็น Global CSS แล้ว คุณยังควรใช้ Global CSS กับ third-party library อย่าง Bootstrap และ Font-awesome ด้วยเช่นกัน เพื่อไม่ให้มี local css ของไลบรารี่เหล่านี้ในหลายๆคอมโพแนนท์อันมีผลทำให้ขนาดไฟล์คุณใหญ่ขึ้น

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

UI Structure

กลับไปที่ http://127.0.0.1:8080/pages อีกครั้ง รอบนี้คุณควรเห็นตารางที่สวยงามเช่นนี้ได้แล้ว

Pages with Styles

ก่อนจะจบหัวข้อนี้ผมขอจัดสไตล์ให้กับส่วนหัวของเพจหน่อยครับ เราคงไม่อยากให้มันโชว์แค่คำว่า Navbar หรอกเนอะ

เนื่องจาก navbar นั้นเป็นส่วนหนึ่งของ App อย่าลืมนะครับตอนนี้เราใช้คอมโพแนนท์ App เป็นเสมือนตัววาง layout ให้เพจเรา ดังนั้น Header ของเราก็ควรเป็นส่วนหนึ่งของ App ด้วย แต่ถ้าเพื่อนๆดูโฟลเดอร์ components ตอนนี้จะพบว่าเรามีไฟล์ชื่อ App.js อยู่แล้ว ถ้าเราจะวางไฟล์ชื่อ Header.js เพิ่มไปอีกไฟล์ มันจะทำให้โฟลเดอร์เราไม่เป็นสัดส่วน จะกลายเป็นว่า Header นั้นมีศักดิ์หรือระดับเท่า App เพราะอยู่ในระดับชั้นเดียวกันในโฟลเดอร์ ทั้งๆที่จริงๆแล้วมันเป็นแค่ส่วนหนึ่งของ App แค่นั้นเอง

เพื่อไม่ให้เกิดความเข้าใจผิดจากลำดับชั้นของโครงสร้างโฟลเดอร์ เราจึงควรสร้างโฟล์เดอร์ชื่อ App ไว้ภายใต้ components แล้วจึงนำทั้ง App.js และ Header.js ไปใส่ไว้ในนั้น เอาหละครับเรามาสร้าง App.scss Header.js และ Header.scss ตามนี้กันครับ

ส่วนของคอมโพแนนท์เป็นดังนี้

JavaScript
1// ui/components/App/Header.js
2import React, { Component } from 'react'
3import styles from './Header.scss'
4
5export default class Header extends Component {
6 render() {
7 return (
8 <header className={styles['header']}>
9 <nav>
10 <a href="/" className={styles['brand']}>
11 Babel Coder Wiki!
12 </a>
13 <ul className={styles['menu']}>
14 <li className={styles['menu__item']}>
15 <a href="/pages" className={styles['menu__link']}>
16 All Pages
17 </a>
18 </li>
19 <li className={styles['menu__item']}>
20 <a href="#" className={styles['menu__link']}>
21 About us
22 </a>
23 </li>
24 </ul>
25 </nav>
26 </header>
27 )
28 }
29}
30
31// ui/components/App/App.js
32// ปรับปรุงโค๊ดเพื่อเรียกใช้ Header
33// เรากำลังต่อ LEGO กันอยู่ครับ ต่อไปนี้เราจะเอาไม่ทุกอย่างมาไว้ในไฟล์เดียวอีกแล้ว
34// Header เป็นตัวต่ออีกตัวที่เราต้องแยกออกไป แล้วนำมาประกบกับตัวหลักคือ App
35
36import React, { Component } from 'react'
37import Header from './Header'
38import styles from './App.scss'
39
40export default class App extends Component {
41 render() {
42 return (
43 <div>
44 <Header />
45 <div className="container">
46 <div className={styles['content']}>{this.props.children}</div>
47 </div>
48 </div>
49 )
50 }
51}

สุดท้ายเราจึงใส่สไตล์เข้าไปให้ทั้ง App และ Header ดังนี้

SASS
1// ui/components/App/App.scss
2.content {
3 margin-top: 4rem;
4}
5
6// ui/components/App/Header.scss
7@import '../../theme/variables';
8
9.header {
10 background: $dark-gray1-color;
11 padding: 1rem;
12 width: 100%;
13 position: fixed;
14 left: 0;
15 top: 0;
16 box-sizing: border-box;
17}
18
19.brand {
20 color: $white-color;
21}
22
23.menu {
24 float: right;
25 display: inline-block;
26 list-style: none;
27 margin: 0;
28 padding: 0;
29
30 &__item {
31 display: inline-block;
32 vertical-align: middle;
33 }
34
35 &__link {
36 padding: 1rem;
37 color: $green1-color;
38 }
39}

คารวะ react-router helpers มือฉมังด้านเส้นทาง

ตอนนี้เมื่อคุณเข้าเพจแล้วคลิกลิงก์บนส่วนหัวเช่นคลิกที่ All Pages สิ่งที่คุณสังเกตได้คือหน้าจอคุณจะกระพริบไปแปปนึง ทำไมจึงเป็นเช่นนี้? เพราะในโค๊ดของเราบอกว่า <a href='/pages'... คือให้ไปที่ pages โดยโหลดเพจใหม่นั่นเอง! แต่ถ้าคุณใช้ react-router ช่วยเปลี่ยน path ให้เรื่องแบบนี้จะไม่เกิดขึ้น เพราะเป็นการกระทำภายใต้ browserHistory (ยังจำได้ไหม) ทำให้คุณไม่ต้องโหลดเพจใหม่ เป็นเพียงการเปลี่ยนสถานะหรือ state ของ location ของ history มึนหละซิ? ไปเรียนรู้การใช้งานจริงเลยแล้วกัน

react-router นั้นมีคอมโพแนนท์หลายตัวที่ช่วยเหลือคุณในการจัดการเกี่ยวกับเส้นทาง หนึ่งในนั้นคือ Link ที่คุณสามารถเรียกใช้เพื่อเปลี่ยนไป path อื่นในเพจของคุณ เปิดไฟล์ Header.js แล้วเอา a tag ทั้งหลายออกเปลี่ยนไปใช้ Link แทนดังนี้

JavaScript
1import React, { Component } from 'react'
2// import Link เข้ามาก่อนนะ
3import { Link } from 'react-router'
4import styles from './Header.scss'
5
6export default class Header extends Component {
7 render() {
8 return (
9 <header className={styles['header']}>
10 <nav>
11 <Link
12 {/* ระบุ path ปลายทาง */}
13 to={{ pathname: '/' }}
14 className={styles['brand']}>
15 Babel Coder Wiki!
16 </Link>
17 <ul className={styles['menu']}>
18 <li className={styles['menu__item']}>
19 <Link
20 to={{ pathname: '/pages' }}
21 className={styles['menu__link']}>
22 All pages
23 </Link>
24 </li>
25 <li className={styles['menu__item']}>
26 <a
27 href='#'
28 className={styles['menu__link']}>
29 About us
30 </a>
31 </li>
32 </ul>
33 </nav>
34 </header>
35 )
36 }
37}

ข้อสังเกตเพิ่มเติมนะครับ pathname ที่เราใส่เข้าไปต้องเป็น route ที่มีอยู่จริงและประกาศไว้ใน routes.js

JavaScript
1// routes.js
2export default () => {
3 return (
4 <Router history={browserHistory}>
5 <Route path='/' {/* จับคู่กับ pathname: '/' ใน Link */}
6 component={App}>
7 <IndexRoute component={Home} />
8 <route path='pages' {/* จับคู่กับ pathname: '/pages' ใน Link */}
9 component={Pages} />
10 </Route>
11 </Router>
12 )
13}

ProTips! ผมเห็นหลายคนยังใช้ <a href='/'>Home</a> เพื่อวิ่งกลับไปหน้าโฮมเพจ มันไม่ผิดหรอกครับ แต่มันไม่ได้ประโยชน์อะไรจากการใช้ react-router เลย คุณสูญเสียการควบคุมสถานะของ history สุญเสีย UX ที่ทำให้ผู้ใช้งานอาจหงุดหงิดที่เห็นหน้าจอกระพริบไปนิดนึง มันก็แค่นั้นเอง!

ตอนนี้หน้า /pages ของเราแสดง wiki หนึ่งรายการที่ได้จากการยัดเข้าไปตรงๆ ในความเป็นจริงแล้วเราต้องการข้อมูลจาก API server มาแสดงผลในส่วนนี้ต่างหาก คำถามคือแล้วเราจะเอาโค๊ดที่ดึงข้อมูลจากเซิร์ฟเวอร์มาใส่ตรงไหนของคอมโพแนนท์เราดี?

ดำดิ่งสู่วงจรชีวิตของ React Component

กระบวนการของ React ตั้งแต่เริ่มสร้างคอมโพแนนท์ (initiated) จนกระทั้งถึงนำคอมโพแนนท์นั้นไปแสดงผล (render) ในแต่ละขั้นตอนนั้นมีเมธอดที่จะโดนเรียกขึ้นมาทำงาน เราเรียกกลุ่มของเมธอดนี้ว่า callback ในเบื้องต้นนี้ผมขอนำเสมอ callback กลุ่มที่ง่ายที่สุดในช่วงชีวิตการทำงานของ React ก่อนตามแผนภาพด้านล่าง

Lifecycle1

เมื่อ React สร้าง instant ของคอมโพแนนท์เราขึ้นมาใช้งานแล้ว มันจะเรียก callback ตัวแรกคือ getDefaultProps เมธอดตัวนี้ทำหน้าที่ตั้งค่าเริ่มต้นให้กับ property ของคอมโพแนนท์ในกรณีที่ไม่ได้ส่ง property นั้นเข้ามาในคอมโพแนนท์

JavaScript
1class HelloWorld extends Component {
2 // ถ้าเพื่อนๆใช้ React.createClass ตรงนี้ต้องเป็น getDefaultProps
3 // เนื่องจากตลอดชุดบทความนี้ผมใช้ ES2015 + ES7
4 // เราจึงลืมสิ่งที่ผมไม่ได้พูดถึงไปดีกว่า แล้วใช้แค่ defaultProps แทน
5 static defaultProps = {
6 message: 'ชาวโลก',
7 }
8
9 render() {
10 return <h1>สวัสดี {this.props.message}</h1>
11 }
12}
13
14ReactDOM.render(<HelloWorld />, document.getElementById('app'))

จากตัวอย่างข้างบน เพื่อนๆจะพบว่าผมไม่ได้ส่ง message เข้าไปในคอมโพแนนท์ HelloWorld เลย แต่กระนั้นมันก็ยังคงแสดงข้อความออกมาเป็น สวัสดี ชาวโลก ทั้งนี้เพราะเรานิยามมันไว้ใน defaultProps จุดสังเกตที่ต้องการเน้นคือคำว่า static นั่นหมายความว่าต่อให้คุณสร้าง instance ของคอมโพแนนท์ HelloWorld ซักกี่ร้อยรอบ React ก็จะจำสิ่งที่อยู่ใน defaultProps ไว้แค่ครั้งเดียว!

เมื่อจบกระบวนการของ getDefaultProps แล้ว สิ่งต่อไปที่จะทำคือ getInitialState ตรงนี้ผมขอข้ามไปก่อนนะครับเพราะเรายังไม่ได้คุยกันเรื่อง state เลย ข้ามไปดูตัวถัดไปเลยดีกว่า

เราทราบกันแล้วว่าทุกคอมโพแนนท์ต้องมีเมธอด render โดยเมธอดนี้นิยามสิ่งที่จะเอาไปแสดงผลไว้ นั่นแปลว่าเมื่อไหร่ที่ render โดนเรียกมันคือสถานะที่พร้อมแสดงผลแล้ว เมื่อเป็นเช่นนี้เราจึงมีเมธอดที่เป็น callback อีกตัวไว้บอกว่าใกล้จะแสดงผลแล้วนะ และเจ้าเมธอดตัวนี้หละครับคือ componentWillMount ถ้าเราอยากตั้งค่าสถานะ (state) ให้กับคอมโพแนนท์ก่อนที่มันจะแสดงผล เราก็โยนมันใส่ไว้ใน callback ตัวนี้ได้

สุดท้ายเมื่อ render โดนเรียกไปแล้ว คือพร้อมจะแสดงผลสุดๆ React ก็จะจับมันยัดเยียดเข้าไปใน DOM นั่นไงหละ แสดงว่าตอนนี้มันพร้อมให้เราละเลงมันได้เต็มที่แล้ว นึกถึงสมัยก่อนเราใช้ jQuery เพื่อจัดการกับ DOM เราก็ต้องรอให้ element เหล่านั้นมีอยู่ก่อนใช่ไหมครับ เพื่อเป็นการการันตีว่าคอมโพแนนท์ของเราไปเสนอหน้าอยู่บน DOM อย่างสมบูรณ์แล้ว componentDidMount จึงเกิดมาเพื่อสิ่งนี้ โดยเราเรียกกระบวนการที่ React ฉายคอมโพแนนท์ของเราไปอยู่ใน DOM ว่า mounting

เริ่มดึงข้อมูลจาก Restful API มาใช้งาน

เมื่อเราเข้าถึง /pages เราคาดหวังว่าคอมโพแนนท์ Pages ของเราจะไปดึงข้อมูลมาจาก http://127.0.0.1:5000/api/v1/pages ซึ่งเป็น API ของเราเอง ผมขอถามเพื่อนๆเล่นๆว่าถ้าเป็นคุณจะโหลดข้อมูลจากเซิร์ฟเวอร์ก่อนหรือหลังคอมโพแนนท์ปรากฎบน DOM แล้วดี? ไม่ต้องคิดมากครับ React แนะนำให้เราสร้าง AJAX request ไว้ใน componentDidMount

เราจะใช้ fetch ซึ่งเป็นมาตรฐานในการดึงข้อมูลด้วย AJAX มาใช้งานกัน และเพื่อให้มันสามารถใช้ได้ทั้ง client และ server (server-side rendering ในบทความที่4) เราจึงใช้ isomorphic-fetch ติดตั้งกันเลยครับ

Code
1npm i --save isomorphic-fetch

จากนั้นเรามาเปลี่ยนโค๊ด Pages.js ของเราเพื่อให้โหลดข้อมูลจากเซิร์ฟเวอร์ดังนี้

JavaScript
1// ui/components/Pages.js
2import React, { Component } from 'react'
3import fetch from 'isomorphic-fetch'
4
5class Pages extends Component {
6 state = {
7 pages: [],
8 }
9
10 componentDidMount() {
11 fetch('http://127.0.0.1:5000/api/v1/pages')
12 .then((response) => response.json())
13 .then((pages) => this.setState({ pages }))
14 }
15
16 render() {
17 return (
18 <table className="table">
19 <thead>
20 <tr>
21 <th>ID</th>
22 <th>Title</th>
23 <th>Action</th>
24 </tr>
25 </thead>
26 <tbody>
27 {this.state.pages.map((page) => (
28 <tr key={page.id}>
29 <th>{page.id}</th>
30 <td>{page.title}</td>
31 <td>
32 <a href="javascript:void(0)">Show</a>
33 </td>
34 </tr>
35 ))}
36 </tbody>
37 </table>
38 )
39 }
40}
41
42export default Pages

ช้าก่อน รู้นะว่าจะปิดบราวเซอร์หนีแล้ว! อยู่อ่านเป็นเพื่อนกันก่อนครับ แม้มันจะดูยาวแต่มันก็เข้าใจได้ง่ายนะ ลองมาไล่ดูกัน เริ่มจาก...

JavaScript
1componentDidMount() {
2 fetch('http://127.0.0.1:5000/api/v1/pages')
3 .then((response) => response.json())
4 .then((pages) => this.setState({ pages }))
5}

ตามที่ผมบอกก่อนหน้านี้แล้ว React แนะนำให้เราเรียก AJAX ภายในเมธอด componentDidMount สำหรับโค๊ดของเราเรียกเพื่อดึง wiki ทั้งหมดจาก /api/v1/pages จากนั้นจึงแปลงก้อนข้อมูลที่ได้จากเซิร์ฟเวอร์เป็น json สุดท้ายเราจึง... setState? อะไรอะ ไม่เข้าใจ = ="

เรารู้จัก props แล้วว่ามันคือ property ที่ส่งผ่านเข้ามาในคอมโพแนนท์ props และ state นั้นเหมือนกันตรงที่เราสามารถนำมันไปแสดงผลใน render ได้ทั้งคู่ แต่สิ่งที่แตกต่างคือ props นั้นจะส่งมาจากข้างนอกคอมโพแนนท์และจะกลายเป็นค่าติดตัวของคอมโพแนนท์นั้นตลอดไป โดยตัวคอมโพแนนท์เองไม่สามารถแก้ไขค่า props เองได้ ผิดกับ state ที่ไม่ได้ส่งมาจากภายนอกแต่เป็นสถานะของตัวคอมโพแนนท์เอง คอมโพแนนท์จึงสามารถเปลี่ยนค่าสถานะของตัวเองได้ตลอดเวลา

เปรียบ props เหมือนการมีบุตรครับ ลูกเรานั้นได้รับรหัสพันธุกรรมมาจากทั้งพ่อและแม่ซึ่งแน่นอนว่าเป็นค่าถาวรเปลี่ยนแปลงไม่ได้ และเพราะมันเปลี่ยนแปลงไม่ได้ที่สำคัญคือมาจากภายนอก (จากพ่อและแม่) มันจึงเป็น property ของลูก เมื่อสายสะดือหลุดลูกก็เป็นอิสระจากแม่สามารถยืน เดิน กิน นอนได้เองแล้ว สิ่งนี้คือ state หรือสถานะที่ลูกกำหนดเองได้ เปลี่ยนแปลงและแก้ไขเองได้ เข้าใจมากขึ้นไหมครับ?

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

เอาหละ ขยับไปที่ render กันบ้างกับโค๊ดชุดนี้

JavaScript
1render() {
2 return (
3 <table className='table'>
4 <thead>
5 <tr>
6 <th>ID</th>
7 <th>Title</th>
8 <th>Action</th>
9 </tr>
10 </thead>
11 <tbody>
12 {
13 this.state.pages.map((page) => (
14 <tr key={page.id}>
15 <th>{page.id}</th>
16 <td>{page.title}</td>
17 <td>
18 <a href='javascript:void(0)'>Show</a>
19 </td>
20 </tr>
21 ))
22 }
23 </tbody>
24 </table>
25 )
26}

ให้ความสนใจกับบรรทัดที่13-21ครับ เมื่อไหร่ก็ตามที่ข้อมูลจากเซิร์ฟเวอร์มาถึง มันจะไปอยู่ใน this.state.pages ถ้าย้อนกลับขึ้นไปดูในหัวข้อของ json-server จะพบว่าข้อมูลก้อนนี้เป็น array เราจึงใช้ map เพื่อรวมก้อนข้อมูลนี้เป็น tr tag สมมติ pages ของเรามีสามค่าผลลัพธ์จากการประมวลผลนี้ก็จะเป็น

HTML
1<tbody>
2 <tr>
3 <th>1</th>
4 <td>title#1</td>
5 <td><a href="javascript:void(0)">Show</a></td>
6 </tr>
7
8 <tr>
9 <th>2</th>
10 <td>title#2</td>
11 <td><a href="javascript:void(0)">Show</a></td>
12 </tr>
13
14 <tr>
15 <th>3</th>
16 <td>title#3</td>
17 <td><a href="javascript:void(0)">Show</a></td>
18 </tr>
19</tbody>

ผมไม่แน่ใจว่าเพื่อนๆจะลืมกันหรือยัง จำได้ไหมเอ่ยอะไรที่ประมวลผลด้วย JavaScript ก่อนต้องครอบด้วย {} นะ นั่นคือเหตุผลที่บรรทัดที่13-21ต้องอยู่ข้างใน {}

ในกระบวนการแสดงผลของ React เมธอด render ไม่ได้โดนเรียกแค่ครั้งเดียวครับ หาก props หรือ state มีการเปลี่ยนแปลงแล้ว เมธอด render ย่อมมีแนวโน้มจะเรียกอีกครั้ง เช่น หากครั้งถัดไปสถานะ pages ของเราเปลี่ยนไป React อาจเรียก render อีกครั้ง คำถามคือเมื่อ pages นั้นเปลี่ยนแปลง React จะทำอย่างไรกับแท็ก tr ที่ใช้แสดงผลแต่ละ wiki? นั่นหละครับเป็นเหตุผลที่ว่าทำไมเราถึงต้องมี key ในบรรทัดที่14 React จะพิจารณาจาก key ว่าควรจะคง tr ตัวไหนไว้บ้าง ลบมันถึง หรือจะจัดลำดับมันใหม่ดี ที่เราใช้ page.id เป็น key เพราะว่าเราต้องการให้ key ของเรานั้นไม่ซ้ำกับ wiki เพจอื่น React จะใช้ความไม่ซ้ำนี้เป็นตัวระบุตัวตนครับ

เหมือนทุกอย่างจะไปได้สวยใช่ไหมครับ แต่เราอย่าลืมนะว่าการเรียก AJAX เป็นโค๊ดแบบ Asynchronous คือเราบอกลำดับมันไม่ได้ว่าเซริฟเวอร์ของเราจะส่งค่ากลับมาตอนไหน เมื่อยังไม่มีค่าจากเซิร์ฟเวอร์ this.state.pages.map จะเอ๋อทันที this.state.pages จะไม่มีค่าทำให้เราเรียก map ต่อไม่ได้ JavaScript Interpreter ก็จะโวยวายแหกปาก error ออกมา

เพื่อเป็นการป้องกันไม่ให้สิ่งนี้เกิดขึ้น เราจึงต้องมีค่าเริ่มต้นให้กับ state ของเรา และนี่หละครับคือ getInitialState callback ที่ผมค้างเพื่อนๆไว้ เพียงแต่ว่า getInitialState นั้นใช้กับ React.createClass แต่พวกเรานั้นใหม่และสดกว่า เราจึงประกาศมันด้วยการใช้ state แทนแบบนี้

JavaScript
1state = {
2 pages: [],
3}

ย่อยคอมโพแนนท์ชิ้นโตเป็น subcomponents

ยังจำที่ผมบอกตอนต้นบทความได้ไหมครับ คอมโพแนนท์ที่ดีนั้นควรทำหน้าที่เดียว คอมโพแนนท์ Pages ของเรานอกจากมีหน้าที่แสดงผล table ที่เป็นตัวแทนของหน้า wiki ทั้งหมดแล้วยังแสดงผลแต่ละหน้า wikiเองอีกด้วย ฉะนั้นแล้วเราต้องแยกโค๊ดส่วน <tr> ที่ใช้แสดง wiki แต่ละหน้าออกเป็นอีกคอมโพแนนท์ ผมขอสร้างไฟล์ใหม่ชื่อ Page.js พร้อมทั้งแก้ไขไฟล์ Pages.js ดังนี้

JavaScript
1// Pages.js
2...
3...
4<tbody>
5 {
6 this.state.pages.map((page) => (
7 {/* ย้ายโค๊ดของ wiki แต่ละหน้าไปไว้ที่ Page */}
8 {/* อย่าลืมใส่ key ให้กับ Page ด้วยครับ */}
9 {/* ส่ง page เข้าไปเป็น property ของคอมโพแนนท์ Page ในชื่อของ page */}
10 <Page
11 key={page.id}
12 page={page} />
13 ))
14 }
15</tbody>
16...
17...
18
19// Page.js
20import React, { Component } from 'react'
21
22export default class Page extends Component {
23 render() {
24 // เมื่อเราส่ง page เข้ามาจากภายนอกมันจึงปรากฎเป็น props ของคอมโพแนนท์
25 // และเป็นค่าถาวรที่แก้ไขไม่ได้
26 const { id, title } = this.props.page
27
28 return (
29 <tr>
30 <th>{id}</th>
31 <td>{title}</td>
32 <td>
33 <a href='javascript:void(0)'>Show</a>
34 </td>
35 </tr>
36 )
37 }
38}

นั่นละฮะท่านผู้ชม โค๊ดของเราดูสะอาดขึ้นเยอะเลยครับ แต่... (ผมว่าต้องมีผู้อ่านบ้างคนคิดแน่ๆว่าไอ้นี่จะแต่... อะไรเยอะแยะ)

PropTypes คู่หูกู้โลก

ย้อนกลับไปดู HelloWorld กันหน่อย ไม่ได้พูดถึงนานแล้วคิดถึง

JavaScript
1class HelloWorld extends Component {
2 render() {
3 const { fullName, birthday } = this.props
4
5 return (
6 <div>
7 <h1>สวัสดีชาวโลก ผมชื่อ {fullName}</h1>
8 <time datetime={birthday.toISOString()}>
9 {birthday.toLocaleDateString()}
10 </time>
11 </div>
12 )
13 }
14}

เพื่อนๆคิดว่าถ้าตอนนี้ผมเมาเหล้าอยู่ เขียนโปรแกรมไม่รู้เรื่อง เลยเผลอเรียก HelloWorld แบบมั่วๆดังข้างล่างนี้แล้วอะไรจะเกิดขึ้น?

JavaScript
1<HelloWorld birthday="วันนี้เนี่ยแหละเว้ยถามอยู่ได้ ปัดโถ่!" />

ใช่ครับคอมโพแนนท์พังพินาศ เพราะบรรทัดที่8 เราบอกว่าให้เรียก toISOString ซึ่งมันเป็นฟังก์ชันของ date แต่เราดันส่ง string เข้าไปหนะซิ นอกจากนี้คอมโพแนนท์นี้คงน่าสงสารมาก เราไม่ส่ง fullName ให้มัน แล้วมันจะไปเซย์ฮัลโหลกับใคร?

ใน development เราสามารถตรวจสอบค่าที่ส่งเข้ามาเป็น props ได้ครับ เพื่อกันความเสี่ยงที่เราจะไม่ส่งค่าหรือส่งค่าผิด และนั่นหละครับคือ PropTypes ที่ผมจะพูดถึงในหัวข้อนี้ ต้องบอกไว้ก่อนเลยครับว่า PropTypes นั้นตรวจสอบ props ของเราแค่ใน development เท่านั้น มันไม่ตามคุณไปถึง production ด้วยหรอกนะ

เพื่อสนองความเมา เรามาแก้ HelloWorld กัน

JavaScript
1class HelloWorld extends Component {
2 static propTypes = {
3 fullName: PropTypes.string.isRequired,
4 birthday: PropTypes.instanceOf(Date).isRequired,
5 }
6
7 render() {
8 const { fullName, birthday } = this.props
9
10 return (
11 <div>
12 <h1>สวัสดีชาวโลก ผมชื่อ {fullName}</h1>
13 <time datetime={birthday.toISOString()}>
14 {birthday.toLocaleDateString()}
15 </time>
16 </div>
17 )
18 }
19}

ตอนนี้ถ้าเราเรียกใช้คอมโพแนนท์มันจะแหกปากโวยวายออกมาว่า

Code
1Warning: Failed propType: Required prop `fullName` was not specified in `HelloWorld`.
2Warning: Failed propType: Invalid prop `birthday` of type `String` supplied to `HelloWorld`, expected instance of `Date`.

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

ย้อนกลับมาที่เพจของเรา Pages เรียก Page ดังนี้

JavaScript
1<Page key={page.id} page={page} />

การเรียกแบบนี้ผมถือว่าเป็นงานหยาบ นั่นเพราะคุณจะคาดหวังให้ Page ตรวจสอบ Proptypes ว่าอย่างไร? ให้มันตรวจสอบว่า page ที่ส่งเข้ามาต้องเป็น object อย่างนั้นหรือ? ถ้างั้นผมส่ง object แบบนี้เข้าไปก็ยังถูกอยู่ใช่ไหม

JavaScript
1<Page key={page.id} page={{ slug: 'kiki', content: 'kuku' }} />

เห็นไหมครับ การตรวจสอบว่ามันเป็นออบเจ็กต์ในกรณีนี้จะมีปัญหา เราคาดหวังว่า page ต้องเป็นออบเจ็กต์ก็จริง แต่ออบเจ็กต์นั้นต้องประกอบด้วย title และ id เท่านั้น!

เพื่อให้การส่ง props มีประสิทธิภาพมากขึ้น ผมจึงแนะนำเพื่อนๆให้ส่งเช่นนี้แทน

JavaScript
1<Page key={page.id} id={page.id} title={page.title} />

แก้ไข Pages.js และ Page.js ของเรากันอีกครั้งดังนี้

JavaScript
1// Pages.js
2...
3...
4<tbody>
5 {
6 this.state.pages.map((page) => (
7 <Page
8 key={page.id}
9 id={page.id}
10 title={page.title} />
11 ))
12 }
13</tbody>
14...
15...
16
17// Page.js
18// import PropTypes เข้ามาก่อนครับ
19import React, { Component, PropTypes } from 'react'
20
21export default class Page extends Component {
22 static propTypes = {
23 id: PropTypes.number.isRequired,
24 title: PropTypes.string.isRequired
25 }
26
27 render() {
28 const { id, title } = this.props
29
30 return (
31 <tr>
32 <th>{id}</th>
33 <td>{title}</td>
34 <td>
35 <a href='javascript:void(0)'>Show</a>
36 </td>
37 </tr>
38 )
39 }
40}

จากการแก้ไขครั้งนี้ เมื่อมีการส่ง props เข้ามาในคอมโพแนนท์ Page จะมีการตรวจสอบว่า id มีหรือไม่และเป็นตัวเลขรึเปล่า เช่นเดียวกัน title ก็จะได้รับการตรวจสอบว่ามีส่งเข้ามาหรือไม่และเป็นข้อความหรือเปล่า ตัวเลือกในการใช้ PropTypes นั้นมีมากผมขอไม่แสดงตัวอย่างมากไปกว่านี้ เพื่อนๆคนไหนสนใจเพิ่มเติมจิ้มลิงก์นี้โดยพลัน

เพื่อนๆอ่านกันเหนื่อยหรือยังครับ เราวนเวียนอยู่กับ Pages และ Page กันมาหลายบรรทัดแล้ว แต่เชื่อเถอะมันยังไม่จบง่ายๆ มีอะไรให้เล่นอีกเยอะ เริ่มจาก...

Presentational Components และ Container Components

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

JavaScript
1// Pages.js
2export default class Pages extends Component {
3 state = {
4 pages: []
5 }
6
7 componentDidMount() {
8 fetch('http://127.0.0.1:5000/api/v1/pages')
9 .then((response) => response.json())
10 .then((pages) => this.setState({ pages }))
11 }
12
13 render() {
14 return <SharedPages pages={this.state.pages} />
15}
16
17// PopularPages.js
18export default class PopularPages extends Component {
19 state = {
20 pages: []
21 }
22
23 componentDidMount() {
24 // URL ไม่เหมือนกันครับ
25 fetch('http://127.0.0.1:5000/api/v1/pages?popular=true')
26 .then((response) => response.json())
27 .then((pages) => this.setState({ pages }))
28 }
29
30 render() {
31 return <SharedPages pages={this.state.pages} />
32}
33
34// SharedPages.js
35export default class SharedPages extends Component {
36 render() {
37 return (
38 <table>
39 ....
40 ....
41 )
42 }
43}

Perfect! เราแยกส่วนที่ซ้ำกันออกเป็น subcomponent แล้วดีจุง แต่วิธีนี้ยังไม่ได้ทำให้คุณเข้าใจ UI ของคุณดีขึ้น ทำไมผมจึงพูดเช่นนั้น นั่นเป็นเพราะคุณแค่แยก SharedPages ออกมาในฐานะที่เป็นโค๊ดชุดที่ซ้ำกันเฉยๆ ถ้าหากคุณเปลี่ยนมุมมองใหม่ว่าตอนนี้คุณมีคอมโพแนนท์เป็นสองประเภท ประเภทแรกคือคอมโพแนนท์แบบมีสมอง รู้ว่าจะดึงข้อมูลจากเซิร์ฟเวอร์อย่างไร เป็นต้น นี่คือคอมโพแนนท์ประเภทที่หนึ่งแบบมีสมอง กับคอมโพแนนท์อีกประเภทคือคอมโพแนนท์ที่มีหน้าที่แสดงผลอย่างเดียว หรือกล่าวอีกนัยยะว่า ไม่รู้เหมือนกันว่าฉันเกิดมาทำไม แค่มีคนสั่งให้ฉัน render ของแบบนี้ออกไปที่หน้าจอก็พอ ฉันไม่รู้หรอกว่าจะดึงข้อมูลจากเซิร์ฟเวอร์มาอย่างไร ฉันไม่ได้เรียน REST API มาเธอเข้าใจไหม ฉันรู้แค่ว่าข้างบนส่ง props มาแบบนี้ ฉันจึงอยู่ตรงนี้เพื่อ render ตามค่า props นั้น เศร้าเนอะ

คอมโพแนนท์ประเภทแรกที่มีสมองเราเรียกว่า Container Component ส่วนประเภทหลังเราเรียก Presentational Component เมื่อเราแบ่งแยกได้ดังนี้ เราจะได้ประโยชน์มหาศาลกลับมา ไม่ว่าจะเป็นความเข้าใจใน UI ที่มากขึ้น หรือแม้กระทั่งเราสามารถยก presentational component ไปให้ดีไซน์เนอร์แก้ไขโดยปราศจากการแตะ business logic ของเราที่อยู่ใน container component

ดังนั้นเราจะสร้างโฟลเดอร์เพิ่มอีกอันชื่อ containers อยู่ภายใต้ ui จากนั้นสร้างไฟล์ index.js และ Pages.js ดังนี้

JavaScript
1// ui/containers/Pages.js
2import React, { Component } from 'react'
3import fetch from 'isomorphic-fetch'
4// import Pages ที่เป็น Presentational Component มาจากโมดูล components
5import { Pages } from '../components'
6
7export default class PagesContainer extends Component {
8 state = {
9 pages: [],
10 }
11
12 // PagesContainer เป็น Container Component
13 // มันมีสมองเลยรู้จักวิธีการดึงข้อมูลจากเซิร์ฟเวอร์
14 componentDidMount() {
15 fetch('http://127.0.0.1:5000/api/v1/pages')
16 .then((response) => response.json())
17 .then((pages) => this.setState({ pages }))
18 }
19
20 render() {
21 // เรียกใช้ Presentational Component
22 return <Pages pages={this.state.pages} />
23 }
24}
25
26// ui/containers/index.js
27export Pages from './Pages'

จากนั้นเราจึงปรับปรุง Pages.js ใน components ของเราดังนี้

JavaScript
1import React, { Component, PropTypes } from 'react'
2import fetch from 'isomorphic-fetch'
3import Page from './Page'
4
5export default class Pages extends Component {
6 // เมื่อ Pages รับ pages มาจาก container จึงควรเพิ่ม PropTypes
7 // เพื่อการันตีว่าสิ่งที่ส่งเข้ามานั้นต้องเป็นอาร์เรย์และมีค่าเสมอ
8 // ตรวจสอบว่า onReloadPages ต้องเป็นฟังก์ชันหรือ callback นั่นเอง
9 static propTypes = {
10 pages: PropTypes.array.isRequired,
11 onReloadPages: PropTypes.func.isRequired,
12 }
13
14 // ไม่มีการดึงข้อมูลจากเซิร์ฟเวอร์อีกต่อไป
15 // รู้เพียงแค่วิธีการแสดงผล
16 render() {
17 return (
18 <table className="table">
19 <thead>
20 <tr>
21 <th>ID</th>
22 <th>Title</th>
23 <th>Action</th>
24 </tr>
25 </thead>
26 <tbody>
27 {
28 // ตอนนี้มันไม่รู้จัก state แล้ว
29 // รู้แค่ว่ามีคนนอกคอยส่ง props เข้ามาให้
30 this.props.pages.map((page) => (
31 <Page key={page.id} id={page.id} title={page.title} />
32 ))
33 }
34 </tbody>
35 </table>
36 )
37 }
38}

จากนั้นเราก็ไปปรับปรุง routes.js ซะหน่อย เพราะตอนนี้เส้นทางจาก /pages ต้องวิ่งตรงไปหา container component ชื่อ Pages ไม่ใช่ presentational component ที่ชื่อ Pages อีกต่อไป ดังนี้

JavaScript
1...
2...
3import { Pages } from './containers'
4import {
5 App,
6 Home
7} from './components'
8...
9...

ProTips! อย่าตั้งชื่อคอมโพแนนท์ให้เหมือนกันแม้มันจะอยู่กันคนละไฟล์ เนื่องจากเมื่อเกิด error ขึ้นมา React จะบ่นๆใส่ browser console พร้อมทั้งบอกด้วยว่าข้อผิดพลาดที่ว่านี้ มาจากคอมโพแนนท์ตัวไหน หากเรามีชื่อคอมโพแนนท์คือ Pages เหมือนกันสองตัว เราจะดูยากว่า Pages ที่ว่าเนี่ย เป็นตัวที่มาจากไฟล์ไหน

เรียก API อย่างชาญฉลาดด้วย Proxy

มีเพื่อนๆคนไหนรู้สึกเหมือนผมไหมครับ เราเรียก http://127.0.0.1:5000/api/v1/pages เพื่อดึงข้อมูลจากเซิร์ฟเวอร์ ตอนนี้ ui เรารันอยู่ที่พอร์ต 8080 ในขณะที่ API เราใช้พอร์ต 5000 มันให้ความรู้สึกว่าแอพของเราไม่ผสานเป็นหนึ่งเดียว อีกอย่างถ้าผู้ใช้งานระบบแอพเปิด network ดูก็จะรู้ทันทีว่า API server ของเราอยู่ที่พอร์ตอะไรโฮสต์อะไร ดังนั้นเราจึงควรหลีกเลี่ยงการยัดโฮสต์และพอร์ตลงไปตรงๆแบบนี้

เข้าไปที่ webpack.config.js ครับแล้วแก้ไข devServer ของเราเพื่อบอกว่าทุกครั้งที่เรียก /api/* ให้วิ่งตรงไปหา API server

JavaScript
1...
2...
3devServer: {
4 historyApiFallback: true,
5 proxy: {
6 '/api/*': {
7 target: 'http://127.0.0.1:5000'
8 }
9 }
10}
11...
12...

จากนั้นเราก็ไปแก้ Pages.js ของเราให้เหลือแค่นี้

JavaScript
1...
2...
3componentDidMount() {
4 fetch('/api/v1/pages') // ไม่ต้องระบุโฮสต์และพอร์ตอีกต่อไป
5 .then((response) => response.json())
6 .then((pages) => this.setState({ pages }))
7}
8...
9...

อะจัดมาให้หมด ยังมีที่ดีกว่านี้อีกไหม! มีครับ ในแอพพลิเคชันขนาดใหญ่มีแนวโน้มที่จะเรียก endpoint ของ API ที่ซ้ำๆกัน เช่นคุณอาจจะเรียก '/api/v1/pages' ในอีกหลายๆที่ของคอมโพแนนท์อื่นเพื่อดึงข้อมูล wiki ทั้งหมด จินตนาการซักนิดครับ หากวันนึง back-end developer ของเราดันเปลี่ยน endpoint ของเราเป็น '/api/v1/wikis' front-end อย่างไรก็ต้องตามไปแก้ในทุกๆคอมโพแนนท์ที่เรียกใช้ หมดเวลาสนุกแล้วซิ เทเลทับบี้!

สร้างโฟลเดอร์ใหม่ภายใต้ ui ชื่อ constants จากนั้นสร้างไฟล์ชื่อ endpoints.js ไว้ภายใต้มันอีกทีดังนี้

JavaScript
1// ui/constants/endpoints.js
2const API_ROOT = '/api/v1'
3
4// มีค่าเป็น /api/v1/pages
5export const PAGES_ENDPOINT = `${API_ROOT}/pages`

จากนั้นทำการแก้ ui/containers/Pages.js อีกครั้งดังนี้

JavaScript
1import React, { Component } from 'react'
2import fetch from 'isomorphic-fetch'
3// เรียก PAGES_ENDPOINT มาใช้งาน
4import { PAGES_ENDPOINT } from '../constants/endpoints'
5import { Pages } from '../components'
6
7export default class PagesContainer extends Component {
8 state = {
9 pages: [],
10 }
11
12 componentDidMount() {
13 // เมื่อไหร่ก็ตามที่ endpoint เปลี่ยนเราแค่ไปแก้ที่ไฟล์ endpoints.js
14 // ไม่ต้องตามไปแก้ในทุกๆที่
15 fetch(PAGES_ENDPOINT)
16 .then((response) => response.json())
17 .then((pages) => this.setState({ pages }))
18 }
19
20 render() {
21 return <Pages pages={this.state.pages} />
22 }
23}

ตอนนี้หน้า /pages ของเราก็จะแสดงรายการ wiki ในตารางอย่างสวยงาม แต่ถ้าข้อมูลฝั่งเซิร์ฟเวอร์อัพเดทหละ เราคงไม่อยากให้ผู้ใช้งานของเรากดปุ่ม reload ของบราวเซอร์เพื่อโหลดเพจใหม่ทั้งหมดหลอกใช่ไหมครับ เราคงแค่อยากให้คอมโพแนนท์ของเราไปดึงข้อมูลจากเซิร์ฟเวอร์มาแสดงผลใหม่ ถ้าอย่างนั้นเรามาเพิ่มปุ่ม reload pages ให้ผู้ใช้งานจิ้มแล้ววิ่งไปดึงข้อมูลจาก API server ของเรามาแสดงผลใหม่โดยไม่ต้องโหลดเพจใหม่ทั้งหมด ปรับปรุงไฟล์ต่างๆตามนี้ครับ

JavaScript
1// ui/containers/Pages.js
2...
3...
4export default class PagesContainer extends Component {
5 state = {
6 pages: []
7 }
8
9 onReloadPages = () => {
10 fetch(PAGES_ENDPOINT)
11 .then((response) => response.json())
12 .then((pages) => this.setState({ pages }))
13 }
14
15 componentDidMount() {
16 // เนื่องจากทั้งปุ่ม reload และใน componentDidMount
17 // มีการโหลดข้อมูลจากเซิร์ฟเวอร์ทั้งคู่
18 // จึงย้ายโค๊ดที่ซ้ำซ้อนแบกไปไว้อีกเมธอดชื่อ onReloadPages
19 this.onReloadPages()
20 }
21
22 render() {
23 // ส่ง onReloadPages ไปให้ ui/components/Pages
24 // เมื่อผู้ใช้งานระบบคลิกปุ่ม reload pages
25 // ui/components/Pages จะเรียกเมธอด onReloadPages ให้ทำงาน
26 return (
27 <Pages
28 pages={this.state.pages}
29 onReloadPages={this.onReloadPages} />
30 )
31 }
32}
33
34// ui/components/Pages.js
35export default class Pages extends Component {
36 static propTypes = {
37 pages: PropTypes.array.isRequired,
38 onReloadPages: PropTypes.func.isRequired
39 }
40
41 render() {
42 const { pages, onReloadPages } = this.props
43
44 return (
45 <div>
46 {/* เรียกใช้ onReloadPages เมื่อคลิก */}
47 <button
48 className='button'
49 onClick={() => onReloadPages()}>
50 Reload Pages
51 </button>
52 <hr />
53 <table className='table'>
54 <thead>
55 <tr>
56 <th>ID</th>
57 <th>Title</th>
58 <th>Action</th>
59 </tr>
60 </thead>
61 <tbody>
62 {
63 pages.map((page) => (
64 <Page
65 key={page.id}
66 id={page.id}
67 title={page.title} />
68 ))
69 }
70 </tbody>
71 </table>
72 </div>
73 )
74 }
75}

ตามที่ผมกล่าวไปข้างต้น container component นั้นฉลาดและมีสมอง มันรู้วิธีการเข้าถึงข้อมูลจากเซิร์ฟเวอร์ ในขณะที่ presentational component นั้นไม่มีสมอง มีหน้าที่แสดงผลอย่างเดียว มันจึงไม่จำเป็นต้องรู้ถึงวิธีการดึงข้อมูล รู้เพียงแต่ว่าถ้ามีเหตุการณ์นี้เกิดขึ้นบน view จะจัดการกับมัน จะรายงานไปให้ผู้บังคับบัญชาอย่างไร เราจึงส่งแค่เมธอด onReloadPages ไปให้มัน เพื่อให้มันเรียกเมื่อมีเหตุการณ์ click เกิดขึ้น กล่าวอีกนัยยะคือ container component จะส่ง callback มาให้ presentational component เรียกใช้เมื่อเกิดเหตุการณ์

ProTips! สำหรับผู้ที่เคยใช้ React มาก่อน อย่าใช้ประโยคต่อไปนี้ใน render onReloadPages={this.onReloadPages.bind(this)} เพราะ performance จะไม่ดีเท่าการใช้ arrow function แบบที่ผมแสดงในบรรทัดที่29

เรามาเพิ่มสไตล์ให้ button กันหน่อยใน theme/elements.scss

SASS
1:global {
2 ...
3 ...
4 // Button
5 .button {
6 display: inline-block;
7 vertical-align: middle;
8 text-align: center;
9 cursor: pointer;
10 user-select: none;
11 box-sizing: border-box;
12 padding: 0.5rem 1rem;
13
14 background-color: $gray2-color;
15 color: $dark-gray1-color;
16 border: none;
17 }
18}

เอาหละเรามาทดสอบกัน เข้าไปที่ http://127.0.0.1:8080/pages เพื่อนๆควรจะพบหน้าจอแบบนี้

Reload Pages

จากนั้นให้เพื่อนๆทำการเปลี่ยนแปลงฐานข้อมูลของ json-server เพื่อให้ส่ง pages มาสองตัว เข้าไปแก้ไข db.json ดังนี้

Code
1{
2 "pages": [
3 {
4 "id": 1,
5 "title": "test page#1",
6 "content": "TEST PAGE CONTENT"
7 },
8 {
9 "id": 2,
10 "title": "test page#2",
11 "content": "TEST PAGE CONTENT"
12 }
13 ]
14}

จากนั้นลองกดปุ่ม Reload Pages ดูครับ เพื่อนๆควรจะเห็นตารางมีสองแถวแล้วนะ

ตอนนี้เราได้ทดลองเปลี่ยนแปลงค่า state ในคอมโพแนนท์กันแล้ว เมื่อเพื่อนๆกดปุ่ม Reload Pages ในเมธอด onReloadPages ก็จะไปเรียก setState เพื่อเปลี่ยนค่า pages ให้เป็นของใหม่ตามที่ API server ส่งค่ากลับมา คำถามคือไม่ว่าจะตอน mount หรือก่อน render นั้น React ก็มี callback เช่น componentWillMount ไว้ให้เรียกใช้งาน แล้วหลังอัพเดท state หละ มีอะไรให้เรียกบ้างไหม? คำตอบคือมีครับ เรามาดูแผนภาพ React Lifecycle อันที่สองกัน

Lifecycle 2

จากแผนภาพจะเห็นว่าเมื่อเราเรียก setState มันจะไปเรียกเมธอดชื่อ componentWillUpdate ก่อนจะเรียก render อีกทีนึง เช่นเดียวกัน เมื่อเราได้ pages จาก API server แล้วเราบอกว่าให้ state มีค่า pages เป็นค่าจากเซิร์ฟเวอร์ ก่อนส่ง pages ตัวนี้เข้าไปใน ui/components/Pages.js ซึ่งเป็น presentational component เมื่อเราโยน pages ใส่เข้าไปมันจะเป็น props หรือ property ก่อนหน้าที่เราจะ reload pages ตัว presentational component ของเราเคยมีค่า props มาก่อนแล้ว พอเรา reload pages ได้รับค่า props ใหม่อีกครั้งทำให้เมธอด componentWillReceiveProps โดนเรียกเป็นลำดับแรก จากนั้นจึงเรียก componentWillUpdate และ render เป็นลำดับถัดไป

ลดขั้นตอนที่ยุ่งยากด้วย Functional Component

สังเกตกันไหมครับ มันจะเรียกอะไรกันนักกันหนา ui/components/Pages.js ของเราเป็นแค่ presentational component คือมีหน้าที่แค่แสดงผล ไม่ได้อยากสนใจเล๊ยยว่าเมธอดไหนจะถูกเรียกบ้าง ยิ่งมี callback เรียกซ้ำเรียกซ้อนมากเท่าไหร่ คอมโพแนนท์ของเรายิ่งช้า

เพื่อเป็นการตัดสิ่งไม่จำเป็นเหล่านี้ React จึงเสนอการเขียนคอมโพแนนท์แบบใหม่ที่มีประสิทธิภาพมากกว่าเดิม เนื่องจากมันจะไม่สนใจ Lifecycle เหล่านี้ คอมโพแนนท์ในรูปการเขียนต่อไปนี้เราเรียกว่า Functional Component หรือจะเรียกว่า Stateless Component ก็ได้เพราะเป็นคอมโพแนนท์ที่ไม่มี state หรือสถานะอยู่ในตนเอง

ปรับปรุง ui/components/Pages.js อีกครั้งตามนี้ครับ

JavaScript
1import React, { Component, PropTypes } from 'react'
2import fetch from 'isomorphic-fetch'
3import Page from './Page'
4
5// เราเขียนคอมโพแนนท์ประเภทนี้เหมือนการประกาศฟังก์ชัน
6// เราจึงเรียกมันว่า functional component
7// สิ่งที่ส่งเข้ามาในฟังก์ชันคือค่า props
8// คุณจะเขียนเป็น Pages = (props) แบบนี้ก็ได้
9// แต่ผมต้องการใช้แค่ pages และ onReloadPages ไม่สนใจอย่างอื่น
10// จึงดึงสองค่านี้ออกมา ซึ่งเป็นลักษณะเดียวกับการประกาศว่า
11// const { pages, onReloadPages } = props
12const Pages = ({ pages, onReloadPages }) => (
13 <div>
14 <button className="button" onClick={() => onReloadPages()}>
15 Reload Pages
16 </button>
17 <hr />
18 <table className="table">
19 <thead>
20 <tr>
21 <th>ID</th>
22 <th>Title</th>
23 <th>Action</th>
24 </tr>
25 </thead>
26 <tbody>
27 {pages.map((page) => (
28 <Page key={page.id} id={page.id} title={page.title} />
29 ))}
30 </tbody>
31 </table>
32 </div>
33)
34
35// functional component ไม่ใช่คลาส
36// จึงไม่มีการนิยาม static จากภายใน
37// ต้องมาประกบร่างข้างนอกแทน
38Pages.propTypes = {
39 pages: PropTypes.array.isRequired,
40 onReloadPages: PropTypes.func.isRequired,
41}
42
43export default Pages

เพียงเท่านี้ประสิทธิภาพของ presentational component ของคุณก็จะพุ่งปรี๊ดเลย

รู้จักกับ React Reconciliation

Reconciliation? อะไรอะศัพท์ใหม่ไม่เคยได้ยิน Google แปป... Reconciliation แปลว่า การประณีประนอม หืม? React เกี่ยวอะไรกับแม่ประนอม?

ตามที่กล่าวไว้ในบทความก่อนหน้านี้ว่า React ใช้ Virtual DOM เพื่อประโยชน์ในการลดการเขียนและการอ่านจาก DOM จริงที่ใช้เวลาดำเนินการค่อนข้างนาน เมื่อ props หรือ state มีการเปลี่ยนแปลง React จะสร้าง Virtual DOM ชิ้นใหม่ขึ้นมาเปรียบเทียบกับของเก่า เพื่อให้รู้ว่ามีจุดไหนบ้างที่เปลี่ยนแปลงและต้องการการอัพเดทลงไปที่ DOM จริงบ้าง สุดท้ายถ้า Virtual DOM ที่ได้มาใหม่จากการเปลี่ยนแปลง props หรือ state มีค่าเท่ากับ Virtual DOM ตัวเดิม React ก็ไม่ชายตามอง DOM จริง ไม่แยแสที่จะอัพเดทหรือที่เราเรียกว่า reconciliation

คำถามคือ ถ้าเรามั่นใจอยู่แล้วว่าค่า props หรือ state ของเราไม่เปลี่ยนแปลง เราจะให้ React สร้าง Virtual DOM ขึ้นมาใหม่เพื่อเปรียบเทียบให้เสียเวลาทำไม?

React มีเมธอดอีกตัวชื่อ shouldComponentUpdate ที่อนุญาตให้เราระบุเงื่อนไขว่าจะให้มีการสร้าง Virtual DOM ขึ้นมาเพื่อเปรียบเทียบหรือไม่ หากเราคืนค่าจากเมธอดนี้เป็น false แล้ว React จะไม่สร้าง Virtual DOM ใหม่ขึ้นมา นั่นหมายความว่ากระบวนการ reconcile DOM จะไม่มีด้วยเช่นกัน แต่ถ้าเราคืนค่าเป็น true แล้ว React จะสร้าง Virtual DOM ใหม่ขึ้นมาเพื่อเปรียบเทียบกับตัวเก่า หากค่าต่างกันจะทำการแก้ไข DOM จริง ในทำนองกลับกัน หากค่าไม่ต่างกัน เราก็สร้างขึ้นมาให้เสียเวลาเล่นๆเฉยๆ

เราตามมาอัพเดท React Lifecycle ของเราอีกครั้งดังภาพข้างล่างครับ

Lifecycle 3

ผมว่าเรามากันได้ถึงขนาดนี้แล้ว คงไม่ต้องอธิบายแผนภาพเพิ่มแล้วหละครับ มาลงมือปฏิบัติกันเถอะ!

JavaScript
1// ui/containers/Pages.js
2...
3...
4export default class PagesContainer extends Component {
5 state = {
6 pages: []
7 }
8
9 // ถ้า pages ของเดิมกับของใหม่เท่ากัน ก็ไม่ต้องทำอะไร
10 shouldComponentUpdate(_nextProps, nextState) {
11 return this.state.pages !== nextState.pages;
12 }
13 ...
14 ...
15}

ผมรู้ว่าเพื่อนๆยังไม่สะใจพอ งั้นเรามาดู React Lifecycle ฉบับเต็มกันเลย ดูซิมีเมธอดอะไรให้เราเรียกอีกบ้าง ผมใช้ภาพประกอบจากที่นี่

Lifecycle 4


คำถามชวนคิด : ถ้าเราต้องการเขียนเงื่อนไขในการตรวจสอบว่า เฉพาะกรณีที่ทั้ง props และ state ของใหม่และของเก่าต่างกันเท่านั้นจึงจะสร้าง Virtual DOM ใหม่มาเปรียบเทียบ กรณีนี้ควรเขียน shouldComponentUpdate อย่างไร?


วิกิจะสมบูรณ์เมื่อมีหน้า Show

เรามีหน้าสำหรับแสดง wiki ทั้งหมดแล้ว แต่ยังไม่มีหน้าสำหรับแสดง wiki แต่ละตัว ยังไม่มีหน้าสำหรับสร้างและอัพเดทด้วย สำหรับในบทความนี้ผมจะกล่าวถึงอีกแค่การสร้างหน้า Show ส่วนที่เหลือจะยกไปพูดพร้อมกับ Redux ในบทความหน้า

จุดประสงค์ของการสร้างวิกิของเราคือการทำ CRUD (Create Read Update Delete) โดยแบ่งคอมโพแนนท์ออกดังนี้

  • Index.js สำหรับโชว์ wiki ทั้งหมด ณ ตอนนี้คือไฟล์ Pages.js
  • Show.js สำหรับแสดงวิกิหนึ่งหน้า
  • New.js สำหรับแสดงหน้าเพจสำหรับสร้างวิกิ

เนื่องจากทั้ง Index, Show และ New ที่เราพูดถึงเป็นของโมดูล Wiki Pages เราจึงควรสร้างโฟลเดอร์ Pages เพื่อเก็บคอมโพแนนท์ทั้งหลายที่สัมผัสกับ Wiki Pages ของเรา เริ่มจากสร้างโฟลเดอร์ Pages ภายใต้ ui/components และ ui/containers จากนั้นดำเนินการต่อดังนี้

  • เปลี่ยนชื่อไฟล์ ui/containers/Pages.js เป็น ui/containers/Pages/Index.js
  • เปลี่ยนชื่อไฟล์ ui/components/Pages.js เป็น ui/components/Pages/Index.js
  • ย้ายไฟล์ ui/components/Page.js ไว้ภายใต้ ui/components/Pages

จากนั้นไปปรับปรุง index.js ของทั้ง ui/containers และ ui/components ให้ชี้ไปถูกที่

JavaScript
1// ui/containers/index.js
2export Pages from './Pages/Index'
3// สำหรับหน้าแสดงเนื้อหาวิกิ
4export ShowPage from './Pages/Show'
5
6// ui/components/index.js
7export App from './App/App'
8export Home from './Home'
9export Pages from './Pages/Index'
10export ShowPage from './Pages/Show'

เรามาทำความเข้าใจกันซะหน่อย จากหน้าแสดงวิกิทั้งหมดของเรา เมื่อผู้ใช้งานระบบจิ้มปุ่ม Show ของวิกิอันไหนให้มันวิ่งไปเปิดหน้าวิกินั้น path ของหน้าวิกิเดี่ยวๆจะเป็นดังนี้ /pages/:id เช่น /pages/1 สำหรับวิกิที่มี ID เป็น 1 เพิ่ม route สำหรับหน้า Show ดังนี้ครับ

JavaScript
1// routes.js
2import React from 'react'
3import { Router, Route, IndexRoute, browserHistory } from 'react-router'
4import { Pages, ShowPage, NewPage } from './containers'
5import { App, Home } from './components'
6
7export default () => {
8 return (
9 <Router history={browserHistory}>
10 <Route path="/" component={App}>
11 <IndexRoute component={Home} />
12 <route path="pages">
13 <IndexRoute component={Pages} />
14 {/* สำหรับ /pages/:id */}
15 <route path=":id" component={ShowPage} />
16 </route>
17 </Route>
18 </Router>
19 )
20}

เริ่มสร้างหน้าแสดงวิกิกัน ดังนี้

JavaScript
1// ui/containers/Pages/Show.js
2import React, { Component } from 'react'
3import { PAGES_ENDPOINT } from '../../constants/endpoints'
4import { ShowPage } from '../../components'
5
6export default class ShowPageContainer extends Component {
7 state = {
8 page: {
9 title: '',
10 content: '',
11 },
12 }
13
14 shouldComponentUpdate(_nextProps, nextState) {
15 return this.state.page !== nextState.page
16 }
17
18 componentDidMount() {
19 // react-router จะจับคู่ URL ที่เข้ามากับ ID
20 // แล้วส่งค่า ID เข้ามาเป็น this.props.params.id
21 // เช่น ถ้าขณะนั้น path คือ /pages/1
22 // ID ที่ส่งเข้ามาจะเป็น 1
23 fetch(`${PAGES_ENDPOINT}/${this.props.params.id}`)
24 .then((response) => response.json())
25 .then((page) => this.setState({ page }))
26 }
27
28 render() {
29 const { id, title, content } = this.state.page
30
31 return <ShowPage id={id} title={title} content={content} />
32 }
33}
34
35// ui/components/Pages/Show.js
36import React, { PropTypes } from 'react'
37
38const ShowPage = ({ title, content }) => {
39 return (
40 <article>
41 <h1>{title}</h1>
42 <p>{content}</p>
43 </article>
44 )
45}
46
47ShowPage.propTypes = {
48 title: PropTypes.string.isRequired,
49 content: PropTypes.string.isRequired,
50}
51
52export default ShowPage

หลังจบขั้นตอนนี้แล้วเมื่อเพื่อนๆเข้าหน้า Show จะเห็นหน้าตาแบบนี้ครับ

Show Wiki Page


คำถามชวนคิด : สังเกต index.js แล้วตอบคำถามต่อไปนี้

JavaScript
1import { Pages, ShowPage, NewPage } from './containers'
2import { App, Home } from './components'
  • ทำไมเราจึงใช้ presentational component อย่าง App และ Home ในการแสดงผลของ Route ทำไมจึงไม่ทำสองตัวนี้เป็น container component?
  • ถ้า ./containers มี App และ Home เช่นกัน จะทำให้ชื่อที่มีซ้ำกับ App และ Home จาก ./components เราควรแก้ปัญหานี้อย่างไร?

ท้ายสุดนี้ styles.css เราไม่ต้องการมันอีกแล้ว อย่าลืมลบทิ้งกันหละ! และนี่คือผลผลิตของบทความนี้ครับเข้าไปดูกันที่Github เลย

เอาหละครับ ยังมีอีกหลายเรื่องที่ผมอยากจะพิมพ์ เรื่องราวของ React นั้นช่างกว้างขวางและลึกล้ำ แต่ก็นั่นละฮะท่านผู้ชมเรายังเหลือกันอีกสามบทความ มันคงยังไม่สายนักถ้าผมจะยกหัวข้อต่อไปนี้ไปเขียนในบทความหน้า

  • วิธีการสร้าง form
  • การ validate ข้อมูลก่อนส่งไปให้เซิร์ฟเวอร์ในกรณีของการสร้างวิกิใหม่
  • Component Lifecycle กับ react-router
  • Pure component
  • Redux
  • และอื่นๆ

ข้อให้ทุกคนสนุกกับโลกของ React แล้วอย่าลืมอ่านบทความอื่นของ babelcoder.com นะครับ (ยิ้มหวานโคตรๆ)

สารบัญ

สารบัญ

  • ปัญหาของการจัดการ View แบบเก่า
  • แนะนำ Component-based Web UI
  • สวัสดีเรา React Component เอง
  • เริ่มใช้ React สร้างโปรเจคจริงกันเถอะ
  • วิเคราะห์ภาพรวมระบบ
  • จัดการเส้นทางเพื่อเข้าถึงหน้าเพจด้วย react-router
  • จมให้ลึกไปกับ Modular Pattern
  • Global CSS ใครว่าไม่จำเป็น
  • คารวะ react-router helpers มือฉมังด้านเส้นทาง
  • ดำดิ่งสู่วงจรชีวิตของ React Component
  • เริ่มดึงข้อมูลจาก Restful API มาใช้งาน
  • ย่อยคอมโพแนนท์ชิ้นโตเป็น subcomponents
  • PropTypes คู่หูกู้โลก
  • Presentational Components และ Container Components
  • เรียก API อย่างชาญฉลาดด้วย Proxy
  • ลดขั้นตอนที่ยุ่งยากด้วย Functional Component
  • รู้จักกับ React Reconciliation
  • วิกิจะสมบูรณ์เมื่อมีหน้า Show