建议先阅读官方指南——SSR.vuejs.org/zh/" _fcksavedurl="https://SSR.vuejs.org/zh/">Vue.js 服务器端渲染指南,再回到本文开始阅读。
本文将分成以下两部分:
- 简述 Vue SSR 过程
- 从零开始搭建 SSR 项目
好了,下面开始正文。
简述 Vue SSR 过程
客户端渲染过程
- 访问客户端渲染的网站。
- 服务器返回一个包含了引入资源语句和 <div id="app"></div> 的 HTML 文件。
- 客户端通过 HTTP 向服务器请求资源,当必要的资源都加载完毕后,执行 new Vue() 开始实例化并渲染页面。
服务端渲染过程
- 访问服务端渲染的网站。
- 服务器会查看当前路由组件需要哪些资源文件,然后将这些文件的内容填充到 HTML 文件。如果有 asyncData() 函数,就会执行它进行数据预取并填充到 HTML 文件里,最后返回这个 HTML 页面。
3. 当客户端接收到这个 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 放在 SSR-demo" _fcksavedurl="https://github.com/woai3c/vue-SSR-demo">Github 上,它是一个比较完善的 DEMO,可以在此基础上进行二次开发。
如果你不仅仅满足于一个 DEMO,建议看一看我的个人博客项目,它原来是客户端渲染的项目,后来重构为服务端渲染,绝对实战。