getElementsBySelector()

rsky2006-03-19


昨日の記事はこれの伏線でした。
セレクタのパーザが一通りできて、さて子要素のスキャンをどうしてくれようと考えあぐね、同じようなことを既にやった人がいるんだろうなと思って探してみたら、やっぱりおられました。

上記リンクのスクリプトを見るまで知らなかったのですが

node.getElementsByTagName(tag)

という方法で特定のノード以下の要素だけを抽出することができるんですね。こいつぁ便利だ。おかげで完成させることができました。(補足:てっきり document オブジェクトでしか使えないものかと思ってた)
いくらか不備もありますが、それなりに便利だと思います。

仕様は以下のとおり。

  • Safari 2.0.3, Firefox 1.5.0.1, IE6SP1, Opera 8.53 で動作確認。
  • 要素名または * は省略できない。
  • 半角スペース, >, + 区切りでセレクタを組み合わせることができる。
  • カンマ区切りで複数のセレクタを指定することはできない。
  • 一つの要素に対してクラス, 属性セレクタを複数指定できる。(ID セレクタは一つだけ)
  • 疑似クラス, 疑似要素セレクタは使えない。
  • セレクタのパースに失敗したときは問題がある (と思われる) 箇所を教えてくれる。
  • 名前空間接頭辞つきの要素名, 属性名を使うことができる。コロンはバックスラッシュでエスケープすること。
    ただし、以下の理由により非推奨。
    • Opera 8.53 では動作しなかった。また、IE6SP1 では名前空間接頭辞つきの要素に appendChild() できない?
    • getElementsByTagNameNS()/getAttributeNS() は使わず、getElementsByTagName('ns:tag')/getAttribute('ns:attr') しており、上記の動作確認したブラウザより古いブラウザではまともに動かない可能性が高く、また将来のバージョンで同様に動作する保証も無い。
  • CSS2 の仕様書をちゃんと読んでおらず、トークンの定義が間違っている可能性がある。
  • たぶん、バグがある。

サンプルはここ数日のやつの使い回し。

test.html

BASE64 の箇所と mynifty2.js は昨日の物と同じです。

<html>
<head>
<title>test</title>
<script type="text/javascript" src="getelementsbyselector.js"></script>
<script type="text/javascript" src="mynifty2.js"></script>
<script type="text/javascript">
var Beveler = new MyNiftyCorner(0, '#fff', '#8af', 12, 1, '#000', 3);
var Rounder = new MyNiftyCorner(1, '#8af', '#fff', 10);

var b64
  = 'iVBORw0KGgoAAAANSUhEUgAAAGAAAAAYCAYAAAFy7sgCAAAGsUlEQVRo3u2ZbWwc'
  /* snip */
  + 'sCYAZ2ngD8CfAkzqTpW7xY//SznyX/VeUb2kVmX4AAAAAElFTkSuQmCC';

window.onload = function() {
  var txt = document.createTextNode(b64.replace(/(.{72})/g, '$1\n'));
  var obj1 = document.createElement('object');
  obj1.setAttribute('data', 'data:image/png;base64,' + b64);
  var obj2 = obj1.cloneNode(true);
  obj2.style.width = '276px';
  obj2.style.height = '72px';

  document.getElementById('pre').appendChild(txt);
  document.getElementById('flt').appendChild(obj1);
  document.getElementById('abs').appendChild(obj2);

  Beveler.roundAndPad(document.body);
  Rounder.roundAndPad.mapToElementsBySelector('*#pre.p[xml\\:lang|="ja"] > div.a.b');
  var x = document.getElementsBySelector('ns\\:hoge');
  if (x.length) {
    var b = document.createElement('b');
    b.appendChild(document.createTextNode('hoge'));
    x[0].parentNode.insertBefore(b, x[0]);
    x[0].appendChild(b.cloneNode(true));
  }
}
</script>
<style type="text/css">
body { margin:10px; padding:0px; background-color:#8af; }
#pre { position:relative; white-space:pre; margin:0px; background-color:#fff;
  font-family:monospace; font-size:12px; line-height:100%; }
#abs { position:absolute; bottom:10%; left:10%; z-index:1;
  font:36px bold; color:red; opacity:0.5; }
#flt { float:left; font:36px bold; color:red; }
</style>
</head>
<body>
<div id="pre" class="p" xml:lang="ja-JP"><div id="abs" class="a b"></div><div id="flt" class="a"></div></div>
<ns:hoge></ns:hoge>
</body>
</html>

getelementsbyselector.js

// {{{ document.getElementsBySelector()

/**
 * CSS2 のセレクタで DOM Element を取得するメソッド
 * document オブジェクトを拡張
 */
document.getElementsBySelector = function(selector)
{
  // {{{ カプセル化されたユーティリティ関数群
  // {{{ in_array()

  /**
   * 変数 v が配列 a に含まれるか確認する関数
   */
  function in_array(v, a)
  {
    var i;
    for (i = 0; i < a.length; i++) {
      if (v == a[i]) {
        return true;
      }
    }
    return false;
  }

  // }}}
  // {{{ array_unique()

  /**
   * 配列 a からユニークな値を取り出す関数
   * 力技であるのは否めず、もっとスマートにしたい
   */
  function array_unique(a)
  {
    var i, A = [];
    for (i = 0; i < a.length; i++) {
      if (!in_array(a[i], A)) {
        A.push(a[i]);
      }
    }
    return A;
  }

  // }}}
  // {{{ getElements()

  /**
   * 要素を走査する関数
   */
  function getElements(root, slctr, dvsr)
  {
    // {{{ getElements - checkAttributes()

    /**
     * 属性をチェックする関数
     */
    function checkAttributes(elem)
    {
      if (!slctr.attributes) {
        return true;
      }
      var attr, val, i = 0, j;
      while (i < slctr.attributes.length) {
        if ((attr = elem.getAttribute(slctr.attributes[i].name)) === null) {
          return false;
        }
        val = attr.toString();
        if (slctr.attributes[i].value) {
          if (slctr.attributes[i].value != val) {
            return false;
          }
        } else if (slctr.attributes[i].list) {
          if (!in_array(val, slctr.attributes[i].list)) {
            return false;
          }
        } else if (slctr.attributes[i].re) {
          if (!slctr.attributes[i].re.test(val)) {
            return false;
          }
        }
        i++;
      }
      return true;
    }

    // }}}
    // {{{ getElements - checkClasses()

    /**
     * クラスをチェックする関数
     */
    function checkClasses(elem)
    {
      if (!slctr.classes) {
        return true;
      }
      if (!elem.className) {
        return false;
      }
      var i = 0;
      while (i < slctr.classes.length) {
        if (!slctr.classes[i].test(elem.className)) {
          return false;
        }
        i++;
      }
      return true;
    }

    // }}}
    // {{{ getElements - checkTagName()

    /**
     * タグ名をチェックする関数
     * 本当は大文字小文字も比較したいが、DOMElement.tagName() が大文字で
     * 返すようなのでケースインセンシティブにする
     */
    var lt = slctr.tag.toLowerCase();
    function checkTagName(elem)
    {
      if (elem.nodeType != 1) {
        return false;
      }
      if (slctr.tag == '*' || elem.tagName.toLowerCase() == lt) {
        return true;
      }
      return false;
    }

    // }}}
    // {{{ getElements - 対象要素を選択

    var i, j, item, items = [];
    if (slctr.id) {
      item = root.getElementById(slctr.id);
      if (!item || !checkTagName(item)) {
        return [];
      }
      items.push(item);
    } else if (dvsr == '+')  {
      if (!root.nextSibling || !checkTagName(root.nextSibling)) {
        return [];
      }
      items.push(root.nextSibling);
    } else if (dvsr == '>')  {
      for (i = 0; i < root.childNodes.length; i++) {
        if (checkTagName(root.childNodes[i])) {
          items.push(root.childNodes[i]);
        }
      }
    } else {
      items = root.getElementsByTagName(slctr.tag);
    }

    // }}}
    // {{{ getElements - 対象要素を絞り込む

    var elems = [];
    for (i = 0; i < items.length; i++) {
      /* 属性値・クラス名がマッチしないときは次に進む */
      if (!checkAttributes(items[i]) || !checkClasses(items[i])) {
        continue;
      }
      /* 疑似セレクタが指定されていないときは対象に追加する */
      //if (!slctr.pseudoes) {
        elems.push(items[i]);
      //  continue;
      //}
      /* 疑似セレクタを判定 */
      //for (j = 0; j < slctr.pseudoes.length; j++) {
      //  (...)
      //}
    }

    // }}}

    return elems;
  }

  // }}}
  // {{{ warn()

  /**
   * エラー情報を表示する関数
   */
  function warn(offset)
  {
    var i, errmsg = [];
    for (i = 1; i < arguments.length; i++) {
      errmsg.push(arguments[i]);
    }
    errmsg.push('The given selectors are "' + selector + '".');
    errmsg.push('An error found at offset ' + offset.toString() + ', near "' + selector.substr(offset, 5) + '".');
    window.alert(errmsg.join('\n'));
  }

  // }}}
  // }}}
  // {{{ 変数の初期化

  // valid characters
  var c = '[A-Za-z_][0-9A-Za-z_\\-]*';
  // valid characters with namaspace
  var ns = '(?:'+c+'\\\\:)?'+c;
  // regular expression object of valid characters
  var re = new RegExp('^'+c+'$');

  // regular expression of valid selector
  var slctr_pat = '(\\*|'+ns+')((?:\\['+ns+'(?:[~|]?=".*?")?\\]|[#.:]'+c+')*)';
  // regular expression of pseudo/id/class/attribute selector (P.I.C.A.S.)
  var picas_pat = '\\[('+ns+')(?:([~|]?=)"(.*?)")?\\]|([#.:])('+c+')';
  // regular expression object of valid selector
  var slctr_re = new RegExp(slctr_pat, 'g');
  // regular expression object of P.I.C.A.S.
  var picas_re = new RegExp();

  // last match position of selector
  var slctr_pos = 0;
  // offset from last match position of selector
  var slctr_off = 0;
  // last match position of P.I.C.A.S.
  var picas_pos = 0;
  // offset from last match position of P.I.C.A.S.
  var picas_off = 0;

  // list of elements
  var roots = [document];
  // variables for scanning
  var slctr, dvsr, elems, i, j, m, n, o, p;

  // }}}
  // {{{ セレクタをパースし、要素を走査

  while (m = slctr_re.exec(selector)) {
    /* 前回の検索終了位置と今回の検索開始位置の差分 */
    slctr_off = slctr_re.lastIndex - m[0].length - slctr_pos;
    /* セレクタ区切り */
    if (slctr_pos > 0 && slctr_off == 0) {
      warn(slctr_pos, 'Bad selector given.');
      return [];
    } else if (slctr_off != 0) {
      dvsr = selector.substr(slctr_pos, slctr_off).replace(/^ +| +$/g, '');
      if (!in_array(dvsr, ['', '+', '>'])) {
        warn(slctr_pos, 'Bad selector given.');
        return [];
      }
    } else {
      dvsr = '';
    }
    /* タグ名 or "*" */
    slctr = {tag:m[1].replace('\\', '')};
    /* 属性/ID/クラス/疑似 セレクタをパース */
    if (m[2]) {
      //picas_re.compile(picas_pat, 'g'); // not works with Safari.
      picas_re = new RegExp(picas_pat, 'g');
      picas_pos = 0;
      p = slctr_re.lastIndex - m[2].length;
      while (n = picas_re.exec(m[2])) {
        /* 全体の先頭から今回の検索開始位置へのオフセット */
        o = p + picas_pos;
        /* 前回の検索終了位置と今回の検索開始位置の差分 */
        picas_off = picas_re.lastIndex - n[0].length - picas_pos;
        if (picas_off != 0) {
          warn(o, 'Bad selector given.');
          return [];
        }
        /* 属性セレクタ */
        if (n[1]) {
          o += 1 + n[1].length;
          if (!slctr.attributes) {
            slctr.attributes = [];
          }
          j = slctr.attributes.length;
          slctr.attributes[j] = {name:n[1].replace('\\', '')};
          switch (n[2]) {
          case '=':
            slctr.attributes[j].value = n[3];
            break;
          case '~=':
            slctr.attributes[j].list = n[3].split(' ');
            break;
          case '|=':
            if (!re.test(n[3])) {
              warn(o + 3, 'Only ' + c + ' can be used with "|=" attribute operator.');
              return[];
            }
            slctr.attributes[j].re = new RegExp('^' + n[3] + '(-|$)');
            break;
          }
        /* {ID,クラス,疑似}セレクタ */
        } else {
          //o += 1;
          switch (n[4]) {
          case '#':
            if (slctr.id) {
              warn(o, 'Only 1 id selector is allowed.');
              return [];
            }
            slctr.id = n[5];
            break;
          case '.':
            if (!slctr.classes) {
              slctr.classes = [];
            }
            slctr.classes.push(new RegExp('\\b' + n[5] + '\\b'));
            break;
          case ':':
            warn(o, 'Pseudo selectors are not supported.');
            return [];
            //if (!slctr.pseudoes) {
            //  slctr.pseudoes = [];
            //}
            //slctr.pseudoes.push(n[5]);
            break;
          }
        }
        /* 検索終了位置を記録 */
        picas_pos = picas_re.lastIndex;
      }
    }
    /* 要素を走査 */
    elems = [];
    for (i = 0; i < roots.length; i++) {
      elems = elems.concat(getElements(roots[i], slctr, dvsr));
    }
    if (elems.length == 0) {
      return [];
    }
    roots = array_unique(elems);
    /* 検索終了位置を記録 */
    slctr_pos = slctr_re.lastIndex;
  }

  // }}}
  // {{{ 最終チェック

  if (slctr_pos == 0) {
    warn(0, 'Bad selector given.');
    return [];
  } else if (slctr_pos != selector.length) {
    if (selector.substring(slctr_pos, selector.length).replace(/ +$/g, '') != '') {
      warn(slctr_pos, 'Bad selector given.');
      return [];
    }
  }

  // }}}

  return roots;
}

// }}}
// {{{ Function.prototype.mapToElementsBySelector()

/**
 * CSS2 のセレクタによって DOM Element を取得し、一つずつ
 * それを第一引数にとる関数に適用するメソッド
 * 任意の個数の追加の引数を指定することも可能
 * Function オブジェクトのプロトタイプを拡張
 */
Function.prototype.mapToElementsBySelector = function(selector)
{
  var elements = document.getElementsBySelector(selector);
  var params = [];
  for (var i = 1; i < arguments.length; i++) {
    params.push(arguments[i]);
  }
  for (var j = 0; j < elements.length; j++) {
    params.unshift(elements[j]);
    this.apply(this, params);
    params.shift();
  }
}

// }}}