前端路由

什么是SPA

SPA是single page web application的简称,译为单页Web应用。
简单的说SPA就是一个WEB项目只有一个HTML页面,一旦页面加载完成,SPA不会因为用户的操作而进行页面的重新加载或跳转。
取而代之的是利用JS动态的变换HTML的内容,从而来模拟多个视图间跳转。

前端路由的由来

最开始的网页是多页面的,直到Ajax的出现,才慢慢有了SPA。
SPA的出现大大提高了WEB应用的交互体验。在与用户的交互过程中,不再需要重新刷新页面,获取数据也是通过Ajax异步获取,页面显示变的更加流畅。
但由于SPA中用户的交互是通过JS改变HTML内容来实现的,页面本身的URL并没有变化,这导致了两个问题:

  • SPA无法记住用户的操作记录,无论是刷新、前进还是后退,都无法展示用户真实的期望内容。
  • SPA中虽然由于业务的不同会有多种页面展示形式,但只有一个URL,对SEO不友好,不方便搜索引擎进行收录。

前端路由就是为了解决上述问题而出现的。

什么是前端路由

简单的说,就是在保证只有一个HTML页面,且与用户交互时不刷新和跳转页面的同时,为SPA中的每个视图展示形式匹配一个特殊的URL。在刷新、前进、后退和SEO时均通过这个特殊的URL来实现。
为实现这一目标,我们需要做到以下二点:

  • 改变URL且不让浏览器像服务器发送请求。
  • 可以监听到URL的变化

hash模式和history模式,就是实现了上面的功能。

hash 模式

这里的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();
image

基本的路由功能我们已经实现了,但依然有点小问题:

  • 视图切换后,新的hash值没有在路由中注册
  • hash值对应的回调函数在执行过程中抛出异常

对应的解决办法如下:

  • 我们追加registerNotFound方法,用于注册hash值为找到时的默认回调函数;
  • 修改load方法,追加try/catch用于捕获异常,追加registerError方法,用于处理异常

代码修改后:

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 模式

在H5之前,浏览器就已经有了history对象。但在早期的history中只能用于多页面的跳转:

history.go(-1);       // 后退一页
history.go(2);        // 前进两页
history.forward();     // 前进一页
history.back();      // 后退一页

在H5的规范中,history新增了以下几个API:

history.pushState();         // 添加新的状态到历史状态栈
history.replaceState();      // 用新的状态代替当前状态
history.state                // 返回当前状态对象

这些方法通常与window.onpopstate配合使用。
history.pushState()history.replaceState()均接收三个参数(state, title, url
参数说明如下:

  • state:合法的Javascript对象,可以用在popstate事件中
  • title:现在大多浏览器忽略这个参数,可以直接用null代替
  • url:任意有效的URL,用于更新浏览器的地址栏

history.pushState()history.replaceState()的区别在于:

  • history.pushState()在保留现有历史记录的同时,将url加入到历史记录中。
  • history.replaceState()会将历史记录中的当前页面历史替换为url

由于history.pushState()history.replaceState()可以改变url同时,不会刷新页面,所以在 HTML5中的histroy具备了实现前端路由的能力。
回想我们之前完成的hash模式,当hash变化时,可以通过hashchange进行监听。
history的改变并不会触发任何事件,所以我们无法直接监听history的改变而做出相应的改变。
所以,我们需要换个思路,我们可以罗列出所有可能触发history改变的情况,并且将这些方式一一进行拦截,变相地监听history的改变。
对于单页应用的history模式而言,url的改变只能由下面四种方式引起:

  • 点击浏览器的前进或后退按钮
  • 点击a标签
  • 在JS代码中触发history.pushState函数
  • 在JS代码中触发history.replaceState函数

思路已经有了,接下来我们来实现一个路由对象。

  1. 创建一个路由对象, 实现register方法用于注册每个location.pathname值对应的回调函数
  2. location.pathname === '/'时,认为是首页,所以实现registerIndex方法用于注册首页时的回调函数
  3. 解决location.path没有对应的匹配,增加方法registerNotFound用于注册默认回调函数
  4. 解决注册的回到函数执行时出现异常,增加方法registerError用于处理异常情况
  5. 定义assign方法,用于通过JS触发history.pushState函数
  6. 定义replace方法,用于通过JS触发history.replaceState函数
  7. 监听popstate用于处理前进后退时调用对应的回调函数
  8. 全局阻止a链接的默认事件,获取a链接的href属性,并调用history.pushState方法
  9. 定义load方法,用于首次进入页面时 根据location.pathname调用对应的回调函数

代码如下:

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模式的优点:

  • 兼容性更好,可以兼容到IE8
  • 无需服务端配合处理非单页的url地址

hash模式相比于history模式的缺点:

  • 看起来更丑
  • 会导致锚点功能失效
  • 相同hash值不会触发动作将记录加入到历史栈中,而pushState则可以

综上所述,当我们不需要兼容老版本IE浏览器,并且可以控制服务端覆盖所有情况的候选资源时,我们可以愉快的使用history模式了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342