Vue同构(二): 路由与代码分割

5,768 阅读11分钟

前言

  首先欢迎大家关注我的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,并启动服务器:

  现在我们思考一个问题,如果我们设置为路由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]         

然后我们启动服务器,访问'/'路由,我们发现请求如下:

首先我们看network,我们发现,0.[hash].js首先被请求,然后再请求1.[hash].js,并且二者加载的优先级是不同的,0.[hash].js的优先级高于1.[hash].js,这是为什么呢?我们看对应的html。

  我们可以看到0.[hash].js在注入的时候是preload而1.[hash].js注入的时候是prefetch,preload和prefetch之间有什么区别吗,其实但要说这两个都能单写一篇文章,但是在这边我们还是简单总结一下。

  prefetch是一种告诉浏览器获取一项可能被下一页访问所需要的资源方式。这意味着资源将以较低优先级地获取,因此prefetch是用于获取非当前页面使用的资源。

  preload是告诉浏览器提前加载较晚发现的资源。有些资源是隐藏在CSS和JavaScript中的,浏览器不知道页面即将需要这些资源,而等到发现时加载又太晚了,因此声明式的提前加载。

总结

这篇文章主要讲了在Vue通过下如果使用路由并且如何通过代码分割的方式进一步提高页面首屏加载速度。具体的代码可以点这里查看。最后希望能点个Star支持一下我的博客,感激不尽,如果有表述错误的地方,欢迎大家指正。