Clark To Do The blog of Clark

JavaScript路由功能的轻量级实现

当下流行的单页面程序可以说是比比皆是. 开发此类程序通常需要一个独立的路由机制. 类似于Emberjs的框架都是在一个路由类的基础上构建的. 虽然我依然不缺点这是否是一个我所喜欢的理论, 但是我百分之百确定AbsurdJS应该有一个内置的路由. 同时, 由于这是一个轻量级的类库, 因此路由应该是一个轻巧, 简单的类. 接下来, 让我们来看看这样一个模块是怎样的.

需求

路由应该是这样的…

  • 少于100行代码
  • 支持井号URL, 例如: http://site.com#products/list
  • 兼容History API
  • 提供简单易用的API
  • 并非自动运行
  • 只在我们需要时, 才监听改变

单例

我决定路由类只会拥有一个实例. 这可能是一个糟糕的选择, 因为我曾有过需要多个路由的工程, 但是这是一个与众不同的应用. 如果我们实现了单例模式, 就不需要在对象间传递路由对象, 并且也不用关注它的创建. 因为我们只需要一个实例, 所以会自动创建.

var Router = {
    routes: [],
    mode: null,
    root: '/'
}

有三个属性是我们需要的.

  • 路由 - 它保留的当前已注册的路线
  • 模式 - 可能是”hash” 或 “history”, 用于表示是否使用History API
  • 源 - 应用URL的根路径, 只在pushState时有用

配置

我们需要一个方法来构建路由对象. 虽然只需要传递2个参数, 但是还是最好在一个函数内完成.

var Router = {
    routes: [],
    mode: null,
    root: '/',
    config: function(options) {
        this.mode = options && options.mode && options.mode == 'history'
                    && !!(history.pushState) ? 'history' : 'hash';
        this.root = options && options.root ? '/' + this.clearSlashes(options.root) + '/' : '/';
        return this;
    }
}

“history”模式只在我们需要并且支持pushState的情况下才启用. 否则我们会在URL中使用井号. 源路径默认被设置为一个斜线”/”.

获取当前URL

此部分在我们的路由对象中是至关重要的, 因为它会指出当前的位置. 因为有两种模式, 因此需要一个if条件.

getFragment: function() {
    var fragment = '';
    if(this.mode === 'history') {
        fragment = this.clearSlashes(decodeURI(location.pathname + location.search));
        fragment = fragment.replace(/\?(.*)$/, '');
        fragment = this.root != '/' ? fragment.replace(this.root, '') : fragment;
    } else {
        var match = window.location.href.match(/#(.*)$/);
        fragment = match ? match[1] : '';
    }
    return this.clearSlashes(fragment);
}

在两种情况下, 都用到了全局对象window.location. 在”history”模式中, 需要去除URL的源路径部分. 同时也应该删除所有GET参数, 此功能通过一个增则表达式(/\?(.*)$/)实现. 井号值的获取则相对简单一点. 注意clearSlashes函数的应用. 它实现了在字符串的头尾处去掉斜线. 这是很有必要的, 因为我们不想强制程序员使用规范好格式的URL. 无论传递了什么, 都会被翻译成一个统一的值.

clearSlashes: function(path) {
    return path.toString().replace(/\/$/, '').replace(/^\//, '');
}

添加与删除路线

当我开发AbsurdJS时, 我经常尝试给开发人员尽量多的控制权. 在几乎所有的路由实现中, 路线都被定义为字符串, 然后我更倾向于直接传递一个正则表达式. 这样会更加灵活, 因为我们可以做相当疯狂的匹配.

add: function(re, handler) {
    if(typeof re == 'function') {
        handler = re;
        re = '';
    }
    this.routes.push({ re: re, handler: handler});
    return this;
}

这个函数填充了路线数据. 当一个函数传进来, 它会被认为是默认路线(只是一个空字符串)的处理程序. 这有助于链接起类的方法.

remove: function(param) {
    for(var i=0, r; i<this.routes.length, r = this.routes[i]; i++) {
        if(r.handler === param || r.re === param) {
            this.routes.splice(i, 1);
            return this;
        }
    }
    return this;
}

当传递一个匹配的正则表达式或者add方法的处理程序时, 路线的删除会被触发.

flush: function() {
    this.routes = [];
    this.mode = null;
    this.root = '/';
    return this;
}

有时需要重新设置这个类. 上面的flush方法就会在此场景得到应用.

检测入口

好了, 我们已经创建了添加,删除URL和获取当前地址的API. 所以, 下一步的逻辑就是进行比较已注册的入口了.

check: function(f) {
    var fragment = f || this.getFragment();
    for(var i=0; i<this.routes.length; i++) {
        var match = fragment.match(this.routes[i].re);
        if(match) {
            match.shift();
            this.routes[i].handler.apply({}, match);
            return this;
        }
    }
    return this;
}

通过getFragment获取片段或者接收它作为函数的参数. 此步骤完成后, 执行一个对路线的循环并尝试匹配. 此处有一个match变量, 当正则表达式匹配不上时,该变量为空.

["products/12/edit/22", "12", "22", index: 1, input: "/products/12/edit/22"]

它是一个类数组的对象, 其中包含了匹配的字符串和所有已记录的字串. 这表示如果移除第一个元素, 会得到一个动态数组. 例如:

Router
.add(/about/, function() {
    console.log('about');
})
.add(/products\/(.*)\/edit\/(.*)/, function() {
    console.log('products', arguments);
})
.add(function() {
    console.log('default');
})
.check('/products/12/edit/22');

脚本输出为:

products ["12", "22"]

这就是如何处理动态URL.

监控变化

当然, 不能一直运行检测方法. 因此需要一个逻辑来提醒我们地址条里的变化, 甚至点击浏览器后退按钮的变化. 你们之中使用过History API的一定了解popstate时间. 当URL改变时, 会触发此事件. 然而我发现一些浏览器会在页面加载时派发此事件. 这点和其他一些区别是我想到了另一个解决方案. 并且我希望即使在模式被设置为”hash”时, 也可以进行监控, 因此我决定使用setInterval.

listen: function() {
    var self = this;
    var current = self.getFragment();
    var fn = function() {
        if(current !== self.getFragment()) {
            current = self.getFragment();
            self.check(current);
        }
    }
    clearInterval(this.interval);
    this.interval = setInterval(fn, 50);
    return this;
}

需要保存最近的URL以便于可以将它和最新的进行比较.

修改URL

最后, 我们路由对象需要一个函数来改变当前地址以及触发路线的处理程序.

navigate: function(path) {
    path = path ? path : '';
    if(this.mode === 'history') {
        history.pushState(null, null, this.root + this.clearSlashes(path));
    } else {
        window.location.href.match(/#(.*)$/);
        window.location.href = window.location.href.replace(/#(.*)$/, '') + '#' + path;
    }
    return this;
}

同样, 不同的模式属性会对应不同的行为. 如果History API可用, 我们会使用pushState. 否则, 使用经典的window.location就可以了.

最终代码

下面就是完成版的路由代码并附带一个小例子:

var Router = {
    routes: [],
    mode: null,
    root: '/',
    config: function(options) {
        this.mode = options && options.mode && options.mode == 'history'
                    && !!(history.pushState) ? 'history' : 'hash';
        this.root = options && options.root ? '/' + this.clearSlashes(options.root) + '/' : '/';
        return this;
    },
    getFragment: function() {
        var fragment = '';
        if(this.mode === 'history') {
            fragment = this.clearSlashes(decodeURI(location.pathname + location.search));
            fragment = fragment.replace(/\?(.*)$/, '');
            fragment = this.root != '/' ? fragment.replace(this.root, '') : fragment;
        } else {
            var match = window.location.href.match(/#(.*)$/);
            fragment = match ? match[1] : '';
        }
        return this.clearSlashes(fragment);
    },
    clearSlashes: function(path) {
        return path.toString().replace(/\/$/, '').replace(/^\//, '');
    },
    add: function(re, handler) {
        if(typeof re == 'function') {
            handler = re;
            re = '';
        }
        this.routes.push({ re: re, handler: handler});
        return this;
    },
    remove: function(param) {
        for(var i=0, r; i<this.routes.length, r = this.routes[i]; i++) {
            if(r.handler === param || r.re === param) {
                this.routes.splice(i, 1);
                return this;
            }
        }
        return this;
    },
    flush: function() {
        this.routes = [];
        this.mode = null;
        this.root = '/';
        return this;
    },
    check: function(f) {
        var fragment = f || this.getFragment();
        for(var i=0; i<this.routes.length; i++) {
            var match = fragment.match(this.routes[i].re);
            if(match) {
                match.shift();
                this.routes[i].handler.apply({}, match);
                return this;
            }
        }
        return this;
    },
    listen: function() {
        var self = this;
        var current = self.getFragment();
        var fn = function() {
            if(current !== self.getFragment()) {
                current = self.getFragment();
                self.check(current);
            }
        }
        clearInterval(this.interval);
        this.interval = setInterval(fn, 50);
        return this;
    },
    navigate: function(path) {
        path = path ? path : '';
        if(this.mode === 'history') {
            history.pushState(null, null, this.root + this.clearSlashes(path));
        } else {
            window.location.href.match(/#(.*)$/);
            window.location.href = window.location.href.replace(/#(.*)$/, '') + '#' + path;
        }
        return this;
    }
}

// configuration
Router.config({ mode: 'history'});

// returning the user to the initial state
Router.navigate();

// adding routes
Router
.add(/about/, function() {
    console.log('about');
})
.add(/products\/(.*)\/edit\/(.*)/, function() {
    console.log('products', arguments);
})
.add(function() {
    console.log('default');
})
.check('/products/12/edit/22').listen();

// forwarding
Router.navigate('/about');

总结

路由实现约为90行. 它支持井号类型的URL与History API. 如果只想使用路由功能而不是整个框架, 这是一个非常有帮助的选择.

这个类是AbsurdJS类库的一部分. 此处可以检阅关于此类的文档

此篇文章出处请参考: A modern JavaScript router in 100 lines

comments powered by Disqus