Babel Coder

React: ใช้ Firebase จัดการ Server-Side Rendering ด้วย Cloud Functions กันเถอะ

advanced

บริการฟรีครอบจักรวาลแบบ Firebase นั้นเป็นดั่งขนมหวาน เราสามารถสร้างและวางเว็บไซต์ของเราได้ด้วย Firebase Hosting เมื่อเนื้อหาของเราไม่ใช่ static แต่จัดเก็บในฐานข้อมูลแล้วใช้ AJAX ดูดข้อมูลมาแสดงผล ปัญหาจึงเริ่มเกิดขึ้น…

Bot ของ Search Engine อย่าง Google นั้นฉลาด มันสามารถเข้าใจ JavaScript ได้อย่างดี เป็นผลให้เพจไหนที่ต้องใช้ JavaScript เพื่อดึงเนื้อหามาแสดง เพจนั้นยังคงได้รับการจัดอันดับบน Google อย่างถูกต้อง

ไม่ใช่กับ Bot หน่อมแน้มแบบ Facebook ที่ไม่ผ่าน JavaScript 101 ทำให้มันไม่สามารถแปลความจาก JavaScript ได้ ปัญหาหนะรึ? เอาลิงก์วางในช่องโพสต์ของเฟสบุค รูป Preview ก็ไม่ขึ้น Title ของโพสต์ก็ไม่โผล่ยังไงละ!

ทางออกของเว็บแบบ Dynamic Content สามารถแก้ได้ด้วยบริการอย่าง Prerender ฮีดช่า~ แต่เหมือนเรื่องจะไม่จบ เพราะปัญหายังคาใจผู้ใช้งานกลาง Github มานานแสนนาน บอกเลยงานนี้ตายตาไม่หลับแน่

เมื่อ Firebase ออกบริการ Cloud Functions ความหวังก็กลับมาอีกครั้ง เราสามารถเพิ่มศักยภาพของการทำ SEO ได้ด้วย Server-Side Rendering ผ่าน Cloud Functions ของ Firebase แล้วแจ้

บทความนี้เราจะทำ Server-Side Rendering อย่างง่ายๆด้วย React บน Cloud Functions ของ Firebase ดูซิว่าวิธีนี้จะช่วยให้บอทหน่อมแน้มได้ดื่มด่ำในทุ่งลาเวนเดอร์หรือไม่

บทความนี้ผู้อ่านควรใช้บริการพื้นฐานของ Firebase เป็น และพอเข้าใจการทำงานของ Server-Side Rendering ด้วย React มาบ้างแล้ว if(skill ~ lim(ssr -> 0)) { read(Server Rendering ด้วย React/Redux และการทำ Isomorphic JavaScript) }

สารบัญ

Firebase Cloud Functions ใน 1 นาที

Firebase นั้นมีบริการอย่าง Firebase Hosting เพื่อวางเนื้อหาแบบ static พูดง่ายๆคือทำเว็บแบบ HTML ธรรมดาสามัญ JavaScript พอกิ๊บเก๋ อยากจัดเก็บข้อมูลอะไรก็ใช้ JavaScript คุยกับ Realtime Database ของ Firebase เอา

มนุษย์นั้นไม่รู้จักพอ เมื่อได้คืบเราต้องเอาศอก ไหนๆก็มีบริการโฮสติ้งแล้วแต่เราก็อยากได้ศักยภาพจากการประมวลผลฝั่งเซิฟเวอร์ด้วย Firebase นั้นรู้ใจเราครับ จึงจัดบริการอย่าง Cloud Functions มาให้

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

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

exports.generateThumbnail = functions.storage.object().onChange(event => {
  // ...
});

นี่เราไม่ได้ทำบริการภาพโป้ออนไลน์นะ จะไปสนใจอะไรกับ thumbnail! สิ่งที่เราจะให้ความสนใจเป้นพิเศษในบทความนี้นั่นก็คือ การใช้ Cloud Functions เพื่อจัดการ HTTPS requests ต่างหากละ

Server-Side Rendering ด้วย Cloud Functions

ทบทวนกันหน่อยครับว่าปกติแล้วหากเราไม่ทำ Server-Side Rendering การทำงานของเว็บเราจะเป้นอย่างไร

แรกเริ่มนั้นเราร้องขอ index.html จากเซิฟเวอร์ซึ่งในไฟล์นี้มีลิงก์ที่ชี้ไปหาไฟล์ JavaScript ที่บรรจุ React และส่วนต่างๆของโค๊ดในแอพพลิเคชันเรา ในเวลาถัดมาเบราเซอร์จะโหลดไฟล์ JavaScript ของเรา ประมวลผลและแปลงเป็น DOM บนหน้าเพจ แน่นอนว่าการประมวลผล JavaScript นี้รวมไปถึงการโหลดข้อมูลของเราผ่าน API ด้วย AJAX เช่นกันทำให้เราไม่ต้องโหลดหน้าเพจใหม่ทั้งหมด และนั่นหละครับคือทั้งหมดของการสร้างแอพพลิเคชันที่ขับเคลื่อนด้วย JavaScript

ทุกอย่างไปได้สวยแต่จะเกิดอะไรขึ้นหละถ้าเบราเซอร์ของเราปิดการทำงานของ JavaScript ไว้? (ใครตอบว่าก็ไปเปิดให้ใช้ได้ซะซินี่ผมเคืองจริงๆนะ) ไม่ใช่แค่นั้น Bot อนุบาลหมีความตายท้องกลมแบบ Facebook เมื่อไม่เข้าใจ JavaScript มันก็จะไม่ได้ข้อมูลอะไรไปแสดง Preview เวลาโพสต์ด้วยเช่นกัน #พี่มาร์คมัวแต่ทำไรวะ #อ้อรอเลือกตั้ง #อ้าวคนละมาร์ค

งั้นเอาใหม่ทำแบบนี้ละกัน เมื่อเราร้องขอ index.html จากเซิร์ฟเวอร์เรายังคงได้ก้อนข้อมูลพร้อมลิงก์ของ JavaScript เช่นเดิม แต่สิ่งที่ต่างออกไปคือ index.html ของเราพร้อมแสดงผลบน DOM ได้ทันทีโดยไม่ต้องอาศัยความช่วยเหลือจาก JavaScript นั่นเป็นเพราะ JavaScript ฝั่งเซิร์ฟเวอร์ประมวลผลลัพธ์ไว้ให้เรียบร้อยแล้ว การประมวลผล JavaScript ในฝั่งเซิร์ฟเวอร์เพื่อให้มีข้อมูลพร้อมแสดงผลแบบนี้แหละครับที่เรียกว่า Server-side Rendering

เพราะคำจำกัดความข้างบนบอกไว้ว่า JavaScript ต้องประมวลผลฝั่งเซิฟเวอร์ก่อนเพื่อให้ได้ข้อมูลไปยัดใส่ index.html ก่อนส่งมาที่บราวเซอร์ เราจึงต้องใช้บริการอย่าง Cloud Functions ของ Firebase เพื่อช่วยประมวลผล JavaScript นั่นเอง

เตรียมฐานข้อมูลให้พร้อม

เราใช้บริการ Realtime Database ของ Firebase ครับ สิ่งที่เราจัดเก็บคือข้อมูลหนังสือ เราจะดึงข้อมูลเหล่านี้เนี่ยหละมาแสดงผล

react-ssr
  - books
    - intro-to-angular
      - desc: 'Learn one way to build applications with Ang...'
      - id: 'intro-to-angular'
      - title: 'Introduction to Angular'
    - intro-to-react
      - desc: 'React makes it painless to create interactiv...'
      - id: 'intro-to-react'
      - title: 'Introduction to React'
    - intro-to-vue
      - desc: 'React makes it painless to create interactive UIs....'
      - id: 'intro-to-vue'
      - title: 'Introduction to Vue'

จากหน้าตาอ็อบเจ็กต์ในฐานข้อมูลทำให้เราทราบว่าเรามีหนังสืออยู่ทั้งหมดสามเล่มได้แก่

  • Introduction to Angular
  • Introduction to React
  • Introduction to Vue

เราคาดหวังว่าเมื่อเราร้องขอผ่าน HTTP GET ไปที่ /books เราจะได้ข้อมูลหนังสือทั้งหมดออกมา และเมื่อเข้าถึง /books/:id จะได้ข้อมูลจำเพาะของหนังสือที่มี ID ตรงกับที่ระบุ

สร้างโปรเจคซิ รอไร!

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

ออกคำสั่ง firebase init เพื่อสร้างโปรเจค หลังจากตอบคำถามเสร็จเรียบร้อย หน้าตาโปรเจคของเพื่อนๆควรเป็นดังนี้ ถ้าไม่ตรงไม่ต้องกลัว แค่สร้างเพิ่ม

react-ssr << ชื่อโฟลเดอร์โปรเจค
  - functions << โค้ด cloud functions จะบรรจุในนี้
  - public << static content ของเราบรรจุในนี้แจ้
  - src << เราจะใส่โค้ด React กันในนี้
  - .firebaserc
  - database.rules.json
  - firebase.json

มีสองไฟล์สำคัญที่เราต้องแก้ไขกันก่อน เริ่มจาก database.rules.json

{
  "rules": {
    ".read": true // เราต้องการให้ใครก็ได้ หมูหมากาไก่ สามารถอ่านข้อมูลจากฐานข้อมูลเราได้หมด
  }
}

และไฟล์ถัดมาคือ firebase.json

{
  "hosting": {
    "public": "public",
    "rewrites": [
      // เราไม่ต้องการให้เข้าถึง / แล้วไปเรียก index.html
      // เราจะทำ SSR ดังนั้นเราจะส่งต่อการทำงานไปที่ cloud functions ที่ชื่อ app แทน
      // ฟังก์ชัน app จะทำ SSR และสร้าง index.html ออกมาให้กับเราเอง
      {
        "source": "**",
        "function": "app"
      }
    ]
  },
  "database": {
    "rules": "database.rules.json"
  }
}

ดึงข้อมูลจากฐานข้อมูลเพื่อใช้ใน Cloud Functions

ตอนนี้เราจะเขียนโค้ดเพื่อใช้บริการ Cloud functions แล้วครับ เนื่องจากบริการนี้ทำให้เราสามารถจัดการงานฝั่ง Backend ได้ด้วย Node และ JavaScript เราจึงสามารถติดตั้ง package ต่างๆได้ผ่าน NPM หรือ Yarn เหมือนที่เราทำกับโปรเจค Node ทั่วไป แล้วนี่คือไฟล์ functions/package.json ของเรา

{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "dependencies": {
    "express": "^4.15.3", << เราจะจัดการกับ HTTP Request
    "firebase": "^4.1.1",
    "firebase-admin": "~4.2.1",
    "firebase-functions": "^0.5.7",
    "react": "^15.5.4", << และ Render React ทางฝั่ง Server
    "react-dom": "^15.5.4",
    "request": "^2.81.0"
  },
  "private": true
}

ก็อบปี้แปะแล้วอย่าลืมสั่ง npm install หรือ yarn install หละฮะ

ลำดับถัดมาเราจะสร้างไฟล์ functions/firebase-database.js เพื่อเข้าถึงข้อมูลหนังสือของเรา

const firebase = global.firebase || require('firebase');

// ส่วนนี้ใช้ init app ของเรา
// จดๆไว้ก่อนว่าอยู่ในไฟล์นี้ เดียวเราจะมาเรียกใช้ภายหลัง
function initializeApp(config) {
  if(firebase.apps.length === 0) firebase.initializeApp(config);
}

// ดูดหนังสือทั้งกะบิออกมาจากฐานข้อมูล
function getBooks() {
  return firebase.database().ref('/books').orderByChild('title').once('value').then(snapshot => {
    return { books: snapshot.val() };
  })
}

// สนใจข้อมูลหนังสือแค่เล่มที่ ID ตรง
function getBookById(id) {
  return firebase.database().ref(`/books/${id}`).orderByChild('title').once('value').then(snapshot => {
    return { currentBook: snapshot.val() };
  })
}

// Export ออกไปให้เรียกใช้ได้ แม้อยู่ไกลยันดาวพลูโต
module.exports = {
  initializeApp,
  getBooks,
  getBookById
}

เตรียมโปรเจคสำหรับทำ Server-Side Rendering

ย้ายจากโฟลเดอร์ functions มาที่ src ซึ่งเป็นที่สิงสถิตย์ของ React กันบ้าง ตอนนี้โฟลเดอร์เราเป็นสาวพรหมจรรย์สุดๆ เราต้องประกอบร่างด้วยการสร้าง package.json ของเราขึ้นมาก่อน ดังนี้

{
  "name": "react-ssr",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build:client": "webpack --config ./webpack.client.js",
    "build:server": "webpack --config ./webpack.server.js",
    "build": "npm-run-all --parallel build:client build:server"
  },
  "dependencies": {
    "firebase": "^4.1.1",
    "react": "^15.5.4",
    "react-dom": "^15.5.4",
    "react-router": "^4.1.1",
    "react-router-dom": "^4.1.1"
  },
  "devDependencies": {
    "babel-core": "^6.24.1",
    "babel-loader": "^7.0.0",
    "babel-plugin-transform-class-properties": "^6.24.1",
    "babel-plugin-transform-object-rest-spread": "^6.23.0",
    "babel-preset-env": "^1.5.1",
    "babel-preset-react": "^6.24.1",
    "npm-run-all": "^4.0.2",
    "webpack": "^2.6.1"
  },
  "babel": {
    "presets": [
      [
        "env",
        {
          "targets": {
            "browsers": [
              "last 2 versions"
            ]
          },
          "modules": false
        }
      ],
      "react"
    ],
    "plugins": [
      "transform-object-rest-spread",
      "transform-class-properties"
    ]
  }
}

จาก package.json เพื่อนๆคงพอเห็นแล้วว่าเรามีการใช้ react และ react-router กันในโปรเจคนี้ เมื่อลอกข้อสอบเรียบร้อยแล้วก็อย่าลืม yarn install ละ

ส่วนของ scripts บอกเราว่าจะมีการใช้ webpack เพื่อสร้างผลลัพธ์ทั้งหมดสองตัวด้วยกัน

  • build:client ไว้สร้างผลลัพธ์เพื่อใช้งานกับเบราเซอร์ ตอบแบบลูกทุ่งก็คือ ผลลัพธ์จากการออกคำสั่งนี้เราจะนำไฟล์นั้นไปแปะใน index.html เมื่อเบราเซอร์โหลด index.html ขึ้นมา ไฟล์ JavaScript ตัวนี้ก็จะถูกโหลดในลำดับถัดมาเช่นกัน
  • build:server สร้างผลลัพธ์ไว้ทำงานกับฝั่ง server หรือก็คือทำงานกับไอ้เจ้า cloud functions ของเรานั่นเอง

สุดท้ายเราก็ต้องมานั่ง config Webpack ให้เรียบร้อย แต่ต้องบอกเลยว่านี่คือ config ที่เรียบง่ายมาก ไม่ optimize ใดๆเลยฮะ

ไฟล์แรกของเรา webpack.common.js จะเป็นไฟล์คอนฟิกที่ใช้ร่วมกันระหว่าง build:client และ build:server

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

module.exports = {
  resolve: {
    extensions: ['.js'],
    alias: {
      // จดๆๆ ต่อไปนี้ถ้าเราบอกว่า `firebase-database` นั่นหมายถึงเรากล่าวถึง firebase-database.js
      'firebase-database': path.resolve(__dirname, '../functions/firebase-database'),
    },
  },
  module: {
    rules: [{
      test: /\.(js|jsx)$/,
      exclude: /node_modules/,
      loader: 'babel-loader'
    }]
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env': {
        'NODE_ENV': JSON.stringify('production')
      }
    })
  ]
};

ลำดับถัดมาคือ webpack.client.js ไฟล์คอนฟิคสำหรับ build:client

const commonConfig = require('./webpack.common');
const path = require('path');

module.exports = Object.assign({}, {
  // Entry เริ่มที่ Client.js จดๆๆไว้ก่อน
  entry: './containers/Client.js',
  output: {
    // ผลลัพธืจากการ build จะอยู่ที่ public/assets/client.bundle.js
    // ที่ต้องอยู่ใต้ public เพราะเราจะให้เข้าถึงได้จาก browser นั่นเอง
    filename: 'client.bundle.js',
    path: path.resolve(__dirname, '../public/assets')
  }
}, commonConfig);

และส่วนสุดท้ายของเรา webpack.server.js คอนฟิคสำหรับ build:server

const commonConfig = require('./webpack.common');
const path = require('path');

module.exports = Object.assign({}, {
  // เราต้องการนำไปใช้กับ node (cloud funtions)
  target: 'node',
  // Entry เริ่มที่ Server.js อันนี้ก็จดๆๆไว้ก่อน
  entry: './containers/Server.js',
  output: {
    filename: 'server.bundle.js',
    path: path.resolve(__dirname, '../functions/build'),
    // ซึ่งถ้าเป็น commonjs ทั่วไปจะเป็น export.<xxx>
    // แต่เราต้องการใช้กับ Node ที่มีลักษณะการใช้ module เป็น module.exports
    // เราจึงใช้ commonjs2
    // ไม่เข้าใจผ่านได้ครับ ตำรวจไม่จับ
    libraryTarget: 'commonjs2',
  }
}, commonConfig);

เตรียมโค้ด React สำหรับทำ Server-Side Rendering

ตามข้อตกลง เราจะเขียนโค้ด React ของเราภายใต้โฟลเดอร์ src ครับ โดยโครงสร้างข้างในประกอบด้วยตับไตไส้พุง ดังนี้่

src
  - components
    - Book.js << แสดงผลหนังสือ 1 เล่ม
    - Books.js << แสดงรายการหนังสือทุกเล่ม
    - index.js
  - containers
    - App.js
    - Client.js << สำหรับ Client
    - Server.js << สำหรับ Server

เริ่มต้นเอาฤกษ์เอาชัยกันด้วย containers/App.js กันก่อนครับ

App.js นั้นจะมีหน้าที่ 2 อย่างด้วยกัน

ภารกิจแรกคือรับ state เข้ามาเพื่อใช้แสดงผล state ที่ว่านี้คืออะไรหนอ? ลองจินตนาการครับหากเราเข้ามาที่่ /books ความหมายคือ เราต้องเตรียมข้อมูลหนังสือทั้งหมดไว้แสดงผล ข้อมูลที่เราเตรียมนี่หละคือ state สำหรับฝั่ง Server นั้นเราทำ SSR มันจึงต้องคุยกับ database ให้เรียบร้อยก่อนเพื่อให้ได้มาซึ่งข้อมูล หลังจากเตรียมข้อมูลเสร็จแล้วจึงนำไปแสดงผลด้วยการสร้างก้อน HTML ส่งกลับออกไป

เนื่องจาก HTML ที่ส่งกลับออกไปมีโค้ด React อยู่ เราคงไม่อยากให้เบราเซอร์ต้องโหลดข้อมูลซ้ำสองใช่ไหมละ เมื่อเป็นเช่นนี้โค้ดฝั่ง Server ของเราจึงต้องสร้าง state ฝังไว้กับ HTML ด้วย ในที่นี้เราจะฝังไปกับ window ในชื่อของ window.__initialState เมื่อ Client เริ่มทำงานจะดึงข้อมูลนี้ไปตั้งค่าเป็น state เลย ทำให้เราไม่ต้องเรียกฐานข้อมูลอีกเป็นครั้งที่สอง

หน้าที่สุดท้ายของ App.js นั่นก็คือเตรียมเมธอด loadBookById และ loadBooks เอาไว้ เมื่อใดที่ Route path เปลี่ยนจะเรียกใช้งานเมธอดดังกล่าวเพื่อเข้าถึงข้อมูลที่มันต้องการแสดงผล

import React, { Component } from 'react'
import { Switch, Route } from 'react-router'
import { Link } from 'react-router-dom'
import { Books, Book } from '../components'
// จำ alias ในคอนฟิคของ Webpack ได้ไหมเอ่ย
import database from 'firebase-database'

export default class extends Component {
  constructor(props) {
    super(props)

    this.state = { books: {}, currentBook: {}, ...props.state }
  }

  loadBookById = id => {
    database.getBookById(id).then(({ currentBook }) => this.setState({ currentBook }))
  }

  loadBooks = () => {
    database.getBooks().then(({ books }) => this.setState({ books }))
  }

  render() {
    const { books, currentBook } = this.state

    return (
      <div>
        <nav className='navbar navbar-light bg-faded mb-3'>
          <div className='container'>
            <div className='navbar-header'>
              <Link to='/books' className='navbar-brand'>Books</Link>
            </div>
          </div>
        </nav>
        <Switch>
          <Route path='/books/:id' render={props => (
            <Book {...props}
              book={currentBook}
              loadBookById={this.loadBookById} />
          )} />
          <Route path='/books' render={props => (
            <Books {...props} key='books'
              books={books}
              loadBooks={this.loadBooks} />
          )} />
        </Switch>
      </div>
    )
  }
}

เมื่อ path ของเราคือ /books เราต้องการให้หยิบ components/Books.js มาแสดง เริ่มสร้างไฟล์นี้ด้วยโค้ดต่อไปนี้เถอะ

import React, { Component } from 'react'
import { Link } from 'react-router-dom'

export default class extends Component {
  componentDidMount() {
    const { books, loadBooks } = this.props

    if(Object.keys(books).length === 0) loadBooks()
  }

  render() {
    const { books } = this.props

    return (
      <div className='container'>
        <ul className='list-group'>
          {
            Object.keys(books).map(
              slug => {
                const book = books[slug]

                return (
                  <li key={slug} className='list-group-item'>
                    <Link to={`/books/${book.id}`}>{book.title}</Link>
                  </li>
                )
              }
            )
          }
        </ul>
      </div>
    )
  }
}

และสำหรับ /books/:id เราจะใช้คอมโพแนนท์ components/Book.js ในการแสดงผล ดังนี้

import React, { Component } from 'react'

export default class extends Component {
  componentDidMount() {
    const { book, loadBookById, match: { params } } = this.props

    if(Object.keys(book).length === 0) {
      loadBookById(params.id)
    }
  }

  componentWillReceiveProps(nextProps) {
    const nextId = nextProps.match.params.id

    if (nextId !== this.props.match.params.id) {
      this.props.loadBookById(nextId)
    }
  }

  render() {
    const { title, desc } = this.props.book

    return (
      <div className='container'>
        <table className='table table-striped details-table'>
          <tbody>
            <tr>
              <td className='title'>Title: </td>
              <td>{title}</td>
            </tr>
            <tr>
              <td className='title'>Desc: </td>
              <td>{desc}</td>
            </tr>
          </tbody>
        </table>
      </div>
    )
  }
}

สุดท้ายคือ components/index.js ที่ใช้เป็นตัวบอกว่าภายใต้โมดูล components มีของอะไรให้เรียกใช้บ้าง

export { default as Books } from './Books'
export { default as Book } from './Book'

ก่อนหน้านี้เราบอกแล้วว่า Client.js จะได้รับค่า window.__initialState ไปเป็น state ของแอพพลิเคชัน และนี่คือส่วนของโค้ดที่เราพูดถึงครับ

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router } from 'react-router-dom'
import App from './App'

ReactDOM.render(
  <Router>
    <App state={window.__initialState}/>
  </Router>
  , document.getElementById('root')
)

ตั้งค่า Cloud Functions ให้ทำงานกับ HTTP Requests

ตอนนี้ก็ถึงตาพระเอกของเราแล้วฮะ เราจะใช้ Cloud Functions ช่วยจับ HTTP Requests ที่เข้ามาที่ /books และ /books/:id เมื่อ Request ตรงตามที่เราตั้งเอาไว้ เราก็จะแสดงผล HTML ออกไป เหตุนี้เราจึงต้องเตรียมก้อน HTML ที่จะส่งออกเอาไว้ก่อนภายใต้ functions/template.js

const template = function(opts) {
  return `
  <!DOCTYLE html>
  <html>
    <head>
      <title>Books</title>
      <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css" integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ" crossorigin="anonymous">
    </head>
    <body>
      <div id="root">${opts.body}</div>
    </body>
    <script>
      <!-- ดูกันไปยาวๆ~ ว่าเราจะเอา state มาเซ็ตได้ยังไง -->
      window.__initialState = ${opts.initialState}
    </script>
    <!-- ภายใต้ Firebase เราสามารถอ้างอิงถึง lib เหล่านี้ได้ *สังเกตจะมี /__/ ขึ้นก่อน -->
    <script src="/__/firebase/4.1.1/firebase-app.js"></script>
    <script src="/__/firebase/4.1.1/firebase-database.js"></script>
    <script src="/__/firebase/init.js"></script>
    <!-- เรา build ได้ client.bundle.js ก็เอามาใส่ไว้ตรงนี้ โค้ดของเราจะได้ทำงานบน client ได้ -->
    <!-- มีเพียง request แรกที่มาหา server เท่านั้นที่เราจะทำ SSR -->
    <!-- หลังจากนั้นจะไม่ทำละ ผลักภาระให้ JavaScript ดำเนินการกับแอพต่อเอง เราจึงต้องมีไฟล์นี้ -->
    <script src='/assets/client.bundle.js'></script>
  </html>
  `;
}

module.exports = template;

สุดท้ายเราสร้างไฟล์ functions/index.js เพื่อประกาศการทำงานของเราเอาไว้

const functions = require('firebase-functions');
const firebase = require('firebase');
const app = require('express')();
const React = require('react');
const ReactDOMServer = require('react-dom/server');

// ความลับที่ทำให้เราปลุก Server.js มารับ state ไปแสดงผลได้
const ServerApp = React.createFactory(require('./build/server.bundle.js').default);
const template = require('./template');

// จะเห็นได้ว่าในแอพพลิเคชันของเรา ไม่ต้องมีการกำหนด config ของ Firebase เลย
// เราสามารถเข้าถึง config ได้ผ่าน Cound Functions ได้โดยตรง
// ทำไมหนะหรอ? ก็บริการนี้มันอยู่ฝั่ง Server อยู่แล้วไงละ มันจึงรู้ config ของตัวมันเองอยู่แล้ว
const appConfig = functions.config().firebase;
const database = require('./firebase-database');
database.initializeApp(appConfig);

function renderApplication(url, res, initialState) {
  const html = ReactDOMServer.renderToString(
    // ด้วยการโยน initialState เข้าไปเป็น state ของแอพพลิเคชัน
    ServerApp({ url: url, context: {}, initialState, appConfig })
  );

  // แล้วจึงจัดการสร้าง HTML ตาม template ที่ได้เตรียมไว้
  const templatedHtml = template({
    body: html,
    initialState: JSON.stringify(initialState)}
  );

  res.send(templatedHtml);
}

app.get('/favicon.ico', function(req, res) {
  res.send(204);
});

// จัดการ Route ที่มี path เป็น /books หรือ /books/:id
app.get('/books/:bookId?', (req, res) => {
  res.set('Cache-Control', 'public, max-age=60, s-maxage=180');

  if (req.params.bookId) {
    database.getBookById(req.params.bookId).then((resp) => {
      renderApplication(req.url, res, resp);
    });
  } else {
    database.getBooks().then((resp) => {
      renderApplication(req.url, res, resp);
    });
  }
});

// ใช้ Cloud Functions เพื่อคุม Request ที่เข้ามา
exports.app = functions.https.onRequest(app);

สุดท้ายเราก็ตั้งค่า config ของ Firebase และ state ใน containers/Server.js กันซะหน่อย

import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import { StaticRouter as Router } from 'react-router'
import App from './App'
import database from 'firebase-database'

export default class extends Component {
  constructor(props) {
    super(props)
    // รับมาจาก functions/index.js ไง
    database.initializeApp(props.appConfig)
  }

  render() {
    const { url, context, initialState } = this.props

    return (
      <Router location={url} context={context}>
        <App state={initialState} />
      </Router>
    )
  }
}

ปลาบปลื้มกับผลลัพธ์

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

cd src
yarn run build
cd ..
firebase deploy

เมื่อทุกอย่างเรียบร้อยเราก็เปิดวาปไปที่ Firebase Hosting ของเรา นี่คือผลลัพธ์ของการทำ SSR ของเราครับ

React SSR with Firebase

เพื่อนๆจะสังเกตได้ว่าเมื่อเราร้องขอข้อมูลไปที่ /books เราจะไม่ได้ HTML เปลือยเปล่ากลับมาพร้อมลิงก์ของ JavaScript แต่เราจะได้ HTML ที่พร้อมแสดงผลทันทีแล้ว เพียงเท่านี้ Bot โง่ๆตัวไหนที่ไม่เข้าใจ JavaScript ก็จะสามารถนำเนื้อหาใน HTML ของเราไปใช้ได้ทันที โดยไม่ต้องประมวลผล JavaScript ก่อนแล้วละ ซื้อยัง ซื้อเลยเหอะ เดี๋ยวตลาดวาย~

สเต็ปถัดไป

แน่นอนว่าบทความนี้ไม่ใช่จุดสิ้นสุดของ SSR บน Firebase ครับ มีหลายอย่างที่เราต้องปรับปรุง นั่นคือ

  • config ของ Webpack สำหรับ Production ต้อง strong กว่านี้ คอนฟิคลูกทุ่งแบบนี้หาได้ตามแผงปลาเค็มทั่วไป
  • Meta tags ทั้งหลายยังไม่มีเลย เช่น meta สำหรับการแสดงผลบน Facebook เป็นต้น ตรงนี้แนะนำ react-helmet

สรุป

โดยส่วนตัวผมไม่ได้ทำ SSR เต็มรูปแบบแบบนี้ครับ แม้ Cloud Functions ของ firebase จะมี logs มีอะไรให้ใช้มากมาย แต่ส่วนตัวค้นพบว่ามันยากในการ Debug โค้ดอยู่ดี สำหรับผมจึงทำแค่ใช้ Cloud Functions เพื่อเตรียม Meta tags เฉยๆ ส่วนเนื้อหาไม่ต้อง Render จาก Server ตั้งแต่ต้น นั่นเพราะ Google Bot ฉลาดอยู่แล้วจึงไม่ต้องทำอะไรมากในส่วนนี้ ที่เหลือเอาเวลาไปต่อยอดทำ PWA ให้ปวดหัวเล่นดีกว่า

เอกสารอ้างอิง

Cloud Functions for Firebase. Retrieved June, 3, 2017, from https://firebase.google.com/docs/functions/

Isomorphic React App. Retrieved June, 3, 2017, from https://github.com/firebase/functions-samples/tree/master/isomorphic-react-app


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


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

แต่พอคลิกที่ชื่อหนังสืออื่น มันแสดง detail ไม่ถูก มันเอาอันแรกที่เราคลิกมาแสดง