關(guān)于VueSSR的一些理解和詳細(xì)配置
如果是靜態(tài)頁面,例如官網(wǎng)的SSR處理可直接使用prerender-spa-plugin插件實現(xiàn)預(yù)渲染,參考我之前的博客:vue單頁面通過prerender-spa-plugin插件進(jìn)行SEO優(yōu)化
以下是基于vue-cli@2.x生成的工程相關(guān)的結(jié)構(gòu)改造。文章最后有異步請求的例子可供參考。
概念
流程圖
這是具體的流程圖,如何實現(xiàn),后續(xù)配置會詳解
編譯圖解
結(jié)合上面的流程圖來理解編譯的過程圖,因為服務(wù)端渲染只是一個可以等待異步數(shù)據(jù)的預(yù)渲染,最終用戶交互還是需要Client entry生成的js來控制,這就是為什么需要兩個entry文件,但是擁有同樣的入口(app.js)的原因。
串聯(lián)server和client的樞紐就是store,server將預(yù)渲染頁面的sotre數(shù)據(jù)放入window全局中,client再進(jìn)行數(shù)據(jù)同步,完成異步數(shù)據(jù)預(yù)渲染
相關(guān)配置
知道大致步驟和流程,再去理解VueSSR的配置就不會那么突兀了。
注意:路由如果用懶加載會出現(xiàn)vue模板里面的樣式?jīng)]辦法抽離到css中,而是用js渲染,瀏覽器會出現(xiàn)一個沒有樣式到最終頁面的空白期。由于SSR渲染做了沒有SPA首屏渲染問題,所以不用懶加載也沒事。
目錄結(jié)構(gòu)
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>
需要安裝的依賴
多了一些相關(guān)的依賴需要添加一下,這里做一個匯總:
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
開發(fā)模式會調(diào)用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緩存在內(nèi)存中,防止從因盤中重復(fù)讀取 * 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函數(shù) 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標(biāo)題', 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 }); //靜態(tài)文件壓縮,支持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)); }); // 監(jiān)聽 app.listen(8082, function () { console.log('success listen...8082'); });
entry-server.js
在路由resolve之前,做數(shù)據(jù)預(yù)渲染
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 就是服務(wù)端存在html中的store數(shù)據(jù),客戶端做一次同步,router.onReady在第一次不會觸發(fā),只有router接管頁面之后才會觸發(fā),在beforeResolved中手動進(jìn)行數(shù)據(jù)請求(否則asyncData中的請求不會觸發(fā))
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; //只需匹配和上一個路由不同的路由,共用的路由因為已經(jīng)渲染過了,不需要再處理 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) }); //以激活模式掛載,不會改變?yōu)g覽器已經(jīng)渲染的內(nèi)容 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
防止數(shù)據(jù)污染,每次都要創(chuàng)造新的實例
注意:路由如果用懶加載會出現(xiàn)vue模板里面的樣式?jīng)]辦法抽離到css中,而是用js渲染,瀏覽器會出現(xiàn)一個沒有樣式到最終頁面的空白期。由于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, // 設(shè)置瀏覽器不支持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的包裝,優(yōu)化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函數(shù); 2. renderer.renderToString(); const update = () => { if (bundle && clientManifest) { //執(zhí)行server.js中的render函數(shù),但是是異步的 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; //監(jiān)聽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(), ], });
啟動命令
- 開發(fā)模式:npm run dev
- 生產(chǎn)模式: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緩存在內(nèi)存中,防止從因盤中重復(fù)讀取 * 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函數(shù) 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標(biāo)題', 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 }); /** * 開始 * 測試數(shù)據(jù)請求的配置,可刪 **/ app.get('/getName', function (req, res, next) { res.json({ code: 200, content: '我是userName', msg: '請求成功' }) }); /** * 結(jié)束 * 測試數(shù)據(jù)請求的配置,可刪 **/ //靜態(tài)文件壓縮,支持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)); }); // 監(jiān)聽 app.listen(8082, function () { console.log('success listen...8082'); });
結(jié)尾
以上就是結(jié)合vue-cli改造的一個ssr框架,流程和原理理解了之后可自行改造相關(guān)配置。希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
vue+element table表格實現(xiàn)動態(tài)列篩選的示例代碼
這篇文章主要介紹了vue+element table表格實現(xiàn)動態(tài)列篩選的示例代碼,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-01-01vue自適應(yīng)布局postcss-px2rem詳解
這篇文章主要介紹了vue自適應(yīng)布局(postcss-px2rem)的相關(guān)知識,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2022-05-05