Step-by-step,打造屬於自己的vue ssr

玩弄心裡的鬼發表於2018-04-14

筆者最近在和小夥伴對vue專案進行ssr的升級,本文筆者將根據一個簡單拿vue cli構建的客戶端渲染的demo一步一步的教大家打造自己的ssr,拙見勿噴哈。

what ? why ?

What ?

在學習一項新技術的時候我們首先要了解一下他是什麼。這裡引用官網的一句話:

Vue.js 是構建客戶端應用程式的框架。預設情況下,可以在瀏覽器中輸出 Vue 元件,進行生成 DOM 和操作DOM。然而,也可以將同一個元件渲染為伺服器端的 HTML 字串,將它們直接傳送到瀏覽器,最後將靜態標記"混合"為客戶端上完全互動的應用程式。

Why ?

知道是什麼後我們要知道這項技術對我們現有的專案有什麼好處,簡單總結一下:

  • 利於SEO,瀏覽器爬蟲不會等待我們的ajax回撥完成之後再去抓取我們的頁面資料;
  • 利於首屏渲染,vue-ssr會把拿到的資料渲染成html,不用等待全部的js資源都完成下載才顯示我們的頁面;

do ? how to do ?

這裡我們用vue-cli去簡單的做一個vue客戶端渲染的demo,具體過程就不做贅述了。

demo地址: https://github.com/LNoe-lzy/vue-ssr-demo/tree/master

這裡我們根據之前寫好的客戶端渲染的demo來一步一步的改造成服務端渲染。先甩下demo連結:

demo地址: https://github.com/LNoe-lzy/vue-ssr-demo/tree/vue-ssr-server

First step:理解下原理

先附一張鎮文之圖,官網的構建流程:

構建步驟

這些都是個啥?

  • app.js用來構建我們的vue例項,這個例項會跑在客戶端和服務端;
  • server entry是我們的服務端entry,用來匯出一個函式在每次請求中呼叫,也做元件匹配和初始化渲染資料的獲取。webpack會將其打包成server bundle;
  • client entry是我們客戶端的entry,用來掛載我們的vue例項到指定的dom元素上。webpack會將其打包成client bundle;

這些都做了啥?

  • 首先我們的entry-server會獲取到當前router匹配到的元件,呼叫元件上asyncData方法,將資料存到服務端的vuex中,然後服務端vuex中的這些資料傳給我們的context。
  • Node.js伺服器通過renderToString將需要首屏渲染的html字串send道我們的客戶端上,這其中混入了window.INITIAL_STATE 用來儲存我們服務端vuex的資料。
  • 然後entry-client,此時服務端渲染時候拿到的資料寫入客戶端的vuex中。
  • 最後就是客戶端和服務端的元件做diff了,更新狀態更新的元件。

Secound step:main.js的改造

為了避免單例的影響,我們需要在每個請求都建立一個新的vue的例項,從而避免請求狀態的汙染,我們來封裝一個createApp的工廠函式:

import Vue from 'vue'
import App from './App'

export function createApp () {
  const app = new Vue({
    render: h => h(App)
  })
  return { app }
}
複製程式碼

Third step:元件的改造

跑在服務端的Vue中所有的生命週期鉤子函式中,只有 beforeCreate 和 created 會在伺服器端渲染過程中被呼叫,而其他的鉤子在客戶端才會被呼叫,畢竟我們的服務端是無法執行dom操作的,所以我們要在路由匹配的元件上定義一個靜態函式,這個函式要做的也很簡單,就是去dispatch我們的action從而非同步獲取資料:

import { mapActions } from 'vuex'

export default {
  asyncData ({ store }) {
    return store.dispatch('getNav')
  },
  methods: {
    ...mapActions([
      'getList'
    ])
  }
  // ...
}
複製程式碼

Fourth step:router和store的改造

同樣為了避免單例的影響,我們也需要用工廠函式封裝我們的router和store

// router
export function createRouter () {
  return new Router({
    mode: 'history',
    routes: []
  })
}

// store
export function createStore () {
  return new Vuex.Store({
    state: {},
    actions,
    mutations
  })
}
複製程式碼

Fifth step:兩個entry

根據構建流程圖我們還需要webpack去構建兩個bundle,服務端根據Server Bundle去做ssr,瀏覽器根據Client Bundle去混合靜態標記。

為此我們在src目錄下新建兩個檔案,entry-server.js 和 entry-client.js。前者在每次渲染中需要重複呼叫,執行服務端的路有匹配和資料預取邏輯。後者負責掛載DOM節點,以及前後端vuex資料狀態的同步。

// entry-server.js
import { createApp } from './main'

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(new Error(`error: ${fullPath}`))
    }
    router.push(url)
    // 需要等到的非同步元件和鉤子函式解析完
    router.onReady(() => {
	  // 獲取匹配到的元件
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute
      }))).then(() => {
        // 將預取的資料從store中取出放到context中
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}
複製程式碼

這裡我們需要注意兩點,一個是我們的資料預取是呼叫元件的asyncData方法,所以需要Promise.all來保證拿到全部的預渲染資料;另一點是context.state = store.state,這時候服務端拿到的預渲染資料會封在**window.INITIAL_STATE**中通過node伺服器send到客戶端。

import Vue from 'vue'
import { createApp } from './main'
const { app, router, store } = createApp()

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))
    })

    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
    if (!asyncDataHooks.length) {
      return next()
    }

    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
      .then(() => {
        next()
      })
      .catch(next)
  })
  console.log('router ready')
  app.$mount('#app')
})
複製程式碼

看到window.INITIAL_STATE我們就可以知道了客戶端拿到了預取的資料,然後去存到客戶端的vuex中,這也就是大家經常談論的通過vuex實現前後端的狀態共享。

至於vuex是不是必須的,當然不是(尤大issuse有說),題外話,筆者也實現了沒有vuex的版本哦。

Sixth step:webpack的改造

webpack的配置上面其實和純客戶端應用類似,為了區分客戶端和服務端兩個環境我們將配置分為base、client和server三部分,base就是我們的通用基礎配置,而client和server分別用來打包我們的客戶端和服務端程式碼。

首先是webpack.server.conf.js,用於生成server bundle來傳遞給createBundleRenderer函式在node伺服器上呼叫,入口是我們的entry-server:

const webpack = require('webpack')
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')
// 去除打包css的配置
baseConfig.module.rules[1].options = ''

module.exports = merge(baseConfig, {
  entry: './src/entry-server.js',
  // 以 Node 適用方式匯入
  target: 'node',
  // 對 bundle renderer 提供 source map 支援
  devtool: '#source-map',
  output: {
    filename: 'server-bundle.js',
    libraryTarget: 'commonjs2'
  },
  externals: nodeExternals({
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    // 這是將伺服器的整個輸出
    // 構建為單個 JSON 檔案的外掛。
    // 預設檔名為 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin()
  ]
})
複製程式碼

其次是webpack.client.conf.js,這裡我們可以根據官方的配置生成clientManifest,自動推斷和注入資源預載入,以及 css 連結 / script 標籤到所渲染的 HTML。入口是我們的client-server:

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.conf')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
  entry: {
    app: './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: 'vendor',
      minChunks: function (module) {
        return (
          /node_modules/.test(module.context) &&
          !/\.css$/.test(module.request)
        )
      }
    }),
    // 這將 webpack 執行時分離到一個引導 chunk 中,
    // 以便可以在之後正確注入非同步 chunk。
    // 這也為你的 應用程式/vendor 程式碼提供了更好的快取。
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest'
    }),
    new VueSSRClientPlugin()
  ]
})

複製程式碼

Seventh step:編寫服務端程式碼

服務端框架我們採用Express(當然Koa2也是可以的):

const express = require('express')
const fs = require('fs')
const path = require('path')
const {
  createBundleRenderer
} = require('vue-server-renderer')

const app = express()
const resolve = file => path.resolve(__dirname, file)
// 生成服務端渲染函式
const renderer = createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'), {
  runInNewContext: false,
  template: fs.readFileSync(resolve('./index.html'), 'utf-8'),
  clientManifest: require('./dist/vue-ssr-client-manifest.json'),
  basedir: resolve('./dist')
})

// 引入靜態資源
app.use(express.static(path.join(__dirname, 'dist')))
// 分發路由

app.get('*', (req, res) => {
  res.setHeader('Content-Type', 'text/html')

  const handleError = err => {
    if (err.url) {
      res.redirect(err.url)
    } else if (err.code === 404) {
      res.status(404).send('404 | Page Not Found')
    } else {
      // Render Error Page or Redirect
      res.status(500).send('500 | Internal Server Error')
      console.error(`error during render : ${req.url}`)
      console.error(err.stack)
    }
  }

  const context = {
    title: 'Vue SSR demo', // default title
    url: req.url
  }
  renderer.renderToString(context, (err, html) => {
    console.log('render')
    if (err) {
      return handleError(err)
    }
    res.send(html)
  })
})

app.on('error', err => console.log(err))
app.listen(3000, () => {
  console.log(`vue ssr started at localhost:3000`)
})
複製程式碼

通過觀察localhost我們可以很清楚的發現,通過服務端send過來的html字串僅包括我們根據資料預取渲染出來的dom結構以及服務端混入的window.INITIAL_STATE

服務端渲染html

通過Performance我們也可以看出在採用了ssr的應用中,我們的首屏渲染並不依賴於客服端的js檔案了,這就大大加快了首屏的渲染速度,畢竟傳統的SPA應用時需要拿到客戶端js檔案後才可以進行虛擬dom的構建以及資料的獲取工作才渲染頁面的。

ssr

不只是題外話

  • vue-router不是必須的,不用router其實做個vue的preRender就可以了,完全沒必要做ssr;
  • vuex不是必須的,vuex是實現我們客戶端和服務端的狀態共享的關鍵,我們可以不使用vuex,但是我們得去實現一套資料預取的邏輯;

不使用vuex其實很頭疼,但又有了點靈感,平時我們在開發專案的時候是如何處理元件間通訊的,一個是vuex,另一個是EventBus,EventBus就是個Vue的例項啊,資料存這裡不也行麼?

在此筆者的思路是:建立一個Vue的例項充當倉庫,那麼我們可以用這個例項的data來儲存我們的預取資料,而用methods中的方法去做資料的非同步獲取,這樣我們只需要在需要預取資料的元件中去呼叫這個方法就可以了。demo很簡單,戳這裡

還有一個思路是在筆者學習的時候看別人部落格學到的:只用了vuex的store和一些支援服務端渲染的api,沒有走action、mutation那套,而是將資料手動寫入state,為了表示對別人部落格的尊重,細節就請轉到作者的部落格吧,戳這裡


寫在最後

本文通過一個簡單的客戶端渲染demo來一步一步的交大家如何搭建屬於自己的ssr程式,文筆拙略還請大家諒解了。

不過學習雖好,但是細節到使用上,大家還是斟酌是否適合在自己的專案中。

多謝支援!

相關文章