张啸


世界上最快乐的事,莫过于为理想而奋斗。


Web(7) 前端路由实现方法

什么是路由?简单的来说,路由是 URL –> 函数 的映射关系。

大部分复杂的网站,都会把业务解耦为模块进行处理。这些网站中又有很多的网站会把适合的部分应用Ajax进行数据交互,展现给用户,很明显处理这样的数据通信交互,不可避免的会涉及到跟URL打交道,让数据交互的变化反映到URL的变化上,进而可以给用户机会去通过保存的URL链接,还原刚才的页面内容板块的布局,这其中包括Ajax局部刷新的变化。

一、routerroute的区别

route是一条路由,它将一个 URL 路径和一个 函数 进行映射,例如:

1
2
/users          -->     getAllUsers()  
/users/count --> getUsersCount()

router可以理解为一个容器,或者一种机制,它管理了一组route

routerroute的一组Map映射表,接收到一个URL后,由router来从映射表中查找相应的函数。


二、服务端路由

对于服务器来说,当接收到客户端发来的HTTP请求,会根据请求的URL,来找到相应的映射函数,然后执行该函数,并将函数的返回值发送给客户端。

  • 静态资源,可以认为,URL的映射函数就是一个文件的读取操作。

  • 动态资源,映射函数可能是一个数据库的读取操作,也可能是一些数据处理等等。

1
2
3
4
5
6
7
8
app.get('/', function(req, res) {
res.sendFile('index');
})

app.get('/users', function(req, res) {
var data = db.queryAllUsers();
res.send(data);
})

router匹配route的过程中,不仅会根据URL来匹配,还会根据请求的方法来匹配,如POSTGETPUTDELETE等等。


三、客户端路由

对于客户端来说,路由的映射函数通常是进行一些DOM元素的显示和隐藏操作。这样,当访问不同的路径的时候,会显示不同的页面组件。

客户端路由最常见的是下面两种实现方案:

1. 基于Hash(锚点)

URL中 # 或者 #! 及其后面的部分为hash,例如

1
2
3
4
const url = require('url')
var a = url.parse('http://example.com/a/b/#/foo/bar')
console.log(a.has)
// => #/foo/bar

H5中对has有一个hashchange事件,当页面的hash变化时,即会出发hashchange

锚点Hash起到引导浏览器将这次记录推入历史记录栈顶的作用,window.location对象处理 # 的改变并不会重新加载页面,而是将之当成新页面,放入历史栈里。

前进(->)或者后退(<-)或者触发hashchange事件时,我们可以在对应的事件处理函数里注册ajax等操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
window.onhashchange = function() {
var hash = window.location.hash;
var path = hash.substring(1);

switch (path) {
case '/':
showHome();
break;
case '/users':
showUsersList();
break;
default:
show404NotFound();
}
}

但是hashchange这个事件并不是每个浏览器都有,地基浏览器需要用轮询检测URL是否在变化,来检测锚点的变化。

当锚点内容 location.hash 被操作时,如果锚点内容发生改变浏览器才会将其放入历史栈中;否则历史栈并不会增加,并且也不会触发hashchange事件。

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
(function(window) {
// 如果浏览器不支持原生实现的事件,则开始模拟,否则退出。
if ( "onhashchange" in window.document.body ) { return; }

var location = window.location,
oldURL = location.href,
oldHash = location.hash;

// 每隔100ms检查hash是否发生变化
setInterval(function() {
var newURL = location.href,
newHash = location.hash;

// hash发生变化且全局注册有onhashchange方法(这个名字是为了和模拟的事件名保持统一);
if ( newHash != oldHash && typeof window.onhashchange === "function" ) {
// 执行方法
window.onhashchange({
type: "hashchange",
oldURL: oldURL,
newURL: newURL
});

oldURL = newURL;
oldHash = newHash;
}
}, 100);
})(window);

2. 基于History

我们首先熟悉几个H5的 History Api,下面是Mozilla在H5中实现的History Api的官方文档描述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*Returns the number of entries in the joint session history.*/
window.history.length

/*Returns the current state object.*/
window.history.state

/*Goes back or forward the specified number of steps in the joint session history.A zero delta will reload the current page.If the delta is out of range, does nothing.*/
window.history.go([delta])

/*Goes back one step in the joint session history.If there is no previous page, does nothing.*/
window.history.back()

/*Goes forward one step in the joint session history.If there is no next page, does nothing.*/
window.history.forward()

/*Pushes the given data onto the session history, with the given title, and, if provided and not null, the given URL.*/
window.history.pushState(data, title[url])

/*Updates the current entry in the session history to have the given data, title, and,if provided and not null, URL.*/
window.history.replaceState(data, title[url])

其中最后的两个方法history.pushStatehistory.replaceState,为前端操控浏览器历史栈提供了可能性。

1
2
3
4
5
6
7
8
9
10
/**
*parameters
*@data {object} state对象,这是一个javascript对象,一般是JSON格式的对象
*字面量。
*@title {string} 可以理解为document.title,在这里是作为新页面传入参数的。
*@url {string} 增加或改变的记录,对应的url,可以是相对路径或者绝对路径,
*url的具体格式可以自定。
*/
history.pushState(data, title, url) //向浏览器历史栈中增加一条记录。
history.replaceState(data, title, url) //替换历史栈中的当前记录。

这两个Api都会操作浏览器的历史栈,而不会引起页面的刷新。不同的是,pushState会增加一条新的历史记录,而replaceState则会替换当前的历史记录。

在将新的历史记录存入栈后,会把传入的data(即state对象)同时存入,方便以后调用,存在用户的本地硬盘上,最大支持到640k。

1
var currentState = history.state; //如果没有则为null

同时,这俩api都会更新或者覆盖当前浏览器的title和url为对应传入的参数。

URL参数可以为绝对路径,也可以为相对路径,但是不能跨域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//假设当前网页URL为:http://tonylee.pw
window.history.pushState(null, null, "http://tonylee.pw?name=tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw?name=tonylee

window.history.pushState(null, null, "http://tonylee.pw/name/tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name/tonylee

window.history.pushState(null, null, "?name=tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw?name=tonylee

window.history.pushState(null, null, "name=tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name=tonylee

window.history.pushState(null, null, "/name/tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name/tonylee

window.history.pushState(null, null, "name/tonylee");
//url变化:http://tonylee.pw -> http://tonylee.pw/name/tonylee

//错误的用法:
window.history.pushState(null, null, "http://www.tonylee.pw?name=tonylee");
//error: 由于跨域将产生错误

可以看到,URL作为一个改变当前浏览器地址的参数,用法是非常灵活的。传入的URL默认以“/”相隔,也可以自己指定为“?”等。

根据URL的变化 –> 页面板块变化 –> 页面发出XHR请求 –> 页面没有reload

这里我们可以看一下mozilla提供的一个pushStatereplaceState的小demo,已经接近一个前端路由的雏形。

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
<!DOCTYPE HTML>
<!-- this starts off as http://example.com/line?x=5 -->
<title>Line Game - 5</title>
<p>You are at coordinate <span id="coord">5</span> on the line.</p>
<p>
<a href="?x=6" onclick="go(1); return false;">Advance to 6</a> or
<a href="?x=4" onclick="go(-1); return false;">retreat to 4</a>?
</p>
<script>
var currentPage = 5; // prefilled by server!!!!
function go(d) {
setupPage(currentPage + d);
history.pushState(currentPage, document.title, '?x=' + currentPage);
}
onpopstate = function(event) {
setupPage(event.state);
}
function setupPage(page) {
currentPage = page;
document.title = 'Line Game - ' + currentPage;
document.getElementById('coord').textContent = currentPage;
document.links[0].href = '?x=' + (currentPage+1);
document.links[0].textContent = 'Advance to ' + (currentPage+1);
document.links[1].href = '?x=' + (currentPage-1);
document.links[1].textContent = 'retreat to ' + (currentPage-1);
}
</script>

从例子中我们看到一个popstate的事件,这里也看看mozalla官方文档

1
2
3
4
5
6
7
8
9
10
11
An event handler for the popstate event on the window.

A popstate event is dispatched to the window every time the active history entry changes between two history entries for the same document. If the history entry being activated was created by a call to history.pushState() or was affected by a call to history.replaceState(), the popstateevent's state property contains a copy of the history entry's state object.

Note that just calling history.pushState() or history.replaceState() won't trigger apopstate event. The popstate event is only triggered by doing a browser action such as clicking on the back button (or calling history.back() in JavaScript). And the event is only triggered when the user navigates between two history entries for the same document.

Browsers tend to handle the popstate event differently on page load. Chrome (prior to v34) and Safari always emit a popstate event on page load, but Firefox doesn't.

Syntax
window.onpopstate = funcRef;
//funcRef is a handler function.

简单来说,当同一个页面在历史记录间切换时,就会派发popstate事件。

正常情况下,用户点击后退按钮或者调用history.back() or history.go(),页面根本没有处理事件的机会,因为这些操作会使得页面reload,所以popstate只在不会让浏览器页面刷新的历史记录之间切换才能触发,这些历史记录一般由pushState/replaceState或者是由hash锚点等操作产生,并且在事件的句柄中可以访问state对象的引用副本!

单纯的调用pushState/replaceState并不会触发popstate事件。

页面初次加载时,是否会主动触发popstate事件,不同的浏览器实现不一样。下面是一个官方demo。

1
2
3
4
5
6
7
8
9
10
window.onpopstate = function(event) {
alert("location: " + document.location + ", state: " + JSON.stringify(event.state));
};

history.pushState({page: 1}, "title 1", "?page=1");
history.pushState({page: 2}, "title 2", "?page=2");
history.replaceState({page: 3}, "title 3", "?page=3");
history.back(); // alerts "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back(); // alerts "location: http://example.com/example.html, state: null
history.go(2); // alerts "location: http://example.com/example.html?page=3, state: {"page":3}

兼容性测试脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script type="text/javascript" src="node_modules/jquery/dist/jquery.js"></script>
<script>
$(function () {
if(history && history.pushState) {
alert("true");
} else {
alert("false");
}
$(window).on("hashchange", function () {
alert("hashchange");
});
});
</script>

兼容性概览:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
history && history.pushState兼容如下:
chrome true;
Firefox true;
IE10 true;
IE <= 9 false;
PS:IE <= 9既然不支持这些api那就只能采用hash方案,来实现路由系统的兼容了。

hashchange兼容如下:
IE9 true;
IE8 true;
IE7 false;
...

页面load时,onhashchange默认触发情况:
chrome 需主动trigger才能触发
FF 需主动trigger才能触发
IE 需主动trigger才能触发

页面load时,onpopstate默认触发情况:
chrome < 34版本之前的默认触发
FF 默认不触发
IE 默认不触发

只有webkit内核浏览器才会默认触发popstate

3. 两种实现的比较

  • 基于Hash的路由,兼容性更好

  • 基于History API的路由,更加直观和正式

有一点很大的区别是,基于Hash的路由不需要对服务器做改动,基于History API的路由需要对服务器做一些改造。

浏览器第一次打开某个链接时,首先会定向到server端进行路由解析。上边所说的前端路由系统,都是建立在页面已经打开的前提下,前端才可以通过History API进行URL拦截,确保这些URL变化不会发送给server端返回新页面。

但是需要考虑这种情况,链接时在一个新的浏览器tab中打开的,那么这个时候就无法拦截下这个URL,所以,这就要求server和前端制定好一个规则,区分URL中前端解析的部分后端解析的部分server端判断出这个URL的某个部分不属于自己的范围时,就应该把这部分URL定向到前端路由页面的javascript代码。

1
2
3
4
5
6
7
8
// nodejs
app.use(function(req, res) {
if (req.path.indexOf('/routeForServer') >= 0) {
res.send('这里返回的都是server端处理的路由');
} else {
res.sendfile('这里返回配置好路由的页面');
}
})

正常情况下,URL中的“/”一般是server端路由采用的标记,而“?”或者“#”再或者“#!”,则一般是前端路由采用的开始标记,我们可以在这些符号后边,通过键值对的形式,描述一个页面具有哪些板块配置信息。也不乏有的网站为了美观,前后端共用“/”进行路由索引。

URL中采用“#”或者“#!”进行前后端的区分,是为了照顾到更多浏览器,因为利用hash方案,IE对这套路由系统有很好的支持性。


四、动态路由

上面提到的例子都是静态路由,即路径都是固定的。但是我们经常会需要在路径中传入参数,例如或者某个用户的信息

1
2
3
4
// nodejs
app.get('/user/:id', function(req, res) {
// ... ...
})
1
2
3
百度:
https://wenku.baidu.com/album/list?cid=197
https://zhidao.baidu.com/daily/view?id=47009
1
2
新浪:
http://sz.sina.com.cn/news/yz/2016-09-02/detail-ifxvqcts9207231.shtml?from=sz_tttj
1
2
3
京东:
https://item.jd.com/3995645.html#crumb-wrap
https://list.jd.com/list.html?cat=9987,653,655&ev=%40exbrand_14026&go=0
1
2
3
4
5
Google Gmail
https://mail.google.com/mail/u/1/#inbox
https://mail.google.com/mail/u/1/#starred
https://mail.google.com/mail/u/1/#sent
https://mail.google.com/mail/u/1/#drafts

参考文献

  1. MDN官方文档