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

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

前言

首先欢迎你们关注个人Github博客,也算是对个人一点鼓励,毕竟写东西无法变现,能坚持下去也是靠的是本身的热情和你们的鼓励。javascript

上一篇文章Vue同构(一)咱们介绍了若是使用Vue同构在服务端渲染一个简单组件并在服务端对应激活。对应的代码已经上传到Github。本篇文章咱们介绍Vue同构中路由相关的知识。html

路由

写到这里咱们首先讨论一下为何会须要有前端路由,为何咱们的程序中须要引入Vue-Router呢?其实最先的网站都是服务器渲染的,并不存在什么浏览器渲染。每次在浏览器导航栏输入对应的URL或者点击当前的页面的连接的时候,浏览器就会接收到对应的URL并渲染出HTML页面。这就会存在一个问题,就是每次操做都意味着页面刷新。异步请求的出现,解决了这一切,咱们能够经过XMLHTTPRequest去动态请求数据而不是每次都刷新对应界面,实现了不须要后台刷新实现页面交互。后来单页面应用(SPA: Single Page Web Application)的出现将这个概念更进一步,不只页面交互不须要刷新页面,连页面跳转都不须要刷新当前页面。当页面跳转都不须要刷新当前页面时,咱们必须就要解决的是不一样URL下组件切换的问题,这也就是前端路由所作的工做。前端

路由(Router)概念实际上是来自于后台,负责URL到函数的映射。好比:vue

/user         ->    getAllUsers()
/user/count   ->    getUserCount()

其中的每个URL到函数的映射规则咱们称为一个route,而router则至关于管理route的容器。前端路由的概念与此相似,只不过URL映射的是前端组件。好比:java

/user         ->    User组件

客户端渲染路由

得益于Vue的优雅设计,Vue与Vue Router的结合使用很是简单,其实就是首先配置好路由规则并生成路由实例,而后将路由实例传递给Vue根元素将其添加进来,最后使用router-view组件来告诉Vue Router在哪里渲染。webpack

<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官方文档,并非本篇文章的重点内容,所以咱们就不在赘述。git

Vue Router其实有两种模式: hash模式和history模式。hash模式是Vue Router默认的模式。要讲清这两种模式咱们不得不提到两种模式所对应的不一样的实现逻辑。github

hash模式

其实咱们能够想到,做为前端路由切换的过程当中是不能引发浏览器刷新的,不然就违反了SPA路由交互的规则。首先咱们就瞄上了URL中的片断标识符(锚点),做为一个完整的URL,格式以下web

http://user:pass@www.example.com:80/dir/index.html?uid=1#ch1

#ch1的部分就是咱们所说的片断标识符,一般可用来标记出已获取资源中的子资源,片断标识符的改变并不会引发浏览器的刷新,所以hash模式就是使用的片断标识符来做为前端路由的依据。在前端路由中咱们把片断标识符称做hash部分,hash部分仅仅只是客户端的状态,hash部分并不会被服务器端所接收。咱们能够经过window.onhashchagnge事件来监听url中hash部分的变化,这也是基于hash路由的基础。举个例子:vue-router

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(); // alerts "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支持一下个人博客,感激涕零,若是有表述错误的地方,欢迎你们指正。

相关文章
相关标签/搜索