Vue.js กับ Server-side rendering

การเลือกพัฒนาเว็บ ว่าจะทำเป็น Web Site หรือ Single Page Web Application (SPA) นั้น สิ่งที่ทำคัญมาก ๆ อย่างนึงคือ การทำ SEO

จริง ๆ ปัญหาเรื่อง SEO กับ SPA ในปัจจุบันน่าจะหมดแล้ว เพราะว่า Google สามารถรัน JavaScript ได้แล้ว แต่ปัญหาใหญ่คือ Facebook ไม่รัน JavaScript ทำให้เวลา share link ที่เป็น SPA แล้วจะได้ OpenGraph meta tags ของไฟล์ index แทนที่จะเป็น tags ของ content ของ link ที่ share

ปัญหานี้จริง ๆ สามารถแก้ได้ง่าย ๆ ด้วยการใช้ Prerender,​ ตัว Prerender มันจะรัน JavaScript แล้วส่งเป็นไฟล์ html กลับไป เหมือนกับเป็น snapshot ของ web ในตอนนั้น ปัญหาของ Prerender คือ มันส่งกลับมาแค่ html ทำให้ใช้ได้เฉพาะ bot เท่านั้น และค่อนข้างช้า

การทำ Server-side rendering (SSR) จะช่วยให้ SPA ของเรา ถูก render ตั้งแต่ใน server (สามารถ cache ได้ด้วย) พอ user ได้ html ที่ render มาแล้ว ยังสามารถโหลดไฟล์ JavaScript มาเพื่อทำงานต่อได้เลย (เว็บจะแสดงผลทันที ไม่ต้องรอให้ browser ส่ง request ไปขอ data)

เราลองมาดูกันว่าถ้าเราเขียน SPA ด้วย Vue.js แล้ว เราจะสามารถทำให้มัน render มาจาก server ได้ยังไง โดยที่ยังสามารถทำงานแบบ SPA ได้อยู่

โค้ดที่อยู่ในนี้ไม่ใช่ Best practice นะครับ แต่จะทำให้เข้าใจหลักการทำงานก่อน

  • สร้าง project ขึ้นมาก่อน ผมจะตั้งชื่อว่า vuejs-ssr-example และสร้างเป็น webpack project และเลือกเป็น Runtime-only
$ mkdir vuejs-ssr-example
$ vue init webpack
$ npm i
$ npm i --save vue-router vue-server-renderer express axios jquery semantic-ui-css
$ npm i --save-dev script-loader style-loader
  • ผมจะลง jQuery และ Semantic-UI เพื่อให้ดูว่าถ้าเราจะใช้ CSS Framework ด้วยจะเขียนยังไง
  • ลง Webpack Loaders เพื่อใส่ jQuery กับ Semantic-UI ลงใน project

เสร็จแล้วให้สร้างไฟล์ตามนี้ครับ

Project Structure
  • เราจะเพิ่ม entry point อีกอันสำหรับ server คือ src/server.js
import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store'
export default (context) => {
router.push(context.url)
Object.assign(store, context.store)
return Promise.resolve(new Vue({
router,
...App
}))
}
  • ส่วนไฟล์ src/main.js จะเป็น entry point สำหรับ SPA ที่ไม่ผ่าน SSR
import Vue from 'vue'
import '!script!jquery/dist/jquery.min.js'
import '!script!semantic-ui-css/semantic.min.js'
import '!style!css!semantic-ui-css/semantic.min.css'
import App from './App'
import router from './router'
import './store'
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
...App
})
  • src/router.js สำหรับเก็บ router เพื่อให้ใช้ได้ทั้งใน src/main.js และ src/server.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from './components/Home'
import About from './components/About'
Vue.use(VueRouter)
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '*', redirect: '/' }
]
})
export default router
  • src/store.js สำหรับเก็บ state ของ SPA
import Vue from 'vue'
import axios from 'axios'
const store = {
currentUser: null
}
const API = {
fetchCurrentUser () {
if (store.currentUser) return
axios.get('http://localhost:8080/me')
.then((res) => {
store.currentUser = res.data
})
}
}
Vue.mixin({
data () {
return {
store
}
},
computed: {
API () {
return API
}
}
})
export default store
  • src/components/Home.vue
<template>
<div class="ui segment">
<h2 class="ui header">Home Component</h2>
<span v-if="username">Hello, {{ username }}</span>
</div>
</template>
<script>
export default {
created () {
this.API.fetchCurrentUser()
},
computed: {
username () {
return this.store.currentUser && this.store.currentUser.username || ''
}
}
}
</script>
  • src/components/About.vue
<template>
<div class="ui segment">
<h2 class="ui header">About Component</h2>
<span>About Page</span>
</div>
</template>
  • src/App.vue
<template>
<div id="app" class="ui basic segment">
<h1 class="ui header">App Component</h1>
<router-link to="/" class="ui button">Home</router-link>
<router-link to="/about" class="ui button">About</router-link>
<router-view></router-view>
</div>
</template>
  • เพื่ม webpack config สำหรับ server, build/webpack.serv.conf.js
const config = require('../config')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const env = config.build.env
const webpackConfig = merge(baseWebpackConfig, {
target: 'node',
entry: {
app: './src/server.js'
},
devtool: false,
output: {
path: config.build.assetsRoot,
filename: 'server.js',
libraryTarget: 'commonjs2'
},
externals: Object.keys(require('../package.json').dependencies),
plugins: [
new webpack.DefinePlugin({
'process.env': env
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
})
]
})
module.exports = webpackConfig
  • แก้ build/build.js ให้ build server ด้วย
/* globals rm mkdir cp env */
require('./check-versions')()
require('shelljs/global')
env.NODE_ENV = 'production'
const path = require('path')
const config = require('../config')
const ora = require('ora')
const webpack = require('webpack')
const webpackConfig = require('./webpack.prod.conf')
const serverConfig = require('./webpack.serv.conf')
console.log(
' Tip:\n' +
' Built files are meant to be served over an HTTP server.\n' +
' Opening index.html over file:// won\'t work.\n'
)
const spinner = ora('building for production...')
spinner.start()
const assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory)
rm('-rf', assetsPath)
mkdir('-p', assetsPath)
cp('-R', 'static/*', assetsPath)
webpack(webpackConfig, function (err, stats) {
if (err) throw err
process.stdout.write(stats.toString({
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}) + '\n')
  webpack(serverConfig, function (err, stats) {
spinner.stop()
if (err) throw err
process.stdout.write(stats.toString({
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}) + '\n')
})
})
  • server.js สำหรับรัน express
const express = require('express')
const fs = require('fs')
const path = require('path')
const code = fs.readFileSync(path.join(__dirname, './dist/server.js'), 'utf8')
const renderer = require('vue-server-renderer').createBundleRenderer(code)
const index = fs.readFileSync(path.join(__dirname, './dist/index.html'), 'utf8')
const app = express()
const getCurrentUser = () => {
return Promise.resolve({
username: 'acoshift',
id: 1
})
}
app.use('/static', express.static(path.join(__dirname, './dist/static')))
app.get('/me', (req, res) => {
getCurrentUser().then((currentUser) => {
res.json(currentUser)
}, (err) => {
console.error(err)
res.sendStatus(500)
})
})
app.get('*', (req, res) => {
getCurrentUser().then((currentUser) => {
const store = { currentUser }
    renderer.renderToString(
{ url: req.url, store },
(err, html) => {
if (err) {
console.log(err)
return res.sendStatus(500)
}
res.send(index.replace('<div id=app></div>', html))
}
)
})
})
app.listen(8080)

เสร็จแล้วก็ build โดยการรัน $ npm run build
และรันตัว server $ node server.js

เราจะสามารถเข้าไปที่ https://localhost:8080 ผ่าน browser ได้

ถ้าเราลองเปิดแบบ view-source:localhost:8080 จะเห็นว่า html และ data จะถูก render มาแล้ว และเมื่อเรากดปุ่มเพื่อเปลี่ยน route จะเห็นว่า หน้าเว็บจะไม่ refresh เพราะมันยังเป็น SPA เหมือนเดิม…

หรือจะลองโหลดโค้ดจาก https://github.com/acoshift/vuejs-ssr-example มารันดูก็ได้นะ

Like what you read? Give acoshift a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.