前面的話
不論是官網教程,還是官方DEMO,都是從0開始的服務端渲染配置。對於現有專案的伺服器端渲染SSR改造,特別是基於vue cli生成的專案,沒有特別提及。本文就小火柴的前端小站這個前臺專案進行SSR改造
效果
下面是經過SSR改造後的前端小站xiaohuochai.cc的網站效果,github原始碼地址
概述
【定義】
伺服器渲染的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') })
經過簡單的更改,即可實現開發環境使用瀏覽器端渲染,生產環境使用伺服器端渲染的效果