JavaScript Advent Calendar 2020の11日目の記事です。
本記事のターゲット
- どうやってJavaScriptのクラスが作られるのかを、試行の脇道も含めて見てみたい方
- URLを弄るクラスを求めて来た方
TL;DR
- URL操作ならURLコンストラクタやURLSearchParamsとかを使ったりするね
- でもどちらもIEでは使えなかったり、URLSearchParamsについてはちょっと使い勝手が悪かったりするよね
- というわけで、イチからURL操作用クラスを作ってみるよ
- IEでも使えるようにするよ(ただし、ページ最後にclass式で書き直したものも載せておくよ)
- URLのパラメータを整形するよ
- インスタンスのString化に対応させるよ
- 相対パス->絶対パス変換メソッドを作るよ
- パラメータのFormData化にも対応させるよ
- 作りながら薀蓄(補足説明)を垂れ流すよ
- 過程に興味の無い人は最後まで飛ばしてね
クラスのガワを用意する
クラス名はUrlにします。クラス外部に幾つか変数を用意したいので、全体を即時関数で囲い、クラスのみグローバルに放り出します。IEにも対応させると書いた通り、書き方はIE準拠です。
**Url.js(折りたたみ)**
(function(global){
    function E(s) {//エラー用
        var e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }
    function Url(url) {
        // クラス
    }
    // メソッドを追加する場所
    Url.prototype['aaa'] = function() {};
    // 作ったクラスをグローバル化
    global.Url = Url;
})(this);
注: 要所要所に「現在のUrl.jsの形」を上記のように折りたたんで置きます。
URLを解析する
解析するにはまず、URLの構造を把握すべし!
こんなURLがあるとします。
http://uname:pswrd@abc.com:1234/a/b/c/sample.html?p=1&t=text#title
URLの各値の説明
プロトコル(http:)
URLスキームの区分の一つで、一般的にはwebページのURLなどに使われるものを指します。
URLスキームといえば、よく目にするものとして、webページのURLなどに使われるプロトコルであるところのhttp:やhttps:、ftp:などや、CSSファイルへの画像埋め込みなどに使われるdata:、URL.createObjectURL()で生成されるblob:、ローカルファイルにアクセスするためのfile:、Google Chromeの各種設定用のページなどを表すchrome:などがありますよね。しかしこれらは氷山の一角、どころか一欠片に過ぎず、恐らくは皆さんの想像するよりもはるかに大量の種類のURLスキームが存在します。それぞれの違いによって、通信の種類やアクセスする場所の違いがあったり、あるいは特に意味があったりなかったり。
ユーザー名&パスワード(uname:pswrd@)
ユーザー名(uname)とパスワード(pswrd)です。//の後に、そして後述するホスト名の前に置かれます。ベーシック認証において、一々ユーザー名とパスワードを入力しなくても自動でログインできるようにするためのものです。セキュリティ上の観点から、Firefox以外のほぼ全てのブラウザ(IEも!)の最新バージョンでは無効になっている(無視される)ことが殆どで、古代のプライベートなRSSフィードなどで発行されたURLに極稀に見ることのできる絶滅危惧種、近絶滅種です。言うまでもないことですが、あっても無意味なことが殆どなので、大抵は省略されています。本当は無視してやりたいのですが、仮にこれのあるURLを解析した場合、後述のホスト名がしっちゃかめっちゃかになってしまうので、泣く泣く考慮します。
因みに、絶滅危惧種云々はあくまでwebページのURL上においてのもの。それに限らないのであれば、MySQLなどのデータベースに接続するURLなどで頻繁に見ることはできます。
ホスト名(abc.com)
webページのURLであれば、そのページが属しているサイトの名前を表す部分です。ドメインとも言います。そう、「クロスドメイン」のドメインです。
ポート番号(:1234)
言うなれば、送受信口の区分け、でしょうか。まあ、この記事では「そんなものがあるんだな」程度の理解で十分です。ごく一般的なwebページのURLでは不要なことが殆どなので、大抵は省略されています。
オリジン(http://abc.com:1234)
プロトコル+//+ホスト名+ポート番号で表される値です。ポート番号の部分は、特に明示されていない場合は、省略可能です。webページのURLであれば、そのページの属しているサイトそのもの、ページリソースの源泉を表す部分です。「オリジン間リソース共有(CORS; Cross-Origin Resource Sharing)」のオリジンのことでもあります。
複数の値を組み合わせて作られるものなので、今回のクラスではこれは考慮しません。個人で実装したいという方は、プロパティではなくゲッターなどで取得する、セッターで設定する方法を取ると良いです。ゲッターについてはまた後ほど説明しますので、そちらを参照してください。
パス(/a/b/c/sample.html)
webページのURLであれば、そのオリジン上におけるファイルの在り処を表す部分です。相対パスのURLの場合、プロトコルやホストなど、これより前に位置している値は省略されている可能性があります。
パラメータ(?p=1&t=text)
URLへの付加情報の一つで、サーバー側が受け取ることになる値です。IE未サポートのURLコンストラクタで解析するときはsearchというキーに格納されます。パスの後に?を付け、キー+=+値の形式で付け加えていきます。複数のパラメータを付けたい場合は、例示にあるように&で区切ります。その他細々としたルールがあるのですが、そのルールはサーバー側で使っているプログラミング言語やライブラリの仕様によってまちまちです。詳細は後述します。
ハッシュ(#title)
URLへの付加情報の一つで、ユーザー側のアクセシビリティの向上のために存在するものです。ページ上のアンカーへ飛ぶためのものですね。
URLを正規表現で解析
正規表現を用意
サンプル文字列(ここでは例示したURLを簡略化したもの)を用意しましょう。
http://uname:pswrd@ホスト名:1234/パス?p=1#title
これをRegExpコンストラクタに放り込み、予約記号をエスケープしてみましょう。
var reg = new RegExp('http:\/\/uname:pswrd@ホスト名:1234\/パス\?p=1#title');
文字列を参考にメタ文字などに置き換え、上に挙げた、オリジン以外のURLの各値ごとにグループ分けしましょう。省略可能とされる値(基本的にはパス以外)はキャプチャ無効化グループ(?:)を駆使しましょう。
var reg = new RegExp(
    '^'
    // (protocol)
    +'(?:([^:\\s]+:)|)'
    // (username), (password), (host), (port)
    +'(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)'
    // path
    +'(\\/?[^\\?#]*)'
    // (search)
    +'(?:\\?([^\\?#]*)|)'
    // (hash)
    +'(?:#(.*)|)'
    +'$'
  ,'i');
// 全部繋げて…
var reg = new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i');
出来ました!
では、この解析手法をクラスに実装してみましょう。
**Url.js(折りたたみ)**
(function(global){
    var REG = {
        URL: {
            R: new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i'),
            G: ['protocol','username','password','host','port','path','search','hash']
        }
    };
    function E(s) {
        var e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }
    function Url(url) {
        var s = this, u;
        (u = (url ? String(url) : '').match(REG.URL.R))
            ? (REG.URL.G.forEach(function(g,i){
                s[g] = u[i+1]
                })
            )
            : E('Invalid URL: "' + url + '"');
    }
    Url.prototype['aaa'] = function() {};
    global.Url = Url;
})(this);
実際に動かしてみると、
console.log(
    new Url('http://uname:pswrd@abc.com:1234/a/b/c/sample.html?p=1&t=text#title')
);
/*
{
    "protocol": "http:",
    "username": "uname",
    "password": "pswrd",
    "host": "abc.com",
    "port": "1234",
    "path": "/a/b/c/sample.html",
    "search": "p=1&t=text",
    "hash": "title"
}
*/
大丈夫そうですね。ちなみに各値に該当する部分が無かった場合、pathは""、それ以外はundefinedになるはずです。
しかし実際には""なパスは存在しませんので、""の時には"/"にしておきましょう。
**Url.js(折りたたみ)**
(function(global){
    var REG = {
        URL: {
            R: new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i'),
            G: ['protocol','username','password','host','port','path','search','hash']
        }
    };
    function E(s) {
        var e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }
    function Url(url) {
        var s = this, u;
        (u = (url ? String(url) : '').match(REG.URL.R))
            ? (REG.URL.G.forEach(function(g,i){
                s[g] = u[i+1]
                }),
                s.path = s.path || '/',// 追加分
            )
            : E('Invalid URL: "' + url + '"');
    }
    Url.prototype['aaa'] = function() {};
    global.Url = Url;
})(this);
では次です。
URLパラメータ(search)を辞書型に変換
p=1&t=textのように登録されたsearchを{p: "1", t: "text"}のように変えましょう、という項です。そこまで難しくはなさそうですね。
しかし、ここで問題が発生します。
皆さんに質問です。
URLパラメータでサーバーに「配列」を、{ary: [1, 2, 3]}を送信する場合、皆さんはどのように書きますか?
こうですか?
?ary=1,2,3
それとも?
?ary=1&ary=2&ary=3
はたまた…?
?ary[0]=1&ary[1]=2&ary[2]=3
どうでしょう、皆さんはどれが正解か分かりますか?
…
…
…
…
…
…
残念ながら、特定の正解はありません。環境によってはそのいずれもが正解になり得ますし、はたまた不正解にもなり得ます。HTML4のW3CのドキュメントにはFormDataに(つまりパラメータに)配列を渡す(同じキーで複数の値を渡す)方法についての言及が無く、しかし当時から既に一定の需要があったために、様々な開発者が好き勝手にバラバラな仕様を実装してしまったためです。HTML5にてフォーム部品のmultiple属性が解禁されたことで、HTML5のW3Cのドキュメントにてようやく配列渡しの方法(?ary=1&ary=2&ary=3)が言及されるようになりました。しかし時すでに遅しというやつなのでしょう。広まってしまった多種多様な仕様が変わることはありませんでした。
**環境別の配列登録方法の例(折りたたみ)**
Wordpress
?ary=1,2,3
Angular.js
?ary=1&ary=2&ary=3
PHP, Python (urllib), Rails
?ary[]=1&ary[]=2&ary[]=3
Node.js
?ary=1&ary=2&ary=3
あるいは
?ary[]=1&ary[]=2&ary[]=3
ただし中身が一つだけの配列を送りたい場合は後者を使わなければならないので、基本的にはブラケットを使用している後者がスタンダードです。
ASP.NET MVC
?ary=1&ary=2&ary=3
あるいは
?ary[0]=1&ary[1]=2&ary[2]=3
後者の場合、PHPやNode.jsとは違ってブラケット内にインデックスをマストで書き入れなければなりません(PHPやNode.jsなどでは、インデックスの書き込みは任意です。HTMLのフォームに配慮し、書かないことが殆どですが。)。Node.jsと同様の理由で後者がスタンダードです。
参考: Stack Overflow - How to pass an array within a query string?
注: 上記の例はあくまで基本的・一般的な、あるいは筆者が普段使用している機能を用いた場合の例示であり、サーバー側のプログラミング言語上で使用されているライブラリなどによってはこの限りではありません。「私の知っているソレと違う!」なんてこともあるかもしれませんが、ここでは例示にあるような配列登録方法の種類があることだけ理解していただければ幸いです。
「へえ、環境によって違うんだね」と思うだけかもしれませんが、事はそう単純ではありません。例えば
?ary=1&ary=2&ary=3
こちらのパラメータ。上記に挙げた一部の環境では、これは{ary: ["1", "2", "3"]}と同義ですが、この配列登録の形式をサポートしていない環境の場合、これは{ary: "3"}と認識されてしまいます(同じキーのパラメータがあった場合、最後のもののみ適用される)。
この頭の痛くなる問題から逃れるため、例えばもしあなたのサーバーで動いているスクリプトがPHPなら、?ary[]=1&ary[]=2&ary[]=3の形式のみを対応したものを作るのは理にかなった選択肢でしょう。しかし今回は、特定の環境だけに特化しない、全ての形式に対応したクラスを作りたいと思います。
ではまず、Urlクラスの第2引数にオプションを取りましょう。
**Url.js(折りたたみ)**
(function(global){
    var REG = {
        URL: {
            R: new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i'),
            G: ['protocol','username','password','host','port','path','search','hash']
        }
    };
    var PARAM = {
        BASE: function(s) {// [['p', '1'], ['t', 'text']]
            return (s || '').split('&').filter(function(v){return v}).map(function(v){return (v = v.split('='), v[0] = decodeURI(v[0]).replace(/\+/g, ' '), v)})
        },
        ARRAY_TYPE: {
            'comma': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = v[1].split(',').map(function(p){return decodeURI(p).replace(/\+/g, ' ')});
                        o[v[0]] = v[1].length - 1 ? v[1] : v[1][0];
                    });
                    return o;
                }
            },
            'sepalate': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        o[v[0]] = o[v[0]]
                            ? (function(p, a){
                                return ((a = Array.isArray(p) ? p : [p]).push(v[1]), a);
                            })(o[v[0]])
                            : v[1]
                    });
                    return o;
                }
            },
            'with_bracket': {
                PARSE: function(s, i) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        var m;
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        (m=v[0].match(new RegExp(i ? '^(.+?)\\[(\\d+?)\\]$' : '^(.+?)\\[\\]$')))
                            ? (o[m[1]] = (function(p, a){
                                return (a = (p==void(0)) ? [] : (Array.isArray(p) ? p : [p]), (m[2] ? (a[Number(m[2])] = v[1]) : a.push(v[1])), a);
                            })(o[m[1]]))
                            : (o[v[0]] = v[1])
                    });
                    return o;
                }
            },
        }
    }
    function E(s) {
        var e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }
    // IEだと初期値設定ができない!! function Url(url, {arrayType='with_bracket', indexed=false}={}) {
    function Url(url, option) {
        var s = this, u, arrayType = (option && option.arrayType) || 'with_bracket', indexed = (option && option.indexed) || false;
        (u = (url ? String(url) : '').match(REG.URL.R))
            ? (REG.URL.G.forEach(function(g,i){
                s[g] = u[i+1]
                }),
                s.path = s.path || '/',
                s.search = (function(t){
                    return t ? t.PARSE(s.search, indexed) : (E('Invalid arrayType: "' + arrayType + '"'), void(0))
                })(PARAM.ARRAY_TYPE[arrayType]),
                s._parseStyle = {array: {type: arrayType, indexed: indexed}}
            )
            : E('Invalid URL: "' + url + '"');
    }
    Url.prototype['aaa'] = function() {};
    global.Url = Url;
})(this);
| arrayType | indexed | 例 | |
|---|---|---|---|
| comma | false | ?ary=1,2 | |
| sepalate | false | ?ary=1&ary=2 | |
| with_bracket | false | ?ary[]=1&ary[]=2 | ★ | 
| with_bracket | true | ?ary[0]=1&ary[1]=2 | |
| (太字が初期値です) | 
console.log(
    new Url('http://uname:pswrd@abc.com:1234/a/b/c/sample.html?p=1&t=text&a[]=1&a[]=2#title').search
)
// {p: "1", t: "text", a: ["1", "2"]}
console.log(
    new Url('http://uname:pswrd@abc.com:1234/a/b/c/sample.html?p=1&t=text&a[0]=1&a[1]=2#title', {
        indexed: true
    }).search
)
// {p: "1", t: "text", a: ["1", "2"]}
console.log(
    new Url('http://uname:pswrd@abc.com:1234/a/b/c/sample.html?p=1&t=text&a=1&a=2#title', {
        arrayType: 'sepalate'
    }).search
)
// {p: "1", t: "text", a: ["1", "2"]}
console.log(
    new Url('http://uname:pswrd@abc.com:1234/a/b/c/sample.html?p=1&t=text&a=1,2#title', {
        arrayType: 'comma'
    }).search
)
// {p: "1", t: "text", a: ["1", "2"]}
できましたね!
「『配列』のパラメータの登録は実装したのに、なぜ『辞書型』のパラメータの登録は実装しないの?」
PHPでは、
?t=text&obj[a][b]=1のようなパラメータも許容されるそうです。この例の場合、{t: 'text', obj: {a: {b: 1}}}というパラメータを送るという意味になります。しかし本クラスではこのような登録方法を実装しません。それはなぜか。URLパラメータとは元来、FormDataを送信するに際しての一形態であるためです。配列であれば、
<form> 
  <select multiple="multiple" name="favorite_fruit[]"> 
    <option>apple</option> 
    <option>banana</option> 
    <option>orange</option> 
  </select>
</form>
HTML5にてこのような形のフォームで送信することになる可能性はあります。
しかしながら、辞書型オブジェクトの形式を、フォームが取ることはありません。ネストするようなキーの配置をする必要性はありませんし、不可能です。どうしても辞書型の形で送りたいのであれば、
obj=%7B%22a%22:%7B%22b%22:1%7D%7Dのように辞書型を文字列に書き換えてエスケープして送り、サーバー側でJSONやDictionalyなどの型などにパースするべきです。よって、今回は考慮しません。
インスタンスのString化を実装する
ここでいうString化の実装とは、UrlインスタンスのString型への変換メソッドを用意するということです。
String()で包んでみたり、あるいはString型の別の値と結合してみたり…
console.log(
    String(new Url('http://example.com/'))
)
// [object Object]
console.log(
    '' + new Url('http://example.com/')
)
// [object Object]
console.log(
    new Url('http://example.com/').toString()
)
// [object Object]
このようなObject型からString型への型変換が行われるとき、内部的には、そのオブジェクトのprototype上にあるtoStringメソッドが間接的に呼び出されます(3つ目の例では直接呼び出していますがね…)。現状ではUrlインスタンスをString化しても、"[object Object]"という何の役にも立たない文字列に変わってしまいますが、前述のtoStringメソッドを上書きしてやれば、代わりにURLの文字列を出力させることができるようになります。
それはさておき、toStringメソッドで値を返却するため、解析して得られたURLの各値から再びURL文字列を復元してみましょう。
searchは辞書型オブジェクトですから、Object.keysで配列に加工し、その後joinで直結すると良いですね。ここでもURLパラメータの配列とその登録方法ごとに処理を変える必要があることに留意してください。
// o: {p: "1", t: "text", a: ["1", "2"]}
// arrayType: commaの場合
Object.keys(o).map(function(k) {
    return k + '=' + Array.isArray(o[k])
        ? o[k].join(',')
        : o[k]
})
ただしこのString化、URL化をする際には、一部エスケープが必要な文字もあります。encodeURIやencodeURIComponentなどを使用してエスケープも行いましょう。またパラメータ内では、例えエスケープをしようとも、半角スペースは使えないそうです。…ホントかな。まあ、そういうことにしておきましょう。ともかく、半角スペースは代わりに+に置き換えられます。半角スペースはエスケープをすると%20になりますから、最後にこれを+に置き換えましょう。
何にせよ、書き足してみましょうか。
**Url.js(折りたたみ)**
(function(global){
    var REG = {
        URL: {
            R: new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i'),
            G: ['protocol','username','password','host','port','path','search','hash']
        }
    };
    var PARAM = {
        BASE: function(s) {// [['p', '1'], ['t', 'text']]
            return (s || '').split('&').filter(function(v){return v}).map(function(v){return (v = v.split('='), v[0] = decodeURI(v[0]).replace(/\+/g, ' '), v)})
        },
        ARRAY_TYPE: {
            'comma': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = v[1].split(',').map(function(p){return decodeURI(p).replace(/\+/g, ' ')});
                        o[v[0]] = v[1].length - 1 ? v[1] : v[1][0];
                    });
                    return o;
                },
                TO_STR: function(o) {
                    return Object.keys(o).map(function(k) {
                        return (k + '=' + Array.isArray(o[k])
                            ? o[k].map(function(v){
                                return encodeURIComponent(v)
                                }).join(',')
                            : encodeURI(o[k]))
                    }).join('&')
                }
            },
            'sepalate': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        o[v[0]] = o[v[0]]
                            ? (function(p, a){
                                return ((a = Array.isArray(p) ? p : [p]).push(v[1]), a);
                            })(o[v[0]])
                            : v[1]
                    });
                    return o;
                },
                TO_STR: function(o) {
                    return Object.keys(o).map(function(k) {
                        return (Array.isArray(o[k])
                            ? o[k].map(function(v){
                                return k + '=' + encodeURI(v)
                                })
                            : k + '=' + encodeURI(o[k]))
                    }).reduce(function(a,v){
                        return a.concat(v)
                    },[]).join('&')
                }
            },
            'with_bracket': {
                PARSE: function(s, i) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        var m;
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        (m=v[0].match(new RegExp(i ? '^(.+?)\\[(\\d+?)\\]$' : '^(.+?)\\[\\]$')))
                            ? (o[m[1]] = (function(p, a){
                                return (a = (p==void(0)) ? [] : (Array.isArray(p) ? p : [p]), (m[2] ? (a[Number(m[2])] = v[1]) : a.push(v[1])), a);
                            })(o[m[1]]))
                            : (o[v[0]] = v[1])
                    });
                    return o;
                },
                TO_STR: function(o, i) {
                    return Object.keys(o).map(function(k) {
                        return (Array.isArray(o[k])
                            ? o[k].map(function(v,_){
                                return k + '[' + (i ? _ : '') + ']' + '=' + encodeURI(v)
                                })
                            : k + '=' + encodeURI(o[k]))
                    }).reduce(function(a,v){
                        return a.concat(v)
                    },[]).join('&')
                }
            },
        }
    }
    function E(s) {
        var e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }
    function Url(url, option) {
        var s = this, u, arrayType = (option && option.arrayType) || 'with_bracket', indexed = (option && option.indexed) || false;
        (u = (url ? String(url) : '').match(REG.URL.R))
            ? (REG.URL.G.forEach(function(g,i){
                s[g] = u[i+1]
                }),
                s.path = s.path || '/',
                s.search = (function(t){
                    return t ? t.PARSE(s.search, indexed) : (E('Invalid arrayType: "' + arrayType + '"'), void(0))
                })(PARAM.ARRAY_TYPE[arrayType]),
                s._parseStyle = {array: {type: arrayType, indexed: indexed}}
            )
            : E('Invalid URL: "' + url + '"');
    }
    Url.prototype.toString = function() {
        var s = this, a = s._parseStyle.array;
        return (s.protocol && s.host
            ? s.protocol + '//' + (s.username && s.password
                ? s.username + ':' + s.password + '@'
                : '')
                + s.host + (s.port ? ':' + s.port : '')
            : ''
        )
        + s.path
        + (Object.keys(s.search).length
            ? '?' + (function(t) {
                return t ? t.TO_STR(s.search, a.indexed).replace(/%20/g,'+') : (E('Invalid arrayType: "' + a.arrayType + '"'), '') 
                })(PARAM.ARRAY_TYPE[a.arrayType])
            : ''
        )
        + (s.hash ? '#' + s.hash : '')
    }
    global.Url = Url;
})(this);
var u = new Url('http://example.com/#x');
u.search['a'] = [1, 2];
console.log('' + u);
// http://example.com/?a[]=1&a[]=2#x
できましたね!
…さて、こんなメソッドを作って何か意味があるのかと思われる方もいらっしゃるかもしれませんね。実は、大きなメリットがあるんです。
JavaScriptには「暗黙的な型変換」と呼ばれるものがあります。システム的に本来String型あるいはNumber型(他言語で言うところのInt型)の代入を望まれる場所に全く違う型のオブジェクトが代入された時、その値をString型の場合はtoStringメソッドを、Number型の場合はvalueOfメソッドを自動で呼び出して変換してくれるんです。これはつまり、toStringメソッドが実装されることによって、URLの文字列(String型)を要求するような組み込み関数であるXMLHttpRequestやfetchの引数に対して、Urlインスタンスを直接代入できるようになるということなのです。
var xhr = new XMLHttpRequest;
xhr.addEventListener('load', e=>console.log(e.responseText));
xhr.open('GET', new Url('http://example.com/'));
xhr.send();
fetch(new Url('http://example.com/'))
    .then(v=>v.text())
    .then(v=>console.log(v))
豆知識: 論理演算子(
&&・||)の使い方論理演算子は条件式(
n >= 100 && n % 2 == 0のような)に使われるだけのものという認識があるかもしれませんが、所謂if式と同じような扱い方も可能です。次のようなスクリプトがあったとします。
var a = true, b = false;
a && alert('when "a" is true.');// 1
b || alert('when "b" is false.');// 2
(a && b) && alert('when both of "a" and "b" are true.');// 3
これは実質的に次のスクリプトと同義です。
var a = true, b = false;
if(a) {
    alert('when "a" is true.');// 1
}
if(!b) {
    alert('when "b" is false.');// 2
}
if(a && b) {
    alert('when both of "a" and "b" are true.');// 3
}
AND (
x && f())の場合、左側がtrueであれば、右側も同様にtrueであることを期待して、右側の式の評価(解析)が開始されます。しかし左側がfalseであった場合、x && f()はその時点で真になり得ないことが確定するため、右側の式の評価は開始されないのです。そしてOR (
x || f())の場合、左側がfalseであれば、右側はtrueであることを期待して、右側の式の評価(解析)が開始されます。しかし左側がtrueであった場合、x || f()は既に真であることが確定するため、右側の式の評価は開始されないのです。
この手法は
if単体のみを用いるような分岐の際には有効ですが、elseやelse ifなどの分岐をさせたい場合は、三項演算子を用いましょう。
var a = true;
a ? alert('when "a" is true.') : console.log('when "a" is false.');
相対パス->絶対パス変換メソッドを作る
相対パスを絶対パスに変換するにはまず、相対パスについて把握すべし!
http://example.com
├── branch
│   ├── static
│   │   ├── main.html (http://example.com/branch/static/main.html)
│   │   ├── main.css
│   │   └── main.js
│   └── image
│       ├── logo16.png
│       └── logo32.png
│ 
└── config
    └── config.json
相対パスとは、同じオリジン上に存在する別のファイルの在り処をあらわす際に用いられる、座標の短縮記法とそれを用いたパスのことです。main.htmlからlogo16.pngという画像ファイルにアクセスする場合、短縮記法を使えば、このファイルの座標は../image/logo16.pngあるいは/branch/image/logo16.pngとあらわすことができます。main.htmlからmain.cssというCSSファイルにアクセスする場合は、/branch/static/main.css、./main.cssあるいはmain.cssとなります。…複数通りがあるようですね。ええ、実はこの短縮記法、種類があります。
相対パスの種類
①オリジン(源泉)からの直接参照
/branch/image/logo16.png
実際のところ、これは相対パスではなく単なる短縮記法に過ぎませんが、面倒なので相対パスということにしてしまいましょう。先頭が/となっていることと、現在位置がどこであろうとも変わることの無い固有性(この時点で全然「相対」じゃない!)が特徴です。http://example.comというオリジン(源泉)から見て、どこにlogo16.pngが存在するのかをあらわします。
ここまでで作ったUrlで解析した際にpathに登録されるものも、これと同じ形式です。
②現在位置からの遡及参照
../image/logo16.png
先程とは打って変わってややこしく、そして正しく相対パスであるのがこちら。
- 「共通するディレクトリまで遡り」
- 「そこから目的のファイルまで参照」
するのがこの方法です。さて、この../は一体何でしょう。…これは「自分が所属しているディレクトリの一つ上のディレクトリを参照せよ」という意味です。1.の「遡り」のために定められたルールですね。
この例の場合、まず「../でmain.htmlの所属しているstaticディレクトリの一つ上、branchディレクトリを参照」します。このディレクトリの直下には、お目当てのファイルであるlogo16.pngの存在する祖先(親)ディレクトリimageがありますね。であれば遡るのはここまで、次は2.、「image/logo16.pngと参照する」のです。
../は複数重ねることもでき、例えばmain.htmlからconfig.jsonを参照するのであれば、../../config/config.jsonのような形になります。
そしてもう一つ、この短縮記法には./というものもあります。これは「自分が所属しているディレクトリと同じディレクトリを参照せよ」というものです。こういった相対パスを書く際にはあまり使う必要の無いもので、単に自分が所属しているディレクトリそのものをあらわすことぐらいにしか本来は用途がありません。が、だからといって相対パスにこれが使われないわけではありません。main.htmlから見た時、main.cssは同じディレクトリにあるため、./main.cssと参照することができます。
しかしながらこれは儀礼的なもので、同じディレクトリ上にある場合は1.の「遡り」の必要がなく、そのまま2.の参照を行えます。ですので単にmain.cssと書けば、同じディレクトリにあるmain.cssにアクセスできます。…まあ、これだとファイル名なのかパスなのか分かりにくいですよね。なので「儀礼的」に、「これはパスである」と明示するために、これを使うのです。あっても無くても意味は変わらない…、幾つ./があろうとも無意味…、そう、これについては無視できるのです。
では、ここまでに述べた「お約束」を元に、Url.jsに相対パス->絶対パス変換メソッドを書いてみましょう。
メソッド名はresolve、引数に相対パスを入力することで、インスタンス内の各値をその相対パスに準じた情報に書き換えさせます。返り値はthisとするので、そのままString化してfetchなどに放り込むもよし、あるいは他のメソッドとチェーンさせる起点にするもよし。
では下準備です。上記のルールを参考に、ざっくり処理を書いてみましょう。"/"で相対パスをsplitすれば、各ディレクトリ階層名と".."、"."、""の配列を作り出せます(""はパスの先頭の"/"のこと;同じディレクトリを指す"."は不要なので切り捨て)。これを相互比較し、pop(配列末尾から一つ切り出し)とshift(配列先頭以下略)を駆使し、共通ディレクトリを洗い出しましょう。
var base_path = '/a/b/c/sample.html';
var result_path;
var rel_path = '../x.php';
// var rel_path = '/a/b/x.php';
// var rel_path = '.././x.php';
var bs = base_path.split('/');
bs.pop();
var sp = rel_path.split('/').filter(function(v) {return v !== '.'});
// bs: ["", "a", "b", "c"]
// sp: ["..", "x.php"] or ["", "a", "b", "x.php"] or ["..", "x.php"]
sp[0] == ''
    ? (result_path = rel_path)
    : (function() {
        var v;
        while((v = sp.shift())) {
            v == '..'
                ? bs.pop() || console.log('Invalid relative path')// 限界以上に遡った時用;実装の際には処理を止めるため、エラーをthrowさせる
                : bs.push(v)
        }
        result_path = bs.join('/')
    })()
console.log(result_path);
// /a/b/x.php
いい具合ですね。
ただ、rel_pathに入力されるものは、文字通り相対パスでなければなりません。しかし入力されたパスが正規の相対パスかどうかを確認していないため、このままでは予期せぬエラーを引き起こすかもしれません。そこで、ここまでで作ったUrlコンストラクタ自身の出番です。rel_pathをこれで解析し、hostがundefinedであることを確認すれば、相対パスのみを受け付けることができます。それにパスとそれ以外の分離もできますし、付加されているハッシュやパラメータも解析できます。
同様に、基になるURLは絶対パスのURL(プロトコルやホスト名のあるURL)のみに限定しておいた方が、胃のキリキリに悩まされずに済みます。基になるURLが、/a.htmlみたいなURLならば問題はないのですが、何かの間違いで../a.htmlみたいなものが使われていると、もうどうにもならなくなってしまいますからね。よって、インスタンスのthis.hostがundefinedの時にも弾いてしまうのが吉です。これに不満のある方は、是非ご自身でこの条件分岐地獄の苦しみと、「これ以外に想定していないケースは無いか?」という連関する自問自答による圧倒的強迫観念の二つを味わっていただきたく思います。
というわけで、これを少し書き換え、組み込みます。
**Url.js(折りたたみ)**
(function(global){
    var REG = {
        URL: {
            R: new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i'),
            G: ['protocol','username','password','host','port','path','search','hash']
        }
    };
    var PARAM = {
        BASE: function(s) {// [['p', '1'], ['t', 'text']]
            return (s || '').split('&').filter(function(v){return v}).map(function(v){return (v = v.split('='), v[0] = decodeURI(v[0]).replace(/\+/g, ' '), v)})
        },
        ARRAY_TYPE: {
            'comma': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = v[1].split(',').map(function(p){return decodeURI(p).replace(/\+/g, ' ')});
                        o[v[0]] = v[1].length - 1 ? v[1] : v[1][0];
                    });
                    return o;
                },
                TO_STR: function(o) {
                    return Object.keys(o).map(function(k) {
                        return (k + '=' + Array.isArray(o[k])
                            ? o[k].map(function(v){
                                return encodeURIComponent(v)
                                }).join(',')
                            : encodeURI(o[k]))
                    }).join('&')
                }
            },
            'sepalate': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        o[v[0]] = o[v[0]]
                            ? (function(p, a){
                                return ((a = Array.isArray(p) ? p : [p]).push(v[1]), a);
                            })(o[v[0]])
                            : v[1]
                    });
                    return o;
                },
                TO_STR: function(o) {
                    return Object.keys(o).map(function(k) {
                        return (Array.isArray(o[k])
                            ? o[k].map(function(v){
                                return k + '=' + encodeURI(v)
                                })
                            : k + '=' + encodeURI(o[k]))
                    }).reduce(function(a,v){
                        return a.concat(v)
                    },[]).join('&')
                }
            },
            'with_bracket': {
                PARSE: function(s, i) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        var m;
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        (m=v[0].match(new RegExp(i ? '^(.+?)\\[(\\d+?)\\]$' : '^(.+?)\\[\\]$')))
                            ? (o[m[1]] = (function(p, a){
                                return (a = (p==void(0)) ? [] : (Array.isArray(p) ? p : [p]), (m[2] ? (a[Number(m[2])] = v[1]) : a.push(v[1])), a);
                            })(o[m[1]]))
                            : (o[v[0]] = v[1])
                    });
                    return o;
                },
                TO_STR: function(o, i) {
                    return Object.keys(o).map(function(k) {
                        return (Array.isArray(o[k])
                            ? o[k].map(function(v,_){
                                return k + '[' + (i ? _ : '') + ']' + '=' + encodeURI(v)
                                })
                            : k + '=' + encodeURI(o[k]))
                    }).reduce(function(a,v){
                        return a.concat(v)
                    },[]).join('&')
                }
            },
        }
    }
    function E(s) {
        var e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }
    function Url(url, option) {
        var s = this, u, arrayType = (option && option.arrayType) || 'with_bracket', indexed = (option && option.indexed) || false;
        (u = (url ? String(url) : '').match(REG.URL.R))
            ? (REG.URL.G.forEach(function(g,i){
                s[g] = u[i+1]
                }),
                s.path = s.path || '/',
                s.search = (function(t){
                    return t ? t.PARSE(s.search, indexed) : (E('Invalid arrayType: "' + arrayType + '"'), void(0))
                })(PARAM.ARRAY_TYPE[arrayType]),
                s._parseStyle = {array: {arrayType: arrayType, indexed: indexed}}
            )
            : E('Invalid URL: "' + url + '"');
    }
    Url.prototype.toString = function() {
        var s = this, a = s._parseStyle.array;
        return (s.protocol && s.host
            ? s.protocol + '//' + (s.username && s.password
                ? s.username + ':' + s.password + '@'
                : '')
                + s.host + (s.port ? ':' + s.port : '')
            : ''
        )
        + s.path
        + (Object.keys(s.search).length
            ? '?' + (function(t) {
                return t ? t.TO_STR(s.search, a.indexed).replace(/%20/g,'+') : (E('Invalid arrayType: "' + a.arrayType + '"'), '') 
                })(PARAM.ARRAY_TYPE[a.arrayType])
            : ''
        )
        + (s.hash ? '#' + s.hash : '')
    }
    Url.prototype.resolve = function(rel_path) {
        var rs, bs = this.path.split('/'), rl = new Url(rel_path, this._parseStyle.array), sp = rl.path.split('/').filter(function(v) {return v !== '.'});
        return((this.host || E('Base URL is not a absolute path: "' + this + '"')), (rl.host && E('"' + rel_path + '" is not a relative path')), bs.pop(), sp[0] == ''
            ? (rs = rl.path)
            : (function(v) {
                while((v = sp.shift())) {
                    v == '..'
                        ? bs.pop() || E('Invalid relative path: "' + rel_path + '"')
                        : bs.push(v)
                }
                rs = bs.join('/')
            })(),
        this.path = rs,
        this.search = rl.search,
        this.hash = rl.hash,
        this)
    }
    global.Url = Url;
})(this);
console.log(
    new Url('http://uname:pswrd@abc.com:1234/a/b/c/sample.html', {indexed: true})
        .resolve('../x.php?a[0]=1&a[1]=2#title')
)
// {
//     "protocol": "http:",
//     "username": "uname",
//     "password": "pswrd",
//     "host": "abc.com",
//     "port": "1234",
//     "path": "/a/b/x.php",
//     "search": {
//         "a": [
//             "1",
//             "2"
//         ]
//     },
//     "hash": "title",
//     "_parseStyle": {
//         "array": {
//             "arrayType": "with_bracket",
//             "indexed": true
//         }
//     }
// }
いい感じですね!
ここらで肝心のIEの動作確認をしてみましょう。
最高に使いづらいコンソールでイライラしますが、キチンと動いていることを確認できます。
豆知識: 丸括弧とカンマの使い方
次のようなスクリプトがあったとします。
var a, b;
console.log(
    (a = [1, 2, 3], b = ['x', 'y', 'z'], a = a.concat(b), a.pop(), a)
)
この場合コンソールにログとして出力される値は
aの中身、つまり[1, 2, 3, "x", "y"]だけとなります。括弧内で処理がカンマに区切られている場合、その括弧があらわす値は最後の処理のリザルトのみです。もちろん途中のb = ['x', 'y', 'z']などの処理は実行されていますが、出力はされません。この手法は通常、コードの圧縮などに使われます。この項で実装した
resolveメソッドにもこの手法が用いられています。そんなコードの短縮に役立つ丸括弧ですが、実は幾つか制限があります。丸括弧内では
varなどの宣言子やif、throw、for、whileなどを直接使用できないのです。
(var a = 1)
// Uncaught SyntaxError: Unexpected token 'var'
(if(true) {console.log('a')})
// Uncaught SyntaxError: Unexpected token 'if'
(throw new Error('a'))
// Uncaught SyntaxError: Unexpected token 'throw'
(for(var a of [1,2]) {console.log(a)})
// Uncaught SyntaxError: Unexpected token 'for'
(while(true) {console.log('a')})
// Uncaught SyntaxError: Unexpected token 'while'
これらを使用する場合は、
- 事前に関数を用意する
- 直接即時関数を書き込む
- 論理演算子・三項演算子や
forEachなどの代替機能を使用する
などすると良いです。
パラメータのFormData化を実装する
パラメータもFormDataも、どちらもサーバーに情報を送るフォーマットの一つです。普通どちらを使っても問題はありませんが、念のためパラメータをFormDataに変換させてみましょう。
…実のところ、このクラスにおいてはFormData変換機構はあまり実用的ではなく、ほぼ不要です。これは単に私がゲッターについて書きたいが故のものです。
ゲッターってなんだ
オブジェクトにおける値の取得の方法を、オブジェクトに直接定義づけたものがゲッターです。ちなみに、値の設定の場合はセッターです。「なんのこっちゃ」って感じですが、要は取得はできるのに自分で直接変更できない値を設定するものです(実際は値の設定では断じてないですが、今回はそういう認識で結構です)。
class Sample {
    constructor() {
        this._a = 1;
    }
    get a() {
        return this._a;
    }
}
// or
function Sample() {
    this._a = 1;
    Object.defineProperty(this, 'a', {
        get: function() {
            return this._a;
        }
    })
}
var s = new Sample;
console.log(s.a);
// 1
// (ゲッターは関数的だが関数ではないので()を付けない)
s.a = 2;
console.log(s.a);
// 1
// (値の取得のためのものなので、代入は無意味。_aの値が変われば変わる。)
ここまでで作ったUrlコンストラクタは、パラメータを含む各値が外部から容易に書き換えられる仕組みです。ですので、このインスタンスに登録されたパラメータが登録時のままであるという保証は無いわけです。そんな中でFormData化したパラメータを取得するならば、動的な取得方法を作るべきです。となれば、メソッドやゲッターが候補に挙がります。しかしインスタンスの値や何かしらのオブジェクトに対して変更を加えるわけでもないので、これをメソッドとして作るのはちょっとダサいです(繰り返しになりますが、単に私がゲッターを使いたいだけです。実際にはメソッドでも別にいいかなあ、だなんて思っています)。そこで、ゲッターの出番なのです。
実際に実装する
ここまでくると、もはや書くべきことはほとんどありません。強いて挙げるならば、``FormData`コンストラクタに配列を登録するときの挙動についてでしょうか。
var f = new FormData;
f.append('a', [1, 2, 3]);
f.append('b[]', 1);
f.append('b[]', 2);
f.append('b[]', 3);
console.log(f.get('a'));
// "1,2,3"
console.log(f.get('b[]'));
// "3"
console.log(f.getAll('b[]'));
// ["1", "2", "3"]
こんな具合になります。これを念頭に置いて書き加えてみましょう。整形方法はString化と大して変わりません。
**Url.js(折りたたみ)**
(function(global){
    var REG = {
        URL: {
            R: new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i'),
            G: ['protocol','username','password','host','port','path','search','hash']
        }
    };
    var PARAM = {
        BASE: function(s) {// [['p', '1'], ['t', 'text']]
            return (s || '').split('&').filter(function(v){return v}).map(function(v){return (v = v.split('='), v[0] = decodeURI(v[0]).replace(/\+/g, ' '), v)})
        },
        ARRAY_TYPE: {
            'comma': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = v[1].split(',').map(function(p){return decodeURI(p).replace(/\+/g, ' ')});
                        o[v[0]] = v[1].length - 1 ? v[1] : v[1][0];
                    });
                    return o;
                },
                TO_STR: function(o) {
                    return Object.keys(o).map(function(k) {
                        return (k + '=' + Array.isArray(o[k])
                            ? o[k].map(function(v){
                                return encodeURIComponent(v)
                                }).join(',')
                            : encodeURI(o[k]))
                    }).join('&')
                },
                TO_FORM: function(o) {
                    var f = new FormData;
                    return (Object.keys(o).forEach(function(k) {
                        f.append(k, o[k])
                    }), f)
                }
            },
            'sepalate': {
                PARSE: function(s) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        o[v[0]] = o[v[0]]
                            ? (function(p, a){
                                return ((a = Array.isArray(p) ? p : [p]).push(v[1]), a);
                            })(o[v[0]])
                            : v[1]
                    });
                    return o;
                },
                TO_STR: function(o) {
                    return Object.keys(o).map(function(k) {
                        return (Array.isArray(o[k])
                            ? o[k].map(function(v){
                                return k + '=' + encodeURI(v)
                                })
                            : k + '=' + encodeURI(o[k]))
                    }).reduce(function(a,v){
                        return a.concat(v)
                    },[]).join('&')
                },
                TO_FORM: function(o) {
                    var f = new FormData;
                    return (Object.keys(o).forEach(function(k) {
                        Array.isArray(o[k])
                            ? o[k].forEach(function(v){
                                f.append(k, v)
                                })
                            : f.append(k, o[k])
                    }), f)
                }
            },
            'with_bracket': {
                PARSE: function(s, i) {
                    var b = PARAM.BASE(s), o = {};
                    b.forEach(function(v){
                        var m;
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        (m=v[0].match(new RegExp(i ? '^(.+?)\\[(\\d+?)\\]$' : '^(.+?)\\[\\]$')))
                            ? (o[m[1]] = (function(p, a){
                                return (a = (p==void(0)) ? [] : (Array.isArray(p) ? p : [p]), (m[2] ? (a[Number(m[2])] = v[1]) : a.push(v[1])), a);
                            })(o[m[1]]))
                            : (o[v[0]] = v[1])
                    });
                    return o;
                },
                TO_STR: function(o, i) {
                    return Object.keys(o).map(function(k) {
                        return (Array.isArray(o[k])
                            ? o[k].map(function(v,_){
                                return k + '[' + (i ? _ : '') + ']' + '=' + encodeURI(v)
                                })
                            : k + '=' + encodeURI(o[k]))
                    }).reduce(function(a,v){
                        return a.concat(v)
                    },[]).join('&')
                },
                TO_FORM: function(o, i) {
                    var f = new FormData;
                    return (Object.keys(o).forEach(function(k) {
                        Array.isArray(o[k])
                            ? o[k].forEach(function(v,_){
                                f.append(k + '[' + (i ? _ : '') + ']', v)
                                })
                            : f.append(k, o[k])
                    }), f)
                }
            },
        }
    }
    function E(s) {
        var e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }
    function Url(url, option) {
        var s = this, u, arrayType = (option && option.arrayType) || 'with_bracket', indexed = (option && option.indexed) || false;
        (u = (url ? String(url) : '').match(REG.URL.R))
            ? (REG.URL.G.forEach(function(g,i){
                s[g] = u[i+1]
                }),
                s.path = s.path || '/',
                s.search = (function(t){
                    return t ? t.PARSE(s.search, indexed) : (E('Invalid arrayType: "' + arrayType + '"'), void(0))
                })(PARAM.ARRAY_TYPE[arrayType]),
                s._parseStyle = {array: {arrayType: arrayType, indexed: indexed}},
                Object.defineProperty(s, 'FormData', {
                    get: function() {
                        var s = this, a = s._parseStyle.array;
                        return(function(t){
                            return t ? t.TO_FORM(s.search, indexed) : (E('Invalid arrayType: "' + arrayType + '"'), void(0))
                        })(PARAM.ARRAY_TYPE[arrayType])
                    }
                })
            )
            : E('Invalid URL: "' + url + '"');
    }
    Url.prototype.toString = function() {
        var s = this, a = s._parseStyle.array;
        return (s.protocol && s.host
            ? s.protocol + '//' + (s.username && s.password
                ? s.username + ':' + s.password + '@'
                : '')
                + s.host + (s.port ? ':' + s.port : '')
            : ''
        )
        + s.path
        + (Object.keys(s.search).length
            ? '?' + (function(t) {
                return t ? t.TO_STR(s.search, a.indexed).replace(/%20/g,'+') : (E('Invalid arrayType: "' + a.arrayType + '"'), '') 
                })(PARAM.ARRAY_TYPE[a.arrayType])
            : ''
        )
        + (s.hash ? '#' + s.hash : '')
    }
    Url.prototype.resolve = function(rel_path) {
        var rs, bs = this.path.split('/'), rl = new Url(rel_path, this._parseStyle.array), sp = rl.path.split('/').filter(function(v) {return v !== '.'});
        return((this.host || E('Base URL is not a absolute path: "' + this + '"')), (rl.host && E('"' + rel_path + '" is not a relative path')), bs.pop(), sp[0] == ''
            ? (rs = rl.path)
            : (function(v) {
                while((v = sp.shift())) {
                    v == '..'
                        ? bs.pop() || E('Invalid relative path: "' + rel_path + '"')
                        : bs.push(v)
                }
                rs = bs.join('/')
            })(),
        this.path = rs,
        this.search = rl.search,
        this.hash = rl.hash,
        this)
    }
    global.Url = Url;
})(this);
var url = new Url('http://example.com/x.php?a[]=1&a[]=2');
console.log(
    url.FormData.getAll('a[]')
)
// ["1", "2"]
これを…
var xhr = new XMLHttpRequest;
xhr.addEventListener('load', e=>console.log(e.responseText));
xhr.open('POST', url);
xhr.send(url.FormData);
fetch(url, {method: 'POST', body: url.FormData})
    .then(v=>v.text())
    .then(v=>console.log(v))
こうじゃ!できた!!
長かった…。
おわりに
さて、いかがだったでしょうか。
JavaScriptはクラスを作らなくてもある程度は動かせる言語ですし、クラス作成に慣れていないJSユーザーも案外多いのではないかと思います。しかしクラスを作れるようになれば、やれることの幅は間違いなく広がるはずです(少なくとも私はそうでした)。
この記事は、クラス作りの指南書とするには少々、スタティックメソッドやセッターなどのその他充実した機能についての説明が欠けている些か不完全な代物かとは思います。ですがこれを機に、皆さんがクラスについてちょっとでも興味を惹かれたなら、またこの記事が皆さんのJavaScriptアビリティ向上の一助となったなら、この上なく幸いです。
ご清覧ありがとうございました。
おまけ
classで書き直してみた。
**Url.js(折りたたみ)**
アロー関数はthisを持たないという性質を用いることで、インスタンスのthisをプロパティ内の内部関数で気兼ねなく使えたりしますね。あとはゲッターの設定のしやすさなど、やはり書きやすいです。まあ、書き直す前とコード量はそんなに変わらないです。
(global=>{
    const REG = {
        URL: {
            R: new RegExp('^(?:([^:\\s]+:)|)(?:\\/\\/(?:([^:\\/\\?#]+?):([^:@]+?)@|)([^:\\/\\?#]+)(?::([0-9]+)|)|)(\\/?[^\\?#]*)(?:\\?([^\\?#]*)|)(?:#(.*)|)$','i'),
            G: ['protocol','username','password','host','port','path','search','hash']
        }
    }, PARAM = {
        BASE: s=>{// [['p', '1'], ['t', 'text']]
            return (s || '').split('&').filter(v=>v).map(v=>(v = v.split('='), v[0] = decodeURI(v[0]).replace(/\+/g, ' '), v))
        },
        ARRAY_TYPE: {
            'comma': {
                PARSE: s=>{
                    const b = PARAM.BASE(s), o = {};
                    b.forEach(v=>{
                        v[1] = v[1].split(',').map(p=>decodeURI(p).replace(/\+/g, ' '));
                        o[v[0]] = v[1].length - 1 ? v[1] : v[1][0];
                    });
                    return o;
                },
                TO_STR: o=>{
                    return Object.keys(o).map(k=>{
                        return (k + '=' + Array.isArray(o[k])
                            ? o[k].map(v=>{
                                return encodeURIComponent(v)
                                }).join(',')
                            : encodeURI(o[k]))
                    }).join('&')
                },
                TO_FORM: o=>{
                    const f = new FormData;
                    return (Object.keys(o).forEach(k=>{
                        f.append(k, o[k])
                    }), f)
                }
            },
            'sepalate': {
                PARSE: s=>{
                    const b = PARAM.BASE(s), o = {};
                    b.forEach(v=>{
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        o[v[0]] = o[v[0]]
                            ? ((p, a)=>((a = Array.isArray(p) ? p : [p]).push(v[1]), a))(o[v[0]])
                            : v[1]
                    });
                    return o;
                },
                TO_STR: o=>{
                    return Object.keys(o).map(k=>{
                        return (Array.isArray(o[k])
                            ? o[k].map(v=>k + '=' + encodeURI(v))
                            : k + '=' + encodeURI(o[k]))
                    }).reduce((a,v)=>{
                        return a.concat(v)
                    },[]).join('&')
                },
                TO_FORM: o=>{
                    const f = new FormData;
                    return (Object.keys(o).forEach(k=>{
                        Array.isArray(o[k])
                            ? o[k].forEach(v=>f.append(k, v))
                            : f.append(k, o[k])
                    }), f)
                }
            },
            'with_bracket': {
                PARSE: (s, i)=>{
                    const b = PARAM.BASE(s), o = {};
                    b.forEach(v=>{
                        const m = v[0].match(new RegExp(i ? '^(.+?)\\[(\\d+?)\\]$' : '^(.+?)\\[\\]$'));
                        v[1] = decodeURI(v[1]).replace(/\+/g, ' '),
                        m ? (o[m[1]] = ((p, a)=>(a = (p==void(0)) ? [] : (Array.isArray(p) ? p : [p]), (m[2] ? (a[Number(m[2])] = v[1]) : a.push(v[1])), a))(o[m[1]]))
                            : (o[v[0]] = v[1])
                    });
                    return o;
                },
                TO_STR: (o, i)=>{
                    return Object.keys(o).map(k=>{
                        return (Array.isArray(o[k])
                            ? o[k].map((v,_)=>k + '[' + (i ? _ : '') + ']' + '=' + encodeURI(v))
                            : k + '=' + encodeURI(o[k]))
                    }).reduce((a,v)=>a.concat(v),[]).join('&')
                },
                TO_FORM: (o, i)=>{
                    const f = new FormData;
                    return (Object.keys(o).forEach(k=>{
                        Array.isArray(o[k])
                            ? o[k].forEach((v,_)=>f.append(k + '[' + (i ? _ : '') + ']', v))
                            : f.append(k, o[k])
                    }), f)
                }
            },
        }
    }, E = s=>{
        const e = new Error(s);
        e.name = 'UrlError';
        throw e;
    }, Url = class {
        constructor(url, {arrayType='with_bracket', indexed=false}={}) {
            const u = (url ? String(url) : '').match(REG.URL.R);
            u ? (REG.URL.G.forEach((g,i)=>{
                    this[g] = u[i+1]
                    }),
                    this.path = this.path || '/',
                    this.search = (t=>t ? t.PARSE(this.search, indexed) : (E('Invalid arrayType: "' + arrayType + '"'), void(0)))(PARAM.ARRAY_TYPE[arrayType]),
                    this._parseStyle = {array: {arrayType: arrayType, indexed: indexed}}
                )
                : E('Invalid URL: "' + url + '"');
        }
        get FormData() {
            const a = this._parseStyle.array;
            return (t=>t ? t.TO_FORM(this.search, a.indexed) : (E('Invalid arrayType: "' + a.arrayType + '"'), void(0)))(PARAM.ARRAY_TYPE[a.arrayType])
        }
        toString() {
            const a = this._parseStyle.array;
            return (this.protocol && this.host
                ? this.protocol + '//' + (this.username && this.password
                    ? this.username + ':' + this.password + '@'
                    : '')
                    + this.host + (this.port ? ':' + this.port : '')
                : ''
            )
            + this.path
            + (Object.keys(this.search).length
                ? '?' + (t=>t ? t.TO_STR(this.search, a.indexed).replace(/%20/g,'+') : (E('Invalid arrayType: "' + a.arrayType + '"'), ''))(PARAM.ARRAY_TYPE[a.arrayType])
                : ''
            )
            + (this.hash ? '#' + this.hash : '')
        }
        resolve(rel_path) {
            const bs = this.path.split('/'), rl = new Url(rel_path, this._parseStyle.array), sp = rl.path.split('/').filter(v=>v !== '.'), rs = ((this.host || E('Base URL is not a absolute path: "' + this + '"')), (rl.host && E('"' + rel_path + '" is not a relative path')), bs.pop(), sp[0] == ''
                ? rl.path
                : (v=>{
                    while((v = sp.shift())) {
                        v == '..'
                            ? bs.pop() || E('Invalid relative path: "' + rel_path + '"')
                            : bs.push(v)
                    }
                    return bs.join('/')
                })());
            return (this.path = rs,
                this.search = rl.search,
                this.hash = rl.hash,
                this)
            }
    }
    global.Url = Url;
})(this);

