Babel Coder

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

advanced

 บทความนี้เป็นส่วนหนึ่งของชุดบทความ [ชุดบทความ] สอนสร้าง Isomorphic Application ด้วย React.js และ Redux ใน 5 วัน

ตลอดทั้งชุดบทความนี้เพื่อนๆได้เรียนรู้การใช้งาน 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>
...
<body>
  <!-- ไฟล์นี้ body ว่างเปล่ามาก -->
  <!-- แต่มันจะมีเนื้อหาโผล่ขึ้นมาเมื่อ JavaScript ที่อยู่ในไฟล์ทำงาน -->
  <!-- JavaScript ต้องถูกโหลดก่อนแล้วดึงข้อมูลจากเซิร์ฟเวอร์หรือซักที่มาแสดงผลในนี้ -->
  <!-- แน่นอนว่าถ้าปิดการทำงานของ JavaScript เนื้อหาใดๆก็จะไม่โผล่ -->
  <div id='main'></div>
  <script src='path/to/javascript.js'></script>
</body>
</html>

ส่วนนี้คือ index.html ด้วยการทำ Isomorphic JavaScript

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

wiki
  |----- .babelrc
  |----- webpack.config.js
  |----- package.json
    |----- ui
      |----- actions
      |----- components
      |----- containers
      |----- constants
      |----- reducers
      |----- routes
      |----- store
      |----- theme
      |----- index.js

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

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

// webpack.config.js
entry: [
  'react-hot-loader/patch',
  'webpack-dev-server/client?http://localhost:8080',
  'webpack/hot/only-dev-server',
  // ตรงนี้
  './ui/common/theme/elements.scss',
  './ui/client/index.js'
],

// ui/client/index.js
import React, { Component } from 'react'
import { render } from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import Root from '../common/containers/Root'

const rootEl = document.getElementById('app')

render(
  <AppContainer>
    <Root />
  </AppContainer>,
  rootEl
)

if (module.hot) {
  module.hot.accept('../common/containers/Root', () => {
    const NextRootApp = require('../common/containers/Root').default

    render(
      <AppContainer>
         <NextRootApp />
      </AppContainer>,
      rootEl
    )
  })
}

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

wiki
|----- api
|----- ui
  |----- client
  |----- common
    |----- actions
    |----- components
    |----- constants
    |----- containers
    |----- reducers
    |----- store
    |----- theme
    |----- routes.js
  |----- server
    |-----> index.js
    |-----> server.js
|----- webpack

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

// ui/server/server.js

import express from 'express'

const PORT = 8080
const app = express()

app.use((req, res) => {
  const HTML = `
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset='utf-8'>
      <title>Wiki!</title>
    </head>
    <body>
      <div id='app'></div>
      <!-- ตอนนี้เราจะใช้พอร์ต 8081 กับ webpack dev server -->
      <script src='http://127.0.0.1:8081/static/bundle.js'></script>
    </body>
  </html>
  `

  res.end(HTML)
})

app.listen(PORT, error => {
  if (error) {
    console.error(error)
  } else {
    console.info(`==> Listening on port ${PORT}.`)
  }
})

อยากให้ทุกคนสังเกตตรง <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

{
  "start-dev-ui": "webpack-dev-server --port 8081"
}

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

// ui/server/index.js

require('babel-core/register')

module.exports = require('./server.js')

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

npm i --save-dev nodemon

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

{
  "scripts": {
    "start": "npm-run-all --parallel start-dev-api start-dev-ui start-dev-ssr",
    "start-dev-api": "json-server --watch api/db.json --routes api/routes.json --port 5000",
    "start-dev-ui": "webpack-dev-server --port 8081",
    "start-dev-ssr": "nodemon ./ui/server/index.js"
  }
}

package.json เราเพิ่ม start-dev-ssr ขึ้นมาเพื่อใช้ออกคำสั่งทำงานกับโค๊ด Server-side Rendering ของเรา

เมื่อเรามี SSR แล้วเราจึงควรย้าย proxy ของเราออกจาก Webpack Dev Server มาไว้ภายใต้ server.js ของเราแทน เข้าไปที่ webpack.config.js แล้วนำส่วนต่อไปนี้ออกไปครับ

proxy: {
  '/api/*': {
    target: 'http://127.0.0.1:5000'
  }
}

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

const webpack = require('webpack');
const path = require('path');
const autoprefixer = require('autoprefixer');

module.exports = {
  devtool: 'eval',
  entry: [
    'react-hot-loader/patch',
    'webpack-dev-server/client?http://localhost:8081',
    'webpack/hot/only-dev-server',
    './ui/common/theme/elements.scss',
    './ui/client/index.js'
  ],
  output: {
    // เปลี่ยนตรงนี้นิดนึงเพื่อให้ทำงานกับพอร์ตที่ถูกต้อง
    publicPath: 'http://127.0.0.1:8081/static/',
    path: path.join(__dirname, 'static'),
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loaders: [
          {
            loader: 'babel-loader',
            query: {
              babelrc: false,
              presets: ["es2015", "stage-0", "react"]
            }
          }
        ]
      },
      {
        test: /\.css$/,
        loaders: [
          'style-loader',
          'css-loader'
        ]
      }, {
        test: /\.scss$/,
        exclude: /node_modules/,
        loaders: [
          'style-loader',
          {
            loader: 'css-loader',
            query: {
              sourceMap: true,
              module: true,
              localIdentName: '[local]___[hash:base64:5]'
            }
          },
          {
            loader: 'sass-loader',
            query: {
              outputStyle: 'expanded',
              sourceMap: true
            }
          },
          'postcss-loader'
        ]
      }
    ]
  },
  postcss: function () {
    return [autoprefixer];
  },
  devServer: {
    hot: true,
    inline: false,
    historyApiFallback: true
  }
};

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

npm i --save http-proxy

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

import express from 'express'
// import เข้ามาโลด
import httpProxy from 'http-proxy'

const PORT = 8080
const app = express()
const targetUrl = 'http://127.0.0.1:5000'
const proxy = httpProxy.createProxyServer({
  // API Server ของเราอยู่ที่ port 5000 ไงหละ
  target: targetUrl
})

// ถ้า path ที่เข้ามาขึ้นต้นด้วย /api ให้เรียกไปที่ http://127.0.0.1:5000/api
app.use('/api', (req, res) => {
  proxy.web(req, res, { target: `${targetUrl}/api` });
})

app.listen(PORT, error => {
  if (error) {
    console.error(error)
  } else {
    console.info(`==> Listening on port ${PORT}.`)
  }
})

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

const API_ROOT = 'http://127.0.0.1:8080/api/v1'

export 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

// ui/src/client/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { browserHistory } from 'react-router'
import configureStore from '../common/store/configureStore'
import { Root } from '../common/containers'

// ตรงนี้
ReactDOM.render(
  <Root
    store={configureStore()}
    history={browserHistory} />,
  document.getElementById('app')
)

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

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

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

import React from 'react'
import { renderToString } from 'react-dom/server'
import Root from '../common/containers/Root'

export default function(req, res) {
  const html = renderToString(<Root />)
  const HTML = `
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset='utf-8'>
      <title>Wiki!</title>
    </head>
    <body>
      <div id='app'>${html}</div>
      <script src='http://127.0.0.1:8081/static/bundle.js'></script>
    </body>
  </html>
  `

  res.end(HTML)
}

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

// ui/src/server/server.js
import express from 'express'
import ssr from './ssr'

const PORT = 8080
const app = express()

// โยน ssr ลงไปเป็น middleware ของ Express
app.use(ssr)

app.listen(PORT, error => {
  if (error) {
    console.error(error)
  } else {
    console.info(`==> Listening on port ${PORT}.`)
  }
})

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

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

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

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

/* test.scss */

.someClass {
    color: red;
}

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

// component.js
import styles from './test.scss'

console.log(styles.someClass)

// แปลงเป็น
const styles = {
  'someClass': 'Test__someClass___2Frqu'
}

console.log(styles.someClass) // จะได้ Test__someClass___2Frqu

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

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

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

{
  "presets": ["es2015", "stage-0", "react"],
  "plugins": [
    "react-hot-loader/babel",
    [
      "css-modules-transform", {
        "preprocessCss": "./lib/processSass.js",
        "extensions": [".css", ".scss"]
      }
    ]
  ]
}

ปลั๊กอินตัวนี้รู้จักแค่ CSS แต่เรากำลังทำงานกับไฟล์ SCSS ดังนั้นจึงต้องสร้างไฟล์ชื่อ lib/processSass.js ขึ้นมาเพื่อบรรจุกระบวนการแปลง SCSS ให้เป็น CSS ซะก่อน

// lib/processSass.js
var sass = require('node-sass');
var path = require('path');

module.exports = function processSass(data, filename) {
  var result;
  
  result = sass.renderSync({
      data: data,
      file: filename
  }).css;

  return result.toString('utf8');
};

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

// webpack.config.js
loaders: [
  'style-loader',
  {
    loader: 'css-loader',
    query: {
      sourceMap: true,
      module: true,
      // ตรงนี้
      localIdentName: '[name]__[local]___[hash:base64:5]'
    }
  },
  {
    loader: 'sass-loader',
    query: {
      outputStyle: 'expanded',
      sourceMap: true
    }
  },
  'postcss-loader'
]

ขั้นตอนสุดท้ายให้แก้ไข ui/server/index.js เพื่อให้ Babel ไม่สนใจที่จะแปลงโค๊ดไฟล์นี้ของเรา มันเป็นบั๊คหนึ่งที่ผู้สร้างก็ยังงงงวยอยู่เลยครับ แต่นี่คือวิธีแก้ปัญหาเฉพาะหน้าที่ได้ผลขณะนี้ ใครอยากศึกษาถึงปัญหานี้เพิ่มเติมก็จิ้มลิงก์นี้โดยพลัน

require('babel-core/register')({
  ignore: [/processSass\.js/, /node_modules/]
})

module.exports = require('./server.js')

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

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

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

// webpack.config.js
const webpack = require('webpack');
const path = require('path');
const autoprefixer = require('autoprefixer');

module.exports = {
  devtool: 'eval',
  entry: [
    'react-hot-loader/patch',
    'webpack-dev-server/client?http://localhost:8081',
    'webpack/hot/only-dev-server',
    './ui/common/theme/elements.scss',
    './ui/client/index.js'
  ],
  output: {
    publicPath: 'http://127.0.0.1:8081/static/',
    path: path.join(__dirname, 'static'),
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loaders: [
          {
            loader: 'babel-loader',
            query: {
              babelrc: false,
              presets: ["es2015", "stage-0", "react"]
            }
          }
        ]
      },
      {
        test: /\.css$/,
        loaders: [
          'style-loader',
          'css-loader'
        ]
      }, {
        test: /\.scss$/,
        exclude: /node_modules/,
        loaders: [
          'style-loader',
          {
            loader: 'css-loader',
            query: {
              sourceMap: true,
              module: true,
              localIdentName: '[name]__[local]___[hash:base64:5]'
            }
          },
          {
            loader: 'sass-loader',
            query: {
              outputStyle: 'expanded',
              sourceMap: true
            }
          },
          'postcss-loader'
        ]
      }
    ]
  },
  postcss: function () {
    return [autoprefixer];
  },
  devServer: {
    hot: true,
    inline: false,
    historyApiFallback: true
  }
};

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

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

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

// ui/common/containers/Root.js
import React, { Component } from 'react'
import { Provider } from 'react-redux'
// จ๊ะเอ๋
import { browserHistory } from 'react-router'
import configureStore from '../store/configureStore'
import routes from '../routes'

export default class App extends Component {
  render() {
    // เค้าอยู่นี่ไง
    const store = configureStore(browserHistory)
    return (
      <Provider store={store} key='provider'>
        {routes(store, browserHistory)}
      </Provider>
    )
  }
}

ตอนนี้ผู้เรียกใช้งาน Root.js มีสองคนคือ client/index.js และ server/ssr.js โดยที่ history ของ client คือ browserHistory แต่ของ ssr.js จะเป็นตัวอื่นซึ่งเป็นคนละตัวกัน ดังนั้นแล้วเราจึงไม่สามารถเรียก browserHistory ตรงๆแบบนี้ใน Root ได้ แต่จะส่งผ่านเข้ามาเป็น property แทนครับดังนี้

// ui/common/containers/Root.js
import React, { Component } from 'react'
import { Provider } from 'react-redux'
import configureStore from '../store/configureStore'
import routes from '../routes'

export default class App extends Component {
  render() {
    // ส่งมาให้ฉันที
    const { history } = this.props
    const store = configureStore(history)

    return (
      <Provider store={store} key='provider'>
        {routes(store, history)}
      </Provider>
    )
  }
}

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

import React, { Component } from 'react'
import { render } from 'react-dom'
// import เข้ามาก่อน
import { browserHistory } from 'react-router'
import { AppContainer } from 'react-hot-loader'
import Root from '../common/containers/Root'

const rootEl = document.getElementById('app')

render(
  <AppContainer>
    <!-- ตรงนี้ไง -->
    <Root
      history={browserHistory} />
  </AppContainer>,
  rootEl
)

if (module.hot) {
  module.hot.accept('../common/containers/Root', () => {
    const NextRootApp = require('../common/containers/Root').default

    render(
      <AppContainer>
         <!-- ตรงนี้ไง -->
         <NextRootApp
           history={browserHistory} />
      </AppContainer>,
      rootEl
    )
  })
}

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

const html = renderToString(
  <Root
    history={createMemoryHistory(???)} />
)

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

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

import React from 'react'
import { match, RouterContext } from 'react-router'
import { renderToString } from 'react-dom/server'
import createMemoryHistory from 'react-router/lib/createMemoryHistory'
import { syncHistoryWithStore } from 'react-router-redux'
import configureStore from '../common/store/configureStore'
import Root from '../common/containers/Root'
import getRoutes from '../common/routes'

// แยกส่วนที่ใช้สร้าง HTML ออกมาเป็นฟังก์ชัน
// รับพารามิเตอร์หนึ่งตัวคือ HTML
const renderHtml = (html) => (`
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset='utf-8'>
      <title>Wiki!</title>
    </head>
    <body>
      <div id='app'>${html}</div>
      <script src='http://127.0.0.1:8081/static/bundle.js'></script>
    </body>
  </html>
`)

export default function(req, res) {
  // สร้าง history ฝั่งเซิร์ฟเวอร์ 
  const memoryHistory = createMemoryHistory(req.originalUrl)
  // สร้าง store โดยส่ง history ที่ได้เป็นอาร์กิวเมนต์
  const store = configureStore(memoryHistory)
  // ยังจำได้ไหมเอ่ย เราต้องการเพิ่มความสามารถให้กับ history
  // เราจึงใช้ react-router-redux ซึ่งเราต้องตั้งค่าผ่าน syncHistoryWithStore
  // เพื่อให้ store รับรู้ถึงการเปลี่ยนแปลงของ history เช่นรู้ว่าตอนนี้อยู่ที่ URL ไหน
  const history = syncHistoryWithStore(memoryHistory, store)

  // ใช้ match เพื่อพิจารณาว่าปัจจุบันเราอยู่ที่ URL ไหนโดยดูจาก req.originalUrl ที่ส่งไปเป็น location
  // match จะเข้าคู่ URL นี้กับ routes ที่เรามีทั้งหมด
  match({
    routes: getRoutes(store, history),
    location: req.originalUrl
  }, (error, redirectLocation, renderProps) => {
    // หากเกิด error ก็ให้โยน HTTP 500 Internal Server Error ออกไป
    if (error) {
      res.status(500).send(error.message)
    } else if (redirectLocation) {
      // แต่ถ้าเจอว่าเป็นการ redirect ก็ให้ redirect ไปที่ path ใหม่
      res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`)
    } else if (renderProps) {
      res.status(200).send(
        // ส่ง RouterContext เข้าไปสร้าง HTML ใน renderHtml
        renderHtml(renderToString(<RouterContext {...renderProps} />))
      )
    } else {
      // ถ้าจับอะไรไม่ได้ซักอย่างก็ 404 Not Found ไปเลย
      res.status(404).send('Not found')
    }
  })
}

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

class PagesContainer extends Component {
  static propTypes = {
    pages: PropTypes.array.isRequired,
    onLoadPages: PropTypes.func.isRequired
  }

  shouldComponentUpdate(nextProps) {
    return this.props.pages !== nextProps.pages;
  }

  onReloadPages = () => {
    this.props.onLoadPages()
  }

  componentDidMount() {
    // เพ่งสายตามาที่นี่โดยพลัน
    this.onReloadPages()
  }

  render() {
    return (
      <Pages
        pages={this.props.pages}
        onReloadPages={this.onReloadPages} />
    )
  }
}

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

class PagesContainer extends Component {
  static propTypes = {
    pages: PropTypes.array.isRequired,
    onLoadPages: PropTypes.func.isRequired
  }
  
  // เราบอกว่า loadPages คือฟังก์ชันที่คอมโพแนนท์นี้จำเป็นต้องใช้เพื่อทำให้ตัวเองสมบูรณ์
  // ด้วยการดึงข้อมูลจาก API Server มาเติมเต็มให้ property ต่างๆของคอมโพแนนท์
  static need = [
    loadPages
  ]

  shouldComponentUpdate(nextProps) {
    return this.props.pages !== nextProps.pages;
  }

  onReloadPages = () => {
    this.props.onLoadPages()
  }

  componentDidMount() {
    // เศร้าจังคุณไม่ได้ไปต่อครับ
    this.onReloadPages()
  }

  render() {
    return (
      <Pages
        pages={this.props.pages}
        onReloadPages={this.onReloadPages} />
    )
  }
}

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

// ui/server/fetchComponent.js

// อีกซักครู่เราจะเรียกใช้งานฟังก์ชันนี้
// ฟังก์ชันนี้รับพารามิเตอร์สามตัว
// - dispatch คือ store.dispatch ใช้เพื่อส่ง action เข้าไป
// - components คือคอมโพแนนท์ที่เกี่ยวข้องทั้งหมด
// - params คือค่าต่างๆจาก router ที่เกี่ยวข้องกับ URL เช่นถ้าเราอยู่ที่ /pages/1 จะได้ว่า params.id คือ 1
export function fetchComponent(dispatch, components, params) {
  const needs =
    components
      // ในบรรดาคอมโพแนนท์ทั้งหมดที่ส่งเข้ามา เอาเฉพาะคอมโพแนนท์ที่มีค่า
      // เป็นการป้องกันกรณี components มี null หรือ undefined ปนอยู่ด้วย
      .filter(component => component)
      .reduce((prev, current) => {
        // จำได้ไหมเอ่ย เราบอกว่าหน้าที่ดึงข้อมูลจะต้องเป็นของ Container Component
        // Container Component ไหนมีการใช้ connect แสดงว่าตัวนั้นเกี่ยวข้องกับ state
        // เราจะเลือกเฉพาะ container Component ที่เกี่ยวข้องกับ state 
        // คือมีการเรียกใช้ connect(mapStateToProps, mapDispatchToProps) นั่นเอง
        // connect เป็นฟังก์ชันที่คืนค่ากลับมาเป็นอีกคอมโพแนนท์ที่ครอบทับคอมโพแนนท์เดิมที่ส่งเข้าไป
        // เช่น connect(...)(FooComponent) จะได้คอมโพแนนท์ใหม่ที่สร้างครอบทับ FooComponent
        // เราสามารถเข้าถึงคอมโพแนนท์เดิมได้จากการเรียก [คอมโพแนนท์ใหม่].WrappedComponent
        // เราจึงใช้ WrappedComponent เป็นตัวทดสอบว่าคอมโพแนนท์นั้นผ่านการเรียก connect รึเปล่า
        // ถ้าผ่านการเรียก มันจะมี WrappedComponent อยู่ในตัวมัน
        // ย้ำอีกครั้ง ที่เราต้องทำแบบนี้เพราะเราจะจัดการดึงข้อมูลเฉพาะ Container Component 
        // ที่มีการเรียก connect นั่นเอง
        const wrappedComponent = current.WrappedComponent

        // เราจะรวบรวมฟังก์ชันที่อยู่ภายใต้ need ของแต่ละ Container Component
        return (current.need || [])
          .concat(
            (wrappedComponent && wrappedComponent.need) || []
          )
          .concat(prev)
        }, []
      )

  // ใช้ Promise.all เพื่อรอให้ข้อมูลตอบกลับมาทั้งหมดจาก API Server ก่อน
  // จากนั้นจึงคืนค่ากลับออกไปจากฟังก์ชัน
  // อย่าลืมว่าเราต้องมีข้อมูลพร้อมทั้งหมดก่อน ถึงจะแสดงผลได้ด้วย SSR
  // สังเกตว่าเราส่ง params เข้าไปใน need ด้วย นั่นคือในแต่ละฟังก์ชันภายใต้ need ของเราจะเข้าถึง params ได้
  return Promise.all(needs.map(need => dispatch(need(params))))
}

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

class ShowPageContainer extends Component {
  static propTypes = {
    page: PropTypes.object.isRequired,
    onLoadPage: PropTypes.func.isRequired
  }

  // เราต้องการบอก fetchComponent ว่า Container Component ตัวนี้ต้องโหลดข้อมูลจาก loadPage
  // เนื่องจากคอมโพแนนท์นี้จะเรียกเมื่อเราเข้าผ่าน /pages/:id เช่น /pages/1
  // เราจึงต้องส่ง id เข้าไปให้ loadPage รู้ด้วยว่าจะโหลดวิกิที่มี id เป็นอะไร
  static need = [
    (params) => (loadPage(params.id))
  ]

  shouldComponentUpdate(nextProps) {
    return this.props.page !== nextProps.page;
  }

  componentDidMount() {
    const { onLoadPage, params: { id } } = this.props

    onLoadPage(id)
  }

  render() {
    const { id, title, content } = this.props.page

    return (
      <ShowPage
        id={id}
        title={title}
        content={content} />
    )
  }
}


// Container Component ตัวนี้มีการเรียก connect จะมี WrappedComponent เกิดขึ้น
// ฟังก์ชัน fetchComponent ของเราจึงจะเข้ามาจัดการกับคอมโพแนนท์ตัวนี้
export default connect(
  (state, ownProps) => ({ page: getPageById(state, ownProps.params.id) }),
  { onLoadPage: loadPage }
)(ShowPageContainer)

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

import React from 'react'
import { match, RouterContext } from 'react-router'
import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import createMemoryHistory from 'react-router/lib/createMemoryHistory'
import { syncHistoryWithStore } from 'react-router-redux'
import configureStore from '../common/store/configureStore'
import Root from '../common/containers/Root'
import getRoutes from '../common/routes'
import { fetchComponent } from './fetchComponent.js'

const renderHtml = (html) => (`
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset='utf-8'>
      <title>Wiki!</title>
    </head>
    <body>
      <div id='app'>${html}</div>
      <script src='http://127.0.0.1:8081/static/bundle.js'></script>
    </body>
  </html>
`)

export default function(req, res) {
  const memoryHistory = createMemoryHistory(req.originalUrl)
  const store = configureStore(memoryHistory)
  const history = syncHistoryWithStore(memoryHistory, store)

  match({
    routes: getRoutes(store, history),
    location: req.originalUrl
  }, (error, redirectLocation, renderProps) => {
    if (error) {
      console.log(error)
      res.status(500).send('Internal Server Error')
    } else if (redirectLocation) {
      res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`)
    } else if (renderProps) {
      // จำได้ไหมเอ่ย renderProps มี components กับ params ในนั้นด้วยนะ
      const { components, params } = renderProps

      // ดึงข้อมูลจาก API Server เสร็จเมื่อไหร่ค่อยนำไปสร้าง HTML
      fetchComponent(store.dispatch, components, params)
        .then(html => {
          const componentHTML = renderToString(
            // คอมโพแนนท์ของเราเกี่ยวข้องกับ state เราต้องการให้คอมโพแนนท์รับรู้ถึง state ผ่าน connect
            // จึงต้องห่อด้วย Provider
            <Provider store={store} key='provider'>
              <RouterContext {...renderProps} />
            </Provider>
          )

          res.status(200).send(
            renderHtml(componentHTML)
          )
        })
        .catch(error => {
          console.log(error)
          res.status(500).send('Internal Server Error')
        })
    } else {
      res.status(404).send('Not found')
    }
  })
}

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

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

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

warning.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:
 (client) 1 data-reactid="13"></h1><p data-reactid
 (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 ของเราอีกครั้งดังนี้

import React from 'react'
import { match, RouterContext } from 'react-router'
import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import createMemoryHistory from 'react-router/lib/createMemoryHistory'
import { syncHistoryWithStore } from 'react-router-redux'
import configureStore from '../common/store/configureStore'
import Root from '../common/containers/Root'
import getRoutes from '../common/routes'
import { fetchComponent } from './fetchComponent.js'

// renderHtml ของเรารอบนี้รับ state เริ่มต้นมาด้วย
const renderHtml = (html, initialState) => (`
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset='utf-8'>
      <title>Wiki!</title>
    </head>
    <body>
      <div id='app'>${html}</div>
      <script>
        <!-- เราจะนำสถานะเริ่มต้นนี้แปะไว้ใน window.__INITIAL_STATE__ -->
        <!-- เมื่อ React ฝั่ง client ทำงานจะนำค่านี้ไปฉีดใส่ store ของเรา -->
        window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
      </script>
      <script src='http://127.0.0.1:8081/static/bundle.js'></script>
    </body>
  </html>
`)

export default function(req, res) {
  const memoryHistory = createMemoryHistory(req.originalUrl)
  const store = configureStore(memoryHistory)
  const history = syncHistoryWithStore(memoryHistory, store)

  match({
    routes: getRoutes(store, history),
    location: req.originalUrl
  }, (error, redirectLocation, renderProps) => {
    if (error) {
      console.log(error)
      res.status(500).send('Internal Server Error')
    } else if (redirectLocation) {
      res.redirect(302, `${redirectLocation.pathname}${redirectLocation.search}`)
    } else if (renderProps) {
      const { components, params } = renderProps

      fetchComponent(store.dispatch, components, params)
        .then(html => {
          const componentHTML = renderToString(
            <Provider store={store} key='provider'>
              <RouterContext {...renderProps} />
            </Provider>
          )
          
          // เรียก getState เพื่อดึงค่าจาก store ปัจจุบันของฝั่งเซิร์ฟเวอร์
          // state ของเซิร์ฟเวอร์จะอัดฉีดลง store ฝั่ง client ภายหลัง
          const initialState = store.getState()

          res.status(200).send(
            renderHtml(componentHTML, initialState)
          )
        })
        .catch(error => {
          console.log(error)
          res.status(500).send('Internal Server Error')
        })
    } else {
      res.status(404).send('Not found')
    }
  })
}

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

import React, { Component } from 'react'
import { render } from 'react-dom'
import { browserHistory } from 'react-router'
import { AppContainer } from 'react-hot-loader'
import Root from '../common/containers/Root'

// เมื่อ JavaScript ฝั่งเบราเซอร์ทำงาน window จะเป็นตัวแปร global เข้าถึงได้ทุกที่
// เราดึงสถานะออกมาจากที่ตั้งค่าไว้ในเซิร์ฟเวอร์
const initialState = window.__INITIAL_STATE__
const rootEl = document.getElementById('app')

render(
  <AppContainer>
    <Root
      history={browserHistory}
      initialState={initialState} />
  </AppContainer>,
  rootEl
)

if (module.hot) {
  module.hot.accept('../common/containers/Root', () => {
    const NextRootApp = require('../common/containers/Root').default

    render(
      <AppContainer>
         <NextRootApp
           history={browserHistory}
           initialState={initialState} />
      </AppContainer>,
      rootEl
    )
  })
}

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

import React, { Component } from 'react'
import { Provider } from 'react-redux'
import configureStore from '../store/configureStore'
import routes from '../routes'

export default class Root extends Component {
  render() {
    // รับ initialState เข้ามาจาก ui/client/index.js
    const { history, initialState } = this.props
    // ส่งต่อไปให้ store เพื่อให้สถานะของเราไปเก็บไว้ใน store
    const store = configureStore(history, initialState)

    return (
      <Provider store={store} key='provider'>
        {routes(store, history)}
      </Provider>
    )
  }
}

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

import { createStore, applyMiddleware } from 'redux'
import { routerMiddleware } from 'react-router-redux'
import thunk from 'redux-thunk'
import { apiMiddleware } from 'redux-api-middleware'
import createLogger from 'redux-logger'
import rootReducer from '../reducers'

export default (history, initialState) => {
  const middlewares = [thunk, apiMiddleware, routerMiddleware(history)]

  if(process.env.NODE_ENV !== 'production')
    middlewares.push(createLogger())

  const store = createStore(
    rootReducer,
    initialState,
    applyMiddleware(...middlewares)
  )

  if (module.hot) {
    module.hot.accept('../reducers', () => {
      System.import('../reducers').then(nextRootReducer =>
        store.replaceReducer(nextRootReducer.default)
      )
    })
  }

  return store
}

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

<!DOCTYPE html>
<html>
   <head>
      <meta charset='utf-8'>
      <title>Wiki!</title>
   </head>
   <body>
      <div id='app'>
         <div data-reactroot="" data-reactid="1" data-react-checksum="1078040375">
            <header class="Header__header___3o9RU" data-reactid="2">
               <nav data-reactid="3">
                  <a class="Header__brand___SbhhJ" href="/" data-reactid="4">Babel Coder Wiki!</a>
                  <ul class="Header__menu___2GEcx" data-reactid="5">
                     <li class="Header__menu__item___2TAAI" data-reactid="6"><a class="Header__menu__link___3xBUs" href="/pages" data-reactid="7">All pages</a></li>
                     <li class="Header__menu__item___2TAAI" data-reactid="8"><a href="#" class="Header__menu__link___3xBUs" data-reactid="9">About us</a></li>
                  </ul>
               </nav>
            </header>
            <div class="container" data-reactid="10">
               <div class="App__content___2YCcf" data-reactid="11">
                  <div data-reactid="12">
                     <button class="button" data-reactid="13">Reload Pages</button><a href="/pages/new" data-reactid="14">Create New Page</a>
                     <hr data-reactid="15"/>
                     <table class="table" data-reactid="16">
                        <thead data-reactid="17">
                           <tr data-reactid="18">
                              <th data-reactid="19">ID</th>
                              <th data-reactid="20">Title</th>
                              <th data-reactid="21">Action</th>
                           </tr>
                        </thead>
                        <tbody data-reactid="22">
                           <tr data-reactid="23">
                              <th data-reactid="24">1</th>
                              <td data-reactid="25">test page#1</td>
                              <td data-reactid="26"><a href="/pages/1" data-reactid="27">Show</a></td>
                           </tr>
                        </tbody>
                     </table>
                  </div>
               </div>
            </div>
         </div>
      </div>
      <script>
         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"}]}
      </script>
      <script src='http://127.0.0.1:8081/static/bundle.js'></script>
   </body>
</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 ของเราดังนี้

{
  "start-dev-ui": "webpack-dev-server --config ui/webpack/webpack.config.js"
}

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

devServer: {
  port: 8081,
  hot: true,
  inline: false,
  historyApiFallback: true
}

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

{
  "preprocessCss": "./ui/lib/processSass.js",
}

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

// ui/config.js
module.exports = {
  host: '127.0.0.1',
  apiPort: 5000,
  serverPort: 8080,
  clientPort: 8081
}

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

// ui/common/constants/endpoints.js
import config from '../../config'
const API_ROOT = `http://${config.host}:${config.serverPort}/api/v1`

export const PAGES_ENDPOINT = `${API_ROOT}/pages`

// webpack.config.js
const webpack = require('webpack');
const path = require('path');
const autoprefixer = require('autoprefixer');
const config = require('../config');

module.exports = {
  devtool: 'eval',
  entry: [
    'react-hot-loader/patch',
    'webpack-dev-server/client?http://localhost:8081',
    'webpack/hot/only-dev-server',
    './ui/common/theme/elements.scss',
    './ui/client/index.js'
  ],
  output: {
    // ตรงนี้
    publicPath: `http://${config.host}:${config.clientPort}/static/`,
    path: path.join(__dirname, 'static'),
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loaders: [
          {
            loader: 'babel-loader',
            query: {
              babelrc: false,
              presets: ["es2015", "stage-0", "react"]
            }
          }
        ]
      },
      {
        test: /\.css$/,
        loaders: [
          'style-loader',
          'css-loader'
        ]
      }, {
        test: /\.scss$/,
        exclude: /node_modules/,
        loaders: [
          'style-loader',
          {
            loader: 'css-loader',
            query: {
              sourceMap: true,
              module: true,
              localIdentName: '[name]__[local]___[hash:base64:5]'
            }
          },
          {
            loader: 'sass-loader',
            query: {
              outputStyle: 'expanded',
              sourceMap: true
            }
          },
          'postcss-loader'
        ]
      }
    ]
  },
  postcss: function () {
    return [autoprefixer];
  },
  devServer: {
    // ตรงนี้
    port: config.clientPort,
    hot: true,
    inline: false,
    historyApiFallback: true
  }
};

// server.js
import express from 'express'
import httpProxy from 'http-proxy'
import ssr from './ssr'
import config from '../config'

// ตรงนี้
const PORT = config.serverPort
const app = express()
// ตรงนี้
const targetUrl = `http://${config.host}:${config.apiPort}`
const proxy = httpProxy.createProxyServer({
  target: targetUrl
})

app.use('/api', (req, res) => {
  proxy.web(req, res, { target: `${targetUrl}/api` });
})
app.use(ssr)

app.listen(PORT, error => {
  if (error) {
    console.error(error)
  } else {
    console.info(`==> Listening on port ${PORT}.`)
  }
})

ข้อเสียของ 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 อย่าลืมติดตามกันนะครับ


แสดงความคิดเห็นของคุณ


Uoo Woraponเดือนที่แล้ว
//ใน fetchComponent.js 
//ตรงนี้เหมือนมันจะเรียก api สองครั้ง
/*return (current.need || [])
          .concat(
            (wrappedComponent && wrappedComponent.need) || []
          )
          .concat(prev)*/
// ผมเลยลองแก้ใหม่ เป็น
return (current && current.need)
// จะมีผลอะไรหรือเปล่าครับ

Thanarack Chaisri6 เดือนที่ผ่านมา

พอเรียก fetchComponent แล้วมันออกมาแบบนี้แก้ยังไงครับผม

TypeError: (0 , _fetchComponent2.default) is not a function
   at C:/sotv3/server/ssr.js:52:7
   at C:\sotv3\node_modules\react-router\lib\match.js:67:5
   at C:\sotv3\node_modules\react-router\lib\createTransitionManager.js:99:11
   at done (C:\sotv3\node_modules\react-router\lib\AsyncUtils.js:79:19)
   at C:\sotv3\node_modules\react-router\lib\AsyncUtils.js:85:7
   at getComponentsForRoute (C:\sotv3\node_modules\react-router\lib\getComponents.js:11:5)
   at C:\sotv3\node_modules\react-router\lib\getComponents.js:35:5
   at C:\sotv3\node_modules\react-router\lib\AsyncUtils.js:84:5
   at Array.forEach (native)
   at mapAsync (C:\sotv3\node_modules\react-router\lib\AsyncUtils.js:83:9)
ข้อความตอบกลับ
Thanarack Chaisri6 เดือนที่ผ่านมา

ลองเปลี่ยนเป็น ได้ มันจะเป็นอะไรปล่าวครับ

const fetchComponent = (dispatch, components, params) => {
         xxxxxxxxxxxxxxxxx
}
export default fetchComponent;

ไม่ระบุตัวตน11 เดือนที่ผ่านมา

ขอบคุณมากๆครับ ยังรอ Day5 อยู่นะครับ 😄


JungKoปีที่แล้ว

สุดยอด! ขอบคุณครับ

ข้อความตอบกลับ
ไม่ระบุตัวตนปีที่แล้ว
console.log('Thank you')

😃