带你五步学会Vue SSR - 前端学习 - SegmentFault 思否
前言
SSR大家肯定都不陌生,通过服务端渲染,可以优化SEO抓取,提升首页加载速度等,我在学习SSR的时候,看过很多文章,有些对我有很大的启发作用,有些就只是照搬官网文档。通过几天的学习,我对SSR有了一些了解,也从头开始完整的配置出了SSR的开发环境,所以想通过这篇文章,总结一些经验,同时希望能够对学习SSR的朋友起到一点帮助。
我会通过五个步骤,一步步带你完成SSR的配置:
- 纯浏览器渲染
- 服务端渲染,不包含
Ajax
初始化数据 - 服务端渲染,包含
Ajax
初始化数据 - 服务端渲染,使用
serverBundle
和clientManifest
进行优化 - 一个完整的基于
Vue + VueRouter + Vuex
的SSR工程
如果你现在对于我上面说的还不太了解,没有关系,跟着我一步步向下走,最终你也可以独立配置一个SSR开发项目, 所有源码我会放到 github上,大家可以作为参考。
正文
1. 纯浏览器渲染
这个配置相信大家都会,就是基于 weback + vue
的一个常规开发配置,这里我会放一些关键代码,完整代码可以去 github查看。
目录结构
- node_modules
- components
- Bar.vue
- Foo.vue
- App.vue
- app.js
- index.html
- webpack.config.js
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
app.js
import Vue from 'vue';
import App from './App.vue';
let app = new Vue({
el: '#app',
render: h => h(App)
});
App.vue
<template>
<div>
<Foo></Foo>
<Bar></Bar>
</div>
</template>
<script>
import Foo from './components/Foo.vue';
import Bar from './components/Bar.vue';
export default {
components: {
Foo, Bar
}
}
</script>
index.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">
<title>纯浏览器渲染</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
components/Foo.vue
<template>
<div class="foo">
<h1>Foo Component</h1>
</div>
</template>
<style>
.foo {
background: yellowgreen;
}
</style>
components/Bar.vue
<template>
<div class="bar">
<h1>Bar Component</h1>
</div>
</template>
<style>
.bar {
background: bisque;
}
</style>
webpack.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
mode: 'development',
entry: './app.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
// 如果需要单独抽出CSS文件,用下面这个配置
// use: ExtractTextPlugin.extract({
// fallback: 'vue-style-loader',
// use: [
// 'css-loader',
// 'postcss-loader'
// ]
// })
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
},
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './index.html'
}),
// 如果需要单独抽出CSS文件,用下面这个配置
// new ExtractTextPlugin("styles.css")
]
};
postcss.config.js
module.exports = {
plugins: [
require('autoprefixer')
]
};
.babelrc
{
"presets": [
"@babel/preset-env"
],
"plugins": [
// 让其支持动态路由的写法 const Foo = () => import('../components/Foo.vue')
"dynamic-import-webpack"
]
}
package.json
{
"name": "01",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "yarn run dev",
"dev": "webpack-dev-server",
"build": "webpack"
},
"dependencies": {
"vue": "^2.5.17"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"babel-plugin-dynamic-import-webpack": "^1.1.0",
"autoprefixer": "^9.1.5",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"postcss": "^7.0.5",
"postcss-loader": "^3.0.0",
"url-loader": "^1.1.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9"
}
}
命令
启动开发环境
yarn start
构建生产环境
yarn run build
最终效果截图:
完整代码查看 github
2. 服务端渲染,不包含 Ajax
初始化数据
服务端渲染SSR,类似于同构,最终要让一份代码既可以在服务端运行,也可以在客户端运行。如果说在SSR的过程中出现问题,还可以回滚到纯浏览器渲染,保证用户正常看到页面。
那么,顺着这个思路,肯定就会有两个 webpack
的入口文件,一个用于浏览器端渲染 weboack.client.config.js
,一个用于服务端渲染 webpack.server.config.js
,将它们的公有部分抽出来作为 webpack.base.cofig.js
,后续通过 webpack-merge
进行合并。同时,也要有一个 server
来提供 http
服务,我这里用的是 koa
。
我们来看一下新的目录结构:
- node_modules
- config // 新增
- webpack.base.config.js
- webpack.client.config.js
- webpack.server.config.js
- src
- components
- Bar.vue
- Foo.vue
- App.vue
- app.js
- entry-client.js // 新增
- entry-server.js // 新增
- index.html
- index.ssr.html // 新增
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
在纯客户端应用程序(client-only app)中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染(cross-request state pollution)。
所以,我们要对 app.js
做修改,将其包装为一个工厂函数,每次调用都会生成一个全新的根组件。
app.js
import Vue from 'vue';
import App from './App.vue';
export function createApp() {
const app = new Vue({
render: h => h(App)
});
return { app };
}
在浏览器端,我们直接新建一个根组件,然后将其挂载就可以了。
entry-client.js
import { createApp } from './app.js';
const { app } = createApp();
app.$mount('#app');
在服务器端,我们就要返回一个函数,该函数的作用是接收一个 context
参数,同时每次都返回一个新的根组件。这个 context
在这里我们还不会用到,后续的步骤会用到它。
entry-server.js
import { createApp } from './app.js';
export default context => {
const { app } = createApp();
return app;
}
然后再来看一下 index.ssr.html
index.ssr.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">
<title>服务端渲染</title>
</head>
<body>
<!--vue-ssr-outlet-->
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>
<!--vue-ssr-outlet-->
的作用是作为一个占位符,后续通过 vue-server-renderer
插件,将服务器解析出的组件 html
字符串插入到这里。
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
是为了将 webpack
通过 webpack.client.config.js
打包出的文件放到这里(这里是为了简单演示,后续会有别的办法来做这个事情)。
因为服务端吐出来的就是一个 html
字符串,后续的 Vue
相关的响应式、事件响应等等,都需要浏览器端来接管,所以就需要将为浏览器端渲染打包的文件在这里引入。
用官方的词来说,叫 客户端激活(client-side hydration)。
所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。
在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:
// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')
由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。
如果你检查服务器渲染的输出结果,你会注意到应用程序的根元素上添加了一个特殊的属性:
<div id="app" data-server-rendered="true">
Vue
在浏览器端就依靠这个属性将服务器吐出来的 html
进行激活,我们一会自己构建一下就可以看到了。
接下来我们看一下 webpack
相关的配置:
webpack.base.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
mode: 'development',
resolve: {
extensions: ['.js', '.vue']
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.js$/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
}
]
},
plugins: [
new VueLoaderPlugin()
]
};
webpack.client.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.html'),
filename: 'index.html'
})
]
});
注意,这里的入口文件变成了 entry-client.js
,将其打包出的 client.bundle.js
插入到 index.html
中。
webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
target: 'node',
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
output: {
libraryTarget: 'commonjs2'
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js'
},
excludeChunks: ['server']
})
]
});
这里有几个点需要注意一下:
- 入口文件是
entry-server.js
- 因为是打包服务器端依赖的代码,所以
target
要设为node
,同时,output
的libraryTarget
要设为commonjs2
这里关于 HtmlWebpackPlugin
配置的意思是,不要在 index.ssr.html
中引入打包出的 server.bundle.js
,要引为浏览器打包的 client.bundle.js
,原因前面说过了,是为了让 Vue
可以将服务器吐出来的 html
进行激活,从而接管后续响应。
那么打包出的 server.bundle.js
在哪用呢?接着往下看就知道啦~~
package.json
{
"name": "01",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "yarn run dev",
"dev": "webpack-dev-server",
"build:client": "webpack --config config/webpack.client.config.js",
"build:server": "webpack --config config/webpack.server.config.js"
},
"dependencies": {
"koa": "^2.5.3",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
"vue": "^2.5.17",
"vue-server-renderer": "^2.5.17"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"autoprefixer": "^9.1.5",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"postcss": "^7.0.5",
"postcss-loader": "^3.0.0",
"style-loader": "^0.23.0",
"url-loader": "^1.1.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9",
"webpack-merge": "^4.1.4"
}
}
接下来我们看 server
端关于 http
服务的代码:
server/server.js
const Koa = require('koa');
const Router = require('koa-router');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const backendApp = new Koa();
const frontendApp = new Koa();
const backendRouter = new Router();
const frontendRouter = new Router();
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
// 后端Server
backendRouter.get('/index', (ctx, next) => {
// 这里用 renderToString 的 promise 返回的 html 有问题,没有样式
renderer.renderToString((err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服务器内部错误';
} else {
console.log(html);
ctx.status = 200;
ctx.body = html;
}
});
});
backendApp.use(serve(path.resolve(__dirname, '../dist')));
backendApp
.use(backendRouter.routes())
.use(backendRouter.allowedMethods());
backendApp.listen(3000, () => {
console.log('服务器端渲染地址: http://localhost:3000');
});
// 前端Server
frontendRouter.get('/index', (ctx, next) => {
let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
ctx.type = 'html';
ctx.status = 200;
ctx.body = html;
});
frontendApp.use(serve(path.resolve(__dirname, '../dist')));
frontendApp
.use(frontendRouter.routes())
.use(frontendRouter.allowedMethods());
frontendApp.listen(3001, () => {
console.log('浏览器端渲染地址: http://localhost:3001');
});
这里对两个端口进行监听,3000端口是服务端渲染,3001端口是直接输出 index.html
,然后会在浏览器端走 Vue
的那一套,主要是为了和服务端渲染做对比使用。
这里的关键代码是如何在服务端去输出 html
`字符串。
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
可以看到, server.bundle.js
在这里被使用啦,因为它的入口是一个函数,接收 context
作为参数(非必传),输出一个根组件 app
。
这里我们用到了 vue-server-renderer
插件,它有两个方法可以做渲染,一个是 createRenderer
,另一个是 createBundleRenderer
。
const { createRenderer } = require('vue-server-renderer')
const renderer = createRenderer({ /* 选项 */ })
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, { /* 选项 */ })
createRenderer
无法接收为服务端打包出的 server.bundle.js
文件,所以这里只能用 createBundleRenderer
。
serverBundle
参数可以是以下之一:
- 绝对路径,指向一个已经构建好的
bundle
文件(.js
或.json
)。必须以/
开头才会被识别为文件路径。 - 由
webpack + vue-server-renderer/server-plugin
生成的bundle
对象。 -
JavaScript
代码字符串(不推荐)。
这里我们引入的是.js文件,后续会介绍如何使用.json文件以及有什么好处。
renderer.renderToString((err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服务器内部错误';
} else {
console.log(html);
ctx.status = 200;
ctx.body = html;
}
});
使用 createRenderer
和 createBundleRenderer
返回的 renderer
函数包含两个方法 renderToString
和 renderToStream
,我们这里用的是 renderToString
成功后直接返回一个完整的字符串, renderToStream
返回的是一个 Node
流。
renderToString
支持 Promise
,但是我在使用 Prmoise
形式的时候样式会渲染不出来,暂时还不知道原因,如果大家知道的话可以给我留言啊。
配置基本就完成了,来看一下如何运行。
yarn run build:client // 打包浏览器端需要bundle
yarn run build:server // 打包SSR需要bundle
yarn start // 其实就是 node server/server.js,提供http服务
最终效果展示:
访问 http://localhost:3000/index
我们看到了前面提过的 data-server-rendered="true"
属性,同时会加载 client.bundle.js
文件,为了让 Vue
在浏览器端做后续接管。
访问 http://localhost:3001/index
还和第一步实现的效果一样,纯浏览器渲染,这里就不放截图了。
完整代码查看 github
3. 服务端渲染,包含Ajax初始化数据
如果SSR需要初始化一些异步数据,那么流程就会变得复杂一些。
我们先提出几个问题:
- 服务端拿异步数据的步骤在哪做?
- 如何确定哪些组件需要获取异步数据?
- 获取到异步数据之后要如何塞回到组件内?
带着问题我们向下走,希望看完这篇文章的时候上面的问题你都找到了答案。
服务器端渲染和浏览器端渲染组件经过的生命周期是有区别的, 在服务器端,只会经历 beforeCreate
和 created
两个生命周期。因为SSR服务器直接吐出 html
字符串就好了,不会渲染DOM结构,所以不存在 beforeMount
和 mounted
的,也不会对其进行更新,所以也就不存在 beforeUpdate
和 updated
等。
我们先来想一下,在纯浏览器渲染的 Vue
项目中,我们是怎么获取异步数据并渲染到组件中的?一般是在 created
或者 mounted
生命周期里发起异步请求,然后在成功回调里执行 this.data = xxx
, Vue
监听到数据发生改变,走后面的 Dom Diff
,打 patch
,做 DOM
更新。
那么服务端渲染可不可以也这么做呢? 答案是不行的。
- 在
mounted
里肯定不行,因为SSR
都没有mounted
生命周期,所以在这里肯定不行。 - 在
beforeCreate
里发起异步请求是否可以呢,也是不行的。因为请求是异步的,可能还没有等接口返回,服务端就已经把html
字符串拼接出来了。
所以,参考一下 官方文档,我们可以得到以下思路:
- 在渲染前,要预先获取所有需要的异步数据,然后存到
Vuex
的store
中。 - 在后端渲染时,通过
Vuex
将获取到的数据注入到相应组件中。 - 把
store
中的数据设置到window.__INITIAL_STATE__
属性中。 - 在浏览器环境中,通过
Vuex
将window.__INITIAL_STATE__
里面的数据注入到相应组件中。
正常情况下,通过这几个步骤,服务端吐出来的 html
字符串相应组件的数据都是最新的,所以第4步并不会引起 DOM
更新,但如果出了某些问题,吐出来的 html
字符串没有相应数据, Vue
也可以在浏览器端通过 `Vuex
注入数据,进行 DOM
更新。
更新后的目录结构:
- node_modules
- config
- webpack.base.config.js
- webpack.client.config.js
- webpack.server.config.js
- src
- components
- Bar.vue
- Foo.vue
- store // 新增
store.js
- App.vue
- app.js
- entry-client.js
- entry-server.js
- index.html
- index.ssr.html
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
先来看一下 store.js
:
store/store.js
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const fetchBar = function() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('bar 组件返回 ajax 数据');
}, 1000);
});
};
function createStore() {
const store = new Vuex.Store({
state: {
bar: ''
},
mutations: {
'SET_BAR'(state, data) {
state.bar = data;
}
},
actions: {
fetchBar({ commit }) {
return fetchBar().then((data) => {
commit('SET_BAR', data);
}).catch((err) => {
console.error(err);
})
}
}
});
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
store.replaceState(window.__INITIAL_STATE__);
}
return store;
}
export default createStore;
typeof window
如果不太了解 Vuex
,可以去 Vuex官网先看一些基本概念。
这里 fetchBar
可以看成是一个异步请求,这里用 setTimeout
模拟。在成功回调中 commit
相应的 mutation
进行状态修改。
这里有一段关键代码:
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
store.replaceState(window.__INITIAL_STATE__);
}
因为 store.js
同样也会被打包到服务器运行的 server.bundle.js
中,所以运行环境不一定是浏览器,这里需要对 window
做判断,防止报错,同时如果有 window.__INITIAL_STATE__
属性,说明服务器已经把所有初始化需要的异步数据都获取完成了,要对 store
中的状态做一个替换,保证统一。
components/Bar.vue
<template>
<div class="bar">
<h1 @click="onHandleClick">Bar Component</h1>
<h2>异步Ajax数据:</h2>
<span>{{ msg }}</span>
</div>
</template>
<script>
const fetchInitialData = ({ store }) => {
store.dispatch('fetchBar');
};
export default {
asyncData: fetchInitialData,
methods: {
onHandleClick() {
alert('bar');
}
},
mounted() {
// 因为服务端渲染只有 beforeCreate 和 created 两个生命周期,不会走这里
// 所以把调用 Ajax 初始化数据也写在这里,是为了供单独浏览器渲染使用
let store = this.$store;
fetchInitialData({ store });
},
computed: {
msg() {
return this.$store.state.bar;
}
}
}
</script>
<style>
.bar {
background: bisque;
}
</style>
这里在 Bar
组件的默认导出对象中增加了一个方法 asyncData
,在该方法中会 dispatch
相应的 action
,进行异步数据获取。
需要注意的是,我在 mounted
中也写了获取数据的代码,这是为什么呢?因为想要做到同构,代码单独在浏览器端运行,也应该是没有问题的,又由于服务器没有 mounted
生命周期,所以我写在这里就可以解决单独在浏览器环境使用也可以发起同样的异步请求去初始化数据。
components/Foo.vue
<template>
<div class="foo">
<h1 @click="onHandleClick">Foo Component</h1>
</div>
</template>
<script>
export default {
methods: {
onHandleClick() {
alert('foo');
}
},
}
</script>
<style>
.foo {
background: yellowgreen;
}
</style>
这里我对两个组件都添加了一个点击事件,为的是证明在服务器吐出首页 html
后,后续的步骤都会被浏览器端的 Vue
接管,可以正常执行后面的操作。
app.js
import Vue from 'vue';
import createStore from './store/store.js';
import App from './App.vue';
export function createApp() {
const store = createStore();
const app = new Vue({
store,
render: h => h(App)
});
return { app, store, App };
}
在建立根组件的时候,要把 Vuex的store
传进去,同时要返回,后续会用到。
最后来看一下 entry-server.js
,关键步骤在这里:
entry-server.js
import { createApp } from './app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app, store, App } = createApp();
let components = App.components;
let asyncDataPromiseFns = [];
Object.values(components).forEach(component => {
if (component.asyncData) {
asyncDataPromiseFns.push(component.asyncData({ store }));
}
});
Promise.all(asyncDataPromiseFns).then((result) => {
// 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
context.state = store.state;
console.log(222);
console.log(store.state);
console.log(context.state);
console.log(context);
resolve(app);
}, reject);
});
}
我们通过导出的 App
拿到了所有它下面的 components
,然后遍历,找出哪些 component
有 asyncData
方法,有的话调用并传入 store
,该方法会返回一个 Promise
,我们使用 Promise.all
等所有的异步方法都成功返回,才 resolve(app)
。
context.state = store.state
作用是,当使用 createBundleRenderer
时,如果设置了 template
选项,那么会把 context.state
的值作为 window.__INITIAL_STATE__
自动插入到模板 html
中。
这里需要大家多思考一下,弄清楚整个服务端渲染的逻辑。
如何运行:
yarn run build:client
yarn run build:server
yarn start
最终效果截图:
服务端渲染:打开 http://localhost:3000/index
可以看到 window.__INITIAL_STATE__
被自动插入了。
我们来对比一下 SSR
到底对加载性能有什么影响吧。
服务端渲染时 performance
截图:
纯浏览器端渲染时 performance
截图:
同样都是在 fast 3G
网络模式下,纯浏览器端渲染首屏加载花费时间 2.9s,因为 client.js
加载就花费了 2.27s,因为没有 client.js
就没有 Vue
,也就没有后面的东西了。
服务端渲染首屏时间花费 0.8s,虽然 client.js
加载扔花费 2.27s
,但是首屏已经不需要它了,它是为了让 Vue
在浏览器端进行后续接管。
从这我们可以真正的看到,服务端渲染对于提升首屏的响应速度是很有作用的。
当然有的同学可能会问,在服务端渲染获取初始 ajax
数据时,我们还延时了1s,在这个时间用户也是看不到页面的。没错,接口的时间我们无法避免,就算是纯浏览器渲染,首页该调接口还是得调,如果接口响应慢,那么纯浏览器渲染看到完整页面的时间会更慢。
完整代码查看 github
4. 使用serverBundle和clientManifest进行优化
前面我们创建服务端 renderer
的方法是:
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
serverBundle
我们用的是打包出的 server.bundle.js
文件。这样做的话,在每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map。
vue-server-renderer
提供一个名为 createBundleRenderer
的 API,用于处理此问题,通过使用 webpack
的自定义插件, server bundle
将生成为可传递到 bundle renderer
的特殊 JSON
文件。所创建的 bundle renderer
,用法和普通 renderer
相同,但是 bundle renderer
提供以下优点:
- 内置的
source map
支持(在webpack
配置中使用devtool: 'source-map'
) - 在开发环境甚至部署过程中热重载(通过读取更新后的
bundle
,然后重新创建renderer
实例) - 关键
CSS(critical CSS)
注入(在使用*.vue
文件时):自动内联在渲染过程中用到的组件所需的CSS
。更多细节请查看CSS
章节。 - 使用
clientManifest
进行资源注入:自动推断出最佳的预加载(preload
)和预取(prefetch
)指令,以及初始渲染所需的代码分割chunk
。
preload
和 prefetch
有不了解的话可以自行查一下它们的作用哈。
那么我们来修改 webpack
配置:
webpack.client.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},
plugins: [
new VueSSRClientPlugin(), // 新增
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.html'),
filename: 'index.html'
})
]
});
webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
target: 'node',
// 对 bundle renderer 提供 source map 支持
devtool: '#source-map',
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
externals: [nodeExternals()], // 新增
output: {
libraryTarget: 'commonjs2'
},
plugins: [
new VueSSRServerPlugin(), // 这个要放到第一个写,否则 CopyWebpackPlugin 不起作用,原因还没查清楚
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js'
},
excludeChunks: ['server']
})
]
});
因为是服务端引用模块,所以不需要打包 node_modules
中的依赖,直接在代码中 require
引用就好,所以配置 externals: [nodeExternals()]
。
两个配置文件会分别生成 vue-ssr-client-manifest.json
和 vue-ssr-server-bundle.json
。作为 createBundleRenderer
的参数。
来看 server.js
server.js
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'));
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'));
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template: template,
clientManifest: clientManifest
});
效果和第三步就是一样的啦,就不截图了,完整代码查看 github。
5. 配置一个完整的基于Vue + VueRouter + Vuex的SSR
这里和第四步不一样的是引入了 vue-router
,更接近于实际开发项目。
在 src
下新增 router
目录。
router/index.js
import Vue from 'vue';
import Router from 'vue-router';
import Bar from '../components/Bar.vue';
Vue.use(Router);
function createRouter() {
const routes = [
{
path: '/bar',
component: Bar
},
{
path: '/foo',
component: () => import('../components/Foo.vue') // 异步路由
}
];
const router = new Router({
mode: 'history',
routes
});
return router;
}
export default createRouter;
这里我们把 Foo
组件作为一个异步组件引入,做成按需加载。
在 app.js
中引入 router
,并导出:
app.js
import Vue from 'vue';
import createStore from './store/store.js';
import createRouter from './router';
import App from './App.vue';
export function createApp() {
const store = createStore();
const router = createRouter();
const app = new Vue({
router,
store,
render: h => h(App)
});
return { app, store, router, App };
}
修改 App.vue
引入路由组件:
App.vue
<template>
<div id="app">
<router-link to="/bar">Goto Bar</router-link>
<router-link to="/foo">Goto Foo</router-link>
<router-view></router-view>
</div>
</template>
<script>
export default {
beforeCreate() {
console.log('App.vue beforeCreate');
},
created() {
console.log('App.vue created');
},
beforeMount() {
console.log('App.vue beforeMount');
},
mounted() {
console.log('App.vue mounted');
}
}
</script>
最重要的修改在 entry-server.js
中,
entry-server.js
import { createApp } from './app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app, store, router, App } = createApp();
router.push(context.url);
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
console.log(context.url)
console.log(matchedComponents)
if (!matchedComponents.length) {
return reject({ code: 404 });
}
Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData({ store });
}
})).then(() => {
// 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
context.state = store.state;
// 返回根组件
resolve(app);
});
}, reject);
});
}
这里前面提到的 context
就起了大作用,它将用户访问的 url
地址传进来,供 vue-router
使用。因为有异步组件,所以在 router.onReady
的成功回调中,去找该 url
路由所匹配到的组件,获取异步数据那一套还和前面的一样。
于是,我们就完成了一个基本完整的基于 Vue + VueRouter + Vuex
SSR配置,完成代码查看 github。
最终效果演示:
访问 http://localhost:3000/bar
:
完整代码查看 github
后续
上面我们通过五个步骤,完成了从纯浏览器渲染到完整服务端渲染的同构,代码既可以运行在浏览器端,也可以运行在服务器端。那么,回过头来我们在看一下是否有优化的空间,又或者有哪些扩展的思考。
1. 优化
- 我们目前是使用
renderToString
方法,完全生成html
后,才会向客户端返回,如果使用renderToStream
,应用bigpipe
技术可以向浏览器持续不断的返回一个流,那么文件的加载浏览器可以尽早的显示一些东西出来。
const stream = renderer.renderToStream(context)
返回的值是 Node.js stream
:
let html = ''
stream.on('data', data => {
html += data.toString()
})
stream.on('end', () => {
console.log(html) // 渲染完成
})
stream.on('error', err => {
// handle error...
})
在流式渲染模式下,当 renderer
遍历虚拟 DOM
树( virtual DOM tree
)时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk
",并开始更快地将其发送给客户端。
然而,当第一个数据 chunk
被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文( render context
),当流( stream
)启动时,这些数据将不可用。这是因为,大量上下文信息( context information
)(如头信息( head information
)或内联关键 CSS(inline critical CSS))
需要在应用程序标记( markup
)之前出现,我们基本上必须等待流( stream
)完成后,才能开始使用这些上下文数据。
因此,如果你依赖由组件生命周期钩子函数填充的上下文数据,则不建议使用流式传输模式。
-
webpack
优化
webpack
优化又是一个大的话题了,这里不展开讨论,感兴趣的同学可以自行查找一些资料,后续我也可能会专门写一篇文章来讲 webpack
优化。
2. 思考
- 是否必须使用
vuex
?
答案是不用。 Vuex
只是为了帮助你实现一套数据存储、更新、获取的机制,入股你不用 Vuex
,那么你就必须自己想一套方案可以将异步获取到的数据存起来,并且在适当的时机将它注入到组件内,有一些文章提出了一些方案,我会放到参考文章里,大家可以阅读一下。
- 是否使用
SSR
就一定好?
这个也是不一定的,任何技术都有使用场景。 SSR
可以帮助你提升首页加载速度,优化搜索引擎 SEO
,但同时由于它需要在 node
中渲染整套 Vue
的模板,会占用服务器负载,同时只会执行 beforeCreate
和 created
两个生命周期,对于一些外部扩展库需要做一定处理才可以在 SSR
中运行等等。
结语
本文通过五个步骤,从纯浏览器端渲染开始,到配置一个完整的基于 Vue + vue-router + Vuex
的SSR环境,介绍了很多新的概念,也许你看完一遍不太理解,那么结合着源码,去自己手敲几遍,然后再来看几遍文章,相信你一定可以掌握 SSR
。
最后,本文所有源代码都放在我的 github上,如果对你有帮助的话,就来点一个赞吧~~
欢迎关注我的公众号