มาทำ Server-side rendering(SSR) ใน vuejs 2 กัน

การทำ SSR กับ vuejs

สำหรับใครที่ยังไม่รู้จัก client rendering หรือ Server-side rendering แนะนำให้ลองหาอ่านข้อมูลจากใน internet ก่อนครับ

ปัจจุบันการทำเว็บแบบ Single Page Web Application (SPA) นั้นเป็นที่นิยมมากไม่ว่าจะใช้ vue2, angular, react ทำก็ฮิตติดลมกันทุกตัวแต่สิ่งนึงที่ SPA ขาดคือเรื่องของ SEO แต่ตอนนี้ google bot สามารถอ่าน javascript ได้ดีมากขึ้นแล้วอ่าว! แล้วเราทำเพื่อใครก็ Facebook ไงเพราะถ้าเราแชร์ลิงค์ SPA ไปที่ FB มันจะมองเป็นแค่ไฟล์ index และจะไม่ได้ meta tag ต่างๆของลิงค์ที่เราแชร์เลยแต่จะไปได้หน้า index แทน

ปกติแล้ว SPA ที่เราเขียนหลังจาก browser request เข้ามา server จะส่ง static ไฟล์เช่น css, js, html กลับไปพอ browser ได้รับก็จะเอามาประมวลผลจาก <div id=”app”></div> กลายเป็นมี รูปภาพ, ข้อความต่างๆ

Server side render นั้นจะต่างกับ client side ตรงเมื่อ browser request มาที่ server ตัว server จะทำการอ่านไฟล์ js ของเราที่เราเขียนแล้วสร้างจาก <div id=”app”></div> ธรรมดาให้มีข้อมูลข้างในตามที่เราเขียน app ไว้แล้วส่งกลับไปในรูปแบบ string พอ browser ได้รับก็จะทำการแสดงผลทันทีส่วน js ที่โหลดมาก็ทำงานต่อจากเดิมได้เลยไม่ต้องเรียก api ใหม่ ว๊าววววว…ดีงามพระราม 8 แต่ถ้ามันง่ายขนานนั้นจะมาเขียน Medium ทำไม

ใน vue2 ก็สามารถเขียน SSR ได้เช่นกันแต่ถ้าอยากง่ายกว่านั้นมี NUXT เจ้าของเดียวกับ NEXT ฝั่ง React แต่ถ้าสายลุยอัพเดทบ่อย เอา vue มาทำเองเนี่ยละมันจะ geek ไปอี๊ก..


สร้าง Project

สำหรับการสร้างโปรเจคผมขอใช้ template webpack-simple เพราะเราจะได้ไม่ปวดหัวกับ webpack config เกินไป

ใครยังไม่ได้ลง vue-cli ก็ลงตามนี้ ลิงค์

vue init webpack-simple vue-ssr

จากนั้นติดตั้ง dependencies เพิ่มเติมโปรดจำไว้ว่า vue กับ vue-server-renderer ต้อง version เท่ากันเพราะถ้า update feature ไม่เท่ากันอาจจะบัคๆหน่อย

yarn add express vue vue-server-renderer vue-router
// npm
npm install --save express vue vue-server-renderer vue-router

ก่อนที่เราจะเริ่มการใหญ่ควรเข้าใจการเล็กๆก่อนสร้างไฟล์ server.js ที่ root project

บรรทัดที่ 5–26 เราสร้าง server.get เป็นการเขียน http get แบบ express ขึ้นมาเพื่อรับ request ว่ารับทุกอย่างเลยใส่ * จากนั้นเราสร้างตัวแปร app ที่เก็บ instant ของ vue ไว้ใน data มี property url = req.url เอาค่า url ที่ได้จาก express ไปใส่ใน instant ของ vue จากนั้นเอาไปแปะใน template

renderer.renderToString() เป็น function ของ vue-server-renderer ที่จะทำการแปลง vue instant ให้กลายเป็น string เพื่อส่งกลับไป browser จากนั้น res.end ส่ง html กลับไปแล้วเอาตัวแปร html ไปยัดไว้ใน body

node server.js

เปลี่ยน url ดูครับจากนั้นกดคลิกขวา view source code จะเห็นว่าเราได้ text มาจาก server แล้วเห้ย SSR มาว่ะ ต่อไปเราจะมาทำให้มันใช้ได้จริงๆอันนี้แค่ concept

Page Template

ในบ้างครั้งเราอยากจัดการ html ที่อยู่นอก instant ของ vue เราสามารถใช้ template มาช่วยได้ให้เราสร้างไฟล์ index.template.html แล้วใส่โค้ดนี้ลงไป

<!DOCTYPE html>
<html lang="en">
  <head>
   <title>{{ title }}</title>
   {{{ meta }}}
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

** {{}} คือ string ธรรมดา {{{}}} คือ มันจะไม่ escape html ออกทำให้เราวาง tag ต่างๆได้

ตอนที่ server render ตัว vue จะสร้าง instant แล้ว inject html ไปที่

<!--vue-ssr-outlet-->

ให้เองครับต่อมาให้เราแก้ server.js เป็นแบบนี้

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})
const context = {
  title: 'hello',
  meta: `
    <meta charset=utf-8>
  `
}

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>The visited URL is: {{ url }}</div>`
  })

  renderer.renderToString(app, context, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(html)
  })
})

server.listen(8080)

เราสามารถส่งตัวแปรเข้าไปใน html template ที่เราสร้างได้โดยผมสร้าง context ขึ้นมาแล้วส่งเข้าไปใน renderToString ด้วย html จะประมวลผลออกมาลองรัน node server เช็คของอีกที

Structure

https://ssr.vuejs.org/en/structure.html

การทำ application จริงๆนั้นเราต้องทำให้ instant ของ vue สามารถ render ในฝั่ง server และ client ก็ต้องเอาไปทำงานต่อได้อย่างเนียนๆ จงพิจารณาดูด้านบน

เราต้องทำ 2 อย่างคือให้ webpack รวม code (bundle) ออกมาสองฝั่งคือ server และ client เพราะตัว node js ไม่รองรับ es2015(เวอร์ชั่นใหม่ๆน่าจะทำได้แล้ว) อย่างเต็มที่เราจึงต้องแปลงฝั่ง server เป็น commonjs2 เพื่อให้ node เอาไปประมวลผลได้และ ฝั่ง client เป็น es2015 ให้ browser ประมวลผล

ตาม SSR DOC ขอยกตัวอย่างการวางโปรเจคแบบนี้

components เก็บ comp ปกติ
router ข้างในมีไฟล์ index.js สำหรับ router เดี๋ยวสร้างทีหลัง
app.js พอดีผมเปลี่ยนชื่อทุกคนที่ทำตามน่าจะ main.js แต่อย่าเปลี่ยนตามผมครับเพราะต้อง config webpack เพิ่ม ผมแค่ชอบชื่อนี้เฉยๆ 555+
entry-client สำหรับให้ webpack มาเรียกเพื่อ bundle ไฟล์สำหรับ client
entry-server สำหรับให้ webpack มาเรียกเพื่อ bundle ไฟล์สำหรับ server


เริ่ม config file

ในไฟล์ main.js ของทุกคนจะเป็นแบบนี้

import Vue from 'vue'
import App from './App.vue'
new Vue({
  el: '#app',
  render: h => h(App)
})

ให้เปลี่ยนเป็นแบบนี้

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'

export function createApp() {
  const router = createRouter()
  const app = new Vue({
    router,
    render: h => h(App)
  })
  return { app, router }
}

เราจะทำการสร้าง function createApp และ export ออกไปที่เราทำแบบนี้เพราะทุกๆครั้งที่มีการ request เข้ามาเราจะต้องส่ง fresh vue instant(ส่งของสดใหม่) ไปให้ทุก request ในส่วนของ router เช่นกันให้เปลี่ยนเป็นแบบนี้สร้าง
folder router/index.js

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

import Test from '../components/test.vue'

export function createRouter() {
  return new Router({
    mode: 'history',
    routes: [
      { path: '/', component: Test }
    ]
  })
}

เหตุผลเดียวกันคือทุกๆ request ที่เข้ามาจะต้องได้ new instant ของ router ตรงนี้ผมเข้าใจว่าเพราะข้อมูลที่เข้ามามันไม่เหมือนเดิมตลอด vue คงต้องการคำนวนจาก instant ใหม่เท่านั้น(ถ้าเจอเหตุผลเดี๋ยวผมมาอัพเดท) อย่าลืมไปสร้าง component test.vue แล้ว ใส่ข้อมูลอะไรไปก็ได้ครับเพื่อเช็คว่ามันมาจาก server จริงๆของผมใช้ vfor เอา text ออกมา

ในส่วนต่อมาคือ entry-client.js

import { createApp } from './main'

const { app, router } = createApp()

router.onReady(() => {
  app.$mount('#app')
})

ตรงนี้ไม่มีอะไรมากครับคือเรา Destructuring ตัวแปรออกมาจาก function createApp ในไฟล์ main.js คือ app , router
router.onReady คือเมื่อ router เตรียมข้อมูลและ route ต่างๆเสร็จให้ทำการ $mount ไปที่ id=app ซึ่งปกติเราเขียนใน vue instant แบบนี้ el: “#app”

entry-server.js

import { createApp } from './main'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    router.push(context.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()

      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      resolve(app)
    }, reject)
  })
}

เรา import createApp มาเช่นเดิมในส่วนนี้เรา export default และ รับ context ลืมกันหรือยังว่ามันจะส่งมาจาก server.js แต่ตอนนี้เรายังไม่ได้ส่งมันมา
ใน function นี้เราจะทำการ return Promise และ Destructuring เหมือนเดิม

เราเรียกใช้ router.push แล้วส่ง context.url ในที่นี้ตัว express จะส่ง url ที่ถูก request มาเราก็ push เข้าไปตัว router จะวิ่งไปหาว่ามี path นี้อยู่ใน instant ที่เราสร้างขึ้นในไฟล์ index.js มั้ย และ เราก็เรียกใช้ router.onReady เหมือนเดิมเพื่อจะได้รู้ว่า router ทำงานหรือยัง

ผมสร้างตัวแปร matchedComponent ขึ้นมาและเรียกใช้ฟังก์ชั่น router.getMatchedComponents() ตัวนี้จะตรวจสอบว่าที่เรา push url เข้ามานั้นตรงกับ path ใน vue router หรือไม่ถ้าไม่มีเรา reject 404 เลยจ้าถ้ามี resolve และส่ง app กลับไป


มาถึงจุดนี้เหนื่อยมั้ยครับ ฮ่าๆผมยังเหนื่อยเลยแต่เอาว่ะไหนๆก็มาถึงแล้วเราไปต่อ

webpack Server Config

เราจะมาทำการ config webpack เพื่อให้มันแปลงจาก es6 ที่เราเขียนอยู่นี้ไปเป็น commonjs2 หรือเรียกอีกอย่างคือ Node-style exports เพื่อให้ node อ่าน code js ออกนั้นเอง ตอนนี้เราต้องลง package เพิ่ม 2 ตัวเพื่อจัดการกับ webpack

yarn add --dev webpack-merge webpack-node-externals
//npm style..
npm install --save-dev webpack-merge webpack-node-externals

webpack-merge เอาไว้รวม webpack config จากไฟล์อื่นมันก็คือการรวม object ปกติแต่ใช้ของเค้านั้นละเดี๋ยวตู้ม..
webpack-node-externals เป็นตัวจัดการ dependency ว่าตอนรวมไม่ต้องรวมพวก node_module หรือไฟล์ต้องห้ามอื่นๆเข้ามานะ

webpack.server.config.js สร้างไว้ที่ root project วางคู่กับ webpack.config.js เลย

const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseConfig, {
  entry: './src/entry-server.js',
  target: 'node',
  devtool: 'source-map',
  // This tells the server bundle to use Node-style exports
  output: {
    libraryTarget: 'commonjs2'
  },
  externals: nodeExternals({
    whitelist: /\.css$/
  }),
  plugins: [
    new VueSSRServerPlugin()
  ]
})

entry เราบอกให้ webpack ไปอ่านไฟล์นี้แล้ว build ออกมาซึ่งชี้ไปที่ entry-server.js
target บอกลักษณะการ build การ import ของ es6(อันนี้ไม่แน่ใจตาม Doc มาอีกที)
devtool ให้ใช้ source-map ได้ในกรณีอยาก debug code|
**output บอกให้ build เป็น commonjs2 สำคัญมากเพราะเดี๋ยว node อ่าน code ไม่ออก
VueSSRServerPlugin เป็นการ build file vue-ssr-server-bundle.json เพื่อให้ plugin renderToString รู้จักกับ bundle ที่เราสร้าง

ใกล้แล้วอีกนิดนึง

webpack.config.js เรากลับไปแก้ไฟล์นี้อีกซักนิดนึงเพื่อสร้าง vue-ssr-client-manifest.json เป็นตัวที่จะ inject พวก script, link ต่างๆเข้าไปใน index.template.html ที่เราเตรียมไว้ ยกตัวอย่าง ถ้าเราเข้า path /profile มันจะใส่

<link rel="preload" href="/main.js" as="script">
<link rel="preload" href="/0.js" as="script">

พวก script ที่ต้องใช้ใน router นั้นๆมาให้ทำให้โหลดเฉพาะสิ่งที่จำเป็นในการ render เท่านั้น และ ทำให้เร็วขึ้นด้วย แก้ไขตามนี้

var path = require('path')
var webpack = require('webpack')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = {
  entry: './src/entry-client.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    publicPath: '/dist/',
    filename: 'build.js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader']
      },
      {
        test: /\.scss$/,
        use: ['vue-style-loader', 'css-loader', 'sass-loader']
      },
      {
        test: /\.sass$/,
        use: ['vue-style-loader', 'css-loader', 'sass-loader?indentedSyntax']
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          loaders: {
            // Since sass-loader (weirdly) has SCSS as its default parse mode, we map
            // the "scss" and "sass" values for the lang attribute to the right configs here.
            // other preprocessors should work out of the box, no loader config like this necessary.
            scss: ['vue-style-loader', 'css-loader', 'sass-loader'],
            sass: [
              'vue-style-loader',
              'css-loader',
              'sass-loader?indentedSyntax'
            ]
          }
          // other vue-loader options go here
        }
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        loader: 'file-loader',
        options: {
          name: '[name].[ext]?[hash]'
        }
      }
    ]
  },
  resolve: {
    alias: {
      vue$: 'vue/dist/vue.esm.js'
    },
    extensions: ['*', '.js', '.vue', '.json']
  },
  devServer: {
    historyApiFallback: true,
    noInfo: true,
    overlay: true
  },
  performance: {
    hints: false
  },
  devtool: '#eval-source-map'
}

if (process.env.NODE_ENV === 'production') {
  module.exports.devtool = '#source-map'
  // http://vue-loader.vuejs.org/en/workflow/production.html
  module.exports.plugins = (module.exports.plugins || []).concat([
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: '"production"'
      }
    }),
    new webpack.optimize.UglifyJsPlugin({
      sourceMap: true,
      compress: {
        warnings: false
      }
    }),
    new webpack.LoaderOptionsPlugin({
      minimize: true
    }),
    // This plugins generates `vue-ssr-client-manifest.json` in the
    // output directory.
    new VueSSRClientPlugin()
  ])
}

เพิ่มมาแค่ 3 บรรทัดคือ 3, 6, 95 แก้ไข entry ชี้ไปที่ entry-client.js ที่เหลือเป็นตัว generate vue-ssr-client-manifest.json


const Vue = require('vue')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(bundle, {
  runInNewContext: false,
  clientManifest,
  template: require('fs').readFileSync('./src/index.template.html', 'UTF-8')
})
const app = express()

app.use('/dist', express.static('./dist'))

app.get('*', (req, res) => {
  const context = { url: req.url }

  renderer.renderToString(context, (err, html) => {
    if (err) {
      if (err.code === 404) {
        res.status(404).end('Page not found')
      } else {
        console.log(err)
        res.status(500).end('Internal Server Error')
      }
    } else {
      res.end(html)
    }
  })

})

app.listen(8080)

อัพเดทตัว server เพื่อให้อ่านไฟล์ทั้งหมดที่เรา config

createBundleRenderer เราเรียกใช้ plugin ของ vue-server-renderer ตัวนี้จะทำการเอาไฟล์ที่เรา build ออกมาแล้วมาสร้างเป็น Vue instant สังเกตุว่าเราไม่ต้องสร้าง app = vue instant แล้วเพราะมันรวมให้แล้วใน createBundleRenderer เราจับ bundle ยัดเข้าไปเพื่อสร้าง instant และ ใส่ clientManifest พร้อม template index ของเราเข้าไป

**runInNewContext ควรเป็น false เพื่อบอกว่าทุกครั้งที่ request ไม่ให้มัน excute code ใหม่ทุกครั้งที่ context เหมือนเดิมให้ทำเฉพาะ context ใหม่ๆเท่านั้นเพราะมันเปลือง server

บรรทัดที่ 13 เป็นการสร้าง http get path สำหรับ static เวลา server เรียกไฟล์จะได้หาไฟล์เจอ

บรรทัดที่ 15 เราส่ง context ที่มี url อยู่ข้างในและส่งเข้าไปใน function อย่าลืมลบ {{ title }} และ {{{ meta }}} ที่ index.template.html ออกด้วยเพราะมันสามารถเขียนใน component ได้มี lib ที่ทำตัวนี้อยู่อาจจะเขียนในครั้งหน้าครับ


เสร็จแล้วมั่ง

อ๋อลืมตั้งค่าในไฟล์ package.json ให้รัน yarn build ได้เลย

//package.json
"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --open --hot",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
"build-server": "cross-env NODE_ENV=production webpack --config webpack.server.config.js --progress --hide-modules",
"build-all": "yarn build && yarn build-server"
},

แก้ส่วนแค่ในส่วน script เราจะพิมพ์แบบนี้ก็ได้ครับ yarn build เสร็จแล้วพิมพ์ yarn build-server แต่อย่างที่บอกเป็น programmer สายเว็บมันต้อง cool cool หน่อยผมทำ yarn build-all ไว้ให้มันรันทีเดียวสองคำสั่งชีวิตมันต้องสบายๆหน่อยใช้ npm run แทน yarn ได้นะครับ แต่!! ยังมีคนใช้ npm อยู่เหรอ O!o

สุดท้ายแล้วพิมพ์คำสั่ง node server.js ได้เลยครับ …………………. ตู้มถ้าคุณคือคนที่รอดชีวิตและทำถูกทุกอย่างคุณคือตัวจริง และ คุณได้ไปต่อ ผ่าม ผ้าม ผ๊ามมม!!

ในบทความหน้าผมจะมาต่อจากบทความนี้ว่าเราจะเรียก api และ ให้แสดงผลออกมาจาก server เลยโดยที่ไม่ได้เรียกหลังจาก app mount ที่ฝั่ง client แล้ว

***สุดท้าย***

บทความนี้เกิดจากการอ่าน SSR doc เพียงลำพังฮ่าๆ ถ้ามีข้อผิดพลาดรบกวนบอกผมด้วยครับจะได้แก้ไขได้ทันขอบคุณทุกท่านที่ติดตามอ่านจนจบถ้าถูกใจปรบมือรัวๆ และ แชร์เยอะๆจ้า

git repo

Referance

เกี่ยวกับผู้เขียน

ITTHIPAT

สวัสดีครับผม อิทธิพัทธ์ (เป้) ชอบหาเทคนิคต่างๆที่ทำให้ชีวิต Programmer ง่ายขึ้น ทั้ง Automate, Library ชอบทำ Blog และ Video ถ้ามีเวลานะ!

ขอบคุณทุกคนที่ติดตาม และอ่านบทความของผมครับ ผมหวังว่าความรู้ที่เขียนขึ้นในเว็บไซต์นี้จะช่วยทุกท่านได้ไม่มากก็น้อย 

Scroll to Top