JavaScriptのUIオブジェクト設計

JavaScriptでUIクラスを設計するとき、ページ内の要素に対し、それを操作するオブジェクトを割り当てるようにする。

    <form>
      <input id="spinner" type="text" value="0"
        spinner:min="0" spinner:max="100" spinner:step="2"
        spinner:initSpeed="150" /> + 
      <input id="spinner2" type="text" value="0" />
      <script language="JavaScript">
        //<![CDATA[
          var spinner1 = new Spinner();
          spinner1.bind("spinner");
          var spinner2 = new Spinner();
          spinner2.bind("spinner2");
          
          function calc() {
            document.getElementById("result").value = spinner1.getValue() + spinner2.getValue();
          }
          //]]>
      </script>
      <input type="button" value="=" onclick="calc()" />
      <input id="result" type="text" value="0"/>
    </form>

オブジェクトをつくり、IDをbindさせると、そのノードと対応するようになるクラスを作る。

Spinnerクラス:

        function Spinner() {
        }
        Spinner.prototype = {
          min : -Number.MAX_VALUE,
          max : Number.MAX_VALUE,
          step : 1,
          initSpeed : 150,
          count : 0,
          speed : 0,
          running : false,
          
          getValue : function () {
            return parseInt(this.inputNode.value);
          },
          setValue : function (value) {
            this.inputNode.value = value;
          },
          inc : function () {
            this.setValue(Math.min(this.max, this.getValue() + this.step));
          },
          dec : function () {
            this.setValue(Math.max(this.min, this.getValue() - this.step));
          },
          
          bind : function (id) {
            var spinner = this;
            this.id = id;
            this.inputNode = document.getElementById(this.id);
            if (this.inputNode.getAttribute("spinner:min")) {
              this.min = parseInt(this.inputNode.getAttribute("spinner:min"));
            }
            if (this.inputNode.getAttribute("spinner:max")) {
              this.max = parseInt(this.inputNode.getAttribute("spinner:max"));
            }
            if (this.inputNode.getAttribute("spinner:step")) {
              this.step = parseInt(this.inputNode.getAttribute("spinner:step"));
            }
            if (this.inputNode.getAttribute("spinner:initSpeed")) {
              this.initSpeed = parseInt(this.inputNode.getAttribute("spinner:initSpeed"));
            }
            this.inputNode.onchange = function () {
              var text = spinner.inputNode.value;
              spinner.inputNode.value = text.replace(new RegExp("[^0-9.+-]", "g"), "");
            };
            var refNode = this.inputNode.nextSibling;
            this.plusButton = document.createElement("input");
            this.plusButton.type = "button";
            this.plusButton.value = "+";
            var plusFunc = function () {
              if (spinner.running) {
                spinner.count += 1;
                if (spinner.count % 10 === 0) {
                  spinner.speed = Math.max(10, spinner.speed / 2);
                }
                spinner.inc();
                setTimeout(plusFunc, spinner.speed);
              }
            };
            this.plusButton.onmousedown = function () {
              spinner.running = true;
              spinner.count = 0;
              spinner.speed = spinner.initSpeed;
              spinner.inc();
              setTimeout(plusFunc, spinner.speed);
            };
            this.plusButton.onmouseup = function () {
              spinner.running = false;
            };
            this.inputNode.parentNode.insertBefore(this.plusButton, refNode);
            
            this.minusButton = document.createElement("input");
            this.minusButton.type = "button";
            this.minusButton.value = "-";
            var minusFunc = function () {
              if (spinner.running) {
                spinner.count += 1;
                if (spinner.count % 10 === 0) {
                  spinner.speed = Math.max(10, spinner.speed / 2);
                }
                spinner.dec();
                setTimeout(minusFunc, spinner.speed);
              }
            };
            this.minusButton.onmousedown = function () {
              spinner.running = true;
              spinner.count = 0;
              spinner.speed = spinner.initSpeed;
              spinner.dec();
              setTimeout(minusFunc, spinner.speed);
            };
            this.minusButton.onmouseup = function () {
              spinner.running = false;
            };
            this.inputNode.parentNode.insertBefore(this.minusButton, refNode);
            
          },
        };

あまりに長いのでリファクタリングする。

リファクタリング

このSpinnerクラスでは、スピナー用ボタンなどを挿入するbind部分のコードがかなり長くなる。前半は要素からのパラメータの設定であり、後半はUIの構築コードになる。パラメータ設定はパターン的で単純にユーティリティ化することが可能。UI構築のほうはより細かなUIオブジェクトを作ることで単純化できる。

パラメータ読み込み関数(使用法は以下のRepeatButtonのbindメソッド参照):

        function lookupAttribute(node, object, prefix, paramTypes) {
          for (var attrName in paramTypes) {
            var nodeAttrName = prefix + attrName;
            if (node.getAttribute(nodeAttrName)) {
              if (paramTypes[attrName] == "string") {
                object[attrName] = node.getAttribute(nodeAttrName);
              } else if (paramTypes[attrName] == "boolean") {
                object[attrName] = Boolean(node.getAttribute(nodeAttrName));
              } else if (paramTypes[attrName] == "int") {
                object[attrName] = parseInt(node.getAttribute(nodeAttrName));
              } else if (paramTypes[attrName] == "float") {
                object[attrName] = parseFloat(node.getAttribute(nodeAttrName));
              } else if (paramTypes[attrName] == "function") {
                var code = node.getAttribute(nodeAttrName);
                object[attrName] = function () { return eval(code); };
              }
            }
          }
        }

RepeatButtonクラス:

        function RepeatButton() {
        }
        RepeatButton.prototype = {
          func : function () {},
          running : false,
          count : 0,
          speed : 0,
          initSpeed : 0,
          
          bind : function (id) {
            this.buttonNode = document.getElementById(id);
            lookupAttribute(this.buttonNode, this, "repeatbutton:", {
              initSpeed : "int", func : "function", 
            });
            this.init();
          },
          
          create : function (value, initSpeed, func) {
            this.buttonNode = document.createElement("input");
            this.buttonNode.setAttribute("type", "button");
            this.buttonNode.value = value;
            this.initSpeed = initSpeed;
            this.func = func;
            this.init();
            return this.buttonNode;
          },
          
          init : function () {
            var repeat = this;
            var repeatFunc = function () {
              if (repeat.running) {
                repeat.count += 1;
                if (repeat.count % 10 === 0) {
                  repeat.speed = Math.max(10, repeat.speed / 2);
                }
                repeat.func();
                setTimeout(repeatFunc, repeat.speed);
              }
            };
            this.buttonNode.onmousedown = function () {
              repeat.running = true;
              repeat.count = 0;
              repeat.speed = repeat.initSpeed;
              repeat.func();
              setTimeout(repeatFunc, repeat.speed);
            };
            this.buttonNode.onmouseup = function () {
              repeat.running = false;
            };
            this.buttonNode.onmouseout = function () {
              repeat.running = false;
            };
          },
        };

そしてこれらを使うと、SpinnerのUI構築コードは以下のようになる:

        function Spinner() {
        }
        Spinner.prototype = {
          min : -Number.MAX_VALUE,
          max : Number.MAX_VALUE,
          step : 1,
          initSpeed : 150,
          
          getValue : function () {
            return parseInt(this.inputNode.value);
          },
          setValue : function (value) {
            this.inputNode.value = value;
          },
          inc : function () {
            this.setValue(Math.min(this.max, this.getValue() + this.step));
          },
          dec : function () {
            this.setValue(Math.max(this.min, this.getValue() - this.step));
          },
          bind : function (id) {
            this.id = id;
            this.inputNode = document.getElementById(this.id);
            lookupAttribute(this.inputNode, this, "spinner:", {
              min : "int", max : "int", step : "int", initSpeed : "int", 
            });
            this.init();
          },
          init : function () {
            var spinner = this;
            var refNode = this.inputNode.nextSibling;
            this.repeatPlus = new RepeatButton();
            this.plusButton = this.repeatPlus.create("+", this.initSpeed, function () {spinner.inc();});
            this.inputNode.parentNode.insertBefore(this.plusButton, refNode);
            
            this.repeatMinus = new RepeatButton();
            this.minusButton = this.repeatMinus.create("-", this.initSpeed, function () {spinner.dec();});
            this.inputNode.parentNode.insertBefore(this.minusButton, refNode);
          },
        };

bind以下の部分がかなり短くなる。