现代前端项目多为单页Web应用(SPA),在单页Web应用中路由是其中的重要环节。javascript
每一个现代前端框架都有与之对应的路由实现,例如 vue-router、react-router 等。html
本文并不涉及 vue-router、react-router 的实现方式,而是介绍前端路由的基本实现原理及实现方式。前端
vue-router、react-router 的源码解析,会在之后的文章中逐步推出。vue
SPA 是 single page web application 的简称,译为单页Web应用。java
简单的说 SPA 就是一个WEB项目只有一个 HTML 页面,一旦页面加载完成,SPA 不会由于用户的操做而进行页面的从新加载或跳转。 取而代之的是利用 JS 动态的变换 HTML 的内容,从而来模拟多个视图间跳转。react
对于初学者来讲,理解传统页面与 SPA 视图间的差别是困难的。git
在这里,用两张图,来分别代表传统页面与 SPA 视图间的区别:github
上图代表了,在传统的网站设计中,每一个HTML文件都是一个完成的HTML页面,涵盖了完整的HTML结构。web
上图代表了,在 SPA 的应用设计中,一个应用只有一个HTML文件,在HTML文件中包含一个占位符(即图中的 container),占位符对应的内容由每一个视图来决定,对于 SPA 来讲,页面的切换就是视图之间的切换。vue-router
最开始的网页是多页面的,直到 Ajax 的出现,才慢慢有了 SPA。
SPA 的出现大大提升了 WEB 应用的交互体验。在与用户的交互过程当中,再也不须要从新刷新页面,获取数据也是经过 Ajax 异步获取,页面显示变的更加流畅。
但因为 SPA 中用户的交互是经过 JS 改变 HTML 内容来实现的,页面自己的 url 并无变化,这致使了两个问题:
前端路由就是为了解决上述问题而出现的。
简单的说,就是在保证只有一个 HTML 页面,且与用户交互时不刷新和跳转页面的同时,为 SPA 中的每一个视图展现形式匹配一个特殊的 url。在刷新、前进、后退和SEO时均经过这个特殊的 url 来实现。
为实现这一目标,咱们须要作到如下二点:
接下来要介绍的 hash 模式和 history 模式,就是实现了上面的功能
这里的 hash 就是指 url 后的 # 号以及后面的字符。好比说 "www.baidu.com/#hashhash" ,其中 "#hashhash" 就是咱们指望的 hash 值。
因为 hash 值的变化不会致使浏览器像服务器发送请求,并且 hash 的改变会触发 hashchange 事件,浏览器的前进后退也能对其进行控制,因此在 H5 的 history 模式出现以前,基本都是使用 hash 模式来实现前端路由。
使用到的API:
window.location.hash = 'hash字符串'; // 用于设置 hash 值
let hash = window.location.hash; // 获取当前 hash 值
// 监听hash变化,点击浏览器的前进后退会触发
window.addEventListener('hashchange', function(event){
let newURL = event.newURL; // hash 改变后的新 url
let oldURL = event.oldURL; // hash 改变前的旧 url
},false)
复制代码
接下来咱们来实现一个路由对象
建立一个路由对象, 实现 register 方法用于注册每一个 hash 值对应的回调函数
class HashRouter{
constructor(){
//用于存储不一样hash值对应的回调函数
this.routers = {};
}
//用于注册每一个视图
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
}
复制代码
不存在hash值时,认为是首页,因此实现 registerIndex 方法用于注册首页时的回调函数
class HashRouter{
constructor(){
//用于存储不一样hash值对应的回调函数
this.routers = {};
}
//用于注册每一个视图
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
//用于注册首页
registerIndex(callback = function(){}){
this.routers['index'] = callback;
}
}
复制代码
经过 hashchange 监听 hash 变化,并定义 hash 变化时的回调函数
class HashRouter{
constructor(){
//用于存储不一样hash值对应的回调函数
this.routers = {};
window.addEventListener('hashchange',this.load.bind(this),false)
}
//用于注册每一个视图
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
//用于注册首页
registerIndex(callback = function(){}){
this.routers['index'] = callback;
}
//用于调用不一样视图的回调函数
load(){
let hash = location.hash.slice(1),
handler;
//没有hash 默认为首页
if(!hash){
handler = this.routers.index;
}else{
handler = this.routers[hash];
}
//执行注册的回调函数
handler.call(this);
}
}
复制代码
咱们作一个例子来演示一下咱们刚刚完成的 HashRouter
<body>
<div id="nav">
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
</div>
<div id="container"></div>
</body>
复制代码
let router = new HashRouter();
let container = document.getElementById('container');
//注册首页回调函数
router.registerIndex(()=> container.innerHTML = '我是首页');
//注册其余视图回到函数
router.register('/page1',()=> container.innerHTML = '我是page1');
router.register('/page2',()=> container.innerHTML = '我是page2');
router.register('/page3',()=> container.innerHTML = '我是page3');
//加载视图
router.load();
复制代码
来看一下效果:
基本的路由功能咱们已经实现了,但依然有点小问题
对应的解决办法以下:
代码修改后:
class HashRouter{
constructor(){
//用于存储不一样hash值对应的回调函数
this.routers = {};
window.addEventListener('hashchange',this.load.bind(this),false)
}
//用于注册每一个视图
register(hash,callback = function(){}){
this.routers[hash] = callback;
}
//用于注册首页
registerIndex(callback = function(){}){
this.routers['index'] = callback;
}
//用于处理视图未找到的状况
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用于处理异常状况
registerError(callback = function(){}){
this.routers['error'] = callback;
}
//用于调用不一样视图的回调函数
load(){
let hash = location.hash.slice(1),
handler;
//没有hash 默认为首页
if(!hash){
handler = this.routers.index;
}
//未找到对应hash值
else if(!this.routers.hasOwnProperty(hash)){
handler = this.routers['404'] || function(){};
}
else{
handler = this.routers[hash]
}
//执行注册的回调函数
try{
handler.apply(this);
}catch(e){
console.error(e);
(this.routers['error'] || function(){}).call(this,e);
}
}
}
复制代码
再来一个例子,演示一下:
<body>
<div id="nav">
<a href="#/page1">page1</a>
<a href="#/page2">page2</a>
<a href="#/page3">page3</a>
<a href="#/page4">page4</a>
<a href="#/page5">page5</a>
</div>
<div id="container"></div>
</body>
复制代码
let router = new HashRouter();
let container = document.getElementById('container');
//注册首页回调函数
router.registerIndex(()=> container.innerHTML = '我是首页');
//注册其余视图回到函数
router.register('/page1',()=> container.innerHTML = '我是page1');
router.register('/page2',()=> container.innerHTML = '我是page2');
router.register('/page3',()=> container.innerHTML = '我是page3');
router.register('/page4',()=> {throw new Error('抛出一个异常')});
//加载视图
router.load();
//注册未找到对应hash值时的回调
router.registerNotFound(()=>container.innerHTML = '页面未找到');
//注册出现异常时的回调
router.registerError((e)=>container.innerHTML = '页面异常,错误消息:<br>' + e.message);
复制代码
来看一下效果:
至此,基于 hash 方式实现的前端路由,咱们已经将基本雏形实现完成了。
接下来咱们来介绍前端路由的另外一种模式:history 模式。
在 HTML5 以前,浏览器就已经有了 history 对象。但在早期的 history 中只能用于多页面的跳转:
history.go(-1); // 后退一页
history.go(2); // 前进两页
history.forward(); // 前进一页
history.back(); // 后退一页
复制代码
在 HTML5 的规范中,history 新增了如下几个 API:
history.pushState(); // 添加新的状态到历史状态栈
history.replaceState(); // 用新的状态代替当前状态
history.state // 返回当前状态对象
复制代码
来自MDN的解释:
HTML5引入了 history.pushState() 和 history.replaceState() 方法,它们分别能够添加和修改历史记录条目。这些方法一般与window.onpopstate 配合使用。
history.pushState() 和 history.replaceState() 均接收三个参数(state, title, url)
参数说明以下:
history.pushState() 和 history.replaceState() 的区别在于:
因为 history.pushState() 和 history.replaceState() 能够改变 url 同时,不会刷新页面,因此在 HTML5 中的 histroy 具有了实现前端路由的能力。
回想咱们以前完成的 hash 模式,当 hash 变化时,能够经过 hashchange 进行监听。 而 history 的改变并不会触发任何事件,因此咱们没法直接监听 history 的改变而作出相应的改变。
因此,咱们须要换个思路,咱们能够罗列出全部可能触发 history 改变的状况,而且将这些方式一一进行拦截,变相地监听 history 的改变。
对于单页应用的 history 模式而言,url 的改变只能由下面四种方式引发:
思路已经有了,接下来咱们来实现一个路由对象
class HistoryRouter{
constructor(){
//用于存储不一样path值对应的回调函数
this.routers = {};
}
//用于注册每一个视图
register(path,callback = function(){}){
this.routers[path] = callback;
}
//用于注册首页
registerIndex(callback = function(){}){
this.routers['/'] = callback;
}
//用于处视图未找到的状况
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用于处理异常状况
registerError(callback = function(){}){
this.routers['error'] = callback;
}
}
复制代码
class HistoryRouter{
constructor(){
//用于存储不一样path值对应的回调函数
this.routers = {};
}
//用于注册每一个视图
register(path,callback = function(){}){
this.routers[path] = callback;
}
//用于注册首页
registerIndex(callback = function(){}){
this.routers['/'] = callback;
}
//用于处理视图未找到的状况
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用于处理异常状况
registerError(callback = function(){}){
this.routers['error'] = callback;
}
//跳转到path
assign(path){
history.pushState({path},null,path);
this.dealPathHandler(path)
}
//替换为path
replace(path){
history.replaceState({path},null,path);
this.dealPathHandler(path)
}
//通用处理 path 调用回调函数
dealPathHandler(path){
let handler;
//没有对应path
if(!this.routers.hasOwnProperty(path)){
handler = this.routers['404'] || function(){};
}
//有对应path
else{
handler = this.routers[path];
}
try{
handler.call(this)
}catch(e){
console.error(e);
(this.routers['error'] || function(){}).call(this,e);
}
}
}
复制代码
最终代码以下:
class HistoryRouter{
constructor(){
//用于存储不一样path值对应的回调函数
this.routers = {};
this.listenPopState();
this.listenLink();
}
//监听popstate
listenPopState(){
window.addEventListener('popstate',(e)=>{
let state = e.state || {},
path = state.path || '';
this.dealPathHandler(path)
},false)
}
//全局监听A连接
listenLink(){
window.addEventListener('click',(e)=>{
let dom = e.target;
if(dom.tagName.toUpperCase() === 'A' && dom.getAttribute('href')){
e.preventDefault()
this.assign(dom.getAttribute('href'));
}
},false)
}
//用于首次进入页面时调用
load(){
let path = location.pathname;
this.dealPathHandler(path)
}
//用于注册每一个视图
register(path,callback = function(){}){
this.routers[path] = callback;
}
//用于注册首页
registerIndex(callback = function(){}){
this.routers['/'] = callback;
}
//用于处理视图未找到的状况
registerNotFound(callback = function(){}){
this.routers['404'] = callback;
}
//用于处理异常状况
registerError(callback = function(){}){
this.routers['error'] = callback;
}
//跳转到path
assign(path){
history.pushState({path},null,path);
this.dealPathHandler(path)
}
//替换为path
replace(path){
history.replaceState({path},null,path);
this.dealPathHandler(path)
}
//通用处理 path 调用回调函数
dealPathHandler(path){
let handler;
//没有对应path
if(!this.routers.hasOwnProperty(path)){
handler = this.routers['404'] || function(){};
}
//有对应path
else{
handler = this.routers[path];
}
try{
handler.call(this)
}catch(e){
console.error(e);
(this.routers['error'] || function(){}).call(this,e);
}
}
}
复制代码
再作一个例子来演示一下咱们刚刚完成的 HistoryRouter
<body>
<div id="nav">
<a href="/page1">page1</a>
<a href="/page2">page2</a>
<a href="/page3">page3</a>
<a href="/page4">page4</a>
<a href="/page5">page5</a>
<button id="btn">page2</button>
</div>
<div id="container">
</div>
</body>
复制代码
let router = new HistoryRouter();
let container = document.getElementById('container');
//注册首页回调函数
router.registerIndex(() => container.innerHTML = '我是首页');
//注册其余视图回到函数
router.register('/page1', () => container.innerHTML = '我是page1');
router.register('/page2', () => container.innerHTML = '我是page2');
router.register('/page3', () => container.innerHTML = '我是page3');
router.register('/page4', () => {
throw new Error('抛出一个异常')
});
document.getElementById('btn').onclick = () => router.assign('/page2')
//注册未找到对应path值时的回调
router.registerNotFound(() => container.innerHTML = '页面未找到');
//注册出现异常时的回调
router.registerError((e) => container.innerHTML = '页面异常,错误消息:<br>' + e.message);
//加载页面
router.load();
复制代码
来看一下效果:
至此,基于 history 方式实现的前端路由,咱们已经将基本雏形实现完成了。
但须要注意的是,history 在修改 url 后,虽然页面并不会刷新,但咱们在手动刷新,或经过 url 直接进入应用的时候, 服务端是没法识别这个 url 的。由于咱们是单页应用,只有一个 html 文件,服务端在处理其余路径的 url 的时候,就会出现404的状况。 因此,若是要应用 history 模式,须要在服务端增长一个覆盖全部状况的候选资源:若是 URL 匹配不到任何静态资源,则应该返回单页应用的 html 文件。
接下来,咱们来探究一下,什么时候使用 hash 模式,什么时候使用 history 模式。
hash 模式相比于 history 模式的优势:
hash 模式相比于 history 模式的缺点:
综上所述,当咱们不须要兼容老版本IE浏览器,而且能够控制服务端覆盖全部状况的候选资源时,咱们能够愉快的使用 history 模式了。
反之,很遗憾,只能使用丑陋的 hash 模式~
本文简单分析并实现了单页路由中的 hash 模式和 history 模式,固然,它与 vue-router、react-router 相比还太过简陋,关于 vue-router、react-router 的源码解析,会在之后的文章中逐步推出。
欢迎关注微信公众号
【前端小黑屋】
,每周1-3篇精品优质文章推送,助你走上进阶之旅