概述
【定義】
服務器渲染的Vue應用程序被認為是"同構"或"通用",因為應用程序的大部分代碼都可以在服務器和客戶端上運行
【優點】
與傳統SPA相比,服務器端渲染(SSR)的優勢主要在於:
1、更好的 SEO,搜索引擎爬蟲抓取工具可以直接查看完全渲染的頁面
截至目前,Google 和 Bing 可以很好對同步 JavaScript 應用程序進行索引。但如果應用程序初始展示 loading 菊花圖,然后通過 Ajax 獲取內容,抓取工具並不會等待異步完成后再行抓取頁面內容
2、更快的內容到達時間,特別是對於緩慢的網絡情況或運行緩慢的設備
無需等待所有的 JavaScript 都完成下載並執行,才顯示服務器渲染的標記,所以用戶將會更快速地看到完整渲染的頁面,通常可以產生更好的用戶體驗
思路
下面以官方的SSR服務器端渲染流程圖為例,進行概要說明
1、universal Application Code是服務器端和瀏覽器端通用的代碼
2、app.js是應用程序的入口entry,對應vue cli生成的項目的main.js文件
3、entry-client.js是客戶端入口,僅運行於瀏覽器,entry-server.js是服務器端入口,僅運行於服務器
4、entry-client和entry-server這兩個文件都需要通過webpack構建,其中entry-client需要通過webpack.server.config.js文件打包,entry-server需要通過webpack.server.config.js文件打包
5、entry-client構建后的client Bundle打包文件是vue-ssr-client-manifest.json,entry-server構建后的server Bundle打包文件是vue-ssr-server-bundle.json
6、server.js文件將客戶端打包文件vue-ssr-client-manifest.json、服務器端打包文件vue-ssr-server-bundle.json和HTML模板混合,渲染成HTML
webpack配置
基於vue-cli生成的項目的build目錄結構如下
build
- build.js
- check-versions.js
- utils.js
- vue-loader.conf.js
- webpack.base.conf.js
- webpack.dev.conf.js
- webpack.prod.conf.js
前面3個文件無需修改,只需修改*.*.conf.js文件
1、修改vue-loader.conf.js,將extract的值設置為false,因為服務器端渲染會自動將CSS內置。如果使用該extract,則會引入link標簽載入CSS,從而導致相同的CSS資源重復加載
- extract: isProduction + extract: false
2、修改webpack.base.conf.js
只需修改entry入門配置即可
...
module.exports = {
context: path.resolve(__dirname, '../'),
entry: {
- app: './src/main.js'
+ app: './src/entry-client.js'
},
...
3、修改webpack.prod.conf.js
包括應用vue-server-renderer、去除HtmlWebpackPlugin、增加client環境變量
'use strict'
...
+ const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const webpackConfig = merge(baseWebpackConfig, {
...
plugins: [
// http://vuejs.github.io/vue-loader/en/workflow/production.html
new webpack.DefinePlugin({
'process.env': env,
+ 'process.env.VUE_ENV': '"client"'
}),
...// generate dist index.html with correct asset hash for caching.
// you can customize output by editing /index.html
// see https://github.com/ampedandwired/html-webpack-plugin
- new HtmlWebpackPlugin({
- filename: config.build.index,
- template: 'index.html',
- inject: true,
- minify: {
- removeComments: true,
- collapseWhitespace: true,
- removeAttributeQuotes: true
- // more options:
- // https://github.com/kangax/html-minifier#options-quick-reference
- },
- // necessary to consistently work with multiple chunks via CommonsChunkPlugin
- chunksSortMode: 'dependency'
- }),
...// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.build.assetsSubDirectory,
ignore: ['.*']
}
]),
+ new VueSSRClientPlugin()
]
})
...
module.exports = webpackConfig
4、新增webpack.server.conf.js
const webpack = require('webpack')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.conf.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, {
entry: './src/entry-server.js',
target: 'node',
devtool: 'source-map',
output: {
libraryTarget: 'commonjs2'
},
externals: nodeExternals({
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
入口配置
在瀏覽器端渲染中,入口文件是main.js,而到了服務器端渲染,除了基礎的main.js,還需要配置entry-client.js和entry-server.js
1、修改main.js
import Vue from 'vue'
import Vuex from 'vuex'
- import '@/assets/style.css'
import App from './App'
- import router from './router'
+ import createRouter from './router'
- import store from './store'
+ import createStore from './store'
import async from './utils/async'
Vue.use(async)
- new Vue({
+ export default function createApp() {
+ const router = createRouter()
+ const store = createStore()
+ const app = new Vue({
- el: '#app',
router,
store,
- components: { App },
- template: '<App/>'
+ render: h => h(App)
})
+ return { app, router, store }
+}
2、新增entry-client.js
后面會介紹到asyncData方法,但是asyncData方法只能用於路由綁定的組件,如果是初始數據則可以直接在entry-client.js中獲取
/* eslint-disable */
import Vue from 'vue'
import createApp from './main'
Vue.mixin({
beforeRouteUpdate (to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
const { app, router, store } = createApp()
/* 獲得初始數據 */
import { LOAD_CATEGORIES_ASYNC } from '@/components/Category/module'
import { LOAD_POSTS_ASYNC } from '@/components/Post/module'
import { LOAD_LIKES_ASYNC } from '@/components/Like/module'
import { LOAD_COMMENTS_ASYNC } from '@/components/Comment/module'
import { LOAD_USERS_ASYNC } from '@/components/User/module'
(function getInitialData() {
const { postCount, categoryCount, userCount, likeCount, commentCount } = store.getters
const { dispatch } = store
// 獲取類別信息
!categoryCount && dispatch(LOAD_CATEGORIES_ASYNC),
// 獲取文章信息
!postCount && dispatch(LOAD_POSTS_ASYNC),
// 獲取點贊信息
!likeCount && dispatch(LOAD_LIKES_ASYNC),
// 獲取評論信息
!commentCount && dispatch(LOAD_COMMENTS_ASYNC),
// 獲取用戶信息
!userCount && dispatch(LOAD_USERS_ASYNC)
})()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
if (!activated.length) {
return next()
}
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
next()
}).catch(next)
})
app.$mount('#root')
})
3、新增entry-sever.js
/* eslint-disable */
import createApp from './main'
export default context => new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute
})
}
})).then(() => {
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
組件修改
由於代碼需要在服務器端和瀏覽器端共用,所以需要修改組件,使之在服務器端運行時不會報錯
1、修改router路由文件,給每個請求一個新的路由router實例
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
+ export default function createRouter() {
- export default new Router({
+ return new Router({
mode: 'history',
routes: [
{
path: '/',
component: () => import(/* webpackChunkName:'home' */ '@/components/Home/Home'),
name: 'home',
meta: { index: 0 }
},
...
]
})
+}
2、修改狀態管理vuex文件,給每個請求一個新的vuex實例
import Vue from 'vue'
import Vuex from 'vuex'
import auth from '@/components/User/module'
...
Vue.use(Vuex)
+ export default function createStore() {
- export default new Vuex.Store({
+ return new Vuex.Store({
modules: {
auth,
...
}
})
+}
3、使用asyncData方法來獲取異步數據
要特別注意的是,由於asyncData只能通過路由發生作用,使用是非路由組件的異步數據獲取最好移動到路由組件中
如果要通過asyncData獲取多個數據,可以使用Promise.all()方法
asyncData({ store }) {
const { dispatch } = store
return Promise.all([
dispatch(LOAD_CATEGORIES_ASYNC),
dispatch(LOAD_POSTS_ASYNC)
])
}
如果該異步數據是全局通用的,可以在entry-client.js方法中直接獲取
將TheHeader.vue通用頭部組件獲取異步數據的代碼移動到entry-client.js方法中進行獲取
// TheHeader.vue
computed: {
...
- ...mapGetters([
- 'postCount',
- 'categoryCount',
- 'likeCount',
- 'commentCount',
- 'userCount'
- ])
},
- mounted() {
// 獲取異步信息
- this.loadAsync()
...
- },
...
methods: {
- loadAsync() {
- const { postCount, categoryCount, userCount, likeCount, commentCount } = this
- const { dispatch } = this.$store
- // 獲取類別信息
- !categoryCount && dispatch(LOAD_CATEGORIES_ASYNC)
- // 獲取文章信息
- !postCount && dispatch(LOAD_POSTS_ASYNC)
- // 獲取點贊信息
- !likeCount && dispatch(LOAD_LIKES_ASYNC)
- // 獲取評論信息
- !commentCount && dispatch(LOAD_COMMENTS_ASYNC)
- // 獲取用戶信息
- !userCount && dispatch(LOAD_USERS_ASYNC)
- },
將Post.vue中的異步數據通過asyncData進行獲取
// post.vue
...
export default {
+ asyncData({ store, route }) {
+ return store.dispatch(LOAD_POST_ASYNC, { id: route.params.postid })
+ },
...
- mounted() {
- this.$store.dispatch(LOAD_POST_ASYNC, { id: this.postId })
- },
...
4、將全局css從main.js移動到App.vue中的內聯style樣式中,因為main.js中未設置css文件解析
// main.js - import '@/assets/style.css' // App.vue ... <style module lang="postcss"> ... </style>
5、由於post組件的模塊module.js中需要對數據通過window.atob()方法進行base64解析,而nodeJS環境下無window對象,會報錯。於是,代碼修改如下
// components/Post/module - text: decodeURIComponent(escape(window.atob(doc.content))) + text: typeof window === 'object' ? decodeURIComponent(escape(window.atob(doc.content))) : ''
服務器配置
1、在根目錄下,新建server.js文件
由於在webpack中去掉了HTMLWebpackPlugin插件,而是通過nodejs來處理模板,同時也就缺少了該插件設置的HTML文件壓縮功能
需要在server.js文件中安裝html-minifier來實現HTML文件壓縮
const express = require('express')
const fs = require('fs')
const path = require('path')
const { createBundleRenderer } = require('vue-server-renderer')
const { minify } = require('html-minifier')
const app = express()
const resolve = file => path.resolve(__dirname, file)
const renderer = createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'), {
runInNewContext: false,
template: fs.readFileSync(resolve('./index.html'), 'utf-8'),
clientManifest: require('./dist/vue-ssr-client-manifest.json'),
basedir: resolve('./dist')
})
app.use(express.static(path.join(__dirname, 'dist')))
app.get('*', (req, res) => {
res.setHeader('Content-Type', 'text/html')
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if (err.code === 404) {
res.status(404).send('404 | Page Not Found')
} else {
res.status(500).send('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err.stack)
}
}
const context = {
title: '小火柴的前端小站',
url: req.url
}
renderer.renderToString(context, (err, html) => {
console.log(err)
if (err) {
return handleError(err)
}
res.send(minify(html, { collapseWhitespace: true, minifyCSS: true}))
})
})
app.on('error', err => console.log(err))
app.listen(8080, () => {
console.log(`vue ssr started at localhost: 8080`)
})
2、修改package.json文件
- "build": "node build/build.js", + "build:client": "node build/build.js", + "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.conf.js --progress --hide-modules", + "build": "rimraf dist && npm run build:client && npm run build:server",
3、修改index.html文件
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
<link rel="shortcut icon" href="/static/favicon.ico">
<title>小火柴的藍色理想</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
4、取消代理
如果繼續使用代理如/api代理到后端接口,則可能會報如下錯誤
error:connect ECONNREFUSED 127.0.0.1:80
直接寫帶有http的后端接口地址即可
const API_HOSTNAME = 'http://192.168.1.103:4000'
測試
1、安裝依賴包
cnpm install --save-dev vue-server-renderer
2、構建
npm run build
3、運行
node server.js
點擊右鍵,查看網頁源代碼。結果如下,說明網站已經實現了服務器端渲染
部署
【pm2】
由於該網站需要守護nodejs程序,使用pm2部署較為合適
在項目根目錄下,新建一個ecosystem.json文件,內容如下
{
"apps" : [{
"name" : "blog-www",
"script" : "./index.js",
"env": {
"COMMON_VARIABLE": "true"
},
"env_production" : {
"NODE_ENV": "production"
}
}],
"deploy" : {
"production" : {
"user" : "xxx",
"host" : ["1.2.3.4"],
"port" : "22",
"ref" : "origin/master",
"repo" : "git@github.com:littlematch0123/blog-client.git",
"path" : "/home/xxx/www/mall",
"post-deploy" : "source ~/.nvm/nvm.sh && cnpm install && pm2 startOrRestart ecosystem.json --env production",
"ssh_options": "StrictHostKeyChecking=no",
"env" : {
"NODE_ENV": "production"
}
}
}
}
【CDN】
由於項目實際上既有靜態資源,也有nodeJS程序。因此,最好把靜態資源上傳到七牛CDN上
自行選擇服務器的一個目錄,新建upload.js文件
var fs = require('fs');
var qiniu = require('qiniu');
var accessKey = 'xxx';
var secretKey = 'xxx';
var mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
var staticPath = '/home/www/blog/client/source/';
var prefix = 'client/static';
var bucket = 'static';
var config = new qiniu.conf.Config();
config.zone = qiniu.zone.Zone_z1;
var formUploader = new qiniu.form_up.FormUploader(config);
var putExtra = new qiniu.form_up.PutExtra();
putExtra = null; // 一定要將putExtra設置為null,否則會出現所有文件類別都被識別為第一個文件的類型的情況
// 文件上傳方法
function uploadFile (localFile) {
// 配置上傳到七牛雲的完整路徑
const key = localFile.replace(staticPath, prefix)
const options = {
scope: bucket + ":" + key,
}
const putPolicy = new qiniu.rs.PutPolicy(options)
// 生成上傳憑證
const uploadToken = putPolicy.uploadToken(mac)
// 上傳文件
formUploader.putFile(uploadToken, key, localFile, putExtra, function(respErr, respBody, respInfo) {
if (respErr) throw respErr
if (respInfo.statusCode == 200) {
console.log(respBody);
} else {
console.log(respInfo.statusCode);
console.log(respBody);
}
})
}
// 目錄上傳方法
function uploadDirectory (dirPath) {
fs.readdir(dirPath, function (err, files) {
if (err) throw err
// 遍歷目錄下的內容
files.forEach(item => {
let path = `${dirPath}/${item}`
fs.stat(path, function (err, stats) {
if (err) throw err
// 是目錄就接着遍歷 否則上傳
if (stats.isDirectory()) uploadDirectory(path)
else uploadFile(path, item)
})
})
})
}
fs.exists(staticPath, function (exists) {
if (!exists) {
console.log('目錄不存在!')
}
else {
console.log('開始上傳...')
uploadDirectory(staticPath)
}
})
【post-deploy】
然后,修改ecosystem.json文件中的post-deploy項
"source ~/.nvm/nvm.sh && cnpm install && npm run build && node /home/xiaohuochai/blog/client/upload.js&& pm2 startOrRestart ecosystem.json --env production",
但是,經過實際測試,在服務器端進行構建build,極其容易造成服務器死機。於是,還是在本地構建完成后,上傳dist文件到服務器再進行相關操作
"source ~/.nvm/nvm.sh && cnpm install && node /home/xiaohuochai/blog/client/upload.js&& pm2 startOrRestart ecosystem.json --env production"
修改項目的靜態資源地址為CDN地址,API地址為服務器API地址
// config/index.js assetsPublicPath: 'https://static.xiaohuochai.site/client/' // src/constants/API.js const API_HOSTNAME = 'https://api.xiaohuochai.cc'
【nginx】
如果要使用域名對項目進行訪問,還需要進行nginx配置
upstream client {
server 127.0.0.1:3002;
}
server{
listen 80;
server_name www.xiaohuochai.cc xiaohuochai.cc;
return 301 https://www.xiaohuochai.cc$request_uri;
}
server{
listen 443 http2;
server_name www.xiaohuochai.cc xiaohuochai.cc;
ssl on;
ssl_certificate /home/blog/client/crt/www.xiaohuochai.cc.crt;
ssl_certificate_key /home/blog/client/crt/www.xiaohuochai.cc.key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
if ($host = 'xiaohuochai.cc'){
rewrite ^/(.*)$ http://www.xiaohuochai.cc/$1 permanent;
}
location / {
expires 7d;
add_header Content-Security-Policy "default-src 'self' https://static.xiaohuochai.site; connect-src https://api.xiaohuochai.cc; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.xiaohuochai.site ; img-src 'self' data: https://pic.xiaohuochai.site https://static.xiaohuochai.site; style-src 'self' 'unsafe-inline' https://static.xiaohuochai.site; frame-src https://demo.xiaohuochai.site https://xiaohuochai.site https://www.xiaohuochai.site;";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Nginx-Proxy true;
proxy_pass http://client;
proxy_redirect off;
}
}
瀏覽器渲染
官網的代碼中,如果使用開發環境development,則需要進行相當復雜的配置
能否應用當前的webpack.dev.conf.js來進行開發呢?完全可以,開發環境中使用瀏覽器端渲染,生產環境中使用服務器端渲染
需要做出如下三點更改:
1、更改API地址,開發環境使用webpack代理,生產環境使用上線地址
// src/constants/API
let API_HOSTNAME
if (process.env.NODE_ENV === 'production') {
API_HOSTNAME = 'https://api.xiaohuochai.cc'
} else {
API_HOSTNAME = '/api'
}
2、在index.html同級目錄下,新建一個index.template.html文件,index.html是開發環境的模板文件,index.template.html是生產環境的模板文件
// index.html
<body>
<div id="root"></div>
</body>
// index.template.html
<body>
<!--vue-ssr-outlet-->
</body>
3、更改服務器端入口文件server.js的模板文件為index.template.html
// server.js
const renderer = createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'), {
runInNewContext: false,
template: fs.readFileSync(resolve('./index.template.html'), 'utf-8'),
clientManifest: require('./dist/vue-ssr-client-manifest.json'),
basedir: resolve('./dist')
})
經過簡單的更改,即可實現開發環境使用瀏覽器端渲染,生產環境使用服務器端渲染的效果

