Vue Server-Side Rendering (SSR)

Phai Panda
5 min readMar 27, 2022

--

document ที่อ่านอยู่นี้ เขากล่าวว่ามันทำเช่นนั้นได้

พูดถึง library หรือ framework ควรที่จะเอ่ยถึงเวอร์ชันด้วย document ของ Vue.js ที่อ่านอยู่นี้คือเวอร์ชัน 3 เมื่อ install ผ่าน NPM ก็ได้เวอร์ชัน 3.2.31

CSR vs SSR content in HTML

Server-Side Rendering ย่อว่า SSR หัวใจคือการสร้าง content ที่เป็นสตริง HTML จากฝั่ง server ส่งไปยัง client ที่ร้องขอเข้ามา

เพียงแต่ว่าสตริง HTML ที่ได้มันทำได้แค่แสดงผลเฉยๆ หากจะให้กดโน่นทำนี่ได้จำต้องเติมความสามารถเข้าไปซึ่ง Vue เรียกการกระทำนี้ว่า Hydrate

ทั้งหมดนั้นผมศึกษาจาก ที่นี่ ครับ

ทำไมต้อง SSR

  • Faster time-to-content: โดยทั่วไปแล้ว client หรือเครื่องของลูกค้าจำต้องโหลด JavaScript ในหน้าที่เรียกให้แล้วเสร็จเสียก่อนจึงจะทำงานได้ สำหรับเครื่องที่ช้าหรือใช้อินเตอร์เน็ตที่ช้าจะพบปัญหาการรอคอยที่เนินนานกว่าที่ content ทั้งหมดจะใช้งานได้, SSR สามารถแก้ปัญหานี้โดยให้การดึงข้อมูล (data fetching) ทำได้เลยที่ server มิหนำซ้ำ server อยู่ใกล้ database มากกว่า การเชื่อมต่อจึงเป็นไปอย่างรวดเร็ว
  • Better SEO: หุ่นยนต์ Google และ Bing หรือเรียกว่า search engine crawlers จะจัดทำ index ได้ดีกับหน้า (page) ที่เป็น synchronous หรือพูดว่าพวกมันชอบ synchronous JavaScript application ไม่ใช่ asynchronously (พวก Ajax) หมายความว่าถ้าแอปของเราเปิดหน้ามาพบกับ loading screen เพื่อ load data ไป render content แล้วล่ะก็ หุ่นยนต์ SEO ข้างต้นจะไม่สนใจเลย (มันไม่คอย), SSR สามารถแก้ปัญหาเรื่องนี้ได้โดยทำให้ทั้งหน้าแล้วเสร็จจึงส่งมอบไปยัง client ตอบโจทย์หุ่นยนต์นั่นเอง

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

สิ่งที่กำลังจะทำมีดังต่อไปนี้และขอตั้งชื่อว่า Counter button

  1. เขียน Vue อย่างง่ายที่สุด มีหนึ่งปุ่ม เพื่อ print HTML ออกทาง console.log
  2. นำข้อ 1. ไปใส่ express เพื่อ print full HTML ไปยัง client
  3. จากข้อ 2. ปุ่มที่ได้กดแล้วตัวเลขไม่นับเพิ่ม จึงต้องทำ Hydration
  4. จากข้อ 3. ให้ client โหลด Vue ผ่าน Import Map

Counter button

  • สร้าง folder ชื่อ counter-button
  • cd เข้าไป จากนั้นรันคำสั่ง npm init -y เพื่อสร้างไฟล์ package.json
  • เปิดไฟล์ package.json เพิ่ม "type": "module" เพื่อบอกแก่ node ว่าจะใช้ ES modules mode
  • รันคำสั่ง npm install vue
package.json
  • สร้างไฟล์แรกชื่อ app.js ภายในเรียกใช้สองฟังก์ชันที่เป็นหัวใจของเรื่อง คือ createSSRApp และ renderToString
  • createSSRApp ให้มี data เก็บค่าตัวแปร count เริ่มต้นที่ 1 ส่วน tempate ให้วาดปุ่มออกมาแสดงค่า count นี้
import { createSSRApp } from 'vue'const app = createSSRApp({
data: () => ({count: 1}),
template: `
<button @click="count++">{{count}}</button>
`
})

มือใหม่พึงสังเกตให้ดีว่าเครื่องหมาย single quote (') กับ backtick (`) ต่างกัน ตัว backtick นั้นถูกเรียกว่า Template literals ใช้ประกอบสตริงแบบหลายบรรทัดได้ (หรือจะใช้กับสตริงบรรทัดเดียวก็ได้)

กดปุ่มแล้วค่า count ต้องเพิ่มขึ้นว่างั้นเถอะ

  • ทีนี้เราจะเปลี่ยนค่าในตัวแปร app ให้กลายเป็นสตริงโดยใช้งานฟังก์ชัน renderToString สิ่งที่ได้คือออบเจกต์ Promise ขอตั้งชื่อว่า html
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const app = createSSRApp({
data: () => ({count: 1}),
template: `
<button @click="count++">{{count}}</button>
`
})
renderToString(app).then(html => {
console.log(html)
})
  • ทดสอบ print หรือพิมพ์ค่า html ออกทาง console รันคำสั่ง node app.js
สตริงที่ได้จาก renderToString
  • ถัดไปจะใช้ Express มาทำหน้าที่เป็น api จัดการ req และ res ในที่นี้เจาะจงไปที่ res.rend ส่งสตริงที่เป็น full HTML กลับไปยัง client ฉะนั้นรันคำสั่ง npm install express
package.json
  • สร้างไฟล์ใหม่ชื่อ server.js
  • import express สั่งสร้าง server และรันที่ port 3000
import express from 'express'const server = express()server.get('/', (req, res) => {
res.send(`Hi Client`)
})
server.listen(3000, () => {
console.log('server is running on port 3000...')
})

ทดสอบ รัน node server.js

client request index (/) page
  • นำเนื้อหาในไฟล์ app.js เข้ามารวมร่างกับ server.js
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const server = express()server.get('/', (req, res) => {
const app = createSSRApp({
data: () => ({count: 1}),
template: `
<button
@click="count++">{{count}}</button>
`
})
renderToString(app).then(html => {
res.send(`
<!DOCTYPE html>
<html>
<head><title>Vue SSR</title></head>
<body>
<div>${html}</div>
</body>
</html>

`)
})
})
server.listen(3000, () => {
console.log('server is running on port 3000...')
})

ทดสอบ รีรัน server.js อีกครั้ง

counter is displayed, but it’s not interactive

เราจะเห็นว่ามีปุ่มปรากฏขึ้นมาแล้ว และมีเลข count เท่ากับ 1 วาดอยู่บนปุ่ม แต่คลิ๊กปุ่มไปกี่ครั้งตัวเลขก็ไม่เพิ่มขึ้น

ข้อสังเกตที่น่าสนใจในเรื่อง SSR คือผลลัพธ์ที่ได้เป็นสตริง HTML ที่ถูกสร้างมาแล้วตั้งแต่ฝั่ง server กล่าวคือไม่ได้วาด (render) ด้วย DOM ณ ฝั่ง client ตามธรรมดาทั่วไป ดังนั้นเมื่อเลือกดูที่ Network เราจะพบว่า content ที่ได้เป็นปุ่มมาเลย

content already done at server side

งั้นทำอย่างไรให้ปุ่มมีชีวิตขึ้นมา?

ทางออกคือการทำ Hydration ในเมื่อสสารไม่หายไปไหนและเราต้องการให้ผู้ใช้งาน interactive กับปุ่มได้ สิ่งที่เราต้องการก็คือ JavaScript นั่นแหละ หนี้ไม่พ้นเราก็ต้องสั่งให้ browser โหลด Vue JavaScript เองอยู่ดี

แต่ก่อนอื่นเราต้องทราบก่อนว่า Vue ตามปกติที่ไม่ใช้ SSR นั้นจะจับคู่ selector ที่ index.html กับตัวมันด้วยโค้ดนี้

import { createApp } from 'vue'const app = createApp({
/* root component options */
})
app.mount('#app')--- index.html ---<div id="app"></div>

ซึ่งโค้ดนี้ Vue จะทำงานมันที่ฝั่ง client หรือก็คือ browser ทว่างานของเราเป็นฝั่ง server หรือก็คือ Node.js และใช้ SSR ด้วยนะ

ทางออกคือใช้เทคนิคที่เรียกว่า isomorphic หรือ universal ทำให้โค้ดเดียวสามารถรันได้ทั้ง client และ server ครับ

  • ที่โปรเจกต์เดิม สร้าง folder ใหม่ชื่อว่า hydration
  • ภายใน hydration สร้างไฟล์ app.js, client.js และ server.js (ขึ้นมาใหม่ทั้งหมด)
  • ไฟล์ app.js จะเขียน universal code มีฟังก์ชัน createApp ครอบฟังก์ชัน createSSRApp
import { createSSRApp } from "vue";export function createApp() {
return createSSRApp({
data: () => ({count: 1}),
template: `
<button @click="count++">{{count}}</button>
`
})
}

โปรดสังเกตว่าชื่อ createApp ไม่ได้พิเศษอะไร แค่ตั้งชื่อให้เหมือนกับ build-in function (createApp) ของ Vue แค่นั้น หัวใจยังคงเป็นฟังก์ชัน createSSRApp

  • ไฟล์ client.js เรียกใช้ createApp จาก app.js แล้วเรียก mount ไปยัง id ชื่อ app ซึ่งจะเพิ่มในภายหลัง
import { createApp } from './app.js'createApp().mount('#app')
  • ไฟล์ server.js เขียนให้สร้าง server รันที่ port 3200 และเรียกใช้ createApp จาก app.js
import express from 'express'
import { createApp } from './app.js'
import { renderToString } from 'vue/server-renderer'
const server = express()server.get('/', (req, res) => {
const app = createApp()
renderToString(app).then(html => {
res.send(`
<!DOCTYPE html>
<html>
<head><title>Vue SSR</title></head>
<body>
<div>${html}</div>
</body>
</html>
`)
})
})
server.listen(3200, () => {
console.log('server is running on port 3200...')
})

cd เข้าไปที่ hydration ทดสอบรัน server.js

client request index (/) page on port 3200

ยังนะ กดปุ่มจะยังไม่เกิดอะไรขึ้น

  • ยังคงอยู่ที่ไฟล์ server.js ถัดมาให้เชื่อม client.js เข้ากับหน้า index โดยการประกาศ id ชื่อ app และเพิ่ม script เรียกไปยัง client.js
import express from 'express'
import { createApp } from './app.js'
import { renderToString } from 'vue/server-renderer'
const server = express()server.use(express.static('.'))server.get('/', (req, res) => {
const app = createApp()
renderToString(app).then(html => {
res.send(`
<!DOCTYPE html>
<html>
<head><title>Vue SSR</title></head>
<body>
<div id="app">${html}</div>
<script type="module" src="./client.js"></script>
</body>
</html>
`)
})
})
server.listen(3200, () => {
console.log('server is running on port 3200...')
})

ทดสอบรันอีกรอบ เปิดดูใน Network ก่อน จะพบว่า client.js ถูกโหลดมาแล้ว โดยตัวมันไปโหลด app.js มาด้วย

แต่ที่ Console กลับพบ error

Uncaught TypeError: Failed to resolve module specifier “vue”. Relative references must start with either “/”, “./”, or “../”.

browser พูดว่าหา vue module ไม่พบ สืบเนื่องจาก client.js เรียก app.js และ app.js เรียก vue module ตามลำดับ

คำถามคือ vue module นั้นอยู่ที่ไหน? คำตอบคืออยู่ใน node_modules ณ ฝั่ง server แน่นอนว่าโหลดมาไม่ได้แน่นอน ทางออกของเรื่องนี้คือวิธีการที่เรียกว่า Import Map

Import Map เป็นการจับคู่คีย์ที่ต้องการกับแหล่งที่มาของ source code

  • ไฟล์ server.js เพิ่ม script ประเภท importmap ก่อนการเรียก client.js
renderToString(app).then(html => {
res.send(`
<!DOCTYPE html>
<html>
<head><title>Vue SSR</title></head>
<body>
<div id="app">${html}</div>
<script type="importmap">
{
"imports": {
"vue": "_vue_module_"
}
}
</script>

<script type="module" src="./client.js"></script>
</body>
</html>
`)
})

และแทนที่ _vue_module_ ด้วย source code ที่ต้องการ ในที่นี้คือบอกให้ bowser ไปโหลดเพิ่มเอง ตัวอย่างนี้ให้ไปที่ (เลือกตามเวอร์ชันที่ใช้พัฒนา)

https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.31/vue.esm-browser.prod.js

<script type="importmap">
{
"imports": {
"vue": "https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.31/vue.esm-browser.prod.js"
}
}
</script>

ทดสอบ คลิ๊กที่ปุ่มตัวเลขจะเพิ่มขึ้นตามจำนวนครั้งที่กด

สำเร็จ!

Higher Level Solutions

กว่าจะเขียนชิ้นงานออกมาได้ก็ทำเอาปาดเหงื่อ ความคิดในการสร้างสตริง HTML จากฝั่ง server ก็เป็นอย่างที่ได้ทำความเข้าใจกัน คงดีกว่านี้หากมีเครื่องมือหรือ framework มาช่วยสร้างชิ้นงานได้ง่ายขึ้น (ปาดเหงื่อกับ framework อีก)

document แนะนำอยู่ 2 จ้าว ได้แก่ Nuxt และ Quasar

ผมคงต้องกลับไปเรียนพื้นฐาน Vue ให้แน่นก่อน ตามที่ได้สำรวจมาแล้วเบื้องต้นผมชมชอบ Quasar มากกว่าตัวอื่นๆ อย่างไรไว้เจอกันใหม่ สวัสดีครับ

--

--

No responses yet