Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

JavaScript 実行エンジンの違いによる URL Interface の挙動の違いについて

こんにちは、エンジニアの id:yunagi_n です。
みなさんは JavaScript において、 URL をパースするとき、どの API を使用していますか?
もっとも簡単なのは、 URL Interface を使用することだと思います。
今回は、その URL Interface が、 JavaScript の実行エンジンによって挙動が異なることについて書こうと思います。


事前情報

この記事の内容は、以下のバージョンにて確認を行っています。

  • macOS 12.5.1
    • Google Chrome 107.0.5304.121
    • Safari 15.6.1
    • Firefox 107.0.1

本題

URL Interface は、 各種ブラウザおよび Node.js 上で URL を扱うためのインターフェースです。適当な URL を渡すと、下のように各部分毎に分解してくれて、 URL を元に何かしたいときに便利なインターフェースです。

const url = new URL("https://example.com")

console.log(url.protocol) // => https:

今回は、そんな URL Interface について、ブラウザー実装毎による違いについてのお話です。 例えば、最もよく使われている Chromium 系列 (V8) の場合は、例として https://example.com/test?a=b#c のような URL を渡すと下記のような結果を返します。

const url = new URL("https://example.com/test?a=b#c")
console.dir(url)

/*
 * URL {
 *   hash: "#c",
 *   host: "example.com",
 *   hostname: "example.com",
 *   href: "https://example.com/test?a=b#c",
 *   origin: "https://example.com",
 *   password: "",
 *   pathname: "/test",
 *   port: "",
 *   protocol: "https:"
 *   search: "?a=b",
 *   username: ""
 * }
 */

このような一般的な URL (ここではプロトコル部が HTTP および HTTPS であるものを指す) であれば、どのブラウザーでも同じ挙動をしてくれます。では、例えば FTP のセキュア版のプロトコルである FTPS を含んだ ftps://example.com/test を渡すとどうなるでしょうか? V8 の場合は以下のような結果を返します。

const url = new URL("ftps://example.com/test")
console.dir(url)

/*
 * URL {
 *   hash: "",
 *   host: "",
 *   hostname: "",
 *   href: "ftps://example.com/test",
 *   origin: "null",
 *   password: "",
 *   pathname: "//example.com/test",
 *   port: "",
 *   protocol: "ftps:",
 *   search: "",
 *   username: ""
 * }
 */

通常の URL を渡した場合と挙動に違いがありますね。ちなみに Firefox (SpiderMonkey 系) でも同じ結果を返してくれます。 では WebKit 系列 (JavaScript Core) ではどうでしょう?答えは以下のようになります。

const url = new URL("ftps://example.com/test")
console.dir(url)

/*
 * URL {
 *   hash: "",
 *   host: "example.com",
 *   hostname: "example.com",
 *   href: "ftps://example.com/test",
 *   origin: "ftps://example.com",
 *   password: "",
 *   pathname: "/test",
 *   port: "",
 *   protocol: "ftps:",
 *   search: "",
 *   username: ""
 * }
 */

それぞれ、 host 部の扱いが異なっているのが特徴です。 V8 は host 部は無かったものとして扱い、 pathname にすべてを含めているのに対し、 JavaScript Core はおそらく私たちがイメージした結果と同じもの、つまりは host 部を example.com として返してくれています。

ではセキュアではない FTP 、つまりはプロトコル部が ftp: の URL を渡すとどうなるでしょうか?答えはすべてのブラウザーで次のような結果になります。

const url = new URL("ftp://example.com/test")
console.dir(url)

/*
 * URL {
 *   hash: "",
 *   host: "example.com",
 *   hostname: "example.com",
 *   href: "ftp://example.com/test",
 *   origin: "ftp://example.com",
 *   password: "",
 *   pathname: "/test",
 *   port: "",
 *   protocol: "ftp:"
 *   search: "",
 *   username: ""
 * }
 */

これから分かるように、 Chromium 系列と Firefox 系列は、プロトコル部によって、 host および hostname のパース結果が異なります。 では、挙動が異なるのがこれだけだというと、他にも異なる部分があります。

例えば、 hostname に大文字小文字の両方を含む文字列を渡した際の結果は、一般的なプロトコルの場合は以下のようになります。

const url = new URL("http://ExAmple.COM/test")
console.dir(url)

/*
 * URL {
 *   hash: "",
 *   host: "example.com",
 *   hostname: "example.com",
 *   href: "http://example.com/test",
 *   origin: "http://example.com",
 *   password: "",
 *   pathname: "/test",
 *   port: "",
 *   protocol: "http:"
 *   search: "",
 *   username: ""
 * }
 */

しかしそうでないもの、ここでは Web3 の文脈でよく使われている IPFS プロトコルの URL を渡した場合、 Chromium 系や Safari では以下のようになり、

const url = new URL("ipfs://QmR3u53ksjcNVzUinvC1hjjKxEpWR2a9SeWhhh7MtofjHe")
console.dir(url)

/*
 * URL {
     hash: "",
     host: "",
     hostname: "",
     href: "ipfs://QmR3u53ksjcNVzUinvC1hjjKxEpWR2a9SeWhhh7MtofjHe",
     origin: "null",
     password: "",
     pathname: "//QmR3u53ksjcNVzUinvC1hjjKxEpWR2a9SeWhhh7MtofjHe",
     port: "",
     protocol: "ipfs:",
     search: "",
     username: "",
 * }
 */

SpiderMonkey 系 (Firefox) ではこうなります。

const url = new URL("ipfs://QmR3u53ksjcNVzUinvC1hjjKxEpWR2a9SeWhhh7MtofjHe")
console.dir(url)

/*
 * URL {
     hash: "",
     host: "qmr3u53ksjcnvzuinvc1hjjkxepwr2a9sewhhh7mtofjhe",
     hostname: "qmr3u53ksjcnvzuinvc1hjjkxepwr2a9sewhhh7mtofjhe",
     href: "ipfs://QmR3u53ksjcNVzUinvC1hjjKxEpWR2a9SeWhhh7MtofjHe/",
     origin: "ipfs://qmr3u53ksjcnvzuinvc1hjjkxepwr2a9sewhhh7mtofjhe",
     password: "",
     pathname: "/",
     port: "",
     protocol: "ipfs:",
     search: "",
     username: "",
 * }
 */

面白いのが、 href は大文字小文字を区別していますが、 host などその他は大文字小文字を区別せず、すべて小文字で表されています。

結論としては、図にまとめると以下のような挙動をします。

Parse Result \ URL Protocol http / https / ftp ftps ipfs
hash すべてのブラウザで挙動は一致 - -
host すべてのブラウザで挙動は一致 Safari で挙動が異なる Firefox で挙動が異なる
hostname すべてのブラウザで挙動は一致 Safari で挙動が異なる Firefox で挙動が異なる
href すべてのブラウザで挙動は一致 すべてのブラウザで挙動は一致 Firefox で挙動が異なる
origin すべてのブラウザで挙動は一致 Safari で挙動が異なる Firefox で挙動が異なる
pathname すべてのブラウザで挙動は一致 Safari で挙動が異なる Firefox で挙動が異なる
protocol すべてのブラウザで挙動は一致 すべてのブラウザで挙動は一致 すべてのブラウザで挙動は一致

では、すべてのブラウザで挙動を揃えるにはどのようにすればよいのでしょうか?
答えは簡単で、 URL Interface を使用していないパーサーライブラリを使用します。 例としては url-parse などが使用できます。
ただし、こちらも内部で isSpecial という関数で一部プロトコルでのみ、 origin をセットしていたりするので、プロトコルが違えば、 origin に限っては挙動が異なります。
しかし、それ以外についてはどのブラウザ、プロトコルでも共通の動作をしているので、より多くのブラウザで同一の挙動を実現するには、十分ではないでしょうか。

ということで、今回は URL Interface の挙動の違いについて、解説しました。