3DSブラウザでのiframeファイルアップロードでつまずいてたのでメモ


## はじめに

JavaScriptでAjaxファイルアップロードをする際、
HTML5のFile APIやインラインフレームによる擬似Ajaxをすることになりますが、
その際3DSブラウザでつまずいてたのでメモ。

メモにしては予想以上に長くなってしまったので、結果のみ見たい方は[結論](#%E7%B5%90%E8%AB%96)へ移動して下さい。
~~また、正直もったいぶるほどの内容でも結論でもないので過度の期待はしないでください。~~

## 前提

3DSブラウザは、JavaScriptで`<input type="file">`の`files`プロパティが読み出せます。
が、`FormData`オブジェクトが作成できず、また[Blobデータ](https://developer.mozilla.org/ja/docs/DOM/Blob)を直接送信してもサイズ0のデータが送信されるだけで、Ajaxによるファイルアップロードは出来ません。
このため、インラインフレームを利用した擬似Ajax[^インラインフレームを利用した擬似Ajax]によるアップロードが必要になります。

[^インラインフレームを利用した擬似Ajax]: `<form>`要素は、target属性により送信結果をインラインフレーム内に表示することが可能です。これを利用し、非表示状態のインラインフレームへフォームを送信する事で、フォームのある元の画面(親ページ)をページ移動せずにフォームの内容を送信する“擬似Ajax”が可能となります。

この擬似Ajax、大抵のブラウザであれば問題なく動作します。
ところが3DSブラウザは、インラインフレーム内のページ読み込み時も**ページ全体が更新されるような挙動をします**。
本当に親ページを再読み込みしているのかは不明ですが、その際、**親ページにあるフォームの内容などが初期化されてしまいます**。

この挙動は、結構有名なファイルアップロードライブラリでも発生してしまい、困っていました。

## 転機

しかしある日、とあるライブラリに出くわし、それがページ全体の更新も起こさずアップロードを行ってくれるので実行内容を調べてみました。
問題のライブラリはコレです。

[FileDrop.js](http://filedropjs.org/)

他のライブラリに依存せず、それなりにクロスブラウザ対応で、
しかもFile APIによるアップロードファイルの読み込みまでサポートされており、
個人的に気に入っています。
~~あと、File API対応のブラウザでは、何故かファイルの(`multipart/form-data`ではない)生データを送ってくるという謎仕様でもあったりします。
(ちなみに、3DSブラウザにおいてもこの送信が行われますが、データの内容は空になっているようです…)~~
File API対応ブラウザで直接[Blobデータ](https://developer.mozilla.org/ja/docs/DOM/Blob)を送信しているらしいのが難点ですが…

さてこのライブラリ、インラインフレームによるアップロードでは他のライブラリ同様、
CSSで隠したインラインフレームへ向けてフォーム内容を送信しているようですが、
どういうわけだかページ全体の更新がされませんでした。
他のライブラリでは起こっていたというのに…

## 検証

悩んでも仕方がないので、検証用のコードを書いてみます。

### コード

```html:hideIframe.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>iframe hide test</title>
</head>
<body>
    <form action="upload.php" method="POST" enctype="multipart/form-data" target="if">
        <input type="file" name="file" />
        <input type="submit" value="Upload" />
    </form>
    <select id="hideSelect"></select>
    <hr />
    <iframe src="javascript:false" name="if" id="if"></iframe>

    <script type="text/javascript" src="dom.js"></script>
    <script type="text/javascript" src="hideIframe.js"></script>
</body>
</html>
```

```js:hideIframe.js
/**
 * 変数定義
 */
var
    formE = document.forms[0], //フォーム
    ifE = $('if'), //インラインフレーム
    statusE = create('div'), //ステータス表示要素
    selectHideE = $('hideSelect') //インラインフレームの隠し方についての選択メニュー
;





/**
 * インラインフレームの隠し方についての選択メニュー
 */

/* option要素を挿入 */
insertOptions(selectHideE, {
    'no hide': '',
    'display:none': 'display:none;',
    'zero-size': 'width:0;height:0;margin:0;border:0;',
    '1px': 'width:1px;height:1px;margin:0;border:0;',
    'visibility:hidden': 'visibility:hidden;',
    'position:absolute': 'position:absolute;top:-9999px;left:-9999px;'
});

/* イベントを設定 */
//選択した値をインラインフレームのstyle属性値とする
addEvent(selectHideE, 'change', function () {
    ifE.style.cssText = selectHideE.value;
});



/**
 * ステータス
 */

/* 内容が自動で折り返されるようCSSを設定 */
statusE.style.wordWrap = 'break-word';

/* インラインフレームの前にステータスを挿入 */
before(ifE, statusE);



/**
 * インラインフレーム
 */

/* インラインフレームに読み込みイベントを設定 */
ifE.onload = function () {
    //インラインフレーム内のbody要素のテキストを取得し、
    //それをステータスに表示する
    text(
        statusE,
        text(ifE.contentDocument.body)
    );
};
```

```js:dom.js
/**
 * 関数定義
 */
//id属性による要素取得
function $(id) {
    return document.getElementById(id);
}
//要素作成
function create(tagName) {
    return document.createElement(tagName);
}
//対象要素の前に挿入
function before(target, newNode) {
    target.parentNode.insertBefore(newNode, target);
}
//対象要素の後ろに挿入
function after(target, newNode) {
    target.parentNode.insertBefore(newNode, target.nextSibling);
}
//対象要素の置換
function replace(target, newNode) {
    return target.parentNode.replaceChild(newNode, target);
}
//要素内のテキストを取得/設定
if (document.body.textContent) {
    text = function (target, str) {
        if (typeof str === 'undefined') {
            return target.textContent;
        } else {
            target.textContent = str;
        }
    };
} else {
    text = function (target, str) {
        if (typeof str === 'undefined') {
            return target.innerText;
        } else {
            target.innerText = str;
        }
    };
}
//イベント追加
if (document.body.addEventListener) {
    addEvent = function (target, type, listener) {
        target.addEventListener(type, listener, false);
    };
} else {
    addEvent = function (target, type, listener) {
        target.attachEvent('on' + type, function (e) {
            e = e || window.event;
            if (!e.target) {
                //イベントの追加要素が DOM でない場合、HTMLDocument を発生元要素とする
                e.target = (
                    target.nodeType
                    ? e.srcElement
                    : document.documentElement
                );
            }
            if (!e.currentTarget) {
                e.currentTarget = target;
            }
            if (!e.preventDefault) {
                e.preventDefault = function () {
                    e.returnValue = false;
                };
            }
            if (!e.stopPropagation) {
                e.stopPropagation = function () {
                    e.cancelBubble = true;
                };
            }
            listener.call(target, e);
        });
    };
}
//option要素の作成&挿入
function insertOptions(targetSelect, optionsData) {
    var optionE = create('option');
    for (var key in optionsData) {
        var
            value = optionsData[key],
            oE = optionE.cloneNode(false)
        ;
        oE.value = value;
        text(oE, key);
        targetSelect.appendChild(oE);
    }
}
```

```php:upload.php
<?php
//現在日付とアップロードされたファイルのデータをJSONとして出力
echo json_encode(array(
	time(), //現在日付
	$_FILES //アップロードされたファイルのデータ
));
```

![](https://qiita-image-store.s3.amazonaws.com/0/35948/94c7d8c9-1684-eec3-eac2-9cd19e64e9c4.png)
※画像はGoogle Chrome 34.0.1847.137でのキャプチャです。

インラインフレームのCSSを切り替え、アップロードしてみるコードです。
CSSはそれぞれ、

```css
/* 未設定(インラインフレームを隠さない) */
```
```css
/* displayプロパティ */
display:none;
```
```css
/* サイズ0 */
width:0;
height:0;
margin:0;
border:0;
```
```css
/* サイズ1px */
width:1px;
height:1px;
margin:0;
border:0;
```
```css
/* visibilityプロパティ */
visibility:hidden;
```
```css
/* position:absoluteによる画面外への移動 */
position:absolute;
top:-9999px;
left:-9999px;
```

としました。

### テスト

このファイルをサーバにアップロードし、3DSでテストしてみました。
検証に使用したのは、ニンテンドー3DS コスモブラック。
ブラウザのバージョンは1.7567です。
結果は…

| CSS                                 | 結果                        |
|:-----------------------------------:|:---------------------------:|
| 未設定                               | アップロード成功、ページ全体が更新 |
| displayプロパティ                     | アップロード成功、ページ全体が更新 |
| サイズ0                              | アップロード成功、ページ全体が更新 |
| サイズ1px                            | アップロード成功、ページ全体が更新 |
| visibilityプロパティ                  | アップロード成功、ページ全体が更新 |
| position:absoluteによる画面外への移動  | アップロード成功、ページ全体が更新 |

ことごとく更新されてしまいました。
未設定は当然でしょうが、それ以外のいずれもだめとは…
[FileDrop.js](http://filedropjs.org/)の場合、displayプロパティの設定により回避していたはずなのに…

### ブレイクスルー

[FileDrop.js](http://filedropjs.org/)の複雑なコードを追ってみても、
デベロッパーツールでDOMを覗いても、別段特別な処理は書き込まれていませんでした。
途方に暮れ、訳もわからないまま短い睡眠をとったのが昨日の事です。
しかし今日、テスト中に頭を悩ませていた時、ふとある考えが浮かびました。

「もしかして、クリックが起点となった処理だからか…?」

iOSブラウザには、ユーザのタッチ操作から続く処理でなければ、ビデオを再生することが出来ないという仕様が存在します。
このため、`<video>`要素のautoplay属性による自動再生や、`load`イベントによる再生開始、タイマー処理による再生開始は無効となります。

[iOS/Android で HTML5 の audio/video を任意のタイミングで再生する方法 - webとかmacとかやってみようか R](http://dsuket.hatenablog.com/entry/2013/05/05/101430)

>ユーザーが何かアクションしないと、JavaScript での play() や load() もダメ。つまり、再生ボタンを押して play() を呼べるけど、onload で play() しても無効だから。

これと同じことが3DSブラウザにも起きているのではないか。
具体的には、**クリック(タッチ)して送信したからページ全体の更新が起きている**のではないか?
そう考えました。

上のサンプルは、`<input type="submit">`要素のボタンでフォームを送信しています。
クリック(タッチ)することでボタンを押し、フォームを送信していました。
3DSブラウザは、送信ボタンをクリック(タッチ)してフォームを送信した時、画面を更新する仕様なのかもしれません。

### コードの改良

送信ボタンをクリックした時ではなく、ファイルを選択した時に送信するようにすれば、ページ全体の更新が発生しない可能性があります。
この考えを確かめるため、ファイルを選択した時の`change`イベントでアップロードするよう、コードを改良します。

`hideIframe.js`に以下の内容を追加し、ファイルを選択したら自動で送信するようにします。

```js:hideIframe.js
//hideIframe.jsに追記する
/**
 * 変数定義
 */
var fileE = formE.file; //ファイル選択欄





/**
 * ファイル選択欄
 */

/* イベントを設定 */
addEvent(fileE, 'change', function (e) {
    //フォームを送信
    formE.submit();
});
```

### 再テスト

| CSS                                 | 結果                              |
|:-----------------------------------:|:--------------------------------:|
| 未設定                               | アップロード成功、ページ全体の更新無し |
| displayプロパティ                     | アップロード成功、ページ全体の更新無し |
| サイズ0                              | アップロード成功、ページ全体の更新無し |
| サイズ1px                            | アップロード成功、ページ全体の更新無し |
| visibilityプロパティ                  | アップロード成功、ページ全体の更新無し |
| position:absoluteによる画面外への移動  | アップロード成功、ページ全体の更新無し |

安直にも程がある。

## 結論

検証結果より、3DSブラウザのインラインフレームは、**タッチがきっかけとなったフォーム送信でなければページ全体の更新(のような挙動)が行われない**ようです。
…要は、ファイルを選択したらすぐアップロードする系のライブラリは全部問題ないんだと思われます。
冒頭で「結構有名なファイルアップロードライブラリでも」とか書いてましたが、
そもそも試したライブラリが悪かったか、あるいは少なかったようですね…

## おまけ

オマケとして、アップロード時にファイル選択欄をリセットする処理を追加してみます。
[【JavaScript】fileコントロールの中身をクリアする | システム開発ブログ(システム開発のアイロベックス｜東京都新宿区の業務システム開発会社)](http://www.ilovex.co.jp/blog/system/cat818/javascriptfile.html)

```js:hideIframe.js
//最初のhideIframe.jsに追記する
/**
 * 変数定義
 */
var
    fileE = formE.file, //ファイル選択欄
    fileCoverE = create('span') //ファイル選択欄を包むspan要素
;





/**
 * ファイル選択欄
 */

/* ファイル選択欄をspan要素で包み込む */
//ファイル選択欄をspan要素に置換
replace(fileE, fileCoverE);
//span要素内にファイル選択欄を挿入
fileCoverE.appendChild(fileE);

/* イベントを設定 */
//アップロードするたびファイル選択欄がリセットされイベントが消えるので、
//ファイル選択欄を包むspan要素にイベントを設定
addEvent(fileCoverE, 'change', function (e) {
    //フォームを送信
    formE.submit();
    //ファイル選択欄をリセット
    fileCoverE.innerHTML = fileCoverE.innerHTML;
});
```

## あとがき

さすがのQiitaでも、3DSブラウザのJavaScriptに関する記事は皆無でした。
これが初発となり、このマイナーブラウザに関する記事が増えれば、と思います。
あと、地味に初投稿ですので、文章の言い回しなどおかしな点があるかもしれませんが、ご容赦下さい。
また、JavaScriptは本で学ばず、ネットの情報による独学でやってきた身です。
ので、不足した情報の指摘、検証結果などのコメントは大歓迎します。