Vue項目SSR改造實戰

博客已全站升級到https,若是遇到沒法訪問,請手動加上 https://前綴

咱們先看「療效」,你能夠打開個人博客u3xyz.com,經過查看源代碼來看SSR直出效果。個人博客已經快上線一年了,但不吹不黑,訪問量很是地小,我也一直在想辦法提高訪問量(包括在sf寫文章,哈哈)。固然,在PC端,搜索引擎一直都是一個重要的流量來源。這裏就不得不提到SEO。下圖是個人博客之前在百度的快照:css

SSR前快照

細心的朋友會發現,這個快照很是簡單,簡單到幾乎什麼都沒有。這也是沒辦法的事,博客是基於Vue的SPA頁面,整個項目原本就是一個「空架子」,這個快照從博客2月份上線以來就一直是上面的樣子,直到最近上線SSR。搜索引擎蜘蛛每次來抓取你的網站都是一個樣子,慢慢得,它也就不會來了,相應的,網站的權重,排名確定不會好。到目前爲此,個人博客不用網址進行搜索都搜不到。在上線了SSR後,再加上一些SEO優化,百度快照終於更新了:html

SSR後快照

爲何要作SSR

文章開始基本已經回答了爲何要作SSR這個問題,固然,還有另外一個緣由是SSR概念如今在前端很是火,無奈在實際項目中沒有機會,也只有拿博客來練手了。下面將詳細介紹本博客項目SSR全過程。前端

SSR改造實戰

總的來講SSR改造仍是至關容易的。推薦在動手以前,先了解官方文檔官方Vue SSR Demo,這會讓咱們事半功倍。vue

1. 構建改造

VueSSR原理

上圖是Vue官方的SSR原理介紹圖片。從這張圖片,咱們能夠知道:咱們須要經過Webpack打包生成兩份bundle文件:node

  • Client Bundle,給瀏覽器用。和純Vue前端項目Bundle相似
  • Server Bundle,供服務端SSR使用,一個json文件

無論你項目先前是什麼樣子,是不是使用vue-cli生成的。都會有這個構建改造過程。在構建改造這裏會用到 vue-server-renderer 庫,這裏要注意的是 vue-server-renderer 版本要與Vue版本同樣。下圖是個人構建文件目錄:webpack

構建

  • util.js 提供一些公共方法
  • webpack.base.js是公共的配置
  • webpack.client.js 是生成Client Bundle的配置。核心配置以下:
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的配置,核心配置以下:
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這樣的跨平臺庫。ios

2.2 兩個打包入口(entry),重構app, store, router, 爲每一個對象增長工廠方法createXXX

每一個用戶經過瀏覽器訪問Vue頁面時,都是一個全新的上下文,但在服務端,應用啓動後就一直運行着,處理每一個用戶請求的都是在同一個應用上下文中。爲了避免串數據,須要爲每次SSR請求,建立全新的app, store, routernginx

項目目錄

上圖是個人項目文件目錄。git

  • app.js, 通用的啓動Vue應用代碼
  • App.vue,Vue應用根組件
  • entry.client.js,瀏覽器環境入口
  • entry.server.js,服務器環境入口
  • index.html,html模板

再看一下具體實現的核心代碼: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'
      }
    ]
  })
}

3. 重構組件獲取數據方式

關於狀態管理,要嚴格遵照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)
  }
}

3. SSR服務器實現

在完成構建和代碼改造後,若是一切順利。咱們能獲得下面的打包文件:

dist文件

這時,咱們能夠開始實現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}`)
})

4. 服務器部署

服務器部署,跟你的項目架構有關。好比個人博客項目在服務端有2個後端服務,一個數據庫服務,nginx用於請求轉發:

u3xyz架構

5. 遇到的問題及解決辦法

加載不到組件的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過程:

  • 構建webpack改造
  • 代碼改造
  • server端SSR實現
  • 上線部署

最後但願文章能對你們有些許幫助!

願文地址:Vue項目SSR改造實戰

相關文章
相關標籤/搜索