Daniel's Studio.

前端路由的实现原理

字数统计: 2k阅读时长: 7 min
2020/02/23 Share

SPA

在传统的Web项目中,每个网页都对应了一个HTML文件,当我们向浏览器输入不同的URL时,服务器会返回不同的HTML文件,再由浏览器负责处理并呈现出不同的页面。但在现代的多数SPA(single page web application),即单页Web应用中,一个项目只有一个HTML页面,一旦加载完成就不会进行重新加载或跳转,取而代之的是通过使用JS动态的改变这单个HTML页面的内容,模拟多页面的跳转。

好处

  • 由于在与用户的交互中不需要重新刷新页面,并且数据的获取也是异步执行的,页面更加流畅,用户的体验更好;
  • 服务器压力小;
  • 前后端分离开发。SPA和RESTful架构一起使用,后端不再负责模板渲染、输出页面工作,web前端和各种移动终端地位对等,后端API通用化。

坏处

由于SPA是通过JS动态改变HTML内容实现的,页面本身的URL没有改变,这就导致了两个问题:

  • 初次加载耗时增加;

  • SPA无法记住用户的操作记录,刷新、前进、后退存在问题,需要自行实现导航。

  • 只有一个URL对于SEO不友好

前端路由

前端路由的产生就是为了解决SPA只有一个URL所带来的导航问题。

在使用Vue、React等前端框架时,我们都会发现项目中只有一个HTML文件,并且在该HTML中都存在一个根标签,起到了类似于容器的作用。容器内部的内容就由我们后续编写的每个视图决定,页面的切换就是容器中视图的切换。

前端路由的实现原理简单来说,就是在不跳转或者刷新页面的前提下,为SPA应用中的每个视图匹配一个特殊的URL,之后的刷新、前进、后退等操作均通过这个特殊的URL实现。为实现上述要求,需要满足:

  • 改变URL且不会向服务器发起请求;
  • 可以监听到URL的变化,并渲染与之匹配的视图。

主要有Hash路由和History路由两种实现方式。下文对两者的基本原理进行简单介绍,并分别实现了一个简易的路由Demo。

Hash路由

Hash即URL中#号及其后面的字符,由于URL中Hash值的改变并不会向服务器发起请求,并且我们也可以通过ha shchange事件对其改变进行监听,因此我们就可以通过改变页面的Hash来实现不同视图的匹配与切换。

使用的API有:

1
2
3
4
5
6
7
8
9
// 设置hash
window.location.hash = 'xxxx';
// 获取hash
let hash = window.location.hash;
// 监听hash变化
window.addEventListener('hashchange', function(event){
let newURL = event.newURL;
let oldURL = event.oldURL;
}, false);

创建路由类

原理就是通过键值对的形式保存路由及对应要执行的回调函数,当监听到页面hash发生改变时,根据最新的hash值调用注册好的回调函数,即改变页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Routers{
constructor(){
// 保存路由信息
this.routes = {};
this.currentUrl = '';
window.addEventListener('load', this.refresh, false);
window.addEventListener('hashchange', this.refresh, false);
}

// 用于注册路由的函数
route = (path, callback) => {
this.routes[path] = callback || function(){};
}

// 监听事件的回调,负责当页面hash改变时执行对应hash值的回调函数
refresh = () => {
this.currentUrl = location.hash.slice(1) || '/';
this.routes[this.currentUrl]();
}
}

window.Router = new Routers();

注册路由

使用route方法添加对应的路由及其回调函数即可。以下代码实现了一个根据不同hash改变页面颜色的路由,模拟了页面的切换,在实际的SPA应用中,对应的就是页面内容的变化了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var content = document.querySelector('body');

function changeBgColor(color){
content.style.background = color;
}

// 添加路由
Router.route('/', () => {
changeBgColor('yellow');
});
Router.route('/red', () => {
changeBgColor('red');
});
Router.route('/green', () => {
changeBgColor('green');
});
Router.route('/blue', () => {
changeBgColor('blue');
});

完整Demo如下:

History路由

在H5之前,浏览器的history仅支持页面之前的跳转,包括前进和后退等功能。

在HTML5中,新增以下API:

1
2
3
history.pushState();			// 添加新状态到历史状态栈
history.replaceState(); // 用新状态代替当前状态
history.state; // 获取当前状态对象

history.pushState()history.replaceState()均接收三个参数:

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

二者的区别是history.pushState()是添加记录,而history.replaceState()是替换当前记录。但二者都不会触发页面跳转。

popstate事件:每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。但仅仅调用history.pushState()或者history.replaceState()并不会触发改事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用 JavaScript 调用history的backforwardgo方法时才会触发。

由于history.pushState()history.replaceState()都具有在改变页面URL的同时,不刷新页面的能力,因此也可以用来实现前端路由。

创建路由类

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
class Routers{
constructor(){
this.routes = {};
window.addEventListener('popstate', e => {
const path = e.state && e.state.path;
this.routes[path] && this.routes[path]();
})
}

init(path){
history.replaceState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}

route(path, callback){
this.routes[path] = callback || function(){};
}

go(path){
history.pushState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
}

window.Router = new Routers();

注册路由

与之前类似,注册每个路由及其对应的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function changeBgColor(color){
content.style.background = color;
}

Router.route(location.pathname, () => {
changeBgColor('yellow');
});
Router.route('/red', () => {
changeBgColor('red');
});
Router.route('/green', () => {
changeBgColor('green');
});
Router.route('/blue', () => {
changeBgColor('blue');
});

const content = document.querySelector('body');
Router.init(location.pathname);

编写触发事件

在使用hash实现的路由中,我们通过hashchange事件来监听hash的变化,但是上述代码中history的改变本身不会触发任何事件,因此无法直接监听history的改变来改变页面。因此,对于不同的情况,我们选择不同的解决方案:

  • 点击浏览器的前进或者后退按钮:监听popstate事件,获取相应路径并执行回调函数
  • 点击a标签:阻止其默认行为,获取其href属性,手动调用history.pushState(),并执行相应回调。
1
2
3
4
5
6
7
8
const ul = document.querySelector('ul');

ul.addEventListener('click', e => {
if(e.target.tagName === 'A'){
e.preventDefault();
Router.go(e.target.getAttribute('href'));
}
})

完整Demo如下:

See the Pen XWbNQzd by DanielXuuuuu (@DanielXuuuuu) on CodePen.

需要注意的是,以上操作中,页面始终没有进行跳转,只是通过history.pushState()显示了“假”的URL,同时由于history状态栈会进行改变,因此前进后退也会实现。

但倘若我们手动刷新,或输入URL直接进入页面的时候, 服务端是无法识别这个 URL 的。因为我们是单页应用,只有一个 html 文件,服务端在处理其他路径的 URL 的时候,就会出现404的情况。 所以,如果要应用 history 模式,需要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回单页应用的 HTML 文件。

对比

基于hash的路由:

  • 看起来比较丑
  • 会导致锚点功能失效

但:

  • 兼容性更好
  • 无需服务器配合

参考

面试官: 你了解前端路由吗?

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

CATALOG
  1. 1. SPA
    1. 1.0.1. 好处
    2. 1.0.2. 坏处
  • 2. 前端路由
    1. 2.1. Hash路由
      1. 2.1.1. 创建路由类
      2. 2.1.2. 注册路由
    2. 2.2. History路由
      1. 2.2.1. 创建路由类
      2. 2.2.2. 注册路由
        1. 2.2.2.1. 编写触发事件
  • 3. 对比
  • 4. 参考