/ JavaScript

单页系统前端MVC设计 – 入口

这一篇讲作为整个MVC框架的入口的LocationManagerHashListener组件。

LocationManager和HashListener组件

LocationManagerHashListener作为框架最外层的组件,提供了整个框架的入口,其中HashListener依赖于LocationManager,并且通过LocationManager才能够进入到框架的运行流程中。

在设计中,HashListener将会被设计成一个内部的组件,不会对外暴露,其所有可能提供的接口都通过LocationManager作代理,以保证对外开放的对象尽可能地少,形成更简明的API。

LocationManager

在接触该组件之前,先解释一下hash这个名词的概念。

在Web中,浏览器的地址栏中的URL通常可以分成几个部分,比如有一个简单的地址:

http://www.some-where.com/list.html?page=2&size=20#item-3

上面的URL就能分为下面几个部分:

  • 橙色:protocol,此处表示使用http协议。
  • 蓝色:path,指定访问的路径。
  • 红色:query/search,指定访问时的相关参数。
  • 紫色:hash,hash值通常指向页面中的某个锚点,且服务器端是不会接收到这一段参数的。

URL作为Uniform Resource Locator的缩写,具有唯一性,意味着每一个URL都会代表一个唯一的资源,这其中包括了protocol、path和query,因此如果改变浏览器地址栏中的这3部分,都会重新发起一个请求,加载新的内容。

这里唯独hash是比较特殊的存在,hash作为只被客户端识别的元素,可以经过任意修改而不会造成浏览器的重定向。

同时,hash作为URL的一部分存在,可以被复制、传递、粘贴,也就起到了基于URL的唯一资源定位的功能,解决了一般AJAX为主的应用中加载了数据后地址栏内容没有变化而无法分享内容页面的问题。

再者,当hash被改变时,浏览器的历史功能认为是加载了新的页面,因此前进和后退功能都可以正常使用,也更好地提升了用户体验。

组件作用

LocationManager是对原生Javascript中的locationhistory对象的模拟。

由于系统是通过URL定位到唯一的模块和功能的,因此系统切换到具体的模块功能也可以通过改变URL的形式完成,因此LocationManager提供修改URL的接口。

另一方面,LocationManager也需要提供“前进”和“后退”的功能,在定向到新的URL时,保证在页面不刷新的情况下,可以让浏览器激活“前进”和“后退”按钮,保证用户的体验。

最后,LocationManager作为掌握URL的组件,必须有分析URL,从URL中提取protocol、path、query、hash的能力,并且可以从hash中进一步分解出伪path和伪query。

接口设计

由于是单页式系统,不允许因为URL的变更导致页面的刷新,LocationManager唯一可以修改的是hash部分,因此只能在hash部分设计一套类似前面path+query的结构,本次的设计如下:

  • hash = path + '~' + query
  • path = part1/part2/par3/…
  • query = key1=value1&key2=value2&key3=value3…

从组件的作用中提取相关的接口,LocationManager需要支持以下的接口:

  • redirect(url),修改URL,定位到给定的位置。
  • go(frame),相当于history的go函数,可以通过数字来定位“前进”和“后退”。
  • getLocation(),获取window.location或其封装体,需要拥有protocol、host、pathname、search、hash等属性,与location对象保持一致接口。
  • parseHash(hash),分解hash,根据系统设定的规则,从hash串中再分离出对应的伪path和伪query部分。
  • buildRequest(),分析URL,并构造一个Request对象,Request对象至少包含以下内容:
    • url:仅需要hash部分的完整串。
    • path:从hash部分分解出来的伪path。
    • query:从hash部分分解出来的伪query。
    • parameters:从hash部分的伪query中分享出来的表示参数的键值对。

从接口上看,虽然LocationManager仅可以操作hash部分,但依旧提供了对整个URL的分析,其目的是提取其中的path和protocol部分,确定与自身系统是吻合的,避免在极端情况下被恶意使用,即LocationManager的redirect本身还起到了一个“准入判断”的作用。

实现原理

redirect函数

LocationManager只能够修改hash部分,因此最简单的redirect的实现是给window.location.hash赋值。

function redirect(url) {
    window.location.hash = url;
}

但是IE不允许给hash赋值,这会产生一个异常。对于IE,比较通用的做法是使用一个iframe作为中介,通过给iframe设置新的src,同样可以触发浏览器的前进和后退功能,因此对于IE,实现的方式如下:

// javascript部分
function redirect(url) {
    // 事先创建好并加入在DOM树中的iframe
    iframe.src = 'history.html?location=' + url;
}

// history.html中的javascript部分
var url = location.search.substring('?location='.length);
// 回调父页面,指定到controller
parent.fe.process(url);

parseHash函数

该函数是基于字符串的分析,按上文提到的固定结构从字符串中获取path和query这2个部分。

根据规则,通过~分隔path和query,因此仅仅是简单的一次分隔:

function parseHash(hash) {
    var data = hash.split('~');
    return {
        path: data[0],
        query: data[1] || ''
    };
}

buildRequest函数

该函数需要在parseHash的基础上,从query中进一步分解出各个参数的键值对,通过&分隔各个键值对,通过=分隔键和值。其中键和值需要经过decodeURIComponent函数解码,但是Firefox在获取hash时自动完成了解码,因此不需要调用解码函数:

function buildRequest(url) {
    var data = parseHash(url),
        query = data.query,
        pairs = query.split('&'),
        i = 0,
        pair,
        params = {};
    for (; pair = pairs[i]; i++) {
        pair = pair.split('=');
        params[pair[0]] = requireDecode ? 
            decodeURIComponent(pair[1]) : pair[1];
    }
}

相关特性检测

LocationManager组件的实现过程中,遇上了2个浏览器的兼容问题:

  • 设置hash时,IE浏览器需要通过<iframe>进行处理。
  • 分解hash时,Firefox不需要调用decodeURIComponent

但是对于这两个问题,对应的浏览器都可能在后续版本中给予修正,为了保持框架的始终高可用性,通过特性检测的方法来判断浏览器对两个兼容问题的支持是一种更为妥当的方式:

var hashSettable = true;
try {
    location.hash = '#abc=' + encodeURIComponent('&abc');
}
catch (ex) {
    hashSettable = false;
}

var requireDecode = true;
// 现阶段来看,当hashSettable为false时,requireDecode必然为true
if (hashSettable) {
    requireDecode = (location.hash.indexOf('&') < 0);
}
console.log(hashSettable);
console.log(requireDecode);

需要注意的是,这个特性检测是有代价的,设置hash以后,会导致浏览器的历史记录中多了一帧,即可以进行一次后退操作。但是这一点对系统来说并不是十分严重的问题,因此权衡之后,还是采用特性检测的方式。

当然也可以采用<iframe>来做测试,但是<iframe>元素在IE下存在不少的问题,如果历史记录中多一帧着实不会严重影响系统的话,还是不建议采用复杂的方案。

HashListener组件

HashListener组件并不会对外暴露,但是在设计过程中,他确实是一个组件。

组件作用

在系统运行过程中,URL的变更并不全部来自于对LocationManagerredirect函数的调用,至少存在以下情况会导致在LocationManager不知情的情况下URL发生变化:

  • 用户修改浏览器地址栏中的hash部分。
  • 用户点击某些a标签导致hash发生变化。

鉴于随着hash值的变化,非常有可能系统需要进行导航,进入不同的模块或功能,因此监听hash的变化是非常重要的。

接口设计

HashListener是一个完全自治的组件,即他不会依赖于其他的组件,完全可以独立自主地运行并完成工作。因此该组件是不存在输入接口的。

在输出接口方面,由于hash值的改变这一情况并不是系统的运行流程可预期的动作,因此HashListener组件将通过事件的方式对外提供接口,即提供hashchanged事件。

实现原理

理论上,监听hash的变化有2种方式:

  • HTML5规范中有window.hashchange事件可以监听。
  • 通过一定频率访问hash进行监听。

很明显的,两种方案中,前者优于后者。但是前者的浏览器支持程度尚未达到100%,因此在实现过程中,还是通过特性检测,尽可能地使用前者:

function observeHashChange()
    if ('onhashchange' in window) {
        // 因为IE9已经提供了addEventListener
        // 所以所有支持该事件的浏览器,肯定有addEventListener
        window.addEventListener('hashchange', trigger, false);
    }
    else {
        var current = location.hash;
        setInterval(
            function() {
                if (current !== location.hash) {
                    current = location.hash;
                    trigger();
                }
            }, 
            100
        );
}

function trigger() {
    // 其上有Observable基类提供事件相关函数
    this.fireEvent('hashchanged', location.hash);
}

组件结合

由于HashListener是不会对外暴露的,因此LocationManager组件需要主动的去监听HashListener提供的hashchanged事件,同时将这个事件继续向外暴露,以便其他组件可以利用。

对于整个系统来说,其他的组件是不关心hash这个专有名词的,对MVC框架来说,只有唯一资源定位符(URL)发生变化才是流程启动的关键,因此当LocationManager暴露接口时,需要在语义上次hash这一句词隐藏起来,提供更好的语义化的事件接口:

// LocationManager
function exportHashListener() {
    var me = this; //保持闭包
    HashListener.bindEvent(
        'hashchanged', 
        function(hash) {
            // 将事件冒泡
            me.fireEvent('redirect', hash);
            // 进入流程
            fe.process(hash);
        }
    );
}