【定義】css
服務器渲染的Vue應用程序被認爲是"同構"或"通用",由於應用程序的大部分代碼均可以在服務器和客戶端上運行html
【優勢】前端
與傳統SPA相比,服務器端渲染(SSR)的優點主要在於:vue
一、更好的 SEO,搜索引擎爬蟲抓取工具能夠直接查看徹底渲染的頁面node
截至目前,Google 和 Bing 能夠很好對同步 JavaScript 應用程序進行索引。但若是應用程序初始展現 loading 菊花圖,而後經過 Ajax 獲取內容,抓取工具並不會等待異步完成後再行抓取頁面內容webpack
二、更快的內容到達時間,特別是對於緩慢的網絡狀況或運行緩慢的設備nginx
無需等待全部的 JavaScript 都完成下載並執行,才顯示服務器渲染的標記,因此用戶將會更快速地看到完整渲染的頁面,一般能夠產生更好的用戶體驗git
下面以官方的SSR服務器端渲染流程圖爲例,進行概要說明github
一、universal Application Code是服務器端和瀏覽器端通用的代碼web
二、app.js是應用程序的入口entry,對應vue cli生成的項目的main.js文件
三、entry-client.js是客戶端入口,僅運行於瀏覽器,entry-server.js是服務器端入口,僅運行於服務器
四、entry-client和entry-server這兩個文件都須要經過webpack構建,其中entry-client須要經過webpack.server.config.js文件打包,entry-server須要經過webpack.server.config.js文件打包
五、entry-client構建後的client Bundle打包文件是vue-ssr-client-manifest.json,entry-server構建後的server Bundle打包文件是vue-ssr-server-bundle.json
六、server.js文件將客戶端打包文件vue-ssr-client-manifest.json、服務器端打包文件vue-ssr-server-bundle.json和HTML模板混合,渲染成HTML
基於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文件
一、修改vue-loader.conf.js,將extract的值設置爲false,由於服務器端渲染會自動將CSS內置。若是使用該extract,則會引入link標籤載入CSS,從而致使相同的CSS資源重複加載
- extract: isProduction + extract: false
二、修改webpack.base.conf.js
只需修改entry入門配置便可
... module.exports = { context: path.resolve(__dirname, '../'), entry: { - app: './src/main.js' + app: './src/entry-client.js' }, ...
三、修改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
四、新增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
一、修改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 } +}
二、新增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') })
三、新增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) })
因爲代碼須要在服務器端和瀏覽器端共用,因此須要修改組件,使之在服務器端運行時不會報錯
一、修改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 } }, ... ] })
+}
二、修改狀態管理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, ... } })
+}
三、使用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 }) - }, ...
四、將全局css從main.js移動到App.vue中的內聯style樣式中,由於main.js中未設置css文件解析
// main.js - import '@/assets/style.css' // App.vue ... <style module lang="postcss"> ... </style>
五、因爲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))) : ''
一、在根目錄下,新建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`) })
二、修改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",
三、修改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>
四、取消代理
若是繼續使用代理如/api代理到後端接口,則可能會報以下錯誤
error:connect ECONNREFUSED 127.0.0.1:80
直接寫帶有http的後端接口地址便可
const API_HOSTNAME = 'http://192.168.1.103:4000'
一、安裝依賴包
cnpm install --save-dev vue-server-renderer
二、構建
npm run build
三、運行
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來進行開發呢?徹底能夠,開發環境中使用瀏覽器端渲染,生產環境中使用服務器端渲染
須要作出以下三點更改:
一、更改API地址,開發環境使用webpack代理,生產環境使用上線地址
// src/constants/API let API_HOSTNAME if (process.env.NODE_ENV === 'production') { API_HOSTNAME = 'https://api.xiaohuochai.cc' } else { API_HOSTNAME = '/api' }
二、在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>
三、更改服務器端入口文件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') })
通過簡單的更改,便可實現開發環境使用瀏覽器端渲染,生產環境使用服務器端渲染的效果