阅读 102

Vue:浅谈 Vue Router 前端路由实现思路

前端路由

路由

路由就是分发请求,通过网络把信息从原地址传输到目的地址

前端路由

定义

在一个HTML页面中实现与用户交互时不刷新和跳转页面的同时,为SPA中的每个视图展示匹配一个特殊的url,改变这个url且不会让浏览器像服务器发送请求,并且可以监听到url的变化。

SPA

single page web application,单页Web应用

简单的说SPA就是一个WEB项目只有一个HTML页面,一旦页面加载完成,SPA不会因为用户的操作而进行页面的重新加载和跳转。取而代之的是利用JS动态的改变HTML的内容,从而来模拟多个视图间的跳转。

传统页面——

每个HTML页面都是由一个HTML文件完成的,包含了完整的HTML结构

image-20210629002818989.png

SPA页面——

一个应用只有一个HTML文件,但是HTML文件中包含一个占位符,这个占位符的内容由视图来决定,SPA的页面切换就是视图的切换

image-20210629002829471.png

hash模式、history模式、memory模式

hash模式

示例

使用场景

任何情况都可以使用做前端路由

缺点

SEO不友好,服务器收不到hash。

比如访问http://www.baidu.com/#river,得到的Request URL 是http://www.baidu.com/

history模式

示例

使用场景

当后端将所有前端路由都渲染到同一页面(不是404页面),可以使用history模式

缺点

IE8 及以下不支持

memory模式

示例

不同

不同于hash模式和history模式,这两种是将通过url来存路径的,memory模式一般将路径放到local storage里,移动端一般放到移动数据库里。

缺点

只对单机有效

VueRouter 源码

官方文档

实现 Vue Router

使用 Vue Router(hash模式)

若使用history模式,只需添加

const router = new VueRouter({
  mode: "history",
  routes
})
复制代码

vue-router源码分析

源码

我们先来看看vue的实现路径。

img

在入口文件中需要实例化一个 VueRouter 的实例对象 ,然后将其传入 Vue 实例的 options 中。

export default class VueRouter {
  static install: () => void;
  static version: string;

  app: any;
  apps: Array<any>;
  ready: boolean;
  readyCbs: Array<Function>;
  options: RouterOptions;
  mode: string;
  history: HashHistory | HTML5History | AbstractHistory;
  matcher: Matcher;
  fallback: boolean;
  beforeHooks: Array<?NavigationGuard>;
  resolveHooks: Array<?NavigationGuard>;
  afterHooks: Array<?AfterNavigationHook>;

  constructor (options: RouterOptions = {}) {
    this.app = null
    this.apps = []
    this.options = options
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    // 创建 matcher 匹配函数
    this.matcher = createMatcher(options.routes || [], this)
    // 根据 mode 实例化具体的 History,默认为'hash'模式
    let mode = options.mode || 'hash'
    // 通过 supportsPushState 判断浏览器是否支持'history'模式
    // 如果设置的是'history'但是如果浏览器不支持的话,'history'模式会退回到'hash'模式
    // fallback 是当浏览器不支持 history.pushState 控制路由是否应该回退到 hash 模式。默认值为 true。
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    // 不在浏览器内部的话,就会变成'abstract'模式
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode
     // 根据不同模式选择实例化对应的 History 类
    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}`)
        }
    }
  }

  match (
    raw: RawLocation,
    current?: Route,
    redirectedFrom?: Location
  ): Route {
    return this.matcher.match(raw, current, redirectedFrom)
  }

  get currentRoute (): ?Route {
    return this.history && this.history.current
  }

  init (app: any /* Vue component instance */) {
    process.env.NODE_ENV !== 'production' && assert(
      install.installed,
      `not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
      `before creating root instance.`
    )

    this.apps.push(app)

    // main app already initialized.
    if (this.app) {
      return
    }

    this.app = app

    const history = this.history
    // 根据history的类别执行相应的初始化操作和监听
    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }

    history.listen(route => {
      this.apps.forEach((app) => {
        app._route = route
      })
    })
  }
  // 路由跳转之前
  beforeEach (fn: Function): Function {
    return registerHook(this.beforeHooks, fn)
  }
  // 路由导航被确认之间前
  beforeResolve (fn: Function): Function {
    return registerHook(this.resolveHooks, fn)
  }
  // 路由跳转之后
  afterEach (fn: Function): Function {
    return registerHook(this.afterHooks, fn)
  }
  // 第一次路由跳转完成时被调用的回调函数
  onReady (cb: Function, errorCb?: Function) {
    this.history.onReady(cb, errorCb)
  }
  // 路由报错
  onError (errorCb: Function) {
    this.history.onError(errorCb)
  }
  // 路由添加,这个方法会向history栈添加一个记录,点击后退会返回到上一个页面。
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.push(location, onComplete, onAbort)
  }
  // 这个方法不会向history里面添加新的记录,点击返回,会跳转到上上一个页面。上一个记录是不存在的。
  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.replace(location, onComplete, onAbort)
  }
  // 相对于当前页面向前或向后跳转多少个页面,类似 window.history.go(n)。n可为正数可为负数。正数返回上一个页面
  go (n: number) {
    this.history.go(n)
  }
  // 后退到上一个页面
  back () {
    this.go(-1)
  }
  // 前进到下一个页面
  forward () {
    this.go(1)
  }

  getMatchedComponents (to?: RawLocation | Route): Array<any> {
    const route: any = to
      ? to.matched
        ? to
        : this.resolve(to).route
      : this.currentRoute
    if (!route) {
      return []
    }
    return [].concat.apply([], route.matched.map(m => {
      return Object.keys(m.components).map(key => {
        return m.components[key]
      })
    }))
  }

  resolve (
    to: RawLocation,
    current?: Route,
    append?: boolean
  ): {
    location: Location,
    route: Route,
    href: string,
    // for backwards compat
    normalizedTo: Location,
    resolved: Route
  } {
    const location = normalizeLocation(
      to,
      current || this.history.current,
      append,
      this
    )
    const route = this.match(location, current)
    const fullPath = route.redirectedFrom || route.fullPath
    const base = this.history.base
    const href = createHref(base, fullPath, this.mode)
    return {
      location,
      route,
      href,
      // for backwards compat
      normalizedTo: location,
      resolved: route
    }
  }

  addRoutes (routes: Array<RouteConfig>) {
    this.matcher.addRoutes(routes)
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }
}
复制代码

HashHistory

• hash虽然出现在url中,但不会被包括在http请求中,它是用来指导浏览器动作的,对服务器端没影响,因此,改变hash不会重新加载页面。

• 可以为hash的改变添加监听事件:

window.addEventListener("hashchange",funcRef,false)
复制代码

• 每一次改变hash(window.location.hash),都会在浏览器访问历史中增加一个记录。

export class HashHistory extends History {
  constructor (router: Router, base: ?string, fallback: boolean) {
    super(router, base)
    // check history fallback deeplinking
    // 如果是从history模式降级来的,需要做降级检查
    if (fallback && checkFallback(this.base)) {
    // 如果降级且做了降级处理,则返回
      return
    }
    ensureSlash()
  }
  .......
function checkFallback (base) {
  const location = getLocation(base)
  // 得到除去base的真正的 location 值
  if (!/^\/#/.test(location)) {
  // 如果此时地址不是以 /# 开头的
  // 需要做一次降级处理,降为 hash 模式下应有的 /# 开头
    window.location.replace(
      cleanPath(base + '/#' + location)
    )
    return true
  }
}

function ensureSlash (): boolean {
// 得到 hash 值
  const path = getHash()
  if (path.charAt(0) === '/') {
   // 如果是以 / 开头的,直接返回即可
    return true
  }
  // 不是的话,需要手动保证一次 替换 hash 值
  replaceHash('/' + path)
  return false
}

export function getHash (): string {
  // We can't use window.location.hash here because it's not
  // consistent across browsers - Firefox will pre-decode it!
  // 因为兼容性的问题,这里没有直接使用 window.location.hash
  // 因为 Firefox decode hash 值
  const href = window.location.href
  const index = href.indexOf('#')
  return index === -1 ? '' : decodeURI(href.slice(index + 1))
}
// 得到hash之前的url地址
function getUrl (path) {
  const href = window.location.href
  const i = href.indexOf('#')
  const base = i >= 0 ? href.slice(0, i) : href
  return `${base}#${path}`
}
// 添加一个hash
function pushHash (path) {
  if (supportsPushState) {
    pushState(getUrl(path))
  } else {
    window.location.hash = path
  }
}
// 替代hash
function replaceHash (path) {
  if (supportsPushState) {
    replaceState(getUrl(path))
  } else {
    window.location.replace(getUrl(path))
  }
}
复制代码

hash的改变会自动添加到浏览器的访问历史记录中。 那么视图的更新是怎么实现的呢,看下 transitionTo()方法:

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const route = this.router.match(location, this.current) //找到匹配路由
    this.confirmTransition(route, () => { //确认是否转化
      this.updateRoute(route) //更新route
      onComplete && onComplete(route)
      this.ensureURL()

      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => {
      if (onAbort) {
        onAbort(err)
      }
      if (err && !this.ready) {
        this.ready = true
        this.readyErrorCbs.forEach(cb => { cb(err) })
      }
    })
  }
  
//更新路由
updateRoute (route: Route) {
    const prev = this.current // 跳转前路由
    this.current = route // 装备跳转路由
    this.cb && this.cb(route) // 回调函数,这一步很重要,这个回调函数在index文件中注册,会更新被劫持的数据 _router
    this.router.afterHooks.forEach(hook => {
      hook && hook(route, prev)
    })
  }
}

复制代码
pushState
export function pushState (url?: string, replace?: boolean) {
  saveScrollPosition()
  // try...catch the pushState call to get around Safari
  // DOM Exception 18 where it limits to 100 pushState calls
  // 加了 try...catch 是因为 Safari 有调用 pushState 100 次限制
  // 一旦达到就会抛出 DOM Exception 18 错误
  const history = window.history
  try {
    if (replace) {
    // replace 的话 key 还是当前的 key 没必要生成新的
      history.replaceState({ key: _key }, '', url)
    } else {
    // 重新生成 key
      _key = genKey()
       // 带入新的 key 值
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
  // 达到限制了 则重新指定新的地址
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

复制代码
replaceState
// 直接调用 pushState 传入 replace 为 true
export function replaceState (url?: string) {
  pushState(url, true)
}

复制代码

pushState和replaceState两种方法的共同特点:当调用他们修改浏览器历史栈后,虽然当前url改变了,但浏览器不会立即发送请求该url,这就为单页应用前端路由,更新视图但不重新请求页面提供了基础。

supportsPushState
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 && 'pushState' in window.history
})()

复制代码

其实所谓响应式属性,即当_route值改变时,会自动调用Vue实例的render()方法,更新视图。 $router.push()-->HashHistory.push()-->History.transitionTo()-->History.updateRoute()-->{app._route=route}-->vm.render()

监听地址栏

在浏览器中,用户可以直接在浏览器地址栏中输入改变路由,因此还需要监听浏览器地址栏中路由的变化 ,并具有与通过代码调用相同的响应行为,在HashHistory中这一功能通过setupListeners监听hashchange实现:

setupListeners () {
    window.addEventListener('hashchange', () => {
        if (!ensureSlash()) {
            return
        }
        this.transitionTo(getHash(), route => {
            replaceHash(route.fullPath)
        })
    })
}

复制代码

HTML5History

History interface是浏览器历史记录栈提供的接口,通过back(),forward(),go()等方法,我们可以读取浏览器历史记录栈的信息,进行各种跳转操作。

export class HTML5History extends History {
  constructor (router: Router, base: ?string) {
    super(router, base)

    const expectScroll = router.options.scrollBehavior //指回滚方式
    const supportsScroll = supportsPushState && expectScroll

    if (supportsScroll) {
      setupScroll()
    }

    const initLocation = getLocation(this.base)
    //监控popstate事件
    window.addEventListener('popstate', e => {
      const current = this.current

      // Avoiding first `popstate` event dispatched in some browsers but first
      // history route not updated since async guard at the same time.
      // 避免在某些浏览器中首次发出“popstate”事件
      // 由于同一时间异步监听,history路由没有同时更新。
      const location = getLocation(this.base)
      if (this.current === START && location === initLocation) {
        return
      }

      this.transitionTo(location, route => {
        if (supportsScroll) {
          handleScroll(router, route, current, true)
        }
      })
    })
  }

复制代码

hash模式仅改变hash部分的内容,而hash部分是不会包含在http请求中的(hash带#):

oursite.com/#/user/id //如请求,只会发送oursite.com/

所以hash模式下遇到根据url请求页面不会有问题

而history模式则将url修改的就和正常请求后端的url一样(history不带#)

oursite.com/user/id

如果这种向后端发送请求的话,后端没有配置对应/user/id的get路由处理,会返回404错误。

官方推荐的解决办法是在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。同时这么做以后,服务器就不再返回 404 错误页面,因为对于所有路径都会返回 index.html 文件。为了避免这种情况,在 Vue 应用里面覆盖所有的路由情况,然后在给出一个 404 页面。或者,如果是用 Node.js 作后台,可以使用服务端的路由来匹配 URL,当没有匹配到路由的时候返回 404,从而实现 fallback。

两种模式比较

一般的需求场景中,hash模式与history模式是差不多的,根据MDN的介绍,调用history.pushState()相比于直接修改hash主要有以下优势:

• pushState设置的新url可以是与当前url同源的任意url,而hash只可修改#后面的部分,故只可设置与当前同文档的url

• pushState设置的新url可以与当前url一模一样,这样也会把记录添加到栈中,而hash设置的新值必须与原来不一样才会触发记录添加到栈中

• pushState通过stateObject可以添加任意类型的数据记录中,而hash只可添加短字符串 pushState可额外设置title属性供后续使用

AbstractHistory

'abstract'模式,不涉及和浏览器地址的相关记录,流程跟'HashHistory'是一样的,其原理是通过数组模拟浏览器历史记录栈的功能

//abstract.js实现,这里通过栈的数据结构来模拟路由路径
export class AbstractHistory extends History {
  index: number;
  stack: Array<Route>;

  constructor (router: Router, base: ?string) {
    super(router, base)
    this.stack = []
    this.index = -1
  }
  
  // 对于 go 的模拟
  go (n: number) {
    // 新的历史记录位置
    const targetIndex = this.index + n
    // 小于或大于超出则返回
    if (targetIndex < 0 || targetIndex >= this.stack.length) {
      return
    }
    // 取得新的 route 对象
    // 因为是和浏览器无关的 这里得到的一定是已经访问过的
    const route = this.stack[targetIndex]
    // 所以这里直接调用 confirmTransition 了
    // 而不是调用 transitionTo 还要走一遍 match 逻辑
    this.confirmTransition(route, () => {
      this.index = targetIndex
      this.updateRoute(route)
    })
  }

 //确认是否转化路由
  confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    const abort = err => {
      if (isError(err)) {
        if (this.errorCbs.length) {
          this.errorCbs.forEach(cb => { cb(err) })
        } else {
          warn(false, 'uncaught error during route navigation:')
          console.error(err)
        }
      }
      onAbort && onAbort(err)
    }
    //判断如果前后是同一个路由,不进行操作
    if (
      isSameRoute(route, current) &&
      route.matched.length === current.matched.length
    ) {
      this.ensureURL()
      return abort()
    }
    //下面是各类钩子函数的处理
    //*********************
    })
  }
复制代码

参考

「前端进阶」彻底弄懂前端路由

Vue番外篇 -- vue-router浅析原理

文章分类
前端
文章标签