一文让你弄懂前端路由到底是怎么实现的~

974 阅读8分钟

本文将详细讲解前端路由的两种模式 - hash 路由和 history 路由的原理,以及 hash 路由实现demo。

在阅读文章之前,不妨先试试回答一下下面的问题,看看自己对于前端路由的知识是否真的掌握熟练了。

hash 路由

  1. 为路由添加 hash 值,在 url 上会有什么变化?
  2. hash 路由如何改变路由内容的?为什么不同的路由可以对应不同的内容?
  3. 如何通过 js 监听 hash 路由的改变?
  4. 如何改变浏览器的 hash ?

history 路由

  1. history 路由如何监听 url 的变化?
  2. 哪些情况会触发 popstate 事件?

如果有不太清楚答案的问题,文章中自会找到答案。

在讲解路由原理之前,有必要先了解下多页应用以及单页应用的基本概念。

背景

服务器渲染的多页应用

最开始的时候,前端项目都是以多页应用进行开发,对于多页应用来说,每次的路由跳转,客户端都会重新向服务端发送请求,公共资源(如html, js, css文件)将会重新加载。

单页应用

由于每次路由的跳转都需要向服务器请求资源,聪明的前端工程师们想到,是不是可以将所有网页内容放入同一个 html 中,每次路由跳转不再向服务器发起请求获取资源,只是切换用户看到的内容。这样可以大大的提升用户在使用中因为网络不稳定造成的页面跳转不顺畅的用户体验,因此前端路由应运而生。

多页应用 vs 单页应用

引用自 juejin.im/post/5a0ea4…

单页应用多页应用
组成一个外壳页面和多个页面片段组成多个完整页面构成
资源共用(css,js)共用,只需在外壳部分加载不共用,每个页面都需要加载
刷新方式页面局部刷新或更改整页刷新
url 模式a.com/#/pageonea.com/pageone.html
用户体验页面片段间的切换快,用户体验良好页面切换加载缓慢,流畅度不够,用户体验比较差
转场动画容易实现无法实现
数据传递容易依赖 url传参、或者cookie 、localStorage等
搜索引擎优化(SEO)需要单独方案、实现较为困难、不利于SEO检索 可利用服务器端渲染(SSR)优化实现方法简易
试用范围高要求的体验度、追求界面流畅的应用适用于追求高度支持搜索引擎的应用
开发成本较高,常需借助专业的框架较低 ,但页面重复代码多
维护成本相对容易相对复杂

前端路由的特性

切入正题,我们经常说的前端路由,具备哪些特性呢?

  1. 根据不同的 url,可以渲染不同的内容
  2. 路由的改变不会引起刷新页面

通俗易懂,那么接下来就看看究竟如何实现的这两种路由吧

Hash 路由原理以及实现

Hash 值的特性

  • hash 值是存于 url 中的一种状态,向服务器发送请求时,hash 值不会被携带。
  • hash 值的改变,不会导致页面刷新。
  • hash 值的更改,会在浏览器的访问历史中增加记录,通过浏览器的前进/回退,可以控制 hash 值的切换。
  • hash 值的改变,通过 hashchange 事件可以监听到。

原理及实现

接下来,我们通过使用 hash 路由实现一个具体的路由跳转,来进一步体会 hash 路由究竟是怎么实现的。

  1. 首先写一个最简单的html文件。
<html>
  <body>
    <div>
      <a href="#page1">page1</a>
      <a href="#page2">page2</a>
      <a href="#page3">page3</a>
      <p></p>
    </div>
  </body>
  <script src="hash-mock.js"></script>
</html>

其中我们定义了三个 a 标签,通过 a 标签的 href 属性,可以快速设置 hash 值。 改变 hash 值还可以使用 window.location.hash = xxx 来直接设置

  1. 接下来在 hash-mock.js 中定义路由的使用
class HashRouter {
  // 我们需要实现的 hash 路由
}

// 模拟页面切换
function goToPage(page) {
  const pageContainer = document.querySelector('p');
  pageContainer.innerHTML = page;
}

const Router = new HashRouter();

Router.setRoute('/page1', function() {
  goToPage('page1')
});

Router.setRoute('/page2', function() {
  goToPage('page2')
});

Router.setRoute('/page3', function() {
  goToPage('page3')
});

使用过前端路由的同学们都知道,路由需要提前定义并注册,在这里我们通过声明一个 HashRouter 的实例,并调用 setRoute 方法,来注册每个路由对应的应该执行的函数。

在真实的实现中,每个路由对应的应该执行的函数,会渲染不同的组件到页面上,这样就实现了页面切换的效果。

因此,实现 HashRouter 这个类,就是实现 hash router 的最核心代码。

HashRouter 作为一个类,必须要有构造函数,在构造函数内,我们初始化一个 map,用来存储用户对于路由的注册关系,在例子中,就是不同的路由对应的不同的函数。

  1. 声明一个 map 用来存储路由与对应函数的关系。
class HashRouter {
  constructor() {
    this.map = {};
  }
}

在使用上面来看,用户是通过 setRoute 方法来注册的,因此我们的类需要实现 setRoute 方法,这个方法会把用户注册的信息以 map 的形式储存下来。

  1. 实现 setRoute 方法

该方法接收两个参数,第一个参数是路径,第二个参数对应的回调函数。

setRoute(path, callback) {
  this.map[path] = callback || function() {};
}

现在路由及对应的函数注册完毕,并存储到 map 中了。

那么在构造函数里,除了声明 map,还应该去做什么呢?

当然是需要定义 hashchange 的监听函数了,我们需要监听到 hash 值的变化,然后去获取当前的 hash 值,再根据 map 里的内容,去执行对应的函数。(在真实路由实现中,就是渲染对应的组件)

为了能正常调用类中声明的 refresh 函数,我们需要对 this 指向做一个绑定。

  1. window 下的 hashchange 事件进行监听。
class HashRouter {
  constructor() {
    const map = {};
    this.refresh = this.refresh.bind(this);
    window.addEventListener('hashchange', this.refresh);
  }

  refresh() {
    // 这里的函数用来获取 hash,并执行对应 hash 的函数
  }
}

接下来,就需要去实现我们的 refresh 方法了。

我们可以通过 location.hash 来获取到当前浏览器上的 hash 值。

⚠️ location.hash 获取到的值会带有 # 字符,在实现时需要通过 split 来获取准确的 hash

拿到 hash 值后,就可以从 map 中获取函数并执行了。

  1. 实现 refresh 函数
refresh() {
  const hash = '/' + (location.hash.split('#')[1] || '' );
  const cb = this.map[hash];
  cb && cb();
}

至此,经过以上六步,就完成了一个简易的 hash 路由模拟实现。

完整代码会放在文章末尾。

History 路由

与 History 有关的方法

  1. window.history.back() // 浏览器历史记录回退一步
  2. window.history.forward() // 浏览器历史记录前进一步
  3. go(number) // 浏览器历史记录前进 number 步,若 number 为负数,则回退 -number 步
  4. pushState() // 向浏览器历史记录中推入一条记录
  5. replaceState() // 向浏览器历史记录中推入一条记录,并覆盖当前记录

History 路由特性

  • 可以通过 popstate 方法来监听 url 的变化
  • pushStatereplaceState 并不会触发 popstate 事件
  • 浏览器的回退/前进、window.history.back()、window.history.forward、go(number)会触发 popstate 事件

pushState 和 replaceState 的 API 调用

这两个 API 都接收三个参数

  • 状态对象(state object):一个 JavaScript 对象,与用 pushState 方法创建的新历史记录条目关联。无论何时用户导航到新创建的状态,popstate 事件都会被触发,并且事件对象的 state 属性都包含历史记录条目的状态对象的拷贝。
  • 标题(title):FireFox 浏览器目前会忽略该参数,虽然以后可能会用上。考虑到未来可能会对该方法进行修改,传一个空字符串会比较安全。或者,你也可以传入一个简短的标题,标明将要进入的状态。
  • 地址(URL): 新的历史记录条目的地址。浏览器不会在调用 pushState 方法后加载该地址,但之后,可能会试图加载,例如用户重启浏览器。新的URL不一定是绝对路径;如果是相对路径,它将以当前 URL 为基准;传入的 URL 与当前 URL 应该是同源的,否则,pushState 会抛出异常。该参数是可选的;不指定的话则为文档当前 URL。

History 路由的实现与 hash 路由大同小异,无非就是 History 路由通过 popstate 方法来监听 url, 而 hash 路由通过 hashchange 来监听 url 上的 hash 值改变;History 路由通过 pushStatereplaceState 方法向浏览器历史记录中推入 url 记录,而 hash 路由中,hash 值的改变会自动被浏览器记录并推入历史记录中。

Hash 路由小 demo 完整代码

<html>
  <body>
    <div>
      <a href="#page1">page1</a>
      <a href="#page2">page2</a>
      <a href="#page3">page3</a>
      <p></p>
    </div>
  </body>

  <script src="hash-mock.js"></script>
</html>
class HashRouter {
  // 我们需要实现的 hash 路由
  constructor() {
    this.map = {};
    this.refresh = this.refresh.bind(this);
    window.addEventListener('hashchange', this.refresh);
  }

  setRoute(path, callback) {
    this.map[path] = callback || function() {};
    console.log(this.map);
  }

  refresh() {
    const hash = '/' + (location.hash.split('#')[1] || '' );
    const cb = this.map[hash];
    cb && cb();
  }
}

// 模拟页面切换
function goToPage(page) {
  console.log(page);
  const pageContainer = document.querySelector('p');
  pageContainer.innerHTML = page;
}

const Router = new HashRouter();

Router.setRoute('/page1', function() {
  goToPage('page1')
});

Router.setRoute('/page2', function() {
  goToPage('page2')
});

Router.setRoute('/page3', function() {
  goToPage('page3')
});