parseQuery / stringifyQuery で URLSearchParams を使う

Nuxt.js
公開日:
更新日:

Nuxt.js(というか Vue Router)では、デフォルトのクエリ構文解析(parseQuery)・文字列化関数(stringifyQuery)を置き換えることができます。

ここでいうクエリとは、よく GET パラメータと呼ばれるもののことです。

デフォルトの stringifyQuery では、'foo bar' という文字列を渡した時に、半角スペースがエンコードされて foo%20bar となります。

個人的には Google 検索のように foo+bar となってほしかったので、URLSearchParams で処理するようにします。

stringifyQuery

stringifyQuery はオブジェクトを受け取って、クエリ文字列(? 含む)に変換するのが仕事です。

最も簡単なのは、オブジェクトを URLSearchParams のコンストラクタに喰わせてしまうことです。

stringifyQuery(obj) {
  if (!obj || !Object.keys(obj).length) return '' // 空オブジェクトなら処理しない

  const query = (new URLSearchParams(obj)).toString()
  return query ? '?' + query : ''
}

かなり簡潔に書けるのですが、クエリに配列が渡された場合の挙動がビミョーです。

const query = {
  q: 'foo bar',
  hoge: [2,3,5,7],
}
stringifyQuery(query) // => ?q=foo+bar&hoge=2%2C3%2C5%2C7

%2C というのはカンマ(,)を指しているので、'2,3,5,7' という文字列に変換されたということになります。これは、[2,3,5,7].toString() の結果と同じです。

.split(',') すれば配列の形には戻せますが、元々配列だったのか、'foo,bar' という文字列だったのか、若しくは ['foo,', 'bar'] だったのか、クエリからは読み取れなくなってしまいます。

デフォルトの stringifyQuery ではどうしているのかというと、配列は同じキーを続けて表現します。PHP と似ていますが、[] は付けません。

Google Fonts などで見られるクエリの書き方ですね。

defaultStringifyQuery(query) // => ?q=foo%20bar&hoge=2&hoge=3&hoge=5&hoge=7

また、デフォルトの stringifyQuery では、値が undefined であるキーを無視します。これはクエリを渡すときにかなり便利です。

const foo = 'foo'
const bar = someCond ? 'bar' : undefined
const baz = getSomeStr('baz') || undefined
defaultStringifyQuery({ foo, bar, baz })

// もし undefined を無視しない作りだと…
const query = { foo: 'foo' }
if (someCond) query['bar'] = 'bar'
const baz = getSomeStr('baz')
if (baz) query['baz'] = baz
stringifyQuery(query)

値が null の場合は、キーだけがクエリに追加されます。こちらは使い所がよく分かりません。

URLSearchParams では再現できないので、null も undefined と同じ扱いにしてしまって良さそうです。

defaultStringifyQuery({ foo: null, bar: [null, undefined, 0, NaN] }) // => ?foo&bar&bar=0&bar=NaN


URLSearchParams で再現する

先に URLSearchParams インスタンスを作っておいて、.append(key, value) でクエリを追加していきます。

似たメソッドで .set(key, value) がありますが、こちらは重複するキーを削除してしまいます。

// nuxt.config.js
export default {
  router: {
    stringifyQuery(obj) {
      if (!obj) return ''

      const keys = Object.keys(obj)
      const keysMax = keys.length|0
      if (!keysMax) return ''

      const params = new URLSearchParams()
      for (let i = 0; i < keysMax; i=(i+1)|0) {
        const key = keys[i]
        const val = obj[key]
        const values = Array.isArray(val) ? val : [val]
        const valuesMax = values.length|0
        for (let j = 0; j < valuesMax; j=(j+1)|0) {
          const value = values[j]
          switch (value) {
            case null:
            case undefined:
              continue
            default:
              params.append(key, value)
          }
        }
      }
      const query = params.toString()
      return query ? '?' + query : ''
    },
  },
}

|0 は内部的に変数を整数型と認識させる裏技だそうです。インクリメント i++ はクッソ遅いらしいので避けたほうがいいですが、見やすさ優先で i+=1 とかでも良いんじゃないかと思います。

for of を使っていないのは、generate 後のコードで何故か try catch 文(クッソ遅い)で囲われてしまったからです。

結局 Babel で for (var i = 0; i < length; i++) の形に直されてしまうので、当分は自分で for 文書いたほうが良いんじゃないかと思います。

parseQuery

stringifyQuery が作ったクエリ文字列を受け取って、オブジェクトの形にするのが仕事です。

URLSearchParams のコンストラクタにクエリ文字列を与えると、クエリ内容を解析できます。

クエリ文字列の先頭に ? があっても無視されるので、一緒に渡してしまって構いません。

.getAll() メソッドを使うと、同じキー名の値を配列で受け取ることができます。.get() では最初の値しか取れません。

const params = new URLSearchParams('?hoge=2&hoge=3') // 先頭に ? があっても大丈夫
params.getAll('hoge') // => ['2', '3']
params.get('hoge') // => '2'

各クエリには .forEach() でアクセスできますが、重複しているキーを飛ばしたりはしないので、Object.prototype.hasOwnProperty() で条件分岐しながら処理します。

// nuxt.config.js
export default {
  router: {
    parseQuery(query) {
      const queries = {}

      if (query) {
        const params = new URLSearchParams(query)
        const hasProp = Object.prototype.hasOwnProperty
        params.forEach((val, key) => {
          if (!hasProp.call(queries, key)) {
            const values = params.getAll(key)
            queries[key] = values.length === 1 ? values[0] : values
          }
        })
      }
      return queries
    },
  },
}

注意点として、まず queries.hasOwnProperty() を使うべきではありません。

クエリ文字列として ?hasOwnProperty=hoge&foo=0 が渡された場合、処理中に queries.hasOwnProperty は文字列 'hoge' となり、次の foo の処理でメソッドとして実行しようとしてエラーが起きるでしょう。

これを回避するには、Object のプロトタイプから hasOwnProperty を拾ってきて、.call() で呼び出します。第1引数に this に設定するもの(ここでは queries)、第2引数以降に元々の関数の引数に設定するものを並べます。

また、params.forEach((val, key) => {})val も使うべきではありません。

こちらも上記のような問題を抱えており、val は文字列とは限りません。クエリ文字列を取るときは、.get().getAll() を使いましょう。

プログラムから利用する

Vue Router の .resolve() というメソッドを使うことで、ページの遷移なく URL を解析・生成できます。

オブジェクトを渡せば stringifyQuery が、URL 文字列を渡せば parseQuery が利用できます。とはいえ、現在のルートの情報は this.$route から得られるので、後者を利用する場面はなさそうです。

export default {
  asyncData({ app }) {
    const { href } = app.router.resolve({
      path: '/path/',
      query: { foo: null, bar: undefined, baz: [null, undefined, 0, '', NaN] },
    })
    console.log(href) // => '/path/?baz=0&baz=&baz=NaN'
  },
  mounted() {
    const { resolved } = this.$router.resolve('/path/?baz=0&baz=&baz=NaN')
    console.log(resolved.path) // => '/path/'
    console.log(resolved.query) // => { baz: ['0', '', 'NaN'] }
  },
}

コンテキストを受け取れるプラグインでも使うことができますし、変わったところでは、モジュールのフック内から直接 stringifyQuery を触ったりできます。

// modules/hoge.js
export default function() {
  this.nuxt.hook('generate:before', () => {
    const url = new URL('path', 'https://example.com/api/v1/')
    url.search = this.options.router.stringifyQuery({ q: 'foo bar' })
    console.log(url.href) // => 'https://example.com/api/v1/path?q=foo+bar'
  })
}