什麼是服務器端渲染 (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.js
、routes.js
、store.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.js
webpack 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.js
裏router.push(url)
這裏,url應該把/ssr
去掉,即router.push(url.replace('/ssr','''))
參考文檔
Demo地址 服務器帶寬垃圾,將就看看。
還有很多不足,後續慢慢折騰....
結束語:生命的價值在於瞎折騰