リアルタイム入力チェック(inputイベント)のクロスブラウザコード(polyfill?)の例


## 導入

リアルタイム入力チェックのためのイベントとして、DOM Level 3 Eventsでは`input`イベントが定義されています。
比較的最近のブラウザはサポートしているものの、未だに非対応のブラウザが存在します。

今回、~~[Dottoro Web Referenceの記事][dwr]~~ <ins>[Matt's 411の記事][m411]</ins>に3DS用の処理を加筆する形で`input`イベントのクロスブラウザ用コードを書いてみました。これについて意見をお願いします。

特に、以下に挙げる環境での検証結果を教えていただけるとありがたいです。

* iOS
* Android
* Windows Phone
* Macintoshの各ブラウザ
* Newニンテンドー3DS
* PlayStation Vita

## 追記

IE9では、`input`イベントで**文字を削除した時にイベントが発生しない**バグが存在するようです。
よく読んだら[Dottoro Web Referenceの記事][dwr]にもそのような記述が存在しました。

> Both the **oninput** and [onpropertychange](http://help.dottoro.com/ljufknus.php) events are supported in Internet Explorer 9, but both of them are buggy. They are not fired when characters are deleted only when inserted.

[新コード]は、[IE9でも問題なく動作するコード][m411]を踏まえ、なおかつテストがやりやすくなるよう改良してあります。
一応[旧コード]も残しますが、採用しないよう注意してください。

## 新

### 概要

[IE9でも問題なく動作するコード][m411]にあった、`input`イベントと`selectionchange`イベントを併用(`selectionchange`イベントはフォーカス時に追加し、フォーカスが外れた時に剥がす)しているコードに、`setTimeout`ベースのタイマー処理による入力値の検証処理を追加しています。
`selectionchange`イベントと同様、タイマーを動作しているのはフォーカス中のみのため、負荷は少ないはずです。加えて、`selectionchange`イベントが動作する場合は即座にタイマーを停止し、以降はイベントを設定した全てのフォームでタイマー処理を行わないようにしています。
IE8以下は、[Dottoro Web Referenceの記事][dwr]にあった`propertychange`イベントによる処理で判定しています。

原理上、フォーカスイベントにさえ対応していればタイマー処理で監視可能なため、DOM Level 2 Eventsに対応する全ての環境で動作するものと考えられます。

### コード

```html:input_event.html
<!DOCTYPE html>
<html>
<head>
    <!--[if IE]><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"><![endif]-->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <meta name="format-detection" content="telephone=no">
    <title>input event JavaScript</title>
</head>
<body>
    <h1>input event JavaScript</h1>
    <input type="text" id="textinput">
    <p id="output1"></p>
    <textarea id="textarea"></textarea>
    <p id="output2"></p>
    <script src="input_event.js" async defer></script>
</body>
</html>
```

```js:input_event.js
var documentElement = document.documentElement;

/**
 * 要素にテキストを設定する。textContentプロパティのクロスブラウザ関数
 * @param {Node} targetNode 設定対象の要素
 * @param {string} text 設定するテキスト
 */
var setText =
    ('textContent' in documentElement)
    ? function (targetNode, text) {
        /**
         * 対象要素のtextContentプロパティに新しいテキストを代入
         */
        targetNode.textContent = text;
    }
    : function (targetNode, text) {
        /**
         * 対象要素内の全子要素を削除
         */
        var childNode;
        while ((childNode = targetNode.firstChild)) {
            targetNode.removeChild(childNode);
        }
        /**
         * テキストノードを挿入
         */
        targetNode.appendChild(
            targetNode.ownerDocument.createTextNode(text)
        );
    }
;

/**
 * クロスブラウザなinputイベントを設定する
 * @param {Node} targetNode 設定対象の要素
 * @param {function(Event)} listener 設定するイベントリスナー(実行関数)
 */
var addInputEventListener =
    (documentElement.addEventListener)
    ? function (targetNode, listener) {
        var document = targetNode.ownerDocument;

        /**
         * タイマーによるinputイベント処理の際、タイマーIDを格納する
         * @type {number}
         */
        var timeoutID;
        /**
         * 重複したイベントの発生を防ぐため、
         * 最後のイベント発生時の入力値を格納する
         * @type {string}
         */
        var lastValue = targetNode.value;
        /**
         * 入力値が変化したか検証する
         * @return {boolean} 入力値が変化している場合、trueを返す
         */
        var isValueUpdated = function () {
            /**
             * 最後のイベント発生時の入力値と比較し、
             * 変更されていた場合にtrueを出力する
             */
            return lastValue !== targetNode.value;
        };
        /**
         * 入力値が変化していた場合、
         * DOM Level 2 Eventsで可能な範囲で擬似inputイベントを作成し、
         * 対象要素に対してディスパッチ(発火)させる。
         */
        var dispatchInputEvent = function () {
            if (isValueUpdated()) {
                var event = document.createEvent('HTMLEvents');
                event.initEvent('input', true, false);
                targetNode.dispatchEvent(event);
            }
        };
        /**
         * タイマーによるinputイベント処理のための関数。
         * selectionchangeイベントに対応していない場合、
         * setTimeoutで250ミリ秒毎にこの関数を呼び出す。
         */
        var timerInput = function () {
            /**
             * DOM Level 2 Eventsで可能な範囲で擬似inputイベントを作成し、
             * 対象要素に対してディスパッチ(発火)させる。
             */
            dispatchInputEvent();
            /**
             * selectionchangeイベントに対応していない場合、
             * setTimeoutで250ミリ秒後に、
             * timerInput関数を呼び出す。
             */
            if (!addInputEventListener._support_selectionchange_event) {
                timeoutID = setTimeout(timerInput, 250);
            }
        };
        /**
         * selectionchangeイベントによる
         * inputイベント処理のための関数。
         * `_support_selectionchange_event`をtrueに変更し、
         * 対応フラグを有効にする。
         */
        var onSelectionchange = function () {
            addInputEventListener._support_selectionchange_event = true;
            /**
             * DOM Level 2 Eventsで可能な範囲で擬似inputイベントを作成し、
             * 対象要素に対してディスパッチ(発火)させる。
             */
            dispatchInputEvent();
        };

        targetNode.addEventListener('input', function (event) {
            /**
             * 最後のイベント発生時の入力値と比較し、
             * 変更されていた場合に
             * 指定された処理を実行する。
             */
            if (isValueUpdated()) {
                lastValue = targetNode.value;
                listener.call(targetNode, event);
            }
        }, false);
        targetNode.addEventListener('focus', function () {
            /**
             * selectionchangeイベントを追加
             */
            document.addEventListener('selectionchange', onSelectionchange, false);
            /**
             * selectionchangeイベントに対応していない場合、
             * timerInput関数を呼び出し
             * タイマーによるinputイベント処理を開始する。
             */
            if (!addInputEventListener._support_selectionchange_event) {
                timerInput();
            }
        }, false);
        targetNode.addEventListener('blur', function () {
            /**
             * selectionchangeイベントを削除
             */
            document.removeEventListener('selectionchange', onSelectionchange, false);
            /**
             * タイマーを停止
             */
            clearTimeout(timeoutID);
        }, false);
    }

    : (documentElement.attachEvent)
    ? function (targetNode, listener) {
        var document = targetNode.ownerDocument;
        var window = document.defaultView;

        /**
         * addEventListenerメソッドに非対応で
         * attachEventメソッドに対応している場合、
         * propertychangeイベントでinputイベントの処理を行う。
         */
        targetNode.attachEvent('onpropertychange', function (event) {
            event = event || window.event;

            if (event.propertyName.toLowerCase() === 'value') {
                event.preventDefault = event.preventDefault || function () { event.returnValue = false; };
                event.stopPropagation = event.stopPropagation || function () { event.cancelBubble = true; };
                event.target = event.target || event.srcElement || document.documentElement;
                event.currentTarget = event.currentTarget || targetNode;
                event.timeStamp = event.timeStamp || (new Date()).getTime();

                listener.call(targetNode, event);
            }
        });
    }

    : function (targetNode, listener) { }
;
/**
 * @type {boolean}
 * selectionchangeイベント対応時にtrueとなる
 */
addInputEventListener._support_selectionchange_event = false;





var textinput = document.getElementById('textinput');
var textarea = document.getElementById('textarea');
var output1 = document.getElementById('output1');
var output2 = document.getElementById('output2');

addInputEventListener(textinput, function () {
    setText(output1, textinput.value);
});
addInputEventListener(textarea, function () {
    setText(output2, textarea.value);
});
```

### テスト

[input event JavaScript](http://jsbin.com/waguf)

### 動作確認ブラウザ

*以下の動作確認テストはJS Bin上では行っていません。JS Binでは無関係なJavaScriptが挿入され、それが動作に影響するブラウザがあったため、ローカルサーバでテストしています。*

* Windows 7 Home Premium 64bit
  + Internet Explorer 11.0.9600.17801
     - Internet Explorer 5, 7, 8, 9, 10 (F12 開発者ツール)
  + Google Chrome 38.0.2125.111 m (64-bit)
  + Firefox 33.0.2
  + Opera 12.17
  + Netscape Navigator 9.0.0.6
  + Lunascape 6.9.3
     - Gecko
     - WebKit
     - Trident
  + Sleipnir 4.3.12.4000
     - Blink
     - IE11
     - IE7 互換
  + Grani 4.7
  + midori 0.5.9
  + GreenBrowser 6.3.0822
  + Konqueror 4.10.2
  + Windroy
     - Android 4.0.3
         * ブラウザ
  + Andy
     - Android 4.2.2
         * ブラウザ
         * Google Chrome 38.0.2125.114
  + Genymotion 2.3.0
     - Android 2.3.7 (Google Nexus One)
         * ブラウザ
     - Android 4.3 (Google Nexus 4)
         * ブラウザ
         * Google Chrome 39.0.2171.59
* iOS 8.1
  + iPod touch
     - Mobile Safari 8.0
* iOS 8.3
  + iPad
     - Mobile Safari 8.0
     - Google Chrome 42.0.2311.47
* Nintendo 3DS (NetFront(R) Browser NX v1.0)
* Nintendo Wii U (NetFront(R) Browser NX v3.0)

## 旧

※以下のコードは推奨されません。[新コード]を利用してください。**下のコードを使用しないでください。**

### コード

```html
<!DOCTYPE html>
<html>
<head>
  <!--[if IE]><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"><![endif]-->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
  <meta name="format-detection" content="telephone=no">
  <title>input event JavaScript</title>
</head>
<body>
  <p>Please modify the contents of the text field.</p>
  <textarea id="textarea">Textarea</textarea>
</body>
</html>
```

```js
function new_content(value) {
    alert("The new content: " + value);
}
function following_text(value) {
    alert("The following text has been entered: " + value);
}

var textarea = document.getElementById("textarea");

if (textarea.addEventListener) {
    /**
     * all browsers except IE before version 9
     */

    // Nintendo 3DS
    var timeoutID;
    var old_value = textarea.value;
    var focus_listener = function () {
        var now_value = textarea.value;
        if (old_value !== now_value) {
            new_content(now_value);
            old_value = now_value;
        }
        timeoutID = setTimeout(function () {
            focus_listener();
        }, 250);
    };
    var blur_listener = function () {
        clearTimeout(timeoutID);
    };
    var remove_3dsInput = function () {
        textarea.removeEventListener("focus", focus_listener, false);
        blur_listener();
        textarea.removeEventListener("blur", blur_listener, false);
    };
    textarea.addEventListener("focus", focus_listener, false);
    textarea.addEventListener("blur", blur_listener, false);


    // Google Chrome, Safari and Internet Explorer from version 9
    var OnTextInput = function (event) {
        remove_3dsInput();
        following_text(event.data);
    };

    // Firefox, Google Chrome, Opera, Safari from version 5, Internet Explorer from version 9
    textarea.addEventListener("input", function (event) {
        remove_3dsInput();
        new_content(event.target.value);
    }, false);

    // Google Chrome and Safari
    textarea.addEventListener("textInput", OnTextInput, false);

    // Internet Explorer from version 9
    textarea.addEventListener("textinput", OnTextInput, false);
} else if (textarea.attachEvent) {
    /**
     * Internet Explorer and Opera
     */

    textarea.attachEvent("onpropertychange", function (event) {
        if (event.propertyName.toLowerCase() == "value") {
            new_content(event.srcElement.value);
        }
    });
}
```

### テスト

[input event JavaScript](http://jsbin.com/ribapo)

### 動作確認ブラウザ

* Windows 7 Home Premium 64bit
  + Internet Explorer 11.0.9600.17358
  + Internet Explorer 5, 7-9(F12 開発者ツール)
  + Google Chrome 38.0.2125.111 m (64-bit)
  + Firefox 33.0, 33.0.2
  + Opera 12.17
  + Netscape Navigator 9.0.0.6
  + Lunascape 6.9.2
  + Sleipnir 4.3.10.4000
  + Grani 4.7
  + midori 0.5.8
* Nintendo 3DS(NetFront(R) Browser NX v1.0)

## 参考サイト

* [oninput event | input event JavaScript][dwr]
* [Internet Explorer 9 oninput event | Matt's 411][m411]

[dwr]:http://help.dottoro.com/ljhxklln.php
[m411]:http://www.matts411.com/post/internet-explorer-9-oninput/
[新コード]:#%E6%96%B0
[旧コード]:#%E6%97%A7