我們先看“療效”,你可以開啟我的部落格u3xyz.com,通過檢視原始碼來看SSR直出效果。我的部落格已經快上線一年了,但不吹不黑,訪問量非常地小,我也一直在想辦法提升訪問量(包括在sf寫文章,哈哈)。當然,在PC端,搜尋引擎一直都是一個重要的流量來源。這裡就不得不提到SEO。下圖是我的部落格以前在百度的快照:
細心的朋友會發現,這個快照非常簡單,簡單到幾乎什麼都沒有。這也是沒辦法的事,部落格是基於Vue的SPA頁面,整個專案本來就是一個“空架子”,這個快照從部落格2月份上線以來就一直是上面的樣子,直到最近上線SSR。搜尋引擎蜘蛛每次來抓取你的網站都是一個樣子,慢慢得,它也就不會來了,相應的,網站的權重,排名肯定不會好。到目前為此,我的部落格不用網址進行搜尋都搜不到。在上線了SSR後,再加上一些SEO優化,百度快照終於更新了:
為什麼要做SSR
文章開始基本已經回答了為什麼要做SSR這個問題,當然,還有另一個原因是SSR概念現在在前端非常火,無奈在實際專案中沒有機會,也只有拿部落格來練手了。下面將詳細介紹本部落格專案SSR全過程。
SSR改造實戰
總的來說SSR改造還是相當容易的。推薦在動手之前,先了解官方文件和官方Vue SSR Demo,這會讓我們事半功倍。
1. 構建改造
上圖是Vue官方的SSR原理介紹圖片。從這張圖片,我們可以知道:我們需要通過Webpack打包生成兩份bundle檔案:
- Client Bundle,給瀏覽器用。和純Vue前端專案Bundle類似
- Server Bundle,供服務端SSR使用,一個json檔案
不管你專案先前是什麼樣子,是否是使用vue-cli生成的。都會有這個構建改造過程。在構建改造這裡會用到 vue-server-renderer 庫,這裡要注意的是 vue-server-renderer 版本要與Vue版本一樣。下圖是我的構建檔案目錄:
- util.js 提供一些公共方法
- webpack.base.js是公共的配置
- webpack.client.js 是生成Client Bundle的配置。核心配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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() ] }) |
- webpack.server.js 是生成Server Bundle的配置,核心配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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() ] }) |
2. 程式碼改造
2.1 必須使用VueRouter, Vuex。ajax庫建議使用axios
可能你的專案沒有使用VueRouter或Vuex。但遺憾的是,Vue-SSR必須基於 Vue + VueRouter + Vuex。Vuex官方沒有提,但其實文件和Demo都是基於Vuex。我的部落格以前也沒有用Vuex,但經過一翻折騰後,還是乖乖加上了Vuex。另外,因為程式碼要能同時在瀏覽器和Node.js環境中執行,所以ajax庫建議使用axios這樣的跨平臺庫。
2.2 兩個打包入口(entry),重構app, store, router, 為每個物件增加工廠方法createXXX
每個使用者通過瀏覽器訪問Vue頁面時,都是一個全新的上下文,但在服務端,應用啟動後就一直執行著,處理每個使用者請求的都是在同一個應用上下文中。為了不串資料,需要為每次SSR請求,建立全新的app, store, router。
上圖是我的專案檔案目錄。
- app.js, 通用的啟動Vue應用程式碼
- App.vue,Vue應用根元件
- entry.client.js,瀏覽器環境入口
- entry.server.js,伺服器環境入口
- index.html,html模板
再看一下具體實現的核心程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// 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} } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// 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') }) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// 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) }) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 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檢查 }) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// 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' } ] }) } |
3. 重構元件獲取資料方式
關於狀態管理,要嚴格遵守Redux思想。建議把應用所有狀態都存於store中,元件使用時再mapState下來,狀態更改嚴格使用action的方式。另一個要提一點的是,action要返回promise。這樣我們就可以使用asyncData方法獲取元件資料了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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) } } |
3. SSR伺服器實現
在完成構建和程式碼改造後,如果一切順利。我們能得到下面的打包檔案:
這時,我們可以開始實現SSR服務端程式碼了。下面是我部落格SSR實現(基於Koa)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
// 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; ' 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}`) }) |
4. 伺服器部署
伺服器部署,跟你的專案架構有關。比如我的部落格專案在服務端有2個後端服務,一個資料庫服務,nginx用於請求轉發:
5. 遇到的問題及解決辦法
載入不到元件的JS檔案
1 2 |
[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’)
1 |
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中:
1 2 3 4 5 |
mounted () { let paragraph = require('./paragraph.vue') Vue.component('paragraph', paragraph) new Vue().$mount('#paragraph') }, |
串資料
dispatch的action沒有返回promise,保證返回promise即可
路由跳轉
路由跳轉使用router方法或標籤,這兩種方式能自適應瀏覽器端和服務端,不要使用a標籤
小結
本文主要記錄了我的部落格u3xyz.comSSR過程:
- 構建webpack改造
- 程式碼改造
- server端SSR實現
- 上線部署
最後希望文章能對大家有些許幫助!