vue项目改造服务端渲染

概述

【定义】

  服务器渲染的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.htmlnew 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 assetsnew 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: ''+ 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.$optionsif (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.gettersconst { 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 = falseconst 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.stateresolve(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 } = storereturn Promise.all([dispatch(LOAD_CATEGORIES_ASYNC),dispatch(LOAD_POSTS_ASYNC)])}

  如果该异步数据是全局通用的,可以在entry-client.js方法中直接获取

  将TheHeader.vue通用头部组件获取异步数据的代码移动到entry-client.js方法中进行获取

// TheHeader.vuecomputed: {...-...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......

  5、由于post组件的模块module.js中需要对数据通过window.atob()方法进行base64解析,而nodeJS环境下无window对象,会报错。于是,代码修改如下

// components/Post/module- text: decodeURIComponent(escape(window.atob(doc.content))) + text: typeof window === 'object' " />服务器配置 

  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文件

小火柴的蓝色理想 

  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 respErrif (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) elseuploadFile(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.jsassetsPublicPath: 'https://static.xiaohuochai.site/client/'// src/constants/API.jsconst 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/APIlet API_HOSTNAMEif (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// index.template.html 

  3、更改服务器端入口文件server.js的模板文件为index.template.html

// server.jsconst 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')})https://www.cnblogs.com/lcosima/p/9613349.html

https://www.cnblogs.com/lcosima/p/9613349.html