vue + koa2 + webpack4 構建ssr項目

什麼是服務器端渲染 (SSR)?爲什麼使用服務器端渲染 (SSR)?

看這 Vue SSR 指南

技術棧

  • vue、vue-router、vuex
  • koa2
  • webpack4
  • axios
  • babel、eslint
  • css、stylus、postcss
  • pm2

目錄層次

webpack4-ssr-config
├── client # 項目代碼目錄
│   ├── assets # css、images等靜態資源目錄
│   ├── components # 項目自定義組件目錄
│   ├── plugins # 第三方插件(只能在客戶端運行)目錄,比如 編輯器
│   ├── store # vuex數據存儲目錄
│   ├── utils # 通用Mixins目錄
│   ├── views # 業務視圖.vue和route路由目錄
│   ├── app.vue # 
│   ├── config.js # vue組件、mixins註冊,http攔截器配置等等
│   ├── entry-client.js # 僅運行於瀏覽器
│   ├── entry-server.js # 僅運行於服務器
│   ├── index.js # 通用 entry
│   ├── router.js # 路由配置和相關鉤子配置
│   └── routes.js # 匯聚業務模塊所有路由route配置
├── config # 配置文件目錄
│   ├── http # axios封裝的http請求
│   ├── logger # .vue裏this.[log,warn,info,error]和koa2裏 logger日誌輸出
│   ├── middle # koa2中間件目錄
│   │   ├── errorMiddleWare.js # 錯誤處理中間件
│   │   ├── proxyMiddleWare.js # 接口代理中間件
│   │   └── staticMiddleWare.js # 靜態資源中間件
│   ├── eslintrc.conf.js # eslint詳細配置
│   ├── index.js # server入口
│   ├── koa.server.js # koa2服務詳細配置
│   ├── setup.dev.server.js # koa2開發模式實現hot熱更新
│   ├── vue.koa.ssr.js # vue ssr的koa2中間件。匹配路由、請求接口生成dom,實現SSR
│   ├── webpack.base.config.js # 基本配置 (base config) 
│   ├── webpack.client.config.js # 客戶端配置 (client config)
│   └── webpack.server.config.js # 服務器配置 (server config)
├── dist # 代碼打包目錄
├── log # pm2日誌輸出目錄
├── node_modules # node包
├── .babelrc # babel配置
├── .eslintrc.js # eslint配置
├── .gitignore # git配置
├── app.config.js # 端口、代理配置、webpack配置等等
├── constants.js # 存放常量
├── favicon.ico # ico圖標
├── index.template.ejs # index模板
├── package.json # 
├── package-lock.json # 
├── pm2.config.js # 項目pm2配置
├── pm2.md # pm2的api文檔
├── postcss.config.js # postcss配置文件
└── README.md # 文檔

源碼結構

構建

使用 webpack 來打包我們的 Vue 應用程序,參考官方分成3個配置,這裏使用的webpack4和官方的略有區別。

├── webpack.base.config.js # 基本配置 (base config) 
├── webpack.client.config.js # 客戶端配置 (client config)
├── webpack.server.config.js # 服務器配置 (server config)

具體webpack配置代碼這裏省略...

對於客戶端應用程序和服務器應用程序,我們都要使用 webpack 打包 - 服務器需要「服務器 bundle」然後用於服務器端渲染(SSR),
而「客戶端 bundle」會發送給瀏覽器,用於混合靜態標記。基本流程如下圖:

圖片描述

項目代碼

├── entry-client.js # 僅運行於瀏覽器
├── entry-server.js # 僅運行於服務器
├── index.js # 通用 entry
├── router.js # 路由配置
├── routes.js # 匯聚業務模塊所有路由route配置

index.js

index.js 是我們應用程序的「通用 entry」,對外導出一個 createApp 函數。這裏使用工廠模式爲爲每個請求創建一個新的根 Vue 實例,
從而避免server端單例模式,如果我們在多個請求之間使用一個共享的實例,很容易導致交叉請求狀態污染。

entry-client.js:

客戶端 entry 只需創建應用程序,並且將其掛載到 DOM 中:

import Vue from 'vue'
import { createApp } from './index'
// 引入http請求
import http from './../config/http/http'
......
const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
  // 客戶端和服務端保持一致
  store.state.$http = http
}

router.onReady(() => {
    ......
    Promise.all(asyncDataHooks.map(hook => hook({ store, router, route: to })))
      .then(() => {
        bar.finish()
        next()
      })
      .catch(next)
  })
  // 掛載
  app.$mount('#app')
})

entry-server.js:

服務器 entry 使用 default export 導出函數,並在每次渲染中重複調用此函數。此時,除了創建和返回應用程序實例之外,
還在此執行服務器端路由匹配和數據預取邏輯。

import { createApp } from './index'
// 引入http請求
import http from './../config/http/http'
// 處理ssr期間cookies穿透
import { setCookies } from './../config/http/http'

// 客戶端特定引導邏輯……
const { app } = createApp()

// 這裏假定 App.vue 模板中根元素具有 `id="app"`
app.$mount('#app')

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    const { url } = context
    ......
    // 設置服務器端 router 的位置,路由配置裏如果設置過base,url需要把url.replace(base,'')掉,不然會404
    router.push(url)

    // 等到 router 將可能的異步組件和鉤子函數解析完
    router.onReady(() => {
      ......
      // SSR期間同步cookies
      setCookies(context.cookies || {})
      // http注入到rootState上,方便store裏調用
      store.state.$http = http
      // 使用Promise.all執行匹配到的Component的asyncData方法,即預取數據
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        router,
        route: router.currentRoute,
      }))).then(() => {
        // 在所有預取鉤子(preFetch hook) resolve 後,
        // 我們的 store 現在已經填充入渲染應用程序所需的狀態。
        // 當我們將狀態附加到上下文,
        // 並且 `template` 選項用於 renderer 時,
        // 狀態將自動序列化爲 `window.__INITIAL_STATE__`,並注入 HTML。
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

router.jsroutes.jsstore.js

router和store也都是工廠模式,routes是業務模塊路由配置的集合。

router

import Vue from 'vue'
import Router from 'vue-router'
import routes from './routes'

Vue.use(Router)

export function createRouter() {
  const router = new Router({
    mode: 'history',
    fallback: false,
    // base: '/ssr',
    routes
  })

  router.beforeEach((to, from, next) => {
    /*todo
    * 做權限驗證的時候,服務端和客戶端狀態同步的時候會執行一次
    * 建議vuex裏用一個狀態值控制,默認false,同步時直接next,因爲服務端已經執行過。
    * */
    next()
  })

  router.afterEach((route) => {
    /*todo*/
  })
  return router
}

route

import testRoutes from './views/test/routes'
import entry from './app.vue'

const home = () => import('./views/home.vue')
const routes = [
  {
    path: '/',
    component: home
  },
  {
    path: '/test',
    component: entry,
    children: testRoutes
  },
]
export default routes

store

import Vue from 'vue'
import Vuex from 'vuex'
import test from './modules/test'

Vue.use(Vuex)

export function createStore() {
  return new Vuex.Store({
    modules: {
      test
    }
  })
}

Http請求

http使用Axios庫封裝

/**
 * Created by zdliuccit on 2019/1/14.
 * @file axios封裝
 * export default http 接口請求
 * export addRequestInterceptor 請求前攔截器
 * export addResponseInterceptor 請求後攔截器
 * export setCookies 同步cookie
 */
import axios from 'axios'

const currentIP = require('ip').address()
const appConfig = require('./../../app.config')

const defaultHeaders = {
  Accept: 'application/json, text/plain, */*; charset=utf-8',
  'Content-Type': 'application/json; charset=utf-8',
  Pragma: 'no-cache',
  'Cache-Control': 'no-cache',
}
Object.assign(axios.defaults.headers.common, defaultHeaders)

if (!process.browser) {
  axios.defaults.baseURL = `http://${currentIP}:${appConfig.appPort}`
}
const methods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'request', 'head']

const http = {}
methods.forEach(method => {
  http[method] = axios[method].bind(axios)
})

export const addRequestInterceptor = (resolve, reject) => {
  if (axios.interceptors.request.handlers.length === 0) axios.interceptors.request.use(resolve, reject)
}
export const addResponseInterceptor = (resolve, reject) => {
  if (axios.interceptors.response.handlers.length === 0) axios.interceptors.response.use(resolve, reject)
}
export const setCookies = Cookies => axios.defaults.headers.cookie = Cookies

export default http

store中已經注入到rootState,使用如下:

loading({ commit, rootState: { $http } }) {
    return $http.get('path').then(res => {
      ...
      })
  }

config.js中,把http註冊到vue的原型鏈和配置request、response的攔截器

import Vue from 'vue'
// 引入http請求插件
import http from './../config/http'
// 引入log日誌插件
import { addRequestInterceptor, addResponseInterceptor } from './../config/http/http'
import titleMixin from './utils/title'
// 引入log日誌插件
import vueLogger from './../config/logger/vue-logger'

// 註冊插件
Vue.use(http)
Vue.use(vueLogger)
Vue.mixin(titleMixin)

// request前自動添加api配置
addRequestInterceptor(
  (config) => {
    /*統一加/api前綴*/
    config.url = `/api${config.url}`
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// http 返回response前處理
addResponseInterceptor(
  (response) => {
    /*todo 在這裏統一前置處理請求響應 */
    return Promise.resolve(response.data)
  },
  (error) => {
    /*
    * todo 統一處理500、400等錯誤狀態
    * 這裏reject下,交給entry-server.js的處理
    */
    const { response, request } = error
    return Promise.reject({ code: response.status, data: response.data, method: request.method, path: request.path })
  }
)

這樣,.vue中間中直接調用this.$http.get()、this.$http.post()...

cookies穿透

在ssr期間我們需要截取客戶端的cookie,保持用戶會話唯一性。

entry-server.js中使用setCookies方法,傳入的參數是從context上獲取。

......
 // SSR期間同步cookies
 setCookies(context.cookies || {})
......

vue.koa.ssr.js代碼中往context注入cookie

......
 const context = {
      url: ctx.url,
      title: 'Vue Koa2 SSR',
      cookies: ctx.request.headers.cookie
 }
......

其他

  • title處理參考官方
  • 用到全局變量的第三方插件、組件如何處理等等
  • 流式渲染
  • 預渲染
  • ......

還有很多優化、深坑,看看官方文檔、踩踩就知道了

Koa

官方使用express框架。express雖然現在也支持async、await,不過獨愛koa。

koa主文件

// 引入相關包和中間件等等
const Koa = require('koa')
...
const appConfig = require('./../app.config')
const uri = `http://${currentIP}:${appConfig.appPort}`

// koa server
const app = new Koa()

// 定義中間件,
const middleWares = [
 ......
]
middleWares.forEach((middleware) => {
  if (!middleware) {
    return
  }
  app.use(middleware)
})

// vue ssr處理
vueKoaSSR(app, uri)
// http代理中間件
app.use(proxyMiddleWare())

console.log(`\n> Starting server... ${uri} \n`)

// 錯誤處理
app.on('error', (err) => {
  // console.error('Server error: \n%s\n%s ', err.stack || '')
})
app.listen(appConfig.appPort)

vue.koa.ssr.js

vue koa2 ssr中間件

  • 開發模式直接使用setup.dev.server.jswebpack hot熱更新
  • 生產模塊直接讀取dist目錄的文件

路由匹配

  • 匹配proxy代理配置,接口請求進入proxyMiddleWare.js接口代理中間件
  • 非接口進入render(),返回html
const fs = require('fs')
const path = require('path')
const LRU = require('lru-cache')
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
const proxyConfig = require('./../app.config').proxy
const setUpDevServer = require('./setup.dev.server')

module.exports = function (app, uri) {

  const renderData = (ctx, renderer) => {
    const context = {
      url: ctx.url,
      title: 'Vue Koa2 SSR',
      cookies: ctx.request.headers.cookie
    }
    return new Promise((resolve, reject) => {
      renderer.renderToString(context, (err, html) => {
        if (err) {
          return reject(err)
        }
        resolve(html)
      })
    })
  }

  function createRenderer(bundle, options) {
    return createBundleRenderer(bundle, Object.assign(options, {
      cache: LRU({
        max: 1000,
        maxAge: 1000 * 60 * 15
      }),
      runInNewContext: false
    }))
  }

  function resolve(dir) {
    return path.resolve(process.cwd(), dir)
  }

  let renderer
  if (isProd) {
    // prod mode
    const template = fs.readFileSync(resolve('dist/index.html'), 'utf-8')
    const bundle = require(resolve('dist/vue-ssr-server-bundle.json'))
    const clientManifest = require(resolve('dist/vue-ssr-client-manifest.json'))
    renderer = createRenderer(bundle, {
      template,
      clientManifest
    })
  } else {
    // dev mode
    setUpDevServer(app, uri, (bundle, options) => {
        try {
          renderer = createRenderer(bundle, options)
        } catch (e) {
          console.log('\nbundle error', e)
        }
      }
    )
  }
  app.use(async (ctx, next) => {
    if (!renderer) {
      ctx.type = 'html'
      return ctx.body = 'waiting for compilation... refresh in a moment.';
    }
    if (Object.keys(proxyConfig).findIndex(vl => ctx.url.startsWith(vl)) > -1) {
      return next()
    }
    let html, status
    try {
      status = 200
      html = await renderData(ctx, renderer)
    } catch (e) {
      console.log('\ne', e)
      if (e.code === 404) {
        status = 404
        html = '404 | Not Found'
      } else {
        status = 500
        html = '500 | Internal Server Error'
      }
    }
    ctx.type = 'html'
    ctx.status = status ? status : ctx.status
    ctx.body = html
  })
}

setup.dev.server.js

koa2的webpack熱更新配置和相關中間件的代碼,這裏就不貼出來了,和express略有區別。

部署

Pm2

簡介

PM2是node進程管理工具,可以利用它來簡化很多node應用管理的繁瑣任務,如性能監控、自動重啓、負載均衡等,而且使用非常簡單。

pm2.config.js配置如下

module.exports = {
  apps: [{
    name: 'ml-app', // app名稱
    script: 'config/index.js', // 要運行的腳本的路徑。
    args: '', // 由傳遞給腳本的參數組成的字符串或字符串數​​組。
    output: './log/out.log',
    error: './log/error.log',
    log: './log/combined.outerr.log',
    merge_logs: true, // 集羣的所有實例的日誌文件合併
    log_date_format: "DD-MM-YYYY",
    instances: 4, // 進程數 1、數字 2、'max'根據cpu內核數
    max_memory_restart: '1G', // 當內存超過1024M時自動重啓
    watching: true,
    env_test: {
      NODE_ENV: 'production'
    },
    env_production: {
      NODE_ENV: 'production'
    }
  }],
}

構建生產代碼

npm run build 構建生產代碼

pm2啓動服務

初次啓動
pm2 start pm2.config.js --env production # production 對應 env_production
or
pm2 start ml-app

pm2的用法和參數說明可以參考pm2.md,也可參考PM2實用入門指南

Nginx

在pm2基礎上,Nginx配置upstream實現負載均衡

在http節點下,加入upstream節點。

upstream server_name {
  server  172.16.119.198:8018 max_fails=2 fail_timeout=30s;
  server  172.16.119.198:8019 max_fails=2 fail_timeout=30s;
  server  172.16.119.198:8020 max_fails=2 fail_timeout=30s;
  .....
}

將server節點下的location節點中的proxy_pass配置爲:http:// + server_name,即“ http://server_name”.

location / { 
  proxy_pass http://server_name;
  proxy_set_header Host localhost;
  proxy_set_header X-Forwarded-For $remote_addr
}

詳細配置參考文檔

如果應用服務是域名子路徑ssr的話,需要注意如下

  • location除了需要設置匹配/ssr規則之外,還需設置接口、資源的前綴比如(/api,/dist) location ~ /(ssr|api|dist) {...}
  • vue的路由也該設置base:'/ssr'
  • entry-server.jsrouter.push(url)這裏,url應該把/ssr去掉,即router.push(url.replace('/ssr','''))

參考文檔

Demo地址 服務器帶寬垃圾,將就看看。

git倉庫地址

還有很多不足,後續慢慢折騰....

結束語:生命的價值在於瞎折騰

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章