React: ใช้ Firebase จัดการ Server-Side Rendering ด้วย Cloud Functions กันเถอะ
บริการฟรีครอบจักรวาลแบบ 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 ก็สร้างรูปย่อซะ
1exports.generateThumbnail = functions.storage.object().onChange((event) => {2 // ...3})
นี่เราไม่ได้ทำบริการภาพโป้ออนไลน์นะ จะไปสนใจอะไรกับ 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 ครับ สิ่งที่เราจัดเก็บคือข้อมูลหนังสือ เราจะดึงข้อมูลเหล่านี้เนี่ยหละมาแสดงผล
1react-ssr2 - books3 - intro-to-angular4 - desc: 'Learn one way to build applications with Ang...'5 - id: 'intro-to-angular'6 - title: 'Introduction to Angular'7 - intro-to-react8 - desc: 'React makes it painless to create interactiv...'9 - id: 'intro-to-react'10 - title: 'Introduction to React'11 - intro-to-vue12 - desc: 'React makes it painless to create interactive UIs....'13 - id: 'intro-to-vue'14 - title: 'Introduction to Vue'
จากหน้าตาอ็อบเจ็กต์ในฐานข้อมูลทำให้เราทราบว่าเรามีหนังสืออยู่ทั้งหมดสามเล่มได้แก่
- Introduction to Angular
- Introduction to React
- Introduction to Vue
เราคาดหวังว่าเมื่อเราร้องขอผ่าน HTTP GET ไปที่ /books
เราจะได้ข้อมูลหนังสือทั้งหมดออกมา และเมื่อเข้าถึง /books/:id
จะได้ข้อมูลจำเพาะของหนังสือที่มี ID ตรงกับที่ระบุ
สร้างโปรเจคซิ รอไร!
ข้อมูลพร้อม ต่อไปก็สร้างโปรเจคเลยครับ ถึงตรงนี้ผมคาดหวังว่าเพื่อนๆที่อ่านบทความนี้ต้องเคยลูบคลำ Firebase กันมาบ้างแล้ว หากไม่ตรงตามเงื่อนไขนี้ ปุ่มกากบาทขวาบนของเว็บเบราเซอร์คือทางออกฮะ~ #แงแมวไล่ #ผมไม่เกี่ยว #แมวนิสัยไม่ดี
ออกคำสั่ง firebase init
เพื่อสร้างโปรเจค หลังจากตอบคำถามเสร็จเรียบร้อย หน้าตาโปรเจคของเพื่อนๆควรเป็นดังนี้ ถ้าไม่ตรงไม่ต้องกลัว แค่สร้างเพิ่ม
1react-ssr << ชื่อโฟลเดอร์โปรเจค2 - functions << โค้ด cloud functions จะบรรจุในนี้3 - public << static content ของเราบรรจุในนี้แจ้4 - src << เราจะใส่โค้ด React กันในนี้5 - .firebaserc6 - database.rules.json7 - firebase.json
มีสองไฟล์สำคัญที่เราต้องแก้ไขกันก่อน เริ่มจาก database.rules.json
1{2 "rules": {3 ".read": true // เราต้องการให้ใครก็ได้ หมูหมากาไก่ สามารถอ่านข้อมูลจากฐานข้อมูลเราได้หมด4 }5}
และไฟล์ถัดมาคือ firebase.json
1{2 "hosting": {3 "public": "public",4 "rewrites": [5 // เราไม่ต้องการให้เข้าถึง / แล้วไปเรียก index.html6 // เราจะทำ SSR ดังนั้นเราจะส่งต่อการทำงานไปที่ cloud functions ที่ชื่อ app แทน7 // ฟังก์ชัน app จะทำ SSR และสร้าง index.html ออกมาให้กับเราเอง8 {9 "source": "**",10 "function": "app"11 }12 ]13 },14 "database": {15 "rules": "database.rules.json"16 }17}
ดึงข้อมูลจากฐานข้อมูลเพื่อใช้ใน Cloud Functions
ตอนนี้เราจะเขียนโค้ดเพื่อใช้บริการ Cloud functions แล้วครับ เนื่องจากบริการนี้ทำให้เราสามารถจัดการงานฝั่ง Backend ได้ด้วย Node และ JavaScript เราจึงสามารถติดตั้ง package ต่างๆได้ผ่าน NPM หรือ Yarn เหมือนที่เราทำกับโปรเจค Node ทั่วไป แล้วนี่คือไฟล์ functions/package.json
ของเรา
1{2 "name": "functions",3 "description": "Cloud Functions for Firebase",4 "dependencies": {5 "express": "^4.15.3", << เราจะจัดการกับ HTTP Request6 "firebase": "^4.1.1",7 "firebase-admin": "~4.2.1",8 "firebase-functions": "^0.5.7",9 "react": "^15.5.4", << และ Render React ทางฝั่ง Server10 "react-dom": "^15.5.4",11 "request": "^2.81.0"12 },13 "private": true14}
ก็อบปี้แปะแล้วอย่าลืมสั่ง npm install
หรือ yarn install
หละฮะ
ลำดับถัดมาเราจะสร้างไฟล์ functions/firebase-database.js
เพื่อเข้าถึงข้อมูลหนังสือของเรา
1const firebase = global.firebase || require('firebase')23// ส่วนนี้ใช้ init app ของเรา4// จดๆไว้ก่อนว่าอยู่ในไฟล์นี้ เดียวเราจะมาเรียกใช้ภายหลัง5function initializeApp(config) {6 if (firebase.apps.length === 0) firebase.initializeApp(config)7}89// ดูดหนังสือทั้งกะบิออกมาจากฐานข้อมูล10function getBooks() {11 return firebase12 .database()13 .ref('/books')14 .orderByChild('title')15 .once('value')16 .then((snapshot) => {17 return { books: snapshot.val() }18 })19}2021// สนใจข้อมูลหนังสือแค่เล่มที่ ID ตรง22function getBookById(id) {23 return firebase24 .database()25 .ref(`/books/${id}`)26 .orderByChild('title')27 .once('value')28 .then((snapshot) => {29 return { currentBook: snapshot.val() }30 })31}3233// Export ออกไปให้เรียกใช้ได้ แม้อยู่ไกลยันดาวพลูโต34module.exports = {35 initializeApp,36 getBooks,37 getBookById,38}
เตรียมโปรเจคสำหรับทำ Server-Side Rendering
ย้ายจากโฟลเดอร์ functions มาที่ src ซึ่งเป็นที่สิงสถิตย์ของ React กันบ้าง ตอนนี้โฟลเดอร์เราเป็นสาวพรหมจรรย์สุดๆ เราต้องประกอบร่างด้วยการสร้าง package.json ของเราขึ้นมาก่อน ดังนี้
1{2 "name": "react-ssr",3 "version": "1.0.0",4 "main": "index.js",5 "license": "MIT",6 "scripts": {7 "build:client": "webpack --config ./webpack.client.js",8 "build:server": "webpack --config ./webpack.server.js",9 "build": "npm-run-all --parallel build:client build:server"10 },11 "dependencies": {12 "firebase": "^4.1.1",13 "react": "^15.5.4",14 "react-dom": "^15.5.4",15 "react-router": "^4.1.1",16 "react-router-dom": "^4.1.1"17 },18 "devDependencies": {19 "babel-core": "^6.24.1",20 "babel-loader": "^7.0.0",21 "babel-plugin-transform-class-properties": "^6.24.1",22 "babel-plugin-transform-object-rest-spread": "^6.23.0",23 "babel-preset-env": "^1.5.1",24 "babel-preset-react": "^6.24.1",25 "npm-run-all": "^4.0.2",26 "webpack": "^2.6.1"27 },28 "babel": {29 "presets": [30 [31 "env",32 {33 "targets": {34 "browsers": ["last 2 versions"]35 },36 "modules": false37 }38 ],39 "react"40 ],41 "plugins": ["transform-object-rest-spread", "transform-class-properties"]42 }43}
จาก 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
1const path = require('path')2const webpack = require('webpack')34module.exports = {5 resolve: {6 extensions: ['.js'],7 alias: {8 // จดๆๆ ต่อไปนี้ถ้าเราบอกว่า `firebase-database` นั่นหมายถึงเรากล่าวถึง firebase-database.js9 'firebase-database': path.resolve(10 __dirname,11 '../functions/firebase-database'12 ),13 },14 },15 module: {16 rules: [17 {18 test: /\.(js|jsx)$/,19 exclude: /node_modules/,20 loader: 'babel-loader',21 },22 ],23 },24 plugins: [25 new webpack.DefinePlugin({26 'process.env': {27 NODE_ENV: JSON.stringify('production'),28 },29 }),30 ],31}
ลำดับถัดมาคือ webpack.client.js
ไฟล์คอนฟิคสำหรับ build:client
1const commonConfig = require('./webpack.common')2const path = require('path')34module.exports = Object.assign(5 {},6 {7 // Entry เริ่มที่ Client.js จดๆๆไว้ก่อน8 entry: './containers/Client.js',9 output: {10 // ผลลัพธืจากการ build จะอยู่ที่ public/assets/client.bundle.js11 // ที่ต้องอยู่ใต้ public เพราะเราจะให้เข้าถึงได้จาก browser นั่นเอง12 filename: 'client.bundle.js',13 path: path.resolve(__dirname, '../public/assets'),14 },15 },16 commonConfig17)
และส่วนสุดท้ายของเรา webpack.server.js
คอนฟิคสำหรับ build:server
1const commonConfig = require('./webpack.common')2const path = require('path')34module.exports = Object.assign(5 {},6 {7 // เราต้องการนำไปใช้กับ node (cloud funtions)8 target: 'node',9 // Entry เริ่มที่ Server.js อันนี้ก็จดๆๆไว้ก่อน10 entry: './containers/Server.js',11 output: {12 filename: 'server.bundle.js',13 path: path.resolve(__dirname, '../functions/build'),14 // ซึ่งถ้าเป็น commonjs ทั่วไปจะเป็น export.<xxx>15 // แต่เราต้องการใช้กับ Node ที่มีลักษณะการใช้ module เป็น module.exports16 // เราจึงใช้ commonjs217 // ไม่เข้าใจผ่านได้ครับ ตำรวจไม่จับ18 libraryTarget: 'commonjs2',19 },20 },21 commonConfig22)
เตรียมโค้ด React สำหรับทำ Server-Side Rendering
ตามข้อตกลง เราจะเขียนโค้ด React ของเราภายใต้โฟลเดอร์ src ครับ โดยโครงสร้างข้างในประกอบด้วยตับไตไส้พุง ดังนี้่
1src2 - components3 - Book.js << แสดงผลหนังสือ 1 เล่ม4 - Books.js << แสดงรายการหนังสือทุกเล่ม5 - index.js6 - containers7 - App.js8 - Client.js << สำหรับ Client9 - 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 เปลี่ยนจะเรียกใช้งานเมธอดดังกล่าวเพื่อเข้าถึงข้อมูลที่มันต้องการแสดงผล
1import React, { Component } from 'react'2import { Switch, Route } from 'react-router'3import { Link } from 'react-router-dom'4import { Books, Book } from '../components'5// จำ alias ในคอนฟิคของ Webpack ได้ไหมเอ่ย6import database from 'firebase-database'78export default class extends Component {9 constructor(props) {10 super(props)1112 this.state = { books: {}, currentBook: {}, ...props.state }13 }1415 loadBookById = (id) => {16 database17 .getBookById(id)18 .then(({ currentBook }) => this.setState({ currentBook }))19 }2021 loadBooks = () => {22 database.getBooks().then(({ books }) => this.setState({ books }))23 }2425 render() {26 const { books, currentBook } = this.state2728 return (29 <div>30 <nav className="navbar navbar-light bg-faded mb-3">31 <div className="container">32 <div className="navbar-header">33 <Link to="/books" className="navbar-brand">34 Books35 </Link>36 </div>37 </div>38 </nav>39 <Switch>40 <Route41 path="/books/:id"42 render={(props) => (43 <Book44 {...props}45 book={currentBook}46 loadBookById={this.loadBookById}47 />48 )}49 />50 <Route51 path="/books"52 render={(props) => (53 <Books54 {...props}55 key="books"56 books={books}57 loadBooks={this.loadBooks}58 />59 )}60 />61 </Switch>62 </div>63 )64 }65}
เมื่อ path ของเราคือ /books
เราต้องการให้หยิบ components/Books.js
มาแสดง เริ่มสร้างไฟล์นี้ด้วยโค้ดต่อไปนี้เถอะ
1import React, { Component } from 'react'2import { Link } from 'react-router-dom'34export default class extends Component {5 componentDidMount() {6 const { books, loadBooks } = this.props78 if (Object.keys(books).length === 0) loadBooks()9 }1011 render() {12 const { books } = this.props1314 return (15 <div className="container">16 <ul className="list-group">17 {Object.keys(books).map((slug) => {18 const book = books[slug]1920 return (21 <li key={slug} className="list-group-item">22 <Link to={`/books/${book.id}`}>{book.title}</Link>23 </li>24 )25 })}26 </ul>27 </div>28 )29 }30}
และสำหรับ /books/:id
เราจะใช้คอมโพแนนท์ components/Book.js
ในการแสดงผล ดังนี้
1import React, { Component } from 'react'23export default class extends Component {4 componentDidMount() {5 const {6 book,7 loadBookById,8 match: { params },9 } = this.props1011 if (Object.keys(book).length === 0) {12 loadBookById(params.id)13 }14 }1516 componentWillReceiveProps(nextProps) {17 const nextId = nextProps.match.params.id1819 if (nextId !== this.props.match.params.id) {20 this.props.loadBookById(nextId)21 }22 }2324 render() {25 const { title, desc } = this.props.book2627 return (28 <div className="container">29 <table className="table table-striped details-table">30 <tbody>31 <tr>32 <td className="title">Title: </td>33 <td>{title}</td>34 </tr>35 <tr>36 <td className="title">Desc: </td>37 <td>{desc}</td>38 </tr>39 </tbody>40 </table>41 </div>42 )43 }44}
สุดท้ายคือ components/index.js
ที่ใช้เป็นตัวบอกว่าภายใต้โมดูล components มีของอะไรให้เรียกใช้บ้าง
1export { default as Books } from './Books'2export { default as Book } from './Book'
ก่อนหน้านี้เราบอกแล้วว่า Client.js จะได้รับค่า window.__initialState
ไปเป็น state ของแอพพลิเคชัน และนี่คือส่วนของโค้ดที่เราพูดถึงครับ
1import React from 'react'2import ReactDOM from 'react-dom'3import { BrowserRouter as Router } from 'react-router-dom'4import App from './App'56ReactDOM.render(7 <Router>8 <App state={window.__initialState} />9 </Router>,10 document.getElementById('root')11)
ตั้งค่า Cloud Functions ให้ทำงานกับ HTTP Requests
ตอนนี้ก็ถึงตาพระเอกของเราแล้วฮะ เราจะใช้ Cloud Functions ช่วยจับ HTTP Requests ที่เข้ามาที่ /books
และ /books/:id
เมื่อ Request ตรงตามที่เราตั้งเอาไว้ เราก็จะแสดงผล HTML ออกไป เหตุนี้เราจึงต้องเตรียมก้อน HTML ที่จะส่งออกเอาไว้ก่อนภายใต้ functions/template.js
1const template = function (opts) {2 return `3 <!DOCTYLE html>4 <html>5 <head>6 <title>Books</title>7 <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">8 </head>9 <body>10 <div id="root">${opts.body}</div>11 </body>12 <script>13 <!-- ดูกันไปยาวๆ~ ว่าเราจะเอา state มาเซ็ตได้ยังไง -->14 window.__initialState = ${opts.initialState}15 </script>16 <!-- ภายใต้ Firebase เราสามารถอ้างอิงถึง lib เหล่านี้ได้ *สังเกตจะมี /__/ ขึ้นก่อน -->17 <script src="/__/firebase/4.1.1/firebase-app.js"></script>18 <script src="/__/firebase/4.1.1/firebase-database.js"></script>19 <script src="/__/firebase/init.js"></script>20 <!-- เรา build ได้ client.bundle.js ก็เอามาใส่ไว้ตรงนี้ โค้ดของเราจะได้ทำงานบน client ได้ -->21 <!-- มีเพียง request แรกที่มาหา server เท่านั้นที่เราจะทำ SSR -->22 <!-- หลังจากนั้นจะไม่ทำละ ผลักภาระให้ JavaScript ดำเนินการกับแอพต่อเอง เราจึงต้องมีไฟล์นี้ -->23 <script src='/assets/client.bundle.js'></script>24 </html>25 `26}2728module.exports = template
สุดท้ายเราสร้างไฟล์ functions/index.js
เพื่อประกาศการทำงานของเราเอาไว้
1const functions = require('firebase-functions')2const firebase = require('firebase')3const app = require('express')()4const React = require('react')5const ReactDOMServer = require('react-dom/server')67// ความลับที่ทำให้เราปลุก Server.js มารับ state ไปแสดงผลได้8const ServerApp = React.createFactory(9 require('./build/server.bundle.js').default10)11const template = require('./template')1213// จะเห็นได้ว่าในแอพพลิเคชันของเรา ไม่ต้องมีการกำหนด config ของ Firebase เลย14// เราสามารถเข้าถึง config ได้ผ่าน Cound Functions ได้โดยตรง15// ทำไมหนะหรอ? ก็บริการนี้มันอยู่ฝั่ง Server อยู่แล้วไงละ มันจึงรู้ config ของตัวมันเองอยู่แล้ว16const appConfig = functions.config().firebase17const database = require('./firebase-database')18database.initializeApp(appConfig)1920function renderApplication(url, res, initialState) {21 const html = ReactDOMServer.renderToString(22 // ด้วยการโยน initialState เข้าไปเป็น state ของแอพพลิเคชัน23 ServerApp({ url: url, context: {}, initialState, appConfig })24 )2526 // แล้วจึงจัดการสร้าง HTML ตาม template ที่ได้เตรียมไว้27 const templatedHtml = template({28 body: html,29 initialState: JSON.stringify(initialState),30 })3132 res.send(templatedHtml)33}3435app.get('/favicon.ico', function (req, res) {36 res.send(204)37})3839// จัดการ Route ที่มี path เป็น /books หรือ /books/:id40app.get('/books/:bookId?', (req, res) => {41 res.set('Cache-Control', 'public, max-age=60, s-maxage=180')4243 if (req.params.bookId) {44 database.getBookById(req.params.bookId).then((resp) => {45 renderApplication(req.url, res, resp)46 })47 } else {48 database.getBooks().then((resp) => {49 renderApplication(req.url, res, resp)50 })51 }52})5354// ใช้ Cloud Functions เพื่อคุม Request ที่เข้ามา55exports.app = functions.https.onRequest(app)
สุดท้ายเราก็ตั้งค่า config ของ Firebase และ state ใน containers/Server.js กันซะหน่อย
1import React, { Component } from 'react'2import ReactDOM from 'react-dom'3import { StaticRouter as Router } from 'react-router'4import App from './App'5import database from 'firebase-database'67export default class extends Component {8 constructor(props) {9 super(props)10 // รับมาจาก functions/index.js ไง11 database.initializeApp(props.appConfig)12 }1314 render() {15 const { url, context, initialState } = this.props1617 return (18 <Router location={url} context={context}>19 <App state={initialState} />20 </Router>21 )22 }23}
ปลาบปลื้มกับผลลัพธ์
หลังจากพัฒนาทุกอย่างเสร็จเรียบร้อย ถึงเวลาของการ Deploy แล้วหละ และนี่คือชุดคำสั่งที่เราใช้
1cd src2yarn run build3cd ..4firebase deploy
เมื่อทุกอย่างเรียบร้อยเราก็เปิดวาปไปที่ Firebase Hosting ของเรา นี่คือผลลัพธ์ของการทำ SSR ของเราครับ
เพื่อนๆจะสังเกตได้ว่าเมื่อเราร้องขอข้อมูลไปที่ /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
สารบัญ
- Firebase Cloud Functions ใน 1 นาที
- Server-Side Rendering ด้วย Cloud Functions
- เตรียมฐานข้อมูลให้พร้อม
- สร้างโปรเจคซิ รอไร!
- ดึงข้อมูลจากฐานข้อมูลเพื่อใช้ใน Cloud Functions
- เตรียมโปรเจคสำหรับทำ Server-Side Rendering
- เตรียมโค้ด React สำหรับทำ Server-Side Rendering
- ตั้งค่า Cloud Functions ให้ทำงานกับ HTTP Requests
- ปลาบปลื้มกับผลลัพธ์
- สเต็ปถัดไป
- สรุป
- เอกสารอ้างอิง