Vue项目改造成服务器端渲染

简介

vue-cli 3.x/4.x搭建的项目改造成服务器端渲染

vue服务器端渲染参考:vue服务器端渲染梳理

思路

服务器端使用koa框架

vue的服务器端渲染使用vue-server-renderer

favicon.ico使用koa-favicon

静态文件使用koa-static

获取webpack配置使用const webpackConfig = require('@vue/cli-service/webpack.config')

vue-cli默认production时会抽离css代码到css文件中。这时候会用到document。打包服务器端vue-ssr-server-bundle.json文件的时候不能抽离

开发环境下获取vue-ssr-client-manifest.json文件直接使用axios请求http://localhost:8080/vue-ssr-client-manifest.json

开发环境需要同时开启8080端口和3000端口。使用concurrently合并两个命令。比如:"concurrently \"npm run serve\" \"npm run start:dev\" "

开发环境需要等8080端口准备好之后再开启3000端口。使用wait-on等待wait-on http://localhost:8080

每次vue-cli-service build都会删除dist/目录再重新生成。不删除方法vue-cli-service build --no-clean

以下代码配置不生成index.html文件

module.exports = { chainWebpack: config => { // 以下三句是配置不生成html文件 config.plugins.delete('html') config.plugins.delete('preload') config.plugins.delete('prefetch') } }

实现

  • src/目录下添加index.template.html文件作为模板文件
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> </head> <body> <!--vue-ssr-outlet--> </body> </html>
  • 修改src/router/index.js文件,添加createRouter()
import Vue from 'vue' import VueRouter from 'vue-router' import Home from '../views/Home.vue' Vue.use(VueRouter) const routes = [ { path: '/', name: 'Home', component: Home }, { path: '/about', name: 'About', component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') } ] export function createRouter() { return new VueRouter({ mode: 'history', // base: process.env.BASE_URL, routes }) }
  • 修改src/store/index.js文件,添加createStore()
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) // 业务逻辑都单独放在每个页面的 asyncData 方法中,服务端渲染时会执行页面中定义的方法,并且将这些数据存储在 store 中 export function createStore() { return new Vuex.Store({ state: { }, mutations: { }, actions: { }, modules: { } }) }
  • 修改src/main.js文件
import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' import { createStore } from './store' import { sync } from 'vuex-router-sync' export function createApp() { // 创建router实例 const router = createRouter() // 创建store实例 const store = createStore() // 同步路由状态(route state)到store sync(store, router) const app = new Vue({ router, store, render: h => h(App) }) return { app, router, store } }
  • src/目录下添加entry-client.js文件
/* eslint-disable */ import { createApp } from './main' import Vue from 'vue' 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() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } // 假设App.vue模板中根元素 id = 'app' router.onReady(() => { // 添加路由钩子,用于处理 asyncData // 在初始路由 resolve 后执行 // 以便我们不会二次预取已有的数据 // 使用 router.beforeResolve(), 确保所有的异步组件都 resolve router.beforeResolve((to, from, next) => { const matched = router.getMatchedComponents(to) const prevMatched = router.getMatchedComponents(from) // 我们只关心非预渲染的组件 // 所有我们需要对比他们,找出两个品牌列表的差异组件 let diffed = false const activited = matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) }) if (!activited.length) { return next() } // 这里如果有加载指示器 (loading indicator),就触发 Promise.all(activited.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { // 停止加载指示器(loading indicator) next() }).catch(next) }) app.$mount('#app') })
  • src/目录下添加entry-server.js文件
/* eslint-disable */ import { createApp } from './main' export default context => { /* 由于 路由钩子函数或组件 有可能是异步的,比如 同步的路由是这样引入 import Foo from './Foo.vue' 但是异步的路由是这样引入的: { path: '/index', component: resolve => require(['./views/index'], resolve) } 如上是 require动态加载进来的,因此我们这边需要返回一个promise对象。以便服务器能够等待所有的内容在渲染前 就已经准备好就绪。 */ return new Promise((resolve, reject) => { const { app, router, store } = createApp() // 设置服务器端 router 的位置 router.push(context.url) /* router.onReady() 等到router将可能的异步组件或异步钩子函数解析完成,在执行,就好比我们js中的 window.onload = function(){} 这样的。 官网的解释:该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和 路由初始化相关联的异步组件。 这可以有效确保服务端渲染时服务端和客户端输出的一致。 */ router.onReady(() => { /* getMatchedComponents()方法的含义是: 返回目标位置或是当前路由匹配的组件数组 (是数组的定义/构造类,不是实例)。 通常在服务端渲染的数据预加载时使用。 有关 Router的实列方法含义可以看官网:https://router.vuejs.org/zh/api/#router-forward */ const matchedComponents = router.getMatchedComponents() // 如果匹配不到路由的话,执行reject函数,并且返回404 if (!matchedComponents.length) { return reject(new Error({ code: 404 })) } // 对所有匹配的路由组件,调用 asyncData() Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }) } })).then(() => { // 在所有预取钩子(preFetch hook) resolve 后, // 我们的 store 现在已经填充入渲染应用程序所需的状态。 // 当我们将状态附加到上下文, // 并且 `template` 选项用于 renderer 时, // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 context.state = store.state resolve(app) }).catch(reject) // 正常的情况 // resolve(app) }, reject) }).catch(new Error()) }
  • 在根目录下添加vue.config.js文件
// 生成JSON文件(vue-ssr-client-manifest.json) const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); // 将服务器的整个输出,构建为单个 JSON 文件的插件。默认文件名为 vue-ssr-server-bundle.json const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); const webpack = require('webpack') const nodeExternals = require('webpack-node-externals'); const { merge } = require('webpack-merge'); const isDev = process.env.NODE_ENV === 'development' const isServer = process.env.VUE_ENV === 'server' const target = process.env.VUE_ENV || 'client' const path = require('path') const resolve = file => path.resolve(__dirname, file) module.exports = { publicPath: isDev ? 'http://localhost:8080/' : '/', devServer: { historyApiFallback: true, headers: { 'Access-Control-Allow-Origin': '*' } }, css: { // extract: process.env.NODE_ENV === 'development' extract: !isDev && !isServer }, configureWebpack: () => ({ // 将 entry 指向应用程序的 server / client 文件 entry: `./src/entry-${target}.js`, // 对 bundle renderer 提供 source map 支持 devtool: isServer ? 'source-map' : false, // 允许webpack以Node适用方式(Node-appropriate fashion)处理动态导入(dynamic import), // 编译vue组件时,告知 vue-loader 输送面向服务器代码 target: isServer ? 'node' : 'web', node: isServer ? undefined : false, output: { // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports) libraryTarget: isServer ? 'commonjs2' : undefined }, // https://webpack.js.org/configuration/externals/#function // https://github.com/liady/webpack-node-externals // 外置化应用程序依赖模块。可以使服务器构建速度更快, // 并生成较小的 bundle 文件。 externals: isServer ? nodeExternals({ // 不要外置化 webpack 需要处理的依赖模块。 // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单 allowlist: [/\.css$/] }) : undefined, optimization: { splitChunks: isServer ? false : undefined }, plugins: [ isServer ? new VueSSRServerPlugin() : new VueSSRClientPlugin(), new webpack.ProgressPlugin((percentage) => {}) ] }), chainWebpack: config => { // config.resolve.alias.set('getData', resolve('src/getData')).set('src', resolve('src')).set('assets', resolve('src/assets')).set('utils', resolve('src/utils')) config.module .rule('vue') .use('vue-loader') .tap(options => { return merge(options, { optimizeSSR: false }) }) // fix ssr hot update bug if (isServer) { config.plugins.delete('hmr') } // 以下三句是配置不生成html文件 config.plugins.delete('html') config.plugins.delete('preload') config.plugins.delete('prefetch') } }
  • 在根目录下新建server/目录

  • server/目录下添加server.js文件

const path = require('path') // const fs = require('fs') // const Vue = require('vue') const Koa = require('koa') const Router = require('koa-router') const static = require('koa-static') // const resolve = file => path.resolve(__dirname, file) const isDev = process.env.NODE_ENV === 'development' // 引入缓存相关的模块 const LRU = require('lru-cache') // const { createBundleRenderer } = require('vue-server-renderer') // 动态监听文件发生改变的配置文件 // const devConfig = require('./build/dev.config.js') // 缓存 const microCache = new LRU({ max: 100, // 在1分钟后过期 maxAge: 1000 * 60 }) const isCacheable = ctx => { // 假如 about 页面进行缓存 if (ctx.url === '/about') { return true } return false } // 设置renderer为全局变量,根据环境变量赋值 let renderer const app = new Koa() const router = new Router() // favicon.ico const favicon = require('koa-favicon') app.use(favicon(path.resolve(__dirname, '../public/favicon.ico'))) // static // 静态资源目录对于相对入口文件 index.js 的路径 !isDev && app.use(static( path.join(__dirname, '../dist') )) const render = async (ctx, next) => { // 下面我们根据环境变量来生成不同的 BundleRenderer 实例 if (isDev) { const devRenderer = require('./dev') try { renderer = await devRenderer.getRenderer(ctx) } catch (e) { e.message === 'wait' ? ctx.body = '等待webpack打包完成后在访问在访问' : console.error(e) return } } else { // 正式环境 const prodRenderer = require('./prod') renderer = prodRenderer.getRenderer() } ctx.set('Content-Type', 'text/html') const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render: ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue服务器渲染组件', meta: ` <meta charset="utf-8"> <meta name="" content="vue服务器渲染组件"> ` } // 判断是否可缓存,可缓存且缓存中有的话,直接把缓存中返回 const cacheable = isCacheable(ctx) if (cacheable) { const hit = microCache.get(ctx.url) if (hit) { console.log('从缓存中取', hit) return ctx.body = hit } } try { const html = await renderer.renderToString(context) ctx.status = 200 ctx.body = html if (cacheable) { console.log('设置缓存', ctx.url) microCache.set(ctx.url, html) } } catch (err) { handleError(err) } next() } router.get('/(.*)', render) app.use(router.routes()).use(router.allowedMethods()) app.listen(3000, () => { console.log(`server started at localhost:3000`) })
  • server/目录下添加prod.js文件
const path = require('path') const fs = require('fs') const { createBundleRenderer } = require('vue-server-renderer') const resolve = file => path.resolve(__dirname, file) exports.getRenderer = () => { const template = fs.readFileSync(resolve('../src/index.template.html'), 'utf-8') // 引入客户端,服务端生成的json文件 const serverBundle = require(resolve('../dist/vue-ssr-server-bundle.json')) const clientManifest = require(resolve('../dist/vue-ssr-client-manifest.json')) return createBundleRenderer(serverBundle, { // 推荐 runInNewContext: false, // 页面模板 template: template, // 客户端构建 manifest clientManifest }) }
  • server/目录下添加dev.js文件
const path = require('path') const fs = require('fs') // memory-fs 可以是webpack将文件写入到内存中,而不是写入到磁盘 const MemoryFS = require('memory-fs') const webpack = require('webpack') const axios = require('axios') const send = require('koa-send') const resolve = file => path.resolve(__dirname, file) // 1、webpack配置文件 const webpackConfig = require('@vue/cli-service/webpack.config') const { createBundleRenderer } = require('vue-server-renderer') // 2、编译webpack配置文件 const serverCompiler = webpack(webpackConfig) const mfs = new MemoryFS() // 指定输出到的内存流中 serverCompiler.outputFileSystem = mfs // 3、监听文件修改,实时编译获取最新的 vue-ssr-server-bundle.json let bundle serverCompiler.watch({}, (err, stats) => { if (err) { throw err } stats = stats.toJson() stats.errors.forEach(error => console.error(error)) stats.warnings.forEach(warn => console.warn(warn)) const bundlePath = path.join( webpackConfig.output.path, 'vue-ssr-server-bundle.json' ) bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')) console.log('new bundle generated') }) exports.getRenderer = async (ctx) => { const template = fs.readFileSync(resolve('../src/index.template.html'), 'utf-8') return new Promise(async (resolve, reject) => { !bundle && reject(new Error('wait')) // 4、获取最新的 vue-ssr-client-manifest.json const clientManifestResp = await axios.get('http://localhost:8080/vue-ssr-client-manifest.json') const clientManifest = clientManifestResp.data const renderer = createBundleRenderer(bundle, { runInNewContext: false, template: template, clientManifest: clientManifest }) resolve(renderer) }) }
  • 修改package.json文件,添加scripts
{ // ... "scripts": { "serve": "vue-cli-service serve", "build:client": "cross-env VUE_ENV=client vue-cli-service build", "build:server": "cross-env VUE_ENV=server vue-cli-service build --no-clean", "build": "npm run build:client && npm run build:server", "start": "cross-env NODE_ENV=production node server/server.js", "start:dev": "cross-env NODE_ENV=development VUE_ENV=server nodemon server/server.js", "dev": "concurrently \"npm run serve\" \"wait-on http://localhost:8080 && npm run start:dev\" ", "lint": "vue-cli-service lint" }, // ... }

备注:

  • build:client:打包客户端,生成静态资源和vue-ssr-client-manifest.json
  • build:server:打包服务器端,生成vue-ssr-server-bundle.json
  • build:打包出客户端和服务器端资源
  • start:生产环境下执行server/server.js
  • start:dev:开发环境下执行server/server.js。这里要配置VUE_ENV=server(这里需要获取webpack配置后实时打包生成vue-ssr-server-bundle.json
  • dev:开发环境下同时监听8080端口和3000端口

开发环境时,执行npm run dev。有文件修改会自动监听并重新打包

生产环境先执行npm run build。生成dist/目录,然后在服务器上面执行npm run start


创作不易,若本文对你有帮助,欢迎打赏支持作者!

 分享给好友: