当客户端浏览器发起一个地址请求时,服务端直接返回完整的HTML内容给浏览器进行渲染。javascript
将本来Vue.js (构建客户端应用程序的框架)输出在浏览器中的 Vue 组件由服务器端()渲染为 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上彻底可交互的应用程序。css
更好的 SEO(搜索引擎爬虫抓取工具能够直接查看彻底渲染的页面。目前Google 和 Bing 能够很好对同步 JavaScript 应用程序进行索引):html
若是你的应用程序初始展现 loading 菊花图,而后经过 Ajax 获取内容,抓取工具并不会等待异步完成后再行抓取页面内容。也就是说,若是 SEO 对你的站点相当重要,而你的页面又是异步获取内容,则你可能须要服务器端渲染(SSR)解决此问题。前端
更快的内容到达时间 (time-to-content,无需等待全部的 js都下载并执行完,才显示完整的数据,因此用户将会更快速地看到完整渲染的页面):vue
网络或设备运行缓慢的状况一般能够改善的用户体验,而且对于那些「内容到达时间(time-to-content) 与转化率直接相关」的应用程序而言,服务器端渲染 (SSR) 相当重要,能够帮助你实现最佳的初始加载性能。java
开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用;一些外部扩展库 (external library) 可能须要特殊处理,才能在服务器渲染应用程序中运行。node
涉及构建设置和部署的更多要求。与能够部署在任何静态文件服务器上的彻底静态单页面应用程序 (SPA) 不一样,服务器渲染应用程序,须要处于 Node.js server 运行环境。webpack
更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源 (CPU-intensive - CPU 密集),所以若是你预料在高流量环境 (high traffic) 下使用,请准备相应的服务器负载,并明智地采用缓存策略。ios
若是你的项目只有少数营销页面须要SEO ,那么你可能只须要预渲染。在构建时 (build time) 针对特定路由简单地生成静态 HTML 文件。预渲染优势是:设置更简单,并能够将你的前端做为一个彻底静态的站点,无需使用 web 服务器实时动态编译 HTML。web
准备:
vue-server-renderer
和 vue
必须匹配版本。vue-server-renderer
依赖一些 Node.js 原生模块,所以只能在 Node.js 中使用。npm install vue npm install vue vue-server-renderer --save npm install express --save
开始:server.js
//引入 const Vue = require('vue') const server = require('express')() const renderer = require('vue-server-renderer').createRenderer() server.get('*', (req, res) => { // 第 1 步:建立一个 Vue 实例 const app = new Vue({ data: { hello: 'hello,vue ssr' }, template: `<div>{{ hello }}</div>` }) // 第 3 步:将 Vue 实例渲染为 HTML 字符串 renderer.renderToString(app, (err, html) => { if (err) { res.status(500).end('Internal Server Error') return } //第 4 步:将拼接好的完整HTML发送给客户端让浏览器直接渲染 res.end(` <!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body>${html}</body> </html> `) }) }) //监听端口 server.listen(8080)
运行:
node server.js
结果:能够看到服务器返回给浏览器的HTML有个data-server-rendered="true"表示这段内容是服务端渲染
结合官网示例,操做须要注意的说明都有打注释,没有出如今代码里的注意项会单独写出来。这里只贴出了与SPA项目不一样的代码。
项目结构:
开发环境运行配置示例:build/setup-dev-server.js
const fs = require('fs') const path = require('path') const MFS = require('memory-fs') const webpack = require('webpack') /*chokidar 是封装 Node.js 监控文件系统文件变化功能的库。解决nodeJs原生监控文件系统的问题: * 1.事件处理有大量问题 * 2.不提供递归监控文件树功能 * 3.致使 CPU 占用高 */ 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 }) const update = () => { if (bundle && clientManifest) { ready() cb(bundle, { template, clientManifest }) } } // read template from disk and watch template = fs.readFileSync(templatePath, 'utf-8') chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf-8') console.log('index.html template updated.') update() }) // modify client config to work with hot middleware clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] clientConfig.output.filename = '[name].js' clientConfig.plugins.push( new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() ) // dev middleware const clientCompiler = webpack(clientConfig) const devMiddleware = require('webpack-dev-middleware')(clientCompiler, { publicPath: clientConfig.output.publicPath, noInfo: true }) app.use(devMiddleware) clientCompiler.plugin('done', stats => { stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) if (stats.errors.length) return clientManifest = JSON.parse(readFile( devMiddleware.fileSystem, 'vue-ssr-client-manifest.json' )) update() }) // hot middleware app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 })) // watch and update server renderer const serverCompiler = webpack(serverConfig) const mfs = new MFS() serverCompiler.outputFileSystem = mfs 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 }
生产环境客户端打包配置示例:build/webpack.client.config.js:
const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') //用于使用service workers缓存您的外部项目依赖项。它将使用sw-precache生成一个服务工做者文件,并将其添加到您的构建目录中。 const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const config = merge(base, { entry: { app: './src/entry-client.js' }, optimization: { splitChunks: { cacheGroups: { commons: { name: 'vendor', minChunks: 1 } } } }, plugins: [ new webpack.DefinePlugin({ 'process.env.VUE_ENV': '"client"' }), // 此插件在输出目录中 // 生成 `vue-ssr-client-manifest.json`。 new VueSSRClientPlugin() ] }) module.exports = config
生产环境服务端打包配置示例:build/webpack.server.config.js
const webpack = require('webpack') const merge = require('webpack-merge') const base = require('./webpack.base.config') //用于使用service workers缓存您的外部项目依赖项。它将使用sw-precache生成一个服务工做者文件,并将其添加到您的构建目录中。 const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const config = merge(base, { entry: { app: './src/entry-client.js' }, optimization: { splitChunks: { cacheGroups: { commons: { name: 'vendor', minChunks: 1 } } } }, plugins: [ new webpack.DefinePlugin({ 'process.env.VUE_ENV': '"client"' }), // 此插件在输出目录中 // 生成 `vue-ssr-client-manifest.json`。 new VueSSRClientPlugin() ] }) module.exports = config
状态管理模块示例:src/store/modules/test.js
export default { namespaced: true, // 重要信息:state 必须是一个函数, // 所以能够建立多个实例化该模块 state: () => ({ count: 1 }), actions: { inc: ({ commit }) => commit('inc') }, mutations: { inc: state => state.count++ } }
状态管理使用示例:src/views/Home.vue
<template> <section> 这里是:views/Home.vue 状态管理数据{{fooCount}} <hello-world></hello-world> </section> </template> <script> import HelloWorld from '../components/HelloWorld.vue' // 在这里导入模块,而不是在 `store/index.js` 中 import fooStoreModule from '../store/modules/test' export default { asyncData ({ store }) { store.registerModule('foo', fooStoreModule); return store.dispatch('foo/inc') }, // 重要信息:当屡次访问路由时, // 避免在客户端重复注册模块。 destroyed () { this.$store.unregisterModule('foo') }, computed: { fooCount () { return this.$store.state.foo.count } }, components: { HelloWorld } } </script>
通用入口:src/app.js:
注意:router、store、vue实例的建立要封装成构造函数,以便每次访问时服务端返回的是一个全新的实例对象
/*app.js通用入口。 *核心做用是建立Vue实例。相似SPA的main.js。 */ import Vue from 'vue' //导入跟页面 import App from './App.vue' // 导入路由生成器 import {createRouter} from "./router"; // 导入状态管理生成器 import {createStore} from "./store"; import {sync} from 'vuex-router-sync' //建立并导出 vue实例生成器 export function createApp() { // 生成路由器 let router = createRouter(); // 生成状态管理器 let store = createStore(); // 同步路由状态(route state)到 store sync(store, router); let app = new Vue({ //将路由器挂载到vue实例 router, //将状态管理器挂载到vue实例 store, // 生成App渲染 render: h => h(App) }); //返回生成的实例们 return {app, router, store} }
客户端渲染入口文件:src/entry-client.js
/** entry-client.js客户端入口。 * 仅运行于浏览器 * 核心做用:挂载、激活app。将服务器刚刚返回给浏览器的完整HTML替换为spa */ // 导入App生成器 import {createApp} from "./app"; //建立实例们 const {app, router,store} = createApp(); //当使用 template 时,context.state 将做为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序以前,store 就应该获取到状态 if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } // 路由就绪后 router.onReady(() => { // 添加路由钩子函数,用于处理 asyncData. // 在初始路由 resolve 后执行, // 以便咱们不会二次预取(double-fetch)已有的数据。 // 使用 `router.beforeResolve()`,以便确保全部异步组件都 resolve。 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() } // 这里若是有加载指示器 (loading indicator),就触发 Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({store, route: to}) } })).then(() => { // 中止加载指示器(loading indicator) next() }).catch(next) }); // 将App实例挂载到#app对应的DOM节点。在没有 data-server-rendered 属性的元素上向 $mount 函数的 hydrating 参数位置传入 true,强制使用应用程序的激活模式:app.$mount('#app', true) app.$mount('#app'); });
服务端渲染入口文件:src/entry-server.js
/** entry-server.js服务端入口。 * 仅运行于服务器。 * 核心做用是:拿到App实例生成HTML返回给浏览器渲染首屏 */ //导入App生成器 import {createApp} from "./app"; /* context:“服务器”调用上下文。如:访问的url,根据url决定未来createApp里路由的具体操做 */ export default context => { return new Promise((resolve, reject) => { //建立App实例,router实例 const {app, router, store} = createApp(); //进入首屏:约定node服务器会将浏览器请求的url放进上下文context中,使用router.push()将当前访问的url对应的vue组件路由到App实例当前页 router.push(context.url); //路由准备就绪后 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,执行 reject 函数,并返回 404 if (!matchedComponents.length) { return reject({code: 404}) } // 对全部匹配的路由组件调用 `asyncData()` Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }) } })).then(() => { // 在全部预取钩子(preFetch hook) resolve 后, // 咱们的 store 如今已经填充入渲染应用程序所需的状态。 // 当咱们将状态附加到上下文, // 而且 `template` 选项用于 renderer 时, // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 context.state = store.state; context.title = router.currentRoute.name; //将渲染出来的App返回 resolve(app); }, reject) }); }); }
服务端渲染模板:index.template.html
注意:data-server-rendered
特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,而且应该以激活模式进行挂载。注意,这里并无添加 id="app"
,而是添加 data-server-rendered
属性:你须要自行添加 ID 或其余可以选取到应用程序根元素的选择器,不然应用程序将没法正常激活。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> <meta http-equiv="X-UA-Compatible" content="ie=edge"/> <title>vue ssr</title> </head> <body> <div id="app"> <!--vue-ssr-outlet--> </div> </body>
项目运行入口文件:server.js
//nodeJs 服务器 const fs = require('fs'); const path = require('path'); const express = require('express'); //建立 express实例 const server = express(); //导入渲染器插件 const { createBundleRenderer } = require('vue-server-renderer'); const resolve = file => path.resolve(__dirname, file); const templatePath = resolve('./src/index.template.html'); //获取 npm run 后面的命令 const isProd = process.env.NODE_ENV === 'production'; /** * 建立Renderer渲染器 */ function createRenderer(bundle, options) { return createBundleRenderer( bundle, Object.assign(options, { runInNewContext: false }) ); } let renderer; //生产环境 if (isProd) { const template = fs.readFileSync(templatePath, 'utf-8'); const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); renderer = createRenderer(serverBundle, { template, clientManifest }); } else { readyPromise = require('./build/setup-dev-server.js')( server, templatePath, (bundle, options) => { renderer = createRenderer(bundle, options); } ); } //当浏览器请求 *(任意接口)时 server.get('*', async (req, res) => { try { const context = { url: req.url }; //将url对应的vue组件渲染为HTML const html = await renderer.renderToString(context); //将HTML返回给浏览器 res.send(html); } catch (e) { console.log(e); res.status(500).send('服务器内部错误'); } }); //监听浏览器8080端口 server.listen(8080, () => { console.log('监听8000,服务器启动成功') });
package.json:
{ "name": "webpackstudy", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "nodemon server", "build": "npm run build:client && npm run build:server", "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules", "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules", "mock": "webpack-dev-server --progress --color" }, "author": "", "license": "ISC", "dependencies": { "axios": "^0.19.0", "body-parser": "^1.19.0", "cheerio": "^1.0.0-rc.3", "cookie-parser": "^1.4.4", "cookie-session": "^1.3.3", "cors": "^2.8.5", "express": "^4.17.1", "jsonwebtoken": "^8.5.1", "mongoose": "^5.7.7", "multer": "^1.4.2", "nodemailer": "^6.3.1", "redis": "^2.8.0", "request": "^2.88.0", "util": "^0.12.1", "vue-router": "^3.1.2", "vuex": "^3.1.1", "ws": "^7.2.0" }, "devDependencies": { "@babel/core": "^7.5.5", "@babel/preset-env": "^7.5.5", "@vue/cli-plugin-typescript": "^4.0.5", "autoprefixer": "^9.6.1", "babel-loader": "^8.0.6", "clean-webpack-plugin": "^3.0.0", "compression": "^1.7.4", "cross-env": "^6.0.3", "css-loader": "^3.2.0", "extract-text-webpack-plugin": "^3.0.2", "file-loader": "^4.2.0", "friendly-errors-webpack-plugin": "^1.7.0", "fs": "0.0.1-security", "html-webpack-plugin": "^3.2.0", "html-withimg-loader": "^0.1.16", "install": "^0.13.0", "jsonc": "^2.0.0", "less": "^3.10.2", "less-loader": "^5.0.0", "lru-cache": "^5.1.1", "memory-fs": "^0.5.0", "mini-css-extract-plugin": "^0.8.0", "mocker-api": "^1.8.1", "npm": "^6.13.3", "optimize-css-assets-webpack-plugin": "^5.0.3", "postcss-loader": "^3.0.0", "route-cache": "^0.4.4", "serve-favicon": "^2.5.0", "style-loader": "^1.0.0", "sw-precache-webpack-plugin": "^0.11.5", "terser-webpack-plugin": "^1.4.1", "uglifyjs-webpack-plugin": "^2.2.0", "url-loader": "^2.1.0", "vue": "^2.6.10", "vue-loader": "^15.7.1", "vue-server-renderer": "^2.6.10", "vue-style-loader": "^4.1.2", "vue-template-compiler": "^2.6.10", "vuex-router-sync": "^5.0.0", "webpack": "^4.39.2", "webpack-cli": "^3.3.7", "webpack-dev-server": "^3.8.0", "webpack-hot-middleware": "^2.25.0", "webpack-merge": "^4.2.2", "webpack-node-externals": "^1.7.2" } }
这里仅提供简单的可运行的代码,详细了解参见官网