15分鐘學會vue項目改造成SSR

15分鐘學會vue項目改造成SSR

Ps:網上看了好多服務器渲染的例子,基本都是從0開始的,用Nuxt或者vue官網推薦的ssr方案(vue-server-renderer),但是我們在開發過程中基本上是已經有了現有的項目了,我們所要做的是對現有項目的SSR改造。那麼這裏,跟我一起對一個vue-cil2.0生成的項目進行SSR改造

關於這篇文章的案例源代碼我放在我的github上面,有興趣的同學,也可以去我的github查看我之前寫的博客。博客


一、改造技術的分析對比。

一般來說,我們做seo有兩種方式:

1、預渲染

我在性能優化的博客中說過,預渲染的問題,預渲染是一個方案,使用爬蟲技術。由於我們打包過後的都是一些js文件,使用一些技術(puppeteer)可以爬取到項目在chrome瀏覽器展示的頁面,然後把它寫入js,和打包文件一起。

類似prerender-spa-plugin 。最大的特點就是,所有獲取的數據都是靜態的,比如說你的頁面首頁有新聞,是通過接口獲取的,當你在2019-11-30打包之後,不管用戶在2020年也是看到的2019-11-30的新聞,當然的爬蟲爬到的也是。

如果你只需要改善少數頁面(例如 /, /about, /contact 等)的 SEO,那麼你可能需要預渲染

2、服務端渲染

服務端渲染是將完整的 html 輸出到客戶端,又被認爲是‘同構’或‘通用’,如果你的項目有大量的detail頁面,相互特別頻繁,建議選擇服務端渲染。

**服務端渲染除了SEO還有很多時候用作首屏優化,加快首屏速度,提高用戶體驗。**但是對服務器有要求,網絡傳輸數據量大,佔用部分服務器運算資源。

由於三大框架的興起,SPA項目到處都是,所以涌現了一批nuxt.js、next.js這些服務器渲染的框架。但是這些框架構建出來的項目可能文件夾和我們現有的項目很大不一樣,所以本文章主要是用vue-server-renderer來對現有項目進行改造,而不是去用框架。

ps:(劃重點)單頁面項目的ssr改造的原理:

vue項目是通過虛擬 DOM來掛載到html的,所以對spa項目,爬蟲纔會只看到初始結構。虛擬 DOM,最終要通過一定的方法將其轉換爲真實 DOM。虛擬 DOM 也就是 JS 對象,整個服務端的渲染流程就是通過虛擬 DOM 的編譯成完整的html來完成的。

我們通過服務端渲染解析虛擬 DOM成html之後,你會發現頁面的事件,都沒法觸發。那是因爲服務端渲染vue-server-renderer插件並沒有做這方面的處理,所以我們需要客戶端再渲染一遍,簡稱同構。所以Vue服務端渲染其實是渲染了兩遍。下面給出一個官方的圖:

在這裏插入圖片描述


二、改造前後目錄文件對比

在這裏插入圖片描述
黃線部分是改造後新增的文件,怎麼樣,是不是覺得差別不大,總體架構上只有6個文件的差別。(#.#) 我們來理一理這些新增的文件。

  • server.dev.conf.js 本地調試和熱更新需要的配置文件

  • webpack.client.conf.js 客戶端打包配置文件,ssr打包是生成分爲客戶端和服務端的兩部分打包文件

  • webpack.server.conf.js 服務端打包配置文件,ssr打包是生成分爲客戶端和服務端的兩部分打包文件

  • entry-client.js 客戶端入口文件。spa的入口是main.js,ssr就分爲兩個入口(服務端和客戶端)

  • entry-server.js 服務端入口文件。spa的入口是main.js,ssr就分爲兩個入口(服務端和客戶端)

  • index.template.html 模板文件,因爲服務端渲染是通過服務器把頁面丟出來,所以我們需要一個模板,作爲頁面初始載體,然後往裏面添加內容。

  • server.js 啓動文件,服務端渲染我們需要啓動一個node服務器,主要配置在這個文件裏面。


三、webpack添加客戶端與服務端配置

1.webpack客戶端配置

const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.conf.js')
const HtmlWebpackPlugin  = require('html-webpack-plugin')
const path = require('path')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
  entry: './src/entry-client.js',
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
      minChunks: Infinity
    }),
    // 此插件在輸出目錄中
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin(),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, './../src/index.template.html'),
      filename: 'index.template.html'
    })
  ]
})

這裏面和spa項目有兩點不同,第一是入口變了,變爲了entry-client.js。第二是VueSSRClientPlugin,這個是生成一個vue-ssr-client-manifest.json客戶端入口文件。

2.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')

module.exports = merge(baseConfig, {
  // 將 entry 指向應用程序的 server entry 文件
  entry: './src/entry-server.js',

  // 這允許 webpack 以 Node 適用方式(Node-appropriate fashion)處理動態導入(dynamic import),
  // 並且還會在編譯 Vue 組件時,
  // 告知 `vue-loader` 輸送面向服務器代碼(server-oriented code)。
  target: 'node',

  // 對 bundle renderer 提供 source map 支持
  devtool: 'source-map',

  // 此處告知 server bundle 使用 Node 風格導出模塊(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },
  externals: nodeExternals({
    // 不要外置化 webpack 需要處理的依賴模塊。
    // 你可以在這裏添加更多的文件類型。例如,未處理 *.vue 原始文件,
    // 你還應該將修改 `global`(例如 polyfill)的依賴模塊列入白名單
    whitelist: /\.css$/
  }),

  // 這是將服務器的整個輸出
  // 構建爲單個 JSON 文件的插件。
  // 默認文件名爲 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin()
  ]
})

這段代碼一目瞭然,第一是是告訴webpack這是要打包node能運行的東西,第二是打包一個服務端入口vue-ssr-server-bundle.json


四、vue、router、store實例改造

當編寫純客戶端 (client-only) 代碼時,我們習慣於每次在新的上下文中對代碼進行取值。但是,Node.js 服務器是一個長期運行的進程。當我們的代碼進入該進程時,它將進行一次取值並留存在內存中。這意味着如果創建一個單例對象,它將在每個傳入的請求之間共享。

nodejs是一個運行時,如果只是個單例的話,所有的請求都會共享這個單例,會造成狀態污染。所以我們需要爲每個請求創造一個vue,router,store實例。

第一步修改main.js

// main.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store/store.js'
import { sync } from 'vuex-router-sync'

export function createApp () {
  // 創建 router 實例
  const router = createRouter()
  const store = createStore()

  // 同步路由狀態(route state)到 store
  sync(store, router)

  const app = new Vue({
    // 注入 router 到根 Vue 實例
    router,
    store,
    render: h => h(App)
  })

  // 返回 app 和 router
  return { app, router, store }
}

看到這個createApp沒,沒錯,它就是我們熟悉的工廠模式。同樣的store和router一樣改造

// router.js
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'

Vue.use(Router)


export let createRouter = () => {
  let route  = new Router({
    mode:'history',
    routes: []
  })
  return route
}
// store.js
// store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
  return new Vuex.Store({
    state: {
    },
    actions: {
    },
    mutations: {
    }
  })
}

到這裏,三個實例對象改造完成了。是不是很簡單~


五、數據預取和存儲

服務器渲染,可以理解爲在被訪問的時候,服務端做預渲染生成頁面,上面說過,預渲染的缺點就是,實時數據的獲取。所以如果應用程序依賴於一些異步數據,那麼在開始渲染過程之前,需要先預取和解析好這些數據。

另一個需要關注的問題是在客戶端,在掛載 (mount) 到客戶端應用程序之前,需要獲取到與服務器端應用程序完全相同的數據 - 否則,客戶端應用程序會因爲使用與服務器端應用程序不同的狀態,然後導致混合失敗。這個地方上面提過,叫同構(服務端渲染一遍,客戶端拿到數據再渲染一遍)

因爲我們用的vue框架嘛,那當然數據存儲選vuex咯。然後我們來理一下總體的流程:

客戶端訪問網站 —> 服務器獲取動態數據,生成頁面,並把數據存入vuex中,然後返回html —> 客戶端獲取html(此時已經返回了完整的頁面) —> 客戶端獲取到vuex的數據,並解析到vue裏面,然後再一次找到根元素掛載vue,重複渲染頁面。(同構階段)

流程清楚之後,那我們怎麼設定,哪個地方的代碼,被服務端執行,並獲取數據存入vuex呢? 我們分爲三步:

1.自定義函數asyncData

官方的例子是定義一個asyncData函數(這個名字不是唯一的哈,是自己定義的,可以隨便取,不要理解爲內置的函數哈),這個函數寫在路由組件裏面。
假設有一個Item.vue組件(官網的例子)

<!-- Item.vue -->
<template>
  <div>{{ item.title }}</div>
</template>

<script>
export default {
  asyncData ({ store, route }) {
    // 觸發 action 後,會返回 Promise
    return store.dispatch('fetchItem', route.params.id)
  },
  computed: {
    // 從 store 的 state 對象中的獲取 item。
    item () {
      return this.$store.state.items[this.$route.params.id]
    }
  }
}
</script>

2. 服務端入口entry-server.js配置

到這裏,asyncData函數,我們知道它是放在哪裏了。接下來,我們有了這個函數,我們服務器肯定要去讀到這個函數,然後去獲取數據吧?我們把目光放到entry-server.js,之前我們提到過,這是服務端的入口頁面。那我們是不是能夠在這裏面處理asyncData呢。下面還是官網的例子:

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

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    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,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 在所有預取鉤子(preFetch hook) resolve 後,
        // 我們的 store 現在已經填充入渲染應用程序所需的狀態。
        // 當我們將狀態附加到上下文,
        // 並且 `template` 選項用於 renderer 時,
        // 狀態將自動序列化爲 `window.__INITIAL_STATE__`,並注入 HTML。
        context.state = store.state

        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

簡單的讀下這段代碼。首先爲什麼是返回Promise呢?因爲可能是異步路由和組件,我們得保證,服務器渲染之前,已經完全準備就緒了。 然後注意**matchedComponents **它是通過傳入的地址,獲取到和路由匹配到的組件,然後如果存在asyncData,我們就去執行它,然後注入到context(渲染上下文,可以在客戶端獲取)裏面。

是不是簡單?這一步我們就已經從服務器端取到動態數據了,同時丟到頁面裏面了。如果不是爲了客戶端數據同步,這一步我們已經搞完服務端渲染了~ = =

3.客戶端入口client-server.js配置

搞完服務器端的配置,該客戶端了,畢竟數據要同步嘛。我們來看看客戶端的入口文件代碼:

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

之前服務端入口說過,狀態將自動序列化爲 window.__INITIAL_STATE__,並注入 HTML。
所以客戶端我們獲取到了,服務端已經搞好了數據了,我們拿過來直接替換現有的vuex就好了。

看到這裏,不是已經完成啦,完整的流程。但是到此爲止了嗎?還沒呢,既然是服務端渲染,你總要啓動服務器吧…

Ps: 數據預期,我們剛纔講到的只是服務端預取,其實還有客戶端預取。什麼是客戶端預取呢,簡單的理解就是,我們可以在路由鉤子裏面,找有當前路由組件沒有asyncData,有的話,就去請求,獲取到數據後,填充完之後,再渲染頁面。


六、啓動服務(server.js)配置

服務端渲染,服務端,肯定要一個啓動服務的文件哈,

const express = require("express");

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

let renderer

const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
function createRenderer (bundle, options) {

  return createBundleRenderer(bundle, Object.assign(options, {
    runInNewContext: false
  }))
}

const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
  template,
  clientManifest
})


server.use(express.static('./dist'))
// 在服務器處理函數中……
server.get('*', (req, res) => {
  const context = { url: req.url }
  // 這裏無需傳入一個應用程序,因爲在執行 bundle 時已經自動創建過。
  renderer.renderToString(context, (err, html) => {
    // 處理異常……
    res.end(html)
  })
})
server.listen(3001, () => {
    console.log('服務已開啓')
})

這就是服務端的啓動代碼了,只需處理獲取幾個打包過後的參數(template模板和clientManifest),傳入createBundleRenderer函數。然後通過renderToString,展現給客戶端。


七、熱更新與本地調試

上面一步是啓動服務,但是我們本地調試的時候,不可能每次build之後,再啓動,然後再修改,再build吧?那也太麻煩了。所以我們藉助webpack搞一個熱更新。這裏在build裏面添加一個文件
server.dev.conf.js

//server.dev.conf.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.conf.js')
const serverConfig = require('./webpack.server.conf.js')

const readFile = (fs, file) => {
  try {
    return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
  } catch (e) {}
}

module.exports = function setupDevServer (app, templatePath, cb) {
  let bundle
  let template
  let clientManifest

  let ready
  const readyPromise = new Promise(r => { ready = r })
  const update = () => {
    if (bundle && clientManifest) {
      ready()
      cb(bundle, {
        template,
        clientManifest
      })
    }
  }

  // read template from disk and watch
  template = fs.readFileSync(templatePath, 'utf-8')
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    console.log('index.html template updated.')
    update()
  })

  // modify client config to work with hot middleware
  clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
  clientConfig.output.filename = '[name].js'
  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  )

  // dev middleware
  const clientCompiler = webpack(clientConfig)
  const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    noInfo: true
  })
  app.use(devMiddleware)
  clientCompiler.plugin('done', stats => {
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))
    if (stats.errors.length) return
    clientManifest = JSON.parse(readFile(
      devMiddleware.fileSystem,
      'vue-ssr-client-manifest.json'
    ))
    update()
  })

  // hot middleware
  app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

  // watch and update server renderer
  const serverCompiler = webpack(serverConfig)
  const mfs = new MFS()
  serverCompiler.outputFileSystem = mfs
  serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    stats = stats.toJson()
    if (stats.errors.length) return

    // read bundle generated by vue-ssr-webpack-plugin
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    update()
  })

  return readyPromise
}

這個代碼基本上是從官方文檔copy下來的,寫的挺好的 哈哈。

怎麼理解這段代碼呢,這個代碼封裝了一個promise,因爲代碼更新後重新打包需要時間,所以我們在renderToString之前,需要等待一段處理的時間。這個代碼對3部分進行了監控,template.html、vue業務代碼、客戶端配置代碼。檢測到有改動之後,就重新打包獲取,然後返回。這裏就是熱更新部分代碼,當然我們還要改動server.js部分代碼,畢竟要處理開發模式和生成模式的不同。

//server.js
const express = require("express");

const fs = require('fs');
let path = require("path");
const server = express()
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
let renderer
let readyPromise
const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
function createRenderer (bundle, options) {
  return createBundleRenderer(bundle, Object.assign(options, {
    runInNewContext: false
  }))
}

if(isProd){
  const template = fs.readFileSync(templatePath, 'utf-8')
  const bundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  renderer = createRenderer(bundle, {
    template,
    clientManifest
  })

}else{
  readyPromise = require('./build/server.dev.conf.js')(
    server,
    templatePath,
    (bundle, options) => {
      renderer = createRenderer(bundle, options)
    }
  )
}
server.use(express.static('./dist'))
// 在服務器處理函數中……
server.get('*', (req, res) => {
  const context = { url: req.url }
  // 這裏無需傳入一個應用程序,因爲在執行 bundle 時已經自動創建過。
  // 現在我們的服務器與應用程序已經解耦!
  if(isProd){
    renderer.renderToString(context, (err, html) => {
      // 處理異常……
      res.end(html)
    })
  }else{
    readyPromise.then(()=>{
      renderer.renderToString(context, (err, html) => {
        // 處理異常……
        res.end(html)
      })
    })
  }
})
server.listen(3001, () => {
    console.log('服務已開啓')
})

從server.js的代碼改動,我們可以看到,server進行了是否爲生產環境的判斷,如果是測試環境,就取運行server.dev.conf.js,獲得返回的promise,然後再renderToString之前,把renderToString加入到promise鏈式調用裏面,這樣,熱更新就完成了,每次調用路由的時候,都會去獲取到最新的頁面。


到這裏所有的ssr改造已經完成了,當然我們還能優化,下面給出幾個點,自己思考哈:

  • 服務器緩存,既然是node服務器,我們當然可以做服務器緩存拉。

  • 流式渲染 (Streaming) 用 renderToStream 替代 renderToString;當 renderer 遍歷虛擬 DOM 樹 (virtual DOM tree) 時,會盡快發送數據。這意味着我們可以儘快獲得"第一個 chunk",並開始更快地將其發送給客戶端

發佈了46 篇原創文章 · 獲贊 138 · 訪問量 23萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章