单页面路由实现原理

前言

如今 React、Vue 等这些框架大行其道,为前端开发提供了不少的便利性。用这些框架开发的应用叫单页面应用(即 SPA),单页面应用就是只有一张Web页面的应用,是一种从 Web 服务器加载的富客户端,单页面跳转仅刷新局部资源 ,公共资源(JS、CSS 等)仅需加载一次,不同的页面只是挂载的在根节点上的内容不一样,那是怎么实现不同的 url 地址对应不同的页面呢,这就前端路由的作用了。

接下来本文将围绕以下三个问题来进行阐述:

  1. 单页面应用为什么需要路由系统?
  2. 单页面应用路由的原理是什么?
  3. vue-router 是怎么实现的?

单页面为什么需要路由系统

最开始的时候,开发网页还是前后端不分离的,意思就是 html 页面是写在后端语言中的,然后用模板引擎进行渲染。后面随着 ajax 的流行,前端获取到数据能不用刷新页面,后面慢慢有了 React、Vue 等 SPA 框架。这些框架不仅能够在不刷新页面的情况下进行交互,还能在页面之间的跳转也不刷新页面,这里面路由系统就做出了很大的贡献,如果 SPA 框架没有路由系统,就会有很多问题:

  1. 用户在使用的过程中,url 不会变化,那么用户在进行多次跳转之后,如果不小心刷新了页面,就会回到最开始的页面,这样用户的体验是很不好的。
  2. 百度、谷歌的爬虫是根据路径来爬网页的内容的,这样就不利于 SEO 和搜索引擎的收录。

现在的前端路由系统主要分为 hash 模式和 history 模式。

Hash 模式

hash 路由的一个明显的标志就是 url 上带有 ## 其实是 url 的锚点,代表的是网页中的一个位置,单单改变 # 后的部分,浏览器只会滚动到相应位置,不会重新加载网页,同时每一次改变 # 后的部分,都会在浏览器的访问历史中增加一个记录,使用浏览器自带的前进后退按钮,就可以回到上一个位置。而且,url 的 hash 值的改变还会触发 hashchange 事件。

总的来说,因为 url 中 hash 值的改变不会引起页面的刷新,而且还能触发 hashchange 这个事件,这样就可以很好的用来做单页面应用的路由。

所以,我们可以根据这个思路来实现一个简单的 hash 路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// router.js
class Router {
constructor() {
this.routes = {}; // 用来存放不同路由对应的回掉函数
this.currentUrl = ''; // 当前路由的 url
}

// 将路径和对应的回调函数存储起来
route(path, callback) {
this.routes[path] = callback || function() {};
}

// 更新视图
updateView() {
// 获取当前 url 中的 hash 路径
this.currentUrl = location.hash.slice(1) || '/';
// 执行当前 hash 路径的回调函数
this.routes[this.currentUrl] && this.routes[this.currentUrl]();
}

// 初始化,监听 load 和 hashchange 事件
init() {
window.addEventListener('load', this.updateView.bind(this), false);
window.addEventListener('hashchange', this.updateView.bind(this), false);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- index.html -->
<div id="app">
<ul>
<li>
<a href="#/">home</a>
</li>
<li>
<a href="#/about">about</a>
</li>
<li>
<a href="#/list">list</a>
</li>
</ul>

<div id="content"></div>
</div>

<script src="./js/router.js"></script>
<script>
const router = new Router();

router.init();
router.route('/', function() {
document.getElementById('content').innerHTML = 'this is home page';
});
router.route('/about', function() {
document.getElementById('content').innerHTML = 'this is about page';
});
router.route('/list', function() {
document.getElementById('content').innerHTML = 'this is list page';
});
</script>

这样在对应的 html 中,只要在所有的链接路径前加 # 即可做成软路由,这样就不会触发刷新页面。

需要注意的是,在第一次进入页面的时候,需要触发一次 hashchange 事件,保证页面能够正常显示。用 hash 做路由跳转的好处就是实现起来比较简单,而且便于理解,但是,它虽然解决了单页面应用路由控制的问题,但是在 url 中却引入了 # ,这样就导致 url 不美观。接下来的 history 模式就解决了这个问题。

History 模式

上面说到的 hash 模式虽然能很好的解决问题,但是导致 url 不够美观,到 2014 年 HTML5 的新规范的发布,才很好的解决了这个问题。HTML5 中新增加了两个 API,pushStatereplaceState ,通过这两个 API 可以改变 url 且不会刷新页面。

比如,当你执行 history.pushState({}, null, '/about') 的时候,页面 url 会从 http://xxx 跳转到 http://xxx/about
先简单看看 pushState 的用法:

  1. state: 通常是一个对象,可以用在 popstate 事件中
  2. title: 通常会忽略这个参数,可直接用 null 代替
  3. url: 任意有效的 url ,用于更新浏览器的地址

想了解 pushState 的更多信息可以戳这里

这样看来,history 也有着控制路由的能力,hash 的改变可以触发 hashchange 事件,而 history 的改变不会触发任何事件,这样就无法直接监听 history 的改变从而做出相应的改变。所以,我们需要换个思路,我们可以列出所有可能触发 history 改变的情况,然后将这些方式一一进行拦截,这就变相的实现了对 history 改变的监听。

对一个应用而言,url 的改变(不包括 hash 值的改变)只能由下面三种情况引起:

  1. 点击 a 标签
  2. 点击浏览器的前进后退
  3. 在 JS 代码中触发 history.pushStatehistory.replaceState 方法

针对情况2,HTML5 规范中有相应的 popstate 事件,通过它可以监听到前进或者后退按钮的点击,需要注意的是,调用 history.pushStatehistory.replaceState 并不会触发 popstate 事件。

所以,我们可以根据这个思路来实现一个简单的 history 路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// router.js
class Router {
constructor() {
this.routes = {}; // 用来存放不同路由对应的回掉函数
this.currentUrl = ''; // 当前路由的 url
}

// 将路径和对应的回调函数存储起来
route(path, callback) {
this.routes[path] = callback || function() {};
}

// 更新视图
updateView() {
// 获取当前 url 中的 hash 路径
this.currentUrl = location.hash.slice(1) || '/';
// 执行当前 hash 路径的回调函数
this.routes[this.currentUrl] && this.routes[this.currentUrl]();
}

// 绑定 DOM 中所有的 a 链接
bindLink() {
let allLink = document.querySelectorAll('a[data-href]');

for (let i = 0, item, len = allLink.length; i < len && (item = allLink[i]); i++) {
item.addEventListener(
'click',
e => {
e.preventDefault();
let url = item.getAttribute('data-href');
history.pushState({}, null, url);
this.updateView(url);
},
false
);
}
}

// 初始化路由,先绑定所有的 a 链接,然后再监听 load 和 popstate 事件
init() {
this.bindLink();
window.addEventListener('load', () => this.updateView('/'), false);
window.addEventListener('popstate', () => {
this.updateView(window.location.pathname);
});
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!-- index.html -->
<div id="app">
<ul>
<li>
<a data-href="/" href="#">home</a>
</li>
<li>
<a data-href="/about" href="#">about</a>
</li>
<li>
<a data-href="/list" href="#">list</a>
</li>
</ul>
<div id="content"></div>
</div>

<script src="./js/router.js"></script>
<script>
const router = new Router();

router.init();
router.route('/', function() {
document.getElementById('content').innerHTML = 'this is home page';
});
router.route('/about', function() {
document.getElementById('content').innerHTML = 'this is about page';
});
router.route('/list', function() {
document.getElementById('content').innerHTML = 'this is list page';
});
</script>