Vue项目添加SSR
前面我们介绍完怎么将一个简单的Vue
实例进行渲染给客户端之后,接下来我们要继续深入了,毕竟我们实际项目中,应该不存在单纯这么简单的业务代码吧。
# 涉及技术栈
node.js
Vue
express
webpack
# 整体架构
# 准备工作
在正式开始介绍如何配合现有Vue
项目实现SSR
之前,我们先初始化一个简易的Vue
项目。你可以使用Vue-cli
创建一个简易项目,也可以跟着笔者直接自己借鉴官方项目结构创建一个简单的项目。
当然也可直接clone
笔者的演示项目(建议):项目地址
# 项目目录
首先新建一个文件夹,作为我们整个项目的根目录,接着在命令行中输入npm init -y
,初始化npm
,然后按照按照如下目录结构创建对应的文件,创建时我们先不用关心各个文件中内容是什么,后面将分逐一进行讲解。
├─.babelrc
├─entry-client.js
├─entry-server.js
├─index.html
├─package.json
├─server.js
├─src
| ├─app.js
| ├─App.vue
| ├─store
| | ├─actions.js
| | ├─index.js
| | └mutations.js
| ├─router
| | └index.js
| ├─components
| | ├─Foo.vue
| | └Home.vue
├─build
| ├─webpack.base.config.js
| ├─webpack.client.config.js
| └webpack.server.config.js
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 前端部分
这里主要写了两个页面,一个首页一个额外的页面,内容也很简单,主要为了演示路由。
首页部分,笔者这里定义了一个asyncData
,用于暴露给服务端渲染时预取数据。
<template>
<div class=''>
<h1>{{title}}</h1>
这里是Home
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import * as actions from '@/store/actions';
export default {
name: 'Home',
computed: {
...mapGetters([
'title'
])
},
asyncData({store}) {
return store.dispatch({
type: actions.FETCH_TITLE
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
笔者在这个asyncData
函数中分发了一个action
用于异步获取数据,该函数只会在服务端执行,在服务端渲染的时候会去调用这个函数预取数据,我们可以大概看一下这个异步action
具体实现:
import * as mutations from './mutations';
export const FETCH_TITLE = 'FETCH_TITLE'
export default {
[FETCH_TITLE]({commit}) {
return new Promise(resolve => {
setTimeout(() => {
commit(mutations.SET_TITLE, '这里是服务端渲染的title')
resolve();
}, 3000)
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
这里直接就用延时函数延时了三秒模拟异步请求,所以我们要等三秒之后才能看到页面中出现这里是服务端渲染的title
。当然也是因为这个因素,在进行服务端渲染的时候,我们访问页面首页路由的时候需要等待三秒,服务器才会响应页面,因为它需要做数据预取操作,这个过程完成之后才能渲染出完整页面。
也就是说如果你想要设定一些预取得数据,你可以定义一个
asyncData
用于满足需求。具体服务端渲染配合实现请接着往下看。
# webpack配置
如果想要对一个完整的Vue
项目添加SSR
,我们需要先对它进行打包,然后将结果作为我们服务器提供SSR
服务的依赖文件。
我们可以注意到,在上述的文件目录中,有一个build
,目录,它就是用来放置我们的webpack
相关配置的。这里我们可以再回过头回想一下前面放出来的官方SSR
整体的流程图,我们可以清晰的知道,我们在在配置webpack
客户端与服务端相关配置文件时,同时也需要创建对应的入口文件,也就上上述目录中的entry-client.js
与entry-server.js
。
# entry-client.js
客户端入口文件。
import createApp from '@/app.js';
const { app, store } = createApp();
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
app.$mount('#app');
2
3
4
5
6
7
8
9
这里导入的地方笔者用了
@
,这个是笔者配置了webpack
的别名,相当于根目录下src
目录,主要为了省略一点路径,也不用一层层找了。
从整体代码来看,笔者这里写的也比较简单了,主要功能就是挂载Vue
实例($mount
),在整个渲染过程中也叫做客户端激活,同时将服务端预取的数据保存到Vuex
中,这个过程主要通过调用replaceState
方式,将服务端挂载在 window
上的__INITIAL_STATE__
替换 store
的根状态。
这里的createApp
方法主要用于创建一个新的Vue
实例,并可以获取到挂载到Vue
实例上的VueRouter
或Vuex
实例对象,根据我们需要,去做一些初始化的操作。
这里说明一下为什么需要把这个操作抽离成一个单独的函数,因为对于服务端而言,如果不对每个用户创建一个全新的实例,那么就会出现多个请求共享一个实例的情况,这个时候就会很容易导致交叉请求状态污染,所以我们需要对每个请求创建一个新的实例。
# createApp
如果你是通过脚手架工具创建了一个新的项目,那么你需要将原有的src
目录下的index.js
改为app.js
,并暴露一个createApp
方法。
import Vue from 'vue';
import App from './App'
import VueRouter from 'vue-router';
import routes from './router'
import Vuex from 'vuex';
import storeConfig from './store';
Vue.use(VueRouter);
Vue.use(Vuex);
const store = new Vuex.Store(storeConfig);
const router = new VueRouter({
routes,
mode: 'history'
})
export default function createApp() {
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
从结构上看,和我们之前在普通Vue
项目中index.js
文件里编写的相关逻辑差别不大,都是进行根实例初始化的一些操作,唯一的区别是将原来直接new Vue(...)
这部分逻辑转移到createApp
这个函数当中提供给外部调用,用于产生新的实例,返回创建好的Vue
实例和VueRouter
、Vuex
实例等。
# entry-server.js
服务端入口文件。
import createApp from '@/app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp();
router.push(context.url);
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
if (!matchedComponents.length) reject({code: 404});
Promise.all(matchedComponents.map(component => {
return component.asyncData && component.asyncData({store, route: router.currentRoute})
})).then(() => {
context.state = store.state;
resolve(app);
}).catch(reject);
}, reject)
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
同样我们需要调用createApp
获取我们需要的实例对象,同时导出一个方法供服务端调用,并且该方法返回一个Promise
,因为我们需要执行一些异步操作,比如预取数据等操作。
方法接收一个context
参数,这里笔者主要用于获取当前请求的路由路径,并调用router
实例的push
方法,将路由置为用户当前请求的路由路径,同时,当路由跳转准备好之后,进行路由组件的匹配,获取到该路由下需要用到的组件列表,如果没有匹配到则直接返回404
,否则遍历所有组件,调用组件的asyncData
方法,把需要进行预取得数据准备好。
当所有组件的数据准备完毕之后,将当前store.state
挂载到context
的state
属性上,以便于在渲染模板时,在模板中添加一个script
标签,内容为:window.__INITIAL_STATE__= xxx
,这里的xxx
就是我们的store.state
,这样我们就能在客户端入口文件中通过这个window
上的属性,初始化客户端的state
。
也就是我们上面entry-client.js
中调用replaceState
部分要用到的数据。
# webpack.base.config.js
对于我们整个项目来说,服务端webpack
配置与客户端webpack
配置也会存在一些公共配置,所以我们可以将共有部分抽出来,作为基础配置,最后合并到特定端的配置中。
来看看都有啥:
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack')
module.exports = {
mode: 'development',
module: {
rules: [
{ test: /\.js$/, loader: 'babel-loader' },
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
{ test: /\.scss$/, loader: 'sass-loader' },
{ test: /\.vue$/, loader: 'vue-loader' },
]
},
output: {
path: path.resolve(__dirname, '../dist')
},
plugins: [
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css'
}),
new webpack.NoEmitOnErrorsPlugin()
],
stats: {
logging: 'none'
},
resolve: {
extensions: ['.vue', '.ts', '.js'],
alias: {
'@': path.resolve(__dirname, '../src')
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
这里对每个参数的含义就不进行过多介绍了,对于
webpack
配置项不太了解的可以参考相关中文文档。传送门:webpack中文网
# loader
这里主要用到了几个loader
,分别是:
babel-loader
:用来转换高级语法为低级语法,这里相关的配置笔者就将它写到.babelrc
文件中了,参见上述目录结构。- 具体内容如下:
{ "presets": ["@babel/preset-env"], "plugins": ["@babel/plugin-syntax-dynamic-import"] }
1
2
3
4
- 具体内容如下:
MiniCssExtractPlugin.loader
与css-loader
:处理css
相关内容(具体用法见官方文档)。sass-loader
:笔者在项目中比较喜欢使用scss
,所以这里添加了对scss
的处理。vue-loader
:对于Vue
项目来说,这个loader
应该还是很重要的吧,用来处理.vue
文件。
# plugins
VueLoaderPlugin
:必须的插件, 它的职责是将你定义过的其它规则复制并应用到.vue
文件里相应语言的块。例如,如果你有一条匹配/\.js$/
的规则,那么它会应用到 .vue 文件里的<script>
块。MiniCssExtractPlugin
:将CSS
提取到单独的文件中,为每个包含CSS
的JS
文件创建一个CSS
文件,并且支持CSS
和SourceMaps
的按需加载。NoEmitOnErrorsPlugin
:在编译出现错误时,使用NoEmitOnErrorsPlugin
来跳过输出阶段。
# 安装相关依赖
笔者比较喜爱使用yarn
进行包安装,你也可以采用npm
或cnpm
,只需要将下面的yarn add
改成npm i
即可。
yarn add vue-loader babel-loader mini-css-extract-plugin webpack@4 webpack-cli sass-loader @babel/preset-env @babel/plugin-syntax-dynamic-import vue-template-compiler css-loader -D
这里还是建议搭建直接克隆笔者的项目比较方便,万一依赖项笔者漏写了,估计你们要锤死我。
# webpack.client.config.js
客户端构建相关配置项:
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const webpackMerge = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const path = require('path')
const TerserPlugin = require("terser-webpack-plugin");
const webpack = require('webpack')
module.exports = webpackMerge.merge(baseConfig, {
entry: path.resolve(__dirname, '../entry-client.js'),
optimization: {
splitChunks: {
cacheGroups: {
common: {
minChunks: 2,
priority: -10,
reuseExistingChunk: true
}
}
},
minimize: true,
minimizer: [new TerserPlugin()]
},
plugins: [
new VueSSRClientPlugin()
]
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# plugins
这里基于基础配置上,添加了热替换的插件和Vue SSR
客户端构建插件,上面entry
部分的写法是为了给我们的项目添加热更新能力,这里主要需要配合webpack-hot-middleware
进行实现,具体配置方式可以参考官方文档:传送门。
这里笔者也配置了一下代码分割,将公共代码进行抽离,并改用terser-webpack-plugin
对代码进行压缩(webpack5
之后内置的,这里采用webpack4
作为演示)。
# 安装依赖
在安装完基础配置文件的依赖后,客户端相关配置也需要进行依赖安装:
yarn add webpack-merge terser-webpack-plugin webpack-hot-middleware -D
# webpack.server.config.js
同理,这里是服务端相关配置。
const webpackMerge = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const path = require('path');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const nodeExternals = require('webpack-node-externals')
module.exports = webpackMerge.merge(baseConfig, {
entry: path.resolve(__dirname, '../entry-server.js'),
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
target: 'node',
externals: nodeExternals({
allowlist: [/\.css$/]
}),
devtool: 'source-map',
plugins: [new VueSSRServerPlugin()]
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
对于服务端相关配置来说,我们这里就不需要配置热更新相关了,所以这里只用到了一个官方提供用来构建服务端配置的插件server-plugin
,然后我们这里配置了externals
,对于服务端来说,它无法处理css
相关逻辑,所以我们这里直接给他忽略一下。同时,这里还有一个注意点,我们需要把构建的目标改成node
,也就是设置target: 'node'
,不仅如此,这里还需要配置libraryTarget: 'commonjs2'
,以便我们在node
端进行导入。
# 安装依赖
yarn add webpack-node-externals -D
这里就新增了一个依赖项。
好了,介绍完webpack
配置相关之后,我们就可以分别构建出服务端需要的结果和客户端相关的结果了,离成功又近了一步。
# 执行打包构建
这里推荐将构建命令写入到package.json
文件中,笔者这里将执行两端代码构建命令浓缩成一句:
"scripts": {
"build": "npm run build:client & npm run build:server",
"build:server": "webpack --config ./build/webpack.server.config.js",
"build:client": "webpack --config ./build/webpack.client.config.js"
}
2
3
4
5
这样在package.json
配置好之后,我们就可以直接执行一个命令就可以启动构建流程了:
npm run build