博客已全站升級到https,若是遇到沒法訪問,請手動加上 https://前綴
咱們先看「療效」,你能夠打開個人博客u3xyz.com,經過查看源代碼來看SSR直出效果。個人博客已經快上線一年了,但不吹不黑,訪問量很是地小,我也一直在想辦法提高訪問量(包括在sf寫文章,哈哈)。固然,在PC端,搜索引擎一直都是一個重要的流量來源。這裏就不得不提到SEO。下圖是個人博客之前在百度的快照:css
細心的朋友會發現,這個快照很是簡單,簡單到幾乎什麼都沒有。這也是沒辦法的事,博客是基於Vue的SPA頁面,整個項目原本就是一個「空架子」,這個快照從博客2月份上線以來就一直是上面的樣子,直到最近上線SSR。搜索引擎蜘蛛每次來抓取你的網站都是一個樣子,慢慢得,它也就不會來了,相應的,網站的權重,排名確定不會好。到目前爲此,個人博客不用網址進行搜索都搜不到。在上線了SSR後,再加上一些SEO優化,百度快照終於更新了:html
文章開始基本已經回答了爲何要作SSR這個問題,固然,還有另外一個緣由是SSR概念如今在前端很是火,無奈在實際項目中沒有機會,也只有拿博客來練手了。下面將詳細介紹本博客項目SSR全過程。前端
總的來講SSR改造仍是至關容易的。推薦在動手以前,先了解官方文檔和官方Vue SSR Demo,這會讓咱們事半功倍。vue
上圖是Vue官方的SSR原理介紹圖片。從這張圖片,咱們能夠知道:咱們須要經過Webpack打包生成兩份bundle文件:node
無論你項目先前是什麼樣子,是不是使用vue-cli生成的。都會有這個構建改造過程。在構建改造這裏會用到 vue-server-renderer 庫,這裏要注意的是 vue-server-renderer 版本要與Vue版本同樣。下圖是個人構建文件目錄:webpack
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') // ... const config = merge(baseConfig, { target: 'web', entry: './src/entry.client.js', plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"client"' }), new webpack.optimize.CommonsChunkPlugin({ name: 'vender', minChunks: 2 }), // extract webpack runtime & manifest to avoid vendor chunk hash changing // on every build. new webpack.optimize.CommonsChunkPlugin({ name: 'manifest' }), new VueSSRClientPlugin() ] })
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') // ... const config = merge(baseConfig, { target: 'node', devtool: '#source-map', entry: './src/entry.server.js', output: { libraryTarget: 'commonjs2', filename: 'server-bundle.js' }, externals: nodeExternals({ // do not externalize CSS files in case we need to import it from a dep whitelist: /\.css$/ }), plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"server"' }), new VueSSRServerPlugin() ] })
可能你的項目沒有使用VueRouter或Vuex。但遺憾的是,Vue-SSR必須基於 Vue + VueRouter + Vuex。Vuex官方沒有提,但其實文檔和Demo都是基於Vuex。個人博客之前也沒有用Vuex,但通過一翻折騰後,仍是乖乖加上了Vuex。另外,由於代碼要能同時在瀏覽器和Node.js環境中運行,因此ajax庫建議使用axios這樣的跨平臺庫。ios
每一個用戶經過瀏覽器訪問Vue頁面時,都是一個全新的上下文,但在服務端,應用啓動後就一直運行着,處理每一個用戶請求的都是在同一個應用上下文中。爲了避免串數據,須要爲每次SSR請求,建立全新的app, store, router。nginx
上圖是個人項目文件目錄。git
再看一下具體實現的核心代碼:github
// app.js import Vue from 'vue' import App from './App.vue' // 根組件 import {createRouter} from './routers/index' import {createStore} from './vuex/store' import {sync} from 'vuex-router-sync' // 把當VueRouter狀態同步到Vuex中 // createApp工廠方法 export function createApp (ssrContext) { let router = createRouter() // 建立全新router實例 let store = createStore() // 建立全新store實例 // 同步路由狀態到store中 sync(store, router) // 建立Vue應用 const app = new Vue({ router, store, ssrContext, render: h => h(App) }) return {app, router, store} }
// entry.client.js import Vue from 'vue' import { createApp } from './app' const { app, router, store } = createApp() // 若是有__INITIAL_STATE__變量,則將store的狀態用它替換 if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } router.onReady(() => { // 經過路由勾子,執行拉取數據邏輯 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)) }) // 組件數據經過執行asyncData方法獲取 const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _) if (!asyncDataHooks.length) { return next() } // 要注意asyncData方法要返回promise,asyncData調用的vuex action也必須返回promise Promise.all(asyncDataHooks.map(hook => hook({ store, route: to }))) .then(() => { next() }) .catch(next) }) // 將Vue實例掛載到dom中,完成瀏覽器端應用啓動 app.$mount('#app') })
// entry.server.js import { createApp } from './app' export default context => { return new Promise((resolve, reject) => { const { app, router, store } = createApp(context) // 設置路由 router.push(context.url) router.onReady(() => { const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) { return reject({ code: 404 }) } // 執行asyncData方法,預拉取數據 Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store: store, route: router.currentRoute }) } })).then(() => { // 將store的快照掛到ssr上下文上 context.state = store.state resolve(app) }).catch(reject) }, reject) }) }
// createStore import Vue from 'vue' import Vuex from 'vuex' // ... Vue.use(Vuex) // createStore工廠方法 export function createStore () { return new Vuex.Store({ // rootstate state: { appName: 'appName', title: 'home' }, modules: { // ... }, strict: process.env.NODE_ENV !== 'production' // 線上環境關閉store檢查 }) }
// createRouter import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) // createRouter工廠方法 export function createRouter () { return new Router({ mode: 'history', // 注意這裏要使用history模式,由於hash不會發送到服務端 fallback: false, routes: [ { path: '/index', name: 'index', component: () => System.import('./index/index.vue') // 代碼分片 }, { path: '/detail/:aid', name: 'detail', component: () => System.import('./detail/detail.vue') }, // ... { path: '/', redirect: '/index' } ] }) }
關於狀態管理,要嚴格遵照Redux思想。建議把應用全部狀態都存於store中,組件使用時再mapState下來,狀態更改嚴格使用action的方式。另外一個要提一點的是,action要返回promise。這樣咱們就可使用asyncData方法獲取組件數據了
const actions = { getArticleList ({state, commit}, curPageNum) { commit(FETCH_ARTICLE_LIST, curPageNum) // action 要返回promise return apis.getArticleList({ data: { size: state.pagi.itemsPerPage, page: curPageNum } }).then((res) => { // ... }) } } // 組件asyncData實現 export default { asyncData ({ store }) { return store.dispatch('getArticleList', 1) } }
在完成構建和代碼改造後,若是一切順利。咱們能獲得下面的打包文件:
這時,咱們能夠開始實現SSR服務端代碼了。下面是我博客SSR實現(基於Koa)
// server.js const Koa = require('koa') const path = require('path') const logger = require('./logger') const server = new Koa() const { createBundleRenderer } = require('vue-server-renderer') const templateHtml = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8') let distPath = './dist' const renderer = createBundleRenderer(require(`${distPath}/vue-ssr-server-bundle.json`), { runInNewContext: false, template: templateHtml, clientManifest: require(`${distPath}/vue-ssr-client-manifest.json`) }) server.use(function * (next) { let ctx = this const context = { url: ctx.req.url, pageTitle: 'default-title' } // cgi請求,前端資源請求不能轉到這裏來。這裏能夠經過nginx作 if (/\.\w+$/.test(context.url)) { return yield next } // 注意這裏也必須返回promise return new Promise((resolve, reject) => { renderer.renderToString(context, function (err, html) { if (err) { logger.error(`[error][ssr-error]: ` + err.stack) return reject(err) } ctx.status = 200 ctx.type = 'text/html; charset=utf-8' ctx.body = html resolve(html) }) }) }) // 錯誤處理 server.on('error', function (err) { logger.error('[error][server-error]: ' + err.stack) }) let port = 80 server.listen(port, () => { logger.info(`[info]: server is deploy on port: ${port}`) })
服務器部署,跟你的項目架構有關。好比個人博客項目在服務端有2個後端服務,一個數據庫服務,nginx用於請求轉發:
加載不到組件的JS文件
[vue-router] Failed to resolve async component default: Error: Cannot find module 'js\main1.js' [vue-router] uncaught error during route navigation:
解決辦法:
去掉webpack配置中的output.chunkFilename: getFileName('js/main[name]-$hash.js')
if you are using CommonsChunkPlugin, make sure to use it only in the client config because the server bundle requires a single entry chunk.
因此對webpack.server.js不要對配置CommonsChunkPlugin,也不要設置output.chunkFilename
代碼高亮codeMirror使用到navigator對象,只能在瀏覽器環境運行
把執行邏輯放到mounted回調中。實現不行,就封裝一個異步組件,把組件的初始化放到mounted中:
mounted () { let paragraph = require('./paragraph.vue') Vue.component('paragraph', paragraph) new Vue().$mount('#paragraph') },
串數據
dispatch的action沒有返回promise,保證返回promise便可
路由跳轉
路由跳轉使用router方法或<router-link />標籤,這兩種方式能自適應瀏覽器端和服務端,不要使用a標籤
本文主要記錄了個人博客u3xyz.comSSR過程:
最後但願文章能對你們有些許幫助!
願文地址:Vue項目SSR改造實戰