[Day #3] จัดการ data flow ใน React.js อย่างมีประสิทธิภาพด้วย Redux
Redux เป็นไลบรารี่ตัวหนึ่งที่ช่วยคุมให้การไหลเวียนข้อมูลภายในแอพพลิเคชันเราดีขึ้น กล่าวคือเราสามารถคาดการณ์ได้ว่าเมื่อเกิดเหตุการณ์หนึ่งๆในคอมโพแนนท์ของเราแล้วจะมีผลกระทบต่อแอพพลิเคชันของเราอย่างไร แต่ก็นั่นละครับแอพพลิเคชันของเราไม่ใช่หนูทดลองบน production ถ้าเราไม่มีปัญหา เราจะแก้ปัญหาทำไม?
บทความนี้จะนำเพื่อนๆไปสู่โลกของ Redux โดยผมจะพยายามอธิบายถึงปัญหาที่เกิดขึ้นก่อน แล้วจึงแทรกด้วยวิธีแก้ปัญหาที่หยาบที่สุดไปจนถึงวิธีการที่ดีกว่า
บทความนี้ไม่เหมาะกับทุกคนครับ เพื่อนๆต้องผ่านการอ่านขั้นตอนปัญหาและวิธีการแก้ปัญหาที่อาจยาวกว่าบทความอื่น เพื่อนๆที่ต้องการรู้และเข้าใจทั้งหมดในไม่กี่บรรทัดอาจต้องพิจารณาบทความอื่นนะครับ ถึงอย่างไรผมก็ยังอยากให้เพื่อนๆลองอ่านดูก่อน ติดขัดอย่างไรถามได้ครับ
เพื่อให้การอ่านบทความนี้ลื่นไหลราวใส่จาระบี ผมแนะนำให้เพื่อนๆอ่านบทความต่อไปนี้ก่อน เพื่อเสริมสร้างความเข้าใจให้แข็งแกร่งมากขึ้น
- [Day #2] สอนการใช้งาน React.js และการเรียกใช้งาน RESTful API ด้วย React สำหรับเพื่อนๆที่คิดว่ายังเข้าใจ React ไม่เพียงพอ
- พื้นฐาน ES2015 สำหรับการเขียน JavaScript สมัยใหม่ เพื่อความเข้าใจใน ES2015 มากขึ้น
- พื้นฐาน funtional programming ใน JavaScript เน้นหัวข้อ Map/Reduce, Higher-Order Functions และ Composition
- กำจัด Callback Hell ด้วย Promise และ Async/Await สำหรับเพื่อนๆที่ยังไม่เข้าใจ Promise ครับ
แบบปฏิบัติที่ดีในโลกของ React
ก่อนพูดถึง Redux เรามาทบทวน React กันซักนิดครับ มีสิ่งหนึ่งที่ผมอยากเน้นมากเป็นพิเศษ และถือว่าเป็นแบบปฏิบัติอันดีงามกันเลยทีเดียว ขีดเส้นใต้ตรงนี้ห้าเส้นไปเลยครับ ใครใช้ React แล้วไม่ปฏิบัติตามนี้ถือว่าบาป ได้ตามไปใช้กรรมยัน production แน่นอน!
ตามแนวคิดของ Presentational Component และ Container Component ที่ผมได้กล่าวอย่างยืดยาวไปแล้วนั้นแสดงให้เห็นถึงการแบ่งคอมโพแนนท์ตามภาระหน้าที่ Container Component นั้นฉลาดและรู้ว่าเมื่อมีเหตุการณ์อย่างหนึ่งอย่างใดเกิดขึ้น ควรจะตอบสนองต่อเหตุการณ์นั้นอย่างไร ส่วน Presentational Component นั้นเป็นเพียงคอมโพแนนท์ที่รักงานศิลปะ รักการแสดงผลอย่างเดียว
เพื่อให้คอมโพแนนท์ทั้งสองประเภทดูมีสายการบังคับบัญชาเฉกเช่นทหาร จึงเกิดคอนเซ็ปต์ที่ว่า action up, data down จริงๆผมไม่แน่ใจว่า React มีคำเรียกวิธีการนี้ที่เฉพาะกว่านี้หรือไม่ แต่คำนี้เกิดขึ้นเป็นคอนเซ็ปต์หลักของ Ember2 ที่ล้อมาจากวิธีการจัดการการไหลของข้อมูลด้วย React
วิธีการจัดการเหตุการณ์จะกำหนดไว้ใน Container Component เมื่อมีเหตุการณ์อะไรก็ตามเกิดขึ้น Presentational Component ที่ถือเป็น subcomponent หรือคอมโพแนนท์ลูกของ Container จะโยนเหตุการณ์นั้นขึ้นไปเรื่อยๆ จนกระทั่งถึง Container Component ที่อยู่บนสุด เพื่อให้คอมโพแนนท์ที่ชาญฉลาดตัวนี้จัดการกับเหตุกาณ์นั้น อย่าลืมนะครับมีเพียง Container Component เท่านั้นที่รู้ว่าจะจัดการเหตุการณ์อย่างไร
กลับมาดูที่วิกิของเรากัน components/Pages/Index.js
เป็น Presentational Component มีหน้าที่แสดงผลอย่างเดียว ในตัวคอมโพแนนท์นี้มีลิงก์ที่มีข้อความว่า Reload Pages
ให้ผู้ใช้งานกดเพื่อจะโหลดวิกิทั้งหมดจากเซิร์ฟเวอร์มาแสดงผลใหม่ แต่เนื่องจากคอมโพแนนท์นี้ไม่มีหน้าที่จัดการเหตุการณ์ เมื่อเรากดลิงก์แล้วมันจึงต้องโยนเหตุการณ์นี้ขึ้นไปให้ Container Component จัดการ เราเรียกวิธีการทำเช่นนี้ว่า action up
หลังจากที่โหลดวิกิมาใหม่แล้ว Container Component ต้องส่งข้อมูลนี้กลับลงไปให้ Presentational Component เพื่อให้คอมโพแนนท์นี้แสดงผลข้อมูลออกมา ที่ต้องทำเช่นนี้เพราะ Presentational Component นั้นไม่รู้วิธีจัดการเหตุการณ์ จึงไม่สามารถไปคุ้ยข้อมูลวิกิเพื่อนำข้อมูลมาแสดงผลได้ด้วยตนเอง นี่หละครับที่เราเรียกกันว่า data down
ข้อสรุปจาก[Day #2] สอนการใช้งาน React.js และการเรียกใช้งาน RESTful API ด้วย Reactอยู่ในย่อหน้านี้ครับ นั่นคือการจัดการข้อมูลของ React ต้องเป็น one-way data flow หรือข้อมูลตลอดทั้งแอพพิลเคชั่นต้องวิ่งไปในทิศทางเดียว เมื่อข้อมูลตลอดทั้งแอพพลิเคชั่นวิ่งวนไปในทิศทางเดียวแล้ว เราจะพยากรณ์สิ่งที่จะเกิดขึ้นเมื่อเหตุการณ์หนึ่งๆเกิดขึ้นได้
เพื่อนๆบางคนอาจเกิดข้อสงสัย ข้อมูลไหลไปทิศทางเดียวมันช่วยให้เราพยากรณ์สิ่งที่จะเกิดขึ้นในแอพพลิเคชันของเราได้อย่างไร? ลองจินตนาการตามผมนะครับ ถ้าเราอยู่ที่หน้า Index ของวิกิและกำลังจะกดปุ่ม Reload Pages เราจะรู้ทันทีว่าคอมโพแนนท์ /containers/Pages/Index
เท่านั้นที่จะเป็นคนโหลดข้อมูลวิกิและเป็นผู้ถือครองข้อมูลทั้งหมดก่อนส่งไปให้คอมโพแนนท์อื่นๆ เมื่อข้อมูลไหลไปทางเดียวเราจะคาดเดาได้ทันทีว่าเมื่อเหตุการณ์นั้นๆเกิดขึ้น ข้อมูลของเราจะมาจากไหนและใครเป็นคนจัดการเหตุการณ์นั้น
เอาหละครับนี่คือข้อสรุปทั้งหมดจากบทความที่แล้ว เพื่อนๆคนไหนที่เข้าใจอย่างท่องแท้ คาดหวังเพียงแค่ประยุกต์ใช้ React กับงานที่ทำอยู่ ไม่ได้วางแผนจะทำอะไรใหญ่โตให้เวอร์วังอลังการ ผมขอแนะนำให้จบเพียงเท่านี้แล้วปิดบทความได้เลยครับ เพียงแค่คุณจัดการข้อมูลไปในทิศทางเดียวได้ตามที่กล่าวมานี้ ตายไปคุณได้ขึ่้นสวรรค์ชั้น7แล้วครับ หากอยากไปให้ถึงพรหมโลกแนะนำให้อ่านบทความนี้ต่อให้จบ แต่ถ้าอยากบรรลุนิพพานต้องอ่านบทความให้จบทั้งซีรีย์ครับ!
** หมายเหตุ: ในทางพุทธศาสนาสวรรค์มีแค่6ชั้นนะเออ
ทบทวน Hot Module Replacement กันอีกครั้ง
เพื่อนๆครับลองเปิดดูไฟล์ package.json กันอีกครั้งครับ ทุกคนเห็นเหมือนกันเนอะครับว่าเรารัน webpack-dev-server ด้วยคำสั่งนี้ webpack-dev-server --hot --inline
เราบอก webpack-dev-server ว่าถ้า loader ของเราสนับสนุน HMR ก็ให้ webpack ช่วยโหลดหน้าเพจของเราแค่ส่วนที่เปลี่ยนแปลง แต่ถ้าไม่สนับสนุนก็ reload ทั้งเพจซะเลย (เพื่อนๆที่อ่านแล้วงง รบกวนอ่าน[Day #1] แนะนำ Webpack2 และการใช้งานร่วมกับ Reactอีกรอบนะครับ ^^)
ตอนนี้เราอาจไม่สังเกตครับ ทุกครั้งที่เราแก้โค๊ดวิกิ หน้าเพจเราจะกระพริบซึ่งก้คือ refresh หน้าเพจใหม่ทั้งหมดทุกครั้ง! ที่เป็นเช่นนี้เพราะเราใช้ babel เป็น loader สำหรับไฟล์ js และ jsx แต่ babel ไม่ได้นิยามไว้ครับว่าหากโค๊ด React ของเรามีการเปลี่ยนแปลง จะให้ทำ HMR อย่างไร นั่นละฮะเมื่อไม่ฉลาดพอจะ HMR มันจึงโหลดเพจใหม่หมดซะเลย
พฤติกรรมแบบนี้ไม่น่าหนักใจสำหรับแอพพลิเคชันขนาดเล็กอย่างวิกิของเรา แต่สำหรับแอพพลิเคชันขนาดใหญ่แล้วมันเป็นเรื่องน่าปวดหัวมากครับ ทุกครั้งที่ reload หน้าเพจใหม่มันคือหายนะแห่งการรอคอยหรือช้านั่นเอง แต่เหนือสิ่งอื่นใดทุกครั้งที่โหลดทั้งเพจ สถานะของแอพพลิเคชันเราจะหายไปทั้งหมด ถ้าเราทำงานกับฟอร์มอยู่ เรากรอกข้อมูลอะไรเอาไว้การรีโหลดเพจจะทำให้ข้อมูลหายหมด สำหรับ HMR นั้นข้อมูลยังคงอยู่เช่นเดิมไม่หายไปไหน (เฉพาะกรณีที่ loader ฉลาดพอที่จะจัดการเป็น)
ติดตั้ง react-hot-loader ซะ
เพื่อให้ HMR สำหรับ React ของเราสมบูรณ์ พวกเราจงลงมือติดตั้ง react-hot-loader กันเถิดด้วยคำสั่งนี้
1npm i --save-dev react-hot-loader@3.0.0-beta.1
สถานะปัจจุบันคุณ Dan Abramov แนะนำให้เรากลับมาใช้ react-hot-loader เวอร์ชัน3ครับ เพราะเฮียแกได้แก้ไขบัคและหลายสิ่งจาก react-hot-loader เวอร์ชัน2 รวมถึง react-transform-hmr ไว้ในเวอร์ชันนี้เลย
ProTips! เครื่องมือนี้เราใช้ใน development จึงยังรับได้ที่จะใช้เวอร์ชัน beta แต่สำหรับ plugin/library อื่นๆที่เราต้องใช้ใน production ด้วยแล้ว ผมไม่แนะนำด้วยประการทั้งปวง production ของเราไม่ใช่หนูทดลองสำหรับ alpha/beta software นะครับ!
จากนั้นไปที่ /ui/index.js
แล้วแก้ไขโค๊ดของเราตามนี้ครับ
1import React, { Component } from 'react'2import { render } from 'react-dom'3// เราต้องใช้ AppContainer จาก hor-loader4// เพื่อครอบคอมโพแนนท์บนสุดของแอพพลิเคชันเราชื่อ Root5// เพื่อให้ทุกๆสิ่งภายใต้คอมโพแนนท์ Root มีคุณสมบัติ HMR ได้6import { AppContainer } from 'react-hot-loader'7// เพื่อให้ hot loader ทำงานสมบูรณ์เราต้องมีเพียงหนึ่งคอมโพแนนท์8// ที่ห่อหุ้มภายใต้ AppContainer โดยคอมโพแนนท์นั้นเราตั้งชื่อว่า Root9import Root from './containers/Root'1011const rootEl = document.getElementById('app')1213render(14 <AppContainer>15 <Root />16 </AppContainer>,17 rootEl18)1920if (module.hot) {21 // เมื่อไหร่ก็ตามที่โค๊ดภายใต้ Root รวมถึง subcomponent ภายใต้ Root22 // มีการเปลี่ยนแปลง ให้ทำ HMR ด้วย Root ตัวใหม่23 // ที่เราตั้งชื่อให้ว่า NextRootApp24 module.hot.accept('./containers/Root', () => {25 const NextRootApp = require('./containers/Root').default2627 render(28 <AppContainer>29 <NextRootApp />30 </AppContainer>,31 rootEl32 )33 })34}
เราต้องการคอมโพแนนท์ Root ไว้เป็นคอมโพแนนท์ที่อยู่บนสุด เมื่อโค๊ดภายใต้ Root และเหล่าคอมโพแนนท์ที่เป็นลูกหลานของ Root มีการเปลี่ยนแปลง react-hot-loader จะจับความเปลี่ยนแปลงนั้นได้และทำ HMR ให้กับเรา สร้าง Root.js แล้วใส่โค๊ดตามนี้ครับ
1import React, { Component } from 'react'2import routes from '../routes'34export default class App extends Component {5 render() {6 return <div>{routes()}</div>7 }8}
ก่อนที่เราจะไปดูผลสำเร็จมีอีกสามสิ่งที่ต้องทำ เปิดไฟล์ .babelrc
และ webpack.config.js
แล้วแก้ไขตามนี้ครับ
1// .babelrc2{3 "presets": ["es2015", "stage-0", "react"],4 "plugins": ["react-hot-loader/babel"]5}67// webpack.config.js8module.exports = {9 devtool: 'eval',10 entry: [11 // patch hot-loader12 'react-hot-loader/patch',13 'webpack-dev-server/client?http://localhost:8080',14 // ยังจำได้ไหม webpack-der-server เราทำได้ทั้ง hot และ inline15 // แต่เราต้องการแค่ hot module replacement16 // เราไม่ต้องการ inline ที่จะแอบทะลึ่งไป reload เพจของเรา17 // เราจึงบอกว่าใช้ hot เท่านั้นนะ18 'webpack/hot/only-dev-server',19 './ui/theme/elements.scss',20 './ui/index.js'21 ],22 ....23 ....24 plugins: [25 new webpack.HotModuleReplacementPlugin()26 ],27 ....28 ....29 devServer: {30 hot: true,31 // เมินไปซะ ชาตินี้อย่าได้บังอาจมา reload เพจอีกเลย32 inline: false,33 historyApiFallback: true,34 proxy: {35 '/api/*': {36 target: 'http://127.0.0.1:5000'37 }38 }39 }40}
และสุดท้าย... ตอนนี้เราย้ายวิธีจัดการกับ hot และ inline ไปไว้ใน webpack.config.js แล้วฉะนั้นจึงไม่มีความจำเป็นใดๆต้องใส่ --hot และ --inline ใน package.json ดังนั้นนำมันออกซะจาก package.json ครับ
1{2 ...3 "scripts": {4 ...5 ...6 "start-dev-ui": "webpack-dev-server"7 },8 ...9}
ถึงเวลาดูผลลัพธ์กันแล้ว เพื่อนๆลองรัน npm start ขึ้นมาใหม่ครับ แล้วลองแก้ไขโค๊ดใน js ไฟล์อะไรก็ได้ แล้วลองดูผลลัพธ์ครับ ตอนนี้ทุกคนน่าจะเห็นแล้วว่าหน้าจอของเราอัพเดทผลลัพธ์โดยไม่โหลดเพจใหม่ทั้งหมด!
ชะโงกหน้าไปดูที่ console เอ... แต่ทำไมดันเจอ warning ซะงั้น
หลังจากดั้นด้นไปคุ้ยส่องใน Github ทำให้พบว่ามีท่านอื่นเจอปัญหานี้เช่นกัน
เฮีย Dan Abramov ของเราก็ได้เข้ามาตอบครับว่า พวกลื้อลองใช้ react-router 3.0.0-alpha.12 ดูนะ แต่ยังไงก็ warning อยู่ดีละพวก!
ถึงเวลาเข้าสู่ Day3 กันแล้ว
นอกเรื่องไปยาวมาก เอาหละถึงเวลาเข้าฝั่ง วันนี้เราจะไปทำความรู้จักกับ Redux กันครับ แต่ก่อนอื่นผมขอให้ทุกคนตั้งกฎเหล็กหนึ่งข้อไว้ในใจว่าเป้าหมายของเราจะไม่ไปจากเธอ one-way data flow หรือ unidirectional
บทความนี้ผมจะเน้นอธิบายถึงปัญหาก่อน จากนั้นจึงนำเข้าสู่แนวคิด ไปสิ้นสุดที่แนวทางการแก้ปัญหา การเปิดเผยเรื่องราวของ Redux ทั้งหมดในสามบรรทัดจึงไม่ใช่แนวทางของบทความนี้ แต่ผมจะเน้นเปิดเผยตัวละครที่ละตัว ขอให้เพื่อนๆทุกคนจำไดอะแกรมนี้ให้ขึ้นใจ พร้อมลุ้นไปกับตัวละครของ Redux ที่จะเชิญฉายทีละตัวในบทความนี้
ปัญหาคลาสสิกของแอพพลิเคชันขนาดใหญ่
ตอนนี้ทุกคนน่าจะเกิดคำถามครับ การไหลของข้อมูลในแอพพลิเคชันเราเป็นไปในทิศทางเดียวผ่านการทำ action up, data down แล้ว เราควรจบแค่นี้แล้วซิ มีเหตุผลอะไรที่เรายังต้องไปต่อ? ปัญหาต่อไปนี้จะเกิดขึ้นเมื่อแอพพลิเคชันของคุณใหญ่และซับซ้อนมากขึ้นครับ
- โค๊ดที่ซ้ำซ้อน มีหลายคอมโพแนนท์ที่มีสถานะ (state) ร่วมกัน เช่นในหน้าสร้างวิกิกับหน้าแสดงวิกิอาจมี state เหมือนกัน ลองจินตนาการครับว่าเรากรอกฟอร์มเพื่อสร้างวิกิแล้ว เมื่อกดสร้างเราอาจนำข้อมูลจากฟอร์มนี้มาแสดงผลในหน้าแสดงวิกิได้เลย โดยไม่ต้องติดต่อขอข้อมูลจากเซิร์ฟเวอร์มาแสดง จำเป็นหรือที่เราจะเก็บสถานะและวิธีการจัดการกับสถานะไว้ในสองคอมโพแนนท์ ทั้งๆที่มันทำในสิ่งเดียวกัน?
- การใช้สถานะร่วมกันระหว่างคอมโพแนนท์ แม้ว่าเราจะมีคอมโพแนนท์บนสุดเป็น Container Component ที่ไว้คอยจัดการกับสารพัด action แต่ในความเป็นจริงแล้วระบบขนาดใหญ่ไม่ได้มีหนึ่ง Container Component ต่อหนึ่งเพจครับ ความหมายคืออะไร? หมายความว่าในหนึ่งเพจเราอาจมีสอง Container Components และทั้งสองคอมโพแนนท์นี้อาจแชร์สถานะบางอย่างร่วมกันอยู่ เช่นทั้งสองคอมโพแนนท์นี้อาจต้องการเข้าถึงข้อมูลผู้ใช้งานระบบปัจจุบัน (current user) เมื่อมีความต้องการร่วมกัน เราจะเก็บสถานะนี้ไว้ที่คอมโพแนนท์ตัวที่หนึ่งหรือสองดี?
- การเขียนโค๊ดคือการเมือง ขอนำเสนอมุมมองด้านการทำงานเป็นทีมบ้างครับ ถ้านายเอทำงานกับคอมโพแนนท์เอที่มีสถานะหรือข้อมูลจำเพาะอยู่แค่คอมโพแนนท์เอ เช่นกันนายบีก็ทำงานกับคอมโพแนนท์บี เนื่องจากคอมโพแนนท์ทั้งสองดันต้องใช้ state บางอย่างร่วมกัน งานงอกหละซิครับ แต่ละคนย่อมนิยามสถานะแตกต่างกัน จะดีกว่าไหมถ้าเราย้าย state ของแอพพลิเคชันออกมาอยู่ตรงกลาง?
- อื่นๆอีกมากมายสุดพรรณนา
นั่นละฮะท่านผู้อ่าน ปัญหาความซับซ้อนของแอพพลิเคชั่นที่ผูกสถานะไว้กับตัวคอมโพแนนท์เองเลย เวลาผู้ใช้งานเข้าถึงเพจสิ่งที่แสดงออกมาทางหน้าจอไม่ใช่เพียงแค่สถานะของคอมโพแนนท์นะครับ แต่มันรวมถึงสถานะที่เกิดจากการทำงานร่วมกันทั้งระบบ (application state) ดังนั้นแล้วเราจึงแยก state ของแต่ละคอมโพแนนท์ออกมาไว้ตรงกลายให้เป็น application state เก็บเอาไว้ในโกดังแห่งหนึ่ง โกดังสำหรับเก็บสถานะของแอพพลิเคชันนี่หละครับที่เราเรียกว่า store หยิบปากกาขีดเส้นใต้สองเส้นครับ
รู้จัก store โกดังเก็บ state
กลับมาดูที่วิกิของเรากัน ขอให้เพื่อนๆเปิดไปที่ไฟล์ containers/Pages/Show.js
ที่เป็น Container Component ไว้ดึงข้อมูลหน้าวิกิจากเซิร์ฟเวอร์พร้อมทั้งเก็บข้อมูลนั้นไว้กับตัว
1...2...3state = {4 page: {5 title: '',6 content: ''7 }8}9...10...
ทุกคนคงเห็นแล้วนะครับว่าตอนนี้เราเก็บสถานะไว้แน่นหนึบกับคอมโพแนนท์ เราจะแงะมันออกแล้วไปเก็บไว้ตรงกลางใน store เพื่อให้มันเป็นสถานะของแอพพลิเคชัน ไม่ใช่ของคอมโพแนนท์แต่เพียงตัวเดียว เพื่อนๆอย่าพึ่งใส่ใจในรายละเอียดนะครับ ผมขอให้เข้าใจคอนเซ็ปต์ก่อน
เนื่องจากในหนึ่งแอพพลิเคชันเรามีสถานะได้หลายตัว เช่นสถานะของวิกิ สถานะของผู้ใช้งานระบบ หรือสถานะของแหล่งอ้างอิง ถ้าเรานำสถานะทุกๆตัวไปยัดอยู่ในไฟล์เดียวเพื่อให้เป็นสถานะโดยรวมของแอพพลิเคชันคงดูไม่ดีนัก เราควรแยกแต่ละสถานะออกจากกันไปคนละไฟล์ ภายใต้โฟลเดอร์ร่วมกัน เช่นผมอาจตั้งชื่อโฟลเดอร์ว่า stores และมีไฟล์ชื่อ page.js อยู่ข้างในเพื่อเก็บสถานะของวิกิเพจดังนี้
1// stores/page.js2state = {3 page: {4 title: '',5 content: '',6 },7}89export function getState() {10 // โค๊ดสำหรับเข้าถึง state ของ page11}1213export function setState(newState) {14 // โค๊ดสำหรับตั้งค่า state ใหม่15}
เอาหละ เมื่อเราเข้าหน้า Show ของวิกิที่จะไปโหลดข้อมูลวิกิมาแสดง มันจะไม่เก็บ title และ content ไว้ในตัว containers/Pages/Show.js
อีกต่อไป แต่เราจะเรียกฟังก์ชัน setState
ของ stores/page.js
แล้วส่งสถานะใหม่ของวิกิซึ่งก็คือ title และ content ที่ได้มาจากเซริฟเวอร์เข้าไป ถึงตอนนี้สถานะของ page จะไม่ใช่ของคอมโพแนนท์อีกแล้ว แต่เป็นของทั้งระบบ ความหมายคือไม่ใช่เพียงแค่คอมโพแนนท์ Show.js เท่านั้นที่จะเข้าถึงสถานะนี้ได้ แต่คอมโพแนนท์อื่นๆย่อมเข้าถึงได้โดยตรงเช่นกัน
จบกระบวนการแล้วลองเช็คดูกันว่าสิ่งที่เราทำนั้นผิดข้อตกลงไหม การมี getState และ setState แบบนี้ยังเป็น one-way data flow อยู่ไหม?
เห็นชัดเลยครับว่าถ้าเรามีคอมโพแนนท์หลายตัวเข้าถึงสถานะของแอพพลิเคชันแบบนี้ ข้อมูลของเราไม่ได้ไหลไปทางเดียวแล้ว? หืมยังไงอะ ดูจากในรูปก็ยังไหลไปทางเดียวนะ? ComponentA เรียก setState วิ่งเข้าไปตั้งค่า state ใหม่ใน store จากนั้นจึงเรียก getState เพื่อดึงค่ากลับอีกที? ก็ดูวนไปทางเดียวหนิ?
ใช่ครับ นั่นคือทิศทางเดียวสำหรับหนึ่งคอมโพแนนท์ แต่ตอนนี้เรามีสองคอมโพแนนท์แล้วครับ แต่ละคอมโพแนนท์มีอิสระที่จะ setState ใหม่ได้เสมอ นั่นละฮะถ้าคอมโพแนนท์ A เรียก setState คำถามคือคอมโพแนนท์ B จะทราบไหมครับว่าตอนนี้สถานะของแอพพลิเคชันเปลี่ยนไปแล้ว?
พอเราเริ่มมีหลาย store มากขึ้น ปัญหาก็จะเกิดตามขึ้นมา เพราะคอมโพแนนท์แต่ละตัวก็สามารถเข้าถึงค่า state จากแต่ละ store ได้ ดูสภาพเส้นที่โยงใยไปซิครับ มันไม่ใช่ one-way data flow แล้ว
วิธีการนี้เกิดปัญหาแน่ในระบบขนาดใหญ่เพราะเราคาดเดาไม่ได้เลยว่าคอมโพแนนท์ไหนแอบไป setState บ้าง การไหลเวียนข้อมูลที่ดีต้องไปในทิศทางเดียวและต้องดีพอให้โปรแกรมเมอร์ที่ทำงานตัวเป็นเกรียว หัวเป็นน็อต ก้นเป็นสว่านแบบเราๆคาดเดาได้ว่าถ้าเกิดเหตุการณ์นี้ผลลัพธ์จะเป็นเช่นไร
เพื่อให้ดูสมเหตุผลขึ้น เราจึงตัดไม่ให้ store เรามี setState ที่ต้องทำเช่นนี้เพื่อให้แอพพลิเคชันเราคาดเดาได้ครับ เมื่อไม่มีใครมามั่วตะลุมบอนเรียก setState ตรงๆ แอพพลิเคชันของเราจะเริ่มมีทิศทาง ตรงนี้ต้องย้ำนะครับ ไม่ใช่ห้ามคอมโพแนนท์ setState แต่ห้ามคอมโพแนนท์ setState ตรงๆเข้าไปใน store ต่างหาก
และแล้วตัวละครตัวแรกของเราก็เผยโฉมมาแล้วใน Redux...
เพื่อให้คอมโพแนนท์ทั้งระบบรับรู้ว่าแอพพลิเคชันของเรามีสถานะเปลี่ยนไปแล้ว เราจึงต้อง subscribe
หรือก็คือให้ทุกๆคอมโพแนนท์ติดตั้งต่อมความเผือกเรื่องชาวบ้านไว้กับตัวเสมอ เมื่อใดก็ตามที่ state ใน store เปลี่ยน ต่อมเผือกของแต่ละคอมโพแนนท์จะเริ่มทำงานด้วยการส่องเข้าไปดูหรือเผือกเฉพาะสถานะที่คอมโพแนนท์นั้นสนใจ นั่นละฮะถ้าเป็นเราก็คงไม่เผือกเรื่องชาวบ้านไปทุกเรื่องใช่ไหมละ เราเผือกแค่สิ่งที่เราอยากรู้ โดยเฉพาะเรื่องชาวบ้าน ตัวอย่างเช่น คอมโพแนนท์ show.js ของวิกิหลังจากรับรู้ว่าสถานะมีการเปลี่ยนแปลง มันจะสนใจเฉพาะสถานะ page เท่านั้น อย่างอื่นเช่นสถานะของผู้ใช้ระบบปัจจุบันมันจะเมินเสีย
ตัวละครตัวที่สองของเรานี่ละครับคือ view เป็นตัวละครที่ต่อมเผือกเบ่งบาน ต้อง subscribe ดูความเปลี่ยนแปลงของ state ใน store ด้วยความอยากรู้อยากเห็นหรือเผือกนั่นเอง
อย่าให้ store รับภาระแต่ฝ่ายเดียว
ปัจจัยหลักที่ห้ามไม่ให้คอมโพแนนท์เรียก setState จาก store นั่นเป็นเพราะเราต้องการแยกการทำงานให้ชัดเจนขึ้น กล่าวคือคอมโพแนนท์มีหน้าที่เดียวคือทำให้ได้ได้มาซึ่งข้อมูลเพื่อการแสดงผล เราจึง subscribe สถานะของแอพพลิเคชันเพื่อให้ได้มาซึ่งข้อมูล แต่การ setState นั้นไม่ได้เกี่ยวข้องกับการได้มาซึ่งข้อมูลเพื่อไปแสดงผล เราจึงไม่ควรรับรู้ว่า store นั้นต้อง setState อย่างไร ถ้าเราฝืนมีให้คอมโพแนนท์ของเรารับรู้ถึงการตั้งค่าสถานะตรงเข้าไปใน store จะผิดหลักการที่ว่า single responsibility principle เพราะเราไม่ได้ทำงานเดียวคือรับข้อมูลไปแสดงผลแล้ว แต่เรายังต้องกังวลว่าจะเปลี่ยนแปลงข้อมูลอย่างไรด้วย
ในระบบที่มีคอมโพแนนท์หลายตัว ย่อมมีโอกาสทำการเปลี่ยนสถานะของแอพพลิเคชันเหมือนๆกันในหลายๆคอมโพแนนท์ คอมโพแนนท์ SignInWithFacebook ย่อมสามารถเปลี่ยนสถานะของผู้ใช้งานระบบให้ isLoggedIn หรือเข้าสู่ระบบแล้วเป็น true เช่นเดียวกันคอมโพแนนท์ SignInWithGithub ก็สามารถเปลี่ยน isLoggedIn เป็น true ได้เหมือนกัน เราจะเห็นว่าทั้งสองคอมโพแนนท์นี้มีเหตุการณ์ที่ทำให้เกิดการเปลี่ยนสถานะของระบบเหมือนกัน เราเรียกเหตุการณ์หรือการกระทำที่ทำให้สถานะของระบบเปลี่ยนว่า action
action เดียวกันสามารถเกิดขึ้นจากคอมโพแนนท์ได้หลายตัว เราจึงควรแยก action ออกไปจากคอมโพแนนท์ เพื่อให้คอมโพแนนท์รับรู้เพียงแค่วิธีการเพื่อให้ได้มาซึ่งการแสดงผล เมื่อไหร่ก็ตามที่ผู้ใช้งานระบบคลิกปุ่มหรือทำอะไรซักอย่างบนหน้าเว็บเพื่อให้เกิดเหตุการณ์ขึ้นมา คอมโพแนนท์จะไม่เปลี่ยนสถานะของระบบเอง แต่คอมโพแนนท์จะส่งค่าสถานะนี้ออกไปโดยหวังว่าจะมีใครซักคนช่วยจัดการให้ อย่าลืมนะครับคอมโพแนนท์จะไม่รู้วิธีการเปลี่ยนสถานะต้องอาศัยคนอื่นทำให้
บางคนอาจงง เอ... ไหนว่าคอมโพแนนท์ควรรู้แค่การแสดงผลไง แต่นี้เหมือนคอมโพแนนท์รู้ด้วยนะว่า action คืออะไร เมื่อเกิด action แล้วต้องทำยังไง?
ไม่จริงครับขอแย้ง การคลิกปุ่ม การกด enter หรือเหตุการณ์อื่นๆบนคอมโพแนนท์นั้นล้วนสัมพันธ์กับการแสดงผล การที่คอมโพแนนท์รับรู้ action จึงไม่ผิด ส่วนกรณีที่คอมโพแนนท์รู้ว่าจะต้องโยน action ไปให้คนภายนอกจัดการนั้นก็เหมือนกรณีของ Container Component กับ Presentational Component ครับ Presentational Component ไม่รู้วิธีจัดการ action จึงต้องทำ action up คือโยน action ขึ้นไปให้ Container Component จัดการ เฮียแกแค่เรียก callback function ที่ส่งเขามาเฉยๆ เฮียแกไม่รู้หรอกครับว่า Container Component เป็นใคร
ตัวละครตัวที่สามเปิดเผยหน้าตาแล้วครับ กราบสวัสดีน้อง action
เพื่อนๆคิดว่าถ้าเราจะเปลี่ยนสถานะของแอพพลิเคชัน เรารู้เพียงแค่ว่ามีคนคลิกปุ่มบนคอมโพแนนท์พอไหม? ไม่พอหรอกครับเราต้องรู้มากกว่านั้น เช่นต้องรู้ว่าปุ่มที่เขาคลิกคือปุ่มอะไร ในกรณีที่เป็นปุ่ม submit ฟอร์มเพื่อที่จะสร้างวิกิ เราก็ต้องส่งข้อมูลภายในฟอร์มไปด้วยเพื่อจะได้สร้างวิกิจากข้อมูลชุดนี้ได้ ด้วยเหตุนี้คอมโพแนนท์ของเราเพียงแค่โยน action ออกไปว่าเห้ยมีคนคลิกปุ่มหวะ แบบนี้ไม่พอ ต้องส่งข้อมูลเพิ่มเติมไปให้ผู้ที่จะทำการเปลี่ยนสถานะแอพพลิเคชันรู้ด้วยว่าปุ่มที่คลิกคืออะไร ข้อมูลในฟอร์มคืออะไร เป็นต้น ด้วยเหตุนี้คอมโพแนนท์ไม่ได้โยนแค่ชนิดของ action ออกไปให้คนอื่นจัดการ แต่มันต้องโยนก้อนอ็อบเจ็กต์ที่มีข้อมูลอธิบายเพิ่มเติมเกี่ยวกับ action นั้น เช่น
1{2 // ชนิดของ action ที่เกิดขึ้น3 type: 'FORM_SUBMISSION',4 // ข้อมูลเพิ่มเติมที่ส่งไป5 data: {6 title: 'Wiki Title',7 content: 'Wiki Content'8 }9}
ในทางปฏิบัติแล้วเรามันนิยามฟังก์ชันขึ้่นมา เพื่อทำหน้าที่สร้างอ็อบเจ็กต์ที่เก็บชนิดของ action และข้อมูลเพิ่มเติม ดังนี้
1const createWiki = (formData) => {2 type: 'FORM_SUBMISSION',3 data: formData4}
ฟังก์ชันผู้สร้างประเภทนี้แหละครับเราเรียกว่า action creator ตัวละครตัวที่สี่ของเรา
ทบทวนกระบวนการส่งผ่านข้อมูลจากตัวละครทั้งสี่
เอาหละตอนนี้ตัวละครเปิดเผยมาแล้วสี่ตัว เรามาทบทวนก่อนที่จะลืมกันซะหน่อย เริ่มจากผู้ใช้งานระบบคลิกปุ่มอะไรซักอย่างบนคอมโพแนนท์ของเรา...
- คอมโพแนนท์รับรู้ว่ามีการคลิกปุ่มเกิดขึ้น
- คอมโพแนนท์เรียก action creator เพื่อสร้างอ็อบเจ็กต์ที่เป็นตัวแทนของ action ที่เกิดขึ้น
- action creator ส่งค่ากลับมาเป็นก้อนอ็อบเจ็กต์ action
- action ตัวนี้นี่แหละครับที่คอมโพแนนท์ของเราจะส่งไปให้ใครซักคน
- ใครซักคนคนนั้นจะแกะดู action แล้วทำการเปลี่ยนแปลง state ใน store ตามชนิดของ action ที่เกิดขึ้น เช่นถ้า action นั้นเป็น LOGOUT ใครคนนั้นก็จะเปลี่ยน state ของ isLoggedIn ให้เป็น false
- store เก็บสถานะใหม่ของระบบที่เปลี่ยนแปลงโดยใครคนนั้น
- คอมโพแนนท์ทุกตัวในระบบ (จริงๆแล้วไม่ทุกตัวนะแต่ตอนนี้ขอให้เข้าใจไปก่อนว่าทุกตัว) ต่อมเผือกทำงาน รับรู้ถึงการเปลี่ยนแปลงของ state ใน store
- คอมโพแนนท์จะหยิบเฉพาะ state ที่เกี่ยวข้องกับการแสดงผลขึ้นมาใช้งาน
- คอมโพแนนท์จะ rerender หรือเปลี่ยนแปลงการแสดงผลใหม่ให้สอดคล้องกับ state ใหม่ที่ได้รับ
HMR พลเมืองชั้นหนึ่งของการพัฒนาด้วย Redux
ย้อนกลับไปดู store กันนิดนึงครับ เราบอกว่าคอมโพแนนท์จะส่ง action ไปให้ใครซักคนจัดการด้วยการเปลี่ยน state ของแอพพลิเคชัน ทำไมเราต้องทำอะไรให้มันซับซ้อนด้วย เอาอย่างนี้ดีกว่าเราก็ให้ store เป็นใครคนนั้นซะเลยซิ ไหนๆ store ก็เก็บ state เองอยู่แล้ว จะแปลกอย่างไรถ้า store จะรับ action มาแล้วเปลี่ยน state ตาม action นั้น
1// stores/page.js2state = {3 page: {4 title: '',5 content: '',6 },7}89// ฟังก์ชันนี้จะรับอ็อบเจ็กต์ของ action เข้ามา ยังจำหน้าตามันได้ไหมครับ10// หน้าตาประมาณนี้ไง { type: 'CREATE_PAGE_SUCCESS', data: ... }11export default (action) => {12 // ตรวจสอบว่า action เป็นชนิดไหนแล้วจึงเปลี่ยนแปลงสถานะของ page ตาม action นั้น13 switch (action.type) {14 case 'CREATE_PAGE_SUCCESS':15 // ส่งค่ากลับเป็นสถานะใหม่ของ page16 }17}
ชีวิตดี๊ดีทุกอย่างดูสวยงามครับ แต่วิธีการแบบนี้มีปัญหาอยู่อย่างหนึ่ง...
ตอนนี้ store ของเราทำสองหน้าที่ครับ อย่างแรกคือเก็บสถานะของแอพพลิเคชัน อย่างหลังคือเปลี่ยนสถานะของแอพพลิเคชันตามแต่ action ที่ระบุเข้ามา สมมติเราแก้ไขโค๊ดสักที่ใน store (ในฟังก์ชันบรรทัดที่ 11-17) เป็นผลให้ react-hot-loader ทำงาน นั่นคือมันจะทำ HMR โดยมันจะนำโค๊ดของ store ใหม่ทั้งไฟล์ไปแทนทีในหน้าเว็บของเรา
เริ่มมองเห็นปัญหาไหมครับ ถ้า store ตัวนี้ทำงานกับฟอร์ม เราเคยกรอกฟอร์มอะไรเอาไว้ เพียงแค่คุณแก้ไข store ข้อมูลในฟอร์มที่คุณเคยกรอกก็จะสิ้นชีพลงอย่างสงบ ที่เป็นเช่นนี้เพราะว่าหนึ่งไฟล์ของ store ถือเป็นหนึ่งโมดูล ในโมดูลนี้มี state ตั้งต้นเป็นค่าว่าง (ดูบรรทัด 2-7) เมื่อทำ HMR ไอ้ state ที่มีแต่ค่าว่างนี้ก็จะโดนโยนไปแสดงผลบนหน้าจอทันที
เพื่อไม่ให้เหตุการณ์เช่นนี้เกิดขึ้น เราจึงควรแยกฟังก์ชันที่ไว้จัดการงานเปลี่ยนสถานะออกไปให้พ้นจาก store ซะ เมื่อเราแก้โค๊ดของฟังก์ชันนี้ react-hot-loader จะได้โหลดเพียงแค่ไฟล์ของฟังก์ชันนั้นไปอัพเดทบนหน้าเพจ จะได้ไม่ต้อง HMR state ใน store ที่อยู่คนละไฟล์อีกต่อไป ไอ้ฟังก์ชันที่นิยามวิธีการเปลี่ยนสถานะของแอพพลิเคชันตามแต่ action ที่ส่งเข้ามานี่ละครับที่เราเรียกว่า reducer ตัวละครตัวที่ห้าของเรา
เรามาลองแทนที่คำว่า ใครคนนั้น
ในกระบวนการทำงานที่ได้กล่าวแล้วในหัวข้อก่อนหน้านี้ จะได้ว่าเมื่อมีผู้ใช้งานระบบกดปุ่มอะไรซักอย่างบนคอมโพแนนท์ลำดับการทำงานต่อไปนี้จะเกิดขึ้น
- คอมโพแนนท์รับรู้ว่ามีการคลิกปุ่มเกิดขึ้น
- คอมโพแนนท์เรียก action creator เพื่อสร้างอ็อบเจ็กต์ที่เป็นตัวแทนของ action ที่เกิดขึ้น
- action creator ส่งค่ากลับมาเป็นก้อนอ็อบเจ็กต์ action
- action ตัวนี้นี่แหละครับที่คอมโพแนนท์ของเราจะส่งไปให้ reducer จัดการ
- reducer จะแกะดู action แล้วทำการเปลี่ยนแปลง state ใน store ตามชนิดของ action ที่เกิดขึ้น เช่นถ้า action นั้นเป็น LOGOUT reducer ก็จะเปลี่ยน state ของ isLoggedIn ให้เป็น false
- store เก็บสถานะใหม่ของระบบที่เปลี่ยนแปลงโดย reducer
- คอมโพแนนท์ทุกตัวในระบบ (จริงๆแล้วไม่ทุกตัวนะแต่ตอนนี้ขอให้เข้าใจไปก่อนว่าทุกตัว) ต่อมเผือกทำงาน รับรู้ถึงการเปลี่ยนแปลงของ state ใน store
- คอมโพแนนท์จะหยิบเฉพาะ state ที่เกี่ยวข้องกับการแสดงผลขึ้นมาใช้งาน
- คอมโพแนนท์จะ rerender หรือเปลี่ยนแปลงการแสดงผลใหม่ให้สอดคล้องกับ state ใหม่ที่ได้รับ
ทฤษฎีเยอะพอแล้ว ลงมือปฏิบัติกัน!
ลงมือติดตั้ง redux และ react-redux ด้วยคำสั่งนี้
1npm i --save react-redux redux
จากนั้นสร้างโฟลเดอร์ต่อไปนี้ขึ้นมาภายใต้โฟลเดอร์ ui ได้แก่ actions, reducers และ store
เราบอกว่าคอมโพแนนท์รับรู้ว่ามี action อะไรเกิดขึ้น แต่มันจะไม่จัดการด้วยตนเอง คอมโพแนนท์มีหน้าที่ส่งก้อนอ็อบเจ็กต์ที่เป็นตัวแทนของ action ไปให้ reducer จัดการ เอาหละเราลองลงมือสร้าง action ผ่าน action creator กัน เพื่อนๆสร้างไฟล์ชื่อ page.js ใต้โฟลเดอร์ actions ครับ ดังนี้
1// actions/page.js23// ฟังก์ชันนี้มีหน้าที่สร้างอ็อบเจ็กต์ที่เป็นตัวแทนของ action4// มันจึงเป็น action creator5// เมื่อเรากดปุ่ม reload pages หรือเมื่อหน้า Index ของวิกิแสดงผล6// คอมโพแนนท์ containers/Pages/Index.js จะเรียก action creator ตัวนี้7// เพื่อทำการสร้าง action ที่มีชนิดเป็น LOAD_PAGES_SUCCESS8// พร้อมกับก้อนข้อมูลของ wiki pages9export const loadPages = () => ({10 type: 'RECEIVE_PAGES',11 pages: [12 {13 id: 1,14 title: 'test page#1',15 content: 'TEST PAGE CONTENT',16 },17 {18 id: 2,19 title: 'test page#2',20 content: 'TEST PAGE CONTENT',21 },22 ],23})
เรามีวิธีสร้างก้อน action แล้ว ทีนี้เราก็ต้องไปบอกคอมโพแนนท์ของเราว่าเมื่อมีการกดปุ่ม reload pages ให้คอมโพแนนท์ของเราเรียกฟังก์ชันนี้เพื่อนสร้างก้อนอ็อบเจ็กต์ของ action
1// containers/Pages/Index.js2...3...4import { loadPages } from '../../actions/page'56class PagesContainer extends Component {7 state = {8 pages: []9 }1011 onReloadPages = () => {12 // เมื่อไหร่ก็ตามที่โหลดเพจหรือกดปุ่ม reload page ให้สร้างอ็อบเจ็กต์ action13 // ผ่าน action creator ชื่อ loadPages14 loadPages()15 // คอมเมนต์ออกไปก่อน เราจะกลับมาคุยเรื่องการโหลดข้อมูลผ่าน AJAX กันอีกที16 // fetch(PAGES_ENDPOINT)17 // .then((response) => response.json())18 // .then((pages) => this.setState({ pages }))19 }2021 componentDidMount() {22 this.onReloadPages()23 }2425 render() {26 return (27 <Pages28 pages={this.props.pages}29 onReloadPages={this.onReloadPages} />30 )31 }32}
คำถามต่อมาคือเราสร้างก้อนอ็อบเจ็กต์ของ action ได้แล้ว เราจะส่งไปให้ reducer ยังไงดี? คำตอบก็คือใน Redux store จะมีเมธอดชื่อ dispatch ให้เราเรียกใช้ เพียงแค่เราส่งอ็อบเจ็กต์ action นี้ผ่านเข้าไปใน dispatch เหล่า reducer ทั้งหลายก็จะรับรู้ทันที พวกเธอจะขวักไขว่จัดการ action เพื่อเปลี่ยน state ตามที่เราคาดหวังไว้
ตามแผนภาพที่ผมให้เพื่อนๆดู ยังจำกันได้ไหมครับว่าคอมโพแนนท์ของเราต้อง subscribe store เพื่อเผือกในการเปลี่ยนแปลงสถานะของแอพพลิเคชัน พูดง่ายๆก็คือคอมโพแนนท์ของเราต้องรับรู้เมื่อ state เปลี่ยน พร้อมทั้งต้องส่ง action ไปให้ reducer ทำงานผ่าน dispatch
ตรงนี้ต้องขอขยายความเพิ่มนิดนึงครับ คอมโพแนนท์ที่จะรับรู้เรื่องการเปลี่ยนสถานะของแอพพลิเคชันและการ dispatch action ไปให้ reducer นั้น จะต้องเป็นคอมโพแนนท์ประเภท Container Component นะครับ ยังจำกันได้ไหม Presentational Component ชอบศิลปะและรักการแสดงผลอย่างเดียว
ในแพคเกจของ react-redux มี connect
ที่เราสามารถเรียกใช้เพื่อประกาศก้องให้โลกรู้ว่าเราต้องการเชื่อมต่อหรือ connect
คอมโพแนนท์ของเราเข้ากับ Redux store สิ่งที่ได้ตามมาคือคอมโพแนนท์ของเราก็จะรับรู้ทุกการเปลี่ยนแปลงของ state ที่อยู่ใน store และรู้จัก dispatch ที่เป็นฟังก์ชันของ store ไปโดยปริยาย
อัพเดท containers/Pages/Index.js กันอีกครั้งเพื่อเรียกใช้ connect
1import React, { Component, PropTypes } from 'react'2// import connect เข้ามาก่อนครับ3import { connect } from 'react-redux'4import fetch from 'isomorphic-fetch'5import { PAGES_ENDPOINT } from '../../constants/endpoints'6import { loadPages } from '../../actions/page'7import { Pages } from '../../components'89class PagesContainer extends Component {10 // เอาโค๊ดตรงนี้ออกไปได้เลย11 // ตอนนี้ state ของเราบรรจุเข้า store แทนแล้ว12 // state = {13 // pages: []14 // }1516 static propTypes = {17 pages: PropTypes.array.isRequired,18 onLoadPages: PropTypes.func.isRequired,19 }2021 // เมื่อ state อัพเดท ฟังก์ชัน mapStateToProps ด้านล่างจะทำงาน22 // สิ่งที่ return ออกมาจากฟังก์ชันนี้จะกลายเป็น props ของคอมโพแนนท์23 shouldComponentUpdate(nextProps) {24 // ดังนั้นเราจึงตรวจสอบ pages ผ่าน props แทน state25 // อย่าสับสนนะครับ state ที่พูดถึงตรงนี้คือ this.state หรือสถานะของคอมโพแนนท์26 // ไม่ใช่สถานะของแอพพลิเคชันนะ27 return this.props.pages !== nextProps.pages28 }2930 onReloadPages = () => {31 // loadPages ตัวนี้เป็น props ที่ได้มาจากค่าที่ mapDispatchToProps ส่งออกมา32 // เมื่อเราเรียกฟังก์ชันนี้ มันจะ dispatch ก้อนอ็อบเจ็กต์ของ action ไปให้ reducer33 // ดู mapDispatchToProps ด้านล่างประกอบ34 this.props.onLoadPages()35 // fetch(PAGES_ENDPOINT)36 // .then((response) => response.json())37 // .then((pages) => this.setState({ pages }))38 }3940 componentDidMount() {41 this.onReloadPages()42 }4344 render() {45 return <Pages pages={this.props.pages} onReloadPages={this.onReloadPages} />46 }47}4849// state ในที่นี้หมายถึงสถานะของแอพพลิเคชันที่เก็บอยู่ใน store50const mapStateToProps = (state) => ({51 // เมื่อ state ใน store มีการเปลี่ยนแปลง52 // เราไม่สนใจทุก state53 // เราสนใจแค่ state ของ pages54 // โดยทำการติดตั้ง pages ให้เป็น props55 // เราใช้ชื่อ key ของ object เป็นอะไร56 // key ตัวนั้นจะเป็นชื่อที่เรียกได้จาก props ของคอมโพแนนท์57 pages: state.pages,58})5960// ส่ง dispatch ของ store เข้าไปให้เรียกใช้61// อยาก dispatch อะไรไปให้ reducer ก็สอยเอาตามปรารถนาเลยครับ62const mapDispatchToProps = (dispatch) => ({63 onLoadPages() {64 // เมื่อเรียก this.props.onLoadPages65 // loadPages ที่เป็น action creator จะโดนปลุกขึ้นมาทำงาน66 // จากนั้นจะ return ก้อนอ็อบเจ็กต์ของ action67 // ส่งเข้าไปใน dispatch68 // store.dispatch จะไปปลุก reducer ให้มาจัดการกับ action ที่เกี่ยวข้อง69 dispatch(loadPages())70 },71})7273// วิธีใช้ connect สังเกตนะครับส่งสองฟังก์ชันคือ74// mapStateToProps และ mapDispatchToProps เข้าไปใน connect75// จะได้ฟังก์ชันใหม่ return กลับมา76// แล้วเราก็ส่ง PagesContainer ที่เป้นคอมโพแนนท์ที่ต้องการเชื่อมต่อกับ store77// เข้าไปในฟังก์ชันใหม่นี้อีกที78// มันคือ Higher-order function นั่นเอง79export default connect(mapStateToProps, mapDispatchToProps)(PagesContainer)
สังเกตตรง mapDispatchToProps นะครับ เรานิยาม onLoadPages ไว้ว่าคือการส่ง loadPages เข้าไปใน dispatch ถ้าเราต้องการทำเพียงแค่นี้เราไม่ต้องถึงกับสร้าง mapDispatchToProps ขึ้นมาก็ได้ แค่จับคู่ onLoadPages เข้ากับ loadPages ดังนี้
1export default connect(mapStateToProps, { onLoadPages: loadPages })(2 PagesContainer3)
เจาะลึก reducer
เราพูดถึง reducer กันมาหลายบรรทัดแล้ว ถึงเวลาต้องเปิดเผยความลับแห่งจักรวาลซะที เริ่มจากสร้างไฟล์ pages.js ภายใต้โฟลเดอร์ reducers ดังนี้ครับ
1const initialState = []23// reducer นั้นเป็นฟังก์ชันที่รับพารามิเตอร์สองตัว4// คือสถานะก่อนหน้า (previous state) และอ็อบเจ็กต์ action5// ตัวอย่างเช่นถ้าเราจะเพิ่มหน้าวิกิใหม่ สถานะก่อนหน้าอาจเป็นหน้าวิกิทั้งหมด6// เมื่อ reducer ทำงานเสร็จจะเพิ่มวิกิใหม่มี่เราพึ่งสร้าง เข้าไปในสถานะก่อนหน้าซึ่งก็คือวิกิทั้งหมดที่มีอยู่ก่อน7// ในกรณีที่เราไม่มีสถานะก่อนหน้า เราบอก reducer ว่าให้ใช้ค่า initialState8// ซึ่งก็คืออาร์เรย์ว่างเปล่าเป็นสถานะตั้งต้น9// สำหรับ [] ใน pages reducer นี้หมายความว่า10// เริ่มต้นนั้นเราไม่มีหน้าวิกิอยู่ในระบบเลย11export default (state = initialState, action) => {12 switch (action.type) {13 // เมื่อไหร่ก็ตามที่ action มีชนิดเป็น RECEIVE_PAGES14 // ให้แกะดูข้อมูล pages จากก้อนอ็อบเจ็กต์ action15 // pages นี้คือหน้าวิกิทั้งหมด16 // เราคืนค้ากลับออกไปจาก reducer เป็นวิกิทั้งหมดที่ได้จากอ็อบเจ็กต์ action17 case 'RECEIVE_PAGES':18 return action.pages19 // ในกรณีที่ไม่มี action ตรงกลับที่ระบุให้คืนค่ากลับออกจาก reducer เป็น state ตัวเดิม20 default:21 return state22 }23}
สิ่งที่เรา return กลับออกมาจาก reducer นี้จะกลายเป็น state ตัวถัดไปครับ ในกรณีของ RECEIVE_PAGES เมื่อเราโหลดวิกิทั้งหมดมาสำเร็จ หลังจากเรียก reducer แล้ว store จะเก็บค่า state ที่ return ออกมานี้ไว้ เมื่อมี action เกิดขึ้นอีกครั้งไอ้ state ตัวที่เก็บไว้อยู่ใน store นี่หละครับ จะกลายเป็น previous state ต่อไป
หัวใจหลักของ reducer คือกิจกรรมภายในตัว reducer ห้ามไปแก้ไข previous state ครับ เพราะฉะนั้น reducer แบบด้านล่างจึงผิด
1export default (state = initialState, action) => {2 switch (action.type) {3 // เมื่อ action คือการเพิ่มเพจใหม่ให้ทำส่วนนี้4 case 'ADD_PAGE':5 // ยัดเพจใหม่เข้าไปในวิกิทั้งหมดที่มีอยู่แต่เดิม6 // นี่คือการเปลี่ยนแปลง previous state7 // ไม่ควรทำ8 state.push(action.page)9 // จากนั้นก็โยน previous state ที่ผ่านการรุมยำเรียบร้อยแล้วออกไป10 return state11 default:12 return state13 }14}
ต้องบอกเลยครับการทำเช่นนี้เพื่อนๆจะได้แก้กรรมยัน production เลยฮะ มีสองสามเหตุผลที่ต้องคงความสดและซิงของ reducer เอาไว้ หนึ่งในนั้นที่อยากพูดถึงก็คือการใช้งานคู่กับ shouldComponentUpdate
ยังจำได้ไหมฮะ shouldComponentUpdate นั้นเราใช้เพื่อป้องกันไม่ให้คอมโพแนนท์ของเรา rerender ใหม่หลายๆรอบ เช่น
1shouldComponentUpdate(nextProps) {2 return this.props.pages !== nextProps.pages3}
ตัวอย่างข้างบนเป้นการตรวจสอบครับ ถ้า pages ก่อนหน้าและ pages ปัจจุบันตรงกัน แสดงว่าข้อมูลของเราอัพเดทแล้ว ไม่มีความจำเป็นใดๆต้อง rerender คอมโพแนนท์ใหม่อีกครั้ง ถ้าใครยังงงตรงจุดนี้ ผมแนะนำให้กลับไปอ่าน [Day #2] สอนการใช้งาน React.js และการเรียกใช้งาน RESTful API ด้วย React อีกครั้งในหัวข้อ รู้จักกับ React Reconciliation
ก่อนจะพูดถึงการทำงานของ reducer และ shouldComponentUpdate อยากทบทวนเพื่อนๆเรื่องนี้ก่อนครับ
1// arr ไม่ได้เก็บค่า [1, 2, 3, 4] นะครับ2// แต่สิ่งที่มันเก็บคือ memory address3const arr = [1, 2, 3]45const add4 = (arr) => {6 // ฉะนั้นแล้วการแก้ไข array ด้วยการเพิ่มของเข้าไปใหม่7 // จึงไม่ได้เป็นการเปลี่ยน memory address8 arr.push(4)9 return arr10}1112const newArr = add4(arr)1314console.log(newArr) // [1, 2, 3, 4]15// ผลของการเปรียบเทียบจึงเท่ากัน เพราะ memory address ไม่เปลี่ยน16// เปลี่ยนแต่ค่าข้างใน17console.log(arr === newArr) // true
เห็นอะไรไหมเอ่ย... ถ้าเราแก้ไขอาร์เรย์โดยตรงจากใน reducer นั่นหมายความว่า return this.props.pages !== nextProps.pages
ใน shouldComponentUpdate จะคืนค่ากลับเป็น true เสมอ ถ้าเพื่อนๆคนไหนอ่านแล้วยังงงแนะนำให้อ่าน 7 เรื่องพื้นฐานชวนสับสนใน JavaScript สำหรับผู้เริ่มต้น ในหัวข้อ 3. Equality Operators
ครับ
สงสัยกันไหมครับทำไมถึงชื่อ reducer? มันมาจากพฤติกรรมของมันครับ ใน JavaScript เรามี Array#reduce เพื่อใช้ลดไอเทมในอาร์เรย์ให้เหลือเป็นค่าตัวเดียวออกมา เช่น
1;[1, 2, 3, 4].reduce((sum, item) => sum + item, 0) // 10
reducer ใน Redux ก็เช่นกันมีไว้ reduce
สถานะของแอพพลิเคชันมันจึงได้ชื่อว่าเป็น reducer
ความจริงมีเพียงหนึ่งเดียว!
ตอนนี้เรามี reducer ตัวเดียวสำหรับเปลี่ยนสถานะของ pages ถ้าในอนาคตเรามี reducers เพิ่มขึ้นอีกหลายๆตัว เช่นอาจมี reducer สำหรับ users นั่นหมายความว่าตอนนี้สถานะของแอพพลิเคชันเรามีมากกว่าหนึ่งตัว มีทั้งสถานะของ pages และ users เราจะเก็บสถานะทั้งสองนี้แบบไหนดี?
ทางออกที่หนึ่ง ในเมื่อเรามีสองสถานะ เราก็เห็บแต่ละสถานะแยกออกจากกันในแต่ละ store ซิ พูดง่ายๆก็คือให้มี store ไว้เก็บสถานะของ pages และมีอีก store ไว้เก็บสถานะของ users ในมุมมองของคอมโพแนนท์นั้นให้เลือกสนใจเฉพาะ store ที่เราต้องการ state จากมัน เช่นคอมโพแนนท์ Index.js ที่ต้องการแสดงวิกิทั้งหมดก็ควรสนใจแค่ store ของ pages โดยเมินเฉยต่อ store ของ users
อีกวิธีหนึ่ง เราเก็บทุกๆสถานะไว้ใน store เพียงแค่ตัวเดียวไปเลย นั่นหมายความว่าทุกๆคอมโพแนนท์ที่เกี่ยวข้องจะ subscribe ไปที่ store เพียงตัวเดียวแล้วเลือกหยิบเฉพาะ state ที่คอมโพแนนท์นั้นสนใจไปใช้งาน
Redux เลือกวิธีเก็บ state แบบหลังครับ นั่นคือเก็บทุก state ไว้ใต้ store เดียว สาเหตุที่ Redux เลือกวิธีนี้เป็นเพราะมันง่ายต่อการจัดการครับ ผมจะยกตัวอย่างนึงให้ฟัง บางที state แต่ละตัวไม่ได้แยกขาดออกจากกันครับเช่น ถ้าเราอยู่หน้าแสดงวิกิที่จะแสดงเฉพาะวิกิที่เราสร้าง คอมโพแนนท์ของเราจะผูกติดอยู่กับ state ของ pages และ users ดังนั้นถ้าเราต้องการ debug โปรแกรมแล้วละก็เราคงต้องการเห็น state ทั้งหมดในครั้งเดียวใช่ไหมครับ? ถ้าเราเก็บทุก state ไว้ใน store เดียว เราแค่เรียก store.getState()
state ทั้งหมดก็จะออกมา เราสามารถดูความสัมพันธ์ของทั้งสอง state ได้ด้วยคำสั่งเดียว โดยไม่ต้องเรียก getState จากทีละ store นี่ละครับความจริงมีหนึ่งเดียวตามคอนเซ็ปต์ของ Redux ที่ว่า Single source of truth
วิธีการที่จะทำให้ได้มาซึ่ง store เดียวนั้นคือการรวมทุกๆ reducer เข้าด้วยกัน ยังจำได้ไหมเอ่ย reducer เป็นเพียงตัวจัดการเปลี่ยน state มันจะคืนค่ากลับออกมาเป็น state ใหม่ซึ่ง state ที่ได้มันก็คือก้อนอ็อบเจ็กต์ดีๆนี่เอง ทีนี้ถ้าเรามีหลาย reducer ไว้จัดการหลายๆ state เราก็ต้องรวม reducer เข้าด้วยกัน เพราะแต่ละ reducer จะคืนค่าเป็นอ็อบเจ็กต์เราจึงต้องรวมอ็อบเจ็กต์ทั้งหมดให้เป็นก้อนเดียว เพื่อจัดเก็บไว้เป็นสิ่งเดียวใน store
1// state ที่ได้จาก pages reducer2{3 pages: [4 {5 id: 1,6 title: 'title#1',7 content: 'content#1'8 },9 {10 id: 2,11 title: 'title#2',12 content: 'content#2'13 }14 ]15}1617// state ที่ได้จาก users reducer18{19 users: [20 {21 id: 1,22 email: 'babelcoder@gmail.com',23 name: 'babel coder'24 }25 ]26}2728// เพื่อที่จะให้ state ที่แยกจากกันรวมเป็นหนึ่งเดียว29// เราจึงต้องรวบรวม state ที่ได้จากแต่ละ reducer เข้าด้วยกัน30// ผลลัพธ์สุดท้ายเป็นดังนี้31{32 pages: [33 {34 id: 1,35 title: 'title#1',36 content: 'content#1'37 },38 {39 id: 2,40 title: 'title#2',41 content: 'content#2'42 }43 ],44 users: [45 {46 id: 1,47 email: 'babelcoder@gmail.com',48 name: 'babel coder'49 }50 ]51}52// คอมโพแนนท์ที่ subscribe store จะเลือกเอาเฉพาะ state ที่ตนเองสนใจไปใช้งาน
สร้างไฟล์ index.js ขึ้นมาภายใต้โฟลเดอร์ reducers ครับ เราจะทำการรวม state จากแต่ละ reducer กันแล้ว
1import { combineReducers } from 'redux'2import pages from './pages'34// ใช้ combineReducers เพื่อรวม reducer แต่ละตัวเข้าเป็นหนึ่ง5export default combineReducers({6 // ES2015 มีค่าเท่ากับ pages: pages7 // pages ตัวแรกที่เป็น key ของอ็อบเจ็กต์บอกว่า8 // เราจะใช้คำว่า pages เป็นคำในการเข้าถึง9 pages,10})
ไหนละ store? พูดถึงมันกันมาตั้งเยอะแต่นี้มีแต่ภาพล่องหน สร้างไฟล์ชื่อ configureStore.js ไว้ใต้โฟลเดอร์ store ครับแล้วเพิ่มโค๊ดตามนี้
1import { createStore } from 'redux'2import rootReducer from '../reducers'34export default () => {5 // วิธีการสร้าง store คือการเรียก createStore6 // โดยผ่าน reducer ตัวบนสุดหรือตัวที่เรารวม reducer ทุกตัวเข้าด้วยกัน7 // เราจะได้ store กลับออกมาเป็นผลลัพธ์8 const store = createStore(rootReducer)910 if (module.hot) {11 // เมื่อใดที่โค๊ดใน reducer เปลี่ยนแปลงเราแค่ HMR มัน12 // จำได้ไหมครับในตอนต้นที่ผมบอกว่าเราแยก state ไปไว้ใน store13 // แล้วแยกวิะีการเปลี่ยน state ไปไว้ใน reducer14 // เพราะต้องการให้ทุกครั้งที่แก้โค๊ด reducer แล้ว webpack จะ HMR เฉพาะ reducer15 // โดย state ปัจจุบันยังคงอยู่16 System.import('../reducers').then((nextRootReducer) =>17 store.replaceReducer(nextRootReducer.default)18 )19 }2021 return store22}
เราห่อหุ้มคอมโพแนนท์ของเราด้วย connect
เพื่อใช้ความสามารถของ Redux ในการเข้าถึง state ใน store เบื้องหลังการทำงานที่จะทำให้การเรียกใช้ connect สำเร็จคือการห่อหุ้มคอมโพแนนท์ไว้ภายใต้ Provider
เปิดไฟล์ Root.js แล้วแก้ไขตามนี้ครับ
1import React, { Component } from 'react'2import { Provider } from 'react-redux'3import configureStore from '../store/configureStore'4import routes from '../routes'56export default class App extends Component {7 render() {8 return (9 // เนื่องจากมีหลายคอมโพแนนท์ที่เรียก connect ได้10 // เราจึงครอบ Provider ไว้รอบ routes11 // เพราะเรารู้ว่าที่ตรงนี้คือคอมโพแนนท์บนสุดแล้ว12 // เมื่อคอมโพแนนท์ต่างๆภายใต้นี้เข้าถึง connect13 // จะอ้างอิงถึง store ได้ทันที14 <Provider store={configureStore()} key="provider">15 {routes()}16 </Provider>17 )18 }19}
เอาหละฮะจบไปแล้วครึ่งนึงของบทความนี้ เพื่อนๆลองรัน npm start แล้วเข้าไปยลโฉมหน้าความพยายามของพวกเราผ่าน Redux ได้เลย!
สรุปครึ่งแรกของบทความจาก Actions, Reducers สู่ Store
ทุกคนครับ ตอนนี้ขอให้ทุกคนจำภาพนี้ให้ขึ้นใจก่อน ผมจะเริ่มสรุปตั้งแต่ต้นตามภาพนี้ครับ
เมื่อผู้ใช้งานระบบกดปุ่ม reload pages บนหน้ารวมวิกิ สิ่งต่างๆต่อไปนี้จะเกิดขึ้น...
- คอมโพแนนท์เรียก action creator เพื่อสร้างก้อนอ็อบเจ็กต์ของ action
1export const loadPages = () => ({2 type: 'RECEIVE_PAGES',3 pages: [4 {5 id: 1,6 title: 'test page#1',7 content: 'TEST PAGE CONTENT',8 },9 {10 id: 2,11 title: 'test page#2',12 content: 'TEST PAGE CONTENT',13 },14 ],15})
- คอมโพแนนท์ส่ง action ไปให้ reducer ผ่าน dispatch
1const mapDispatchToProps = (dispatch) => ({2 onLoadPages() {3 dispatch(loadPages())4 },5})
- action ลอยไปตามสายลมจนถึง reducer ตัวบนสุดที่เราเรียกว่า reducer
1export default combineReducers({2 pages,3})
- root reducer รู้ว่าตัวเองมีลูกเป็น reducer อีกหนึ่งตัวชื่อ pages จึงส่ง action ลงไปให้ reducer ตัวลูก
- pages reducer มีวิธีการจัดการกับ action ชื่อ
RECEIVE_PAGES
จึงเริ่มทำงาน
1export default (state = initialState, action) => {2 switch (action.type) {3 case 'RECEIVE_PAGES':4 return action.pages5 default:6 return state7 }8}
- เมื่อ pages reducer ทำงานเสร็จ root reducer จะนำส่ง state ใหม่นี้ไปให้ store
- คอมโพแนนท์ subscribe store ผ่าน connect จึงรับรู้ถึงการเปลี่ยนแปลงของ state
1export default connect(mapStateToProps, { onLoadPages: loadPages })(2 PagesContainer3)
- คอมโพแนนท์เลือกเฉพาะ state ที่ตนเองสนใจเพื่อนำไปแสดงผลต่อไป
1const mapStateToProps = (state) => ({2 pages: state.pages,3})
เอาหละครับจบแล้วครึ่งแรก ใครจะพักแล้วเก็บไว้อ่านต่อวันหลังก็ได้นะครับ แต่ใครที่ยังไหว เราไปลุยกันต่อกับครึ่งหลังของบทความกันครับ!
Hello Middleware
ตอนนี้ใน actions/page.js ของเราใส่ก้อน json ของวิกิที่เราตั้งขึ้นมาเองมั่วๆ ดังนี้
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}
แต่ในการใช้งานจริงเราต้องการดึงข้อมูลจากเซิร์ฟเวอร์มาแสดงผลต่างหาก ฉะนั้นแล้วเรามาเปลี่ยนแปลงโค๊ดกันนิดหน่อยใน actions/page.js เพื่อสนับสนุนให้โหลดข้อมูลมาจากเซิร์ฟเวอร์
1import fetch from 'isomorphic-fetch'2import { PAGES_ENDPOINT } from '../constants/endpoints'34// เมื่อได้รับข้อมูล pages จากเซิร์ฟเวอร์แล้ว5// ให้ส่งเข้า reducer ไปเลย6const receivePages = (pages) => ({7 type: 'RECEIVE_PAGES',8 pages,9})1011export const loadPages = () =>12 // ดึงข้อมูลจากเซิร์ฟเวอร์13 // ฟังก์ชันนี้ return promise ออกไปนะครับ14 // สังเกตดีๆสิ่งที่คืนออกไปไม่ใช่ก้อนอ็อบเจ็กต์ของ action ละนะ15 fetch(PAGES_ENDPOINT)16 .then((response) => response.json())17 // เมื่อได้รับข้อมูลแล้ว จึงส่งไปให้ receivePages ซึ่งเป็น action creator18 // ให้ช่วยสร้าง type และห่อหุ้มข้อมูลเป็นก้่อนอ็อบเจ็กต์ของ action19 .then((pages) => receivePages(pages))
จากนั้นก็ไปลบโค๊ดที่เราไม่ต้องการใช้แล้วใน containers/Pages/Index.js โค๊ดที่สมบูรณ์เป็นดังนี้
1import React, { Component, PropTypes } from 'react'2import { connect } from 'react-redux'3import { loadPages } from '../../actions/page'4import { Pages } from '../../components'56class PagesContainer extends Component {7 static propTypes = {8 pages: PropTypes.array.isRequired,9 onLoadPages: PropTypes.func.isRequired,10 }1112 shouldComponentUpdate(nextProps) {13 return this.props.pages !== nextProps.pages14 }1516 onReloadPages = () => {17 this.props.onLoadPages()18 }1920 componentDidMount() {21 this.onReloadPages()22 }2324 render() {25 return <Pages pages={this.props.pages} onReloadPages={this.onReloadPages} />26 }27}2829const mapStateToProps = (state) => ({30 pages: state.pages,31})3233export default connect(mapStateToProps, { onLoadPages: loadPages })(34 PagesContainer35)
ท้าวความกันนิดนึงครับ เวลาเราเรียกให้ reducer ทำงานเราจะเรียกผ่าน dispatch ใช่ไหม dispatch เนี่ยจะรับก้อนอ็อบเจ็กต์ของ action เข้าไป แต่ตอนนี้สิ่งที่เราทำอยู่คือเรียก dispatch(loadPages()) เมื่อมีคนคลิกปุ่ม reload pages แต่เอ.. loadPages ของเรามันคืนค่ากลับมาเป็น promise นะครับ แต่ dispatch ของเราปรารถนาให้ส่งก้อน action ต่างหากละ?
เพื่อให้การทำงานถูกต้อง เราจึงต้องไปแอบแก้ไข dispatch ให้สามารถทำงานร่วมกับ promise ได้ เปิดไฟล์ configureStore.js แล้วเพิ่มเติมตามนี้เลยครับ
1import { createStore } from 'redux'2import rootReducer from '../reducers'34// ก่อนจะมาอ่านตรงนี้ อ่านคอมเม้นข้างล่างตรง store.dispatch ก่อนนะครับ5// เรารับ store เข้ามาเพื่อเข้าถึง dispatch6const promise = (store) => {7 // next ในที่นี้คือ dispatch ตัวดั้งเดิม8 const next = store.dispatch910 // เนื่องจากเราจะสร้าง dispatch ตัวใหม่11 // เราจึงต้องทำตัวให้เหมือน dispatch12 // dispatch นั้นรับ action เข้ามา เราจึงต้องรับ action เช่นกัน13 return (action) => {14 // ตรวจสอบซักหน่อย ถ้า action มี then เป็นฟังก์ชันแสดงว่ามันเป็น promise15 if (typeof action.then === 'function')16 // เมื่อเป็น promise เราจึงให้มันทำงานให้เสร็จก่อน17 // จากนั้นจึงค่อยเรียก dispatch ตัวดังเดิมทำงานต่อไป18 return action.then(next)19 return next(action)20 }21}2223export default () => {24 const store = createStore(rootReducer)25 // เปลี่ยนแปลงการทำงานของ dispatch นิดนึงแล้วกัน26 // เราบอกว่าให้ dispatch นั้นมีค่าเป็นสิ่งที่คืนกลับมาจากการเรียก promise(store)27 store.dispatch = promise(store)2829 if (module.hot) {30 module.hot.accept('../reducers', () => {31 const nextRootReducer = require('../reducers')32 store.replaceReducer(nextRootReducer)33 })34 }3536 return store37}
จะเห็นว่าเรื่องราวชั่งซับซ้อนครับ เราต้องเข้าใจเรื่อง promise พอสมควร เพื่อนๆคนไหนยังสับสนแนะนำให้อ่านเรื่อง รู้ลึกการทำงานแบบ Asynchronous กับ Event Loop และ กำจัด Callback Hell ด้วย Promise และ Async/Await
ลำดับการทำงานของ dispatch หลังเราปู้ยี้ปู้ยำมันเป็นดังนี้ ตรวจสอบก่อนว่าเป็น promise ไหม ถ้าเป็นจัดการแก้ปัญหาชีวิตซะก่อนแล้วค่อยเรียก dispatch ตัวเดิมมาทำงาน แต่ถ้าไม่เป็น promise ก็แค่เรียก dispatch ตัวปกติมาทำงานได้เลย
ทำไมเราต้อง resolve promise ก่อน? นั่นเป็นเพราะถ้าเราไม่ทำ promise ก่อนแล้ว จะไม่มีสิ่งใดตอบกลับมาจากเซิร์ฟเวอร์นั่นเอง เมื่อเซิร์ฟเวอร์โยนข้อมูลกลับมาแล้ว จะสังเกตเห็นว่าเราเรียก action creator ชื่อ receivePages ต่ออีกทีซึ่งตัวนี้จะสร้างอ็อบเจ็กต์ของ action ขึ้นมา และนี่หละครับคือจุดที่ dispatch ตัวดั้งเดิมจะเข้ามามีบทบาท บทบาทของมันก็คือรับ action ตัวนี้เข้าไปส่งมอบให้ reducer นั่นเอง
จะเห็นว่าสิ่งที่เราทำนั้นเป็นคาบเกี่ยวระหว่างการ dispatch action ไปให้ reducer สิ่งที่เราทำเพื่อแทรกกลางระหว่างสองเรานี้เรียกว่า Middleware ตัวละครตัวสุดท้ายของเราที่จะมีหรือไม่มีก็ได้
จัดการ middleware อย่างชาญฉลาดด้วยการใช้ของชาวบ้าน!
นอกจากเราจะเป็น Google Programmer ที่พัฒนาโปรแกรมด้วยแบบแผน Stackoverflow-driven Development แล้ว เรายังประกอบโค๊ดเป็นจิ๊กซอว์ด้วยการไม่เขียนเองแต่ใช้ของที่ชาวบ้านทำไว้แล้ว จับมายำๆผสมในโปรเจคเราเอง
จากที่ผ่านมาเราดึงข้อมูลจากเซิร์ฟเวอร์มาแสดงผล เมื่อข้อมูลมาถึงก็จับไปแสดงบนหน้าจอ แต่ถ้าข้อมูลยังไม่มาก็ไม่มีอะไรเกิดขึ้น จะดีกว่าไหมถ้าเราจะขึ้นข้อความว่า กำลังโหลดอยู่นะจ๊ะ
ถ้าข้อมูลยังไม่มา และขึ้นข้อความว่า ซวยแหล่วๆ
เมื่อเซิร์ฟเวอร์ส่งข้อผิดพลาดกลับมา
เพื่อให้ทำได้ตามที่คาดหวัง เราจึงต้องแยกประเภทของ action เสียใหม่เป็นสามประเภท คือ LOAD_PAGES_REQUEST
ที่จะเรียกเมื่อเริ่มส่งคำร้องไปหาเซิร์ฟเวอร์, LOAD_PAGES_SUCCESS
เมื่อเซิร์ฟเวอร์ตอบข้อมูลกลับมา และสุดท้ายคือ LOAD_PAGES_FAILURE
หากเซิร์ฟเวอร์ตอบกลับมาด้วยข้อผิดพลาด
เรามาแก้ actions/page.js ให้เป็นไปตามนี้กันครับ
1import fetch from 'isomorphic-fetch'2import { PAGES_ENDPOINT } from '../constants/endpoints'34export const loadPages = () => {5 // เมื่อเรียก loadPages จะคืนค่ากลับเป็นฟังก์ชันที่รับ dispatch เข้ามา6 return (dispatch) => {7 // ก่อนอื่นเมื่อเรียก loadPages ก็ให้สร้าง action เพื่อบอกว่ากำลังโหลดนะ8 dispatch({9 type: 'LOAD_PAGES_REQUEST',10 })1112 fetch(PAGES_ENDPOINT)13 .then((response) => response.json())14 .then(15 // เมื่อโหลดเสร็จแล้วก็สร้าง action เพื่อบอกว่าสำเร็จแล้ว16 // พร้อมส่ง pages วิ่งเข้าไปใน reducer17 (pages) =>18 dispatch({19 type: 'LOAD_PAGES_SUCCESS',20 pages,21 }),22 // หากเกิดข้อผิดพลาด ใช้ action ตัวนี้23 (error) =>24 dispatch({25 type: 'LOAD_PAGES_FAILURE',26 })27 )28 }29}
ฟังก์ชัน loadPages ของเราตอนนี้เป็น Higher-order Function ไปซะแล้ว ตอนนี้มันคืนค่ากลับออกมาเป็นฟังก์ชันอีกตัวที่รับ dispatch เข้าไปในตัวมันเพื่อให้เราสามารถ dispatch ทุกสรรพสิ่งที่เราปรารถนาภายใต้ฟังก์ชันนั้น แต่... ในคอมโพแนนท์ของเราเวลาเรียกเราก็เรียกแค่ loadPages() นี่นา แต่มันส่งค่ากลับเป็นฟังก์ชันอีกทอดแบบนี้ต้องไปแก้อะไรในคอมโพแนนท์ไหม?
เราไม่ต้องจัดการอะไรเพิ่มเติมในคอมโพแนนท์ครับ เราอาศัยความสามารถในการแทรกกลางระหว่างเราของ Middleware ที่จะทำตัวเป็นตัวเผือกแทรกระหว่าง action และ reducer สิ่งที่เราต้องทำมีแค่สร้าง middleware ขึ้นมาตัวหนึ่ง โดย middleware ตัวนี้จะแอบยัด dispatch เข้าไปให้ ไปดูกันเลย เปิดไฟล์ configureStore.js แล้วแก้ไขตามนี้
1import { createStore, applyMiddleware } from 'redux'2import rootReducer from '../reducers'34// Middleware ตัวนี้ละฮะที่เขามาแทรกกลางระหว่างสองเรา5// สังเกตการสร้าง middleware ใน Redux ให้ดีนะครับ6// มันช่างเป็นฟังก์ชันซ้อนฟังก์ชันซะจริง7// จำรูปแบบไว้ง่ายๆว่า store => next => action8const thunk = (store) => (next) => (action) =>9 // ถ้า action เป็น function เราก็ยัดเยียด dispatch เข้าไปเลย10 // ในที่นี้เราส่งทั้ง dispatch และ getState เข้าไป11 // ในส่วนของ action เราต้องการใช้แค่ dispatch เราก็เลยไม่อ้างถึง getState12 typeof action === 'function'13 ? action(store.dispatch, store.getState)14 : // แต่ถ้าใน action creator เราไม่ได้ประกาศว่า (dispatch) => {...}15 // ก็คือไม่ได้ประกาศคืนค่ากลับเป็นฟังก์ชัน16 // เราก็เรียกมันทำงานซะเลย โดยไม่ส่ง dispatch กับ getState ไปให้17 next(action)1819export default () => {20 // ประกาศ middlewares เป็นอาร์เรย์ซะก่อน21 // ที่ทำเช่นนี้เพราะในอนาคตเราอาจได้ใช้ middleware ตัวอื่นอีก22 // เราสามารถเพิ่มทีหลังเข้าไปในอาร์เรย์นี้ได้23 const middlewares = [thunk]24 const store = createStore(25 rootReducer,26 // จะใช้ middleware อะไรก็ยัดเยียดเข้าไปใน applyMiddleware ซะเลย27 applyMiddleware(...middlewares)28 )2930 if (module.hot) {31 module.hot.accept('../reducers', () => {32 System.import('../reducers').then((nextRootReducer) =>33 store.replaceReducer(nextRootReducer.default)34 )35 })36 }3738 return store39}
สุดท้ายเมื่อเราแก้ชื่อ action ก็อย่าลืมไปอัพเดทกันใน reducers/pages.js นะครับ
1const initialState = []23export default (state = initialState, action) => {4 switch(action.type) {5 case 'LOAD_PAGES_SUCCESS': << ไปมองที่ไหนเล่า อยู่ตรงนี้!6 return action.pages7 default:8 return state9 }10}
แบบฝึกหัด: ให้สร้างคอมโพแนนท์ขึ้นมาหนึ่งตัวตั้งชื่อว่า FlashMessage โดยให้คอมโพแนนท์นี้อยู่ใต้ Navbar ทุกครั้งที่หน้า Index ของวิกิเริ่มโหลดหรือผู้ใช้งานกดปุ่ม reload pages ให้แสดงข้อความว่า
Loading
ในคอมโพแนนท์ FlashMessage ในทางกลับกันให้เพิ่มข้อผิดพลาดเข้าไปในคอมโพแนนท์เมื่อเซิร์ฟเวอร์ตอบกลับมาด้วยข้อผิดพลาด เมื่อใดที่การโหลดวิกิสำเร็จให้ยกเลิกการแสดงคอมโพแนนท์นั้น Hint: เพิ่ม action ที่เกี่ยวข้องลงใน actions/page.js รวมถึงเพิ่มสถานะบางอย่างที่บ่งบอกถึงว่ากำลังโหลดหรือมีข้อผิดพลาดเกิดขึ้น
เอาหละได้เวลาแสดงความขี้เกียจกันแล้ว เราจะไม่มานั่งเขียน thunk กันเองรวมถึงไม่มานั่งจัดการกับ AJAX อีกต่อไปเพราะเรานั้่นขี้เกียจ! ในที่นี้ผมจะใช้ middleware ตัวนึงเข้ามาช่วยในการติดต่อเพื่อรับข้อมูลจากเซิร์ฟเวอร์นั่นคือ redux-api-middleware ติดตั้งตามนี้เลยครับ
1npm i --save redux-api-middleware redux-thunk
เมื่อทำการติดตั้งเสร็จแล้ว เราก็ต้องรายงานให้ Redux นั้นรับรู้ว่าเราจะใช้ middleware ตัวนี้นะ
1// configureStore.js2import { createStore, applyMiddleware } from 'redux'3import thunk from 'redux-thunk'4import { apiMiddleware } from 'redux-api-middleware'5import rootReducer from '../reducers'67export default () => {8 // ใช้ middleware ตัวที่พึ่งติดตั้งไป9 const middlewares = [thunk, apiMiddleware]10 const store = createStore(11 rootReducer,12 applyMiddleware(...middlewares)13 )1415 ...16 ...1718 return store19}
กฏเกณฑ์การใช้งานนั้นแสนเรียบง่าย เพียงแค่เราบอกว่าจะมี action อะไรสำหรับการร้องขอข้อมูล การได้รับข้อมูลแล้ว และการจัดการข้อผิดพลาดจากเซิร์ฟเวอร์ เราเขียนบอก action สามตัวนี้เข้าไปใน action creator ที่เหลือ redux-api-middleware จะจัดการให้เอง
1// actions/page.js2import { CALL_API } from 'redux-api-middleware'3import { PAGES_ENDPOINT } from '../constants/endpoints'45export const loadPages = () => ({6 // ต้องมี Symbol ตัวนี้เพื่อบอกให้ redux-api-middleware รับทราบ7 // ว่าสิ่งที่อยู่ในนี้มันควรเป็นผู้จัดการ8 // หากปราศจาก Symbol ตัวนี้9 // redux-api-middleware จะเมินเฉยไม่สนใจ10 [CALL_API]: {11 endpoint: PAGES_ENDPOINT,12 method: 'GET',13 types: ['LOAD_PAGES_REQUEST', 'LOAD_PAGES_SUCCESS', 'LOAD_PAGES_FAILURE'],14 },15})
redux-api-middleware นั้นจะคืนค่าจากเซิร์ฟเวอร์กลับมาในรูปของ payload หน้าตาของก้อนอ็อบเจ็กต์ที่ redux-api-middle ปั้นแต่งนั้นมีลักษณะแบบนี้
1{2 type: 'LOAD_PAGES_SUCCESS',3 // ถ้าเซิร์ฟเวอร์คืนค่ากลับเป็น { pages: {....} }4 // ผลลัพธ์ที่ได้จะเป็น { payload: { pages: [...] } }5 // แต่เซิร์ฟเวอร์ของเราส่งกลับเป็นอาร์เรย์เลย จึงไม่มี pages ซ้อนอีกชั้น6 payload: [7 { id: 1, title: 'Title#1', content: 'Content#1' }8 ]9}
และเจ้าก้อนอ็อบเจ็กต์ตัวนี้หละฮะ ที่จะล่องลอยไปตามสายลมเข้าสู่ reducer ฉะนั้นแล้วเราต้องไปแก้ reducers/pages.js ตามนี้
1const initialState = []23export default (state = initialState, action) => {4 switch (action.type) {5 case 'LOAD_PAGES_SUCCESS':6 // จากเดิมเป็น action.pages7 // แต่ตอนนี้ก้อนอ็อบเจ็กต์ที่เข้ามาอยู่ในชื่อ payload แล้ว8 return action.payload9 default:10 return state11 }12}
สิ่งสุดท้ายที่เราจะพูดถึงในหัวข้อนี้นั่นคือ logger
รู้สึกไหมครับเราอยากรู้อยากเห็นว่าตอนนี้แอพพลิเคชันของเราทำ action อะไรไปแล้วบ้าง ก่อนทำ action สถานะของแอพพลิเคชันเราเป็นอย่างไร และหลังทำ action แล้วสถานะของแอพพลิเคชันเราเปลี่ยนไปอย่างไร ด้วยความสามารถของ redux-logger เวทย์มนต์นี้จึงเกิดขึ้น ติดตั้ง redux-logger ด้วยคำสั่งนี้
1npm i --save-dev redux-logger
เข้าไปแก้ไข configuteStore.js อีกครั้งเพื่อเรียกใช้ redux-logger
1import { createStore, applyMiddleware } from 'redux'2import { apiMiddleware } from 'redux-api-middleware'3import createLogger from 'redux-logger'4import rootReducer from '../reducers'56export default () => {7 const middlewares = [apiMiddleware]89 // คงไม่มีใครอยากให้ logger ไปพ่นๆข้อความใน production ใช่ไหม10 // เราจึงตรวจสอบก่อน ถ้าเป็น production แล้วริวจะไม่ยุ่ง11 if (process.env.NODE_ENV !== 'production')12 // แต่ถ้าไม่ใช่ production เจนคงสัมผัสได้...13 middlewares.push(createLogger())1415 const store = createStore(rootReducer, applyMiddleware(...middlewares))1617 if (module.hot) {18 module.hot.accept('../reducers', () => {19 System.import('../reducers').then((nextRootReducer) =>20 store.replaceReducer(nextRootReducer.default)21 )22 })23 }2425 return store26}
npm start อีกครั้งแล้วดูผลลัพธ์ที่เกิดขึ้นได้เลยครับ...
สรุปตัวละครทั้งหมดของ Redux
รูปเดิมเอามาหากินอีกครั้ง ดูประกอบแล้วทำความเข้าใจตามครับ เมื่อมีเหตุการณ์หนึ่งๆเกิดขึ้นบนคอมโพแนนท์ สิ่งเหล่านี้จะเกิดขึ้นเป็นลำดับ
- คอมโพแนนท์รับรู้ว่ามี event เกิดขึ้น
- คอมโพแนนท์ไม่รู้วิธีจัดการสถานะของแอพพลิเคชันตาม event ที่เกิดขึ้น
- คอมโพแนนท์จึงเรียก action creator เพื่อสร้างก้อนอ็อบเจ็กต์ของ action
- เราได้ action ที่ประกอบด้วย type และข้อมูลมาแล้ว
- คอมโพแนนท์เรียก dispatch เพื่อโยน action นั้นให้ล่องลอยไปหวังว่าจะมีใครซักคนคอยรับไปจัดการ
- ถ้ามี middleware มันจะเข้าไปปู้ยี่ปู้ยำก้อน action นั้นก่อนส่งต่อให้ reducer
- root reducer จมูกไว เจอกลิ่นของ action จึงรับเข้ามาอุปการะ
- root reducer ส่ง action เป็นทอดๆไปให้ reducer อื่นๆที่เป็นลูกหลานเหลนโหลน
- reducer แต่ละตัวถ้าเขียนจับเอาไว้ให้ตอบสนองต่อ action นั้น มันจะรับ action นั้นเข้าไปทำงาน
- reducer
reduce
state ก่อนหน้าให้เป็น state ใหม่ ตามประเภทของ action ที่ส่งเข้ามา - state ใหม่ที่ได้จะไปเก็บเป็นสถานะใหม่ของแอพพลิเคชันใน store
- คอมโพแนนท์เองนั้นมีต่อมเผือกที่ผูกพันธ์อยู่กับ store เมื่อ state ใน store อัพเดท คอมโพแนนท์จะรับช่วงต่อ
- คอมโพแนนท์เลือกเฟ้นเฉพาะ state ที่ตนเองสนใจจากสถานะทั้งหมดที่ได้รับมา
- คอมโพแนนท์ rerender ใหม่ด้วยสถานะใหม่ที่ได้รับ
ปรับเปลี่ยนหน้า Show ด้วย Redux
เราแก้ไขหน้า Index กันแล้ว ต่อไปเป็นตาของหน้า Show บ้างครับ ลงมือแก้ไขตามนี้เลยครับ
1// actions/page.js2import { CALL_API } from 'redux-api-middleware'3import { PAGES_ENDPOINT } from '../constants/endpoints'45export const loadPages = () => ({6 [CALL_API]: {7 endpoint: PAGES_ENDPOINT,8 method: 'GET',9 types: ['LOAD_PAGES_REQUEST', 'LOAD_PAGES_SUCCESS', 'LOAD_PAGES_FAILURE'],10 },11})1213// สำหรับโหลดวิกิแค่เพจเดียว14export const loadPage = (id) => ({15 [CALL_API]: {16 endpoint: `${PAGES_ENDPOINT}/${id}`,17 method: 'GET',18 types: ['LOAD_PAGE_REQUEST', 'LOAD_PAGE_SUCCESS', 'LOAD_PAGE_FAILURE'],19 },20})2122// reducers/pages.js23const initialState = []2425export default (state = initialState, action) => {26 switch (action.type) {27 case 'LOAD_PAGES_SUCCESS':28 return action.payload29 // เมื่อโหลดวิกิมาเพจเดียวก็ให้สถานะของแอพพลิเคชันเรามีเพจเดียว30 case 'LOAD_PAGE_SUCCESS':31 return [action.payload]32 default:33 return state34 }35}3637// เวลาเข้าหน้า Show เราจะเอาวิกิเพจไหนมาแสดงให้ค้นหาด้วย ID38export const getPageById = (state, id) =>39 state.pages.find((page) => page.id === +id) || { title: '', content: '' }4041// containers/Show.js42import React, { Component, PropTypes } from 'react'43import { connect } from 'react-redux'44import { loadPage } from '../../actions/page'45import { getPageById } from '../../reducers/pages'46import { ShowPage } from '../../components'4748class ShowPageContainer extends Component {49 static propTypes = {50 page: PropTypes.object.isRequired,51 onLoadPage: PropTypes.func.isRequired,52 }5354 shouldComponentUpdate(nextProps) {55 return this.props.page !== nextProps.page56 }5758 componentDidMount() {59 const {60 onLoadPage,61 params: { id },62 } = this.props63 // โหลดเพจตาม ID ที่ปรากฎบน URL64 onLoadPage(id)65 }6667 render() {68 const { id, title, content } = this.props.page6970 return <ShowPage id={id} title={title} content={content} />71 }72}7374const mapStateToProps = (state, ownProps) => ({75 // เลือกเพจด้วย ID76 page: getPageById(state, ownProps.params.id),77})7879export default connect(mapStateToProps, { onLoadPage: loadPage })(80 ShowPageContainer81)
เอาหละครับเพื่อนๆคงเกิดคำถามแน่ว่าทำไมตอนเราได้วิกิมาหนึ่งหน้า เราตั้งค่าให้ pages เป็นอาร์เรย์ที่ประกอบด้วยวิกิหน้าเดียวแล้ว แต่สุดท้ายต้องมาคุ้ยหาหน้าวิกิผ่าน ID อีก? นั่นเป็นเพราะการเข้าถึงวิกิโดยการเรียก state.pages[0]
มันทำให้การกลับมาอ่านโค๊ดอีกครั้งลำบาก เราจะไม่เข้าใจเหตุผลเลยว่าทำไมต้องเข้าถึงตำแหน่งที่ 0 แต่ถ้าเราอ้างผ่าน ID มันเป็นการอธิบายโค๊ดในตนเองแล้วว่าเรากำลังทำอะไรอยู่
วิธีการนี้ยังไม่ดีพอครับ เพราะเราไม่สามารถ cache วิกิเพจได้เลย ทุกครั้งที่เราเปลี่ยนไปหน้า Show วิกิทั้งหมดที่เคยโหลดมาก็จะหายเหลือเพียงวิกิใหม่ตัวเดียว ทางแก้ก็คือจัดโครงสร้างใหม่ให้กับ state แบบนี้
1{2 result: {3 pages: [1, 2, 3, 4]4 },5 entitles: {6 pages: [7 {8 id: 1,9 title: 'Title#1',10 content: 'Content#111 },12 ...13 ...14 ...15 ]16 }17}
เมื่อเวลาเราได้วิกิหน้าใหม่มา เราแค่เพิ่ม ID เข้าไปใน result.pages
พร้อมทั้งเพิ่มอ็อบเจ็กต์ตัวเต็มไว้ใน entities.pages
เวลาเราจะเข้าถึงหน้า Show เราแค่เรียก entities.pages[ID]
แค่นี้ก็ได้ผลลัพธ์กลับมาแล้ว ทั้งนี้เรายังคงแคชผลลัพธ์เก่าไว้ได้ด้วย นี่เป็นวิธีการที่ใช้ใน normalizr ซึ่งเราจะไม่กล่าวถึงกันในที่นี้ เชื่อว่าหลายคนคงภาวนาให้รีบจบบทความจะแย่แล้ว ใช่ไหมหละครับ ^^
ก่อนที่เราจะเดินหน้าไปกันต่อ มาทำโค๊ดให้ดูดีขึ้นกันก่อนครับ
Refactor กันซะหน่อย
อย่างแรกเลยที่เราจะทำคือย้ายค่าคงที่ไปไว้ข้างนอก บรรดา LOAD_PAGES_REQUEST, LOAD_PAGES_SUCCESS หรืออื่นๆพวกนี้เราควรแยกไปประกาศไว้อีกไฟล์ ทำไมหนะหรอครับ? เพราะว่าเวลาเราทำงานกันเป็นทีม ต่างคนต่างทำจะเกิดปัญหาชื่อ action ชนกันได้ ถ้าเราย้ายการประกาศชื่อ action ไปรวมกันไว้ที่ไฟล์หนึ่ง เวลาใครอยากสร้าง action ใหม่จะได้รู้ว่ามี action อะไรใช้ไปแล้วบ้าง
สร้างไฟล์ใหม่ชื่อ actionTypes.js ภายใต้โฟลเดอร์ constants
1export const LOAD_PAGES_REQUEST = 'LOAD_PAGES_REQUEST'2export const LOAD_PAGES_SUCCESS = 'LOAD_PAGES_SUCCESS'3export const LOAD_PAGES_FAILURE = 'LOAD_PAGES_FAILURE'45export const LOAD_PAGE_REQUEST = 'LOAD_PAGE_REQUEST'6export const LOAD_PAGE_SUCCESS = 'LOAD_PAGE_SUCCESS'7export const LOAD_PAGE_FAILURE = 'LOAD_PAGE_FAILURE'
ProTips! สังเกตไหมครับทำไมเราประกาศตัวแปรด้วยชื่อที่เหมือนกันกับค่าของมันแล้ว เราต้องพิมพ์ซ้ำสองครั้ง? วิธีการนี้ขัดหลักการ DRY (Don't Repeat Yourself) จะดีกว่าไหมถ้าเราพิมพ์แค่นี้ แต่ได้ผลลัพธ์เช่นเดียวกัน
JavaScript1{2 LOAD_PAGES_REQUEST: null,3 LOAD_PAGES_SUCCESS: null,4 ...5}ความปรารถนาของคุณจะเป็นจริงครับถ้าใช้ keyMirror
เอาหละต่อไปก็แก้ไฟล์อื่นที่มีชื่อ action อยู่ด้วย
1// reducers/pages.js2import { LOAD_PAGES_SUCCESS, LOAD_PAGE_SUCCESS } from '../constants/actionTypes'34const initialState = []56export default (state = initialState, action) => {7 switch (action.type) {8 case LOAD_PAGES_SUCCESS:9 return action.payload10 case LOAD_PAGE_SUCCESS:11 return [action.payload]12 default:13 return state14 }15}1617export const getPageById = (state, id) =>18 state.pages.find((page) => page.id === +id) || { title: '', content: '' }1920// actions/page.js21import { CALL_API } from 'redux-api-middleware'22import { PAGES_ENDPOINT } from '../constants/endpoints'23import {24 LOAD_PAGES_REQUEST,25 LOAD_PAGES_SUCCESS,26 LOAD_PAGES_FAILURE,27 LOAD_PAGE_REQUEST,28 LOAD_PAGE_SUCCESS,29 LOAD_PAGE_FAILURE,30} from '../constants/actionTypes'3132export const loadPages = () => ({33 [CALL_API]: {34 endpoint: PAGES_ENDPOINT,35 method: 'GET',36 types: [LOAD_PAGES_REQUEST, LOAD_PAGES_SUCCESS, LOAD_PAGES_FAILURE],37 },38})3940export const loadPage = (id) => ({41 [CALL_API]: {42 endpoint: `${PAGES_ENDPOINT}/${id}`,43 method: 'GET',44 types: [LOAD_PAGE_REQUEST, LOAD_PAGE_SUCCESS, LOAD_PAGE_FAILURE],45 },46})
เนื่องจากตอนนี้ mapStateToProps ของเราในแต่ละคอมโพแนนท์ช่างสั้นเหลือเกิน เราเอาไปรวมไว้ใน connect เลยครับ
1// containers/Index.js2export default connect((state) => ({ pages: state.pages }), {3 onLoadPages: loadPages,4})(PagesContainer)56// containers/Show.js7export default connect(8 (state, ownProps) => ({ page: getPageById(state, ownProps.params.id) }),9 { onLoadPage: loadPage }10)(ShowPageContainer)
ถึงเวลาสร้างวิกิแล้ว
หัวข้อนี้จะเป็นหัวข้อสุดท้ายสำหรับบทความนี้แล้ว เอ้าพวกเราดีใจกันหน่อย! เนื่องจากการสร้างวิกินั้นเราต้องทำงานกับฟอร์ม และเพื่อให้ชีวิตเราง่ายขึ้นผมแนะนำให้ใช้ redux-form เป็นตัวช่วยสร้างฟอร์มครับ ติดตั้งกันเลยรออะไรเล่า
1npm i --save redux-form
ตอนนี้เราต้องสร้าง action ใหม่สำหรับการสร้างวิกิแล้ว เพิ่มสาม action ต่อไปนี้ลงใน actionTypes.js
1export const CREATE_PAGE_REQUEST = 'CREATE_PAGE_REQUEST'2export const CREATE_PAGE_SUCCESS = 'CREATE_PAGE_SUCCESS'3export const CREATE_PAGE_FAILURE = 'CREATE_PAGE_FAILURE'
จากนั้นไปเพิ่ม action creator ตัวใหม่เพื่อใช้ในการสร้างวิกิที่ actions/page.js
1import { CALL_API } from 'redux-api-middleware'2import { PAGES_ENDPOINT } from '../constants/endpoints'3import {4 ...5 ...67 CREATE_PAGE_REQUEST,8 CREATE_PAGE_SUCCESS,9 CREATE_PAGE_FAILURE10} from '../constants/actionTypes'1112...13...1415export const createPage = (values) => ({16 [CALL_API]: {17 endpoint: PAGES_ENDPOINT,18 headers: {19 'Accept': 'application/json',20 'Content-Type': 'application/json'21 },22 method: 'POST',23 body: JSON.stringify(values),24 types: [CREATE_PAGE_REQUEST, CREATE_PAGE_SUCCESS, CREATE_PAGE_FAILURE]25 }26})
ตอนนี้ถึงเวลาสร้าง route แล้ว เราจะให้ทุกครั้งที่เข้า path /pages/new เป็นการไปสู่หน้าสร้างวิกิใหม่ แก้ไข routes.js ดังนี้
1import React from 'react'2import { Router, Route, IndexRoute, browserHistory } from 'react-router'3import {4 Pages,5 ShowPage,6 NewPage7} from './containers'8import {9 App,10 Home11} from './components'1213export default () => {14 return (15 <Router history={browserHistory}>16 <Route path='/'17 component={App}>18 <IndexRoute component={Home} />19 <route path='pages'>20 <IndexRoute component={Pages} />21 {/* เพิ่ม route ใหม่ชื่อ new *}22 <route path='new'23 component={NewPage} />24 <route path=':id'25 component={ShowPage} />26 </route>27 </Route>28 </Router>29 )30}
จากนั้นจึงสร้าง container component ขึ้นมาชื่อ containers/Pages/New.js
1import React, { Component } from 'react'2import Form from './Form'34export default class NewPageContainer extends Component {5 render() {6 return (7 // เรารู้ว่าหน้า New และหน้า Edit วิกิมีฟอร์มที่เหมือนกัน8 // เราจึงแยกส่วนจัดการฟอร์มออกเป็นอีกคอมโพแนนท์ชื่อ Form9 <Form />10 )11 }12}
เมื่อเราแยกส่วนจัดการฟอร์มออกแล้ว เราก็สร้างมันขึ้นมาครับ ตั้งชื่อไฟล์ว่า containers/Pages/Form.js
1import React, { Component, PropTypes } from 'react'2import { reduxForm } from 'redux-form'3import { createPage } from '../../actions/page'4import { PageForm } from '../../components'56// เราต้องการให้ฟอร์มของเรามี 2 fields7const FIELDS = ['title', 'content']89class PageFormContainer extends Component {10 static propTypes = {11 fields: PropTypes.object.isRequired,12 handleSubmit: PropTypes.func.isRequired,13 }1415 render() {16 const { fields, handleSubmit } = this.props1718 return <PageForm fields={fields} handleSubmit={handleSubmit} />19 }20}2122// ใช้ reduxForm เพื่อสร้างฟอร์ม23export default reduxForm(24 {25 // โดยระบุว่าฟอรฺมขชองเรานั้นชื่อ page26 form: 'page',27 // มีฟิลด์อะไรบ้างที่ต้องการ28 fields: FIELDS,29 // จะให้ตรวจสอบฟิลด์ไหม?30 validate: (values, props) =>31 // ตัวอย่างนี้จะทำการตรวจสอบฟิลด์ทั้งหมด32 // ถ้าฟิลด์ไหนไม่ได้พิมพ์ค่า จะให้มี error ว่า Required33 FIELDS.reduce(34 (errors, field) =>35 values[field] ? errors : { ...errors, [field]: 'Required' },36 {}37 ),38 },39 // เราสามารถใส่ mapStateToProps เข้าไปใน reduxForm ได้40 (state) => ({}),41 // เช่นเดียวกัน mapDispatchToProps ใส่ได้เหมือนกัน42 (dispatch) => ({43 // onSubmit จะจับคู่กับ handleSubmit ของ reduxForm44 // เมื่อใดก็ตามที่ฟอร์ม submit onSubmit ตัวนี้หละครับจะทำงาน45 onSubmit: (values) =>46 // เมื่อ onSubmit ทำงานเราต้องการให้มันไปเรียก createPage47 // เพื่อสร้างก้อนอ็อบเจ็กต์ action ที่สัมพันธ์กับการสร้างวิกิออกมา48 dispatch(createPage(values)),49 })50)(PageFormContainer)
เนื่องจากตัว reduxForm นั้นจะสร้าง state ใหม่อัดเข้าไปใน store สถานะตัวนี้หละครับที่ reduxForm จะใช้จัดการกับฟอร์มที่เรากรอกข้อมูล ไม่ว่าจะเป็นการตรวจสอบว่าข้อมูล valid ไหม มีการบันทึกว่ากำลัง submit ฟอร์มอยู่หรือไม่ เป็นต้น ดังนั้นเราจึงต้องเข้าไปตั้งค่าใน root reducer ของเรา เพื่อให้เก็บสถานะของฟอร์มจาก reduxForm ด้วย
1// reducers/index.js2import { combineReducers } from 'redux'3import { reducer as formReducer } from 'redux-form'4import pages from './pages'56export default combineReducers({7 form: formReducer,8 pages,9})
ต่อไปเราจะสร้าง presentational component ที่เป็นตัวแทนของฟอร์มชื่อ components/Pages/Form.js ดังนี้
1import React, { PropTypes } from 'react'23// ถ้ามี error เกิดขึ้นในแต่ละฟิลด์ให้แสดงข้อความนั้นออกมา4// วิธีการตรวจสอบว่ามี error หรือไม่คือ5// 1. เช๋คก่อนว่าฟิลด์นั้น touched หรือยัง6// 2. มี error เกิดที่ฟิลด์นั้นหรือไม่7// ถ้าผ่านทั้งสองข้อก็แสดง div ที่มีคลาสเป้น error พร้อมข้อความแสดงข้อผิดพลาด8const errorMessageElement = (field) =>9 field['touched'] &&10 field['error'] && <div className="error">{field['error']}</div>11const PageForm = ({ fields, handleSubmit }) => {12 const { title, content } = fields1314 return (15 <form16 // เรัยก handleSubmit ที่ส่งเข้ามาเมื่อ submit ฟอร์ม17 onSubmit={handleSubmit}18 className="form"19 >20 <fieldset>21 <label>Title</label>22 <input type="text" placeholder="Enter Title" {...title} />23 {errorMessageElement(title)}24 </fieldset>25 <fieldset>26 <label>Content</label>27 <textarea rows="5" placeholder="Enter Content" {...content}></textarea>28 {errorMessageElement(content)}29 </fieldset>30 <button type="submit" className="button">31 Submit32 </button>33 </form>34 )35}3637PageForm.propTypes = {38 fields: PropTypes.shape({39 title: PropTypes.object.isRequired,40 content: PropTypes.object.isRequired,41 }).isRequired,42 handleSubmit: PropTypes.func.isRequired,43}4445export default PageForm
เอาหละ เราเพิ่มทั้ง presentational component และ container component กันแล้ว ต่อไปเราก็เพิ่มการเข้าถึงให้กับมัน ดังนี้
1// components/index.js2export PageForm from './Pages/Form'34// containers/index.js5export NewPage from './Pages/New'
สุดท้ายเพิ่มสไตล์ให้ฟอร์มซะหน่อย จะได้ดูมีสีสันขึ้นครับ
1// theme/_variables.scss2...3...4$red1-color: #da4453;56// theme/elements.scss7...8...9// Form10.fo// Form11.form {12 fieldset {13 margin: 0;14 padding: 0.35rem 0 0.75rem;15 border: 0;16 }1718 input[type='text'],19 textarea,20 label {21 display: block;22 margin: 0.25rem 0;23 width: 100%;24 }2526 .error {27 color: $red1-color;28 }29}
ถึงเวลาทดสอบกันแล้ว ไปที่ http://127.0.0.1:8080/pages/new เพื่อนๆควรจะพบฟอร์มหน้าตาแบบนี้
ลองเล่นดูครับ ทดสอบด้วยการไม่กรอกอะไรเลยแล้วกด Submit จากนั้นลองกรอกข้อมูลให้ครบดูแล้ว Submit ฟอร์มครับ ตอนนี้เราจะสามารถสร้างวิกิหน้าใหม่ได้แล้ว แต่... เดี๋ยวก่อน เมื่อสร้างหน้าใหม่แล้วมันก็ควร redirect ไปที่หน้าแสดงวิกิ?
ท่านผู้ชมครับ เราจะติดตั้งไลบรารี่เพิ่มอีกตัวเพื่อทำให้เราสามารถเปลี่ยนสถานะของ router ได้ ความจริงแล้วเราสามารถใช้ withRouter
จาก react-router
เพื่อทำสิ่งเดียวกันนี้ได้ แต่การใช้แพคเกจนี้นั้นง่ายกว่า เอาหละลงมือติดตั้งกันเถอะ
1npm i --save react-router-redux
เพื่อนๆที่อ่านมาถึงตรงนี้แล้ว ผมว่าเก่งกันทุกคนแล้วครับ ตรงนี้จะขอไปอย่างรวดเร็ว ก็อปปี้แปะอย่างรวดเร็วไปกับผมนะครับ
1// routes.js23import { syncHistoryWithStore } from 'react-router-redux'45// รับ store เข้ามา6export default (store, history) => {7 return (8 // เพื่อใช้เพิ่มความสามารถให้ history9 <Router history={syncHistoryWithStore(history, store)}>1011 </Router>12 )13}1415// Root.js16import { browserHistory } from 'react-router'1718export default class App extends Component {19 render() {20 const store = configureStore(browserHistory)21 return (22 <Provider store={store} key='provider'>23 // ส่ง store และ history เข้าไปใน routes24 {routes(store, browserHistory)}25 </Provider>26 )27 }28}2930// reducers/index.js31import { routerReducer } from 'react-router-redux'3233export default combineReducers({34 form: formReducer,35 // จับ routing เป็นสถานะหนึ่งของแอพพลิเคชัน36 routing: routerReducer,37 pages38})3940// configureStore.js41import { routerMiddleware } from 'react-router-redux'4243export default (history) => {44 // ทำให้สามารถใช้ push ใน action เพื่อเปลี่ยนเส้นทางไป URL อื่นได้45 const middlewares = [thunk, apiMiddleware, routerMiddleware(history)]4647 ...48 ...49}
เมื่อตั้งค่าทุกอย่างเสร็จสิ้น เราก็จะได้ routing เป็น state หนึ่งของระบบเราแล้วครับ
ต่อไปเราก็ต้องทำให้ทุกครั้งที่สร้างวิกิสำเร็จให้วิ่งไปที่หน้าแสดงวิกิ
1// actions/page.js2import { push } from 'react-router-redux'34export const createPage = (values) =>5 // ใช้ redux-thunk ช่วยให้เราสามารถเข้าถึง dispatch ได้6 (dispatch) =>7 dispatch({8 [CALL_API]: {9 endpoint: PAGES_ENDPOINT,10 headers: {11 Accept: 'application/json',12 'Content-Type': 'application/json',13 },14 method: 'POST',15 body: JSON.stringify(values),16 types: [17 CREATE_PAGE_REQUEST,18 // นี่เป้นวิธีการ custom ของ redux-api-middleware19 {20 type: CREATE_PAGE_SUCCESS,21 payload: (_action, _state, res) => {22 return res.json().then((page) => {23 // เมื่อโหลดเพจสำเร็จให้วิ่งไปที่ /pages/:id24 dispatch(push(`/pages/${page.id}`))25 return page26 })27 },28 },29 CREATE_PAGE_FAILURE,30 ],31 },32 })
เมื่อทุกอย่างเสร็จเรียบร้อย เพื่อนๆลองทดสอบสร้างวิกิใหม่ดูครับ ตอนนี้จะพบว่าหลังสร้างเสร็จหน้าเว็บเราจะวิ่งไปที่ /pages/:id
ตามที่เราต้องการแล้ว เหนื่อยเนอะจะทำอะไรให้ได้ซักอย่างนึง redux-api-middleware ตัวนี้ไม่ได้สนับสนุนให้เราสามารถเรียก action อื่นได้เมื่อโหลดเพจสำเร็จ จึงต้องมานั่งทำอะไรยุ่งยากแบบนี้ เพื่อนๆสามารถเลือกใช้ middleware ตัวอื่นที่รองรับความสามารถที่เพื่อนๆต้องการได้ครับ
สุดท้ายแล้ว เราคงไม่อยากพิมพ์ http://127.0.0.1:8080/pages/new
เพื่อเข้าถึงหน้าสร้างวิกิใช่ไหมครับ ดังนั้นแล้วเรามาสร้างลิงก์กันเถอะ เพื่อให้คลิกแล้ววิ่งไปที่หน้านั้นเลย
1// components/Pages/Index.js2<div>3 <button4 className='button'5 onClick={() => onReloadPages()}>6 Reload Pages7 </button>8 {/* ตรงนี้ */}9 <Link to={{ pathname: '/pages/new' }}>Create New Page</Link>10 <hr />11 ...12 ...
เย้ ในที่สุดก็จบลงได้ซะที เล่นเอาคนเขียนเหนื่อยเหมือนกันนะครับเนี่ย แต่ก่อนที่เราจะจากกัน เพื่อนๆควรทำแบบฝึกหัดต่อไปนี้ด้วยนะครับ
แบบฝึกหัด ตอนนี้เรามีหน้า New สำหรับสร้างวิกิใหม่แล้ว แต่เรายังไม่มีหน้า Edit สำหรับแก้ไขวิกิ ให้เพื่อนๆลองเพิ่มคอมโพแนนท์สำหรับจัดการ route
/pages/:id/edit
ครับ โดยต้องนำ containers/Pages/Form.js กลับมาใช้ใหม่อีกครั้ง ใน Form.js ของเดิมเราจะเรียกcreatePage
เพื่อสร้างเพจใหม่ ให้เพื่อนๆลองแก้เป็นเรียก createPage เมื่อ Form นี้อยู่หน้า/pages/new
และเรียก updatePage เมื่อ Form นี้อยู่ในหน้า/pages/:id/edit
สุดท้ายให้เปลี่ยนข้อความของปุ่มเป็นSave
สำหรับหน้า Edit และCreate
สำหรับหน้า New แทนของเดิมที่เป็น Submit
ลากเลือดกันเลยทีเดียว สำหรับโค๊ดของวันนี้ทั้งหมดดูได้จาก Github ครับ ในบทความถัดไปเราจะพูดถึงวิธีการทำ Isomorphic Application ด้วย React และ Redux กันครับ ฝากติดตามด้วยนะครับ :)
เอกสารอ้างอิง
Lin Clark (2015). A cartoon intro to redux. Retrieved June, 4, 2016, from https://code-cartoons.com/a-cartoon-intro-to-redux-3afb775501a6#.lu5ps2t45
Dan Abramov (2016). Building React Applications with Idiomatic Redux. Retrieved June, 4, 2016, from https://egghead.io/courses/building-react-applications-with-idiomatic-redux
Redux. Retrieved June, 4, 2016, from http://redux.js.org/
สารบัญ
- แบบปฏิบัติที่ดีในโลกของ React
- ทบทวน Hot Module Replacement กันอีกครั้ง
- ติดตั้ง react-hot-loader ซะ
- ถึงเวลาเข้าสู่ Day3 กันแล้ว
- ปัญหาคลาสสิกของแอพพลิเคชันขนาดใหญ่
- รู้จัก store โกดังเก็บ state
- อย่าให้ store รับภาระแต่ฝ่ายเดียว
- ทบทวนกระบวนการส่งผ่านข้อมูลจากตัวละครทั้งสี่
- HMR พลเมืองชั้นหนึ่งของการพัฒนาด้วย Redux
- ทฤษฎีเยอะพอแล้ว ลงมือปฏิบัติกัน!
- เจาะลึก reducer
- ความจริงมีเพียงหนึ่งเดียว!
- สรุปครึ่งแรกของบทความจาก Actions, Reducers สู่ Store
- Hello Middleware
- จัดการ middleware อย่างชาญฉลาดด้วยการใช้ของชาวบ้าน!
- สรุปตัวละครทั้งหมดของ Redux
- ปรับเปลี่ยนหน้า Show ด้วย Redux
- Refactor กันซะหน่อย
- ถึงเวลาสร้างวิกิแล้ว
- เอกสารอ้างอิง