关于VueSSR的一些理解和详细配置
作者:Chepy2018
如果是静态页面,例如官网的SSR处理可直接使用prerender-spa-plugin插件实现预渲染,参考我之前的博客:vue单页面通过prerender-spa-plugin插件进行SEO优化
以下是基于vue-cli@2.x生成的工程相关的结构改造。文章最后有异步请求的例子可供参考。
概念
流程图
这是具体的流程图,如何实现,后续配置会详解
编译图解
结合上面的流程图来理解编译的过程图,因为服务端渲染只是一个可以等待异步数据的预渲染,最终用户交互还是需要Client entry生成的js来控制,这就是为什么需要两个entry文件,但是拥有同样的入口(app.js)的原因。
串联server和client的枢纽就是store,server将预渲染页面的sotre数据放入window全局中,client再进行数据同步,完成异步数据预渲染
相关配置
知道大致步骤和流程,再去理解VueSSR的配置就不会那么突兀了。
注意:路由如果用懒加载会出现vue模板里面的样式没办法抽离到css中,而是用js渲染,浏览器会出现一个没有样式到最终页面的空白期。由于SSR渲染做了没有SPA首屏渲染问题,所以不用懒加载也没事。
目录结构
index.html 的改动
需要加入<!–vue-ssr-outlet–>占位符
<!DOCTYPE html> <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"> <!--vue-ssr-outlet--> </div> </body> </html>
需要安装的依赖
多了一些相关的依赖需要添加一下,这里做一个汇总:
npm install memory-fs chokidar vue-server-renderer@2.5.17 lru-cache serve-favicon compression route-cache vuex-router-sync --save
注意: vue-server-renderer要和工程的vue版本一致。
server.js
开发模式会调用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'); });
entry-server.js
在路由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) }) }
entery-client.js
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') }
app.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} }
router.js 和 store.js
防止数据污染,每次都要创造新的实例
注意:路由如果用懒加载会出现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 }) }
setup-dev-server.js
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; };
webpack.base.config.js
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: ['.*'] } ]), ] };
webpack.server.config.js
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() ] });
webpack.client.config.js
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框架,流程和原理理解了之后可自行改造相关配置。希望能给大家一个参考,也希望大家多多支持脚本之家。