65.9K
CodeProject 正在变化。 阅读更多。
Home

自己动手:一个 JavaScript 路由器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2012 年 11 月 19 日

CPOL

6分钟阅读

viewsIcon

50937

downloadIcon

545

构建您自己的 JavaScript 路由器

引言

如果您曾经构建过单页 AJAX 站点,您可能遇到过导航方面的一些问题。首先,标准的浏览器历史记录是损坏的。其次,由于没有经验,我开始使用看起来像这样的 URL:

<a href='javascript:void(0);' onclick='somefunction(this);'>SomeSubPage</a>

这很糟糕,尽管爬虫还没有完全理解 AJAX 站点,但这肯定会破坏它们。此外,在 Android 浏览器等移动浏览器中,click 事件会在 touchEnd 之后触发,导致令人恼火的 300 毫秒延迟。

我们需要找到一种方法来拥有有效的 href 属性,但又不会重新加载页面。进入 URL 的 #(hash) 部分。更改 URL 的 hash 不会重新加载页面。但历史记录会受到影响。因此,我们显然可以绑定到 onhashchanged 事件并解析 URL,运行一些 AJAX,然后更新页面。虽然对于小型网站来说这已经足够了,但如果事情变得更复杂,那就行不通了。我们需要某种 URL 路由。

市面上有一些不错的选择。例如 crossroadsdirectorbackbone.js 也包含出色的路由功能。但是,直接使用现成的库一点乐趣都没有。我鼓励使用框架,但在这样做之前,请务必先尝试自己动手制作一个。这样,如果出现问题,您将获得更好的理解。

我们的链接会是什么样子?

<a href="#home">home</a>
<a href="#home/about">about</a>
<a href="#products/">products</a>

所以,如果点击了“主页”,我们希望运行一个完全是“主页”的路由。此路由应从后端服务器获取 /home

onhashchanged 事件

首先,我们需要检测 URL hash 是否已更改。这是通过绑定到 onhashchanged 事件来完成的。

if ("onhashchange" in window) { // event supported?
    window.onhashchange = function () {
        console.log(window.location.hash.split('#')[1]);
    }
else    {
    //Support starts at IE8, however be very careful to have a correct DOCTYPE, 
    //because any IE going in Quircksmode will report having the event, but never fires it.
    console.log("hashchanging not supported!")
}

请注意,在 IE 中写入 console.log 会在日志未打开时引发异常。谁想出这个主意的!所以,让我们编写一个小的日志函数来防止这种情况发生。

function log(message)   {
    
    try     {
        console.log(message)
    }
    catch(err) { 
        //no action. probably just IE
    }
}

路由

我们将把所有内容放在 router.js 中。在其中,我们将从一个匿名函数开始。首先,我们将重点关注 Route 原型。这是实际匹配传入路由的部分。一个路由有三个主要参数。

  • route:我们将用来匹配传入路由的路由
  • fn:当路由匹配时我们将调用的回调函数
  • scope:我们将在此范围内触发回调函数

它至少需要一个函数,称为 matches

(function() {}    
    var Route=function() {
        //We're assuming an object which contains our configuration. 
        //This would be in arguments[0]
        
        //The route against which we'll match incoming routes
        this.route=arguments[0].route;
        //The callback function we'll call when the route matches
        this.fn=arguments[0].fn;
        //The scope in which we'll fire the callback function
        this.scope=arguments[0].scope;
    }
    Route.prototype.matches=function(route)    {
        //this is somewhat to simple
        if(route==this.route)   {
            return true;
        }
        else    {
            return false;
        }    
    }
    //We'll create the alias for route in the window object
    window["Route"]=Route;

)();

路由器

我们的路由原型有点简单。它只能匹配精确的路由。但我们将先设置骨架,然后再使其更有用。为了完成我们的骨架,我们将需要 Router 原型本身。这至少需要两个函数:

  • registerRoute:添加一个新路由以匹配传入的路由
  • applyRoute:匹配所有已注册路由与传入路由,并在 callbackfunctiontrue 时触发
(function() {
    var Router=function()   {
        this.routes=new Array();
    }
    var Router.prototype={
        //Here we use a somewhat different style of create the prototype
        //than for the Route prototype. Both ways are valid. 
        //I'm using them mixed here, but It's probably wise not to do that.
        //And stick to a single pattern. Here I'm doing it to show both possibilities
        registerRoute: function(route, fn, paramObject) {
            //We'll have route and function as named parameters
            //and all the future optional parameters in a single object.
            //Right now we just have scope as a optional parameters
            var scope=paramObject?paramObject.scope?paramObject.scope:{}:{};
            return this.routes[this.routes.length]=new Route({
                route: route,
                fn: fn,
                scope: scope
            });
        },
        applyRoute: function(route) {
            //iterate all routes
            for(var i=0, j=this.routes.length;i <j; i++)  {
                var sRoute=this.routes[i];                    
                //match route
                if(sRoute.matches(route)) {
                    //if true call callback function with the proper scope
                    sRoute.fn.apply(sRoute.scope);   
                }    
            }        
        }        
    }
    
    //We'll create an alias for router in the window object
    window["Router"]=Router;
    
    //We'll create an instance of router in the window object
    window["router"]=new Router();
)();

现在我们有了一个路由器。一个非常非常简单的路由器。它只能匹配精确的路由。但我们可以通过它进行一些测试。

router.registerRoute("home", function() {
    log("call home");
});

router.registerRoute("about", function() {
    log("call about");
});

router.applyRoute("home");      //call home
router.applyRoute("about");     //call about
router.applyRoute("products");  //no reaction

现在我们需要更改我们的 onhashchange 事件处理程序。

if ("onhashchange" in window) { // event supported?
    window.onhashchange = function () {
        //we cut of the actual hash
        router.applyRoute(window.location.hash.split('#')[1]);
    }
else    {
    //Support starts at IE8, however be very careful to have a correct DOCTYPE, 
    //because any IE going in Quircksmode will report having the event, but never fires it.
    console.log("hashchanging not supported!")
}

现在我们可以将链接放在我们的页面中,这些链接将使用这些路由。

<a href="#home" >Home </a>
<a href="#about" >About</a>

matches 函数

我们现在可能有一个路由器了。但它有点没用。我们需要更复杂的匹配过程。我们希望创建如下路由:

  • products/{productid}
  • products/:productid
  • home/{subpage}

对于 products/{productsid},我们希望 product IDs 作为我们函数的变量传入。:productsid: 也应该在为空时调用产品的回调。home 路由之后可能只跟着 about 或 contact。

所以,让我们让 Route 对象中的 matches 函数更智能一些。

首先,我们需要检查在 Route 类构造函数中传入的路由。

var Route=function()    {
    this.route=arguments[0].route;
    this.fn=arguments[0].fn;
    this.scope=arguments[0].scope ? arguments[0].scope : null;
    this.rules=arguments[0].rules ? arguments[0].rules: {};
    
    this.routeArguments=Array();
    this.optionalRouteArguments=Array();
    
    //Create the route arguments if they exist
    this.routeParts=this.route.split("/");
    for(var i=0, j=this.routeParts.length; i<j; i++)   {
        var rPart=this.routeParts[i]
        
        //See if there are pseudo macro's in the route
            
        //will fetch all {id} parts of the route. So the manditory parts
        if(rPart.substr(0,1)=="{" && rPart.substr(rPart.length-1, 1) == "}") {
            var rKey=rPart.substr(1,rPart.length-2); 
            this.routeArguments.push(rKey);
        }
        //will fetch all :id: parts of the route. So the optional parts
        if(rPart.substr(0,1)==":" && rPart.substr(rPart.length-1, 1) == ":") {
            var rKey=rPart.substr(1,rPart.length-2); 
            this.optionalRouteArguments.push(rKey);
        }
    }
}

现在我们将路由的每个部分拆分开来检查。

Route.prototype.matches=function(route)    {
    //We'd like to examen every individual part of the incoming route
    var incomingRouteParts=route.split("/");
    //This might seem strange, but assuming the route is correct
    //makes the logic easier, than assuming it is wrong.    
    var routeMatches=true;
    //if the route is shorter than the route we want to check it against we can immidiatly stop.
    if(incomingRouteParts.length < this.routeParts.length-this.optionalRouteArguments.length)  {
        routeMatches false;

    } 
    else    {
        for(var i=0, j=incomingRouteParts.length; i<j && routeMatches; i++)    {
            //Lets cache the variables, to prevent variable lookups by the JavaScript engine
            var iRp=incomingRouteParts[i];//current incoming Route Part
            var rP=this.routeParts[i];//current routePart                     
            if(typeof rP=='undefined')  {
                //The route almost certainly doesn't match it 
                //is longer than the route to check against
                routeMatches=false;   
            }
            else    {
                var cP0=rP.substr(0,1); //char at position 0
                var cPe=rP.substr(rP.length-1, 1);//char at last position                   
                if((cP0!="{" && cP0!=":") && (cPe != "}" && cPe != ":")) {
                    //This part of the route to check against is not a pseudo macro, 
                    //so it has to match exactly
                    if(iRp != rP)   {
                        routeMatches=false; 
                    }
                }
                else    {
                    //Since this is a pseudo macro and there was a value at this place. 
                    //The route is correct.
                        routeMatches=true;
                    }                       
                }
            }
        }
    }
    return routeMatches;
}

测试我们到目前为止所做的

我们可以创建路由,并且可以测试它们是否匹配。所以,让我们做一些测试。

让我们在我们的 html body 中添加几行代码。

<script>
    
router.registerRoute("home/:section:", function()   {
    console.log("home/:section: route true");        
});    

router.registerRoute("products/{productid}", function()   {
    console.log("products/{productid} route true");        
});    
    
</script>

<a href="#home">Home</a>
<a href="#home/about">About</a>
<a href="#home/contact">Contact</a>
<a href="#products">Products</a>
<a href="#products/5">Product 5</a>

这很棒。路由 0、1、2 和 4 是正确的。路由 3 不对,因为 productid 是必需的。但 product id 是什么?或者 home 路由中的 section 是什么?它的创建方式是我们现在可以确定一个路由是否正确,然后呢?显然,我们需要能够将这些值作为返回函数的输入值的功能。

获取伪宏在路由中的值

我们必须在 Route 原型中引入一个新函数:getArgumentsValues。它应该返回一个包含这些值的数组。我们将按照出现的顺序将它们发送到我们的回调函数。但首先是函数本身。

Route.prototype.getArgumentsValues=function(route) {
    //Split the incoming route
    var rRouteParts=route.split("/");   
    //Create an array for the values
    var rArray=new Array();
    for(var i=0, j=this.routeParts.length; i < j; i++) {
        var rP=this.routeParts[i];//current routePart
        var cP0=rP.substr(0,1); //char at position 0
        var cPe=rP.substr(rP.length-1, 1);//char at last position
        if((cP0=="{" || cP0==":" ) && (cPe == "}" || cPe == ":"))  {
            //if this part of the route was a pseudo macro,
            //either manditory or optional add this to the array
            rArray[rArray.length]=rRouteParts[i];
        }                   
    }
    return rArray;
}

现在路由器是启动我们函数的那一个。所以,让我们改变它执行该部分的代码。

//Change this part;
if(sRoute.matches(route)) {
    //if true call callback function with the proper scope
    sRoute.fn.apply(sRoute.scope);   
}    
//Into this;
if(sRoute.matches(route)) {
    //if true call callback function with the proper scope and send in the variables
    sRoute.fn.apply(sRoute.scope, sRoute.getArgumentsValues(route));   
}

现在我们可以将我们的测试代码更改为这样:

router.registerRoute("home/:section:", function(section)   {
    console.log("home/:section: route true, section=" + section);        
});    

router.registerRoute("products/{productid}", function(productid)   {
    console.log("products/{productid} route true, productid=" + productid);        
});

最后一次打磨刀刃

我仍然不是 100% 满意。原因是我想在将伪宏的值发送到回调函数之前检查它们是否有效。当然,我可以检查 product ID 是否实际上是一个数字,但我宁愿如果它不是数字,路由就失败。到目前为止,我们都是自己想出功能需求的,但我的脑海里有一些路由器。其中一个就是 crossroads。所以,让我们看看他们是如何解决这个问题的。让我们看看:http://millermedeiros.github.com/crossroads.js/#route-rules

它说:验证规则可以是数组、RegExp 或函数

  • 如果规则是数组,crossroads 将尝试将请求段与数组中的项进行匹配;如果找到项,则参数有效。
  • 如果规则是 RegExp,crossroads 将尝试将其与请求段进行匹配。
  • 如果规则是函数,crossroads 将根据函数返回的值进行验证(应返回布尔值)。

这应该不难。

规则

我们先从路由器开始。

registerRoute: function(route, fn, paramObject)  {
    var scope=paramObject?paramObject.scope?paramObject.scope:{}:{};
    //Add this line
    var rules=paramObject?paramObject.rules?paramObject.rules:null:null;
    return this.routes[this.routes.length]=new Route({
        route: route,
        fn: fn,
        scope: scope,
        rules: rules
    })  
},

现在我们将更改我们 Route 的构造函数以使用 rules 对象。

var Route=function()    {
    this.route=arguments[0].route;
    this.fn=arguments[0].fn;
    this.scope=arguments[0].scope ? arguments[0].scope : null;
    this.rules=arguments[0].rules ? arguments[0].rules: {};
    //the rest of the constructor

最后,我们将更改我们的 matches 函数。

//This is the part we need to change
else    {
   //Since this is a pseudo macro and there was a value at this place. The route is correct.
   routeMatches=true;
}

//the change
else    {
   //Since this is a pseudo macro and there was a value at this place. The route is correct.
   //But a rule might change that
    if(this.rules!=null) {
        var rKey=rP.substr(1,rP.length-2);
         //RegExp will return as object. One more test required
        if(this.rules[rKey] instanceof RegExp)   {
            routeMatches=this.rules[rKey].test(iRp);  
        }
        //Array will return as object
        if(this.rules[rKey] instanceof Array)   {
            if(this.rules[rKey].indexOf(iRp) < 0)  {
                routeMatches=false;
            }
        }
        if(this.rules[rKey] instanceof Function)   {
            //getArgumentsObject see example package
            routeMatches=this.rules[rKey](iRp, this.getArgumentsObject(route), this.scope);
        }
    }
}

现在,我们可以添加一个 rules 对象,将 productid 设置为数字。

router.registerRoute("home/:section:", function(section)   {
    console.log("home/:section: route true, section=" + section);        
});    

router.registerRoute("products/{productid}", function(productid)   {
    console.log("products/{productid} route true, productid=" + productid);        
    }, 
    {
    rules: {
        productid: new RegExp('^[0-9]+$');
    }
});

结论

路由器是复杂的东西,但只要有一些常识,我们就可以自己做一个。也许使用现成的产品更好,但我是一个 DIY 的信徒。很多时候,我会 DIY 一些功能,然后扔掉它并引入一个库。但不同之处在于,从那时起,库就不再是神秘的黑匣子了。此外,有时我只需要库提供的功能的 10%。为什么我要带着其他 90% 的负重呢?

源 zip 文件包含 router.js,您可以使用它来构建简单的 AJAX 站点。或者也许只是看一下并学习,然后直接包含 crossroads.js眨眼

希望大家喜欢这篇文章。祝您编码愉快!

历史

  • 2013-02-13:修正了文章中的几个拼写错误。下载未更改。
© . All rights reserved.