我本人在刚开始看 VUE SSR 官方文档的时候遇到不少问题,它一开始是创建在你有一个可运行的构建环境的,因此它直接讲代码的实现,可是对于刚接触的开发者来讲并无一个运行环境,因此全部的代码片断都没法运行。那为何做者不先讲构建,再讲程序实现呢?我以为多是由于构建、运行又重度依赖具体的代码实现,先讲构建也不利于理解总体过程,因此是一个不太好平衡的事。javascript
咱们这个 demo 将先讲构建过程,其中有些问题可能须要在后面讲完之后回头再看,但力求能将总体过程交待清楚。同时,文章中的每一步都会在这个 DEMO 有体现,经过这个 demo 的不一样 commit ,能够快速定位到不一样阶段,具体的 commit id 以下:css
* e06aee792a59ffd9018aea1e3601e220c37fedbd (HEAD -> master, origin/master) 优化:添加缓存
* c65f08beaff1dea1eaf05d02fb30a7e8776ce289 程序开发:初步完成demo
* 2fb0d28ee6d84d2b1bdbbe419c744efdad3227de 程序开发:完成store定义,api编写和程序同步
* 9604aec0de526726f4fe435385f7c2fa4009fa63 程序开发:第一个可独立运行版本,无store
* 7d567e254fc9dc5a1655d2f0abbb4b8d53bccfce 构建配置:webpack配置、server.js后端入口文件编写
* 969248b64af82edd07214a621dfd19cf357d6c53 构建配置:babel 配置
* a5453fdeb20769e8c9e9ee339b624732ad14658a 初始化项目,完成第一个可运行demo
复制代码
在阅读、测试的时候,能够经过 git reset --hard commitid
来切换不一样的阶段,看具体的实现。html
Vue.js 是构建客户端应用程序的框架。默认状况下,能够在浏览器中输出 Vue 组件,进行生成 DOM 和操做 DOM。然而,也能够将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上彻底可交互的应用程序。前端
服务器渲染的 Vue.js 应用程序也能够被认为是"同构"或"通用",由于应用程序的大部分代码均可以在服务器和客户端上运行。vue
与传统 SPA(Single-Page Application - 单页应用程序)相比,服务器端渲染(SSR)的优点主要在于:java
安装须要用到的模板node
npm install vue vue-server-renderer express --savewebpack
新建 /server.js
、 /src/index.template.html
ios
const server = require('express')()
const Vue = require('vue')
const fs = require('fs')
const Renderer = require('vue-server-renderer').createRenderer({
template:fs.readFileSync('./src/index.template.html', 'utf-8')
})
server.get('*', (req, res) => {
const app = new Vue({
data: {
name: 'vue app~',
url: req.url
},
template:'<div>hello from {{name}}, and url is: {{url}}</div>'
})
const context = {
title: 'SSR test#'
}
Renderer.renderToString(app, context, (err, html) => {
if(err) {
console.log(err)
res.status(500).end('server error')
}
res.end(html)
})
})
server.listen(4001)
console.log('running at: http://localhost:4001');
复制代码
经过以上程序,能够看到经过 vue-server-renderer 将VUE实例进行编译,最终经过 express 输出到浏览器。nginx
但同时也能看到,输出的是一个静态的纯html页面,因为没有加载任何 javascript 文件,前端的用户交互也无所实现,因此上面的 demo 只是一个极简的实例,要想实现一个完整的 VUE ssr 程序,还须要借助 VueSSRClientPlugin(vue-server-renderer/client-plugin) 将文件编译成前端浏览器可运行的 vue-ssr-client-manifest.json 文件和 js、css 等文件,VueSSRServerPlugin(vue-server-renderer/server-plugin) 将文件编译成可供node调用的 vue-ssr-server-bundle.json
真正开始以前,须要了解一些概念
"通用"代码时的约束条件 - 即运行在服务器和客户端的代码,因为用例和平台 API 的差别,当运行在不一样环境中时,咱们的代码将不会彻底相同。
每一个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求形成的状态污染(cross-request state pollution)
因为没有动态更新,全部的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程当中被调用
通用代码不可接受特定平台的 API,所以若是你的代码中,直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。
如何将相同的 Vue 应用程序提供给服务端和客户端。为了作到这一点,咱们须要使用 webpack 来打包 Vue 应用程序。
一般 Vue 应用程序是由 webpack 和 vue-loader 构建,而且许多 webpack 特定功能不能直接在 Node.js 中运行(例如经过 file-loader 导入文件,经过 css-loader 导入 CSS)。
尽管 Node.js 最新版本可以彻底支持 ES2015 特性,咱们仍是须要转译客户端代码以适应老版浏览器。这也会涉及到构建步骤。
因此基本见解是,对于客户端应用程序和服务器应用程序,咱们都要使用 webpack 打包 - 服务器须要「服务器 bundle」而后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。
下面看具体实现过程
新建 /.babelrc 配置
// es6 compile to es5 相关配置
{
"presets": [
[
"env",
{
"modules": false
}
]
],
"plugins": ["syntax-dynamic-import"]
}
npm i -D babel-loader@7 babel-core babel-plugin-syntax-dynamic-import babel-preset-env
复制代码
新建一个 build 文件夹,用于存放 webpack
相关的配置文件
/
├── build
│ ├── setup-dev-server.js # 设置 webpack-dev-middleware 开发环境
│ ├── webpack.base.config.js # 基础通用配置
│ ├── webpack.client.config.js # 编译出 vue-ssr-client-manifest.json 文件和 js、css 等文件,供浏览器调用
│ └── webpack.server.config.js # 编译出 vue-ssr-server-bundle.json 供 nodejs 调用
复制代码
先把相关的包安装
安装 webpack 相关的包
npm i -D webpack webpack-cli webpack-dev-middleware webpack-hot-middleware webpack-merge webpack-node-externals
安装构建依赖的包
npm i -D chokidar cross-env friendly-errors-webpack-plugin memory-fs rimraf vue-loader
接下来看每一个文件的具体内容:
webpack.base.config.js
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
context: path.resolve(__dirname, '../'),
devtool: isProd ? 'source-map' : '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
filename: '[name].[chunkhash].js'
},
resolve: {
// ...
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
}
// ...
]
},
plugins: [new VueLoaderPlugin()]
}
复制代码
webpack.base.config.js
这个是通用配置,和咱们以前SPA开发配置基本同样。
webpack.client.config.js
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(base, {
mode: 'development',
entry: {
app: './src/entry-client.js'
},
resolve: {},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'development'
),
'process.env.VUE_ENV': '"client"'
}),
new VueSSRClientPlugin()
]
})
module.exports = config
复制代码
webpack.client.config.js
主要完成了两个工做
entry-client.js
VueSSRClientPlugin
生成 vue-ssr-client-manifest.json
这个 manifest.json 文件被 server.js 引用
const { createBundleRenderer } = require('vue-server-renderer')
const template = require('fs').readFileSync('/path/to/template.html', 'utf-8')
const serverBundle = require('/path/to/vue-ssr-server-bundle.json')
const clientManifest = require('/path/to/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
template,
clientManifest
})
复制代码
经过以上设置,使用代码分割特性构建后的服务器渲染的 HTML 代码,全部都是自动注入。
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')
module.exports = merge(base, {
mode: 'production',
target: 'node',
devtool: '#source-map',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
resolve: {},
externals: nodeExternals({
whitelist: /\.css$/ // 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
复制代码
webpack.server.config.js
主要完成的工做是:
target: 'node'
告诉 webpack 编译的目录代码是 node 应用程序VueSSRServerPlugin
插件,将代码编译成 vue-ssr-server-bundle.json
在生成 vue-ssr-server-bundle.json
以后,只需将文件路径传递给 createBundleRenderer
,在 server.js
中以下实现:
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {
// ……renderer 的其余选项
})
复制代码
至此,基本已经完成构建
安装 VUE 相关的依赖包
npm i axios vue-template-compiler vue-router vuex vuex-router-sync
新增并完善以下文件:
/
├── server.js # 实现长期运行的 node 程序
├── src
│ ├── app.js # 新增
│ ├── router.js # 新增 定义路由
│ ├── App.vue # 新增
│ ├── entry-client.js # 浏览器端入口
│ ├── entry-server.js # node程序端入口
└── views
└── Home.vue # 首页
复制代码
接下来逐个看这些文件:
server.js
const fs = require('fs');
const path = require('path');
const express = require('express');
const { createBundleRenderer } = require('vue-server-renderer');
const devServer = require('./build/setup-dev-server')
const resolve = file => path.resolve(__dirname, file);
const isProd = process.env.NODE_ENV === 'production';
const app = express();
const serve = (path, cache) =>
express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
});
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 startTime = Date.now();
res.setHeader('Content-Type', 'text/html');
const context = {
title: 'SSR 测试', // default title
url: req.url
};
renderer.renderToString(context, (err, html) => {
res.send(html);
});
}
let renderer;
let readyPromise;
const templatePath = resolve('./src/index.template.html');
if (isProd) {
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文件注入到页面中
renderer = createRenderer(bundle, {
template,
clientManifest
});
} else {
readyPromise = devServer( app, templatePath, (bundle, options) => {
renderer = createRenderer(bundle, options);
}
);
}
app.get('*',isProd? render : (req, res) => {
readyPromise.then(() => render(req, res));
}
);
const port = process.env.PORT || 8088;
app.listen(port, () => {
console.log(`server started at localhost:${port}`);
});
复制代码
server.js
主要完成了如下工做
npm run dev
的时候,调用 /build/setup-dev-server.js
启动 'webpack-dev-middleware' 开发中间件vue-server-renderer
调用以前编译生成的 vue-ssr-server-bundle.json
启动 node 服务vue-ssr-client-manifest.json
注入到 createRenderer
中实现前端资源的t自动注入express
处理 http
请求server.js
是整个站点的入口程序,经过他调用编译事后的文件,最终输出到页面,是整个项目中很关键的一部分
app.js
import Vue from 'vue'
import App from './App.vue';
import { createRouter } from './router';
export function createApp(context) {
const router = createRouter();
const app = new Vue({
router,
render: h => h(App)
});
return { app, router };
};
复制代码
app.js
暴露一个能够重复执行的工厂函数,为每一个请求建立新的应用程序实例,提交给 'entry-client.js' 和 entry-server.js
调用
entry-client.js
import { createApp } from './app';
const { app, router } = createApp();
router.onReady(() => {
app.$mount('#app');
});
复制代码
entry-client.js
常规的实例化 vue 对象并挂载到页面中
entry-server.js
import { createApp } from './app';
export default context => {
// 由于有可能会是异步路由钩子函数或组件,因此咱们将返回一个 Promise,
// 以便服务器可以等待全部的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router } = createApp(context);
// 设置服务器端 router 的位置
router.push(context.url);
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 });
}
resolve(app);
});
});
};
复制代码
entry-server.js
做为服务器入口,最终通过 VueSSRServerPlugin
插件,编译成 vue-ssr-server-bundle.json
供 vue-server-renderer
调用
router.js
和 Home.vue
为常规 vue
程序,这里不进一步展开了。
至此,咱们完成了第一个能够完整编译和运行的 vue ssr
实例
在此以前完成的程序,只是将预想定义的变量渲染成html返回给客户端,但若是要实现一个真正可用的web程序,是要有动态数据的支持的,如今咱们开始看如何从远程获取数据,而后渲染成html输出到客户端。
在服务器端渲染(SSR)期间,咱们本质上是在渲染咱们应用程序的"快照",因此若是应用程序依赖于一些异步数据,那么在开始渲染过程以前,须要先预取和解析好这些数据。
先定义一个获取数据的 api.js
,使用 axios
:
import axios from 'axios';
export function fetchItem(id) {
return axios.get('https://api.mimei.net.cn/api/v1/article/' + id);
}
export function fetchList() {
return axios.get('https://api.mimei.net.cn/api/v1/article/');
}
复制代码
咱们将使用官方状态管理库 Vuex。咱们先建立一个 store.js 文件,里面会获取一个文件列表、根据 id 获取文章内容:
import Vue from 'vue';
import Vuex from 'vuex';
import { fetchItem, fetchList } from './api.js'
Vue.use(Vuex);
export function createStore() {
return new Vuex.Store({
state: {
items: {},
list: []
},
actions: {
fetchItem({commit}, id) {
return fetchItem(id).then(res => {
commit('setItem', {id, item: res.data})
})
},
fetchList({commit}){
return fetchList().then(res => {
commit('setList', res.data.list)
})
}
},
mutations: {
setItem(state, {id, item}) {
Vue.set(state.items, id, item)
},
setList(state, list) {
state.list = list
}
}
});
}
复制代码
而后修改 app.js
:
import Vue from 'vue'
import App from './App.vue';
import { createRouter } from './router';
import { createStore } from './store'
import { sync } from 'vuex-router-sync'
export function createApp(context) {
const router = createRouter();
const store = createStore();
sync(store, router)
const app = new Vue({
router,
store,
render: h => h(App)
});
return { app, router, store };
};
复制代码
store action
定义好了之后,如今来看如何触发请求,官方建议是放在路由组件里,接下来看 Home.vue
:
<template>
<div>
<h3>文章列表</h3>
<div class="list" v-for="i in list">
<router-link :to="{path:'/item/'+i.id}">{{i.title}}</router-link>
</div>
</div>
</template>
<script> export default { asyncData ({store, route}){ return store.dispatch('fetchList') }, computed: { list () { return this.$store.state.list } }, data(){ return { name:'wfz' } } } </script>
复制代码
在 entry-server.js
中,咱们能够经过路由得到与 router.getMatchedComponents()
相匹配的组件,若是组件暴露出 asyncData
,咱们就调用这个方法。而后咱们须要将解析完成的状态,附加到渲染上下文(render context)中。
// entry-server.js
import { createApp } from './app';
export default context => {
// 由于有可能会是异步路由钩子函数或组件,因此咱们将返回一个 Promise,
// 以便服务器可以等待全部的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(context);
// 设置服务器端 router 的位置
router.push(context.url);
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 });
}
Promise.all(
matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData({
store,
route: router.currentRoute
});
}
})
).then(() => {
context.state = store.state
// Promise 应该 resolve 应用程序实例,以便它能够渲染
resolve(app);
});
});
});
};
复制代码
当使用 template
时,context.state
将做为 window.__INITIAL_STATE__
状态,自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序以前,store 就应该获取到状态:
// entry-client.js
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
复制代码
在客户端,处理数据预取有两种不一样方式:在路由导航以前解析数据
和 匹配要渲染的视图后,再获取数据
,咱们的 demo 里用第一种方案:
// entry-client.js
import { createApp } from './app';
const { app, router, store } = createApp();
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to);
const prevMatched = router.getMatchedComponents(from);
let diffed = false;
const activated = matched.filter((c, i) => {
return diffed || (diffed = prevMatched[i] !== c);
});
if (!activated.length) {
return next();
}
Promise.all(
activated.map(component => {
if (component.asyncData) {
component.asyncData({
store,
route: to
});
}
})
)
.then(() => {
next();
})
.catch(next);
});
app.$mount('#app');
});
复制代码
经过检查匹配的组件,并在全局路由钩子函数中执行 asyncData
函数获取接口数据。
因为这个 demo
是两个页面,还须要的 router.js
添加一个路由信息、添加一个路由组件 Item.vue
,至此已经完成了一个基本的 VUE SSR
实例。
因为服务端渲染属于计算密集型,若是并发较大的话,颇有可能有性能问题。适当的使用缓存策略能够大幅提升响应速度。
const microCache = LRU({
max: 100,
maxAge: 1000 // 重要提示:条目在 1 秒后过时。
})
const isCacheable = req => {
// 实现逻辑为,检查请求是不是用户特定(user-specific)。
// 只有非用户特定(non-user-specific)页面才会缓存
}
server.get('*', (req, res) => {
const cacheable = isCacheable(req)
if (cacheable) {
const hit = microCache.get(req.url)
if (hit) {
return res.end(hit)
}
}
renderer.renderToString((err, html) => {
res.end(html)
if (cacheable) {
microCache.set(req.url, html)
}
})
})
复制代码
基本上,经过 nginx
和缓存,可能很大程度上解决性能瓶颈问题。