您好,登錄后才能下訂單哦!
這篇文章主要介紹vue項(xiàng)目如何改造成SSR,文中介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們一定要看完!
一、改造技術(shù)的分析對(duì)比。
一般來(lái)說(shuō),我們做seo有兩種方式:
1、預(yù)渲染
我在性能優(yōu)化的博客中說(shuō)過(guò),預(yù)渲染的問(wèn)題,預(yù)渲染是一個(gè)方案,使用爬蟲技術(shù)。由于我們打包過(guò)后的都是一些js文件,使用一些技術(shù)(puppeteer)可以爬取到項(xiàng)目在chrome瀏覽器展示的頁(yè)面,然后把它寫入js,和打包文件一起。
類似prerender-spa-plugin 。最大的特點(diǎn)就是,所有獲取的數(shù)據(jù)都是靜態(tài)的,比如說(shuō)你的頁(yè)面首頁(yè)有新聞,是通過(guò)接口獲取的,當(dāng)你在2019-11-30打包之后,不管用戶在2020年也是看到的2019-11-30的新聞,當(dāng)然的爬蟲爬到的也是。
如果你只需要改善少數(shù)頁(yè)面(例如 /, /about, /contact 等)的 SEO,那么你可能需要預(yù)渲染
2、服務(wù)端渲染
服務(wù)端渲染是將完整的 html 輸出到客戶端,又被認(rèn)為是‘同構(gòu)'或‘通用',如果你的項(xiàng)目有大量的detail頁(yè)面,相互特別頻繁,建議選擇服務(wù)端渲染。
**服務(wù)端渲染除了SEO還有很多時(shí)候用作首屏優(yōu)化,加快首屏速度,提高用戶體驗(yàn)。**但是對(duì)服務(wù)器有要求,網(wǎng)絡(luò)傳輸數(shù)據(jù)量大,占用部分服務(wù)器運(yùn)算資源。
由于三大框架的興起,SPA項(xiàng)目到處都是,所以涌現(xiàn)了一批nuxt.js、next.js這些服務(wù)器渲染的框架。但是這些框架構(gòu)建出來(lái)的項(xiàng)目可能文件夾和我們現(xiàn)有的項(xiàng)目很大不一樣,所以本文章主要是用vue-server-renderer來(lái)對(duì)現(xiàn)有項(xiàng)目進(jìn)行改造,而不是去用框架。
ps:(劃重點(diǎn))單頁(yè)面項(xiàng)目的ssr改造的原理:
vue項(xiàng)目是通過(guò)虛擬 DOM來(lái)掛載到html的,所以對(duì)spa項(xiàng)目,爬蟲才會(huì)只看到初始結(jié)構(gòu)。虛擬 DOM,最終要通過(guò)一定的方法將其轉(zhuǎn)換為真實(shí) DOM。虛擬 DOM 也就是 JS 對(duì)象,整個(gè)服務(wù)端的渲染流程就是通過(guò)虛擬 DOM 的編譯成完整的html來(lái)完成的。
我們通過(guò)服務(wù)端渲染解析虛擬 DOM成html之后,你會(huì)發(fā)現(xiàn)頁(yè)面的事件,都沒(méi)法觸發(fā)。那是因?yàn)榉?wù)端渲染vue-server-renderer插件并沒(méi)有做這方面的處理,所以我們需要客戶端再渲染一遍,簡(jiǎn)稱同構(gòu)。所以Vue服務(wù)端渲染其實(shí)是渲染了兩遍。下面給出一個(gè)官方的圖:
二、改造前后目錄文件對(duì)比
黃線部分是改造后新增的文件,怎么樣,是不是覺(jué)得差別不大,總體架構(gòu)上只有6個(gè)文件的差別。(#.#) 我們來(lái)理一理這些新增的文件。
server.dev.conf.js 本地調(diào)試和熱更新需要的配置文件
webpack.client.conf.js 客戶端打包配置文件,ssr打包是生成分為客戶端和服務(wù)端的兩部分打包文件
webpack.server.conf.js 服務(wù)端打包配置文件,ssr打包是生成分為客戶端和服務(wù)端的兩部分打包文件
entry-client.js 客戶端入口文件。spa的入口是main.js,ssr就分為兩個(gè)入口(服務(wù)端和客戶端)
entry-server.js 服務(wù)端入口文件。spa的入口是main.js,ssr就分為兩個(gè)入口(服務(wù)端和客戶端)
index.template.html 模板文件,因?yàn)榉?wù)端渲染是通過(guò)服務(wù)器把頁(yè)面丟出來(lái),所以我們需要一個(gè)模板,作為頁(yè)面初始載體,然后往里面添加內(nèi)容。
server.js 啟動(dòng)文件,服務(wù)端渲染我們需要啟動(dòng)一個(gè)node服務(wù)器,主要配置在這個(gè)文件里面。
三、webpack添加客戶端與服務(wù)端配置
1.webpack客戶端配置
const webpack = require('webpack') const merge = require('webpack-merge') const baseConfig = require('./webpack.base.conf.js') const HtmlWebpackPlugin = require('html-webpack-plugin') const path = require('path') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') module.exports = merge(baseConfig, { entry: './src/entry-client.js', plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: "manifest", minChunks: Infinity }), // 此插件在輸出目錄中 // 生成 `vue-ssr-client-manifest.json`。 new VueSSRClientPlugin(), new HtmlWebpackPlugin({ template: path.resolve(__dirname, './../src/index.template.html'), filename: 'index.template.html' }) ] })
這里面和spa項(xiàng)目有兩點(diǎn)不同,第一是入口變了,變?yōu)榱薳ntry-client.js。第二是VueSSRClientPlugin,這個(gè)是生成一個(gè)vue-ssr-client-manifest.json客戶端入口文件。
2.webpack服務(wù)端配置
const merge = require('webpack-merge') const nodeExternals = require('webpack-node-externals') const baseConfig = require('./webpack.base.conf.js') const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') module.exports = merge(baseConfig, { // 將 entry 指向應(yīng)用程序的 server entry 文件 entry: './src/entry-server.js', // 這允許 webpack 以 Node 適用方式(Node-appropriate fashion)處理動(dòng)態(tài)導(dǎo)入(dynamic import), // 并且還會(huì)在編譯 Vue 組件時(shí), // 告知 `vue-loader` 輸送面向服務(wù)器代碼(server-oriented code)。 target: 'node', // 對(duì) bundle renderer 提供 source map 支持 devtool: 'source-map', // 此處告知 server bundle 使用 Node 風(fēng)格導(dǎo)出模塊(Node-style exports) output: { libraryTarget: 'commonjs2' }, externals: nodeExternals({ // 不要外置化 webpack 需要處理的依賴模塊。 // 你可以在這里添加更多的文件類型。例如,未處理 *.vue 原始文件, // 你還應(yīng)該將修改 `global`(例如 polyfill)的依賴模塊列入白名單 whitelist: /\.css$/ }), // 這是將服務(wù)器的整個(gè)輸出 // 構(gòu)建為單個(gè) JSON 文件的插件。 // 默認(rèn)文件名為 `vue-ssr-server-bundle.json` plugins: [ new VueSSRServerPlugin() ] })
這段代碼一目了然,第一是是告訴webpack這是要打包node能運(yùn)行的東西,第二是打包一個(gè)服務(wù)端入口vue-ssr-server-bundle.json
四、vue、router、store實(shí)例改造
當(dāng)編寫純客戶端 (client-only) 代碼時(shí),我們習(xí)慣于每次在新的上下文中對(duì)代碼進(jìn)行取值。但是,Node.js 服務(wù)器是一個(gè)長(zhǎng)期運(yùn)行的進(jìn)程。當(dāng)我們的代碼進(jìn)入該進(jìn)程時(shí),它將進(jìn)行一次取值并留存在內(nèi)存中。這意味著如果創(chuàng)建一個(gè)單例對(duì)象,它將在每個(gè)傳入的請(qǐng)求之間共享。
nodejs是一個(gè)運(yùn)行時(shí),如果只是個(gè)單例的話,所有的請(qǐng)求都會(huì)共享這個(gè)單例,會(huì)造成狀態(tài)污染。所以我們需要為每個(gè)請(qǐng)求創(chuàng)造一個(gè)vue,router,store實(shí)例。
第一步修改main.js
// main.js import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' import { createStore } from './store/store.js' import { sync } from 'vuex-router-sync' export function createApp () { // 創(chuàng)建 router 實(shí)例 const router = createRouter() const store = createStore() // 同步路由狀態(tài)(route state)到 store sync(store, router) const app = new Vue({ // 注入 router 到根 Vue 實(shí)例 router, store, render: h => h(App) }) // 返回 app 和 router return { app, router, store } }
看到這個(gè)createApp沒(méi),沒(méi)錯(cuò),它就是我們熟悉的工廠模式。同樣的store和router一樣改造
// router.js import Vue from 'vue' import Router from 'vue-router' import HelloWorld from '@/components/HelloWorld' Vue.use(Router) export let createRouter = () => { let route = new Router({ mode:'history', routes: [] }) return route }
// store.js // store.js import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export function createStore () { return new Vuex.Store({ state: { }, actions: { }, mutations: { } }) }
到這里,三個(gè)實(shí)例對(duì)象改造完成了。是不是很簡(jiǎn)單~
五、數(shù)據(jù)預(yù)取和存儲(chǔ)
服務(wù)器渲染,可以理解為在被訪問(wèn)的時(shí)候,服務(wù)端做預(yù)渲染生成頁(yè)面,上面說(shuō)過(guò),預(yù)渲染的缺點(diǎn)就是,實(shí)時(shí)數(shù)據(jù)的獲取。所以如果應(yīng)用程序依賴于一些異步數(shù)據(jù),那么在開始渲染過(guò)程之前,需要先預(yù)取和解析好這些數(shù)據(jù)。
另一個(gè)需要關(guān)注的問(wèn)題是在客戶端,在掛載 (mount) 到客戶端應(yīng)用程序之前,需要獲取到與服務(wù)器端應(yīng)用程序完全相同的數(shù)據(jù) - 否則,客戶端應(yīng)用程序會(huì)因?yàn)槭褂门c服務(wù)器端應(yīng)用程序不同的狀態(tài),然后導(dǎo)致混合失敗。這個(gè)地方上面提過(guò),叫同構(gòu)(服務(wù)端渲染一遍,客戶端拿到數(shù)據(jù)再渲染一遍)。
因?yàn)槲覀冇玫膙ue框架嘛,那當(dāng)然數(shù)據(jù)存儲(chǔ)選vuex咯。然后我們來(lái)理一下總體的流程:
客戶端訪問(wèn)網(wǎng)站 —> 服務(wù)器獲取動(dòng)態(tài)數(shù)據(jù),生成頁(yè)面,并把數(shù)據(jù)存入vuex中,然后返回html —> 客戶端獲取html(此時(shí)已經(jīng)返回了完整的頁(yè)面) —> 客戶端獲取到vuex的數(shù)據(jù),并解析到vue里面,然后再一次找到根元素掛載vue,重復(fù)渲染頁(yè)面。(同構(gòu)階段)
流程清楚之后,那我們?cè)趺丛O(shè)定,哪個(gè)地方的代碼,被服務(wù)端執(zhí)行,并獲取數(shù)據(jù)存入vuex呢? 我們分為三步:
1.自定義函數(shù)asyncData
官方的例子是定義一個(gè)asyncData函數(shù)(這個(gè)名字不是唯一的哈,是自己定義的,可以隨便取,不要理解為內(nèi)置的函數(shù)哈),這個(gè)函數(shù)寫在路由組件里面。
假設(shè)有一個(gè)Item.vue組件(官網(wǎng)的例子)
<!-- Item.vue --> <template> <div>{{ item.title }}</div> </template> <script> export default { asyncData ({ store, route }) { // 觸發(fā) action 后,會(huì)返回 Promise return store.dispatch('fetchItem', route.params.id) }, computed: { // 從 store 的 state 對(duì)象中的獲取 item。 item () { return this.$store.state.items[this.$route.params.id] } } } </script>
2. 服務(wù)端入口entry-server.js配置
到這里,asyncData函數(shù),我們知道它是放在哪里了。接下來(lái),我們有了這個(gè)函數(shù),我們服務(wù)器肯定要去讀到這個(gè)函數(shù),然后去獲取數(shù)據(jù)吧?我們把目光放到entry-server.js,之前我們提到過(guò),這是服務(wù)端的入口頁(yè)面。那我們是不是能夠在這里面處理asyncData呢。下面還是官網(wǎng)的例子:
// entry-server.js import { createApp } from './app' export default context => { return new Promise((resolve, reject) => { const { app, router, store } = createApp() router.push(context.url) router.onReady(() => { const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) { return reject({ code: 404 }) } // 對(duì)所有匹配的路由組件調(diào)用 `asyncData()` Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }) } })).then(() => { // 在所有預(yù)取鉤子(preFetch hook) resolve 后, // 我們的 store 現(xiàn)在已經(jīng)填充入渲染應(yīng)用程序所需的狀態(tài)。 // 當(dāng)我們將狀態(tài)附加到上下文, // 并且 `template` 選項(xiàng)用于 renderer 時(shí), // 狀態(tài)將自動(dòng)序列化為 `window.__INITIAL_STATE__`,并注入 HTML。 context.state = store.state resolve(app) }).catch(reject) }, reject) }) }
簡(jiǎn)單的讀下這段代碼。首先為什么是返回Promise呢?因?yàn)榭赡苁钱惒铰酚珊徒M件,我們得保證,服務(wù)器渲染之前,已經(jīng)完全準(zhǔn)備就緒了。 然后注意**matchedComponents **它是通過(guò)傳入的地址,獲取到和路由匹配到的組件,然后如果存在asyncData,我們就去執(zhí)行它,然后注入到context(渲染上下文,可以在客戶端獲取)里面。
是不是簡(jiǎn)單?這一步我們就已經(jīng)從服務(wù)器端取到動(dòng)態(tài)數(shù)據(jù)了,同時(shí)丟到頁(yè)面里面了。如果不是為了客戶端數(shù)據(jù)同步,這一步我們已經(jīng)搞完服務(wù)端渲染了~ = =
3.客戶端入口client-server.js配置
搞完服務(wù)器端的配置,該客戶端了,畢竟數(shù)據(jù)要同步嘛。我們來(lái)看看客戶端的入口文件代碼:
const { app, router, store } = createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) }
之前服務(wù)端入口說(shuō)過(guò),狀態(tài)將自動(dòng)序列化為 window.__INITIAL_STATE__
,并注入 HTML。
所以客戶端我們獲取到了,服務(wù)端已經(jīng)搞好了數(shù)據(jù)了,我們拿過(guò)來(lái)直接替換現(xiàn)有的vuex就好了。
看到這里,不是已經(jīng)完成啦,完整的流程。但是到此為止了嗎?還沒(méi)呢,既然是服務(wù)端渲染,你總要啟動(dòng)服務(wù)器吧…
Ps: 數(shù)據(jù)預(yù)期,我們剛才講到的只是服務(wù)端預(yù)取,其實(shí)還有客戶端預(yù)取。什么是客戶端預(yù)取呢,簡(jiǎn)單的理解就是,我們可以在路由鉤子里面,找有當(dāng)前路由組件沒(méi)有asyncData,有的話,就去請(qǐng)求,獲取到數(shù)據(jù)后,填充完之后,再渲染頁(yè)面。
六、啟動(dòng)服務(wù)(server.js)配置
服務(wù)端渲染,服務(wù)端,肯定要一個(gè)啟動(dòng)服務(wù)的文件哈,
const express = require("express"); const fs = require('fs'); let path = require("path"); const server = express() const { createBundleRenderer } = require('vue-server-renderer') let renderer const resolve = file => path.resolve(__dirname, file) const templatePath = resolve('./src/index.template.html') function createRenderer (bundle, options) { return createBundleRenderer(bundle, Object.assign(options, { runInNewContext: false })) } 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 }) server.use(express.static('./dist')) // 在服務(wù)器處理函數(shù)中…… server.get('*', (req, res) => { const context = { url: req.url } // 這里無(wú)需傳入一個(gè)應(yīng)用程序,因?yàn)樵趫?zhí)行 bundle 時(shí)已經(jīng)自動(dòng)創(chuàng)建過(guò)。 renderer.renderToString(context, (err, html) => { // 處理異?!? res.end(html) }) }) server.listen(3001, () => { console.log('服務(wù)已開啟') })
這就是服務(wù)端的啟動(dòng)代碼了,只需處理獲取幾個(gè)打包過(guò)后的參數(shù)(template模板和clientManifest),傳入createBundleRenderer函數(shù)。然后通過(guò)renderToString,展現(xiàn)給客戶端。
七、熱更新與本地調(diào)試
上面一步是啟動(dòng)服務(wù),但是我們本地調(diào)試的時(shí)候,不可能每次build之后,再啟動(dòng),然后再修改,再build吧?那也太麻煩了。所以我們借助webpack搞一個(gè)熱更新。這里在build里面添加一個(gè)文件server.dev.conf.js
//server.dev.conf.js const fs = require('fs') const path = require('path') const MFS = require('memory-fs') const webpack = require('webpack') const chokidar = require('chokidar') const clientConfig = require('./webpack.client.conf.js') const serverConfig = require('./webpack.server.conf.js') 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 }
這個(gè)代碼基本上是從官方文檔copy下來(lái)的,寫的挺好的 哈哈。
怎么理解這段代碼呢,這個(gè)代碼封裝了一個(gè)promise,因?yàn)榇a更新后重新打包需要時(shí)間,所以我們?cè)趓enderToString之前,需要等待一段處理的時(shí)間。這個(gè)代碼對(duì)3部分進(jìn)行了監(jiān)控,template.html、vue業(yè)務(wù)代碼、客戶端配置代碼。檢測(cè)到有改動(dòng)之后,就重新打包獲取,然后返回。這里就是熱更新部分代碼,當(dāng)然我們還要改動(dòng)server.js部分代碼,畢竟要處理開發(fā)模式和生成模式的不同。
//server.js const express = require("express"); const fs = require('fs'); let path = require("path"); const server = express() const { createBundleRenderer } = require('vue-server-renderer') const isProd = process.env.NODE_ENV === 'production' let renderer let readyPromise const resolve = file => path.resolve(__dirname, file) const templatePath = resolve('./src/index.template.html') function createRenderer (bundle, options) { return createBundleRenderer(bundle, Object.assign(options, { runInNewContext: false })) } 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/server.dev.conf.js')( server, templatePath, (bundle, options) => { renderer = createRenderer(bundle, options) } ) } server.use(express.static('./dist')) // 在服務(wù)器處理函數(shù)中…… server.get('*', (req, res) => { const context = { url: req.url } // 這里無(wú)需傳入一個(gè)應(yīng)用程序,因?yàn)樵趫?zhí)行 bundle 時(shí)已經(jīng)自動(dòng)創(chuàng)建過(guò)。 // 現(xiàn)在我們的服務(wù)器與應(yīng)用程序已經(jīng)解耦! if(isProd){ renderer.renderToString(context, (err, html) => { // 處理異常…… res.end(html) }) }else{ readyPromise.then(()=>{ renderer.renderToString(context, (err, html) => { // 處理異?!? res.end(html) }) }) } }) server.listen(3001, () => { console.log('服務(wù)已開啟') })
從server.js的代碼改動(dòng),我們可以看到,server進(jìn)行了是否為生產(chǎn)環(huán)境的判斷,如果是測(cè)試環(huán)境,就取運(yùn)行server.dev.conf.js,獲得返回的promise,然后再renderToString之前,把renderToString加入到promise鏈?zhǔn)秸{(diào)用里面,這樣,熱更新就完成了,每次調(diào)用路由的時(shí)候,都會(huì)去獲取到最新的頁(yè)面。
到這里所有的ssr改造已經(jīng)完成了,當(dāng)然我們還能優(yōu)化,下面給出幾個(gè)點(diǎn),自己思考哈:
服務(wù)器緩存,既然是node服務(wù)器,我們當(dāng)然可以做服務(wù)器緩存拉。
流式渲染 (Streaming) 用 renderToStream 替代 renderToString;當(dāng) renderer 遍歷虛擬 DOM 樹 (virtual DOM tree) 時(shí),會(huì)盡快發(fā)送數(shù)據(jù)。這意味著我們可以盡快獲得"第一個(gè) chunk",并開始更快地將其發(fā)送給客戶端
以上是“vue項(xiàng)目如何改造成SSR”這篇文章的所有內(nèi)容,感謝各位的閱讀!希望分享的內(nèi)容對(duì)大家有幫助,更多相關(guān)知識(shí),歡迎關(guān)注億速云行業(yè)資訊頻道!
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。