NodeListを配列に変換するのにArray.applyを使うのはどうか

NodeListHTMLCollectionを配列(Array)に変換する方法の一つに、Array.prototype.sliceを使う方法がある。

var array = Array.prototype.slice.call(document.getElementsByTagName('A'));

でも、最近の実装であればArray.applyもその用途に使える。

var array = Array.apply(null, document.getElementsByTagName('A'));

ES3ではFunction.prototype.applyの第二引数には配列(Array)かargumentsしか取れなかったけど、ES5でその制限が緩和され、NodeListなどの array like オブジェクトも渡せるようになった。

なお、IE8以下ではどちらの方法でもエラーになるので注意(エラーメッセージは「JScript オブジェクトを指定してください」)。ループで処理するしかない。

var array = [];
var nodes = document.getElementsByTagName('A');
for (var i = 0, l = nodes.length; i < l; ++i) {
    array[i] = nodes[i];
}

ちなみに、argumentsの配列化にArray.applyは使えない。argumentsは要素が単一の数値だけになる可能性があり、その場合意図しない結果になる。

(function () {
    var args = Array.apply(null, arguments); // Array(5, 6, 7) つまり [5, 6, 7]
    console.log(args.shift()); // 5
})(5, 6, 7);

(function () {
    var args = Array.apply(null, arguments); // Array(5) つまり length: 5 の配列
    console.log(args.shift()); // undefined
})(5);

lengthが1でないことを確かめてからなら使える(参考:slice vs [].apply · jsPerf)けど、Array.prototype.sliceを使う方がシンプル。

(function () {
    var args = arguments.length === 1 ? [arguments[0]] : Array.apply(null, arguments);
    console.log(args.shift()); // 5
})(5);

(function () {
    var args = Array.prototype.slice.call(arguments);
    console.log(args.shift()); // 5
})(5);

おまけ:ES3実装およびIE8以下でFunction.prototype.applyをES5仕様にするモンキーパッチ。

Array/Stringの汎用メソッドをこだわって定義する

FirefoxではArray.prototypeやString.prototypeにあるメソッドを簡単に転用できるように、コンストラクタ(Array, String)自身に汎用メソッド(generic method)が定義されています。
これにより、

  • Array.prototype.forEach.call(nodeList, body.appendChild, body)
    Array.forEach(nodeList, body.appendChild, body) と書くことができる
  • Array.prototype.slice.call(arguments, 1)
    Array.slice(arguments, 1) と書くことができる

ところが、これはECMAScript標準ではないので、Firefox以外では定義されていません。Array | MDNに次のようなshimが用意されています。

/*globals define*/
// Assumes Array extras already present (one may use shims for these as well)
(function () {
    'use strict';
 
    var i,
        // We could also build the array of methods with the following, but the
        //   getOwnPropertyNames() method is non-shimable:
        // Object.getOwnPropertyNames(Array).filter(function (methodName) {return typeof Array[methodName] === 'function'});
        methods = [
            'join', 'reverse', 'sort', 'push', 'pop', 'shift', 'unshift',
            'splice', 'concat', 'slice', 'indexOf', 'lastIndexOf',
            'forEach', 'map', 'reduce', 'reduceRight', 'filter',
            'some', 'every', 'isArray'
        ],
        methodCount = methods.length,
        assignArrayGeneric = function (methodName) {
            var method = Array.prototype[methodName];
            Array[methodName] = function (arg1) {
                return method.apply(arg1, Array.prototype.slice.call(arguments, 1));
            };
        };
 
    for (i = 0; i < methodCount; i++) {
        assignArrayGeneric(methods[i]);
    }
}());

isArrayは消さなければなりません。ミスだと思います。使う分にはこれで十分ですが、次の点が気になります。

  • 定義される関数は無名関数(nameなし)なので、デバッグ時のコールスタックが嫌な感じになる
  • 定義される関数のlengthプロパティが0なので、lengthプロパティを使う何か(例えばカリー化)に使えない

そこで、evalなりFunctionコンストラクタなりを使って、関数名と引数を合わせるように定義することを考えます。

var call = Function.prototype.call;

function assignArrayGeneric(methodName) {
    var method = Array.prototype[methodName];
    var args = generateArgsString(method.length + 1);
    Array[methodName] = new Function('call', 'method',
        'return function ' + methodName + '(' + args +
        ') { return call.apply(method, arguments); };'
        )(call, method);
}

function generateArgsString(length) {
    var args = [];
    var i;
    for (i = 0; i < length; ++i) {
        args[i] = '$' + i;
    }
    return args.join(', ');
}

引数の数は、this相当の引数を受け取る分、+ 1 しています(Firefoxに合わせています)。これで、例えば Array.forEach には function forEach($0, $1) { return call.apply(method, arguments); } が定義されます(いわゆるクロージャにより、callFunction.prototype.callmethodArray.prototype.forEachを指します)。関数名もlengthプロパティも、外部から使う分には十分になりました。
でも、欲が出てきて、今度は引数名が機械的$0, $1 である点が気になりました。動的解析に対応したIDEで、$0, $1 よりは array, callbackfn と表示された方が嬉しいです。
Functionコンストラクタを使うのもどうかと思うので、ES5の仕様書を見ながらそれぞれ書きました。

ご自由にお使いください。

breakできるforEach ―throwを使って―

思いつき。break文のように入れ子になった内側のループの中から外側のループも抜けられるように。

function Break(target) {
    this.target = target;
}

function forEach(array, callbackfn, thisArg) {
    try {
        Array.prototype.forEach.call(array, callbackfn, thisArg);
    } catch (e) {
        if (!(e instanceof Break) || (e.target && e.target !== callbackfn)) {
            throw e;
        }
    }
}

使用例は以下の通り。

var array = [1, 2, 3, 4, 5, 6, 7, 8, 9];

forEach(array, function (n) {
    console.log(n);
    if (n === 5) {
        // 引数を指定しなければ直近のforEachが止まる
        throw new Break();
    }
});

forEach(array, function outer(i) {
    forEach(array, function inner(j) {
        console.log('%d * %d = %d', i, j, i * j);
        if (i === 5 && j === 5) {
            // 引数を指定するとその関数をコールバックとして渡したforEachが止まる
            throw new Break(outer);
        }
    });
});

戻り値 === false で判断する方法は、reduceやfilterのような戻り値が意味を持つメソッドには使えない。一方、このthrow文の方法なら区別できる。でも、やっぱりここでthrow文は変な感じ。

someをforEach代わりに使うのも変な感じがする(と言いつつよくやるけど)。mapがあればforEachいらないよね、戻り値を無視すればいいだけなんだから、みたいな。

いっそのこと……

Array.prototype.forEach = Array.prototype.some;


Underscore.js、breaker変数をpublicにすればいいのに。

IEではフォーム内の要素にIDを付けてもグローバル変数として定義されない

サンプルコード
<!DOCTYPE html>
<title>TEST</title>
<body>
<input id="input1">
<form>
    <input id="input2">
</form>

<pre><script>
function w(text) { document.writeln(text); }
try { w('input1: ' + input1); } catch (e) { w('input1 is undefined'); }
try { w('input2: ' + input2); } catch (e) { w('input2 is undefined'); }
</script></pre>
結果
input1 input2
IE 10 [object HTMLInputElement] undefined
Firefox 18 [object HTMLInputElement] [object HTMLInputElement]
Chrome 24 [object HTMLInputElement] [object HTMLInputElement]
Opera 12.12 [object HTMLInputElement] undefined

Windows ストアアプリを作っているときにはまった。IntelliSenseで補完候補としてinput2が出てくるものの、実際に使うと Reference Error になる。

HTML Standard では 6.2.4 Named access on the Window object がこれに関しての説明だと思うんだけど、form内の要素については特に何も書いていないように見える。

msdnのどこかに書いてるのかな……。

slideshareのスライドをスクロールでめくるユーザースクリプト

wheel de slideというユーザースクリプトとして作り直した。以下の内容はもう古い。

version 1.2: speakerdeck, docs.google.comにも対応。

version 1.3: slideshareでめくり終えたときに表示されるスクリーンを考慮するように修正。

https://gist.github.com/raw/3765455/f74808d68d8211b06139d10c6c82c65d9660f3bc/slideshare_scroll.user.js

// ==UserScript==
// @id slideshare_scroll
// @name slideshare scroll
// @version 1.3
// @namespace http://rikuba.com/
// @include http://slideshare.net/*
// @include http://www.slideshare.net/*
// @include http://speakerdeck.com/*
// @include https://speakerdeck.com/*
// @include https://docs.google.com/presentation/embed*
// @run-at document-end
// ==/UserScript==

var siteinfos = [
    {
        url: /^https?:\/\/speakerdeck\.com\//,
        container: '#presenter, #player',
        prev: 'a.prev',
        next: 'a.next'
    },
    {
        url: /^http:\/\/(?:www\.)?slideshare\.net\/mobile\//,
        container: '.slideShow',
        prev: '#previous_slide',
        next: '#next_slide'
    },
    {
        url: /^https?:\/\/(?:www\.)?slideshare\.net\//,
        container: '#svPlayerId, #player',
        prev: ['.lastScreenContainer .close-btn', '.btnPrevious'],
        next: '.btnNext'
    },
    {
        url: /^https:\/\/docs\.google\.com\/presentation\/embed\?/,
        container: 'body',
        prev: 'a[role="button"][title="前へ"]',
        next: 'a[role="button"][title="次へ"]'
    }
];

function findMatchingSiteinfo(url) {
    for (var i = 0, l = siteinfos.length; i < l; ++i) {
        if (siteinfos[i].url.test(url)) {
            return siteinfos[i];
        }
    }
    return null;
}


var MATCHES_SELECTOR = (function () {
    var elem = document.documentElement, i, name;
    for (i = 0; name = arguments[i++];) if (elem[name]) return name;
})('matchesSelector', 'mozMatchesSelector', 'webkitMatchesSelector', 'oMatchesSelector');

function handleEvent(e) {
    var types = ['wheel', 'mousewheel'];
    e.currentTarget.removeEventListener(types[+!types.indexOf(e.type)], this);
    this.handleEvent = onwheel;
    this.handleEvent(e);
}

function onwheel(e) {
    if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
        return;
    }

    FIND_CONTAINER: {
        for (var elem = e.target; elem; elem = elem.parentNode) {
            // event target may be text node in Google Chrome
            if (elem.nodeType === 1 &&
                elem[MATCHES_SELECTOR](this.siteinfo.container)) {
                break FIND_CONTAINER;
            }
        }
        return;
    }

    if (e.deltaY > 0 || e.wheelDelta < 0) {
        e.preventDefault();
        clickElement(e.currentTarget, this.siteinfo.next);
    } else if (e.deltaY < 0 || e.wheelDelta > 0) {
        e.preventDefault();
        clickElement(e.currentTarget, this.siteinfo.prev);
    }
}


function clickElement(element, selectors) {
    if (Array.isArray(selectors)) {
        return selectors.some(_clickElement, element);
    }
    return _clickElement.call(element, selectors);
}

function _clickElement(selector) {
    var target = this.querySelector(selector);
    if (target) {
        target.click();
        return true;
    }
    return false;
}


(function main() {
    var siteinfo = findMatchingSiteinfo(location.href), listener;
    if (siteinfo) {
        listener = { siteinfo: siteinfo, handleEvent: handleEvent };
        document.addEventListener('wheel', listener);
        document.addEventListener('mousewheel', listener);
    }
}());

HTMLElement::click()、ユーザースクリプトを作るのに便利。

DOM Traversal の出番

({
    observe: function (doc) {
        this.iterator = doc.createNodeIterator(doc, NodeFilter.SHOW_ELEMENT, this, true);
        doc.addEventListener('keydown', this, false);
    },

    acceptNode: function (node) {
        if ('H1 H2 H3 H4 H5 H6'.indexOf(node.tagName) >= 0) {
            return NodeFilter.FILTER_ACCEPT;
        }
        return NodeFilter.FILTER_SKIP;
    },

    handleEvent: function (evt) {
        var CTRL = evt.ctrlKey, SHIFT = evt.shiftKey, ALT = evt.altKey,
            META = evt.metaKey, code = evt.keyCode, node;

        if (code === 40 && ALT && !CTRL && !SHIFT && !META) {
            if ((node = this.iterator.nextNode())) {
                node.scrollIntoView();
                evt.preventDefault();
            }
        } else if (code === 38 && ALT && !CTRL && !SHIFT && !META) {
            if ((node = this.iterator.previousNode())) {
                node.scrollIntoView();
                evt.preventDefault();
            }
        }
    }
}).observe(document);

acceptNode は CSS セレクタから作れるようにして、イベントとアクションの対応を関数内に書かないようにしよう(イベントセレクタ? イベントフィルタ? のようなものを作る)。

メモ:

  • Opera 12.02 では「Alt + 修飾キー以外のキー」という組み合わせでは keydown イベントが発生しない。
  • Chrome 21 では NodeFilter::acceptNode の this 値が window になっている(今回のコードでは問題にならない)。

// そういえばJavaScriptリファレンス 第6版の索引だけ立ち読みしたら、TreeWalker や NodeIterator が載っていないようだった。IE もバージョン 9 でようやく使えるようになったのに。