简介
将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
发表评论