Pull to refresh

Парсим URL

Reading time 25 min
Views 67K
Хочу поделиться одной полезной утилиткой, написанной на pure JavaScript, — URL. По сути это небольшой парсер URL'ов, работающий почти как window.location, но не перезагружающий страницу браузера при манипуляциях.

А заодно скажу пару слов про getters & setters в JavaScript.

UPD1: по просьбам трудящихся, вынесу сюда примеры:
// Пусть текущий URL = 'http://my.site.com/somepath/'
var u = new URL('relative/path/index.html')
u.href // my.site.com/somepath/relative/path/index.html
u.href = '/absolute/path.php?a=8#some-hash'
u.href // my.site.com/absolute/path.php?a=8#some-hash
u.hash // #some-hash
u.protocol = 'https:'
u.href // my.site.com/absolute/path.php?a=8#some-hash
u.host = 'another.site.com:8080'
u.href // another.site.com:8080/absolute/path.php?a=8#some-hash
u.port // 8080
// и так далее, и тому подобное

* This source code was highlighted with Source Code Highlighter.

Работает в FF3+ (может и в 2+, не пробовал) и в IE6+ ( и это — моё ноу-хау :-) ).
Разобрана в статье также полностью кросс-браузерная реализация, но в использовании — немного более громоздкая:
// Пусть текущий URL = 'http://my.site.com/somepath/'
var u = new URL('relative/path/index.html')
u.href() // my.site.com/somepath/relative/path/index.html
u.href('/absolute/path.php?a=8#some-hash')
u.href() // my.site.com/absolute/path.php?a=8#some-hash
// и т.д.

* This source code was highlighted with Source Code Highlighter.


Да, и я привожу свой листинг полностью, извиняйте, так надо.


UPD2: кратко объясню цели моей библиотеки:
Данная тулза возникла именно из практических нужд.
И я видел уже несколько кустарных разработок подобного назначения в больших JS-проектах, таких, как TinyMCE. В RTE часто имеешь дело со ссылками на ресурсы. И эти ссылки нужно обрабатывать в real-time.

Конкретно мне надо было распарсить текущий URL и изменить/добавить новый параметр в search, с последующим редиректом.

Можно придумать ещё.

Проблема


В чём же, собственно, проблема? Проблема в том, что:
  1. Мы не можем использовать объект window.location, т.к. он перезагружает текущую страницу при малейших изменениях
  2. Мы не можем создать ещё один такой же объект через конструктор Location — атата! запрещено браузероводами!
  3. Сам объект довольно нетривиален в поведении
  4. Ну и я не нашёл никакой готовой реализации :)

Я упомянул про нетривиальность поведения. Вот она в чём:

Рисунок: взаимосвязь частей URL

При изменении любой из частей URL должны обновляться другие.

Разбор на части


По сути я буду создавать подобие window.location, поэтому и обозначения тащу оттуда. Разберём пример:

Рисунок: разбор частей URL

Без комментариев :)

Как ни крутись, без RegExp не обойтись


Основную работу выполнять будет, конечно же, Regular Expression:
var pattern = "^(([^:/\\?#]+):)?(//(([^:/\\?#]*)(?::([^/\\?#]*))?))?([^\\?#]*)(\\?([^#]*))?(#(.*))?$";

* This source code was highlighted with Source Code Highlighter.

Теперь более подробно:
var pattern =
    // Match #0. URL целиком (#0 - это HREF, в терминах window.location).
    // Например, #0 == "https://example.com:8080/some/path/index.html?p=1&q=2&r=3#some-hash"
    "^" +
    // Match #1 & #2. SCHEME (#1 - это PROTOCOL, в терминах window.location).
    // Например, #1 == "https:", #2 == "https"
    "(([^:/\\?#]+):)?" +
    // Match #3-#6. AUTHORITY (#4 = HOST, #5 = HOSTNAME и #6 = PORT, в терминах window.location)
    // Например, #3 == "//example.com:8080", #4 == "example.com:8080", #5 == "example.com", #6 == "8080"
    "(" +
        "//(([^:/\\?#]*)(?::([^/\\?#]*))?)" +
    ")?" +
    // Match #7. PATH (#7 = PATHNAME, в терминах window.location).
    // Например, #7 == "/some/path/index.html"    
    "([^\\?#]*)" +
    // Match #8 & #9. QUERY (#8 = SEARCH, в терминах window.location).
    // Например, #8 == "?p=1&q=2&r=3", #9 == "p=1&q=2&r=3"    
    "(\\?([^#]*))?" +
    // Match #10 & #11. FRAGMENT (#10 = HASH, в терминах window.location).
    // Например, #10 == "#some-hash", #11 == "some-hash"
    "(#(.*))?" + "$";


* This source code was highlighted with Source Code Highlighter.

Как нетрудно догадаться, этот RegExp будет работать не только в JavaScript, но и в сотне других языков. Пользуйтесь на здоровье! ;)

Попытка №1


function URL(url) {
    url = url || "";
    this.parse(url);
}
URL.prototype = {
    // Если меняем this.href, не забываем вызывать после этого this.parse()
    href: "",
    // Если меняем что-то из следующего, не забываем вызывать после этого this.update()
    protocol: "",
    host: "",
    hostname: "",
    port: "",
    pathname: "",
    search: "",
    hash: "",
    
    parse: function(url) {
        url = url || this.href;
        var pattern = "^(([^:/\\?#]+):)?(//(([^:/\\?#]*)(?::([^/\\?#]*))?))?([^\\?#]*)(\\?([^#]*))?(#(.*))?$";
        var rx = new RegExp(pattern);
        var parts = rx.exec(url);
        
        this.href = parts[0] || "";
        this.protocol = parts[1] || "";
        this.host = parts[4] || "";
        this.hostname = parts[5] || "";
        this.port = parts[6] || "";
        this.pathname = parts[7] || "/";
        this.search = parts[8] || "";
        this.hash = parts[10] || "";
        
        this.update();
    },
    
    update: function() {
        // Плюшка для protocol - если не указан, берём текущий
        if (!this.protocol)
            this.protocol = window.location.protocol;
        
        // Плюшки для relative pathname/URL - если задаём relative, то "добавляется" к текущему
        this.pathname = this.pathname.replace(/^\s*/g, '');
        if (!this.host && this.pathname && !/^\//.test(this.pathname)) {
            // Если честно, это не лучший вариант. Но тут лучше я не придумал.
            var _p = window.location.pathname.split('/');
            _p[_p.length - 1] = this.pathname;
            this.pathname = _p.join('/');
        };
        
        // Плюшка для hostname - если не указан, берём текущий
        if (!this.hostname)
            this.hostname = window.location.hostname;
        
        this.host = this.hostname + (("" + this.port) ? ":" + this.port : "");
        this.href = this.protocol + '//' + this.host + this.pathname + this.search + this.hash;
    },
    
    /**
     * Есть такой метод у window.location. Переход по заданому URL.
     */
    assign: function(url) {
        this.parse(url);
        window.location.assign(this.href);
    },
    
    /**
     * Есть такой метод у window.location. Переход по заданому URL, но без внесения в history
     */
    replace: function(url) {
        this.parse(url);
        window.location.replace(this.href);
    }
}


* This source code was highlighted with Source Code Highlighter.

В деталях


  • Как видим, присутствуют привычные для window.location аттрибуты href, port, hash и т.д.
  • Присутствуют также привычные для window.location методы assign(...), replace(...)
  • Метод parse(...) делает главную работу — парсит URL на составные части.
  • И метод update(...) — обновляет все части, если одна из них была изменена.

Всё бы ничего, но мы обязуем пользователя постоянно вызывать update(...) и parse(...) после изменения любого кусочка URL (например, port). Это ужасно. Ведь пользователь может забыть это сделать, и тогда всё летит в тар-тарары.

К сожалению, в данной реализации от этого не уйти. Но можно ведь всё сделать иначе :)

Попытка №2


А сейчас я предложу уже приемлемый вариант. Нам нужны getters & setters. Самый очевидный путь — для каждого параметра создать методы (н-р) getProtocol() & setProtocol(newProtocol). Но мне такой подход не нравится из-за своей громоздкости.

Сделаем это in more JavaScript way. Будет один метод protocol(...) и если мы вызываем его без параметров, то это getter, а если с одним параметром, — то setter.

Настоящие же данные мы спрячем в замыкании.
var URL;

// Прячем всю реализацию в замыкание. Так надо, т.к. мы прячем служебные функции parseURL и updateURL.
(function() {

URL = function(url) {
    // Собственно, данные. Для каждого нового объекта URL - свои, естественно.
    var href, protocol, host, hostname, port, pathname, search, hash;
    
    // Минус данного подхода - нам приходится определять методы в конструкторе, а не в прототипе.
    // Get/set href - при set вызываем parseURL.call(this),
    // т.е. внешняя функция parseURL обрабатывает объект типа URL - this.
    this.href = function(val) {
        if (typeof val != "undefined") {
            href = val;
            parseURL.call(this);
        }
        return href;
    }
    
    // Get/set protocol
    // Подобно set href, set protocol вызывает updateURL.call(this), который обновляет все параметры.
    this.protocol = function(val) {
        if (typeof val != "undefined") {
            // Плюшка - если protocol не задан, берём из window.location
            if (!val)
                val = protocol || window.location.protocol;
            protocol = val;
            updateURL.call(this);
        }
        return protocol;
    }
    
    // Get/set host
    // Здесь особенность в том, что host, hostname и port - связаны между собой.
    // Поэтому надо делать дополнительную работу при set host.
    this.host = function(val) {
        if (typeof val != "undefined") {
            val = val || '';
            var v = val.split(':');
            var h = v[0], p = v[1] || '';
            host = val;
            hostname = h;
            port = p;
            updateURL.call(this);
        }
        return host;
    }
    
    // Get/set hostname
    // Опять учитываем связку host, hostname и port.
    this.hostname = function(val) {
        if (typeof val != "undefined") {
            if (!val)
                val = hostname || window.location.hostname;
            hostname = val;
            host = val + (("" + port) ? ":" + port : "");
            updateURL.call(this);
        }
        return hostname;
    }
    
    // Get/set port
    // Опять учитываем связку host, hostname и port.
    this.port = function(val) {
        if (typeof val != "undefined") {
            port = val;
            host = hostname + (("" + port) ? ":" + port : "");
            updateURL.call(this);
        }
        return port;
    }
    
    // Get/set pathname
    // С pathname интересно. Я сделал возможность использования
    // relative pathname, т.е. если мы будем set'ить pathname,
    // и новое значение не будет начинаться с '/', то дополнится текущее.
    this.pathname = function(val) {
        if (typeof val != "undefined") {
            if (val.indexOf("/") != 0) { // relative url
                var _p = (pathname || window.location.pathname).split("/");
                _p[_p.length - 1] = val;
                val = _p.join("/");
            }
            pathname = val;
            updateURL.call(this);
        }
        return pathname;
    }
    
    // Get/set search
    this.search = function(val) {
        if (typeof val != "undefined") {
            search = val;
        }
        return search;
    }
    
    // Get/set hash
    this.hash = function(val) {
        if (typeof val != "undefined") {
            hash = val;
        }
        return hash;
    }
    
    url = url || "";
    parseURL.call(this, url);
}

URL.prototype = {
    /**
     * Есть такой метод у window.location. Переход по заданому URL.
     */
    assign: function(url) {
        parseURL.call(this, url);
        window.location.assign(this.href());
    },
    
    /**
     * Есть такой метод у window.location. Переход по заданому URL, но без внесения в history
     */
    replace: function(url) {
        parseURL.call(this, url);
        window.location.replace(this.href());
    }
}

// Служебная функция, которая разбирает URL на кусочки.
// В предидущей реализации эта ф-ция была методом объекта URL.
// Теперь я её вынес, т.к. пользователь больше никогда не будет её вызывать.
function parseURL(url) {
    if (this._innerUse)
        return;
    
    url = url || this.href();
    var pattern = "^(([^:/\\?#]+):)?(//(([^:/\\?#]*)(?::([^/\\?#]*))?))?([^\\?#]*)(\\?([^#]*))?(#(.*))?$";
    var rx = new RegExp(pattern);
    var parts = rx.exec(url);
    
    // Prevent infinite recursion
    this._innerUse = true;
    
    this.href(parts[0] || "");
    this.protocol(parts[1] || "");
    //this.host(parts[4] || "");
    this.hostname(parts[5] || "");
    this.port(parts[6] || "");
    this.pathname(parts[7] || "/");
    this.search(parts[8] || "");
    this.hash(parts[10] || "");
    
    delete this._innerUse;
    
    updateURL.call(this);
}

// Служебная функция, которая обновляет URL при изменении кусочка.
// В предидущей реализации эта ф-ция тоже была методом объекта URL.
// Теперь я её вынес, т.к. пользователь больше никогда не будет её вызывать.
// Заметим, что эта фуекция сильно похудела, её части разошлись по setter'ам.
function updateURL() {
    if (this._innerUse)
        return;
    
    // Prevent infinite recursion
    this._innerUse = true;
    
    this.href(this.protocol() + '//' + this.host() + this.pathname() + this.search() + this.hash());
    
    delete this._innerUse;
}

})()


* This source code was highlighted with Source Code Highlighter.

В целом — код есть self-documented, поэтому объясню лишь ключевые моменты:
  • Прототип оскуднел на 2 метода parse(...) и update(...), которые были вынесены, соответственно, в функции parseURL(...) и updateURL(...)
  • Также из прототипа ушли все данные (href, port, host и т.д.), и поселились в замыкании, созданном конструктором. А работа с ними теперь идёт через getters & setters

Примеры


Ну и сразу к примерам. Ведь главное — посмотреть эту штуку в действии.
// Пусть текущий URL = 'http://my.site.com/somepath/'
var u = new URL('relative/path/index.html')
u.href() // my.site.com/somepath/relative/path/index.html
u.href('/absolute/path.php?a=8#some-hash')
u.href() // my.site.com/absolute/path.php?a=8#some-hash
u.hash() // #some-hash
u.protocol('https:')
u.href() // my.site.com/absolute/path.php?a=8#some-hash
u.host('another.site.com:8080')
u.href() // another.site.com:8080/absolute/path.php?a=8#some-hash
u.port() // 8080
// и так далее, и тому подобное

* This source code was highlighted with Source Code Highlighter.

Вот так. Всё работает.
Вообщем-то это вполне рабочая версия. Назовём её version 1.0 final.
А теперь перейдём к version 2.0 alpha, или в игру вступают tru getter'ы и setter'ы.

Попытка №3


Приведу код, а потом рассмотрю интересные моменты.
var URL;

(function() {
var isIE = window.navigator.userAgent.indexOf('MSIE') != -1;

URL = function(url) {
    var data = {href: '', protocol: '', host: '', hostname: '', port: '', pathname: '', search: '', hash: ''};
    
    var gs = {
        getHref: function() {
            return data.href;
        },
        setHref: function(val) {
            data.href = val;
            parseURL.call(this);
            return data.href;
        },
        
        getProtocol: function() {
            return data.protocol;
        },
        setProtocol: function(val) {
            if (!val)
                val = data.protocol || window.location.protocol; // update || init
            data.protocol = val;
            updateURL.call(this);
            return data.protocol;
        },

        getHost: function() {
            return data.host;
        },
        setHost: function(val) {
            val = val || '';
            var v = val.split(':');
            var h = v[0], p = v[1] || '';
            data.host = val;
            data.hostname = h;
            data.port = p;
            updateURL.call(this);
            return data.host;
        },
        
        getHostname: function() {
            return data.hostname;
        },
        setHostname: function(val) {
            if (!val)
                val = data.hostname || window.location.hostname; // update || init
            data.hostname = val;
            data.host = val + (("" + data.port) ? ":" + data.port : "");
            updateURL.call(this);
            return data.hostname;
        },
        
        getPort: function() {
            return data.port;
        },
        setPort: function(val) {
            data.port = val;
            data.host = data.hostname + (("" + data.port) ? ":" + data.port : "");
            updateURL.call(this);
            return data.port;
        },
        
        getPathname: function() {
            return data.pathname;
        },
        setPathname: function(val) {
            if (val.indexOf("/") != 0) { // relative url
                var _p = (data.pathname || window.location.pathname).split("/");
                _p[_p.length - 1] = val;
                val = _p.join("/");
            }
            data.pathname = val;
            updateURL.call(this);
            return data.pathname;
        },
        
        getSearch: function() {
            return data.search;
        },
        setSearch: function(val) {
            return data.search = val;
        },
        
        getHash: function() {
            return data.hash;
        },
        setHash: function(val) {
            return data.hash = val;
        }
    };

    if (isIE) { // IE5.5+
        var el=document.createElement('div');
        el.style.display='none';
        document.body.appendChild(el);
        el.assign = URL.prototype.assign;
        el.replace = URL.prototype.replace;
        var keys = ["href", "protocol", "host", "hostname", "port", "pathname", "search", "hash"];
        el.onpropertychange=function(){
            var pn = event.propertyName;
            var pv = event.srcElement[event.propertyName];
            if (this._holdOnMSIE || pn == '_holdOnMSIE')
                return pv;
            this._holdOnMSIE = true;
            for (var i = 0, l = keys.length; i < l; i++)
                el[keys[i]] = data[keys[i]];
            this._holdOnMSIE = false;
            for (var i = 0, l = keys.length; i < l; i++) {
                var key = keys[i];
                if (pn == key) {
                    var sKey = 'set' + key.substr(0, 1).toUpperCase() + key.substr(1);
                    return gs[sKey].call(el, pv);
                }
            }
        }
        url = url || "";
        parseURL.call(el, url);
        return el;
    } else if (URL.prototype.__defineSetter__) { // FF
        var keys = ["href", "protocol", "host", "hostname", "port", "pathname", "search", "hash"];
        for (var i = 0, l = keys.length; i < l; i++) {
            (function(i) {
                var key = keys[i];
                var gKey = 'get' + key.substr(0, 1).toUpperCase() + key.substr(1);
                var sKey = 'set' + key.substr(0, 1).toUpperCase() + key.substr(1);
                URL.prototype.__defineGetter__(key, gs[gKey]);
                URL.prototype.__defineSetter__(key, gs[sKey]);
            })(i);
        }
        url = url || "";
        parseURL.call(this, url);
    }
}

URL.prototype = {
    assign: function(url) {
        parseURL.call(this, url);
        window.location.assign(this.href);
    },
    
    replace: function(url) {
        parseURL.call(this, url);
        window.location.replace(this.href);
    }
}

function parseURL(url) {
    if (this._innerUse)
        return;
    
    url = url || this.href;
    var pattern = "^(([^:/\\?#]+):)?(//(([^:/\\?#]*)(?::([^/\\?#]*))?))?([^\\?#]*)(\\?([^#]*))?(#(.*))?$";
    var rx = new RegExp(pattern);
    var parts = rx.exec(url);
    
    // Prevent infinite recursion
    this._innerUse = true;
    
    this.href = parts[0] || "";
    this.protocol = parts[1] || "";
    //this.host = parts[4] || "";
    this.hostname = parts[5] || "";
    this.port = parts[6] || "";
    this.pathname = parts[7] || "/";
    this.search = parts[8] || "";
    this.hash = parts[10] || "";
    
    if (!isIE)
        delete this._innerUse;
    else
        this._innerUse = false;

    updateURL.call(this);
}

function updateURL() {
    if (this._innerUse)
        return;

    // Prevent infinite recursion
    this._innerUse = true;
    
    this.href = this.protocol + '//' + this.host + this.pathname + this.search + this.hash;
    
    if (!isIE)
        delete this._innerUse;
    else
        this._innerUse = false;
}

})()


* This source code was highlighted with Source Code Highlighter.

Рассмотрим создание getter/setter'ов:
  • Случай для Firefox:
    var keys = ["href", "protocol", "host", "hostname", "port", "pathname", "search", "hash"];
    for (var i = 0, l = keys.length; i < l; i++) {
        (function(i) {
            var key = keys[i];
            var gKey = 'get' + key.substr(0, 1).toUpperCase() + key.substr(1);
            var sKey = 'set' + key.substr(0, 1).toUpperCase() + key.substr(1);
            URL.prototype.__defineGetter__(key, gs[gKey]);
            URL.prototype.__defineSetter__(key, gs[sKey]);
        })(i);
    }

    * This source code was highlighted with Source Code Highlighter.

    Используем магические URL.prototype.__defineGetter__ и URL.prototype.__defineSetter__. Вследствии у нас появятся псевдо-аттрибуты url.href, url.path и т.д., изменяя которые на самом деле будут вызываться функции-обработчики.
  • Случай для Internet Explorer: а вот тут начинаются танцы с бубном. Версии IE < 8 вообще не имеют механизмов getter/setter. Однако есть чудесное событие — onpropertchange. Ничего не остаётся, как воспользоваться. Однако возникает осложнение — это событие присутствует только у DOM-элементов, да и то лишь тогда, когда эти элементы уже включены в DOM-модель. Что же, так и поступим:
    var el = document.createElement('div');
    el.style.display = 'none';
    document.body.appendChild(el);
    // ...
    el.onpropertychange = function(){
        var pn = event.propertyName; // имя изменённого параметра
        var pv = event.srcElement[event.propertyName]; // его новое значение
        // ...
    }
    // ...
    return el; // обязательно вернуть el. Т.е. по сути new URL(...)
               // вернёт не объект типа URL, а элемент DIV.
               // Признаюсь, это не очень хорошо, т.к. к примеру
               // теряется связь instanceof. Но что тут поделаешь, это IE, детка :)

    * This source code was highlighted with Source Code Highlighter.


Примеры №2


// Пусть текущий URL = 'http://my.site.com/somepath/'
var u = new URL('relative/path/index.html')
u.href // my.site.com/somepath/relative/path/index.html
u.href = '/absolute/path.php?a=8#some-hash'
u.href // my.site.com/absolute/path.php?a=8#some-hash
u.hash // #some-hash
u.protocol = 'https:'
u.href // my.site.com/absolute/path.php?a=8#some-hash
u.host = 'another.site.com:8080'
u.href // another.site.com:8080/absolute/path.php?a=8#some-hash
u.port // 8080
// и так далее, и тому подобное

* This source code was highlighted with Source Code Highlighter.

Работает в FF3+, IE6+. Можно докрутить для Safari/Chrome. Насчёт Opera — не уверен. Необходимо RTFM.

Вот так


Надеюсь, я сделал что-то полезное и не впустую потратил день своей жизни на написание этой статьи :-)
P.S.: да, я думаю написать отдельную статейку, посвящённую getter'ам и setter'ам в разных браузерах. Не Firefox'ом одним живы (небольшой пи-ар: чтобы не нагружать Habrahabr моим потоком мыслей — милости прошу на мой блог — http://web-by-kott.blogspot.com/. Там пока-что пустынно, но я только начинаю)
Tags:
Hubs:
+114
Comments 81
Comments Comments 81

Articles