前端路由原理及实现

现代前端项目多为单页Web应用(SPA),在单页Web应用中路由是其中的重要环节。

在 SPA 的应用设计中,一个应用只有一个HTML文件,在HTML文件中包含一个占位符(即图中的 container),占位符对应的内容由每个视图来决定,对于 SPA 来说,页面的切换就是视图之间的切换。

image-20200312090534121

最开始的网页是多页面的,直到 Ajax 的出现,才慢慢有了 SPA。

SPA 的出现大大提高了 WEB 应用的交互体验。在与用户的交互过程中,不再需要重新刷新页面,获取数据也是通过 Ajax 异步获取,页面显示变的更加流畅

但由于 SPA 中用户的交互是通过 JS 改变 HTML 内容来实现的,页面本身的 url 并没有变化,这导致了两个问题:

  1. SPA 无法记住用户的操作记录,无论是刷新、前进还是后退,都无法展示用户真实的期望内容。
  2. SPA 中虽然由于业务的不同会有多种页面展示形式,但只有一个 url,对 SEO 不友好,不方便搜索引擎进行收录。

前端路由就是为了解决上述问题而出现的。

什么是前端路由

简单的说,就是在保证只有一个 HTML 页面,且与用户交互时不刷新和跳转页面的同时,为 SPA 中的每个视图展示形式匹配一个特殊的 url。在刷新、前进、后退和SEO时均通过这个特殊的 url 来实现。

为实现这一目标,我们需要做到以下二点:

  1. 改变 url 且不让浏览器像服务器发送请求
  2. 可以监听到 url 的变化

hash 路由

  • hash 值的变化不会导致浏览器向服务器发送请求
  • hash 的改变会触发 hashchange 事件,浏览器的前进后退也能对其进行控制

所以在 H5 的 history 模式出现之前,基本都是使用 hash 模式来实现前端路由。

hash-router.js

class HashRouter {
  constructor() {
    this.routers = {}
    // 挂载事件
    window.addEventListener('hashchange', this.load.bind(this), false)
  }

  // 用于注册每个视图
  register(hash, cb) {
    if (typeof cb !== 'function') cb = () => { }
    this.routers[hash] = cb
  }

  // 不存在hash值时,认为是首页
  registerIndex(cb) {
    if (typeof cb !== 'function') cb = () => { }
    this.routers['index'] = cb
  }

  //用于处理视图未找到的情况
  registerNotFound(cb) {
    if (typeof cb !== 'function') cb = () => { }
    this.routers['404'] = cb
  }

  //用于处理异常情况
  registerError(cb) {
    if (typeof cb !== 'function') cb = () => { }
    this.routers['error'] = cb
  }

  load() {
    // 去掉开头 # 号,如果没有 hash 说明是首页
    let hash = location.hash.slice(1)
    let cb

    console.log('hash: ', hash)
    if (hash === '') {
      cb = this.routers['index']
    } else if (!this.routers.hasOwnProperty(hash)) {
      cb = this.routers['404'] || (() => { })
    } else {
      cb = this.routers[hash]
    }

    try {
      console.log(cb)
      cb()
    } catch (error) {
      console.dir(error.message)
      let errorCb = this.routers['error'] || function (e) { }
      errorCb(error)
    }
  }
}

index.html

<body>
  <div id="nav">
    <a href="#/page1">page1</a>
    <a href="#/page2">page2</a>
    <a href="#/page3">page3</a>
    <a href="#/404">404</a>
    <a href="#/error">Error</a>
  </div>
  <div id="container"></div>
</body>
<script src="hash-router.js"></script>
<script>
  let router = new HashRouter()
  let container = document.getElementById('container')
  // 注册首页
  router.registerIndex(() => (container.innerHTML = '我是首页'))
  // 注册其他视图
  router.register('/page1', () => (container.innerHTML = '我是page1'))
  router.register('/page2', () => (container.innerHTML = '我是page2'))
  router.register('/page3', () => (container.innerHTML = '我是page3'))
  router.register('/error', () => {
    throw new Error('我是报错信息')
  })
  // 注册 404 事件
  router.registerNotFound(() => (container.innerHTML = '404,页面找不到了'))
  // 注册hash回调函数异常
  router.registerError(
    err => (container.innerHTML = '页面异常,错误消息:<br>' + err.message)
  )
  // 先执行一次,加载出首页
  window.onload = router.load()
</script>
body>

history 路由

HTML5 引入了 history.pushState()history.replaceState() 方法,它们分别可以添加和修改历史记录条目。这些方法通常与 window.onpopstate 配合使用。

都不会触发跳转,但是区别在于:

  • history.pushState() 在保留现有历史记录的同时,将 url 加入到历史记录中。
  • history.replaceState() 会将历史记录中的当前页面历史替换为 url。

由于 pushState()replaceState() 可以改变 url 同时,不会刷新页面,所以在 HTML5 中的 histroy 具备了实现前端路由的能力。

不同于 hash,history 的改变并不会触发任何事件,所以我们无法直接监听 history 的改变而做出相应的改变。

要换个思路,我们可以罗列出所有可能触发 history 改变的情况,并且将这些方式一一进行拦截,变相地监听 history 的改变。

对于单页应用的 history 模式而言,url 的改变只能由下面四种方式引起:

  1. 点击浏览器的前进或后退按钮
  2. 点击 a 标签
  3. 在 JS 代码中触发 history.pushState 函数
  4. 在 JS 代码中触发 history.replaceState 函数
class HistoryRouter {
  constructor() {
    //用于存储不同path值对应的回调函数
    this.routers = {}
    this.listenPopState()
    this.listenLink()
  }

  // 监听返回按钮
  listenPopState() {
    window.addEventListener('popstate', (e) => {
      console.log('popstate', e)
      let state = e.state || {}
      let path = state.path || ''
      this.handleCallback(path)
    }, false)
  }

  // 阻止 a 跳转,调用我们自己的 go 函数改变 dom 内容
  listenLink() {
    window.addEventListener('click', (e) => {
      let dom = e.target
      if (dom.tagName.toUpperCase() === 'A' && dom.getAttribute('href')) {
        console.log('click a', e)
        e.preventDefault()
        this.go(dom.getAttribute('href'))
      }
    }, false)
  }

  // 用于首次load
  load() {
    let path = location.pathname
    this.handleCallback(path)
  }

  //用于注册每个视图
  register(path, cb = function () { }) {
    this.routers[path] = cb
  }

  //用于注册首页
  registerIndex(cb = function () { }) {
    this.routers['/'] = cb
  }

  //用于处视图未找到的情况
  registerNotFound(cb = function () { }) {
    this.routers['404'] = cb
  }

  //用于处理异常情况
  registerError(cb = function () { }) {
    this.routers['error'] = cb
  }

  // 跳转到 path
  go(path) {
    // 关键是 pushState 函数
    history.pushState({ path }, null, path)
    this.handleCallback(path)
  }

  handleCallback(path) {
    let cb
    if (!this.routers.hasOwnProperty(path)) {
      cb = this.routers['404'] || function () { }
    } else {
      cb = this.routers[path]
    }

    try {
      cb()
    } catch (error) {
      let errorCb = this.routers['error'] || function (e) { }
      errorCb(error)
    }
  }
}

index.html

<body>
  <body>
    <div id="nav">
      <a href="/page1">page1</a>
      <a href="/page2">page2</a>
      <a href="/page3">page3</a>
      <a href="/404">404</a>
      <a href="/error">Error</a>
      <button id="btn">page2</button>
    </div>
    <div id="container"></div>
  </body>
  <script src="history-router.js"></script>
  <script>
    let router = new HistoryRouter()
    let container = document.getElementById('container')
    // 注册首页
    router.registerIndex(() => (container.innerHTML = '我是首页'))
    // 注册其他视图
    router.register('/page1', () => (container.innerHTML = '我是page1'))
    router.register('/page2', () => (container.innerHTML = '我是page2'))
    router.register('/page3', () => (container.innerHTML = '我是page3'))
    router.register('/error', () => {
      throw new Error('我是报错信息')
    })
    document.getElementById('btn').onclick = () => router.go('/page2')
    // 注册 404 事件
    router.registerNotFound(() => (container.innerHTML = '404,页面找不到了'))
    // 注册hash回调函数异常
    router.registerError(
      err => (container.innerHTML = '页面异常,错误消息:<br>' + err.message)
    )
    // 先执行一次,加载出首页
    window.onload = router.load()
  </script>
</body>

hash、history 如何抉择

hash 模式优点:

  • 兼容性更好,可以兼容到IE8
  • 无需服务端配合处理非单页的url地址

hash 模式缺点:

  • 看起来更丑。
  • 会导致锚点功能失效。
  • 相同 hash 值不会触发动作将记录加入到历史栈中,而 pushState 则可以。

另外,使用 history 可能产生刷新404的问题。

综上所述,当我们不需要兼容老版本IE浏览器,并且可以控制服务端覆盖所有情况的候选资源时,我们可以愉快的使用 history 模式了。

总结

那么,前端路由本质上,就是去改变url,但是不让游览器跳转,而是使用我们自己的方法来改变部分DOM。

因此有了两种解决方案:hash 和 history。

对于hash,原理是

  • hash 值的变化不会导致浏览器向服务器发送请求
  • hash 的改变会触发 hashchange 事件,浏览器的前进后退也能对其进行控制

对于 history,关键是 history.pushState 函数、popstate 事件和屏蔽 <a> 的默认跳转

pushState 用于在浏览历史中添加历史记录,但是并不触发跳转

state:一个与指定网址相关的状态对象,popstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填nulltitle:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填nullurl:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

popstate 每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。

需要注意的是,仅仅调用pushState方法或replaceState方法 ,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用 JavaScript 调用backforwardgo方法时才会触发。

屏蔽 a 标签跳转,或者直接使用 button 来做,或者其他标签挂 onclick 也可以的。