如果是静态页面,例如官网的SSR处理可直接使用prerender-spa-plugin插件实现预渲染,参考我之前的博客:vue单页面通过prerender-spa-plugin插件进行SEO优化
以下是基于[email protected]生成的工程相关的结构改造。文章最后有异步请求的例子可供参考。
结合上面的流程图来理解编译的过程图,因为服务端渲染只是一个可以等待异步数据的预渲染,最终用户交互还是需要Client entry生成的js来控制,这就是为什么需要两个entry文件,但是拥有同样的入口(app.js)的原因。串联server和client的枢纽就是store,server将预渲染页面的sotre数据放入window全局中,client再进行数据同步,完成异步数据预渲染
知道大致步骤和流程,再去理解VueSSR的配置就不会那么突兀了。
注意:路由如果用懒加载会出现vue模板里面的样式没办法抽离到css中,而是用js渲染,浏览器会出现一个没有样式到最终页面的空白期。由于SSR渲染做了没有SPA首屏渲染问题,所以不用懒加载也没事。
需要加入占位符
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="" type="image/x-icon" />
<title>{{title}}title>
head>
<body>
<div id="app">
div>
body>
html>
多了一些相关的依赖需要添加一下,这里做一个汇总:
npm install memory-fs chokidar [email protected] lru-cache serve-favicon compression route-cache vuex-router-sync --save
注意: vue-server-renderer要和工程的vue版本一致。
开发模式会调用setup-dev-server中的热更新插件,实时编译
const fs = require('fs'); //读取文件
const path = require('path');
const express = require('express');
const app= express();
const LRU = require('lru-cache'); //封装缓存的get set方法
/*
* 处理favicon.ico文件:作用:
* 1. 去除这些多余无用的日志
* 2. 将icon缓存在内存中,防止从因盘中重复读取
* 3. 提供了基于icon 的 ETag 属性,而不是通过文件信息来更新缓存
* 4. 使用最兼容的Content-Type处理请求
*/
const favicon = require('serve-favicon');
const compression = require('compression'); //压缩
const microcache = require('route-cache'); //请求缓存
const resolve = (file) => path.resolve(__dirname, file); //返回绝对路径
const {createBundleRenderer} = require('vue-server-renderer');
const isProd = process.env.NODE_ENV === 'production';
const useMicroCache = process.env.MICRO_CACHE !== 'false';
const serverInfo =
`express/${require('express/package').version}` +
`vue-server-renderer/${require('vue-server-renderer/package').version}`;
let renderer;
let readyPromise;
//生成renderer函数
function createRenderer(bundle, options) {
return createBundleRenderer(bundle, Object.assign(options, {
cache: new LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
basedir: resolve('./dist'),
runInNewContext: false
}));
}
function render(req, res) {
const s = Date.now();
res.setHeader('Content-type', 'text/html');
res.setHeader('Server', serverInfo);
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if(err.code === 404) {
res.status(404).send('404 | Page Not Found')
} else {
// Render Error Page or Redirect
res.status(500).send('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err.stack)
}
};
const context = {
title: 'ssr标题',
url: req.url
};
renderer.renderToString(context, (err, html) => {
console.log(err);
if (err) {
return handleError(err);
}
res.send(html);
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`);
}
})
}
const templatePath = resolve('./index.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');
renderer = createRenderer(bundle, {
template,
clientManifest
})
} else {
readyPromise = require('./build/setup-dev-server')(
app,
templatePath,
(bundle, options) => {
renderer = createRenderer(bundle, options)
}
)
}
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
});
//静态文件压缩,支持gzip和deflate方式(原来这步是nginx做的),threshold: 0, 0kb以上的都压缩,即所有文件都压缩,
//可通过filter过滤
//TODO
app.use(compression({threshold: 0}));
app.use(favicon('./favicon.ico'));
app.use('/dist', serve('./dist', true));
app.use('/static', serve('./static', true));
app.use('/service-worker.js', serve('./dist/service-worker.js', true));
//TODO
app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl));
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res));
});
// 监听
app.listen(8082, function () {
console.log('success listen...8082');
});
在路由resolve之前,做数据预渲染
import { createApp } from './app'
const isDev = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'sit';
// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
return new Promise((resolve, reject) => {
const s = isDev && Date.now();
const { app, router, store } = createApp();
const { url } = context;
const { fullPath } = router.resolve(url).route;
if (fullPath !== url) {
return reject({ url: fullPath })
}
// set router's location
router.push(url);
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// Call fetchData hooks on components matched by the route.
// A preFetch hook dispatches a store action and returns a Promise,
// which is resolved when the action is complete and store state has been
// updated.
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute
}))).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
// After all preFetch hooks are resolved, our store is now
// filled with the state needed to render the app.
// Expose the state on the render context, and let the request handler
// inline the state in the HTML response. This allows the client-side
// store to pick-up the server-side state without having to duplicate
// the initial data fetching on the client.
context.state = store.state;
resolve(app)
}).catch(reject)
}, reject)
})
}
window.INITIAL_STATE 就是服务端存在html中的store数据,客户端做一次同步,router.onReady在第一次不会触发,只有router接管页面之后才会触发,在beforeResolved中手动进行数据请求(否则asyncData中的请求不会触发)
import {createApp} from './app';
const {app, router, store} = createApp();
// prime the store with server-initialized state.
// the state is determined during SSR and inlined in the page markup.
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
// Add router hook for handling asyncData.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
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))
});
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _);
if (!asyncDataHooks.length) {
console.log('there no client async');
return next()
}
// bar.start()
console.log('client async begin');
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to }))).then(() => {
// bar.finish()
console.log('client async finish');
next()
}).catch(next)
});
//以激活模式挂载,不会改变浏览器已经渲染的内容
app.$mount('#app');
});
// service worker
if ('https:' === location.protocol && navigator.serviceWorker) {
navigator.serviceWorker.register('/service-worker.js')
}
import Vue from 'vue';
import App from './App.vue';
import {createStore} from './store';
import {createRouter} from './router';
import {sync} from 'vuex-router-sync';
export function createApp() {
const store = createStore();
const router = createRouter();
// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router);
// create the app instance.
// here we inject the router, store and ssr context to all child components,
// making them available everywhere as `this.$router` and `this.$store`.
const app = new Vue({
router,
store,
render: h => h(App)
});
// expose the app, the router and the store.
// note we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {app, router, store}
}
防止数据污染,每次都要创造新的实例
注意:路由如果用懒加载会出现vue模板里面的样式没办法抽离到css中,而是用js渲染,浏览器会出现一个没有样式到最终页面的空白期。由于SSR渲染做了没有SPA首屏渲染问题,所以不用懒加载也没事。
// store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export function createStore () {
return new Vuex.Store({
state: {
token,
},
mutations: {},
actions: {}
})
}
//router.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
import Table from '@/views/Table';
import page1 from '@/views/page1';
export const constantRouterMap = [
{ path: '/', component: Table, hidden: true },
{ path: '/page1', component: page1, hidden: true }
];
export function createRouter() {
return new Router({
mode: 'history',
fallback: false, // 设置浏览器不支持history.pushState时,不回退
linkActiveClass: 'open active',
scrollBehavior: () => ({ y: 0 }),
routes: constantRouterMap
})
}
const fs = require('fs');
const path = require('path');
//文件处理工具
const MFS = require('memory-fs');
const webpack = require('webpack');
//对fs.watch的包装,优化fs,watch原来的功能
const chokidar = require('chokidar');
const clientConfig = require('./webpack.client.config');
const serverConfig = require('./webpack.server.config');
const readFile = (fs, file) => {
try {
return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8');
} catch (e) {
}
};
module.exports = function setupDevServer(app, templatePath, cb) {
let bundle;
let template;
let clientManifest;
let ready;
const readyPromise = new Promise(r => ready = r);
//1. 生成新的renderer函数; 2. renderer.renderToString();
const update = () => {
if (bundle && clientManifest) {
//执行server.js中的render函数,但是是异步的
ready();
cb(bundle, {
template,
clientManifest
})
}
};
template = fs.readFileSync(templatePath, 'utf-8');
//模板改了之后刷新 TODO
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8');
console.log('index.html template updated');
update();
});
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app];
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
);
const clientComplier = webpack(clientConfig);
const devMiddleware = require('webpack-dev-middleware')(clientComplier, {
publicPath: clientConfig.output.publicPath,
noInfo: true
});
app.use(devMiddleware);
clientComplier.plugin('done', stats => {
stats = stats.toJson();
stats.errors.forEach(err => console.log(err));
stats.warnings.forEach(err => console.log(err));
if (stats.errors.length) return;
clientManifest = JSON.parse(readFile(
devMiddleware.fileSystem,
'vue-ssr-client-manifest.json'
));
update();
});
app.use(require('webpack-hot-middleware')(clientComplier, {heartbeat: 5000}));
const serverCompiler = webpack(serverConfig);
const mfs = new MFS();
serverCompiler.outputFileSystem = mfs;
//监听server文件修改 TODO
serverCompiler.watch({}, (err, stats) => {
if (err) throw err;
stats = stats.toJson();
if (stats.errors.length) return;
// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'));
update();
});
return readyPromise;
};
vue-loader.conf就是vue脚手架自动生成的文件,就不再贴出了
const path = require('path');
var utils = require('./utils');
var config = require('../config');
var vueLoaderConfig = require('./vue-loader.conf');
var ExtractTextPlugin = require('extract-text-webpack-plugin');
var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin');
var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin');
var CopyWebpackPlugin = require('copy-webpack-plugin')
function resolve(dir) {
return path.join(__dirname, '..', dir)
}
const isProd = process.env.NODE_ENV === 'production';
module.exports = {
devtool: isProd
? false
: '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
filename: isProd ? utils.assetsPath('js/[name].[chunkhash].js') : utils.assetsPath('[name].js'),
chunkFilename: isProd ? utils.assetsPath('js/[id].[chunkhash].js') : utils.assetsPath('[id].js')
},
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': resolve('client'),
'src': path.resolve(__dirname, '../client'),
'assets': path.resolve(__dirname, '../client/assets'),
'components': path.resolve(__dirname, '../client/components'),
'views': path.resolve(__dirname, '../client/views'),
'api': path.resolve(__dirname, '../client/api'),
'utils': path.resolve(__dirname, '../client/utils'),
'router': path.resolve(__dirname, '../client/router'),
'vendor': path.resolve(__dirname, '../client/vendor'),
'static': path.resolve(__dirname, '../static'),
}
},
externals: {
jquery: 'jQuery'
},
module: {
rules: [
// {
// test: /\.(js|vue)$/,
// loader: 'eslint-loader',
// enforce: "pre",
// include: [resolve('src'), resolve('test')],
// options: {
// formatter: require('eslint-friendly-formatter')
// }
// },
{
test: /\.vue$/,
loader: 'vue-loader',
options: vueLoaderConfig
},
{
test: /\.js$/,
loader: 'babel-loader?cacheDirectory',
include: [resolve('client'), resolve('test')]
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url-loader',
query: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url-loader',
query: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[ext]')
}
},
...utils.styleLoaders({sourceMap: config.dev.cssSourceMap})
]
},
plugins: isProd
? [
// new webpack.optimize.ModuleConcatenationPlugin(),
new ExtractTextPlugin({
filename: 'common.[chunkhash].css'
}),
// Compress extracted CSS. We are using this plugin so that possible
// duplicated CSS from different components can be deduped.
new OptimizeCSSPlugin(),
// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.build.assetsSubDirectory,
ignore: ['.*']
}
]),
]
: [
new FriendlyErrorsPlugin(),
// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.build.assetsSubDirectory,
ignore: ['.*']
}
]),
]
};
const webpack = require('webpack');
const merge = require('webpack-merge');
const config = require('../config');
const baseConfig = require('./webpack.base.config');
// const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
let env, NODE_ENV = process.env.NODE_ENV;
if (NODE_ENV === 'development') {
env = config.dev.env;
} else if (NODE_ENV === 'production') {
env = config.build.prodEnv;
} else {
env = config.build.sitEnv;
}
module.exports = merge(baseConfig, {
target: 'node',
devtool: '#source-map',
entry: './client/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
plugins: [
new webpack.DefinePlugin({
'process.env': env,
'process.env.VUE_ENV': '"server"',
}),
new VueSSRServerPlugin()
]
});
const webpack = require('webpack');
const merge = require('webpack-merge');
const config = require('../config');
const utils = require('./utils');
const base = require('./webpack.base.config');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
// TODO
// const SWPrecachePlugin = require('sw-precache-webpack-plugin');
let env, NODE_ENV = process.env.NODE_ENV;
if (NODE_ENV === 'development') {
env = config.dev.env;
} else if (NODE_ENV === 'production') {
env = config.build.prodEnv;
} else {
env = config.build.sitEnv;
}
module.exports = merge(base, {
entry: {
app: './client/entry-client.js'
},
plugins:
NODE_ENV !== 'development' ? [
new webpack.DefinePlugin({
'process.env': env,
'process.env.VUE_ENV': '"client"'
}),
new webpack.optimize.UglifyJsPlugin({
compress: {warnings: false}
}),
// extract vendor chunks for better caching
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
// a module is extracted into the vendor chunk if...
return (
// it's inside node_modules
/node_modules/.test(module.context) &&
// and not a CSS file (due to extract-text-webpack-plugin limitation)
!/\.css$/.test(module.request)
)
}
}),
// extract webpack runtime & manifest to avoid vendor chunk hash changing
// on every build.
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin(),
] : [
new webpack.DefinePlugin({
'process.env': env,
'process.env.VUE_ENV': '"client"'
}),
new VueSSRClientPlugin(),
],
});
开发模式:npm run dev
生产模式:npm run build & npm run start
"scripts": {
"dev": "cross-env NODE_ENV=development supervisor server/app.js",
"start": "cross-env NODE_ENV=production node server/app.js",
"build": "npm run build:client && npm run build:server",
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js"
},
store.js
//store.js
import {TableRequest} from '@/api/Table';
export default {
state: {
userName: ''
},
mutations: {
GETUSERNAME(state, data) {
state.userName = data;
}
},
actions: {
GetUserName({commit}) {
return TableRequest().then(res => {
commit('GETUSERNAME', res.data.content);
})
}
}
}
// api.js 请求可以用node做一个
export function GetUserName (user, password) {
const data = {
user,
password
}
return fetch({
url: '/apis/getUserName',
data
})
}
vue页面
<template>
<div>
{{$store.state.userName}}
</div>
</template>
<script>
export default {
name: 'APP',
asyncData({store, route}) {
return store.dispatch('fetchItem', route.params.id);
},
}
</script>
server.js添加请求处理
const fs = require('fs'); //读取文件
const path = require('path');
const express = require('express');
const app= express();
const LRU = require('lru-cache'); //封装缓存的get set方法
/*
* 处理favicon.ico文件:作用:
* 1. 去除这些多余无用的日志
* 2. 将icon缓存在内存中,防止从因盘中重复读取
* 3. 提供了基于icon 的 ETag 属性,而不是通过文件信息来更新缓存
* 4. 使用最兼容的Content-Type处理请求
*/
const favicon = require('serve-favicon');
const compression = require('compression'); //压缩
const microcache = require('route-cache'); //请求缓存
const resolve = (file) => path.resolve(__dirname, file); //返回绝对路径
const {createBundleRenderer} = require('vue-server-renderer');
const isProd = process.env.NODE_ENV === 'production';
const useMicroCache = process.env.MICRO_CACHE !== 'false';
const serverInfo =
`express/${require('express/package').version}` +
`vue-server-renderer/${require('vue-server-renderer/package').version}`;
let renderer;
let readyPromise;
//生成renderer函数
function createRenderer(bundle, options) {
return createBundleRenderer(bundle, Object.assign(options, {
cache: new LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
basedir: resolve('./dist'),
runInNewContext: false
}));
}
function render(req, res) {
const s = Date.now();
res.setHeader('Content-type', 'text/html');
res.setHeader('Server', serverInfo);
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if(err.code === 404) {
res.status(404).send('404 | Page Not Found')
} else {
// Render Error Page or Redirect
res.status(500).send('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err.stack)
}
};
const context = {
title: 'ssr标题',
url: req.url
};
renderer.renderToString(context, (err, html) => {
console.log(err);
if (err) {
return handleError(err);
}
res.send(html);
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`);
}
})
}
const templatePath = resolve('./index.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');
renderer = createRenderer(bundle, {
template,
clientManifest
})
} else {
readyPromise = require('./build/setup-dev-server')(
app,
templatePath,
(bundle, options) => {
renderer = createRenderer(bundle, options)
}
)
}
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
});
/**
* 开始
* 测试数据请求的配置,可删
**/
app.get('/getName', function (req, res, next) {
res.json({
code: 200,
content: '我是userName',
msg: '请求成功'
})
});
/**
* 结束
* 测试数据请求的配置,可删
**/
//静态文件压缩,支持gzip和deflate方式(原来这步是nginx做的),threshold: 0, 0kb以上的都压缩,即所有文件都压缩,
//可通过filter过滤
//TODO
app.use(compression({threshold: 0}));
app.use(favicon('./favicon.ico'));
app.use('/dist', serve('./dist', true));
app.use('/static', serve('./static', true));
app.use('/service-worker.js', serve('./dist/service-worker.js', true));
//TODO
app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl));
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res));
});
// 监听
app.listen(8082, function () {
console.log('success listen...8082');
});
以上就是结合vue-cli改造的一个ssr框架,流程和原理理解了之后可自行改造相关配置。