Vue同構(二): 路由與程式碼分割

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

前言

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

  上一篇文章Vue同構(一)我們介紹瞭如果使用Vue同構在服務端渲染一個簡單元件並在服務端對應啟用。對應的程式碼已經上傳到Github。本篇文章我們介紹Vue同構中路由相關的知識。

路由

  寫到這裡我們首先討論一下為什麼會需要有前端路由,為什麼我們的程式中需要引入Vue-Router呢?其實最早的網站都是伺服器渲染的,並不存在什麼瀏覽器渲染。每次在瀏覽器導航欄輸入對應的URL或者點選當前的頁面的連結的時候,瀏覽器就會接收到對應的URL並渲染出HTML頁面。這就會存在一個問題,就是每次操作都意味著頁面重新整理。非同步請求的出現,解決了這一切,我們可以通過XMLHTTPRequest去動態請求資料而不是每次都重新整理對應介面,實現了不需要後臺重新整理實現頁面互動。後來單頁面應用(SPA: Single Page Web Application)的出現將這個概念更進一步,不僅頁面互動不需要重新整理頁面,連頁面跳轉都不需要重新整理當前頁面。當頁面跳轉都不需要重新整理當前頁面時,我們必須就要解決的是不同URL下元件切換的問題,這也就是前端路由所做的工作。

路由(Router)概念其實是來自於後臺,負責URL到函式的對映。比如:

/user         ->    getAllUsers()
/user/count   ->    getUserCount()
複製程式碼

其中的每一個URL到函式的對映規則我們稱為一個route,而router則相當於管理route的容器。前端路由的概念與此類似,只不過URL對映的是前端元件。比如:

/user         ->    User元件
複製程式碼

客戶端渲染路由

  得益於Vue的優雅設計,Vue與Vue Router的結合使用非常簡單,其實就是首先配置好路由規則並生成路由例項,然後將路由例項傳遞給Vue根元素將其新增進來,最後使用router-view元件來告訴Vue Router在哪裡渲染。

<div id="app">
  <!-- 路由匹配到的元件將渲染在這裡 -->
  <router-view></router-view>
</div>
複製程式碼
//引入路由元件
import Home from '../components/Home.vue'
import About from '../components/About.vue'

//路由配置
const routes = [
  { path: '/', component: Home },
  { path: '/home', component: Home },
  { path: '/about', component: About }
]

//建立Vue Router例項
var router = new VueRouter({
    routes
})

//引入vue-router
const app = new Vue({
  router,
  //......
})
複製程式碼

伺服器渲染路由

上面我們介紹了Vue Router在客戶端渲染的邏輯,當然這只是最簡單的邏輯,更高階的使用可以參閱Vue Router官方文件,並不是本篇文章的重點內容,因此我們就不在贅述。

Vue Router其實有兩種模式: hash模式和history模式。hash模式是Vue Router預設的模式。要講清這兩種模式我們不得不提到兩種模式所對應的不同的實現邏輯。

hash模式

  其實我們可以想到,作為前端路由切換的過程中是不能引起瀏覽器重新整理的,否則就違反了SPA路由互動的規則。首先我們就瞄上了URL中的片段識別符號(錨點),作為一個完整的URL,格式如下

user:pass@www.example.com:80/dir/index.h…

#ch1的部分就是我們所說的片段識別符號,通常可用來標記出已獲取資源中的子資源,片段識別符號的改變並不會引起瀏覽器的重新整理,因此hash模式就是使用的片段識別符號來作為前端路由的依據。在前端路由中我們把片段識別符號稱作hash部分,hash部分僅僅只是客戶端的狀態,hash部分並不會被伺服器端所接收。我們可以通過window.onhashchagnge事件來監聽url中hash部分的變化,這也是基於hash路由的基礎。舉個例子:

window.addEventListener('hashchange', function(e){
  console.log('hashchange', e);
})
複製程式碼

如果瀏覽器的hash部分變化了,監聽函式會立刻呼叫對應的事件。

history模式

HTML5 引入了新的API,可以在不重新整理當前頁面的情況下,改變URL。分別對應的兩個方法:

  • pushState(state, title, url)
  • replaceState(state, title, url)

pushState用於向瀏覽器的歷史記錄中新增一條新記錄,同時改變位址列的地址內容。replaceState則與pushState類似,是修改了當前的歷史記錄項而不是新建一個。兩個函式對應的引數分別是:

  • state(狀態物件): 狀態物件state是一個JavaScript物件,url改變後對應的事件狀態可以讀取到該狀態物件。可用於還原頁面狀態。
  • title(標題): 目前忽略這個引數,但未來可能會用到。可傳空字串
  • URL: 該引數定義了新的歷史URL記錄。

pushStatereplaceState配套使用的是onpopstate事件,需要注意的是呼叫history.pushState()history.replaceState()不會觸發popstate事件。只有在做出瀏覽器動作時,才會觸發該事件,如使用者點選瀏覽器的回退按鈕(或者在Javascript程式碼中呼叫history.back()

例如:

window.addEventListener('popstate', function(){
	console.log("location: " + document.location + ", state: " +JSON.stringify(event.state));
})

history.pushState({page: 1}, "title 1", "?page=1");
history.back(); //"location: http://example.com/example.html?page=1, state: {"page":1}"
複製程式碼

  兩種模式我們說完了,history相比於hash來說url要美觀,但是需要後臺伺服器的支援,因為history最怕瀏覽器重新整理了,比如我們前端的路由從/home改變為/about,這個僅僅是前端url的改變,並不會重新整理當前頁面,並且包括瀏覽器的後退和前進也不會重新整理瀏覽器。但是如果一旦重新整理,瀏覽器是真的會去請求當前的url,比如/about。這個時候,如果瀏覽器並不能識別這個url,就可能找不到當前頁面。

簡單的例子

  說了這麼多,我們伺服器渲染需要採用哪種模式呢?我們採用的是history模式,這個是唯一的選擇,答案其實上面已經說過了,因為hash部分僅僅只是客戶端的狀態,並不會被伺服器端所接收。現在我們假設我們當前的應用有兩個路由:

/             ->    Home
/about        ->    About
複製程式碼

  首先我們建立我們的路由例項,上一篇文章中我們會為每次的請求建立新的元件例項,其目的就是為了方式不同的請求之間交叉影響,路由例項也是相同的道理:

// router/index.js
import Vue from 'vue'
import Router from 'vue-router'

import Home from '../components/Home.vue'
import About from '../components/About.vue'

Vue.use(Router)

export function createRouter() {
    return new Router({
        mode: "history",
        routes: [{
            path: '/', component: Home
        }, {
            path: "/about", component: About
        }]
    })
}

複製程式碼

  createRouter函式每次呼叫都建立一個路由例項,路由例項中配置的history模式,並且配置了路由規則。

接下來我們看看Home元件:

<template>
    <div>
        <div>當前位置: About</div>
        <router-link to="/home">前往Home</router-link>
        <button @click="directHome">按鈕: 前往Home</button>
    </div>
</template>

<script>
    export default {
        name: "about",
        methods: {
            directHome: function () {
                this.$router.push('/');
            }
        }
    }
</script>
複製程式碼

這個元件我之所以在使用了router-link的情況下還使用了button,主要是為了證明客戶端已經啟用。About元件和Home元件除了名字和連結地址不同,其餘完全一致,不再列出。

我們在根元件App中渲染路由匹配的元件

//App.Vue
<template>
    <div id="app">
        <router-view></router-view>
    </div>
</template>
複製程式碼

  接下來我們需要繼續改造app.js,上篇文章中我們已經介紹過伺服器中app.js主要任務是對外暴露一個工廠函式,具體客戶端和瀏覽器端的邏輯已經分別轉移到客戶端和瀏覽器端的入口檔案entry-client.jsentry-server.js

import Vue from 'vue'
import App from './components/App.vue'
import {createRouter} from './router'

export function createApp() {
    const router = createRouter()

    const app =  new Vue({
        router,
        render: h => h(App)
    })

    return {
        app,
        router
    }
}
複製程式碼

createApp與之前不同之處在於,每次建立的Vue例項中都注入了router。並返回了建立的Vue例項和Vue Router例項。

服務端渲染的邏輯集中在entry-server.js:

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

export default function (context) {
    return new Promise((resolve, reject) => {
        const {app, router} = createApp()
        router.push(context.url)
        router.onReady(() => {
            // Promise 應該 resolve 應用程式例項,以便它可以渲染
            resolve(app)
        }, reject)

    })
}

複製程式碼

  entry-server.js作為服務端渲染的入口打包為對應的bundle傳入createBundleRenderer生成renderer,呼叫renderer.renderToString可以傳入context,其中就可以包含當前路由的url。而我們在entry-server.js的函式中接受該context物件便可以獲取該路由資訊。

  與上面文章不同的,我們並沒有直接返回Vue例項而是返回了一個Promise,在Promise中首先我們呼叫createApp獲取到Vue例項app和Vue Router例項router,然後我們呼叫push函式將當前的路由導航到目標url上。然後我們呼叫在router.onReady函式,確保等待路由的所有非同步鉤子函式非同步元件載入完畢之後,resolve當前的Vue例項。

  與entry-server.js相似,客戶端的打包入口檔案entry-client.js也需要在掛載 app 之前呼叫 router.onReady:

import { createApp } from './app'

const {app, router} = createApp();

router.onReady(() => {
    app.$mount('#app')
})
複製程式碼

  現在我們繼續來看我們的express伺服器程式碼,和上次的渲染基本完全一致,只不過我們需要給renderToString傳遞一個context物件,其中包含當前的url值即可。

//server.js
//省略......

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

//省略......
複製程式碼

現在我們打包好服務端和瀏覽器端的bundle,並啟動伺服器:

Vue同構(二): 路由與程式碼分割

  現在我們思考一個問題,如果我們設定為路由router中設定了守衛,是會在瀏覽器中執行還是會為服務端執行呢?為了驗證這個問題,我們給router增加全域性守衛beforeEachafterEach:

export function createApp() {
    const router = createRouter()

    router.beforeEach((to, from, next) => {
        console.log("beforeEach---start");
        console.log('to: ', to.path, ' from: ', from.path);
        console.log("beforeEach---end");
        next();
    })

    router.afterEach((to, from) => {
        console.log("afterEach---start");
        console.log('to: ', to.path, ' from: ', from.path);
        console.log("afterEach---end");
    })
    // 省略......
}
複製程式碼

  我們直接訪問/路由,我們可以看到服務端和客戶端的輸出結果如下:

服務端

客戶端

  這說明守衛函式在伺服器端和客戶端都同時執行了,兩端的路由都解析了呼叫元件中可能存在的路由鉤子。開發過程中可能要留心這點。

程式碼分割

  首先可以考慮一個問題,我們當初引入Vue的同構的主要目的就是加快首屏的顯示速度,那麼我們可以考慮一下,如果我們訪問/路由的時候,其實只需要載入Home元件就可以了,並不需要載入About元件。等到需要的時候,我們可以再去載入About元件,這樣我們就可以減少初始渲染中下載的資源體積,加快可互動時間。在這裡我們就可以考慮對程式碼進行分割。

  程式碼分割其實也是Webpack所支援的特性,可以將不同的程式碼打包到不同的bundle中,然後按需載入檔案。

  Webpack最簡單的程式碼分割無非是手動操作,你可以通過配置多個entry來實現,但是手動的模式存在諸多的問題,比如多個bundle都引用了相同的模組,則每個bundle中都存在重複程式碼。這個問題倒是好解決,我們可以使用SplitChunksPlugin外掛去解決這個問題。但是手動畢竟還是不太方便,所以Webpack提供了更為方便的動態匯入

  動態匯入的功能推薦使用ECMAScript提案的import()語法,import()可以指定所要載入的模組的位置,然後執行時動態載入該模組,並返回一個Promise。比如說我們在一個模組中想要動態載入lodash模組,我們首先可以在Webpack的配置檔案中新增:

output: {
    chunkFilename: '[name].bundle.js',
},
複製程式碼

chunkFilename就是為了配置決定非入口chunk的名稱,然後在程式碼中:

import(/* webpackChunkName: "lodash" */ 'lodash').then(lodash => {
    //lodash便可以使用
})
複製程式碼

  打包程式碼我們可以發現lodash被單獨打包,因為在註釋中我們將webpackChunkName的值賦值為lodash,因此將其命名為 lodash.bundle.js。當然這種chunkFilename也並不是必須的,預設會被命名成[id].bundle.js

非同步元件

  Vue提供非同步元件的概念,允許我們將程式碼分割成程式碼塊,並且按需載入。相比與普通的元件註冊,我們可以用工廠函式的方式定義元件,這個工廠函式會收到一個resolve回撥,這個回撥函式會在你從伺服器得到元件定義的時候被呼叫。或者直接在該工廠函式中返回一個Promise。我們知道import()語法返回的就是一個Promise,因此我們搭配改造之前的程式碼:

// router.js
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export function createRouter() {
    return new Router({
        mode: "history",
        routes: [{
            path: '/',
            component: () => import('../components/Home.vue')
        }, {
            path: "/about",
            component: () => import('../components/About.vue')
        }]
    })
}
複製程式碼

然後打包客戶端bundle:

> vue-ssr-demo@1.0.0 build:client /Users/mr_wang/WebstormProjects/vue-ssr-demo
> cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules

Hash: 16fbba9bf008ec7ef466                                                            
Version: webpack 3.12.0
Time: 1158ms
                           Asset     Size  Chunks                    Chunk Names
       0.8ac6ad83b93d774d3817.js  5.04 kB       0  [emitted]         
       1.5967060b78729a4577f9.js  5.04 kB       1  [emitted]         
     app.1c160fc3e08eec3aed0f.js  7.37 kB       2  [emitted]         app
  vendor.f32c57c9ee5145002da1.js   296 kB       3  [emitted]  [big]  vendor
manifest.4b057fd51087adaec1f3.js  5.85 kB       4  [emitted]         manifest
    vue-ssr-client-manifest.json  1.48 kB          [emitted]     
複製程式碼

  我們發現輸出檔案多了0.[hash].js和1.[hash].js,其中分別對應的就是Home元件與About元件。當然如果你覺得這個模組看起來不清晰,也可以按照之前所說的傳入webpackChunkName引數,讓打包出來的問題更具有可識別性:

component: import(/* webpackChunkName: "home" */'../components/Home.vue')
複製程式碼
component: import(/* webpackChunkName: "about" */'../components/About.vue')
複製程式碼

這時Webpack打包出的檔案:

Hash: aaf79995904c4786cadc                                                           
Version: webpack 3.12.0
Time: 976ms
                           Asset     Size  Chunks                    Chunk Names
                  home.bundle.js  5.04 kB       0  [emitted]         home
                 about.bundle.js  5.04 kB       1  [emitted]         about
     app.f22015420ff0db6ec4b0.js  7.37 kB       2  [emitted]         app
  vendor.f32c57c9ee5145002da1.js   296 kB       3  [emitted]  [big]  vendor
manifest.2a21c55e4a3e98ab252c.js  5.83 kB       4  [emitted]         manifest
    vue-ssr-client-manifest.json  1.44 kB          [emitted]         
複製程式碼

然後我們啟動伺服器,訪問'/'路由,我們發現請求如下:

Vue同構(二): 路由與程式碼分割

首先我們看network,我們發現,0.[hash].js首先被請求,然後再請求1.[hash].js,並且二者載入的優先順序是不同的,0.[hash].js的優先順序高於1.[hash].js,這是為什麼呢?我們看對應的html。

Vue同構(二): 路由與程式碼分割

  我們可以看到0.[hash].js在注入的時候是preload而1.[hash].js注入的時候是prefetch,preload和prefetch之間有什麼區別嗎,其實但要說這兩個都能單寫一篇文章,但是在這邊我們還是簡單總結一下。

  prefetch是一種告訴瀏覽器獲取一項可能被下一頁訪問所需要的資源方式。這意味著資源將以較低優先順序地獲取,因此prefetch是用於獲取非當前頁面使用的資源。

  preload是告訴瀏覽器提前載入較晚發現的資源。有些資源是隱藏在CSS和JavaScript中的,瀏覽器不知道頁面即將需要這些資源,而等到發現時載入又太晚了,因此宣告式的提前載入。

總結

這篇文章主要講了在Vue通過下如果使用路由並且如何通過程式碼分割的方式進一步提高頁面首屏載入速度。具體的程式碼可以點這裡檢視。最後希望能點個Star支援一下我的部落格,感激不盡,如果有表述錯誤的地方,歡迎大家指正。

相關文章