[Day #4] Server Rendering ด้วย React/Redux และการทำ Isomorphic JavaScript

Nuttavut Thongjor

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

ข้อแนะนำก่อนอ่านบทความนี้

เนื่องจากบทความนี้เป็นบทความที่ 4 ในชุดบทความ สอนสร้าง Isomorphic Application ด้วย React.js และ Redux ใน 5 วัน เพื่อนๆมีความจำเป็นอย่างยิ่งที่จะต้องศึกษาเรื่องของ React และ 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 แบบปกติ

HTML
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

HTML
1<html>
2...
3<body>
4 <div id='main'>
5 <!-- JavaScript ฝั่งเซิร์ฟเวอร์จะสร้างเนื้อหาอัดใส่ไฟล์เรียบร้อย -->
6 <article>
7 <header>
8 <h1>รู้จัก babelcoder.com</h1>
9 </header>
10 ...
11 ...
12 </article>
13 </div>
14 <script src='path/to/javascript.js'></script>
15</body>
16</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 ต้องเป็นแบบนี้นะครับ

Code
1wiki
2 |----- .babelrc
3 |----- webpack.config.js
4 |----- package.json
5 |----- ui
6 |----- actions
7 |----- components
8 |----- containers
9 |----- constants
10 |----- reducers
11 |----- routes
12 |----- store
13 |----- theme
14 |----- index.js

เราบอกว่าอยากประมวลผล JavaScript บนเซิร์ฟเวอร์ของเราแต่ตอนนี้เรายังไม่มีโค๊ดฝั่งเซิร์ฟเวอร์เลย ความเป็นจริงแล้วเราสามารถทำงานกับ JavaScript ฝั่งเซิร์ฟเวอร์หรือ backend ได้ด้วยภาษาอื่นๆ แต่ในที่นี้เมื่อพูดถึง JavaScript ก็ขอจบด้วย JavaScript เริ่มจากติดตั้ง Express กันก่อนเลยครับ

Code
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 ของเราให้ชี้ไปที่ตำแหน่งใหม่

JavaScript
1// webpack.config.js
2entry: [
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],
10
11// ui/client/index.js
12import React, { Component } from 'react'
13import { render } from 'react-dom'
14import { AppContainer } from 'react-hot-loader'
15import Root from '../common/containers/Root'
16
17const rootEl = document.getElementById('app')
18
19render(
20 <AppContainer>
21 <Root />
22 </AppContainer>,
23 rootEl
24)
25
26if (module.hot) {
27 module.hot.accept('../common/containers/Root', () => {
28 const NextRootApp = require('../common/containers/Root').default
29
30 render(
31 <AppContainer>
32 <NextRootApp />
33 </AppContainer>,
34 rootEl
35 )
36 })
37}

ตอนนี้โครงสร้างไฟล์ของเพื่อนๆควรเป็นแบบนี้

Code
1wiki
2|----- api
3|----- ui
4 |----- client
5 |----- common
6 |----- actions
7 |----- components
8 |----- constants
9 |----- containers
10 |----- reducers
11 |----- store
12 |----- theme
13 |----- routes.js
14 |----- server
15 |-----> index.js
16 |-----> server.js
17|----- webpack

ไฟล์ server.js ของเราจะเป็นหน้าด่านในการประมวลผล JavaScript ฝั่งเซิร์ฟเวอร์ เพื่อให้มีเนื้อหาพร้อมแปะใน HTML ก่อนส่งให้เบราเซอร์แสดงผล

JavaScript
1// ui/server/server.js
2
3import express from 'express'
4
5const PORT = 8080
6const app = express()
7
8app.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 `
23
24 res.end(HTML)
25})
26
27app.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

JavaScript
1{
2 "start-dev-ui": "webpack-dev-server --port 8081"
3}

แม้ Node.js จะเข้าใจ ES2015 มากขึ้น แต่กระนั้นประโยค import ที่เราใช้ในไฟล์นี้ก็เป็นสิ่งหนึ่งที่ Node.js ยังไม่เข้าใจ เพิ่ม index.js ให้เป็นปราการด่านแรกก่อนเข้าถึงไฟล์ server.js ในไฟล์นี้เราจะลงทะเบียนให้ Babel เข้ามาจัดการแปลความหมาย ES2015 ของเรากัน

JavaScript
1// ui/server/index.js
2
3require('babel-core/register')
4
5module.exports = require('./server.js')

ก่อนจะไปกันต่อ เราอยากให้ไฟล์ฝั่งเซิร์ฟเวอร์ของเราโหลดใหม่ทุกครั้งที่มีการเปลี่ยนแปลงโค๊ด อย่ารอช้าติดตั้ง nodemon กันเถอะ

Code
1npm i --save-dev nodemon

ทุกอย่างพร้อมแล้วแต่เรายังไม่มีคำสั่งสำหรับทำงานไฟล์จากเซิร์ฟเวอร์นี้เลย แก้ไข package.json กันครับ

Code
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 แล้วนำส่วนต่อไปนี้ออกไปครับ

JavaScript
1proxy: {
2 '/api/*': {
3 target: 'http://127.0.0.1:5000'
4 }
5}

สุดท้ายจะเหลือแค่

JavaScript
1const webpack = require('webpack');
2const path = require('path');
3const autoprefixer = require('autoprefixer');
4
5module.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: [
21 new webpack.HotModuleReplacementPlugin()
22 ],
23 module: {
24 loaders: [
25 {
26 test: /\.jsx?$/,
27 exclude: /node_modules/,
28 loaders: [
29 {
30 loader: 'babel-loader',
31 query: {
32 babelrc: false,
33 presets: ["es2015", "stage-0", "react"]
34 }
35 }
36 ]
37 },
38 {
39 test: /\.css$/,
40 loaders: [
41 'style-loader',
42 'css-loader'
43 ]
44 }, {
45 test: /\.scss$/,
46 exclude: /node_modules/,
47 loaders: [
48 'style-loader',
49 {
50 loader: 'css-loader',
51 query: {
52 sourceMap: true,
53 module: true,
54 localIdentName: '[local]___[hash:base64:5]'
55 }
56 },
57 {
58 loader: 'sass-loader',
59 query: {
60 outputStyle: 'expanded',
61 sourceMap: true
62 }
63 },
64 'postcss-loader'
65 ]
66 }
67 ]
68 },
69 postcss: function () {
70 return [autoprefixer];
71 },
72 devServer: {
73 hot: true,
74 inline: false,
75 historyApiFallback: true
76 }
77};

ต่อไปติดตั้ง http-proxy เพื่อให้มี proxy ไว้ใช้ใน server.js ผ่านคำสั่งนี้

Code
1npm i --save http-proxy

เข้าไปที่ server.js ของเราแล้วเพิ่มความสามารถในการทำ proxy ให้มันดังนี้

JavaScript
1import express from 'express'
2// import เข้ามาโลด
3import httpProxy from 'http-proxy'
4
5const PORT = 8080
6const app = express()
7const targetUrl = 'http://127.0.0.1:5000'
8const proxy = httpProxy.createProxyServer({
9 // API Server ของเราอยู่ที่ port 5000 ไงหละ
10 target: targetUrl
11})
12
13// ถ้า path ที่เข้ามาขึ้นต้นด้วย /api ให้เรียกไปที่ http://127.0.0.1:5000/api
14app.use('/api', (req, res) => {
15 proxy.web(req, res, { target: `${targetUrl}/api` });
16})
17
18app.listen(PORT, error => {
19 if (error) {
20 console.error(error)
21 } else {
22 console.info(`==> Listening on port ${PORT}.`)
23 }
24})

สุดท้ายแก้ไข endpoints.js ซักนิดนึง

JavaScript
1const API_ROOT = 'http://127.0.0.1:8080/api/v1'
2
3export const PAGES_ENDPOINT = `${API_ROOT}/pages`

ตอนนี้ก็ถึงเวลาทดสอบโค๊ดกันแล้ว สั่ง npm start แล้วเข้าไปที่ http://127.0.0.1:8080 ครับ

Wiki ที่สวยงามของเราปรากฎออกมาใช่ไหมครับ แต่ถ้าเราสังเกตดูดีๆจะพบว่าสิ่งที่เซิร์ฟเวอร์ตอบกลับเรามาเป็นเพียง HTML ว่างเปล่าที่มีเพียงไฟล์ JavaScript สุดท้ายก็เข้ารูปแบบเดิมคือรอ JavaScript โหลดเนื้อหาเข้ามาใส่ DOM

Server-side Rendering1

ต่อไปนี้คือเป้าหมายของการทำ Isomorphic ในบทความนี้ครับ

Server-side Rendering2

  • แรกเริ่มเราร้องขอ /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

JavaScript
1// ui/src/client/index.js
2import React from 'react'
3import ReactDOM from 'react-dom'
4import { browserHistory } from 'react-router'
5import configureStore from '../common/store/configureStore'
6import { Root } from '../common/containers'
7
8// ตรงนี้
9ReactDOM.render(
10 <Root
11 store={configureStore()}
12 history={browserHistory} />,
13 document.getElementById('app')
14)

แต่ตอนนี้เราจะประมวลผล React บน Node.js วิธีการจึงต่างออกไปหน่อยคือใช้ renderToString จาก react-dom/server แทนครับ

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

เราจะสร้าง ui/src/server/ssr.js เพื่อเก็บ middleware ของ Express ตัวหนึ่งเพื่อใช้ในการทำ Server-side Rendering

JavaScript
1import React from 'react'
2import { renderToString } from 'react-dom/server'
3import Root from '../common/containers/Root'
4
5export 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 `
20
21 res.end(HTML)
22}

จากนั้นจึงเรียกใช้ middleware ตัวนี้ใน server.js

JavaScript
1// ui/src/server/server.js
2import express from 'express'
3import ssr from './ssr'
4
5const PORT = 8080
6const app = express()
7
8// โยน ssr ลงไปเป็น middleware ของ Express
9app.use(ssr)
10
11app.listen(PORT, error => {
12 if (error) {
13 console.error(error)
14 } else {
15 console.info(`==> Listening on port ${PORT}.`)
16 }
17})

กลับไปดูที่ terminal ของเรากันครับ

Code
1SyntaxError: wiki/ui/common/components/App/Header.scss: Unexpected token (1:1)

ปัญหาแรกเกิดขึ้นกับเราแล้ว! ในไฟล์ ui/common/components/App/Header/Header.js มีประโยค import เพื่อนำไฟล์ SCSS เข้ามาใช้งานแบบนี้

JavaScript
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 ดังนี้

SASS
1/* test.scss */
2
3.someClass {
4 color: red;
5}

เมื่อเราเรียกใช้ test.scss ในคอมโพแนนท์ของเราจะได้

JavaScript
1// component.js
2import styles from './test.scss'
3
4console.log(styles.someClass)
5
6// แปลงเป็น
7const styles = {
8 'someClass': 'Test__someClass___2Frqu'
9}
10
11console.log(styles.someClass) // จะได้ Test__someClass___2Frqu

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

Code
1$ npm i --save-dev babel-plugin-css-modules-transform

เนื่องจากมันเป็นปลักอินของ Babel เราจึงต้องไปตั้งค่าใน .babelrc เพื่อให้มันทำงานครับ

JavaScript
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 ซะก่อน

JavaScript
1// lib/processSass.js
2var sass = require('node-sass');
3var path = require('path');
4
5module.exports = function processSass(data, filename) {
6 var result;
7
8 result = sass.renderSync({
9 data: data,
10 file: filename
11 }).css;
12
13 return result.toString('utf8');
14};

ปลั๊กอินตัวนี้จะแปลงชื่อคลาสของ CSS ในรูปแบบ [name]__[local]___[hash:base64:5] แต่ใน webpack.config.js ของเราจะแปลงคลาสของ CSS เป็น [local]___[hash:base64:5] ซึ่งมันไม่ตรงกัน เราจึงควรไปเปลี่ยนให้ css-loader แปลงชื่อคลาสของ CSS ให้เหมือนชาวบ้านเขาดังนี้

JavaScript
1// webpack.config.js
2loaders: [
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 ไม่สนใจที่จะแปลงโค๊ดไฟล์นี้ของเรา มันเป็นบั๊คหนึ่งที่ผู้สร้างก็ยังงงงวยอยู่เลยครับ แต่นี่คือวิธีแก้ปัญหาเฉพาะหน้าที่ได้ผลขณะนี้ ใครอยากศึกษาถึงปัญหานี้เพิ่มเติมก็จิ้มลิงก์นี้โดยพลัน

JavaScript
1require('babel-core/register')({
2 ignore: [/processSass\.js/, /node_modules/]
3})
4
5module.exports = require('./server.js')

โจทย์ของเราคือเราจะใช้ปลักอินตัวนี้กับแค่โค๊ดฝั่งเซิร์ฟเวอร์เท่านั้น ถ้าเราไม่ทำอะไรซักอย่างการใส่ปลั๊กอินนี้ลง .babelrc จะกระทบทั้งโปรเจค รวมไปถึง Webpack ที่เราตั้งใจให้เป็นตัวแทนของฝั่งเบราเซอร์และไม่ต้องการให้ปลั๊กอินตัวนี้เข้ามาจัดการ นั่นเพราะ Webpack มี loader คอยจัดการกับ SCSS อยู่แล้วไง เพิ่มการตั้งค่าต่อไปนี้ครับเพื่อไม่ให้ปลั๊กอินนี้มีผลกับ Webpack

JavaScript
1// webpack.config.js
2{
3 test: /\.jsx?$/,
4 exclude: /node_modules/,
5 loaders: [
6 {
7 loader: 'babel-loader',
8 query: {
9 // บอก Webpack ให้ยุติการใช้งาน babelrc
10 babelrc: false,
11 // ดังนั้นเราจึงต้องตั้งค่าสิ่งที่เราจะใช้เองโดยไม่รวมปลั๊กอินเจ้าปัญหานั้นเข้าไปด้วย
12 presets: ["es2015", "stage-0", "react"]
13 }
14 }
15 ]
16},

เมื่อปู้ยี้ปู้ยำ webpack.config.js เสร็จแล้ว มาดูไฟล์เต็มกันดีกว่า ตรวจสอบอีกครั้งให้อุ่นใจดังนี้

JavaScript
1// webpack.config.js
2const webpack = require('webpack');
3const path = require('path');
4const autoprefixer = require('autoprefixer');
5
6module.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: [
21 new webpack.HotModuleReplacementPlugin()
22 ],
23 module: {
24 loaders: [
25 {
26 test: /\.jsx?$/,
27 exclude: /node_modules/,
28 loaders: [
29 {
30 loader: 'babel-loader',
31 query: {
32 babelrc: false,
33 presets: ["es2015", "stage-0", "react"]
34 }
35 }
36 ]
37 },
38 {
39 test: /\.css$/,
40 loaders: [
41 'style-loader',
42 'css-loader'
43 ]
44 }, {
45 test: /\.scss$/,
46 exclude: /node_modules/,
47 loaders: [
48 'style-loader',
49 {
50 loader: 'css-loader',
51 query: {
52 sourceMap: true,
53 module: true,
54 localIdentName: '[name]__[local]___[hash:base64:5]'
55 }
56 },
57 {
58 loader: 'sass-loader',
59 query: {
60 outputStyle: 'expanded',
61 sourceMap: true
62 }
63 },
64 'postcss-loader'
65 ]
66 }
67 ]
68 },
69 postcss: function () {
70 return [autoprefixer];
71 },
72 devServer: {
73 hot: true,
74 inline: false,
75 historyApiFallback: true
76 }
77};

ถ้าเราลองรัน npm start ใหม่อีกรอบก็จะอุ่นใจมากขึ้น

แต่ช้าก่อน ถ้ามันง่ายขนาดนั้นบทความนี้คงไม่เกิดขึ้นครับ ตอนนี้เราจะเจอข้อผิดพลาดตัวใหม่แล้วที่บอกว่า TypeError: Cannot read property 'listen' of undefined จาก syncHistoryWithStore

ถ้าเราเข้าไปดู ui/common/containers/Root.js ซักนิดจะพบว่าเราเรียกใช้ browserHistory เพื่อจัดการเส้นทางจราจรบนเบราเซอร์ แต่นี้มัน Node.js นะ ไม่ใช่เว็บเบราเซอร์ซะหน่อย!

JavaScript
1// ui/common/containers/Root.js
2import React, { Component } from 'react'
3import { Provider } from 'react-redux'
4// จ๊ะเอ๋
5import { browserHistory } from 'react-router'
6import configureStore from '../store/configureStore'
7import routes from '../routes'
8
9export 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 แทนครับดังนี้

JavaScript
1// ui/common/containers/Root.js
2import React, { Component } from 'react'
3import { Provider } from 'react-redux'
4import configureStore from '../store/configureStore'
5import routes from '../routes'
6
7export default class App extends Component {
8 render() {
9 // ส่งมาให้ฉันที
10 const { history } = this.props
11 const store = configureStore(history)
12
13 return (
14 <Provider store={store} key='provider'>
15 {routes(store, history)}
16 </Provider>
17 )
18 }
19}

แน่นอนว่า ui/client/index.js ก็ต้องส่ง history เข้ามาใน Root เช่นกัน

JavaScript
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'
7
8const rootEl = document.getElementById('app')
9
10render(
11 <AppContainer>
12 <!-- ตรงนี้ไง -->
13 <Root
14 history={browserHistory} />
15 </AppContainer>,
16 rootEl
17)
18
19if (module.hot) {
20 module.hot.accept('../common/containers/Root', () => {
21 const NextRootApp = require('../common/containers/Root').default
22
23 render(
24 <AppContainer>
25 <!-- ตรงนี้ไง -->
26 <NextRootApp
27 history={browserHistory} />
28 </AppContainer>,
29 rootEl
30 )
31 })
32}

ตอนนี้ก็ถึงคิวของ ssr.js แล้วครับ จากเดิมที่ history ของเราจะทราบว่าตอนนี้เราอยู่ที่ path ไหนของเว็บเพื่อเรียกคอมโพแนนท์มาทำงานได้ถูก แต่ตอนนี้เมื่ออยู่บนฝั่งเซิร์ฟเวอร์ เราไม่มี address bar แบบในเบราเซอร์นะ แล้วเราจะรู้ได้ยังไงว่าตอนนี้เราอยู่ที่เส้นทางไหนบนเว็บ ด้วยเหตุนี้เราจึงต้องใช้ createMemoryHistory เพื่อสร้าง history ทางฝั่งเซิร์ฟเวอร์ดังนี้

JavaScript
1const html = renderToString(
2 <Root
3 history={createMemoryHistory(???)} />
4)

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

ปัญหาอยู่ตรงนี้หละครับ เราจะรู้ได้ยังไงว่า location หรือ path ปัจจุบันของเราอยู่ที่ไหน? react-router มีคำตอบให้กับเราแล้วในเรื่องนี้ผ่าน match ครับ แก้ไข ssr.js ตามผมดังนี้

JavaScript
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'
9
10// แยกส่วนที่ใช้สร้าง HTML ออกมาเป็นฟังก์ชัน
11// รับพารามิเตอร์หนึ่งตัวคือ HTML
12const 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`)
25
26export default function(req, res) {
27 // สร้าง history ฝั่งเซิร์ฟเวอร์
28 const memoryHistory = createMemoryHistory(req.originalUrl)
29 // สร้าง store โดยส่ง history ที่ได้เป็นอาร์กิวเมนต์
30 const store = configureStore(memoryHistory)
31 // ยังจำได้ไหมเอ่ย เราต้องการเพิ่มความสามารถให้กับ history
32 // เราจึงใช้ react-router-redux ซึ่งเราต้องตั้งค่าผ่าน syncHistoryWithStore
33 // เพื่อให้ store รับรู้ถึงการเปลี่ยนแปลงของ history เช่นรู้ว่าตอนนี้อยู่ที่ URL ไหน
34 const history = syncHistoryWithStore(memoryHistory, store)
35
36 // ใช้ match เพื่อพิจารณาว่าปัจจุบันเราอยู่ที่ URL ไหนโดยดูจาก req.originalUrl ที่ส่งไปเป็น location
37 // match จะเข้าคู่ URL นี้กับ routes ที่เรามีทั้งหมด
38 match({
39 routes: getRoutes(store, history),
40 location: req.originalUrl
41 }, (error, redirectLocation, renderProps) => {
42 // หากเกิด error ก็ให้โยน HTTP 500 Internal Server Error ออกไป
43 if (error) {
44 res.status(500).send(error.message)
45 } else if (redirectLocation) {
46 // แต่ถ้าเจอว่าเป็นการ redirect ก็ให้ redirect ไปที่ path ใหม่
47 res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`)
48 } else if (renderProps) {
49 res.status(200).send(
50 // ส่ง RouterContext เข้าไปสร้าง HTML ใน renderHtml
51 renderHtml(renderToString(<RouterContext {...renderProps} />))
52 )
53 } else {
54 // ถ้าจับอะไรไม่ได้ซักอย่างก็ 404 Not Found ไปเลย
55 res.status(404).send('Not found')
56 }
57 })
58}

เมื่อเพื่อนๆอ่านโค๊ดแล้วต้องสงสัยกันเป็นแน่ว่า 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 ดูครับ

JavaScript
1class PagesContainer extends Component {
2 static propTypes = {
3 pages: PropTypes.array.isRequired,
4 onLoadPages: PropTypes.func.isRequired
5 }
6
7 shouldComponentUpdate(nextProps) {
8 return this.props.pages !== nextProps.pages;
9 }
10
11 onReloadPages = () => {
12 this.props.onLoadPages()
13 }
14
15 componentDidMount() {
16 // เพ่งสายตามาที่นี่โดยพลัน
17 this.onReloadPages()
18 }
19
20 render() {
21 return (
22 <Pages
23 pages={this.props.pages}
24 onReloadPages={this.onReloadPages} />
25 )
26 }
27}

เมื่อเราเข้าไปที่ /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 แบบนี้ครับ

JavaScript
1class PagesContainer extends Component {
2 static propTypes = {
3 pages: PropTypes.array.isRequired,
4 onLoadPages: PropTypes.func.isRequired
5 }
6
7 // เราบอกว่า loadPages คือฟังก์ชันที่คอมโพแนนท์นี้จำเป็นต้องใช้เพื่อทำให้ตัวเองสมบูรณ์
8 // ด้วยการดึงข้อมูลจาก API Server มาเติมเต็มให้ property ต่างๆของคอมโพแนนท์
9 static need = [
10 loadPages
11 ]
12
13 shouldComponentUpdate(nextProps) {
14 return this.props.pages !== nextProps.pages;
15 }
16
17 onReloadPages = () => {
18 this.props.onLoadPages()
19 }
20
21 componentDidMount() {
22 // เศร้าจังคุณไม่ได้ไปต่อครับ
23 this.onReloadPages()
24 }
25
26 render() {
27 return (
28 <Pages
29 pages={this.props.pages}
30 onReloadPages={this.onReloadPages} />
31 )
32 }
33}

need ไม่ใช่ static data พิเศษอะไรที่ React หรือ Redux มีให้ครับ เราต้องสร้างขั้นตอนวิธีเพื่อจัดการเอง สร้างไฟล์ fetchComponent.js ขึ้นมาภายใต้ ui/server ครับ

JavaScript
1// ui/server/fetchComponent.js
2
3// อีกซักครู่เราจะเรียกใช้งานฟังก์ชันนี้
4// ฟังก์ชันนี้รับพารามิเตอร์สามตัว
5// - dispatch คือ store.dispatch ใช้เพื่อส่ง action เข้าไป
6// - components คือคอมโพแนนท์ที่เกี่ยวข้องทั้งหมด
7// - params คือค่าต่างๆจาก router ที่เกี่ยวข้องกับ URL เช่นถ้าเราอยู่ที่ /pages/1 จะได้ว่า params.id คือ 1
8export function fetchComponent(dispatch, components, params) {
9 const needs =
10 components
11 // ในบรรดาคอมโพแนนท์ทั้งหมดที่ส่งเข้ามา เอาเฉพาะคอมโพแนนท์ที่มีค่า
12 // เป็นการป้องกันกรณี components มี null หรือ undefined ปนอยู่ด้วย
13 .filter(component => component)
14 .reduce((prev, current) => {
15 // จำได้ไหมเอ่ย เราบอกว่าหน้าที่ดึงข้อมูลจะต้องเป็นของ Container Component
16 // Container Component ไหนมีการใช้ connect แสดงว่าตัวนั้นเกี่ยวข้องกับ state
17 // เราจะเลือกเฉพาะ container Component ที่เกี่ยวข้องกับ state
18 // คือมีการเรียกใช้ connect(mapStateToProps, mapDispatchToProps) นั่นเอง
19 // connect เป็นฟังก์ชันที่คืนค่ากลับมาเป็นอีกคอมโพแนนท์ที่ครอบทับคอมโพแนนท์เดิมที่ส่งเข้าไป
20 // เช่น connect(...)(FooComponent) จะได้คอมโพแนนท์ใหม่ที่สร้างครอบทับ FooComponent
21 // เราสามารถเข้าถึงคอมโพแนนท์เดิมได้จากการเรียก [คอมโพแนนท์ใหม่].WrappedComponent
22 // เราจึงใช้ WrappedComponent เป็นตัวทดสอบว่าคอมโพแนนท์นั้นผ่านการเรียก connect รึเปล่า
23 // ถ้าผ่านการเรียก มันจะมี WrappedComponent อยู่ในตัวมัน
24 // ย้ำอีกครั้ง ที่เราต้องทำแบบนี้เพราะเราจะจัดการดึงข้อมูลเฉพาะ Container Component
25 // ที่มีการเรียก connect นั่นเอง
26 const wrappedComponent = current.WrappedComponent
27
28 // เราจะรวบรวมฟังก์ชันที่อยู่ภายใต้ need ของแต่ละ Container Component
29 return (current.need || [])
30 .concat(
31 (wrappedComponent && wrappedComponent.need) || []
32 )
33 .concat(prev)
34 }, []
35 )
36
37 // ใช้ Promise.all เพื่อรอให้ข้อมูลตอบกลับมาทั้งหมดจาก API Server ก่อน
38 // จากนั้นจึงคืนค่ากลับออกไปจากฟังก์ชัน
39 // อย่าลืมว่าเราต้องมีข้อมูลพร้อมทั้งหมดก่อน ถึงจะแสดงผลได้ด้วย SSR
40 // สังเกตว่าเราส่ง params เข้าไปใน need ด้วย นั่นคือในแต่ละฟังก์ชันภายใต้ need ของเราจะเข้าถึง params ได้
41 return Promise.all(needs.map(need => dispatch(need(params))))
42}

ถ้ายังงงหละก็ ไปดูตัวอย่างการใช้งานกันอีกซักตัวครับ เปิดไฟล์ ui/common/containers/Pages/Show.js

JavaScript
1class ShowPageContainer extends Component {
2 static propTypes = {
3 page: PropTypes.object.isRequired,
4 onLoadPage: PropTypes.func.isRequired
5 }
6
7 // เราต้องการบอก fetchComponent ว่า Container Component ตัวนี้ต้องโหลดข้อมูลจาก loadPage
8 // เนื่องจากคอมโพแนนท์นี้จะเรียกเมื่อเราเข้าผ่าน /pages/:id เช่น /pages/1
9 // เราจึงต้องส่ง id เข้าไปให้ loadPage รู้ด้วยว่าจะโหลดวิกิที่มี id เป็นอะไร
10 static need = [
11 (params) => (loadPage(params.id))
12 ]
13
14 shouldComponentUpdate(nextProps) {
15 return this.props.page !== nextProps.page;
16 }
17
18 componentDidMount() {
19 const { onLoadPage, params: { id } } = this.props
20
21 onLoadPage(id)
22 }
23
24 render() {
25 const { id, title, content } = this.props.page
26
27 return (
28 <ShowPage
29 id={id}
30 title={title}
31 content={content} />
32 )
33 }
34}
35
36
37// Container Component ตัวนี้มีการเรียก connect จะมี WrappedComponent เกิดขึ้น
38// ฟังก์ชัน fetchComponent ของเราจึงจะเข้ามาจัดการกับคอมโพแนนท์ตัวนี้
39export default connect(
40 (state, ownProps) => ({ page: getPageById(state, ownProps.params.id) }),
41 { onLoadPage: loadPage }
42)(ShowPageContainer)

สุดท้ายก็ถึงขั้นตอนการเรียกใช้งานแล้วครับ เปิดไฟล์ ssr.js แล้วแก้ตามกันเลย

JavaScript
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'
11
12const 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`)
25
26export default function(req, res) {
27 const memoryHistory = createMemoryHistory(req.originalUrl)
28 const store = configureStore(memoryHistory)
29 const history = syncHistoryWithStore(memoryHistory, store)
30
31 match({
32 routes: getRoutes(store, history),
33 location: req.originalUrl
34 }, (error, redirectLocation, renderProps) => {
35 if (error) {
36 console.log(error)
37 res.status(500).send('Internal Server Error')
38 } else if (redirectLocation) {
39 res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`)
40 } else if (renderProps) {
41 // จำได้ไหมเอ่ย renderProps มี components กับ params ในนั้นด้วยนะ
42 const { components, params } = renderProps
43
44 // ดึงข้อมูลจาก API Server เสร็จเมื่อไหร่ค่อยนำไปสร้าง HTML
45 fetchComponent(store.dispatch, components, params)
46 .then(html => {
47 const componentHTML = renderToString(
48 // คอมโพแนนท์ของเราเกี่ยวข้องกับ state เราต้องการให้คอมโพแนนท์รับรู้ถึง state ผ่าน connect
49 // จึงต้องห่อด้วย Provider
50 <Provider store={store} key='provider'>
51 <RouterContext {...renderProps} />
52 </Provider>
53 )
54
55 res.status(200).send(
56 renderHtml(componentHTML)
57 )
58 })
59 .catch(error => {
60 console.log(error)
61 res.status(500).send('Internal Server Error')
62 })
63 } else {
64 res.status(404).send('Not found')
65 }
66 })
67}

จุดน่าสังเกต ถึงตรงนี้ถ้าเพื่อนๆลองรีเฟรชหน้า http://127.0.0.1:8080/pages จะพบว่าทุกอย่างทำงานถูกต้องแล้ว แต่ถ้าเปิด Network บน Chrome Developer Tool ดูจะพบว่าแม้เซิร์ฟเวอร์จะมีข้อมูลพร้อมตอบกลับมาในรูปแบบ HTML แล้วก็ตาม แต่เมื่อ JavaScript โหลด มันจะทำ componentDidMount เป็นผลทำให้มันยิง API Server เพื่อขอข้อมูลวิกิอีกรอบ

ตั้ง state เริ่มต้นหลังทำ Server-side Rendering

กลับไปดูที่ Console ของ Chrome Developer Tool อีกครั้งเพื่อนๆจะพบกับมหกรรมการกร่นด่าดังนี้

Code
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-reactid
3 (server) 1 data-reactid="13">test page#1</h1><p d

เกิดอะไรขึ้น??

การทำ SSR นั้น React จะมีการตรวจสอบว่า tag ของเราที่มาจากฝั่งเซิร์ฟเวอร์นั้นตรงกับสิ่งที่ได้จากการทำงานของ JavaScript บนเบราเซอร์ไหม เพื่อให้เข้าใจปัญหานี้มากขึ้นขอทบทวนกระบวนการทั้งหมดอีกรอบนึงก่อนครับ

  1. ผู้ใช้งานร้องขอข้อมูล
  2. fetchComponent ดึงข้อมูลจาก API Server
  3. นำข้อมูลที่ได้สร้าง HTML ผ่าน renderToString
  4. ส่งกลับไปให้เว็บเบราเซอร์พร้อมแสดงผลได้ทันที
  5. เว็บเบราเซอร์โหลด JavaScript จาก <script>
  6. React ทำงาน
  7. Redux store ว่างเปล่าไม่มีสถานะของแอพพลิเคชันใดๆอยู่ในนั้นเพราะพึ่งเริ่มทำงาน
  8. จากข้อ 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 ของเราอีกครั้งดังนี้

JavaScript
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'
11
12// 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`)
31
32export default function(req, res) {
33 const memoryHistory = createMemoryHistory(req.originalUrl)
34 const store = configureStore(memoryHistory)
35 const history = syncHistoryWithStore(memoryHistory, store)
36
37 match({
38 routes: getRoutes(store, history),
39 location: req.originalUrl
40 }, (error, redirectLocation, renderProps) => {
41 if (error) {
42 console.log(error)
43 res.status(500).send('Internal Server Error')
44 } else if (redirectLocation) {
45 res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`)
46 } else if (renderProps) {
47 const { components, params } = renderProps
48
49 fetchComponent(store.dispatch, components, params)
50 .then(html => {
51 const componentHTML = renderToString(
52 <Provider store={store} key='provider'>
53 <RouterContext {...renderProps} />
54 </Provider>
55 )
56
57 // เรียก getState เพื่อดึงค่าจาก store ปัจจุบันของฝั่งเซิร์ฟเวอร์
58 // state ของเซิร์ฟเวอร์จะอัดฉีดลง store ฝั่ง client ภายหลัง
59 const initialState = store.getState()
60
61 res.status(200).send(
62 renderHtml(componentHTML, initialState)
63 )
64 })
65 .catch(error => {
66 console.log(error)
67 res.status(500).send('Internal Server Error')
68 })
69 } else {
70 res.status(404).send('Not found')
71 }
72 })
73}

เราเก็บสถานะของแอพพลิเคชันที่ได้จากการทำงานของ SSR ไว้ใน window.__INITIAL_STATE__ ตอนนี้ก็ถึงเวลาที่เราต้องนำตัวแปรนี้ไปอัดฉีดเข้าสู่ store ฝั่ง client เปิดไฟล์ ui/client/index.js ครับ

JavaScript
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'
6
7// เมื่อ JavaScript ฝั่งเบราเซอร์ทำงาน window จะเป็นตัวแปร global เข้าถึงได้ทุกที่
8// เราดึงสถานะออกมาจากที่ตั้งค่าไว้ในเซิร์ฟเวอร์
9const initialState = window.__INITIAL_STATE__
10const rootEl = document.getElementById('app')
11
12render(
13 <AppContainer>
14 <Root
15 history={browserHistory}
16 initialState={initialState} />
17 </AppContainer>,
18 rootEl
19)
20
21if (module.hot) {
22 module.hot.accept('../common/containers/Root', () => {
23 const NextRootApp = require('../common/containers/Root').default
24
25 render(
26 <AppContainer>
27 <NextRootApp
28 history={browserHistory}
29 initialState={initialState} />
30 </AppContainer>,
31 rootEl
32 )
33 })
34}

จากนั้นเราจะส่ง initialState ต่อไปยัง Root เปิดไฟล์ common/containers/Root.js ขึ้นมาแก้ไขครับ

JavaScript
1import React, { Component } from 'react'
2import { Provider } from 'react-redux'
3import configureStore from '../store/configureStore'
4import routes from '../routes'
5
6export default class Root extends Component {
7 render() {
8 // รับ initialState เข้ามาจาก ui/client/index.js
9 const { history, initialState } = this.props
10 // ส่งต่อไปให้ store เพื่อให้สถานะของเราไปเก็บไว้ใน store
11 const store = configureStore(history, initialState)
12
13 return (
14 <Provider store={store} key='provider'>
15 {routes(store, history)}
16 </Provider>
17 )
18 }
19}

ขั้นตอนสุดท้ายเราต้องไปอัพเดท store ของเราให้รับค่าจาก initialState เข้ามาตั้งค่าให้ตนเอง เปิดไฟล์ configureStore.js ขึ้นมาครับ

JavaScript
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'
7
8export default (history, initialState) => {
9 const middlewares = [thunk, apiMiddleware, routerMiddleware(history)]
10
11 if(process.env.NODE_ENV !== 'production')
12 middlewares.push(createLogger())
13
14 const store = createStore(
15 rootReducer,
16 initialState,
17 applyMiddleware(...middlewares)
18 )
19
20 if (module.hot) {
21 module.hot.accept('../reducers', () => {
22 System.import('../reducers').then(nextRootReducer =>
23 store.replaceReducer(nextRootReducer.default)
24 )
25 })
26 }
27
28 return store
29}

กลับไปรีเฟรช http://127.0.0.1:8080/pages ของเราอีกครั้ง ถ้าทุกอย่างถูกต้อง console ของเพื่อนๆต้องไม่มี Warning อะไรอีกแล้ว ต้องแสดงผลข้อมูลถูกต้อง CSS ต้องสวยงาม และที่สำคัญ HTML ที่ส่งกลับมาจากเซิร์ฟเวอร์ต้องมีข้อมูลพร้อมแบบนี้

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">Babel Coder Wiki!</a>
13 <ul class="Header__menu___2GEcx" data-reactid="5">
14 <li class="Header__menu__item___2TAAI" data-reactid="6"><a class="Header__menu__link___3xBUs" href="/pages" data-reactid="7">All pages</a></li>
15 <li class="Header__menu__item___2TAAI" data-reactid="8"><a href="#" class="Header__menu__link___3xBUs" data-reactid="9">About us</a></li>
16 </ul>
17 </nav>
18 </header>
19 <div class="container" data-reactid="10">
20 <div class="App__content___2YCcf" data-reactid="11">
21 <div data-reactid="12">
22 <button class="button" data-reactid="13">Reload Pages</button><a href="/pages/new" data-reactid="14">Create New Page</a>
23 <hr data-reactid="15"/>
24 <table class="table" data-reactid="16">
25 <thead data-reactid="17">
26 <tr data-reactid="18">
27 <th data-reactid="19">ID</th>
28 <th data-reactid="20">Title</th>
29 <th data-reactid="21">Action</th>
30 </tr>
31 </thead>
32 <tbody data-reactid="22">
33 <tr data-reactid="23">
34 <th data-reactid="24">1</th>
35 <td data-reactid="25">test page#1</td>
36 <td data-reactid="26"><a href="/pages/1" data-reactid="27">Show</a></td>
37 </tr>
38 </tbody>
39 </table>
40 </div>
41 </div>
42 </div>
43 </div>
44 </div>
45 <script>
46 window.__INITIAL_STATE__ = {"form":{},"routing":{"locationBeforeTransitions":{"pathname":"/pages/","search":"","hash":"","state":null,"action":"POP","key":"pt98hr","query":{},"$searchBase":{"search":"","searchBase":""}}},"pages":[{"id":1,"title":"test page#1","content":"TEST PAGE CONTENT"}]}
47 </script>
48 <script src='http://127.0.0.1:8081/static/bundle.js'></script>
49 </body>
50</html>

สรุปขั้นตอนการทำงานของ Isomorphic JavaScript

Server-side Rendering3

  1. เบราเซอร์ร้องขอ /pages
  2. Express.js (server) พอร์ต 8080 จะเป็นคนรับการร้องขอนั้น fetchComponent จะทำการดึงข้อมูลของ pages จาก /api/pages
  3. API Server ตอบข้อมูลกลับมาที่ server
  4. server จะส่งข้อมูลที่ได้จาก API Server ไปเก็บไว้ใน store เพื่อให้เป็น application state
  5. server จะสร้างก้อน HTML จากข้อมูลที่ได้ โดยแปะ application state ไว้ในตัวแปร window.__INITIAL_STATE__ พร้อมทั้งมี <script> ที่ชี้ไปยัง JavaScript ที่อยู่บน webpack dev server
  6. ก้อน HTML ที่ server สร้างขึ้นจะส่งกลับไปให้เบราเซอร์
  7. เบราเซอร์เจอ <script> จึงโหลด JavaScript ที่อยู่บน webpack dev server พอร์ต 8081 ขึ้นมา
  8. JavaScript จะเข้าถึงตัวแปร window.__INITIAL_STATE__ แล้วทำการโยนข้อมูลนี้ลงไปใน store เพื่อเป็น application state ฝั่ง client
  9. React Component ปรากฎตัวบน DOM โดยมีข้อมูลที่เหมือนกับฝั่ง server ทุกประการ

จัดระเบียบโปรเจคกันซะหน่อย

เมื่อเราทำทุกอย่างเรียบร้อย เรามาจัดระเบียบให้โปรเจคเราดูดีขึ้นกันครับ

เริ่มแรกเลยคือเราไม่มีความต้องการ index.html อีกต่อไปแล้ว เพราะเราใช้ SSR เพื่อสร้างก้อน HTML แทน ดังนั้นจัดการลบมันทิ้งเลยครับ!

ต่อไป .babelrc ของเราใช้เพื่อจัดการกับ ui ไม่ใช่ api ดังนั้นจึงไม่สมเหตุสมผลที่จะวางไฟล์นี้ไว้ระดับบนสุด จัดการย้าย .babelrc ของเราไปไว้ใต้ ui เลยครับ

ถึงคิวของ webpack.config.js แล้ว เราย้ายมันไปไว้ที่ ui/webpack/webpack.config.js เลยครับ แล้วอย่าลืมเปลี่ยน package.json ของเราดังนี้

Code
1{
2 "start-dev-ui": "webpack-dev-server --config ui/webpack/webpack.config.js"
3}

ย้ายพอร์ต 8081 ไปใส่ใน devServer ของ webpack.config.js

JavaScript
1devServer: {
2 port: 8081,
3 hot: true,
4 inline: false,
5 historyApiFallback: true
6}

โฟลเดอร์ lib ของเราตอนนี้ที่เก็บ processSass.js ไว้เป็นส่วนที่สัมพันธ์กับ ui เช่นกัน ไม่ต้องคิดอะไรมากย้ายมันไปไว้ใต้โฟลเดอร์ ui เลยครับ จากนั้นก็เปลี่ยนตำแหน่งของ processSass.js ใน .babelrc ด้วย

JavaScript
1{
2 "preprocessCss": "./ui/lib/processSass.js",
3}

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

JavaScript
1// ui/config.js
2module.exports = {
3 host: '127.0.0.1',
4 apiPort: 5000,
5 serverPort: 8080,
6 clientPort: 8081
7}

แก้ไขไฟล์ต่อไปนี้เพื่อเรียกใช้การตั้งค่าใน config.js

JavaScript
1// ui/common/constants/endpoints.js
2import config from '../../config'
3const API_ROOT = `http://${config.host}:${config.serverPort}/api/v1`
4
5export const PAGES_ENDPOINT = `${API_ROOT}/pages`
6
7// webpack.config.js
8const webpack = require('webpack');
9const path = require('path');
10const autoprefixer = require('autoprefixer');
11const config = require('../config');
12
13module.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: [
29 new webpack.HotModuleReplacementPlugin()
30 ],
31 module: {
32 loaders: [
33 {
34 test: /\.jsx?$/,
35 exclude: /node_modules/,
36 loaders: [
37 {
38 loader: 'babel-loader',
39 query: {
40 babelrc: false,
41 presets: ["es2015", "stage-0", "react"]
42 }
43 }
44 ]
45 },
46 {
47 test: /\.css$/,
48 loaders: [
49 'style-loader',
50 'css-loader'
51 ]
52 }, {
53 test: /\.scss$/,
54 exclude: /node_modules/,
55 loaders: [
56 'style-loader',
57 {
58 loader: 'css-loader',
59 query: {
60 sourceMap: true,
61 module: true,
62 localIdentName: '[name]__[local]___[hash:base64:5]'
63 }
64 },
65 {
66 loader: 'sass-loader',
67 query: {
68 outputStyle: 'expanded',
69 sourceMap: true
70 }
71 },
72 'postcss-loader'
73 ]
74 }
75 ]
76 },
77 postcss: function () {
78 return [autoprefixer];
79 },
80 devServer: {
81 // ตรงนี้
82 port: config.clientPort,
83 hot: true,
84 inline: false,
85 historyApiFallback: true
86 }
87};
88
89// server.js
90import express from 'express'
91import httpProxy from 'http-proxy'
92import ssr from './ssr'
93import config from '../config'
94
95// ตรงนี้
96const PORT = config.serverPort
97const app = express()
98// ตรงนี้
99const targetUrl = `http://${config.host}:${config.apiPort}`
100const proxy = httpProxy.createProxyServer({
101 target: targetUrl
102})
103
104app.use('/api', (req, res) => {
105 proxy.web(req, res, { target: `${targetUrl}/api` });
106})
107app.use(ssr)
108
109app.listen(PORT, error => {
110 if (error) {
111 console.error(error)
112 } else {
113 console.info(`==> Listening on port ${PORT}.`)
114 }
115})

ข้อเสียของ 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 -
  • บทสรุป -