前端路由:手写 hash 和 history 模式

384 阅读2分钟

前端路由是什么?(what)

这个问题要从页面路由的发展历程来看。

传统的后端路由:通过不同的 URL 向后端请求不同的 HTML 页面。

“新潮”的前端路由:看起来好像访问了不同的页面,但实际上没有向后端发起过多次请求,访问的是同一份 HTML,前端识别 URL 变换来动态切换页面内容。

为什么会产生前端路由?或者说有什么好处?(why)

  • 前后端解耦:日渐复杂的前端应用对于前后端解耦提出了更高的要求。使用后端路由,前端切换页面需要后端配置相应的路由,也就意味着沟通成本、逻辑耦合;而使用前端路由,页面的切换成为纯粹的交互逻辑,无需后端关心。
  • 更优的用户体验:切换页面无需刷新,用户体验更佳。

前端路由怎么实现的?(how)

Demo 简介

先来看一下 demo 动画:

Demo 实现了 hash 模式和 history 模式。每个模式下的按钮代表不同的路由路径,蓝色方框表示对应的路由页面,页面内容是显示当前激活的路由名称。

用这样一个 HTML 片段来实现这个简单的页面布局:

<body>
  <h1>实现一个前端路由</h1>  

  <h2>hash 模式</h2>
  <div id="hash-btn">
    <button>page1</button>
    <button>page2</button>
  </div>
  <div id="hash-view"></div>

  <h2>history 模式</h2>
  <div id="history-btn">
    <button>history-page1</button>
    <button>history-page2</button>
  </div>
</body>

简单地定义一个加载页面的函数:它的功能就是将当前路由对应的页面加载到视图区域。

    function loadPage(page, viewId) {
      const view = document.querySelector(`#${viewId}`)
      view.innerHTML = page
    }

Hash 模式

然后我们来看 hash 模式的实现:

  • 为每个切换路由的按钮,添加事件监听,以便在点击时,将 URL 中的 hash 修改为对应值。核心就是监听 click 事件,修改 location.hash。
  • 为 hashchange 事件定义回调函数,以便在路由发生变化时,加载对应的页面。

页面事件流程:

  1. 点击按钮
  1. 触发 click 事件
  1. 修改 location.hash 为当前按钮对应的路由
  1. 因为 hash 修改,触发了 hashchange 事件
  1. 加载对应的路由页面
    function initHash() {
      // 为每个按钮添加事件监听
      const buttons = Array.from(document.querySelector('#hash-btn').childNodes).filter(node => node.nodeName === 'BUTTON')

      buttons.forEach(button => {
        button.addEventListener('click', () => {
          // 修改哈希值
          location.hash = button.innerText
        })
      })

      loadPage(location.hash, 'hash-view')
      window.onhashchange = function() {
        // 挂载对应的组件
        const page = location.hash
        loadPage(page, 'hash-view')
      }
    }
    // 哈希模式
    initHash()

History 模式

最后再来看下 history 模式的实现。与 hash 模式是一样的思路,只是其中3、4步骤的具体实现不同。

页面事件流程:

  1. 点击按钮
  1. 触发 click 事件
  1. 调用 pushState 在路由列表末尾新增一项
  1. 触发了 popstate 事件(划重点!)
  1. 加载对应的路由页面

注意,步骤 4 中,pushState 和 replaceState 方法原本并不触发 popstate 事件,所以这里我们要想办法让它触发事件。具体方法就是在原有方法上,增加 dispatch(new Event('popstate') ,再将对象方法指向这个新方法。具体实现如下:

      // 劫持原方法并做发射事件处理
      const wrappedFn = function (eventName, ) {
        const originFn = window.history[eventName]
        const event = new Event('popstate')
        return function(...args) {
          const ret = originFn.apply(this, args)
          event.state = args[0]
          window.dispatchEvent(event)
          return ret
        }
      }

      window.history.pushState = wrappedFn('pushState')
      window.history.replaceState = wrappedFn('replaceState')

添加 popstate 的巧妙之处还在于,go、forward、back 方法本身会触发 popstate 事件。这样我们只需要监听 popstate 这一个事件就足够了。

// 历史模式
    function initHistory() {
      // 为每个按钮添加事件监听
      const buttons = Array.from(document.querySelector('#history-btn').childNodes).filter(node => node.nodeName === 'BUTTON')
      buttons.forEach(button => {
        button.addEventListener('click', () => {
          // 修改路由状态
          window.history.pushState({state: button.innerText}, `${button.innerText}页面` ,`${button.innerText}.html`)
        })
      })

      window.addEventListener('popstate', (e) => {
        loadPage(e.state?.state, 'history-view')
      })

      // 初始化状态
      window.history.replaceState({state: 'router'}, '', 'router.html')
    }

    initHistory()

*常见问题:history 模式刷新 404 问题

原因:都没能进入正确的页面,当然无法执行任何逻辑了

解决方案:借助 nginx 配置跳转到页面内;由于页面内有对 path 的监听,就可以直接加载正确的页面啦!

Hash VS history,选择哪一个更好?

模式hashhistory
优点实现方式更简单更主流的方式
缺点不美观,路径中带有 # 符号 ;会导致描点功能失效; 相同 hash 值不会触发,不会记录到历史中需要服务端协助解决刷新 404 问题

【附】完整代码链接

github.com/dandanDQ/bl…

PS:在 vscode 中,安装 Live Server 插件后,右键选择 open with Live Server 就可以看到效果啦~