简介
官方资料:Vue SSR 指南
主要使用的是vue-server-renderer
来实现的服务器端渲染
- 安装:
npm i vue vue-server-renderer --save
vue-server-renderer
createRenderer
createRenderer()
:创建一个renderer
实列。以vue组件为入口renderer.renderToString(vm, cb)
:将Vue
实列呈现为字符串。该方法的回调函数是一个标准的Node.js
回调,它接收错误作为第一个参数
const Vue = require('vue')
// 创建渲染器
const renderer = require('vue-server-renderer').createRenderer()
const app = new Vue({
template: `<div>Hello World</div>`
})
// 生成预渲染的HTML字符串. 如果没有传入回调函数,则会返回 promise
renderer.renderToString(app).then(html => {
console.log(html)
}).catch(err => {
console.log(err)
})
// 当然我们也可以使用另外一种方式渲染,传入回调函数,
// 其实和上面的结果一样,只是两种不同的方式而已
renderer.renderToString(app, (err, html) => {
if (err) {
throw err
}
console.log(html)
})
输出的结果如下(里面的属性data-server-rendered
作用是告诉VUE
这是服务器渲染的元素。并且应该以激活的模式进行挂载):
<div data-server-rendered="true">Hello World</div>
<div data-server-rendered="true">Hello World</div>
createBundleRenderer
createBundleRenderer(code, [rendererOptions])
:创建一个renderer
实列。以打包后的JS文件或json文件为入口
const { createBundleRenderer } = require('vue-server-renderer').createBundleRenderer
let renderer = createBundleRenderer('./package.json')
console.log(renderer)
与Koa集成
集成
- 新建
app.js
文件
const Vue = require('vue')
const Koa = require('koa')
const Router = require('koa-router')
const renderer = require('vue-server-renderer').createRenderer()
const app = new Koa()
const router = new Router()
router.get('/(.*)', async (ctx, next) => {
// 创建vue实例
const app = new Vue({
data() {
return {
url: ctx.url
}
},
template: `<div>访问的URL是:{{url}}</div>`
})
try {
// vue实例转换成字符串
const html = await renderer.renderToString(app)
ctx.status = 200
ctx.body = `
<!DOCTYPE html>
<html>
<head><title>vue服务器渲染组件</title></head>
<body>${html}</body>
</html>
`
} catch(e) {
console.log(e)
ctx.status = 500
ctx.body = '服务器错误'
}
})
app.use(router.routes()).use(router.allowedMethods())
app.listen(3000, () => {
console.log(`server started at localhost:3000`)
})
使用页面模板
将html
代码抽离出来,写到一个html
文件中
- 新建
index.template.html
文件(里面的<!--vue-ssr-outlet-->
是必需的,是一个占位符。等renderer.renderToString
函数 真正渲染成html
后,会把内容插入到该地方来)
<!DOCTYPE html>
<html>
<head>
<!-- 三花括号不会进行html转义 -->
{{{ meta }}}
<title>{{title}}</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
- 修改
app.js
文件,通过fs
模块读取index.template.html
页面代码
const Vue = require('vue')
const Koa = require('koa')
const Router = require('koa-router')
const renderer = require('vue-server-renderer').createRenderer({
// 读取传入的template参数
template: require('fs').readFileSync('./index.template.html', 'utf-8')
})
const app = new Koa()
const router = new Router()
router.get('/(.*)', async (ctx, next) => {
// 创建vue实例
const app = new Vue({
data() {
return {
url: ctx.url
}
},
template: `<div>访问的URL是:{{url}}</div>`
})
const context = {
title: 'vue服务器渲染组件',
meta: `
<meta charset="utf-8">
<meta name="" content="vue服务器渲染组件">
`
}
try {
// vue实例转换成字符串
const html = await renderer.renderToString(app, context)
ctx.status = 200
ctx.body = html
} catch(e) {
console.log(e)
ctx.status = 500
ctx.body = '服务器错误'
}
})
app.use(router.routes()).use(router.allowedMethods())
app.listen(3000, () => {
console.log(`server started at localhost:3000`)
})
服务器渲染搭建
基本目录结构如下
demo
├── src
│ ├── app.js # 为每个请求创建一个新的根vue实列
│ ├── index.template.html
├── server.js # 服务相关的代码
└── package.json # 依赖的包文件
src/index.template.html
<!DOCTYPE html>
<html>
<head>
<!-- 三花括号不会进行html转义 -->
{{{ meta }}}
<title>{{title}}</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
避免单例
服务器端需要为每个请求创建一个新的根 Vue
实例。
如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染
- 修改
src/app.js
文件
const Vue = require('vue')
// 为每个请求创建一个新的根vue实例
module.exports = function createApp(ctx) {
return new Vue({
data() {
return {
url: ctx.url
}
},
template: `<div>访问的URL是:{{url}}</div>`
})
}
- 修改
server.js
文件
const Koa = require('koa')
const Router = require('koa-router')
const renderer = require('vue-server-renderer').createRenderer({
// 读取传入的template参数
template: require('fs').readFileSync('./src/index.template.html', 'utf-8')
})
const app = new Koa()
const router = new Router()
const createApp = require('./src/app')
router.get('/(.*)', async (ctx, next) => {
const app = createApp(ctx)
const context = {
title: 'vue服务器渲染组件',
meta: `
<meta charset="utf-8">
<meta name="" content="vue服务器渲染组件">
`
}
try {
// 传入context渲染上下文对象
const html = await renderer.renderToString(app, context)
ctx.status = 200
ctx.body = html
} catch(e) {
ctx.status = 500
ctx.body = '服务器错误'
}
})
app.use(router.routes()).use(router.allowedMethods())
app.listen(3000, () => {
console.log(`server started at localhost:3000`)
})
路由实现和代码分割
以上只是使用 node server.js
运行服务器端的启动程序,然后进行服务器端渲染页面,但是我们并没有将相同的vue
代码提供给客户端
要实现这一点的话,我们需要在项目中引用我们的webpack来
打包我们的应用程序
使用vue-router
引入前端路由
目录结构如下
demo
├── build
│ ├── webpack.base.conf.js # webpack 基本配置
│ ├── webpack.client.conf.js # 客户端打包配置
│ ├── webpack.server.conf.js # 服务器端打包配置
├── src
│ ├── components # 存放所有的vue页面,当然我们这边也可以新建文件夹分模块
│ | ├── home.vue
│ | ├── item.vue
│ ├── app.js # 创建每一个实列文件
│ ├── App.vue
│ ├── entry-client.js # 挂载客户端应用程序
│ ├── entry-server.js # 挂载服务器端应用程序
│ ├── index.template.html # 页面模板html文件
│ ├── router.js # 所有的路由
├── server.js # 服务相关的代码
└── package.json # 依赖的包文件
- 添加
src/components/home.vue
文件
<template>
<h1> home </h1>
</template>
- 添加
src/components/item.vue
文件
<template>
<h1> item </h1>
</template>
- 添加
src/App.vue
文件
<template>
<div id="app">
<router-view></router-view>
<h1>{{ msg }}</h1>
<input type="text" v-model="msg" />
</div>
</template>
export default {
name: 'app',
data() {
return {
msg: '欢迎光临vue.js App'
}
}
}
- 添加
src/router.js
路由文件,导出一个createRouter()
函数
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export function createRouter() {
return new Router({
mode: 'history',
routes: [
{
path: '/home',
component: resolve => require(['./components/home'], resolve)
},
{
path: '/item',
component: resolve => require(['./components/item'], resolve)
},
{
path: '*',
component: resolve => require(['./components/home'], resolve)
}
]
})
}
- 修改
src/app.js
文件,先简单导出一个createApp()
函数
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
export function createApp() {
// 创建router实例
const router = createRouter()
const app = new Vue({
// 注入router到根vue实例中
router,
// 根据实例简单的渲染应用程序组件
render: h => h(App)
})
return { app, router }
}
- 添加
src/entry-client.js
,创建应用程序,并且将其挂载到DOM中
import { createApp } from './app'
import Vue from 'vue'
const { app, router } = createApp()
// 假设App.vue模板中根元素 id = 'app'
router.onReady(() => {
app.$mount('#app')
})
- 添加
src/entry-server.js
import { createApp } from './app'
export default context => {
// const { app } = createApp()
// return app
/*
由于 路由钩子函数或组件 有可能是异步的,比如 同步的路由是这样引入 import Foo from './Foo.vue'
但是异步的路由是这样引入的:
{
path: '/index',
component: resolve => require(['./views/index'], resolve)
}
如上是 require动态加载进来的,因此我们这边需要返回一个promise对象。以便服务器能够等待所有的内容在渲染前
就已经准备好就绪。
*/
return new Promise((resolve, reject) => {
const { app, router } = 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({ code: 404 })
}
// 正常的情况
resolve(app)
}, reject)
}).catch(new Function())
}
- 添加
build/webpack.base.config.js
const path = require('path')
// vue-loader v15版本需要引入此插件
const VueLoaderPlugin = require('vue-loader/lib/plugin')
// 用于返回文件相对于根目录的绝对路径
const resolve = dir => path.join(__dirname, '..', dir)
// 是否是开发环境
const isDev = process.env.NODE_ENV === 'development'
module.exports = {
// 入口暂定客户端入口,服务端配置需要更改它
entry: resolve('src/entry-client.js'),
// 生成文件路径、名字、引入公共路径
output: {
path: resolve('dist'),
filename: '[name].js',
publicPath: '/'
},
// 开发环境devtool 我们可以使用cheap-module-eval-source-map编译会更快,css样式没有必要打包单独文件。
devtool: isDev ? 'cheap-module-eval-source-map' : false,
resolve: {
// 对于.js、.vue引入不需要写后缀
extensions: ['.js', '.vue'],
// 引入components、assets可以简写,可根据需要自行更改
alias: {
'components': resolve('src/components'),
'assets': resolve('src/assets')
}
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
// 配置哪些引入路径按照模块方式查找
transformAssetUrls: {
video: ['src', 'poster'],
source: 'src',
img: 'src',
image: 'xlink:href'
}
}
},
{
test: /\.js$/, // 利用babel-loader编译js,使用更高的特性,排除npm下载的.vue组件
loader: 'babel-loader',
exclude: file => (
/node_modules/.test(file) &&
!/\.vue\.js/.test(file)
)
},
{
test: /\.(png|jpe?g|gif|svg)$/, // 处理图片
use: [
{
loader: 'url-loader',
options: {
limit: 10000,
name: 'static/img/[name].[hash:7].[ext]'
}
}
]
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, // 处理字体
loader: 'url-loader',
options: {
limit: 10000,
name: 'static/fonts/[name].[hash:7].[ext]'
}
}
]
},
plugins: [
new VueLoaderPlugin()
]
}
- 添加
build/webpack.client.config.js
文件,对客户端代码进行打包
const path = require('path')
const webpack = require('webpack')
// 使用 webpack-merge 对 webpack.base.config.js 代码配置进行合并
const { merge } = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.config.js')
// css样式提取单独文件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// 服务端渲染用到的插件、默认生成JSON文件(vue-ssr-client-manifest.json)
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseWebpackConfig, {
mode: process.env.NODE_ENV || 'development',
output: {
// chunkhash是根据内容生成的hash, 易于缓存,
// 开发环境不需要生成hash,目前先不考虑开发环境
filename: 'static/js/[name].[chunkhash].js',
chunkFilename: 'static/js/[id].[chunkhash].js'
},
module: {
rules: [
{
test: /\.less?$/,
// 利用mini-css-extract-plugin提取css
// 开发环境不需要提取css单独文件
use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader']
},
]
},
devtool: false,
plugins: [
// webpack4.0版本以上采用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin
new MiniCssExtractPlugin({
filename: 'static/css/[name].[contenthash].css',
chunkFilename: 'static/css/[name].[contenthash].css'
}),
// 当vendor模块不再改变时, 根据模块的相对路径生成一个四位数的hash作为模块id
new webpack.HashedModuleIdsPlugin(),
new VueSSRClientPlugin()
]
})
- 修改
package.json
文件,添加scripts
{
// ...
"scripts": {
"build:client": "webpack --config ./build/webpack.client.config.js"
},
// ...
}
-
执行
npm run build:client
会进行打包。打包后对多生成一个文件vue-ssr-client-manifest.json
。这个文件就是用于客户端渲染的json
文件 -
添加
build/webpack.server.config.js
文件,打包服务器渲染文件
const path = require('path');
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const baseConfig = require('./webpack.base.config');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
module.exports = merge(baseConfig, {
entry: path.resolve(__dirname, '../src/entry-server.js'),
/*
允许webpack以Node适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
编译vue组件时,告知 vue-loader 输送面向服务器代码
*/
target: 'node',
devtool: 'source-map',
// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
output: {
libraryTarget: 'commonjs2',
filename: '[name].server.js'
},
/*
服务器端也需要编译样式,不能使用 mini-css-extract-plugin 插件
,因为该插件会使用document,但是服务器端并没有document, 因此会导致打包报错,我们可以如下的issues:
https://github.com/webpack-contrib/mini-css-extract-plugin/issues/48#issuecomment-375288454
注意:css-loader版本:1.0.0
*/
module: {
rules: [
{
test: /\.less?$/,
use: ['css-loader/locals', 'less-loader']
}
]
},
// https://webpack.js.org/configuration/externals/#function
// https://github.com/liady/webpack-node-externals
// 外置化应用程序依赖模块。可以使服务器构建速度更快,
// 并生成较小的 bundle 文件。
externals: nodeExternals({
// 不要外置化 webpack 需要处理的依赖模块。
// 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
allowlist: /\.css$/
}),
// 这是将服务器的整个输出
// 构建为单个 JSON 文件的插件。
// 默认文件名为 `vue-ssr-server-bundle.json`
plugins: [
new VueSSRServerPlugin()
]
});
- 修改
package.json
文件,添加scripts
{
// ...
"scripts": {
"build:client": "webpack --config ./build/webpack.client.config.js",
"build:server": "webpack --config ./build/webpack.server.config.js"
},
// ...
}
- 执行
npm run build:server
会在dist/
目录下生成vue-ssr-server-bundle.json
。这个文件就是用于服务器端渲染的json
文件
vue-ssr-client-manifest.json
和 vue-ssr-server-bundle.json
文件生成后,就可以编写server.js
来实现整个服务器端渲染流程了
- 修改
server.js
文件
const path = require('path')
const fs = require('fs')
const Koa = require('koa')
const Router = require('koa-router')
const static = require('koa-static')
const { createBundleRenderer } = require('vue-server-renderer')
const app = new Koa()
const router = new Router()
const template = fs.readFileSync('./src/index.template.html', 'utf-8')
// 引入客户端,服务端生成的json文件
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
// 推荐
runInNewContext: false,
// 页面模板
template: template,
// 客户端构建 manifest
clientManifest
})
// 静态资源目录对于相对入口文件 index.js 的路径
app.use(static(
path.join(__dirname, './dist')
))
const render = async (ctx, next) => {
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服务器渲染组件">
`
}
try {
const html = await renderer.renderToString(context)
ctx.status = 200
ctx.body = 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`)
})
- 修改
package.json
文件,添加scripts
{
// ...
"scripts": {
"build:client": "webpack --config ./build/webpack.client.config.js",
"build:server": "webpack --config ./build/webpack.server.config.js",
"start": "node server.js"
},
// ...
}
- 执行
npm run start
。即可启动服务。访问http://localhost:3000/home
或者http://localhost:3000/item
即可看到效果
每次打包都需要先npm run build:client
,然后再npm run build:server
。修改下package.json
合并成一个命令npm run build
{
// ...
"scripts": {
"build:client": "webpack --config ./build/webpack.client.config.js",
"build:server": "webpack --config ./build/webpack.server.config.js",
"build": "npm run build:client && npm run build:server",
"start": "node server.js"
},
// ...
}
开发环境配置
开发环境下,每次修改都需要先npm run build
然后再npm run start
,比较麻烦。
配置一个开发环境,当修改vue
代码时,自动打包客户端和服务器端代码、重新进行BundleRenderr.renderToString()
方法、重启server.js
代码中的服务
实现
- 通过
process.env.NODE_ENV
来区分是开发环境还是线上环境- 使用
koa-webpack-dev-middleware
,通过传入webpack
编译好的compiler
实现热加载,也就是说可以监听文件的变化,从而进行刷新网页- 使用
koa-webpack-hot-middleware
实现模块热替换操作
- 修改
package.json
配置
{
// ...
"scripts": {
"build:client": "webpack --config ./build/webpack.client.config.js",
"build:server": "webpack --config ./build/webpack.server.config.js",
"build": "npm run build:client && npm run build:server",
"start": "cross-env NODE_ENV=production node server.js",
"dev": "cross-env NODE_ENV=development node server.js"
},
// ...
}
- 修改
server.js
文件
const path = require('path')
const fs = require('fs')
const Koa = require('koa')
const Router = require('koa-router')
const static = require('koa-static')
const { createBundleRenderer } = require('vue-server-renderer')
// 动态监听文件发生改变的配置文件
const devConfig = require('./build/dev.config.js')
const isDev = process.env.NODE_ENV === 'development'
// 设置renderer为全局变量,根据环境变量赋值
let renderer
const app = new Koa()
const router = new Router()
// 静态资源目录对于相对入口文件 index.js 的路径
!isDev && app.use(static(
path.join(__dirname, './dist')
))
// 下面我们根据环境变量来生成不同的 BundleRenderer 实例
if (!isDev) {
// 正式环境
const template = fs.readFileSync('./src/index.template.html', 'utf-8')
// 引入客户端,服务端生成的json文件
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createBundleRenderer(serverBundle, {
// 推荐
runInNewContext: false,
// 页面模板
template: template,
// 客户端构建 manifest
clientManifest
})
} else {
// 开发环境
const template = path.resolve(__dirname, './src/index.template.html')
devConfig(app, template, (bundle, options) => {
console.log('开发环境重新打包......')
const option = Object.assign({
runInNewContext: false
}, options)
renderer = createBundleRenderer(bundle, option)
})
}
const render = async (ctx, next) => {
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服务器渲染组件">
`
}
try {
const html = await renderer.renderToString(context)
ctx.status = 200
ctx.body = 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`)
})
- 添加
build/dev.config.js
文件
const path = require('path')
const fs = require('fs')
// memory-fs 可以是webpack将文件写入到内存中,而不是写入到磁盘
const MFS = require('memory-fs')
const webpack = require('webpack')
// 监听文件变化,兼容性更好(比fs.watch、fs.watchFile、fsevents)
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')
// webpack热加载需要
const webpackDevMiddleware = require('koa-webpack-dev-middleware')
// 配合热加载实现模块热替换
const webpackHotMiddleware = require('koa-webpack-hot-middleware')
// 读取vue-ssr-webpack-plugin生成的文件
const readFile = (fs, file) => {
try {
return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
} catch (e) {
console.log('读取文件错误:', e)
}
}
module.exports = function devConfig(app, templatePath, cb) {
let bundle
let template
let clientManifest
// 监听改变后更新函数
const update = () => {
if (bundle && clientManifest) {
cb(bundle, {
template,
clientManifest
})
}
}
// 监听html模板改变,需手动刷新
template = fs.readFileSync(templatePath, 'utf-8')
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
update()
})
// 修改webpack入口配合模块热替换使用
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
// 编译clientWebpack插入Koa中间件
const clientCompiler = webpack(clientConfig)
const devMiddleware = webpackDevMiddleware(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
})
app.use(devMiddleware)
clientCompiler.plugin('done', stats => {
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
if (stats.errors.length) return
clientManifest = JSON.parse(readFile(
devMiddleware.fileSystem,
'vue-ssr-client-manifest.json'
))
update()
})
// 插入Koa中间件(模块热替换)
app.use(webpackHotMiddleware(clientCompiler))
const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
if (stats.errors.length) return
// vue-ssr-webpack-plugin 生成的bundle
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
update()
})
}
- 修改
build/webpack.client.config.js
文件,开发环境下不使用mini-css-extract-plugin
提取css
// ...
// 是否是生产环境
const isDev = process.env.NODE_ENV === 'development'
module.exports = merge(baseWebpackConfig, {
// ...
module: {
rules: [
{
test: /\.less?$/,
// 利用mini-css-extract-plugin提取css
// 开发环境不需要提取css单独文件
use: !isDev
? [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader']
: ['vue-style-loader', 'css-loader', 'less-loader']
},
// ...
]
},
// ...
})
- 开发环境下使用命令
npm run dev
数据预获取和状态
在服务器端渲染(SSR
)期间,比如说我们的应用程序有异步请求,在服务器端渲染之前,我们希望先返回异步数据后,我们再进行SSR
渲染,因此我们需要的是先预取和解析好这些数据。
并且在客户端,在挂载(mount
)到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据。否则的话,客户端应用程序会因为使用与服务器端应用程序不同的状态。会导致混合失败。
使用官方状态管理库Vuex
存储这些数据
在路由组件上暴露出一个自定义静态函数 asyncData
,用来获取该组件需要的数据
备注:静态函数 asyncData
会在组件实例化之前调用,所以它无法访问this
。需要将 store
和路由信息作为参数传递进去
- 新建
src/api/index.js
文件,来模拟一个异步方法
export function fetchItem(id) {
return Promise.resolve({
text: 'wmm66'
})
}
- 新建
src/store/index.js
文件
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
// 假定我们有一个可以返回 Promise 的
import { fetchItem } from '../api/index';
export function createStore() {
return new Vuex.Store({
state: {
items: {}
},
actions: {
fetchItem({ commit }, id) {
// `store.dispatch()` 会返回 Promise,
// 以便我们能够知道数据在何时更新
return fetchItem(id).then(item => {
commit('setItem', { id, item });
});
}
},
mutations: {
setItem(state, { id, item }) {
state.items[id] = item
// Vue.set(state.items, id, item);
}
}
});
}
- 修改
src/app.js
文件
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store/index'
// 将 vue-router 和 vuex 结合在一起,实现应用的路由状态管理
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到根vue实例中
router,
store,
// 根据实例简单的渲染应用程序组件
render: h => h(App)
})
return { app, router, store }
}
- 修改
src/components/item.vue
文件,添加asyncData
<template>
<div>
<h1> item </h1>
<p>{{ item.text }}</p>
</div>
</template>
export default {
asyncData({ store, route }) {
// 触发action代码,会返回Promise
return store.dispatch('fetchItem', 'name')
},
computed: {
// 从store的state对象中获取item
item() {
return this.$store.state.items['name']
}
}
}
- 修改
src/entry-server.js
文件
import { createApp } from './app'
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({ 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 Function())
}
- 修改
src/entry-client.js
文件
import { createApp } from './app'
import Vue from 'vue'
// 给客户端加全局的mixin
// 实现切换页面之前,执行新页面的asyncData
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')
})
注入不同的Head
服务器端渲染的时候,我们会根据不同的页面会有不同的meta
或title
这里实现一个简单的title
注入
- 修改
index.template.html
模板文件,定义<title>{{ title }}</title>
注意:
- 使用双花括号进行
HTML
转义插值,以避免XSS
攻击。- 应该在创建
context
对象时提供一个默认标题,以防在渲染过程中组件没有设置标题。
- 新建
src/mixins/title-mixin.js
文件
function getTitle(vm) {
// 组件可以提供一个 `title` 选项
// 此选项可以是一个字符串或函数
const { title } = vm.$options
if (title) {
return typeof title === 'function' ? title.call(vm) : title
} else {
return 'Vue SSR demo'
}
}
const serverTitleMixin = {
created() {
const title = getTitle(this)
if (title && this.$ssrContext) {
this.$ssrContext.title = title
}
}
}
const clientTitleMixin = {
mounted() {
const title = getTitle(this)
if (title) {
document.title = title
}
}
}
// 我们通过 'webpack.DefinePlugin' 注入 'VUE_ENV'
export default process.env.VUE_ENV === 'server' ? serverTitleMixin : clientTitleMixin
- 修改
build/webpack.server.config.js
文件,添加process.env.VUE_ENV
// ...
module.exports = merge(baseConfig, {
// ...
plugins: [
new webpack.DefinePlugin({
'process.env.VUE_ENV': '"server"'
}),
// ...
]
});
- 修改
src/components/item.vue
文件
import titleMixin from '../mixins/title-mixins'
export default {
mixins: [titleMixin],
title() {
return 'item页面'
},
// ...
}
页面级别缓存
可以使用micro-caching
的缓存策略,来大幅提高应用程序处理高流量的能力。
这通常在 Nginx
层完成,但是我们也可以在 Node.js
中实现
- 修改
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 LRU = require('lru-cache')
const { createBundleRenderer } = require('vue-server-renderer')
// 动态监听文件发生改变的配置文件
const devConfig = require('./build/dev.config.js')
const isDev = process.env.NODE_ENV === 'development'
// 缓存
const microCache = new LRU({
max: 100,
// 在1分钟后过期
maxAge: 1000 * 60
})
const isCacheable = ctx => {
// 假如 item 页面进行缓存
if (ctx.url === '/item') {
return true
}
return false
}
// 设置renderer为全局变量,根据环境变量赋值
let renderer
const app = new Koa()
const router = new Router()
// 静态资源目录对于相对入口文件 index.js 的路径
!isDev && app.use(static(
path.join(__dirname, './dist')
))
// 下面我们根据环境变量来生成不同的 BundleRenderer 实例
if (!isDev) {
// 正式环境
const template = fs.readFileSync('./src/index.template.html', 'utf-8')
// 引入客户端,服务端生成的json文件
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createBundleRenderer(serverBundle, {
// 推荐
runInNewContext: false,
// 页面模板
template: template,
// 客户端构建 manifest
clientManifest
})
} else {
// 开发环境
const template = path.resolve(__dirname, './src/index.template.html')
devConfig(app, template, (bundle, options) => {
console.log('开发环境重新打包......')
const option = Object.assign({
runInNewContext: false
}, options)
renderer = createBundleRenderer(bundle, option)
})
}
const render = async (ctx, next) => {
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`)
})
发表评论