ajaxでファイル送信したい(xhr, jquery, axios, fetch)

さて、前回は色々なHTTP Requestの仕方をご紹介しました。
今回はajaxでファイル送信をする方法について、模索していこうと思います。

ファイル送信の基本

まずはajaxとかjavascript無しでファイル送信をする方法をおさらいしましょう。

<form action="file.php" method="post" enctype="multipart/form-data">
  <input type="hidden" name="foo" value="fuga" />  
  <input type="file" name="img" />
  <button>送信!</button>
</form>

こんな感じですね。本来ならacceptやら何やら設定すべき項目はあるでしょうけど、まあいいでしょう。
ファイルだけ送信することって実際そこまでない気がするので、一応適当なfoo=fugaも送信しときます。
今回はjpg画像を送信することを考えます。

  • メソッドはPOST
  • Content-Type=multipart/form-data
  • input[type=file]でファイル選択

サーバー側の処理

今回は、送信されたjpg画像をbase64エンコードしてData URIの形にし、それをsrcとしたimgタグを返すにしましょう。

<?php

if (isset($_FILES['img']) && is_uploaded_file($_FILES['img']['tmp_name'])) {
  $img = file_get_contents($_FILES['img']['tmp_name']);
  http_response_code(200);
  echo '<img src="data:image/jpg;base64,' . base64_encode($img) . '" />';
  exit(0);
}

http_response_code(400);
echo "<p>upload failed</p>";

jpgのチェックなどは一旦無しです。jpgが送られることだけを信じているサーバーです。
ファイルアップロードがされない場合は400を返します。

ちなみにさっきのhtmlでフォーム送信してみるとこうなります。

これを非同期処理で行えば、この送信画像のimgタグを取得できますね。

実際には何が送られているのか

ファイル送信では何が送られているのでしょうか?
普通のフォームではaaa=bbb&ccc=dddみたいなリクエストボディとして送られます。この形式のことをapplication/x-www-form-urlencodedって言いますね。

しかし、ファイル送信時にはformのenctype属性にmultipart/form-dataを指定します。これが一体なんなのか、サーバー側は何を受け取ってどう処理しているのかを知った方が楽しいと思うので、それを調査します。

サーバー側が受け取っているものをみてみる

Content-Typeを見てみます。

multipart/form-data; boundary=----WebKitFormBoundarySlKAOJPci5YYJrxB

Content-Typemultipart/form-dataになってますね。formのenctype属性に設定したやつです。
その後にboundaryってのがあります。これは受け取ったボディはこの文字列で分割しますよーっていう宣言みたいなもんです。

リクエストボディは以下のようになります。

----WebKitFormBoundarySlKAOJPci5YYJrxB
Content-Disposition: form-data; name="img"; filename="emopro.jpg"
Content-Type: image/jpeg

送信ファイルのバイト列.......
----WebKitFormBoundarySlKAOJPci5YYJrxB
Content-Disposition: form-data; name="foo"

fuga
----WebKitFormBoundarySlKAOJPci5YYJrxB--

----WebKitFormBoundarySlKAOJPci5YYJrxBで分割してみましょう。

Content-Disposition: form-data; name="img"; filename="emopro.jpg"
Content-Type: image/jpeg

送信ファイルのバイト列.......
Content-Disposition: form-data; name="foo"

fuga

上はアップロードした画像について、下はfoo=fugaのつもりで送ったやつです。

application/x-www-form-urlencodedのリクエストボディ(aaa=bbb&ccc=ddd)に比べて随分と様変わりしましたね。

実はこの形式、メールでも見られます。
テキストメッセージと添付ファイルが上記のような方法で送られます。

一応これで、サーバーが何を受け取っているのかを知ることができました。
ここまでわかればajaxで試してみるだけですね。

ajaxでファイル送信

要はformで送信するのと同様のものを送ればいいんだろって話ですよ。
やってやりましょう。
xhrjqueryaxiosfetchでそれぞれチャレンジしてみます。

まずはこんな画面を用意します。

ボタンを押すとファイル選択ダイアログが表示されます。
送信する画像を選択すると先ほど作ったimgタグ文字列を返すphpに送信されます。
そのimgタグをボタン下に配置することで画像を表示される予定です。

ボタンを押したらファイル選択ダイアログを表示する関数

const chooseFile = () =>
  new Promise(resolve => {
    const input = document.createElement('input')
    input.setAttribute('type', 'file')
    input.style.display = 'none'
    document.body.appendChild(input)

    input.addEventListener('change', () => {
      const f = input.files[0]
      document.body.removeChild(input)
      resolve(f)
    })
    input.click()
  })

こんな関数を用意しました。
この関数を呼ぶとファイル選択ダイアログが表示されます。選択したファイルはPromiseのresolveに渡します。

xhrでチャレンジ

// xhrボタンクリック
btnxhr.addEventListener('click', evt => {
  console.log('xhr')

  chooseFile().then(f => {
    const fr = new FileReader()
    fr.onload = ev => {
      const xhr = new XMLHttpRequest()
      const boundary = 'boundaryyyyyyyyyyy'

      xhr.open('post', 'file.php')
      xhr.setRequestHeader(
        'Content-Type',
        'multipart/form-data; boundary=' + boundary
      )
      xhr.onload = () => {
        result.innerHTML = xhr.responseText
      }

      let before = ''
      before += '--' + boundary + '\r\n'
      before +=
        'Content-Disposition: form-data; name="img"; filename="emopro.jpg"' +
        '\r\n'
      before += 'Content-Type: image/jpeg' + '\r\n'
      before += '\r\n'

      let after = '\r\n'
      after += '--' + boundary + '\r\n'
      after += 'Content-Disposition: form-data; name="foo"' + '\r\n'
      after += '\r\n'
      after += 'fuga' + '\r\n'
      after += '--' + boundary + '--' + '\r\n'

      const beforearr = new TextEncoder().encode(before)
      const arr = new Uint8Array(fr.result)
      const afterarr = new TextEncoder().encode(after)

      const body = new Uint8Array(
        beforearr.byteLength + arr.byteLength + afterarr.byteLength
      )
      body.set(beforearr, 0)
      body.set(arr, beforearr.byteLength)
      body.set(afterarr, beforearr.byteLength + arr.byteLength)

      xhr.send(body)
    }
    fr.readAsArrayBuffer(f)
  })
})

どうです?地獄のようでしょう?
一個ずつ処理を追いかけます。

  1. xhrボタンが押されたらchooseFileでファイル選択ダイアログを表示
  2. 選択したファイルをFileRederに食わせて、ArrayBufferを取得する。
  3. xhrを生成、boundaryの値を適当に設定(boundaryyyyyyyyyyyにしてみました)
  4. Content-Type=multipart/form-data; boundary=boundaryyyyyyyyyyyを設定
  5. リクエストボディ部分作成
    • 最終的なリクエストボディはUint8Arrayにする
    • 文字列として入力できるものは文字列で作成し、Uint8Arrayに変換
    • 画像のバイナリArrayBufferUint8Arrayに変換
    • これらのUint8Arrayを1つに結合
  6. レスポンスのimgタグを表示用divに書き込み

リクエストボディは以下の文字列をバイナリにしたものです。

--boundaryyyyyyyyyyy
Content-Disposition: form-data; name="img"; filename="emopro.jpg"
Content-Type: image/jpeg

[画像のバイナリ]
--boundaryyyyyyyyyyy
Content-Disposition: form-data; name="foo"

fuga
--boundaryyyyyyyyyyy--

javascriptはバイナリの扱いが下手くそなので、うまいことやってあげなければなりません。
ちなみにこのリクエストはPOSTの値としてfoo=fugaも送っています。
結果を見てみましょう。

無事imgタグが書かれて画像が表示されました!
これがformで送信するのと同様のものを愚直に送る方法です。
サーバー側が理解できるモノさえ送ればいいんです。

さすがにこれは酷すぎるので、簡単な方法も

form使わずに画像送る時っていっつもこんなん書かなきゃならんの?と不安になった方もいるでしょう。
安心してください。簡単な方法あります。

// xhrボタンクリック
btnxhr.addEventListener('click', evt => {
  console.log('xhr')

  chooseFile().then(f => {
    const body = new FormData()
    body.append('img', f)
    body.append('foo', 'fuga')
    const xhr = new XMLHttpRequest()
    xhr.open('post', 'file.php')
    xhr.onload = () => {
      result.innerHTML = xhr.responseText
    }
    xhr.send(body)
  })
})

随分と短くなりましたね!FileReaderに食わせる必要もなく楽チンです。
標準搭載のFormDataというAPIを使用すればこんなに簡単に同じことができます。
appendで送りたいものを追加していき、もしその中にファイルが含まれるならば自動でContent-Typeをよしなにしてくれます。
同じ動作をするコードとは全く思ませんね。

jqueryでもやってみる

// jqueryボタンクリック
btnjquery.addEventListener('click', evt => {
  console.log('jquery')
  chooseFile().then(f => {
    const body = new FormData()
    body.append('img', f)
    body.append('foo', 'fuga')
    $.ajax({
      url: 'file.php',
      method: 'post',
      data: body,
      success: data => {
        result.innerHTML = data
      },
      contentType: false,
      processData: false,
    })
  })
})

jqueryはFormDataの送信に対応してくれます。ってか全部内部でxhr使ってるんだからそらそうよ。
ただし$.postでは送れません。少し細やかな設定が必要だからです。

FormDataを送るときはcontentTypeprocessDataをfalseにします。
$.postにはこの設定ができないので、$.ajaxを使用します。

axiosでもやってみる

// axiosボタンクリック
btnaxios.addEventListener('click', evt => {
    console.log('axios')
    chooseFile().then(f => {
      const body = new FormData()
      body.append('img', f)
      body.append('foo', 'fuga')
      axios.post('file.php', body).then(resp => {
        result.innerHTML = resp.data
      })
    })
  })

まさかaxiosが一番シンプルに書けるとは思いませんでした。
さすがにContent-Typefalseにしたりmultipart/form-dataにしなきゃならないと思っていましたが、何の指定も無くいけちゃいました。
axios愛してるわー。

fetchでもやってみる

// fetchボタンクリック
btnfetch.addEventListener('click', evt => {
  console.log('fetch')
  chooseFile().then(f => {
    const body = new FormData()
    body.append('img', f)
    body.append('foo', 'fuga')
    fetch('file.php', {
      method: 'post',
      body: body,
    })
      .then(e => e.text())
      .then(txt => {
        result.innerHTML = txt
      })
  })
})

こっちもシンプル!こっちももう少し手間がかかるのかなと考えていましたが、意外と簡単な記述で送れるんですね。

application/jsonでファイル送りたい

今回はmultipart/form-dataで送信しましたが、私はリクエストをapplication/jsonにしていることが多いのです。
つまりこのままではファイル送信の時だけ、リクエストの形式が変わってしまうんです。これは良くない!

でも安心してください!方法ならあります!めっちゃ簡単ですよ!

ファイルを文字列にしjsonの値として送っちゃえばいいんです!

具体的には

  1. ファイル選択
  2. 選択されたファイルをFileReaderに食わせる
  3. readAsDataURLでファイルのバイナリをBase64形式で取得する
  4. コレをサーバーに送る

って感じです。
readAsDataURLで取得できる値ってのはdata:image/jpg;base64,[ファイルのBase64エンコードして取得した文字列]みたいな形式の文字列です。

ただの文字列なんで普通に送れますし、先頭のdata:image/jpg;base64,を取っ払ってBase64デコードをすれば元のバイナリを取得できます。

終わりに

今回はなかなか楽しい内容でしたね!
multipart/form-dataのリクエストボディ文字列をガリガリ書いてる時はアホなことしてるなーとも思いながら、ちょっと楽しかったですもん。

みなさんも暇だったらサーバー側が理解できるリクエストを手書きしてみるのはいかがでしょうか?

それではまた来週!