[Day #4] Server Rendering ด้วย React/Redux และการทำ Isomorphic JavaScript
ตลอดทั้งชุดบทความนี้เพื่อนๆได้เรียนรู้การใช้งาน React และ Redux อย่างคร่าวๆกันไปแล้ว ถึงเวลาแล้วหละที่เราจะเข้าใจถึงสิ่งที่เรียกว่าเป็น Isomorphic JavaScript
ข้อแนะนำก่อนอ่านบทความนี้
เนื่องจากบทความนี้เป็นบทความที่ 4 ในชุดบทความ สอนสร้าง Isomorphic Application ด้วย React.js และ Redux ใน 5 วัน เพื่อนๆมีความจำเป็นอย่างยิ่งที่จะต้องศึกษาเรื่องของ React และ Redux ก่อนโดยไล่ลำดับบทความดังนี้
- Day#1 แนะนำ Webpack2 และการใช้งานร่วมกับ React
- Day #2 สอนการใช้งาน React.js และการเรียกใช้งาน RESTful API ด้วย React
- Day#3 จัดการ data flow ใน React.js อย่างมีประสิทธิภาพด้วย Redux
สิ่งที่เพื่อนๆต้องเข้าใจเป็นอย่างยิ่งก่อนเริ่มอ่านบทความนี้ได้แก่เรื่องต่อไปนี้
- Redux Store คืออะไร
- History และ react-router
- Webpack Loaders
- ES2015
- Promise
- พื้นฐาน Express.js
พฤติกรรมปกติของ JavaScript Framework
ทบทวนกันหน่อยครับว่าในชุดบทความนี้ React เข้ามามีส่วนในการทำงานอย่างไรบ้าง
แรกเริ่มนั้นเราร้องขอ index.html จากเซิฟเวอร์ซึ่งในไฟล์นี้มีลิงก์ที่ชี้ไปหาไฟล์ JavaScript ที่บรรจุ React และส่วนต่างๆของโค๊ดในแอพพลิเคชันเรา ในเวลาถัดมาเบราเซอร์จะโหลดไฟล์ JavaScript ของเรา ประมวลผลและแปลงเป็น DOM บนหน้าเพจ แน่นอนว่าการประมวลผล JavaScript นี้รวมไปถึงการโหลดข้อมูลของเราผ่าน API ด้วย AJAX เช่นกันทำให้เราไม่ต้องโหลดหน้าเพจใหม่ทั้งหมด และนั่นหละครับคือทั้งหมดของการสร้างแอพพลิเคชันที่ขับเคลื่อนด้วย JavaScript
ทุกอย่างไปได้สวยแต่จะเกิดอะไรขึ้นหละถ้าเบราเซอร์ของเราปิดการทำงานของ JavaScript ไว้? (ใครตอบว่าก็ไปเปิดให้ใช้ได้ซะซินี่ผมเคืองจริงๆนะ)
Isomorphic JavaScript และ Server-side Rendering คืออะไร?
ความสามารถที่ทำให้โค๊ด JavaScript ที่เราเขียนเพื่อจัดการ business logic อะไรซักอย่าง แต่สามารถทำงานได้ทั้งบนเบราเซอร์และบนฝั่งเซิฟเวอร์นั่นละครับคือ Isomorphic JavaScript
เมื่อเราร้องขอ index.html จากเซิร์ฟเวอร์เรายังคงได้ก้อนข้อมูลพร้อมลิงก์ของ JavaScript เช่นเดิม แต่สิ่งที่ต่างออกไปคือ index.html ของเราพร้อมแสดงผลบน DOM ได้ทันทีโดยไม่ต้องอาศัยความช่วยเหลือจาก JavaScript นั่นเป็นเพราะ JavaScript ฝั่งเซิร์ฟเวอร์ประมวลผลลัพธ์ไว้ให้เรียบร้อยแล้ว การประมวลผล JavaScript ในฝั่งเซิร์ฟเวอร์เพื่อให้มีข้อมูลพร้อมแสดงผลแบบนี้แหละครับที่เรียกว่า Server-side Rendering
ถ้ายังไม่เห็นภาพมาดูตัวอย่างกันครับ
นี่คือ index.html ของแอพพลิเคชันที่ขับเคลื่อนด้วย React แบบปกติ
1<html>2 ...3 <body>4 <!-- ไฟล์นี้ body ว่างเปล่ามาก -->5 <!-- แต่มันจะมีเนื้อหาโผล่ขึ้นมาเมื่อ JavaScript ที่อยู่ในไฟล์ทำงาน -->6 <!-- JavaScript ต้องถูกโหลดก่อนแล้วดึงข้อมูลจากเซิร์ฟเวอร์หรือซักที่มาแสดงผลในนี้ -->7 <!-- แน่นอนว่าถ้าปิดการทำงานของ JavaScript เนื้อหาใดๆก็จะไม่โผล่ -->8 <div id="main"></div>9 <script src="path/to/javascript.js"></script>10 </body>11</html>
ส่วนนี้คือ index.html ด้วยการทำ Isomorphic JavaScript
1<html>2 ...3 <body>4 <div id="main">5 <!-- JavaScript ฝั่งเซิร์ฟเวอร์จะสร้างเนื้อหาอัดใส่ไฟล์เรียบร้อย -->6 <article>7 <header>8 <h1>รู้จัก babelcoder.com</h1>9 </header>10 ... ...11 </article>12 </div>13 <script src="path/to/javascript.js"></script>14 </body>15</html>
Isomorphic JavaScript ทำให้เราสามารถแสดงผลข้อมูลของเราได้โดยไม่ต้องรอให้ JavaScript โหลดก่อน นั่นคือเราพร้อมแสดงผลข้อมูลได้ทันทีเมื่อได้รับ index.html แล้ว จากนั้น JavaScript ในลิงก์ของเราก็จะโหลดปกติ นั่นหมายความว่าหลังจากนี้เมื่อเราทำอะไรผ่านหน้าเพจ JavaScript ก็จะเข้ามามีบทบาทเป็นตัวขับเคลื่อนอย่างเต็มที่เช่นเดิม
มีสองคำที่ใกล้เคียงกันคือ Isomorphic JavaScript และ Universal JavaScript สิ่งที่สองคำนี้ต่างกันคือ Isomorphic JavaScript เน้นความสามารถในการเขียนโค๊ดครั้งเดียว ประมวลผลได้ทั้งบนเบราเซอร์และฝั่งเซริฟ์เวอร์ แต่ Universal JavaScript นั้นต่างออกไป เราเขียนโค๊ด JavaScript ครั้งเดียว แต่นำไปใช้ได้ทั้งบนเบราเซอร์ เซิร์ฟเวอร์ และมือถือ
ข้อดีของ Isomorphic JavaScript
ความเร็วในการแสดงผลสำหรับเพจแรก
เมื่อ HTML ของเรามีเนื้อหาพร้อมมันจึงแสดงผลได้ทันทีโดยไม่ต้องรอ JavaScript ทำงาน ถือได้ว่าเป็นการเพิ่มประสบการณ์การใช้งานของผู้ใช้อีกทาง
SEO ที่ดีขึ้น
เขาว่ากันว่า Google ตอนนี้เข้าใจ JavaScript มากขึ้น แต่เชื่อเถอะว่า Isomorphic JavaScript จะช่วยเพิ่มประสิทธิภาพของการทำ SEO นั่นเป็นเพราะเมื่อ Google bot ไต่มาถึงเว็บเรามันจะได้ HTML เพจที่มีเนื้อหาพร้อมจับไปทำดัชนีได้ทันที แม้ bot จะไม่เข้าใจ JavaScript เราก็ยังมีข้อมูลให้มันได้ใช้งาน
ใช้งานได้แม้ปิดการทำงานของ JavaScript
ถ้าเราไม่ได้ทำ Isomorphic เมื่อเราปิดความสามารถของ JavaScript บทเบราเซอร์ จะไม่มีข้อมูลใดๆปรากฎบนหน้าเพจของเราเลย นั่นเป็นเพราะเราไม่มี JavaScript ให้ไปดึงข้อมูลมาแสดงผล สำหรับ Isomorphic นั้นต่างออกไป เรามีข้อมูลพร้อมแสดงผลแม้ JavaScript จะไม่ได้ทำงานอยู่ก็ตาม
ลงมือสร้าง Isomorphic JavaScript กันเถอะ
ทบทวนโครงสร้างไฟล์จาก Day3 กันหน่อยครับ ถ้าทุกอย่างถูกต้องโครงสร้างไฟล์ก่อนที่จะเริ่ม Day4 ต้องเป็นแบบนี้นะครับ
1wiki2 |----- .babelrc3 |----- webpack.config.js4 |----- package.json5 |----- ui6 |----- actions7 |----- components8 |----- containers9 |----- constants10 |----- reducers11 |----- routes12 |----- store13 |----- theme14 |----- index.js
เราบอกว่าอยากประมวลผล JavaScript บนเซิร์ฟเวอร์ของเราแต่ตอนนี้เรายังไม่มีโค๊ดฝั่งเซิร์ฟเวอร์เลย ความเป็นจริงแล้วเราสามารถทำงานกับ JavaScript ฝั่งเซิร์ฟเวอร์หรือ backend ได้ด้วยภาษาอื่นๆ แต่ในที่นี้เมื่อพูดถึง JavaScript ก็ขอจบด้วย JavaScript เริ่มจากติดตั้ง Express กันก่อนเลยครับ
1$ npm i express --save
ตอนนี้เราจะมาจัดระเบียบโฟลเดอร์ ui ของเราเสียใหม่ สร้างโฟลเดอร์ต่อไปนี้ขึ้นมาภายใต้ ui ครับ
- client: สำหรับเก็บไฟล์ที่ใช้กับเบราเซอร์
- server: สำหรับการทำ server-side rendering
- common: สำหรับเก็บไฟล์ที่ใช้งานร่วมกันระหว่าง client และ server
จากนั้์นสร้างไฟล์ ui/server/index.js และ ui/server/server.js ขึ้่นมาครับ รวมถึงทำการย้ายโฟล์เดอร์และไฟล์เหล่านี้ไปไว้ใต้ ui/common เพื่อให้ใช้งานร่วมกันได้ทั้ง client และ server
- actions
- components
- constants
- containers
- reducers
- store
- theme
- routes.js
สุดท้ายย้ายไฟล์ ui/index.js ของเราไปไว้ที่ ui/client/index.js พร้อมทั้งอัพเดท webpack.config.js และ ui/client/index.js ของเราให้ชี้ไปที่ตำแหน่งใหม่
1// webpack.config.js2entry: [3 'react-hot-loader/patch',4 'webpack-dev-server/client?http://localhost:8080',5 'webpack/hot/only-dev-server',6 // ตรงนี้7 './ui/common/theme/elements.scss',8 './ui/client/index.js'9],1011// ui/client/index.js12import React, { Component } from 'react'13import { render } from 'react-dom'14import { AppContainer } from 'react-hot-loader'15import Root from '../common/containers/Root'1617const rootEl = document.getElementById('app')1819render(20 <AppContainer>21 <Root />22 </AppContainer>,23 rootEl24)2526if (module.hot) {27 module.hot.accept('../common/containers/Root', () => {28 const NextRootApp = require('../common/containers/Root').default2930 render(31 <AppContainer>32 <NextRootApp />33 </AppContainer>,34 rootEl35 )36 })37}
ตอนนี้โครงสร้างไฟล์ของเพื่อนๆควรเป็นแบบนี้
1wiki2|----- api3|----- ui4 |----- client5 |----- common6 |----- actions7 |----- components8 |----- constants9 |----- containers10 |----- reducers11 |----- store12 |----- theme13 |----- routes.js14 |----- server15 |-----> index.js16 |-----> server.js17|----- webpack
ไฟล์ server.js ของเราจะเป็นหน้าด่านในการประมวลผล JavaScript ฝั่งเซิร์ฟเวอร์ เพื่อให้มีเนื้อหาพร้อมแปะใน HTML ก่อนส่งให้เบราเซอร์แสดงผล
1// ui/server/server.js23import express from 'express'45const PORT = 80806const app = express()78app.use((req, res) => {9 const HTML = `10 <!DOCTYPE html>11 <html>12 <head>13 <meta charset='utf-8'>14 <title>Wiki!</title>15 </head>16 <body>17 <div id='app'></div>18 <!-- ตอนนี้เราจะใช้พอร์ต 8081 กับ webpack dev server -->19 <script src='http://127.0.0.1:8081/static/bundle.js'></script>20 </body>21 </html>22 `2324 res.end(HTML)25})2627app.listen(PORT, (error) => {28 if (error) {29 console.error(error)30 } else {31 console.info(`==> Listening on port ${PORT}.`)32 }33})
อยากให้ทุกคนสังเกตตรง <script src='http://127.0.0.1:8081/static/bundle.js'></script>
ตรงนี้เราตั้งใจว่าจะให้ผู้ใช้ระบบของเราเข้าถึงเพจผ่าน 127.0.0.1:8080 โดยจะได้ก้อน HTML ที่มี http://127.0.0.1:8081/static/bundle.js อยู่ในนั้น สังเกตนะครับว่าพอร์ตมันต่างกัน 8081 นั้นเป็นของ Webpack Dev Server พูดง่ายๆก็คือเราทำ Server-side Rendering บนพอร์ต 8080 โดย JavaScript ที่อยู่บนเบราเซอร์และไม่เกี่ยวกับ Server-side อยู่ที่พอร์ต 8081 แก้ไขไฟล์ package.json ครับเพื่อให้ Webpack Dev Server ของเราทำงานที่พอร์ต 8081
1{2 "start-dev-ui": "webpack-dev-server --port 8081"3}
แม้ Node.js จะเข้าใจ ES2015 มากขึ้น แต่กระนั้นประโยค import ที่เราใช้ในไฟล์นี้ก็เป็นสิ่งหนึ่งที่ Node.js ยังไม่เข้าใจ เพิ่ม index.js ให้เป็นปราการด่านแรกก่อนเข้าถึงไฟล์ server.js ในไฟล์นี้เราจะลงทะเบียนให้ Babel เข้ามาจัดการแปลความหมาย ES2015 ของเรากัน
1// ui/server/index.js23require('babel-core/register')45module.exports = require('./server.js')
ก่อนจะไปกันต่อ เราอยากให้ไฟล์ฝั่งเซิร์ฟเวอร์ของเราโหลดใหม่ทุกครั้งที่มีการเปลี่ยนแปลงโค๊ด อย่ารอช้าติดตั้ง nodemon กันเถอะ
1npm i --save-dev nodemon
ทุกอย่างพร้อมแล้วแต่เรายังไม่มีคำสั่งสำหรับทำงานไฟล์จากเซิร์ฟเวอร์นี้เลย แก้ไข package.json กันครับ
1{2 "scripts": {3 "start": "npm-run-all --parallel start-dev-api start-dev-ui start-dev-ssr",4 "start-dev-api": "json-server --watch api/db.json --routes api/routes.json --port 5000",5 "start-dev-ui": "webpack-dev-server --port 8081",6 "start-dev-ssr": "nodemon ./ui/server/index.js"7 }8}
package.json เราเพิ่ม start-dev-ssr ขึ้นมาเพื่อใช้ออกคำสั่งทำงานกับโค๊ด Server-side Rendering ของเรา
เมื่อเรามี SSR แล้วเราจึงควรย้าย proxy ของเราออกจาก Webpack Dev Server มาไว้ภายใต้ server.js ของเราแทน เข้าไปที่ webpack.config.js แล้วนำส่วนต่อไปนี้ออกไปครับ
1proxy: {2 '/api/*': {3 target: 'http://127.0.0.1:5000'4 }5}
สุดท้ายจะเหลือแค่
1const webpack = require('webpack')2const path = require('path')3const autoprefixer = require('autoprefixer')45module.exports = {6 devtool: 'eval',7 entry: [8 'react-hot-loader/patch',9 'webpack-dev-server/client?http://localhost:8081',10 'webpack/hot/only-dev-server',11 './ui/common/theme/elements.scss',12 './ui/client/index.js',13 ],14 output: {15 // เปลี่ยนตรงนี้นิดนึงเพื่อให้ทำงานกับพอร์ตที่ถูกต้อง16 publicPath: 'http://127.0.0.1:8081/static/',17 path: path.join(__dirname, 'static'),18 filename: 'bundle.js',19 },20 plugins: [new webpack.HotModuleReplacementPlugin()],21 module: {22 loaders: [23 {24 test: /\.jsx?$/,25 exclude: /node_modules/,26 loaders: [27 {28 loader: 'babel-loader',29 query: {30 babelrc: false,31 presets: ['es2015', 'stage-0', 'react'],32 },33 },34 ],35 },36 {37 test: /\.css$/,38 loaders: ['style-loader', 'css-loader'],39 },40 {41 test: /\.scss$/,42 exclude: /node_modules/,43 loaders: [44 'style-loader',45 {46 loader: 'css-loader',47 query: {48 sourceMap: true,49 module: true,50 localIdentName: '[local]___[hash:base64:5]',51 },52 },53 {54 loader: 'sass-loader',55 query: {56 outputStyle: 'expanded',57 sourceMap: true,58 },59 },60 'postcss-loader',61 ],62 },63 ],64 },65 postcss: function () {66 return [autoprefixer]67 },68 devServer: {69 hot: true,70 inline: false,71 historyApiFallback: true,72 },73}
ต่อไปติดตั้ง http-proxy เพื่อให้มี proxy ไว้ใช้ใน server.js ผ่านคำสั่งนี้
1npm i --save http-proxy
เข้าไปที่ server.js ของเราแล้วเพิ่มความสามารถในการทำ proxy ให้มันดังนี้
1import express from 'express'2// import เข้ามาโลด3import httpProxy from 'http-proxy'45const PORT = 80806const app = express()7const targetUrl = 'http://127.0.0.1:5000'8const proxy = httpProxy.createProxyServer({9 // API Server ของเราอยู่ที่ port 5000 ไงหละ10 target: targetUrl,11})1213// ถ้า path ที่เข้ามาขึ้นต้นด้วย /api ให้เรียกไปที่ http://127.0.0.1:5000/api14app.use('/api', (req, res) => {15 proxy.web(req, res, { target: `${targetUrl}/api` })16})1718app.listen(PORT, (error) => {19 if (error) {20 console.error(error)21 } else {22 console.info(`==> Listening on port ${PORT}.`)23 }24})
สุดท้ายแก้ไข endpoints.js ซักนิดนึง
1const API_ROOT = 'http://127.0.0.1:8080/api/v1'23export const PAGES_ENDPOINT = `${API_ROOT}/pages`
ตอนนี้ก็ถึงเวลาทดสอบโค๊ดกันแล้ว สั่ง npm start แล้วเข้าไปที่ http://127.0.0.1:8080 ครับ
Wiki ที่สวยงามของเราปรากฎออกมาใช่ไหมครับ แต่ถ้าเราสังเกตดูดีๆจะพบว่าสิ่งที่เซิร์ฟเวอร์ตอบกลับเรามาเป็นเพียง HTML ว่างเปล่าที่มีเพียงไฟล์ JavaScript สุดท้ายก็เข้ารูปแบบเดิมคือรอ JavaScript โหลดเนื้อหาเข้ามาใส่ DOM
ต่อไปนี้คือเป้าหมายของการทำ Isomorphic ในบทความนี้ครับ
- แรกเริ่มเราร้องขอ /pages จาก server (port 8080)
- server จะขอ /api/pages เพื่อดึงข้อมูลวิกิทั้งหมดจาก API Server อีกที
- เมื่อ API Server ตอบกลับคำขอ server จะสร้าง HTML ที่มีเนื้อหาของวิกิทั้งหมดลงไป
- ส่ง HTML กลับไปให้เบราเซอร์
- เบาร์เซอร์โหลด JavaScript จาก
<script>
- JavaScript นี้ทำงานอยู่บน webpack-dev-server port 8081
- ในการทำงานต่อๆไปจะวิ่งไปที่ webpack-dev-server port 8081 แทน
Server-side Rendering ด้วย React
ถึงเวลาที่เราต้องปรับโค๊ดใน server.js ของเรา เพื่อให้สร้างข้อมูลจาก React ที่เรามีอยู่แล้วอัดฉีดลง HTML
บนฝั่ง client หรือฝั่งเบราเซอร์ของเรา เราใช้ ReactDOM.render เพื่อแสดงผลแอพพลิเคชัน React ของเราไปยัง DOM
1// ui/src/client/index.js2import React from 'react'3import ReactDOM from 'react-dom'4import { browserHistory } from 'react-router'5import configureStore from '../common/store/configureStore'6import { Root } from '../common/containers'78// ตรงนี้9ReactDOM.render(10 <Root store={configureStore()} history={browserHistory} />,11 document.getElementById('app')12)
แต่ตอนนี้เราจะประมวลผล React บน Node.js วิธีการจึงต่างออกไปหน่อยคือใช้ renderToString จาก react-dom/server แทนครับ
renderToString นั้นรับพารามิเตอร์เป็น ReactElement ที่เราต้องการนำไปแสดงผลตัวนึง แน่นอนครับว่าเรามี Root Component ที่เป็นพ่อทุกสถาบันอยู่ในระดับบนสุดของสายคอมโพแนนท์ทั้งปวง เราจึงคาดหวังที่จะใช้คอมโพแนนท์ตัวนี้เป็นพารามิเตอร์ให้กับ ReactElement
เราจะสร้าง ui/src/server/ssr.js เพื่อเก็บ middleware ของ Express ตัวหนึ่งเพื่อใช้ในการทำ Server-side Rendering
1import React from 'react'2import { renderToString } from 'react-dom/server'3import Root from '../common/containers/Root'45export default function (req, res) {6 const html = renderToString(<Root />)7 const HTML = `8 <!DOCTYPE html>9 <html>10 <head>11 <meta charset='utf-8'>12 <title>Wiki!</title>13 </head>14 <body>15 <div id='app'>${html}</div>16 <script src='http://127.0.0.1:8081/static/bundle.js'></script>17 </body>18 </html>19 `2021 res.end(HTML)22}
จากนั้นจึงเรียกใช้ middleware ตัวนี้ใน server.js
1// ui/src/server/server.js2import express from 'express'3import ssr from './ssr'45const PORT = 80806const app = express()78// โยน ssr ลงไปเป็น middleware ของ Express9app.use(ssr)1011app.listen(PORT, (error) => {12 if (error) {13 console.error(error)14 } else {15 console.info(`==> Listening on port ${PORT}.`)16 }17})
กลับไปดูที่ terminal ของเรากันครับ
1SyntaxError: wiki/ui/common/components/App/Header.scss: Unexpected token (1:1)
ปัญหาแรกเกิดขึ้นกับเราแล้ว! ในไฟล์ ui/common/components/App/Header/Header.js มีประโยค import เพื่อนำไฟล์ SCSS เข้ามาใช้งานแบบนี้
1import styles from './Header.scss'
แต่ Node.js รู้จักแค่ JavaScript เองนะ มันไม่รู้จัก SCSS ซะหน่อย นี่หละครับคือเหตุผลที่มันบ่นด่าขนาดนี้ วิธีการที่ง่ายสุดสำหรับเราคือแปลง SCSS ให้เป็นสิ่งที่ Node.js รู้จัก นั่นละครับคือลักษณะที่ plugin ของ Babel ตัวนึงทำงานนั่นคือ babel-plugin-css-modules-transform
วิธีการทำงานของ babel-plugin-css-modules-transform คือการแยกชื่อ class ออกจากไฟล์ CSS/SCSS ดังนี้
1/* test.scss */23.someClass {4 color: red;5}
เมื่อเราเรียกใช้ test.scss ในคอมโพแนนท์ของเราจะได้
1// component.js2import styles from './test.scss'34console.log(styles.someClass)56// แปลงเป็น7const styles = {8 someClass: 'Test__someClass___2Frqu',9}1011console.log(styles.someClass) // จะได้ Test__someClass___2Frqu
เมื่อมันแปลงประโยค import ของเราเป็นอ็อบเจ็กต์ธรรมดาข้อผิดพลาดของเราก็จะหายไป ติดตั้งปลักอินตัวนี้ด้วยคำสั่งนี้ครับ
1$ npm i --save-dev babel-plugin-css-modules-transform
เนื่องจากมันเป็นปลักอินของ Babel เราจึงต้องไปตั้งค่าใน .babelrc เพื่อให้มันทำงานครับ
1{2 "presets": ["es2015", "stage-0", "react"],3 "plugins": [4 "react-hot-loader/babel",5 [6 "css-modules-transform", {7 "preprocessCss": "./lib/processSass.js",8 "extensions": [".css", ".scss"]9 }10 ]11 ]12}
ปลั๊กอินตัวนี้รู้จักแค่ CSS แต่เรากำลังทำงานกับไฟล์ SCSS ดังนั้นจึงต้องสร้างไฟล์ชื่อ lib/processSass.js ขึ้นมาเพื่อบรรจุกระบวนการแปลง SCSS ให้เป็น CSS ซะก่อน
1// lib/processSass.js2var sass = require('node-sass')3var path = require('path')45module.exports = function processSass(data, filename) {6 var result78 result = sass.renderSync({9 data: data,10 file: filename,11 }).css1213 return result.toString('utf8')14}
ปลั๊กอินตัวนี้จะแปลงชื่อคลาสของ CSS ในรูปแบบ [name]__[local]___[hash:base64:5]
แต่ใน webpack.config.js ของเราจะแปลงคลาสของ CSS เป็น [local]___[hash:base64:5]
ซึ่งมันไม่ตรงกัน เราจึงควรไปเปลี่ยนให้ css-loader แปลงชื่อคลาสของ CSS ให้เหมือนชาวบ้านเขาดังนี้
1// webpack.config.js2loaders: [3 'style-loader',4 {5 loader: 'css-loader',6 query: {7 sourceMap: true,8 module: true,9 // ตรงนี้10 localIdentName: '[name]__[local]___[hash:base64:5]',11 },12 },13 {14 loader: 'sass-loader',15 query: {16 outputStyle: 'expanded',17 sourceMap: true,18 },19 },20 'postcss-loader',21]
ขั้นตอนสุดท้ายให้แก้ไข ui/server/index.js เพื่อให้ Babel ไม่สนใจที่จะแปลงโค๊ดไฟล์นี้ของเรา มันเป็นบั๊คหนึ่งที่ผู้สร้างก็ยังงงงวยอยู่เลยครับ แต่นี่คือวิธีแก้ปัญหาเฉพาะหน้าที่ได้ผลขณะนี้ ใครอยากศึกษาถึงปัญหานี้เพิ่มเติมก็จิ้มลิงก์นี้โดยพลัน
1require('babel-core/register')({2 ignore: [/processSass\.js/, /node_modules/],3})45module.exports = require('./server.js')
โจทย์ของเราคือเราจะใช้ปลักอินตัวนี้กับแค่โค๊ดฝั่งเซิร์ฟเวอร์เท่านั้น ถ้าเราไม่ทำอะไรซักอย่างการใส่ปลั๊กอินนี้ลง .babelrc จะกระทบทั้งโปรเจค รวมไปถึง Webpack ที่เราตั้งใจให้เป็นตัวแทนของฝั่งเบราเซอร์และไม่ต้องการให้ปลั๊กอินตัวนี้เข้ามาจัดการ นั่นเพราะ Webpack มี loader คอยจัดการกับ SCSS อยู่แล้วไง เพิ่มการตั้งค่าต่อไปนี้ครับเพื่อไม่ให้ปลั๊กอินนี้มีผลกับ Webpack
1// webpack.config.js2{3 test: /\.jsx?$/,4 exclude: /node_modules/,5 loaders: [6 {7 loader: 'babel-loader',8 query: {9 // บอก Webpack ให้ยุติการใช้งาน babelrc10 babelrc: false,11 // ดังนั้นเราจึงต้องตั้งค่าสิ่งที่เราจะใช้เองโดยไม่รวมปลั๊กอินเจ้าปัญหานั้นเข้าไปด้วย12 presets: ["es2015", "stage-0", "react"]13 }14 }15 ]16},
เมื่อปู้ยี้ปู้ยำ webpack.config.js เสร็จแล้ว มาดูไฟล์เต็มกันดีกว่า ตรวจสอบอีกครั้งให้อุ่นใจดังนี้
1// webpack.config.js2const webpack = require('webpack')3const path = require('path')4const autoprefixer = require('autoprefixer')56module.exports = {7 devtool: 'eval',8 entry: [9 'react-hot-loader/patch',10 'webpack-dev-server/client?http://localhost:8081',11 'webpack/hot/only-dev-server',12 './ui/common/theme/elements.scss',13 './ui/client/index.js',14 ],15 output: {16 publicPath: 'http://127.0.0.1:8081/static/',17 path: path.join(__dirname, 'static'),18 filename: 'bundle.js',19 },20 plugins: [new webpack.HotModuleReplacementPlugin()],21 module: {22 loaders: [23 {24 test: /\.jsx?$/,25 exclude: /node_modules/,26 loaders: [27 {28 loader: 'babel-loader',29 query: {30 babelrc: false,31 presets: ['es2015', 'stage-0', 'react'],32 },33 },34 ],35 },36 {37 test: /\.css$/,38 loaders: ['style-loader', 'css-loader'],39 },40 {41 test: /\.scss$/,42 exclude: /node_modules/,43 loaders: [44 'style-loader',45 {46 loader: 'css-loader',47 query: {48 sourceMap: true,49 module: true,50 localIdentName: '[name]__[local]___[hash:base64:5]',51 },52 },53 {54 loader: 'sass-loader',55 query: {56 outputStyle: 'expanded',57 sourceMap: true,58 },59 },60 'postcss-loader',61 ],62 },63 ],64 },65 postcss: function () {66 return [autoprefixer]67 },68 devServer: {69 hot: true,70 inline: false,71 historyApiFallback: true,72 },73}
ถ้าเราลองรัน npm start ใหม่อีกรอบก็จะอุ่นใจมากขึ้น
แต่ช้าก่อน ถ้ามันง่ายขนาดนั้นบทความนี้คงไม่เกิดขึ้นครับ ตอนนี้เราจะเจอข้อผิดพลาดตัวใหม่แล้วที่บอกว่า TypeError: Cannot read property 'listen' of undefined
จาก syncHistoryWithStore
ถ้าเราเข้าไปดู ui/common/containers/Root.js ซักนิดจะพบว่าเราเรียกใช้ browserHistory เพื่อจัดการเส้นทางจราจรบนเบราเซอร์ แต่นี้มัน Node.js นะ ไม่ใช่เว็บเบราเซอร์ซะหน่อย!
1// ui/common/containers/Root.js2import React, { Component } from 'react'3import { Provider } from 'react-redux'4// จ๊ะเอ๋5import { browserHistory } from 'react-router'6import configureStore from '../store/configureStore'7import routes from '../routes'89export default class App extends Component {10 render() {11 // เค้าอยู่นี่ไง12 const store = configureStore(browserHistory)13 return (14 <Provider store={store} key="provider">15 {routes(store, browserHistory)}16 </Provider>17 )18 }19}
ตอนนี้ผู้เรียกใช้งาน Root.js มีสองคนคือ client/index.js และ server/ssr.js โดยที่ history ของ client คือ browserHistory แต่ของ ssr.js จะเป็นตัวอื่นซึ่งเป็นคนละตัวกัน ดังนั้นแล้วเราจึงไม่สามารถเรียก browserHistory ตรงๆแบบนี้ใน Root ได้ แต่จะส่งผ่านเข้ามาเป็น property แทนครับดังนี้
1// ui/common/containers/Root.js2import React, { Component } from 'react'3import { Provider } from 'react-redux'4import configureStore from '../store/configureStore'5import routes from '../routes'67export default class App extends Component {8 render() {9 // ส่งมาให้ฉันที10 const { history } = this.props11 const store = configureStore(history)1213 return (14 <Provider store={store} key="provider">15 {routes(store, history)}16 </Provider>17 )18 }19}
แน่นอนว่า ui/client/index.js ก็ต้องส่ง history เข้ามาใน Root เช่นกัน
1import React, { Component } from 'react'2import { render } from 'react-dom'3// import เข้ามาก่อน4import { browserHistory } from 'react-router'5import { AppContainer } from 'react-hot-loader'6import Root from '../common/containers/Root'78const rootEl = document.getElementById('app')910render(11 <AppContainer>12 <!-- ตรงนี้ไง -->13 <Root14 history={browserHistory} />15 </AppContainer>,16 rootEl17)1819if (module.hot) {20 module.hot.accept('../common/containers/Root', () => {21 const NextRootApp = require('../common/containers/Root').default2223 render(24 <AppContainer>25 <!-- ตรงนี้ไง -->26 <NextRootApp27 history={browserHistory} />28 </AppContainer>,29 rootEl30 )31 })32}
ตอนนี้ก็ถึงคิวของ ssr.js แล้วครับ จากเดิมที่ history ของเราจะทราบว่าตอนนี้เราอยู่ที่ path ไหนของเว็บเพื่อเรียกคอมโพแนนท์มาทำงานได้ถูก แต่ตอนนี้เมื่ออยู่บนฝั่งเซิร์ฟเวอร์ เราไม่มี address bar แบบในเบราเซอร์นะ แล้วเราจะรู้ได้ยังไงว่าตอนนี้เราอยู่ที่เส้นทางไหนบนเว็บ ด้วยเหตุนี้เราจึงต้องใช้ createMemoryHistory เพื่อสร้าง history ทางฝั่งเซิร์ฟเวอร์ดังนี้
1const html = renderToString(2 <Root3 history={createMemoryHistory(???)} />4)
แต่ช้าก่อน เราบอกไปแล้วว่าเราไม่มี address bar นะ แล้วเราจะรู้ได้ยังไงว่าตอนนี้เราอยู่ที่ path ไหน? ถ้าเราไม่รู้ว่าตัวเองอยู่ที่ไหนจะหยิบคอมโพแนนท์มาแสดงผลลง HTML ให้ถูกต้องก็ไม่ได้ ดังนี้ createMemoryHistory จึงรับพารามิเตอร์หนึ่งตัวคือตำแหน่งของ path ปัจจุบัน เพื่อให้รู้ว่าควรทำงานกับเส้นทางไหนดี
ปัญหาอยู่ตรงนี้หละครับ เราจะรู้ได้ยังไงว่า location หรือ path ปัจจุบันของเราอยู่ที่ไหน? react-router มีคำตอบให้กับเราแล้วในเรื่องนี้ผ่าน match
ครับ แก้ไข ssr.js ตามผมดังนี้
1import React from 'react'2import { match, RouterContext } from 'react-router'3import { renderToString } from 'react-dom/server'4import createMemoryHistory from 'react-router/lib/createMemoryHistory'5import { syncHistoryWithStore } from 'react-router-redux'6import configureStore from '../common/store/configureStore'7import Root from '../common/containers/Root'8import getRoutes from '../common/routes'910// แยกส่วนที่ใช้สร้าง HTML ออกมาเป็นฟังก์ชัน11// รับพารามิเตอร์หนึ่งตัวคือ HTML12const renderHtml = (html) => `13 <!DOCTYPE html>14 <html>15 <head>16 <meta charset='utf-8'>17 <title>Wiki!</title>18 </head>19 <body>20 <div id='app'>${html}</div>21 <script src='http://127.0.0.1:8081/static/bundle.js'></script>22 </body>23 </html>24`2526export default function (req, res) {27 // สร้าง history ฝั่งเซิร์ฟเวอร์28 const memoryHistory = createMemoryHistory(req.originalUrl)29 // สร้าง store โดยส่ง history ที่ได้เป็นอาร์กิวเมนต์30 const store = configureStore(memoryHistory)31 // ยังจำได้ไหมเอ่ย เราต้องการเพิ่มความสามารถให้กับ history32 // เราจึงใช้ react-router-redux ซึ่งเราต้องตั้งค่าผ่าน syncHistoryWithStore33 // เพื่อให้ store รับรู้ถึงการเปลี่ยนแปลงของ history เช่นรู้ว่าตอนนี้อยู่ที่ URL ไหน34 const history = syncHistoryWithStore(memoryHistory, store)3536 // ใช้ match เพื่อพิจารณาว่าปัจจุบันเราอยู่ที่ URL ไหนโดยดูจาก req.originalUrl ที่ส่งไปเป็น location37 // match จะเข้าคู่ URL นี้กับ routes ที่เรามีทั้งหมด38 match(39 {40 routes: getRoutes(store, history),41 location: req.originalUrl,42 },43 (error, redirectLocation, renderProps) => {44 // หากเกิด error ก็ให้โยน HTTP 500 Internal Server Error ออกไป45 if (error) {46 res.status(500).send(error.message)47 } else if (redirectLocation) {48 // แต่ถ้าเจอว่าเป็นการ redirect ก็ให้ redirect ไปที่ path ใหม่49 res.redirect(50 302,51 `${redirectLocation.pathname}${redirectLocation.search}`52 )53 } else if (renderProps) {54 res.status(200).send(55 // ส่ง RouterContext เข้าไปสร้าง HTML ใน renderHtml56 renderHtml(renderToString(<RouterContext {...renderProps} />))57 )58 } else {59 // ถ้าจับอะไรไม่ได้ซักอย่างก็ 404 Not Found ไปเลย60 res.status(404).send('Not found')61 }62 }63 )64}
เมื่อเพื่อนๆอ่านโค๊ดแล้วต้องสงสัยกันเป็นแน่ว่า RouterContext กับ renderProps คืออะไร?
renderProps นั้นเป็น `router state** หรือสถานะที่ได้จากการเข้าคู่ URL ปัจจุบันกับ route ที่เกี่ยวข้อง เจ้าสถานะตัวนี้ประกอบไปด้วยข้อมูลต่างๆที่เพียงพอต่อการนำไปใช้เพื่อสร้าง HTML เช่น
- components: เป็นอาร์เรย์ที่ประกอบไปด้วยคอมโพแนนท์ที่เกี่ยวข้องกับ route ที่มันหาเจอ
- location: เป็นอ็อบเจ็กต์ที่เก็บความสัมพันธ์ที่อ้างถึง URL ปัจจุบัน ประกอบด้วย pathname, search, hash, state, action, key และ query
- router: เป็นอ็อบเจ็กต์ที่ประกอบด้วยข้อมูลและเมธอดที่เกี่ยวข้องกับการจัดการเส้นทาง เช่น go, goBack, goForward เป็นต้น
ถึงตาของ RouterContext แล้วครับ RouterContext ใช้สร้าง (render) โครงสร้างคอมโพแนนท์ที่เกี่ยวข้องกับ route นั้น แน่นอนว่าเราต้องส่ง renderProps เข้าไปให้กับมัน ไม่งั้นมันจะรู้ได้ยังไงว่าจะเอาคอมโพแนนท์ที่ไหนไปแสดงจริงไหมครับ?
กลับไปดูที่ http://127.0.0.1:8080/ ของเราอีกครั้ง จะพบว่าตอนนี้ไม่มีข้อผิดพลาดอะไรเกิดขึ้นแล้ว ยังไม่พอ CSS ยังแสดงผลได้สวยงามอีกด้วย เย้! เอาหละครับเมื่อถึงตรงนี้แล้วผู้เขียนอนุญาตให้ผู้อ่านพักดื่มน้ำปัสสาวะกันเลยฮะ แล้วเราค่อยไปกันต่อ!
Application State และ Server-side Rendering
รอบนี้เพื่อนๆลองเข้าไปที่หน้า http://127.0.0.1:8080/ ไม่มีอะไรซับซ้อนครับเป็นเพียงข้อมูลธรรมดาๆ แต่ถ้าเราไปที่ http://127.0.0.1:8080/pages นี่หละครับจะเกิดปัญหา หน้า /pages นั้นเราต้องดึงข้อมูลจาก API Server เพื่อนำวิกิทั้งหมดมาแสดงใช่ไหมครับ เพื่อนๆลองเปิดไฟล์ containers/Pages/Index.js ดูครับ
1class PagesContainer extends Component {2 static propTypes = {3 pages: PropTypes.array.isRequired,4 onLoadPages: PropTypes.func.isRequired,5 }67 shouldComponentUpdate(nextProps) {8 return this.props.pages !== nextProps.pages9 }1011 onReloadPages = () => {12 this.props.onLoadPages()13 }1415 componentDidMount() {16 // เพ่งสายตามาที่นี่โดยพลัน17 this.onReloadPages()18 }1920 render() {21 return <Pages pages={this.props.pages} onReloadPages={this.onReloadPages} />22 }23}
เมื่อเราเข้าไปที่ /pages คอมโพแนนท์ pagesContainer ของเราจะเรียกเมธอด componentDidMount เมื่อคอมโพแนนท์นี้ลงไปอยู่ใน DOM แล้ว เป็นผลให้ loadPages ที่ใช้ในการดึงข้อมูลวิกิทั้งหมดจาก API Server ได้รับการโหลดด้วยเช่นกัน แต่ช้าก่อน... Node.js มันมี DOM ซะที่ไหนหละ นั่นหละครับ componentDidMount จึงไม่โหลดในการทำ Server-side Rendering (SSR)
อีกเรื่องที่ต้องคำนึงถึงก็คือเราบอกว่าเมื่อผู้ใช้ร้องขอหน้าเพจ การทำ SSR นั้นจะส่ง HTML ที่มีข้อมูลทั้งหมดพร้อมให้ผู้ใช้เห็นได้เลย นั่นหมายความว่า SSR ของเราต้องรอข้อมูลทั้งหมดจาก API Server ก่อนเพื่อนำข้อมูลเหล่านั้นไปสร้างก้อน HTML ตัวอย่างเช่น ถ้าผู้ใช้ร้องขอเพจจาก /pages สิ่งต่อไปนี้จะเกิดขึ้น
- ด้านเซิร์ฟเวอร์ของเราที่ทำ SSR จะต้องยิงรีเควสไปยัง http://127.0.0.1:5000/api/pages เพื่อดึงข้อมูลวิกิทั้งหมดก่อน
- จากนั้นนำข้อมูลพวกนี้ส่งต่อให้คอมโพแนนท์ต่างๆ เพื่อให้แสดงผลในคอมโพแนนท์เหล่านั้น
- แปลงสายของคอมโพแนนท์เหล่านั้นให้เป็นก้อน HTML ผ่านเมธอด renderToString
ในเมื่อเราใช้ componentDidMount ไม่ได้เราจึงต้องสร้างการเรียกใช้งานพิเศษเพื่อให้เซิร์ฟเวอร์ของเรารู้ว่าแต่ละคอมโพแนนท์ให้ไปดึงข้อมูลจาก API Server อย่างไร เราจะเพิ่ม need ซึ่งเป็น static เข้าไปใน pagesContainer แบบนี้ครับ
1class PagesContainer extends Component {2 static propTypes = {3 pages: PropTypes.array.isRequired,4 onLoadPages: PropTypes.func.isRequired,5 }67 // เราบอกว่า loadPages คือฟังก์ชันที่คอมโพแนนท์นี้จำเป็นต้องใช้เพื่อทำให้ตัวเองสมบูรณ์8 // ด้วยการดึงข้อมูลจาก API Server มาเติมเต็มให้ property ต่างๆของคอมโพแนนท์9 static need = [loadPages]1011 shouldComponentUpdate(nextProps) {12 return this.props.pages !== nextProps.pages13 }1415 onReloadPages = () => {16 this.props.onLoadPages()17 }1819 componentDidMount() {20 // เศร้าจังคุณไม่ได้ไปต่อครับ21 this.onReloadPages()22 }2324 render() {25 return <Pages pages={this.props.pages} onReloadPages={this.onReloadPages} />26 }27}
need ไม่ใช่ static data พิเศษอะไรที่ React หรือ Redux มีให้ครับ เราต้องสร้างขั้นตอนวิธีเพื่อจัดการเอง สร้างไฟล์ fetchComponent.js ขึ้นมาภายใต้ ui/server ครับ
1// ui/server/fetchComponent.js23// อีกซักครู่เราจะเรียกใช้งานฟังก์ชันนี้4// ฟังก์ชันนี้รับพารามิเตอร์สามตัว5// - dispatch คือ store.dispatch ใช้เพื่อส่ง action เข้าไป6// - components คือคอมโพแนนท์ที่เกี่ยวข้องทั้งหมด7// - params คือค่าต่างๆจาก router ที่เกี่ยวข้องกับ URL เช่นถ้าเราอยู่ที่ /pages/1 จะได้ว่า params.id คือ 18export function fetchComponent(dispatch, components, params) {9 const needs = components10 // ในบรรดาคอมโพแนนท์ทั้งหมดที่ส่งเข้ามา เอาเฉพาะคอมโพแนนท์ที่มีค่า11 // เป็นการป้องกันกรณี components มี null หรือ undefined ปนอยู่ด้วย12 .filter((component) => component)13 .reduce((prev, current) => {14 // จำได้ไหมเอ่ย เราบอกว่าหน้าที่ดึงข้อมูลจะต้องเป็นของ Container Component15 // Container Component ไหนมีการใช้ connect แสดงว่าตัวนั้นเกี่ยวข้องกับ state16 // เราจะเลือกเฉพาะ container Component ที่เกี่ยวข้องกับ state17 // คือมีการเรียกใช้ connect(mapStateToProps, mapDispatchToProps) นั่นเอง18 // connect เป็นฟังก์ชันที่คืนค่ากลับมาเป็นอีกคอมโพแนนท์ที่ครอบทับคอมโพแนนท์เดิมที่ส่งเข้าไป19 // เช่น connect(...)(FooComponent) จะได้คอมโพแนนท์ใหม่ที่สร้างครอบทับ FooComponent20 // เราสามารถเข้าถึงคอมโพแนนท์เดิมได้จากการเรียก [คอมโพแนนท์ใหม่].WrappedComponent21 // เราจึงใช้ WrappedComponent เป็นตัวทดสอบว่าคอมโพแนนท์นั้นผ่านการเรียก connect รึเปล่า22 // ถ้าผ่านการเรียก มันจะมี WrappedComponent อยู่ในตัวมัน23 // ย้ำอีกครั้ง ที่เราต้องทำแบบนี้เพราะเราจะจัดการดึงข้อมูลเฉพาะ Container Component24 // ที่มีการเรียก connect นั่นเอง25 const wrappedComponent = current.WrappedComponent2627 // เราจะรวบรวมฟังก์ชันที่อยู่ภายใต้ need ของแต่ละ Container Component28 return (current.need || [])29 .concat((wrappedComponent && wrappedComponent.need) || [])30 .concat(prev)31 }, [])3233 // ใช้ Promise.all เพื่อรอให้ข้อมูลตอบกลับมาทั้งหมดจาก API Server ก่อน34 // จากนั้นจึงคืนค่ากลับออกไปจากฟังก์ชัน35 // อย่าลืมว่าเราต้องมีข้อมูลพร้อมทั้งหมดก่อน ถึงจะแสดงผลได้ด้วย SSR36 // สังเกตว่าเราส่ง params เข้าไปใน need ด้วย นั่นคือในแต่ละฟังก์ชันภายใต้ need ของเราจะเข้าถึง params ได้37 return Promise.all(needs.map((need) => dispatch(need(params))))38}
ถ้ายังงงหละก็ ไปดูตัวอย่างการใช้งานกันอีกซักตัวครับ เปิดไฟล์ ui/common/containers/Pages/Show.js
1class ShowPageContainer extends Component {2 static propTypes = {3 page: PropTypes.object.isRequired,4 onLoadPage: PropTypes.func.isRequired,5 }67 // เราต้องการบอก fetchComponent ว่า Container Component ตัวนี้ต้องโหลดข้อมูลจาก loadPage8 // เนื่องจากคอมโพแนนท์นี้จะเรียกเมื่อเราเข้าผ่าน /pages/:id เช่น /pages/19 // เราจึงต้องส่ง id เข้าไปให้ loadPage รู้ด้วยว่าจะโหลดวิกิที่มี id เป็นอะไร10 static need = [(params) => loadPage(params.id)]1112 shouldComponentUpdate(nextProps) {13 return this.props.page !== nextProps.page14 }1516 componentDidMount() {17 const {18 onLoadPage,19 params: { id },20 } = this.props2122 onLoadPage(id)23 }2425 render() {26 const { id, title, content } = this.props.page2728 return <ShowPage id={id} title={title} content={content} />29 }30}3132// Container Component ตัวนี้มีการเรียก connect จะมี WrappedComponent เกิดขึ้น33// ฟังก์ชัน fetchComponent ของเราจึงจะเข้ามาจัดการกับคอมโพแนนท์ตัวนี้34export default connect(35 (state, ownProps) => ({ page: getPageById(state, ownProps.params.id) }),36 { onLoadPage: loadPage }37)(ShowPageContainer)
สุดท้ายก็ถึงขั้นตอนการเรียกใช้งานแล้วครับ เปิดไฟล์ ssr.js แล้วแก้ตามกันเลย
1import React from 'react'2import { match, RouterContext } from 'react-router'3import { renderToString } from 'react-dom/server'4import { Provider } from 'react-redux'5import createMemoryHistory from 'react-router/lib/createMemoryHistory'6import { syncHistoryWithStore } from 'react-router-redux'7import configureStore from '../common/store/configureStore'8import Root from '../common/containers/Root'9import getRoutes from '../common/routes'10import { fetchComponent } from './fetchComponent.js'1112const renderHtml = (html) => `13 <!DOCTYPE html>14 <html>15 <head>16 <meta charset='utf-8'>17 <title>Wiki!</title>18 </head>19 <body>20 <div id='app'>${html}</div>21 <script src='http://127.0.0.1:8081/static/bundle.js'></script>22 </body>23 </html>24`2526export default function (req, res) {27 const memoryHistory = createMemoryHistory(req.originalUrl)28 const store = configureStore(memoryHistory)29 const history = syncHistoryWithStore(memoryHistory, store)3031 match(32 {33 routes: getRoutes(store, history),34 location: req.originalUrl,35 },36 (error, redirectLocation, renderProps) => {37 if (error) {38 console.log(error)39 res.status(500).send('Internal Server Error')40 } else if (redirectLocation) {41 res.redirect(42 302,43 `${redirectLocation.pathname}${redirectLocation.search}`44 )45 } else if (renderProps) {46 // จำได้ไหมเอ่ย renderProps มี components กับ params ในนั้นด้วยนะ47 const { components, params } = renderProps4849 // ดึงข้อมูลจาก API Server เสร็จเมื่อไหร่ค่อยนำไปสร้าง HTML50 fetchComponent(store.dispatch, components, params)51 .then((html) => {52 const componentHTML = renderToString(53 // คอมโพแนนท์ของเราเกี่ยวข้องกับ state เราต้องการให้คอมโพแนนท์รับรู้ถึง state ผ่าน connect54 // จึงต้องห่อด้วย Provider55 <Provider store={store} key="provider">56 <RouterContext {...renderProps} />57 </Provider>58 )5960 res.status(200).send(renderHtml(componentHTML))61 })62 .catch((error) => {63 console.log(error)64 res.status(500).send('Internal Server Error')65 })66 } else {67 res.status(404).send('Not found')68 }69 }70 )71}
จุดน่าสังเกต ถึงตรงนี้ถ้าเพื่อนๆลองรีเฟรชหน้า http://127.0.0.1:8080/pages จะพบว่าทุกอย่างทำงานถูกต้องแล้ว แต่ถ้าเปิด Network บน Chrome Developer Tool ดูจะพบว่าแม้เซิร์ฟเวอร์จะมีข้อมูลพร้อมตอบกลับมาในรูปแบบ HTML แล้วก็ตาม แต่เมื่อ JavaScript โหลด มันจะทำ componentDidMount เป็นผลทำให้มันยิง API Server เพื่อขอข้อมูลวิกิอีกรอบ
ตั้ง state เริ่มต้นหลังทำ Server-side Rendering
กลับไปดูที่ Console ของ Chrome Developer Tool อีกครั้งเพื่อนๆจะพบกับมหกรรมการกร่นด่าดังนี้
1warning.js:44 Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server:2 (client) 1 data-reactid="13"></h1><p data-reactid3 (server) 1 data-reactid="13">test page#1</h1><p d
เกิดอะไรขึ้น??
การทำ SSR นั้น React จะมีการตรวจสอบว่า tag ของเราที่มาจากฝั่งเซิร์ฟเวอร์นั้นตรงกับสิ่งที่ได้จากการทำงานของ JavaScript บนเบราเซอร์ไหม เพื่อให้เข้าใจปัญหานี้มากขึ้นขอทบทวนกระบวนการทั้งหมดอีกรอบนึงก่อนครับ
- ผู้ใช้งานร้องขอข้อมูล
- fetchComponent ดึงข้อมูลจาก API Server
- นำข้อมูลที่ได้สร้าง HTML ผ่าน renderToString
- ส่งกลับไปให้เว็บเบราเซอร์พร้อมแสดงผลได้ทันที
- เว็บเบราเซอร์โหลด JavaScript จาก
<script>
- React ทำงาน
- Redux store ว่างเปล่าไม่มีสถานะของแอพพลิเคชันใดๆอยู่ในนั้นเพราะพึ่งเริ่มทำงาน
- จากข้อ 7 เป็นผลให้ HTML ก้อนแรกจากการทำงานของ React บนเบราเซอร์ไม่แสดงผลข้อมูลใดๆจากเซิร์ฟเวอร์
เมื่อถึงขั้นตอนที่ 8 แล้ว เราจะพบว่า HTML จากเซิร์ฟเวอร์มันมีส่วนแสดงผลข้อมูลอย่างครบถ้วน แต่ HTML จากเบราเซอร์ (client) เมื่อโหลดครั้งแรก state ว่างเปล่าจึงไม่มีอะไรให้แสดงผล ทั้งสองส่วนนี้มี HTML ที่ไม่ตรงกันเป็นผลให้ React กร่นด่าแบบสาดเสียเทเสียว่า React attempted to reuse markup in a container but the checksum was invalid
เพื่อแก้ปัญหานี้เราจึงต้องอัดฉีด state ที่ฝั่งเซิร์ฟเวอร์มีอยู่ไปให้ฝั่งไคลเอ็นต์ เมื่อ React ฝั่งไคลเอ็นต์ทำงานจะได้มี state แต่แรกเลย เป็นผลให้ HTML จากทั้งสองฝั่งตรงกัน
แก้ไขไฟล์ ssr.js ของเราอีกครั้งดังนี้
1import React from 'react'2import { match, RouterContext } from 'react-router'3import { renderToString } from 'react-dom/server'4import { Provider } from 'react-redux'5import createMemoryHistory from 'react-router/lib/createMemoryHistory'6import { syncHistoryWithStore } from 'react-router-redux'7import configureStore from '../common/store/configureStore'8import Root from '../common/containers/Root'9import getRoutes from '../common/routes'10import { fetchComponent } from './fetchComponent.js'1112// renderHtml ของเรารอบนี้รับ state เริ่มต้นมาด้วย13const renderHtml = (html, initialState) => `14 <!DOCTYPE html>15 <html>16 <head>17 <meta charset='utf-8'>18 <title>Wiki!</title>19 </head>20 <body>21 <div id='app'>${html}</div>22 <script>23 <!-- เราจะนำสถานะเริ่มต้นนี้แปะไว้ใน window.__INITIAL_STATE__ -->24 <!-- เมื่อ React ฝั่ง client ทำงานจะนำค่านี้ไปฉีดใส่ store ของเรา -->25 window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}26 </script>27 <script src='http://127.0.0.1:8081/static/bundle.js'></script>28 </body>29 </html>30`3132export default function (req, res) {33 const memoryHistory = createMemoryHistory(req.originalUrl)34 const store = configureStore(memoryHistory)35 const history = syncHistoryWithStore(memoryHistory, store)3637 match(38 {39 routes: getRoutes(store, history),40 location: req.originalUrl,41 },42 (error, redirectLocation, renderProps) => {43 if (error) {44 console.log(error)45 res.status(500).send('Internal Server Error')46 } else if (redirectLocation) {47 res.redirect(48 302,49 `${redirectLocation.pathname}${redirectLocation.search}`50 )51 } else if (renderProps) {52 const { components, params } = renderProps5354 fetchComponent(store.dispatch, components, params)55 .then((html) => {56 const componentHTML = renderToString(57 <Provider store={store} key="provider">58 <RouterContext {...renderProps} />59 </Provider>60 )6162 // เรียก getState เพื่อดึงค่าจาก store ปัจจุบันของฝั่งเซิร์ฟเวอร์63 // state ของเซิร์ฟเวอร์จะอัดฉีดลง store ฝั่ง client ภายหลัง64 const initialState = store.getState()6566 res.status(200).send(renderHtml(componentHTML, initialState))67 })68 .catch((error) => {69 console.log(error)70 res.status(500).send('Internal Server Error')71 })72 } else {73 res.status(404).send('Not found')74 }75 }76 )77}
เราเก็บสถานะของแอพพลิเคชันที่ได้จากการทำงานของ SSR ไว้ใน window.__INITIAL_STATE__
ตอนนี้ก็ถึงเวลาที่เราต้องนำตัวแปรนี้ไปอัดฉีดเข้าสู่ store ฝั่ง client เปิดไฟล์ ui/client/index.js ครับ
1import React, { Component } from 'react'2import { render } from 'react-dom'3import { browserHistory } from 'react-router'4import { AppContainer } from 'react-hot-loader'5import Root from '../common/containers/Root'67// เมื่อ JavaScript ฝั่งเบราเซอร์ทำงาน window จะเป็นตัวแปร global เข้าถึงได้ทุกที่8// เราดึงสถานะออกมาจากที่ตั้งค่าไว้ในเซิร์ฟเวอร์9const initialState = window.__INITIAL_STATE__10const rootEl = document.getElementById('app')1112render(13 <AppContainer>14 <Root history={browserHistory} initialState={initialState} />15 </AppContainer>,16 rootEl17)1819if (module.hot) {20 module.hot.accept('../common/containers/Root', () => {21 const NextRootApp = require('../common/containers/Root').default2223 render(24 <AppContainer>25 <NextRootApp history={browserHistory} initialState={initialState} />26 </AppContainer>,27 rootEl28 )29 })30}
จากนั้นเราจะส่ง initialState ต่อไปยัง Root เปิดไฟล์ common/containers/Root.js ขึ้นมาแก้ไขครับ
1import React, { Component } from 'react'2import { Provider } from 'react-redux'3import configureStore from '../store/configureStore'4import routes from '../routes'56export default class Root extends Component {7 render() {8 // รับ initialState เข้ามาจาก ui/client/index.js9 const { history, initialState } = this.props10 // ส่งต่อไปให้ store เพื่อให้สถานะของเราไปเก็บไว้ใน store11 const store = configureStore(history, initialState)1213 return (14 <Provider store={store} key="provider">15 {routes(store, history)}16 </Provider>17 )18 }19}
ขั้นตอนสุดท้ายเราต้องไปอัพเดท store ของเราให้รับค่าจาก initialState เข้ามาตั้งค่าให้ตนเอง เปิดไฟล์ configureStore.js ขึ้นมาครับ
1import { createStore, applyMiddleware } from 'redux'2import { routerMiddleware } from 'react-router-redux'3import thunk from 'redux-thunk'4import { apiMiddleware } from 'redux-api-middleware'5import createLogger from 'redux-logger'6import rootReducer from '../reducers'78export default (history, initialState) => {9 const middlewares = [thunk, apiMiddleware, routerMiddleware(history)]1011 if (process.env.NODE_ENV !== 'production') middlewares.push(createLogger())1213 const store = createStore(14 rootReducer,15 initialState,16 applyMiddleware(...middlewares)17 )1819 if (module.hot) {20 module.hot.accept('../reducers', () => {21 System.import('../reducers').then((nextRootReducer) =>22 store.replaceReducer(nextRootReducer.default)23 )24 })25 }2627 return store28}
กลับไปรีเฟรช http://127.0.0.1:8080/pages ของเราอีกครั้ง ถ้าทุกอย่างถูกต้อง console ของเพื่อนๆต้องไม่มี Warning อะไรอีกแล้ว ต้องแสดงผลข้อมูลถูกต้อง CSS ต้องสวยงาม และที่สำคัญ HTML ที่ส่งกลับมาจากเซิร์ฟเวอร์ต้องมีข้อมูลพร้อมแบบนี้
1<!DOCTYPE html>2<html>3 <head>4 <meta charset="utf-8" />5 <title>Wiki!</title>6 </head>7 <body>8 <div id="app">9 <div data-reactroot="" data-reactid="1" data-react-checksum="1078040375">10 <header class="Header__header___3o9RU" data-reactid="2">11 <nav data-reactid="3">12 <a class="Header__brand___SbhhJ" href="/" data-reactid="4"13 >Babel Coder Wiki!</a14 >15 <ul class="Header__menu___2GEcx" data-reactid="5">16 <li class="Header__menu__item___2TAAI" data-reactid="6">17 <a18 class="Header__menu__link___3xBUs"19 href="/pages"20 data-reactid="7"21 >All pages</a22 >23 </li>24 <li class="Header__menu__item___2TAAI" data-reactid="8">25 <a href="#" class="Header__menu__link___3xBUs" data-reactid="9"26 >About us</a27 >28 </li>29 </ul>30 </nav>31 </header>32 <div class="container" data-reactid="10">33 <div class="App__content___2YCcf" data-reactid="11">34 <div data-reactid="12">35 <button class="button" data-reactid="13">Reload Pages</button36 ><a href="/pages/new" data-reactid="14">Create New Page</a>37 <hr data-reactid="15" />38 <table class="table" data-reactid="16">39 <thead data-reactid="17">40 <tr data-reactid="18">41 <th data-reactid="19">ID</th>42 <th data-reactid="20">Title</th>43 <th data-reactid="21">Action</th>44 </tr>45 </thead>46 <tbody data-reactid="22">47 <tr data-reactid="23">48 <th data-reactid="24">1</th>49 <td data-reactid="25">test page#1</td>50 <td data-reactid="26">51 <a href="/pages/1" data-reactid="27">Show</a>52 </td>53 </tr>54 </tbody>55 </table>56 </div>57 </div>58 </div>59 </div>60 </div>61 <script>62 window.__INITIAL_STATE__ = {63 form: {},64 routing: {65 locationBeforeTransitions: {66 pathname: '/pages/',67 search: '',68 hash: '',69 state: null,70 action: 'POP',71 key: 'pt98hr',72 query: {},73 $searchBase: { search: '', searchBase: '' },74 },75 },76 pages: [{ id: 1, title: 'test page#1', content: 'TEST PAGE CONTENT' }],77 }78 </script>79 <script src="http://127.0.0.1:8081/static/bundle.js"></script>80 </body>81</html>
สรุปขั้นตอนการทำงานของ Isomorphic JavaScript
- เบราเซอร์ร้องขอ /pages
- Express.js (server) พอร์ต 8080 จะเป็นคนรับการร้องขอนั้น fetchComponent จะทำการดึงข้อมูลของ pages จาก /api/pages
- API Server ตอบข้อมูลกลับมาที่ server
- server จะส่งข้อมูลที่ได้จาก API Server ไปเก็บไว้ใน store เพื่อให้เป็น application state
- server จะสร้างก้อน HTML จากข้อมูลที่ได้ โดยแปะ application state ไว้ในตัวแปร
window.__INITIAL_STATE__
พร้อมทั้งมี<script>
ที่ชี้ไปยัง JavaScript ที่อยู่บน webpack dev server - ก้อน HTML ที่ server สร้างขึ้นจะส่งกลับไปให้เบราเซอร์
- เบราเซอร์เจอ
<script>
จึงโหลด JavaScript ที่อยู่บน webpack dev server พอร์ต 8081 ขึ้นมา - JavaScript จะเข้าถึงตัวแปร
window.__INITIAL_STATE__
แล้วทำการโยนข้อมูลนี้ลงไปใน store เพื่อเป็น application state ฝั่ง client - React Component ปรากฎตัวบน DOM โดยมีข้อมูลที่เหมือนกับฝั่ง server ทุกประการ
จัดระเบียบโปรเจคกันซะหน่อย
เมื่อเราทำทุกอย่างเรียบร้อย เรามาจัดระเบียบให้โปรเจคเราดูดีขึ้นกันครับ
เริ่มแรกเลยคือเราไม่มีความต้องการ index.html อีกต่อไปแล้ว เพราะเราใช้ SSR เพื่อสร้างก้อน HTML แทน ดังนั้นจัดการลบมันทิ้งเลยครับ!
ต่อไป .babelrc ของเราใช้เพื่อจัดการกับ ui ไม่ใช่ api ดังนั้นจึงไม่สมเหตุสมผลที่จะวางไฟล์นี้ไว้ระดับบนสุด จัดการย้าย .babelrc ของเราไปไว้ใต้ ui เลยครับ
ถึงคิวของ webpack.config.js แล้ว เราย้ายมันไปไว้ที่ ui/webpack/webpack.config.js เลยครับ แล้วอย่าลืมเปลี่ยน package.json ของเราดังนี้
1{2 "start-dev-ui": "webpack-dev-server --config ui/webpack/webpack.config.js"3}
ย้ายพอร์ต 8081 ไปใส่ใน devServer ของ webpack.config.js
1devServer: {2 port: 8081,3 hot: true,4 inline: false,5 historyApiFallback: true6}
โฟลเดอร์ lib ของเราตอนนี้ที่เก็บ processSass.js ไว้เป็นส่วนที่สัมพันธ์กับ ui เช่นกัน ไม่ต้องคิดอะไรมากย้ายมันไปไว้ใต้โฟลเดอร์ ui เลยครับ จากนั้นก็เปลี่ยนตำแหน่งของ processSass.js ใน .babelrc ด้วย
1{2 "preprocessCss": "./ui/lib/processSass.js",3}
เพื่อนๆจะพบว่าตอนนี้เราตั้งค่าพอร์ตรวมถึง host ไว้หลายจุดมาก มันทั้งซ้ำไปซ้ำมาและยากต่อการแก้ไขใช่ไหม เราจึงจะสร้างไฟล์ config.js ขึ้นมาเพื่อเก็บการตั้งค่าของเราแยกออกมาต่างหาก
1// ui/config.js2module.exports = {3 host: '127.0.0.1',4 apiPort: 5000,5 serverPort: 8080,6 clientPort: 8081,7}
แก้ไขไฟล์ต่อไปนี้เพื่อเรียกใช้การตั้งค่าใน config.js
1// ui/common/constants/endpoints.js2import config from '../../config'3const API_ROOT = `http://${config.host}:${config.serverPort}/api/v1`45export const PAGES_ENDPOINT = `${API_ROOT}/pages`67// webpack.config.js8const webpack = require('webpack')9const path = require('path')10const autoprefixer = require('autoprefixer')11const config = require('../config')1213module.exports = {14 devtool: 'eval',15 entry: [16 'react-hot-loader/patch',17 'webpack-dev-server/client?http://localhost:8081',18 'webpack/hot/only-dev-server',19 './ui/common/theme/elements.scss',20 './ui/client/index.js',21 ],22 output: {23 // ตรงนี้24 publicPath: `http://${config.host}:${config.clientPort}/static/`,25 path: path.join(__dirname, 'static'),26 filename: 'bundle.js',27 },28 plugins: [new webpack.HotModuleReplacementPlugin()],29 module: {30 loaders: [31 {32 test: /\.jsx?$/,33 exclude: /node_modules/,34 loaders: [35 {36 loader: 'babel-loader',37 query: {38 babelrc: false,39 presets: ['es2015', 'stage-0', 'react'],40 },41 },42 ],43 },44 {45 test: /\.css$/,46 loaders: ['style-loader', 'css-loader'],47 },48 {49 test: /\.scss$/,50 exclude: /node_modules/,51 loaders: [52 'style-loader',53 {54 loader: 'css-loader',55 query: {56 sourceMap: true,57 module: true,58 localIdentName: '[name]__[local]___[hash:base64:5]',59 },60 },61 {62 loader: 'sass-loader',63 query: {64 outputStyle: 'expanded',65 sourceMap: true,66 },67 },68 'postcss-loader',69 ],70 },71 ],72 },73 postcss: function () {74 return [autoprefixer]75 },76 devServer: {77 // ตรงนี้78 port: config.clientPort,79 hot: true,80 inline: false,81 historyApiFallback: true,82 },83}8485// server.js86import express from 'express'87import httpProxy from 'http-proxy'88import ssr from './ssr'89import config from '../config'9091// ตรงนี้92const PORT = config.serverPort93const app = express()94// ตรงนี้95const targetUrl = `http://${config.host}:${config.apiPort}`96const proxy = httpProxy.createProxyServer({97 target: targetUrl,98})99100app.use('/api', (req, res) => {101 proxy.web(req, res, { target: `${targetUrl}/api` })102})103app.use(ssr)104105app.listen(PORT, (error) => {106 if (error) {107 console.error(error)108 } else {109 console.info(`==> Listening on port ${PORT}.`)110 }111})
ข้อเสียของ Server Rendering ด้วย React
ReactDOMServer.renderToString ที่เราใช้สร้าง HTML ถ้าสังเกตให้ดีจะพบว่ามันเป็นการทำงานแบบ synchronous หรือการทำงานแบบประสานจังหวะ React จะค่อยๆแปลงทีละส่วนของคอมโพแนนท์เป็น string แค่นั้นยังไม่พอเรายังรอให้ API Server ตอบกลับก่อนถึงจะส่งผลลัพธ์กลับไปหาเบราเซอร์
ถ้าคอมโพแนนท์เราเยอะและมีขนาดใหญ่ หรือถ้า API Server เราใช้เวลาตอบกลับนานหละอะไรจะเกิดขึ้น? Node.js ตัวหลักใช้การทำงานแบบ single-thread ถ้าเจอการทำงานแบบ synchronous ที่ช้าแบบนี้ มันก็จะบลอค main thread ไว้ทำให้ไม่สามารถประมวลผล request ถัดไปได้ นั่นคือผู้ใช้งานคนถัดไปก็รอไปก่อนนะเออ
เมื่อ renderToString ต้องสร้างก้อน HTML จากคอมโพแนนท์ทั้งหมดที่จะแสดงผลก่อนส่งไปให้ผู้ใช้งาน นั่นหมายความว่า React จะต้องจองหน่วยความจำเพื่อแทนที่คอมโพแนนท์ทั้งหมดเหล่านี้ด้วย
TTFB (Time to First Byte) คือช่วงเวลาที่เบราเซอร์ต้องรอก่อนจะได้รับข้อมูลแรกจากเซิร์ฟเวอร์ เมื่อ renderToString ที่ทำงานกับคอมโพแนนท์จำนวนมากช้า มีผลทำให้ TTFB มากตาม จะดีกว่าไหมถ้าเราลด TTFB ด้วยการส่งข้อมูลก่อนแรกมาก่อน ก้อนแรกที่เบราเซอร์จะเอาไปใช้ได้เลยเช่น ชื่อไฟล์ CSS ชื่อไฟล์ JavaScript จากนั้นเมื่อ React พร้อมส่งข้อมูล ค่อยส่งข้อมูลตามหลังมาอีกทีแบบ stream
นั่นละครับคือคอนเซปต์ของ react-dom-stream ที่ผู้พัฒนาระบุว่าช่วยเพิ่มประสิทธิภาพของ SSR ได้มากโขเลยทีเดียว แต่ถึงอย่างไรผมไม่แนะนำให้ใช้กับ production นะครับ เหมือนผู้พัฒนาจะไม่ได้พัฒนาต่อแล้ว
ถ้าเพื่อนๆยังสนใจจะทำ Stream Server-side Redering อยู่ลอง Vue.js 2 เลยครับ มันมาพร้อมความสามารถนี้ที่แม้แต่ React ก็ยังไม่มี!
บทสรุป
Server-side Rendering แม้จะแลกมาด้วยความยุ่งยากในการตั้งค่า แต่ก็แลกมาด้วยความคุ้มค่ากับผลลัพธ์ที่ได้ เพื่อนๆสามารถเข้าไปดูโค๊ดของบทความนี้ได้จากที่นี่
ในบทความถัดไปซึ่งเป็นบทความสุดท้ายของซีรีย์นี้จะพูดถึงเรื่องของการใช้งาน React/Redux บน production อย่าลืมติดตามกันนะครับ
สารบัญ
- ข้อแนะนำก่อนอ่านบทความนี้
- พฤติกรรมปกติของ JavaScript Framework
- Isomorphic JavaScript และ Server-side Rendering คืออะไร?
- ข้อดีของ Isomorphic JavaScript
- ลงมือสร้าง Isomorphic JavaScript กันเถอะ
- Server-side Rendering ด้วย React
- Application State และ Server-side Rendering
- ตั้ง state เริ่มต้นหลังทำ Server-side Rendering
- สรุปขั้นตอนการทำงานของ Isomorphic JavaScript
- จัดระเบียบโปรเจคกันซะหน่อย
- ข้อเสียของ Server Rendering ด้วย React
- บทสรุป