理解前端路由, hash与history,看懂vue-router

245 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

路由,可以理解为提供一个可供访问的URL,浏览器请求该地址获取到正确的页面资源并呈现给用户。

路由就是就好比是图书馆记录书籍的索引,我们通过这个索引(地址)从而快速准确的获取我们想要的资源;在前后端不分离,后端通过模板引擎整合html的时期,用户每次获取页面,都需要通过访问服务器获取该索引对应的静态资源,这就意味着每次访问页面都是加载新的html,用户都要经历页面刷新过程
前端路由,顾名思义,就是对索引的维护和跳转等逻辑处理交由前端开发处理,不再需要通过访问服务器来获取索引对应资源,这种方式带来的最显而易见优势就是用户点击页面的跳转,不再需要经过页面的加载,而是无刷新感知,很好的保证操作的连贯性,带来更流畅的用户体验。

前端路由方式目前有两种,一种是hash,一种是history,这两种有什么区别呢,我们往下看:

hash

hash模式下的路由处理,依赖的底层实现基于浏览器Location对象, 先来了解下它的属性和API。

  • 假如我们访问https://juejin.cn/creator/content/article/drafts#/iscool?isright=1
location.href; 
// 完整链接, 'https://juejin.cn/creator/content/article/drafts#/iscool?isright=1'
location.protocol;
// 对应http协议,最后有一个":", 'https:'
location.host;
// 域名(可能在该串最后带有一个":"并跟上 URL 的端口号), 'juejin.cn'
location.hostname;
// 域名, 'juejin.cn'
location.port;
// 返回端口号, ''
location.pathname;
// 链接中以'/'开头直到'#'前的这段路径, '/creator/content/article/drafts'
location.search;
// 链接中'?'后面的内容 注意'https://juejin.cn/creator/content/article/drafts#/iscool?isright=1' -> ''; 'https://juejin.cn/creator/content/article/drafts?isright=1' -> '?isright=1'
location.hash;
// 返回'#'后面的内容, '#/iscool?isright=1'
location.origin;
// 返回当前页面域名的标准形式
  • Location.API < https://juejin.cn/creator/content/article/drafts#/iscool?isright=1 >
// 假定originUrl: 'https://juejin.cn/creator/content/article/drafts#/iscool?isright=1'

location.assign((path = ''));
// 加载给定 URL 的内容资源到当前location上(基于当前域名资源路径),如果传空字符,会刷新页面
// location.assign('notcool') -> 'https://juejin.cn/creator/content/article/notcool'

location.reload(refresh?);
// 重新加载RUL,refresh为可选参数,true -> 类似于强制刷新; false -> 允许本地缓存刷新

location.replace(url);
// 用给定的url替换掉当前资源(基于当前域名资源路径),与assign方法不同的是,replace替换的新页面不会保存在回话的History栈中,用户进入到新url无法通过后退按钮返回回去
// 满足下面这个规则:
// location.replace('notcool'|| './notcool'):    originUrl ->  'https://juejin.cn/creator/content/article/notcool'
// location.replace('/notcool'):                 originUrl ->  'https://juejin.cn/notcool'
// location.replace('www.baidu.com'):            originUrl ->  'https://juejin.cn/creator/content/article/www.baidu.com'
// location.replace('https://www.baidu.com'):    originUrl ->  'https://www.baidu.com'

location.toString();
// 返回整个URL,与href效果相同,但是用它无法修改location的值
  • hashchange
当 URL 的片段标识符更改时,将触发hashchange事件 (跟在#符号后面的 URL 部分,包括#符号)

了解了Location对象以及浏览器hashchange事件,我们再看下怎么利用location处理前端路由;

  • 简单的路由跳转实现
html:
<div id="apps">
    <span>点击跳转路由A</span>
    <span>点击跳转路由B</span>
    <div id="routerView"></div>
</div>

js:
const routeList = [
    {
        path: '#/routerA',
        name: 'routerA',
        component: '这个是路由A',
    },
    {
        path: '#/routerB',
        name: 'routerB',
        component: '这个是路由B',
    },
]
window.addEventListener('hashchange', (hash) => {
    const curHash = hash.target.location.hash
    const curPage = routeList.find((item) => item.path === curHash)
    document.getElementById('routerView').textContent = curPage.component
})
const spanList = document.querySelectorAll('span')
for (let i = 0; i < spanList.length; i++) {
    spanList[i].addEventListener(
        'click',
        () => {
            console.log(routeList[i])
            location.replace(routeList[i].path)
        },
        false
    )
}

show.gif 你看,我们已经实现了路由的跳转,以及将对应的component渲染到了<router-view>内,routeList的结构跟vue-router是不是很类似呢。

首先,我们监听了hashchange事件,然后通过点击绑定事件,利用location.replace,将点击要跳转的路由推入地址栏,再通过监听hash的变化,更改页面显示内容(加载对应component).

history

history模式的路由处理,依赖的底层实现基于从HTML5开始,为浏览器history对象提供对history历史栈中内容操作的相关api能力的支持。

  • 属性
history.length; // 返回当前加载的页面数量
history.scrollRestoration; // 许 Web 应用程序在历史导航上显式地设置默认滚动恢复行为, auto || manual
history.state; // 返回一个表示历史堆栈顶部的状态的任意(any)值(这是一种不必等待 popstate 事件而查看状态的方式)
  • API
history.back()
// 异步调用, 转到浏览器会话历史的上一页, 与用户点击浏览器左上角返回行为相同,等价于history.go(-1);
history.forward()
// 异步调用, 转到浏览器会话历史的下一页, 与用户点击浏览器左上角下一页行为相同, 等价于history.go(1), 如果没有下一页,调用返回undefined, 不会报错
history.go()
// 异步调用, 通过当前页面的相对位置从浏览器历史记录(会话记录)异步加载页面, 不传参或者参数为0会重新载入当前页面
// IE浏览器指定一个字符串,而不是整数,可以转到历史记录列表中的特定 URL
history.pushState(stateObj, newpageTitle, newpageURL)
// 接收三个参数:一个状态对象(可以是能被序列化的任何东西, 序列化后640k的大小限制-原因在于 Firefox 将状态对象保存在用户的磁盘上,以便在用户重启浏览器时使用; 超过该限制会抛出异常), 一个标题 (目前被忽略), 和 (可选的) 一个 URL
// eg: history.pushState({page: 'newPage'}, "page new", "new.html"), originURL=www.jimous.com/cool.html
//     浏览器的地址栏会显示为www.jimous.com/new.html, 但是不会加载new.html(但可能会在稍后某些情况下加载这个 URL,比如在用户重新打开浏览器时),甚至也不会检查new.html是否存在(新 URL 必须与当前 URL 同源,否则 pushState() 会抛出一个异常)。
// pushState() 绝对不会触发 hashchange 事件

history.replaceState()
// 接收参数同pushState方法,与pushState()类似, 只不过是修改当前的历史记录项而不是新建一个, 使用场景在于为了响应用户操作,你想要更新状态对象 state 或者当前历史记录的 URL,比如执行了pushState之后,通过replaceState变更URL。
  • onpopstate

每当活动的历史记录项发生变化时,都会触发popstate事件。如果当前活动的历史记录项是被 pushState 创建的,或者是由 replaceState 改变的,那么 popstate 事件的状态属性 state 会包含一个当前历史记录状态对象的拷贝.

window.addEventListener('popstate', (event) => {
    console.log(event.state, document.location.href)
})
history.pushState({ page: 'a' }, 'title a', '?page=a')
history.pushState({ page: 'b' }, 'title b', '?page=b')
history.replaceState({ page: 'c' }, 'title c', '?page=c')
history.back() // state: {page: 'a'} location: 'file:///E:/codeEntry/github/knowledge_library/front-end/route/history.html?page=a'
history.back() // state: null  location: 'file:///E:/codeEntry/github/knowledge_library/front-end/route/history.html'
history.go(2) // 点击浏览器前进键 {page: 'c'}  location: 'file:///E:/codeEntry/github/knowledge_library/front-end/route/history.html?page=c'

hash模式下,前端路由的跳转浏览器不会向服务器发起请求,但是history模式不同,浏览器会把history模式下的url当做新的请求发送到服务器端,因为后台服务不存在对应路由,所以我们还需要改造一下nginx,利用nginx的location配置项的try_files(除了location下面配置的路由路径外,其他访问路径均走try_files提供的路径返回)字段,补上前端模板对应的url.

eg:
location /app {
    root /Users/admin/www;
    index index.html;
    try_files $uri $uri/ /app/index.html;
}

vue-router关键源码解析(version: 3.6.5)

先让我们回顾下vue-router的使用:

import Vue from 'vue';
import VueRouter from 'vue-router';
const IndexPage = () => import('index.vue');
Vue.use(VueRouter);

const routes = [
    { path: '/', redirect: '/index' },
    { path: '/index', name: 'index', component: IndexPage },
];
const router = new VueRouter({
    routes,
});

new Vue({
    render: (h) => h(App),
    router,
}).$mount('#app');

首先我们引入VueRouter对象,通过Vue.use调用它的install方法,install方法里做了三件事:

  • 通过Vue.mixin,在生命周期内beforeCreate调用router的初始化,destoryed周期注册了路由;
(代码有移除一些干扰理解的代码,有兴趣可直接阅读源码)
  Vue.mixin({
    beforeCreate () {
      ...
      this._router = this.$options.router
      this._router.init(this)
      ...
      registerInstance(this, this)
    },
    destroyed () {
      registerInstance(this)
    }
  })
  • 向Vue原型上挂载routerrouter,route变量
  Object.defineProperty(Vue.prototype, '$router', {get () {...})
  Object.defineProperty(Vue.prototype, '$route', {get () {...}
  })
  • 注册RouterView,RouterLink组件
Vue.component('RouterView', View)
Vue.component('RouterLink', Link)

new VueRouter()

new VueRouter()内部就做了两件事:

  • 调用createMatcher方法,将路由映射关系,路由动态添加的方法挂载到VueRouter类下matcher属性上。
this.matcher = createMatcher(options.routes || [], this) // vue-router\src\index.js

返回的matcher包含以下属性(如果组件包含父组件,还有parent, parent.alias等属性): 企业微信截图_16696051575376.png

  • 通过mode模式(默认为hash),添加路由监听
/**  */
switch (mode) {
    case 'history':
      this.history = new HTML5History(this, options.base)
      break
    case 'hash':
      this.history = new HashHistory(this, options.base, this.fallback)
      break
    case 'abstract':
      this.history = new AbstractHistory(this, options.base)
      break
    default:
      if (process.env.NODE_ENV !== 'production') {
        assert(false, `invalid mode: ${mode}`)
      }
}

由于onpopstate事件也能监听hash的变化,所以vueRouter内部其实对于支持onpopstate事件的浏览器也默认用onpopstate事件处理路由的监听。

const eventType = supportsPushState ? 'popstate' : 'hashchange'

export const supportsPushState =
  inBrowser &&
  (function () {
    const ua = window.navigator.userAgent

    if (
      (ua.indexOf('Android 2.') !== -1 || ua.indexOf('Android 4.0') !== -1) &&
      ua.indexOf('Mobile Safari') !== -1 &&
      ua.indexOf('Chrome') === -1 &&
      ua.indexOf('Windows Phone') === -1
    ) {
      return false
    }

    return window.history && typeof window.history.pushState === 'function'
  })()

最后总结一下VueRouter的流程:首先通过.install函数,为每个组件beforeCreate生命周期内注入router的init方法调用,将router,router,route挂载到Vue原型,注册<router-view>、<router-link>组件;当组件加载时,调用init方法,获取路由映射表,并注册window的路由监听方法,当用户在当前页面(组件)调用this.$router.push, 执行相应的路由加载,组件即页面。 很多相关的依赖函数都在vue-router源码,这里就不贴出来了,大家有兴趣可以自行去了解看看。 vue-router.github