Server side render dùng Vue

Trong bài viết này chúng ta sẽ cùng nhau tìm hiểu về cách để SSR hoạt động với Vue Router, tăng hiệu quả để tránh những thiếu sót có thể khiến bạn phải mất nhiều thời gian để giải quyết.

Trước khi chúng ta bắt đầu, có một vài khái niệm cơ bản bạn cần phải biết trước

  • SSR liên quan đến việc tạo phiên bản ứng dụng được tải đầy đủ theo các route được yêu cầu trên Server. Trong khi đó trang web được render bằng client-side, code của client-side sẽ được tải về (không đầy đủ)
  • Bạn sẽ cần sử dụng cả server side và client side trong ứng dụng của mình

Dependencies

Hãy cùng nhau tìm hiểu xem những Dependencies nào mà chúng ta cần phải cài đặt

Chúng ta sẽ sử dụng template có sẵn giúp tạo ứng dụng VueJS mà chứa các cấu hình cơ bản sử dụng Webpack. Đầu tiên chúng ta sẽ cần cài đặt vue-cli

# Cài đặt vue-cli
npm install -g vue-cli
# Tạo project sử dụng webpack-simple
vue init webpack-simple vue-ssr

Tiếp theo chúng ta cần cài đặt toàn bộ Dependencies của webpack-simple template. Nhưng hãy nhớ rằng hiện tại chúng ta chưa làm gì để hỗ trợ SSR, chúng ta hiện tại chỉ cài đặt môi trường VueJS thông thường

# Đến thư mục project
cd vue-cli
# Cài đặt dependencies
npm install

Giờ thì chúng ta đã có một VueJS project sẵn sàng cho cấu hình SSR. Nhưng trước khi bắt đầu, chúng ta cần cài đặt thêm 3 Dependency liên quan đến SSR

# Cài đặt vue-server-render, vue-router, express and webpack-merge
npm install vue-server-renderer vue-router express webpack-merge --save
  • vue-server-render: Thư viện Vue cho SSR.
  • vue-router: Thư viện Vue cho SPA.
  • express: Chúng ta cần một NodeJS server để chạy
  • webpack-merge: Chúng ta sẽ cần merge cấu hình của Webpack.

Cấu hình Webpack

Chúng ta sẽ cần 2 cấu hình của Webpack, 1 dùng để build các file cho client side và một được dùng để build các file cho server side.

Đầu tiên hãy cùng xem cấu hình của Webpack client, nó sẽ là cấu hình gốc của Webpack cho Server side. Chúng ta sẽ sử dụng chính cấu hình Webpack được sinh ra bởi template mà chúng ta đã cài đặt ở trên, tất nhiên nó sẽ không bao gồm những thông tin mà chúng ta đã thay đổi bên dưới đây: Sửa entry sử dụng entry-client.js

var path = require('path')
var webpack = require('webpack')

module.exports = {
  entry: './src/entry-client.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    publicPath: '/dist/',
    filename: 'bundle.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
    })
  ])
}

Tiếp theo hãy tạo thêm một Webpack cho Server side với tên là webpack.server.config

var path = require('path')
var webpack = require('webpack')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.config')
var webpackConfig = merge(baseWebpackConfig, {
  target: 'node',
  entry: {
    app: './src/entry-server.js'
  },
  devtool: false,
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'server.bundle.js',
    libraryTarget: 'commonjs2'
  },
  externals: Object.keys(require('./package.json').dependencies),
  plugins: [
    new webpack.DefinePlugin({
      'process.env': 'production'
    }),
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false
      }
    })
  ]
})
module.exports = webpackConfig

Không có gì mới ở đây ngoại trừ 2 điều sau: Entry sẽ là entry-server.js và trong output chúng ta sử dụng commonjs2

Như vậy chúng ta đã cấu hình xong Webpack. Tiếp theo hãy cùng nhau cấu hình các script build ứng dụng trong package.json

Cấu hình package.json 

Bạn có thể cấu hình lại nếu bạn cần, nhưng có 3 bước mà bạn cần nhớ khi chạy ứng dụng của mình

  • Bạn cần build client script: build-client
  • Bạn cần build server script: build-server
  • Bạn cần start một server để chạy: start-server
"scripts": {
  "start": "npm run build && npm run start-server",
  "build": "npm run build-client && npm run build-server",
  "build-client": "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",
  "start-server": "node server.js"
}

Trong cấu hình bên trên, chúng ta sử dụng start trong scripts để thực hiện 3 bước bên trên. Nhưng chúng ta cũng có thể thiệt lập các scripts để chạy chúng tách biệt nếu cần

Cấu trúc thư mục

  • Thư mục dist được tạo bởi Webpack khi Webpack build
  • node_modules cài đặt các Dependency
  • Src chứa code Vue của chúng ta. Ở bên trong nó chứa cả source code của Server và Client, main.js của Vue, các component code của Vue, thư mục Router để chứa các router, thư mục ui chứa các trang hay component của vue  và cuối cùng là thư mục assets
  • index.html  là file HTML chính của project
  • server.js là file cấu hình server và start ứng dụng
  • Cuối cùng là 2 file cấu hình Webpack

Index HTML

Đây là file index.html

<!doctype html>
<html lang="en">
<head>
  <!-- use triple mustache for non-HTML-escaped interpolation -->
  {{{ meta }}}
  <!-- use double mustache for HTML-escaped interpolation -->
  <title>{{ title }}</title>
</head>
<body>
    <!--vue-ssr-outlet-->
  <script src="dist/bundle.js"></script>
</body>
</html>

Có 2 điều cần chú ý

  • <!--vue-ssr-outlet--> đây là nơi dữ liệu được điền vào từ server. Đây là một tính năng của Vue SSR mà chúng ta sẽ tìm hiểu sau đây
  • Chúng ta sử dụng build.js, file này được tạo ra bởi Webpack của client

App.vue

Component này là component gốc của ứng dụng và nó có một vài nhiệm vụ chính như sau:

  • Cấu hình cho menu chứa các link của Router
  • Cài đặt container cho các thành phần route để render
  • Cài đặt các phần từ bên trong div với id app  mà sẽ được mounting phần client side của ứng dụng
<template>
  <div id="app">
    Server side render for Vue
    <p>
      <router-link to="/">Go To Home</router-link>
      <router-link to="/about">Go To About</router-link>
    </p>
    <router-view></router-view>
  </div>
</template>

Cấu hình file Router

Bởi vì ứng dụng của chúng ta sẽ start một server, chúng ta cần cung cấp một instance mới của router cho mỗi reuqest của server. Bên trong thư mục Router, chúng ta sẽ có một file chứa thông tin cấu hình router

// router.js
import Vue from 'vue';
import Router from 'vue-router';
import Home from '../ui/home/Home.vue';
import About from '../ui/about/About.vue';

Vue.use(Router);

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

Nào hãy cùng tìm hiểu đoạn code ở bên trên

  • Chúng ta import tất cả các Dependency cần thiết
  • Chúng ta sử dụng router của Vue
  • Chúng ta export một hàm cung cấp một instance mới của cấu hình Router
  • Chúng ta khởi tạo router với chế độ history và khai báo 2 router mà chúng ta cần kiểm soát một trỏ đến trang Home và 1 đến trang About

Như ở trên chúng ta thấy rằng router đang trỏ đến 2 page là Home va About thế nên chúng ta cần tạo 2 trang này trong thư mục ui/home và ui/about

Home.vue

<template>
  <div>
    {{ homeText }}
  </div>
</template>
<script>
  export default {
    data() {
      return {
        homeText: 'Home page'
      };
    }
  };
</script>

About.vue

<template>
  <div>
    {{ aboutText }}
  </div>
</template>
<script>
  export default {
    data() {
      return {
        aboutText: 'About Page'
      };
    }
  };
</script>

Cấu hình file main.js

Với lý do tương tự như ở trên, chúng ta cần cung cấp một instance mới của router, chúng ta cần cung cập một instance mới cho ứng dụng. File này có nhiệm vụ khởi tạo router và component gốc app.vue. Cả Server và Client sẽ đều sử dụng file này

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

// export a factory function for creating fresh app, router and store
// instances
export function createApp() {
  // create router instance
  const router = createRouter();

  const app = new Vue({
    router,
    // the root instance simply renders the App component.
    render: h => h(App)
  });

  return { app, router };
}

Nào hãy cùng tìm hiểu đoạn code ở trên

  • Chúng ta import tất cả các Dependency cần thiết
  • Chúng ta export một hàm cung cấp một instance mới của app và router
  • Chúng ta khởi tạo router sử dụng phương thức mà chúng ta đã tạo ở bên trên trong file router.js
  • Chúng ta tạo một instance mới của app với router và hàm render, truyền vào component gốc app
  • Chúng ta trả về cả hai app và router

Client

Code file này tương đối đơn giản. File này là file ban đầu cho Webpack client build cấu hình

//client-entry.js
import { createApp } from './main.js';

const { app } = createApp()

// Giả định App.vue element của template gốc có `id="app"`
app.$mount('#app')

Nào hãy cùng tìm hiểu đoạn code bên trên

  • Chúng ta import tất cả các Dependency cần thiết
  • Chúng ta tạo app từ file main.js và dữ instace app
  • Chúng ta mount app trong một node với id là #app. Trong ví dụ của chúng ta node mà chứa id đó chính là phần tử nằm trong template gốc của component App.vue

Server

File này là file ban đầu để cho Webpack Server build.

/main.js';

export default context => {
  // trả về một Promise do đó server có thể đợi cho tới khi
  // Tất cả đều sẵn sàng trước khi render.
  return new Promise((resolve, reject) => {
    const { app, router } = createApp();

    // thiết lập ví trí của server-side router
    router.push(context.url);
       
    // Đợi cho đến khi router sẵn sàng wait until
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();
    
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }
  
      resolve(app);
    }, reject);
  });
}

Nào hãy cùng tìm hiểu đoạn code bên trên

  • Chúng ta import tất cả các Dependency cần thiết
  • Chúng ta export một hàm mà nhận context như tham số
  • Hàm trả về một promise
  • Chúng ta khởi tạo app và router từ hàm createApp() trong file main.js
  • Lấy thông tin URL hiên tại từ context mục đích để một URL chính xác vào trong router
  • Khi router đã sẵn sàng chúng ta sẽ kiểm tra route có giống với URL của context hay không. Nếu đúng chúng ta sẽ thực hiện resolve(app) và trả về một app instance. Nếu không thì reject Promise

Cấu hình và start Server

Chúng ta có hầu như tất cả mọi thứ sẵn sàng. Chỉ có một điều thiếu nữa là cấu hình và start Server dùng express.

//server.js
const express = require('express');
const server = express();
const fs = require('fs');
const path = require('path');
//obtain bundle
const bundle =  require('./dist/server.bundle.js');
//get renderer from vue server renderer
const renderer = require('vue-server-renderer').createRenderer({
  //set template
  template: fs.readFileSync('./index.html', 'utf-8')
});

server.use('/dist', express.static(path.join(__dirname, './dist')));

//start server
server.get('*', (req, res) => { 
    
  bundle.default({ url: req.url }).then((app) => {    
    //context to use as data source
    //in the template for interpolation
    const context = {
      title: 'Vue JS - Server Render',
      meta: `
        <meta description="vuejs server side render">
      `
    };

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

server.listen(8080);

Đoạn code khá dài đúng không? Chúng ta sẽ cùng tìm hiểu xem nó hoạt động như thế nào

  • Chúng ta đang import express để tạo Server. Chúng ta cũng đang import một vài tính năng của NodeJS
  • Chúng ta cũng import file bundle của Server được tạo bởi Webpack.
  • Chúng ta cũng import thư viện vue-server-renderer, để tạo ra renderer, cung cấp vị trí index.html cho template
  • Chúng ta cấu hình đường dẫn express
  • Chúng ta start server
  • bundle được tạo ra bởi Webpack và entry-server.js , nên chúng ta có thể sử dụng các hàm mặc định mà nhận context như tham số chứa URL. Sau đó nó trả về Promise, kết quả có thể thành công hoặc lỗi

Nếu kết quả trả về là thành công

  • Chúng ta sẽ tạo một hằng số với dữ liệu mà sẽ được thêm vào trong index.html. (chúng ta đã nhìn thấy trong phần index.html ở trên)
  • Chúng ta sẽ gọi hàm renderToString nhận app (được trả về bởi Promise), context chúng ta tạo và hàm callback nếu tất cả hoạt động tốt.
  • Hàm renderToString sẽ kiểm tra lỗi, nếu không có gì xảy ra, nó sẽ gửi một HTML được tạo ra như một response

Cuối cùng Server bắt đầu listen port 8080

Nào bây giờ chúng ta thử chạy script start và mở địa chỉ localhost:8080 trên trình duyệt bạn sẽ nhìn thất kết quả của SSR với Vue-router

Source code sử dụng branch vue-ssr

You May Also Like

About the Author: Nguyen Dinh Thuc

Leave a Reply

Your email address will not be published.