當客戶端瀏覽器發起一個地址請求時,服務端直接返回完整的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" } }
這裏僅提供簡單的可運行的代碼,詳細瞭解參見官網