Vue同構(一): 快速上手

請叫我王磊同學發表於2018-08-09

前言

  首先歡迎大家關注我的Github部落格,也算是對我的一點鼓勵,畢竟寫東西沒法獲得變現,能堅持下去也是靠的是自己的熱情和大家的鼓勵。   

同構(伺服器渲染)

  Vue同構也就是我們常說的伺服器渲染(Server Side Render),伺服器渲染放在今天已經算不上是一個新鮮的東西了,從React到Vue都有各自的伺服器渲染方案,很多小夥伴可能都有所接觸,首先我們要了解一下為什麼需要伺服器渲染呢?Vue和React這類框架有一個特點,都屬於瀏覽器渲染,比如一個最簡單的例子:   

<div id="app">
  {{ message }}
</div>
複製程式碼
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})
複製程式碼

  我們可以看到,我們收到伺服器的模板中其實並沒有我們所期待介面對應的html結構,而僅有一個用於掛載應用的根元素,在客戶端瀏覽器執行載入的JavaScript程式碼時,才會建立對應的DOM結構。然而瀏覽器渲染其實存在兩個明顯的缺點:

  • 對搜尋引擎優化(SEO:Search Engine Optimization)不友好,各個搜尋引擎實際上都是對網頁的html結構和同步Javascript程式碼進行索引,因而客戶端渲染可能會造成你的網頁無法被搜尋引擎正確索引。
  • TTC(內容到達時間:Time-To-Conten)過長,試想如果裝置的網路較差或者裝置的程式碼執行速度較慢,使用者需要等待較長的時間才能看到頁面的內容,等待期間看到的都是網頁的白屏或者其他的載入狀態,這絕對是糟糕的使用者體驗。

  幸運的是,Node的到來為這一切帶來了曙光,JavaScript不僅僅可以在瀏覽器中執行,而且也可能在後端環境中執行。因此我們可以將使用者的介面在伺服器中渲染成HTML 字串,然後再傳給瀏覽器,這樣使用者獲得的就是可預覽的介面,最後將靜態標記"混合"為客戶端上完全互動的應用程式,整個渲染的過程就結束了。

最簡單的例子

  Vue伺服器渲染使用官方提供的庫vue-server-renderer,由於Express比較直觀,我們採用Express作為後端伺服器,我們首先給出一個最簡單的例子:

// server.js
const Vue = require('vue')
const server = require('express')()
// 建立一個 renderer
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
  // 建立一個 Vue 例項
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>訪問的 URL 是: {{ url }}</div>`
  })

  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    // html就是Vue例項app渲染的html
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
  })
})

server.listen(8080)
複製程式碼

  然後啟動node server.js,並且瀏覽器中訪問比如http://localhost:8080/app,瀏覽器介面中則會顯示出:

訪問的 URL 是:/app

  這時候觀察該請求的返回值是:

Vue同構(一): 快速上手

  我們發現返回的html中已經渲染好DOM元素。因此我們無需等待立即可以看見頁面的內容。而上面的程式碼邏輯也非常簡單,http伺服器接收到get請求的時候,都會建立一個Vue例項,vue-server-renderer中的createRenderer用來建立一個Renderer例項,Renderer中的renderToString用來將Vue例項轉化對應的HTML字串,需要注意的是,我們需要將建立好的字串包裹在html一併返回。當然你可以採用頁面模板的形式,將兩者相分離:

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
    <!--這裡將是應用程式 HTML 標記注入的地方>
  </body>
</html>
複製程式碼
// renderer中包含了模板
const renderer = createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})

renderer.renderToString(app, (err, html) => {
  res.end(html)
})
複製程式碼

  當然這只是最簡單的一個例子,瀏覽器收到的僅僅是對應Vue例項的html程式碼,並沒有將其啟用,因此是不可互動的。

瀏覽器渲染的流程

  對於瀏覽器渲染中,我們首選Webpack對程式碼進行打包,整體流程可以通過下面圖來釋義:

Vue同構(一): 快速上手

  對於一個Vue應用,原始碼層面其實主要包括三個方面: 元件、路由、狀態管理。這部分程式碼我們認為是通用程式碼,可以同時在伺服器端和瀏覽器端執行,Webpack有兩個入口: server entryclient entry,分別用來打包在伺服器端執行的程式碼與在瀏覽器端執行的程式碼。Server Bundle作為打包在伺服器端執行的程式碼,負責的生成對應的HTML,而Clinet Bundle作為執行在瀏覽器端的程式碼,主要負責的就是啟用應用。

下面我們給出對應的webpack配置,為了方便上手我們就僅僅只列出最簡單的配置,讓我們能將程式碼跑起來,配置包括三個部分: baseclientserver,其中base是二者間能通用的部分,client則是對應瀏覽器的打包配置,server是伺服器端的打包配置,通過webpack-merge(可以簡單理解成 Object.assign)將其連線:

// webpack.base.config.js
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        filename: '[name].[chunkhash].js'
    },
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            },
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            }
        ]
    },
    plugins: [
        new VueLoaderPlugin()
    ]
}
複製程式碼

  上面是一個最簡單的webpack中通用的配置,規定了三部分:

  • output: 打包檔案怎樣儲存輸出結果以及儲存到哪裡
  • module: 我們對js檔案和vue檔案執行相應的loader
  • plugins: VueLoaderPlugin外掛是必須的,作用是將你定義過的其它規則複製並應用到 .vue 檔案裡相應語言的塊。比如vue檔案中script標籤對應的JavaScript程式碼和stype標籤對應的css程式碼。
// webpack.server.config.js
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(base, {
    target: 'node',
    entry: './src/entry-server.js',
    output: {
        libraryTarget: 'commonjs2'
    },
    plugins: [
        new VueSSRServerPlugin()
    ]
})
複製程式碼

  上面的配置用來打包伺服器架bundle:

  • target: 用來指示構建目標,node表示webpack會編譯為用於類 Node.js環境
  • entry: 伺服器打包入口檔案
  • libraryTarget: 因為是用於Node環境,因此我們選擇commonjs2
  • VueSSRServerPlugin: 用來打包生成的伺服器端的bundle,最終可以將所有檔案打包成一個json檔案,最終傳給伺服器renderer使用。
// webpack.client.config.js
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(base, {
    entry: {
        app: './src/entry-client.js'
    },
    plugins: [
        // extract vendor chunks for better caching
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function (module) {
                // a module is extracted into the vendor chunk if...
                return (
                    // it's inside node_modules
                    /node_modules/.test(module.context)
                )
            }
        }),
        // extract webpack runtime & manifest to avoid vendor chunk hash changing
        // on every build.
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest'
        }),
        new VueSSRClientPlugin()
    ]
})
複製程式碼
  • entry: 瀏覽器打包入口檔案。
  • VueSSRClientPlugin:類似於VueSSRServerPlugin外掛,主要的作用就是將前端的程式碼打包成bundle.json,然後傳值給renderer,可以自動推斷和注入preload/prefetch指令和script標籤到渲染的HTML中。

  關於CommonsChunkPlugin外掛,其實對於一個最簡單的應用而言是可以沒有的,但是因為其有助於效能提升還是加了進來。在最開始學習Webpack的時候,每次打包的時候都會將所有的程式碼打包到同一個檔案,比如app.[hash].js中,其實在app.[hash].js中包含兩部分程式碼,一部分是每次都在變化的業務邏輯程式碼,另一部分是幾乎不會變化的類庫程式碼(例如Vue的原始碼)。現在這種情況其實很不利於瀏覽器的快取,因為每次業務程式碼改變後,app.[hash].js一定會發生改變,因此瀏覽器不得不重新請求,而app.[hash].js的程式碼量可能都是數以兆計的。因此我們可以將業務程式碼和類庫程式碼相分離,在上面的例子中:

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: function (module) {
    // a module is extracted into the vendor chunk if...
        return (
        // it's inside node_modules
        /node_modules/.test(module.context)
        )
    }
}),
複製程式碼

  我們將引用的node_modules中的程式碼打包成vendor.[hash].js,其中就包含了引用的類庫,這是程式碼中相對不變的部分。但是如果僅僅只有上面的部分的話,你會發現每次邏輯程式碼改變後,vendor.[hash].jshash值也會發生改變,這是為什麼呢?因為Webpack每次打包執行的時候,仍然是會產生一些和Webpack當前執行相關的程式碼,會影響到執行的打包值,因此vendor.[hash].js每次打包仍然是會發生改變,這時候其實瀏覽器並不能正確的快取。因此我們使用:

new webpack.optimize.CommonsChunkPlugin({
    name: 'manifest'
})
複製程式碼

我們需要將執行環境提取到一個單獨的manifest檔案中,這樣vendorhash就不會變了,瀏覽器就可以將vendor正確快取,mainfesthash雖然每次在變,但很小,比起vendor變化帶來的影響可以忽略不計。

我們之前講過,Vue的應用其實可以劃分成三個部分: 元件、路由、狀態管理,作為SSR系列的第一篇上手文章,我們僅介紹如何在服務端渲染一個簡單元件並在客戶端啟用該元件,使得其可互動。路由和狀態管理等其他部分會在後序部分介紹。

元件

  首先我們用Vue寫一個最簡單的可計數的元件,點選"+"可以增加計數,點選"-"可以減少計數。

// App.vue
<template>
    <div id="app">
        <span>times: {{times}}</span>
        <button @click="add">+</button>
        <button @click="sub">-</button>
    </div>
</template>

<script>
    export default {
        name: "app",
        data: function () {
            return {
                times: 0
            }
        },
        methods: {
            add: function () {
                this.times = this.times + 1;
            },
            sub: function () {
                this.times = this.times - 1;
            }
        }
    }
</script>
<style scoped>
</style>
複製程式碼

  上面的部分是一個非常簡單的Vue元件,也是服務端和客戶端渲染的通用程式碼。在單純的客戶端渲染的程式中,會存在一個app.js用來建立一個Vue例項並將其掛載到對應的dom上,例如:

// 客戶端渲染 app.js
import App from './App.vue'

new Vue({
  el: '#app',
  components: { App },
  template: '<App/>',
})
複製程式碼

  在伺服器渲染中,app.js僅會對外暴露一個工廠函式,用來每次都呼叫的都會返回一個新的元件例項用於渲染。具體的其他邏輯都被各自轉移到客戶端和瀏覽器端的入口檔案中。

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

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

  不同於客戶端渲染,值得注意的是我們需要為每一次請求都建立一個新的Vue例項,而不能共享同一個例項,因為如果我們在多個請求之間使用一個共享的例項,可能會在各自的請求中造成狀態的汙染,所以我們為每一次請求都建立獨立的的元件例項。

  接下來看瀏覽器端打包入口檔案:

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

export default context => {
    const app = createApp()
    return app
}
複製程式碼

  entry-server.js對外提供一個函式,用於建立當前的元件例項。接著看客戶端打包入口檔案:

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

var app = createApp();

app.$mount('#app')
複製程式碼

  邏輯也是非常的簡單,我們建立一個Vue例項,用將其掛載到idapp的DOM結構中。

  這時候我們執行命令分別打包客戶端和伺服器端的程式碼,我們發現dist,目錄下分別出現以下檔案:

Vue同構(一): 快速上手

  我們可以看到app.[hash].js是打包的業務程式碼,vendor.[hash].js則是相應的庫的程式碼(比如Vue原始碼),manifest.[hash].js則是CommonsChunkPlugin生成manifest檔案。而vue-ssr-client-manifest.json則是VueSSRClientPlugin生成的對應客戶端的bundle,而vue-ssr-server-bundle.json則是VueSSRServerPlugin外掛生成的伺服器端的bundle。有了上面的打包檔案,我們就可以處理請求:

//server.js
const fs = require("fs")
const express = require("express")
const { createBundleRenderer } = require('vue-server-renderer')

const template = fs.readFileSync("./src/index.template.html", "utf-8")
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')


const app = express();
app.use("/dist", express.static("dist"))

const renderer = createBundleRenderer(bundle, {
    template,
    clientManifest
})


app.get('*', (req, res) => {
    renderer.renderToString({}, function (err, html) {
        res.end(html);
    });
})

app.listen(8080, function () {
    console.log("server start and listen port 8080")
})
複製程式碼

  這次我們並沒有使用一開始介紹的vue-server-renderer中的createRenderer函式,而是使用的createBundleRenderer函式,我們在server.js中分別引入了server-bundle.jsonclient-manifest.json與模板template.html,然後將其傳給createBundleRenderer函式生成renderer,然後在每一次請求中,呼叫的rendererrenderToString方法,生成對應的html,然後返回客戶端。renderToString的第一個引數實質是上下文context物件,一方面context用於處理模板檔案,比如模板檔案中存在

<title>{{title}}</title>
複製程式碼

  而context中存在title: 'SSR',模板中的檔案則會被插值。另一部分,客戶端的入口檔案server-entry.js的中函式也會收到該context,可用於傳遞相關的引數。

  我們之所以會使用express.staticdist資料夾下面的檔案提供靜態的資源服務的原因是客戶端的程式碼中會注入相應的JavaScript檔案(比如app.[hash].js),這樣才能保證對應的資源可以被請求到。

  然後我們執行命令:

node server.js
複製程式碼

  並在瀏覽器中訪問http://localhost:8080。你就會發現一個簡單的計數器的程式已經執行,並且是可執行,點選按鈕會觸發相應的事件。

Vue同構(一): 快速上手

  這是的對應接受的html結構為:

Vue同構(一): 快速上手

  我們發現返回的html程式碼中就有我們Vue例項對應的DOM結構,與普通的客戶端結構不同的,根元素中存在data-server-rendered屬性,表示該結構是由服務端對應渲染的節點,在開發模式中,Vue將渲染的虛擬DOM與當前的DOM結構相比較,如果相等的時候,則會複用當前結構,否則會放棄已經渲染好的結構,轉而重新在客戶端渲染。在生產模式下,則會略過檢測的步驟,直接複用,避免浪費效能。

  在伺服器渲染中,一個元件僅僅會經歷beforeCreatecreated兩個生命週期,而其餘的例如beforeMount等生命週期並不會在伺服器端執行,因此應該注意的是避免在beforeCreatecreated 生命週期時產生全域性副作用的程式碼,例如在beforeCreatecreated中使用setInterval設定timer,而在beforeDestroydestroyed生命週期時將其銷燬,這會造成timer永遠不會被取消。

  至此我們介紹了一個最簡單的Vue伺服器渲染示例並在客戶端對應將其啟用,伺服器渲染的其他部分比如路由、狀態管理等部分我們將在接下來的文章一一介紹,有興趣的同學記得在我的Github部落格中點個Star,如果文章中有不正確的地方,歡迎指出,願一同進步。

相關文章