倡议先浏览官网指南——Vue.js 服务器端渲染指南,再回到本文开始浏览。
本文将分成以下两局部:
- 简述 Vue SSR 过程
- 从零开始搭建 SSR 我的项目
好了,上面开始注释。
简述 Vue SSR 过程
客户端渲染过程
- 拜访客户端渲染的网站。
- 服务器返回一个蕴含了引入资源语句和
<div id="app"></div>
的 HTML 文件。 - 客户端通过 HTTP 向服务器申请资源,当必要的资源都加载结束后,执行
new Vue()
开始实例化并渲染页面。
服务端渲染过程
- 拜访服务端渲染的网站。
- 服务器会查看以后路由组件须要哪些资源文件,而后将这些文件的内容填充到 HTML 文件。如果有
asyncData()
函数,就会执行它进行数据预取并填充到 HTML 文件里,最初返回这个 HTML 页面。 - 当客户端接管到这个 HTML 页面时,能够马上就开始渲染页面。与此同时,页面也会加载资源,当必要的资源都加载结束后,开始执行
new Vue()
开始实例化并接管页面。
从上述两个过程中,能够看出,区别就在于第二步。客户端渲染的网站会间接返回 HTML 文件,而服务端渲染的网站则会渲染完页面再返回这个 HTML 文件。
这样做的益处是什么?是更快的内容达到工夫 (time-to-content)。
假如你的网站须要加载完 abcd 四个文件能力渲染结束。并且每个文件大小为 1 M。
这样一算:客户端渲染的网站须要加载 4 个文件和 HTML 文件能力实现首页渲染,总计大小为 4M(疏忽 HTML 文件大小)。而服务端渲染的网站只须要加载一个渲染结束的 HTML 文件就能实现首页渲染,总计大小为曾经渲染结束的 HTML 文件(这种文件不会太大,个别为几百 K,我的集体博客网站(SSR)加载的 HTML 文件为 400K)。这就是服务端渲染更快的起因。
客户端接管页面
对于服务端返回来的 HTML 文件,客户端必须进行接管,对其进行 new Vue()
实例化,用户能力失常应用页面。
如果不对其进行激活的话,外面的内容只是一串字符串而已,例如上面的代码,点击是有效的:
<button @click="sayHi"> 如果不进行激活,点我是不会触发事件的 </button>
那客户端如何接管页面呢?上面援用一篇文章中的内容:
客户端 new Vue() 时,客户端会和服务端生成的 DOM 进行 Hydration 比照(判断这个 DOM 和本人行将生成的 DOM 是否雷同(vuex store 数据同步能力保持一致)
如果雷同就调用 app.$mount(‘#app’)将客户端的 vue 实例挂载到这个 DOM 上,即去“激活”这些服务端渲染的 HTML 之后,其变成了由 Vue 动静治理的 DOM,以便响应后续数据的变动,即之后所有的交互和 vue-router 不同页面之间的跳转将全副在浏览器端运行。
如果客户端构建的虚构 DOM 树与服务器渲染返回的 HTML 构造不统一,这时候,客户端会申请一次服务器再渲染整个应用程序,这使得 ssr 生效了,达不到服务端渲染的目标了
小结
不论是客户端渲染还是服务端渲染,都须要期待客户端执行 new Vue()
之后,用户能力进行交互操作。但服务端渲染的网站能让用户更快的看见页面。
从零开始搭建 SSR 我的项目
配置 weback
webpack 配置文件共有 3 个:
webpack.base.config.js
,根底配置文件,客户端与服务端都须要它。webpack.client.config.js
,客户端配置文件,用于生成客户端所需的资源。webpack.server.config.js
,服务端配置文件,用于生成服务端所需的资源。
webpack.base.config.js
根底配置文件
const path = require('path')
const {VueLoaderPlugin} = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'
function resolve(dir) {return path.join(__dirname, '..', dir)
}
module.exports = {context: path.resolve(__dirname, '../'),
devtool: isProd ? 'source-map' : '#cheap-module-source-map',
output: {path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
// chunkhash 同属一个 chunk 中的文件批改了,文件名会发生变化
// contenthash 只有文件本人的内容变动了,文件名才会变动
filename: '[name].[contenthash].js',
// 此选项给打包后的非入口 js 文件命名,与 SplitChunksPlugin 配合应用
chunkFilename: '[name].[contenthash].js',
},
resolve: {extensions: ['.js', '.vue', '.json', '.css'],
alias: {public: resolve('public'),
'@': resolve('src')
}
},
module: {
// https://juejin.im/post/6844903689103081485
// 应用 `mini-css-extract-plugin` 插件打包的的 `server bundle` 会应用到 document。// 因为 node 环境中不存在 document 对象,所以报错。// 解决方案:款式相干的 loader 不要放在 `webpack.base.config.js` 文件
// 将其分拆到 `webpack.client.config.js` 和 `webpack.client.server.js` 文件
// 其中 `mini-css-extract-plugin` 插件要放在 `webpack.client.config.js` 文件配置。rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {preserveWhitespace: false}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{test: /\.(png|svg|jpg|gif|ico)$/,
use: ['file-loader']
},
{test: /\.(woff|eot|ttf)\??.*$/,
loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
},
]
},
plugins: [new VueLoaderPlugin()],
}
根底配置文件比较简单,output
属性的意思是打包时依据文件内容生成文件名称。module
属性配置不同文件的解析 loader。
webpack.client.config.js
客户端配置文件
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const CompressionPlugin = require('compression-webpack-plugin')
const WebpackBar = require('webpackbar')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const isProd = process.env.NODE_ENV === 'production'
const plugins = [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
new VueSSRClientPlugin(),
new MiniCssExtractPlugin({filename: 'style.css'})
]
if (isProd) {
plugins.push(
// 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
new CompressionPlugin(),
// 该插件会依据模块的相对路径生成一个四位数的 hash 作为模块 id, 用于生产环境。new webpack.HashedModuleIdsPlugin(),
new WebpackBar(),)
}
const config = {
entry: {app: './src/entry-client.js'},
plugins,
optimization: {
runtimeChunk: {name: 'manifest'},
splitChunks: {
cacheGroups: {
vendor: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial',
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
},
}
},
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {// 解决 export 'default' (imported as 'mod') was not found
// 启用 CommonJS 语法
esModule: false,
},
},
'css-loader'
]
}
]
},
}
if (isProd) {
// 压缩 css
config.optimization.minimizer = [new CssMinimizerPlugin(),
]
}
module.exports = merge(base, config)
客户端配置文件中的 config.optimization
属性是打包时宰割代码用的。它的作用是将第三方库都打包在一起。
其余插件作用:
MiniCssExtractPlugin
插件, 将 css 提取进去独自打包。CssMinimizerPlugin
插件,压缩 css。CompressionPlugin
插件,将资源压缩成 gzip 格局(大大晋升传输效率)。另外还须要在 node 服务器上引入compression
插件配合应用。WebpackBar
插件,打包时显示进度条。
webpack.server.config.js
服务端配置文件
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const WebpackBar = require('webpackbar')
const plugins = [
new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()]
if (process.env.NODE_ENV == 'production') {
plugins.push(new WebpackBar()
)
}
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
externals: nodeExternals({allowlist: /\.css$/ // 避免将某些 import 的包 (package) 打包到 bundle 中,而是在运行时 (runtime) 再去从内部获取这些扩大依赖
}),
plugins,
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
}
]
},
})
服务端打包和客户端不同,它将所有文件一起打包成一个文件 server-bundle.js
。同时解析 css 须要应用 vue-style-loader
,这一点在官网指南中有阐明:
配置服务器
生产环境
pro-server.js
生产环境服务器配置文件
const fs = require('fs')
const path = require('path')
const express = require('express')
const setApi = require('./api')
const LRU = require('lru-cache') // 缓存
const {createBundleRenderer} = require('vue-server-renderer')
const favicon = require('serve-favicon')
const resolve = file => path.resolve(__dirname, file)
const app = express()
// 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
const compression = require('compression')
app.use(compression())
// 设置 favicon
app.use(favicon(resolve('../public/favicon.ico')))
// 新版本 须要加 new,旧版本不必
const microCache = new LRU({
max: 100,
maxAge: 60 * 60 * 24 * 1000 // 重要提醒:缓存资源将在 1 天后过期。})
const serve = (path) => {return express.static(resolve(path), {maxAge: 1000 * 60 * 60 * 24 * 30})
}
app.use('/dist', serve('../dist', true))
function createRenderer(bundle, options) {
return createBundleRenderer(
bundle,
Object.assign(options, {basedir: resolve('../dist'),
runInNewContext: false
})
)
}
function render(req, res) {const hit = microCache.get(req.url)
if (hit) {console.log('Response from cache')
return res.end(hit)
}
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.log(err)
}
}
const context = {
title: 'SSR 测试', // default title
url: req.url
}
renderer.renderToString(context, (err, html) => {if (err) {return handleError(err)
}
microCache.set(req.url, html)
res.send(html)
})
}
const templatePath = resolve('../public/index.template.html')
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json') // 将 js 文件注入到页面中
const renderer = createRenderer(bundle, {
template,
clientManifest
})
const port = 8080
app.listen(port, () => {console.log(`server started at localhost:${ port}`)
})
setApi(app)
app.get('*', render)
从代码中能够看到,当首次加载页面时,须要调用 createBundleRenderer()
生成一个 renderer,它的参数是打包生成的 vue-ssr-server-bundle.json
和 vue-ssr-client-manifest.json
文件。当返回 HTML 文件后,页面将会被客户端接管。
在文件的最初有一行代码 app.get('*', render)
,它示意所有匹配不到的申请都交给它解决。所以如果你写了 ajax 申请处理函数必须放在后面,就像上面这样:
app.get('/fetchData', (req, res) => {...})
app.post('/changeData', (req, res) => {...})
app.get('*', render)
否则你的页面会打不开。
开发环境
开发环境的服务器配置和生产环境没什么不同,区别在于开发环境下的服务器有热更新。
个别用 webpack 进行开发时,简略的配置一下 dev server 参数就能够应用热更新了,然而 SSR 我的项目须要本人配置。
因为 SSR 开发环境服务器的配置文件 setup-dev-server.js
代码太多,我对其进行简化后,大抵代码如下:
// dev-server.js
const express = require('express')
const webpack = require('webpack')
const webpackConfig = require('../build/webpack.dev') // 获取 webpack 配置文件
const compiler = webpack(webpackConfig)
const app = express()
app.use(require('webpack-hot-middleware')(compiler))
app.use(require('webpack-dev-middleware')(compiler, {
noInfo: true,
stats: {colors: true}
}))
同时须要在 webpack 的入口文件加上这一行代码 webpack-hot-middleware/client?reload=true
。
// webpack.dev.js
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base.config.js') // 这个配置和热更新无关,可疏忽
module.exports = merge(webpackBaseConfig, {
mode: 'development',
entry: {app: ['webpack-hot-middleware/client?reload=true' , './client/main.js'] // 开启热模块更新
},
plugins: [new webpack.HotModuleReplacementPlugin()]
})
而后应用 node dev-server.js
来开启前端代码热更新。
热更新次要应用了两个插件:webpack-dev-middleware
和 webpack-hot-middleware
。顾名思义,看名称就晓得它们的作用,
webpack-dev-middleware
的作用是生成一个与 webpack 的 compiler 绑定的中间件,而后在 express 启动的 app 中调用这个中间件。
这个中间件的作用呢,简略总结为以下三点:通过 watch mode,监听资源的变更,而后主动打包; 疾速编译,走内存;返回中间件,反对 express 的 use 格局。
webpack-hot-middleware
插件的作用就是热更新,它须要配合 HotModuleReplacementPlugin
和 webpack-dev-middleware
一起应用。
打包文件 vue-ssr-client-manifest.json
和 vue-ssr-server-bundle.json
webpack 须要对源码打包两次,一次是为客户端环境打包的,一次是为服务端环境打包的。
为客户端环境打包的文件,和以前咱们打包的资源一样,不过多出了一个 vue-ssr-client-manifest.json
文件。服务端环境打包只输入一个 vue-ssr-server-bundle.json
文件。
vue-ssr-client-manifest.json
蕴含了客户端环境所需的资源名称:
从上图中能够看到有三个关键词:
- all,示意这是打包的所有资源。
- initial,示意首页加载必须的资源。
- async,示意须要异步加载的资源。
vue-ssr-server-bundle.json
文件:
- entry,服务端入口文件。
- files,服务端依赖的资源。
填坑记录
1. [vue-router] failed to resolve async component default: referenceerror: window is not defined
因为在一些文件或第三方文件中可能会用到 window 对象,并且 node 中不存在 window 对象,所以会报错。
此时可在 src/app.js
文件加上以下代码进行判断:
// 在 app.js 文件增加上这段代码,对环境进行判断
if (typeof window === 'undefined') {global.window = {}
}
2. mini-css-extract-plugin
插件造成 ReferenceError: document is not defined
应用 mini-css-extract-plugin
插件打包的的 server bundle
,会应用到 document。因为 node 环境中不存在 document 对象,所以报错。
解决方案:款式相干的 loader 不要放在 webpack.base.config.js
文件,将其分拆到 webpack.client.config.js
和 webpack.client.server.js
文件。其中 mini-css-extract-plugin
插件要放在 webpack.client.config.js
文件配置。
base
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {preserveWhitespace: false}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{test: /\.(png|svg|jpg|gif|ico)$/,
use: ['file-loader']
},
{test: /\.(woff|eot|ttf)\??.*$/,
loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
},
]
}
client
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {// 解决 export 'default' (imported as 'mod') was not found
esModule: false,
},
},
'css-loader'
]
}
]
}
server
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
}
]
}
3. 开发环境下跳转页面款式不失效,但生产环境失常。
因为开发环境应用的是 memory-fs
插件,打包文件是放在内存中的。如果此时 dist
文件夹有方才打包留下的资源,就会应用 dist
文件夹中的资源,而不是内存中的资源。并且开发环境和打包环境生成的资源名称是不一样的,所以就造成了这个 BUG。
解决办法是执行 npm run dev
时,删除 dist
文件夹。所以要在 npm run dev
对应的脚本中加上 rimraf dist
。
"dev": "rimraf dist && node ./server/dev-server.js --mode development",
4. [vue-router] Failed to resolve async component default: ReferenceError: document is not defined
不要在有可能应用到服务端渲染的页面拜访 DOM,如果有这种操作请放在 mounted()
钩子函数里。
如果你引入的数据或者接口有拜访 DOM 的操作也会报这种错,在这种状况下能够应用 require()
。因为 require()
是运行时加载的,所以能够这样应用:
<script>
// 原来报错的操作,这个接口有 DOM 操作,所以这样应用的时候在服务端会报错。import {fetchArticles} from '@/api/client'
export default {
methods: {getAppointArticles() {
fetchArticles({
tags: this.tags,
pageSize: this.pageSize,
pageIndex: this.pageIndex,
})
.then(res => {this.$store.commit('setArticles', res)
})
},
}
}
</script>
批改后:
<script>
// 先定义一个内部变量,在 mounted() 钩子里赋值
let fetchArticles
export default {mounted() {// 因为服务端渲染不会有 mounted() 钩子,所以在这里能够保障是在客户端的状况下引入接口
fetchArticles = require('@/api/client').fetchArticles
},
methods: {getAppointArticles() {
fetchArticles({
tags: this.tags,
pageSize: this.pageSize,
pageIndex: this.pageIndex,
})
.then(res => {this.$store.commit('setArticles', res)
})
},
}
}
</script>
批改后能够失常应用。
5. 开发环境下,开启服务器后无任何反馈,也没见控制台输入报错信息。
这个坑其实是有报错信息的,然而没有输入,导致认为没有谬误。
在 setup-dev-server.js
文件中有一行代码 if (stats.errors.length) return
,如果有报错就间接返回,不执行后续的操作。导致服务器没任何反馈,所以咱们能够在这打一个 console.log
语句,打印报错信息。
小结
这个 DEMO 是基于官网 DEMO vue-hackernews-2.0 革新的。不过官网 DEMO 发表于 4 年前,最近批改工夫是 2 年前,很多选项参数曾经过期了。并且官网 DEMO 须要翻墙能力应用。所以我在此基础上对其进行了革新,革新后的 DEMO 放在 Github 上,它是一个比较完善的 DEMO,能够在此基础上进行二次开发。
如果你不仅仅满足于一个 DEMO,倡议看一看我的集体博客我的项目,它原来是客户端渲染的我的项目,起初重构为服务端渲染,相对实战。
参考资料
- Vue.js 服务器端渲染指南
- vue-ssr 服务端渲染透析
- webpack-dev-middleware 详解