vue 服務端渲染折騰記錄

爲了解決 vue 項目的 seo 問題,最近研究了下服務端渲染,因此就有了本文的記錄。javascript

項目結構

├─.babelrc // babel 配置文件
├─index.template.html // html 模板文件
├─server.js // 提供服務端渲染及 api 服務
├─src // 前端代碼
|  ├─app.js // 主要用於建立 vue 實例
|  ├─App.vue // 根組件
|  ├─entry-client.js // 客戶端渲染入口文件
|  ├─entry-server.js // 服務端渲染入口文件
|  ├─stores // vuex 相關
|  ├─routes // vue-router 相關
|  ├─components // 組件
├─dist // 代碼編譯目標路徑
├─build // webpack 配置文件
複製代碼

項目的主要目錄結構如上所示,其中 package.json 請查看項目。關於爲何要使用狀態管理庫 Vuex,官網有明確的解釋。後文有例子幫助進一步理解。css

接下來咱們暫時無論服務端渲染的事情,先搭建一個簡單的 vue 的開發環境。html

搭建 vue 開發環境

利用 webpack 能夠很是快速的搭建一個簡單的 vue 開發環境,能夠直接乘電梯前往。前端

爲了高效地進行開發,vue 開發環境應該有代碼熱加載和請求轉發的功能。這些均可以使用 webpack-dev-server 來輕鬆實現,只需配置 webpackdevServer 項:vue

module.exports = merge(baseWebpackConfig, {
  devServer: {
    historyApiFallback: true,
    noInfo: true,
    overlay: true,
    proxy: config.proxy
  },
  devtool: '#eval-source-map',
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'index.template.html',
      inject: true // 插入css和js
    }),
    new webpack.HotModuleReplacementPlugin(),
    new FriendlyErrors()
  ]
})
複製代碼

而後啓動時添加 --hot 參數便可:java

cross-env NODE_ENV=development webpack-dev-server --config build/webpack.dev.conf.js --open --hot
複製代碼

注意到 routerstore 以及 vue 都採用了工廠函數來生成實例,這是爲了方便代碼在後面的服務端渲染中進行復用,由於 「Node.js 服務器是一個長期運行的進程。必須爲每一個請求建立一個新的 Vue 實例」 (官網)。node

一樣,前端請求使用的是 axios 庫,也是爲了照顧服務端。webpack

在項目根目錄下運行 npm run server 啓動後端 api 服務,而後運行 npm run devwebpack 會自動在默認瀏覽器中打開 http://localhost:8080 地址,便可看到效果。ios

服務端渲染

基於上面搭建好的項目基礎上來搭建服務端渲染就比較容易了,讓咱們開始吧。或者直接看最後的代碼git

要實現服務端渲染,只需增長以下 webpack 配置:

module.exports = merge(baseWebpackConfig, {
  entry: './src/entry-server.js',
  // 告知 `vue-loader` 輸送面向服務器代碼(server-oriented code)。
  target: 'node',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2',
  },
  plugins: [
     new VueSSRServerPlugin()
  ]
})
複製代碼

注意到 entry 的文件路徑跟以前的不太同樣,這裏使用的是專門爲服務端渲染準備的入口文件:

import { createApp } from './app'
// 這裏的 context 是服務端渲染模板時傳入的
export default context => {
  // 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise,
  // 以便服務器可以等待全部的內容在渲染前,
  // 就已經準備就緒。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    const { url } = context
    const { fullPath } = router.resolve(url).route

    if (fullPath !== url) {
      return reject({ url: fullPath })
    }

    router.push(url)

    // 等到 router 將可能的異步組件和鉤子函數解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,執行 reject 函數,並返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // 執行全部組件中的異步數據請求
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute
      }))).then(() => {
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}
複製代碼

其中的 asyncData 可能會讓人疑惑,稍後咱們用一個例子來講明。如今,然咱們來編譯一下,運行 npm run build:server ,將會在 dist 目錄下獲得 vue-ssr-server-bundle.json 文件。能夠看到,該文件包含了 webpack 打包生成的全部 chunk 並指定了入口。後面服務端會基於該文件來作渲染。

如今就讓咱們移步服務端,新增一些代碼:

...
 const { createBundleRenderer } = require('vue-server-renderer')
 const bundle = require('./dist/vue-ssr-server-bundle.json')

 const renderer = createBundleRenderer(bundle, {
   template: fs.readFileSync('./index.template.html', 'utf-8')
 })
...
// 服務端渲染
server.get('*', (req, res) => {
  const context = { url: req.originalUrl }
  renderer.renderToString(context, (err, html) => {
    if (err) {
      if (err.code === 404) {
        res.status(404).end('Page not found')
      } else {
        res.status(500).end('Internal Server Error')
      }
    } else {
      res.end(html)
    }
  })
})
複製代碼

新增代碼很少,首先使用上面生成的文件建立了一個 renderer 對象,而後調用其 renderToString 方法並傳入包含請求路徑的對象做爲參數來進行渲染,最後將渲染好的數據即 html 返回。

運行 npm run server 啓動服務端,打開 http://localhost:8081 就能夠看到效果了:

關於 asyncData

前面提到了 asyncData ,如今以該例子來梳理一下。首先,看看組件中的代碼:

...
<script>
export default {
  asyncData ({ store, route }) {
    // 觸發 action 後,會返回 Promise
    return store.dispatch('fetchItems')
  },
  data () {
    return {
      title: "",
      content: ""
    }
  },
  computed: {
    // 從 store 的 state 對象中的獲取 item。
    itemList () {
      return this.$store.state.items
    }
  },
  methods: {
    submit () {
      const {title, content} = this
      this.$store.dispatch('addItem', {title, content})
    }
  }
}
</script>
複製代碼

這是一個很簡單的組件,包括一個列表,該列表的內容經過請求從後端獲取,一個表單,用於提交新的記錄到後端保存。其中 asyncData 是咱們約定的函數名,表示渲染組件須要預先執行它獲取初始數據,它返回一個 Promise,以便咱們在後端渲染的時候能夠知道何時該操做完成。這裏,該函數觸發了 fetchItems 以更新 store 中的狀態。還記得咱們的 entry-server.js 文件嗎,裏面正是調用了組件的 asyncData 方法來進行數據預取的。

在開發階段,咱們一樣須要進行數據預取,爲了複用 asyncData 代碼,咱們在組件的 beforeMount 中調用該方法,咱們將這個處理邏輯經過 Vue.mixin 混入到全部的組件中:

Vue.mixin({
  beforeMount() {
    const { asyncData } = this.$options
    if (asyncData) {
      // 將獲取數據操做分配給 promise
      // 以便在組件中,咱們能夠在數據準備就緒後
      // 經過運行 `this.dataPromise.then(...)` 來執行其餘任務
      this.dataPromise = asyncData({
        store: this.$store,
        route: this.$route
      })
    }
  }
})
複製代碼

還有一個問題就是咱們生成的 html 中並無引入任何 js,用戶沒法進行任何交互,好比上面的列表頁,用戶沒法提交新的內容。固然,若是這個頁面是隻給爬蟲來「看」的話這樣就足夠了,但若是考慮到真實的用戶,咱們還須要在 html 中引入前端渲染的 js 文件。

前端渲染

該部分的代碼能夠直接查看這裏

前端渲染部分須要先增長一個 webpack 的配置文件用於生成所需的 js, css 等靜態文件:

module.exports = merge(baseWebpackConfig, {
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      compress: {
        warnings: false,
        drop_console: true
      }
    }),
    // 重要信息:這將 webpack 運行時分離到一個引導 chunk 中,
    // 以即可以在以後正確注入異步 chunk。
    // 這也爲你的 應用程序/vendor 代碼提供了更好的緩存。
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
      minChunks: Infinity
    }),
    // 此插件在輸出目錄中
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})
複製代碼

同時,前端渲染還須要有本身的入口文件 entry-client,該文件在講 asyncData 的時候有所說起:

import Vue from 'vue'
import {
  createApp
} from './app.js'
// 客戶端特定引導邏輯……
const {
  app,
  router,
  store
} = createApp()
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

Vue.mixin({
  beforeMount() {
    const { asyncData } = this.$options
    if (asyncData) {
      // 將獲取數據操做分配給 promise
      // 以便在組件中,咱們能夠在數據準備就緒後
      // 經過運行 `this.dataPromise.then(...)` 來執行其餘任務
      this.dataPromise = asyncData({
        store: this.$store,
        route: this.$route
      })
    }
  }
})
// 這裏假定 App.vue 模板中根元素具備 `id="app"`
router.onReady(() => {
  app.$mount('#app')
})
複製代碼

如今咱們 npm run build:client 編譯一下,dist 目錄中能夠獲得若干文件:

0.js
1.js
2.js
app.js
manifest.js
vue-ssr-client-manifest.json
複製代碼

其中,js 文件都是須要引入的文件,json 文件像是一個說明文檔,這裏暫不討論其原理,感興趣的能夠查看這裏

最後,server.js 中,稍微作一點點修改:

const clientManifest = require('./dist/vue-ssr-client-manifest.json')

 const renderer = createBundleRenderer(bundle, {
   template: fs.readFileSync('./index.template.html', 'utf-8'),
   clientManifest
 })
複製代碼

而後 npm run server 啓動服務,再打開 http://localhost:8081,能夠看到渲染後的 html 文件中已經引入了 js 資源了。

列表頁中也能夠提交新記錄了:

總結

本文先從搭建一個簡單的 vue 開發環境開始,而後基於此實現了服務端渲染,並引入了客戶端渲染所需的資源。經過這個過程跑通了 vue 服務端渲染的大體流程,但不少地方還需更進一步深刻:

  • 樣式的處理

    本文並無對樣式進行處理,需進一步研究

  • 編譯後文件的解釋

    文章中編譯生成的 json 等文件究竟是怎麼用的呢?

  • 針對爬蟲和真實用戶的不一樣策略

    服務端渲染其實主要是用來解決 seo 的問題,因此能夠在服務端經過請求頭判斷來源並作不一樣處理,如果爬蟲則進行服務端渲染(不須要引入客戶端渲染所需的資源),如果普通用戶則仍是用原始的客戶端渲染方式。

相關文章
相關標籤/搜索