v8 (Google JavaScript Engine) を Perl XS モジュールにしてみた

やはりやっつけで。Joke module です。

いろいろ書きたいことがあるけど,そのうち(追記するかも)。

libv8 と XS の間をとりもつ bridge.cc

/* bridge.cc */
#include <v8.h>

#include "EXTERN.h"
#include "perl.h"
#include "XSUB.h"
#include "ppport.h"

/* harmful macro!, but, but, ... */
#undef New

#include "bridge.h"

using namespace v8;

class V8Context {
public:

    V8Context() {
        HandleScope scope;

        Handle<ObjectTemplate> global = ObjectTemplate::New();
        Handle<Context> context = Context::New(NULL, global);

        context_ = Persistent<Context>::New(context);
    };

    virtual ~V8Context() {
        context_.Dispose();
    };

    Handle<Context> context() { return context_; };

private:
    Persistent<Context> context_;
};

V8CONTEXT *
create_v8context(void)
{
    return new V8Context();
}

void
release_v8context(V8CONTEXT *ctx)
{
    V8Context *context = static_cast<V8Context *>(ctx);

    delete context;
}

static SV *
_convert_v8value_to_sv(Handle<Value> value)
{
    if (0) ;
    else if (value->IsUndefined())
        return &PL_sv_undef;
    else if (value->IsNull())
        return &PL_sv_undef;
    else if (value->IsInt32())
        return newSViv(value->Int32Value());
    else if (value->IsBoolean())
        return newSVuv(value->Uint32Value());
    else if (value->IsNumber())
        return newSVnv(value->NumberValue());
    else if (value->IsString())
        return newSVpv(*(String::AsciiValue(value)), 0);
    else {
        Perl_warn(aTHX_ "Unsupported value type");
        return &PL_sv_undef;
    }
}

static Handle<Value>
_convert_sv_to_v8value(SV *sv)
{
    HandleScope scope;

    if (0) ;
    else if (SvIOK_UV(sv))
        return Uint32::New(SvUV(sv));
    else if (SvIOK(sv))
        return Integer::New(SvIV(sv));
    else if (SvNOK(sv))
        return Number::New(SvNV(sv));
    else if (SvPOK(sv))
        return String::New(SvPV_nolen(sv));

    return Undefined();
}

static Handle<Value>
_perl_method_by_name(const Arguments &args)
{
    dSP;
    int count;
    Handle<Value> result = Undefined();
    char ** arguments;

    ENTER;
    SAVETMPS;

    String::AsciiValue method(args.Data()->ToString());
    if (0) Perl_warn(aTHX_ "method called: %s", *method);

    arguments = new char *[args.Length() + 1];
    for (int i = 0; i < args.Length(); i ++) {
        String::AsciiValue str(args[i]);
        arguments[i] = savepv(*str);
    }
    arguments[args.Length()] = NULL;

    count = call_argv(*method, G_SCALAR, arguments);

    for (int i = 0; i < args.Length(); i ++) {
        Safefree(arguments[i]);
    }
    delete arguments;

    SPAGAIN;

    if (count > 1) {
        result = _convert_sv_to_v8value(POPs);
    }

    PUTBACK;
    FREETMPS;
    LEAVE;

    return result;
}

void
v8context_register_method_by_name(V8CONTEXT *ctx, const char *method)
{
    V8Context *context = static_cast<V8Context *>(ctx);
    HandleScope scope;
    TryCatch try_catch;

    Context::Scope context_scope(context->context());

    context->context()->Global()->Set(
        String::New(method),
        FunctionTemplate::New(_perl_method_by_name,
                              String::New(method))->GetFunction()
    );
}


SV *
v8context_execute(V8CONTEXT *ctx, const char *source)
{
    V8Context *context = static_cast<V8Context *>(ctx);
    HandleScope scope;
    TryCatch try_catch;

    Context::Scope context_scope(context->context());

    Handle<Script> script
        = Script::Compile(String::New(source), Undefined());
    if (script.IsEmpty()) {
        String::AsciiValue error(try_catch.Exception());
        Perl_croak(aTHX_ "execute(): compile error: %s", *error);
        return &PL_sv_undef;
    }
    else {
        Handle<Value> result = script->Run();
        if (result.IsEmpty()) {
            String::AsciiValue error(try_catch.Exception());
            Perl_croak(aTHX_ "execute(): execute error: %s", *error);
            return &PL_sv_undef;
        }
        else {
            return _convert_v8value_to_sv(result);
        }
    }
    /* NOTREACHED */
}

ヘッダファイル。XS でも読み込んでます。

/* bridge.h */

#ifdef __cplusplus
extern "C" {
#endif

typedef void V8CONTEXT;

V8CONTEXT * create_v8context(void);
void release_v8context(V8CONTEXT *ctx);
void v8context_register_method_by_name(V8CONTEXT *ctx, const char *method);
SV * v8context_execute(V8CONTEXT *ctx, const char *source);

#ifdef __cplusplus
}
#endif

これらの bridge.o を生成するための MakefileMakefile.PL に組み込めればかっこいいんですけど。

# bridge.mk

V8=../google-v8

PERL_INC=$(shell perl -MConfig -e 'print $$Config{archlib}')/CORE
CFLAGS+=-Wall -fPIC -fno-rtti -I$(PERL_INC) -I$(V8)/include

all:    build

build:  bridge.o

clean:
        rm -f bridge.o

bridge.o:       bridge.cc bridge.h
        g++ $(CFLAGS) -c -o $@ $<

bridge.o:       bridge.mk

.PHONY:	all build clean


んで,XS のコード。の前に,typemap

TYPEMAP
V8CONTEXT *     T_V8CONTEXT

INPUT
T_V8CONTEXT
        if (SvOK($arg) && SvROK($arg))
            $var = (V8CONTEXT *) mg_find(SvRV($arg), PERL_MAGIC_ext)->mg_obj;
        else
            $var = NULL;

XS の実体,V8.xstypemap とブリッジのおかげであまりコードがないです。

#include "EXTERN.h"
#include "perl.h"
#include "XSUB.h"
#include "ppport.h"

#include "bridge.h"

MODULE = Acme::JavaScript::V8   PACKAGE = Acme::JavaScript::V8::Context

void
_bind_new_v8context(sv)
        SV *sv;
    PREINIT:
        V8CONTEXT *ctx;
    CODE:
        ctx = create_v8context();
        sv_magic(SvRV(sv), NULL, PERL_MAGIC_ext, NULL, 0);
        mg_find(SvRV(sv), PERL_MAGIC_ext)->mg_obj = (void *) ctx;

void
_destroy_v8context(ctx)
        V8CONTEXT *ctx;
    CODE:
        release_v8context(ctx);

MODULE = Acme::JavaScript::V8	PACKAGE = Acme::JavaScript::V8::Context PREFIX = v8context_

void
v8context_register_method_by_name(ctx, method)
        V8CONTEXT *ctx;
        const char *method;

SV *
v8context_execute(ctx, source)
        V8CONTEXT *ctx;
        const char *source;

Perl 側のライブラリインタフェース Acme/JavaScript/V8.pmXSLoader で読み込んで,Context の newDESTROY を実装しているだけ。

package Acme::JavaScript::V8;

use strict;
use warnings;

our $VERSION = '0.01';

require XSLoader;
XSLoader::load(__PACKAGE__, $VERSION);

package Acme::JavaScript::V8::Context;

sub new {
    my $class = shift;
    my $self = bless {}, $class;

    _bind_new_v8context($self);

    return $self;
}

sub DESTROY {
    my $self = shift;
    _destroy_v8context($self);
}

1;
__END__

念のため,Makefile.PL もおいときますね。

# Makefile.PL

use inc::Module::Install;

name 'Acme-JavaScript-V8';
all_from 'lib/Acme/JavaScript/V8.pm';

my $V8 = '../google-v8';
makemaker_args(
    LDFROM => join(q{ }, '$(OBJECT)', 'bridge.o'),
    LIBS   => join(q{ }, makemaker_args()->{LIBS}, "-L${V8}", '-lv8', '-lstdc++'),
);

build_requires 'Test::More';

auto_include;
WriteAll;

libstdc++ をリンクするとリンカとして g++ を指定しなくてもいいみたい。とりあえず。libv8 のリンクの仕方を変えました。


以下サンプルスクリプト

use strict;
use warnings;

use Acme::JavaScript::V8;

my $ctx = Acme::JavaScript::V8::Context->new();

my $r = $ctx->execute(<<'END_JS');
a = 1;
b = 2;
a + b;
END_JS

print $r, "\n";     # => 3

sub foo {
    print "foo called: ", join(q{, }, @_), "\n";
    return "bar";
}

# 'foo' という名前の Perl サブルーチンを登録する
$ctx->register_method_by_name("foo");

$r = $ctx->execute(<<'END_JS');
foo(a, b, "baz");   /* => foo called: 1, 2, baz */
END_JS

print $r, "\n";     # => bar

Perl コードサイドの関数を名前でしか呼び出せないのが,まだカコワルイです。あと Global を Perl からいじれるようにしたいですねぇ。

v8 (Google JavaScript Engine) を Apache のモジュールにしてみた

ちまたでは Google Chrome より v8 がアツいらしいので,やっつけでつくりました。Joke module です。

ふつーに CGI モジュール的につくってもよかったんですが,なんとなくテンプレート的にしました。ほんとうは mod_perl みたいにサーバのあらゆるところに hook をかけれるようにしたほうがおもしろいんでしょうけど。

<html>
    <head>
        <title><?js print('Hello, world!'); ?></title>
    </head>
    <body>
        <ul>
<?js
    for (var i = 0; i < 10; i ++) {
?>
            <li><?js print(i + 1); ?></li>
<?js
    }
?>
        </ul>
    </body>
</html>

UA のリクエストとか全然処理してない(エンジンにわたしていない)です。だから CGI 的には使えません(フォームの内容取得したりできないってこと)。

ややはまりしたところ

  • Apache は C で書いてあるのにたいして,v8 は C++ で書かれている。
    • なので,モジュールのビルド時には g++ を使う必要がある。リンク時も。
    • 外部インタフェースとか extern "C" する必要がある。
      • ほぼ全体を囲ってしまった。
  • 最初 -lv8 ってやったら Apache の再起動時にシンボルが見つからないといわれてエラー。
    • リンク時に -lv8 ではなく libv8.a でライブラリをまるかかえすると大丈夫になった。
      • んーとなんででしたっけ?PLT 経由で呼び出すようになっちゃうからかな。
  • リンカの引数の指定順に関係があったんですね。恥ずかしい……

c++ でダイナミックモジュールを書いて C (Apache) から読み込み,な形になってますけど,c++ としてのイニシャライザ,ファイナライザまわりはうまくいってるのかしら。ともかく実験としてはおもしろいけど実用としては不安が残ります*1

まーもっと真面目に作るなら,リクエストオブジェクトの引き渡しはもちろんのことながら,外部モジュールの include をできるようにしたり(ネームスペースはどうすればいいんだろ),ダイナミックオブジェクトをロードして JavaScript から呼び出せるようにしたりするべきでしょう。


ソースはこちら。エラー処理とかはしょりまくってます。
あうあう。クォーテーションのとこ \ エスケープ文字考慮してない……

#include "apr.h"
#include "apr_strings.h"
#include "apr_buckets.h"
#include "apr_lib.h"

#include "ap_config.h"
#include "httpd.h"
#include "http_config.h"
#include "http_request.h"
#include "http_core.h"
#include "http_protocol.h"
#include "http_main.h"
#include "http_log.h"

#include <v8.h>

#define V8PP_MAGIC_TYPE "application/x-httpd-v8pp"

extern "C" {

//module AP_MODULE_DECLARE_DATA v8pp_module;

static apr_status_t
put_precode(apr_bucket_brigade *bb, apr_brigade_flush flush, void *ctx)
{
    return
        apr_brigade_puts(bb, flush, ctx,
"var server = { response: { buffer: '' } };\n"
"server.response.print   = function (msg) { server.response.buffer += msg; };\n"
"server.response.println = function (msg) { server.response.print(msg + \"\\n\"); };\n"
"\n"
"var print   = function () { server.response.print.apply(this, arguments); };\n"
"var println = function () { server.response.println.apply(this, arguments); };\n"
"\n"
        );
}

static apr_status_t
put_postcode(apr_bucket_brigade *bb, apr_brigade_flush flush, void *ctx)
{
    return
        apr_brigade_puts(bb, flush, ctx,
"\n"
"/*return*/; server.response.buffer;\n"
        );
}

static apr_status_t
qstr_to_brigade(apr_bucket_brigade *bb, apr_brigade_flush flush, void *ctx,
                const char *str, apr_size_t nbyte)
{
    apr_status_t s;
    const char *eol;
    const char *p, *q;

    if (nbyte == 0)
        return APR_SUCCESS;

    for (eol = str + nbyte; eol > str; -- eol)
        if (! ap_strchr_c("\r\n", *(eol - 1)))
            break;

    s = apr_brigade_puts(bb, flush, ctx, "server.response.");
    if (s != APR_SUCCESS)
        return s;

    if (eol < str + nbyte)
        s = apr_brigade_puts(bb, flush, ctx, "println('");
    else
        s = apr_brigade_puts(bb, flush, ctx, "print('");
    if (s != APR_SUCCESS)
        return s;

    for (p = str; p < eol; ) {
        for (q = p; q < eol; ++ q)
            if (*q == '\'')
                break;

        if (q > p) {
            s = apr_brigade_write(bb, flush, ctx, p, q - p);
            if (s != APR_SUCCESS)
                return s;
        }
        if (q >= eol)
            break;

        s = apr_brigade_puts(bb, flush, ctx, "\\'");
        if (s != APR_SUCCESS)
            return s;

        p = q + 1;
    }

    s = apr_brigade_puts(bb, flush, ctx, "');\n");

    return s;
}

static int
v8pp_handler(request_rec *r)
{
    apr_pool_t *pool;
    apr_file_t *f = NULL;
    static char buffer[4096];
    ap_regex_t *re_o, *re_c;
    int in_tag = 0;
    const char *p;
    apr_bucket_brigade *bb;
    char *source;
    apr_size_t len;

    if (strcmp(r->handler, V8PP_MAGIC_TYPE) &&
        strcmp(r->handler, "v8pp-script"))
        return DECLINED;

    pool = r->main ? r->main->pool : r->pool;

    if (r->finfo.filetype != APR_REG && r->finfo.filetype != APR_LNK)
        return HTTP_INTERNAL_SERVER_ERROR;
    
    if (apr_file_open(&f, r->filename, APR_READ, APR_OS_DEFAULT,
                      pool) != APR_SUCCESS)
        return HTTP_INTERNAL_SERVER_ERROR;

    re_o = ap_pregcomp(pool, "<\\?js\\s+", AP_REG_EXTENDED);
    re_c = ap_pregcomp(pool, "\\?>",       AP_REG_EXTENDED);
    /* @todo: check errors */

    bb = apr_brigade_create(pool, r->connection->bucket_alloc);
    /* @todo: check errors */

    if (put_precode(bb, NULL, NULL) != APR_SUCCESS) {
        /* @todo: handle errors */
        return HTTP_INTERNAL_SERVER_ERROR;
    }

    while (apr_file_eof(f) == APR_SUCCESS) {
        if (apr_file_gets(buffer, 4096, f) != APR_SUCCESS)
            break;

        for (p = buffer; p && *p; ) {
            ap_regmatch_t match;

            if (in_tag) {
                if (ap_regexec(re_c, p, 1, &match, 0)) {
                    /* no match */

                    if (apr_brigade_puts(bb, NULL, NULL, p) != APR_SUCCESS) {
                        /* @todo: handle errors */
                        return HTTP_INTERNAL_SERVER_ERROR;
                    }
                    break;
                }

                if (apr_brigade_write(bb, NULL, NULL,
                                      p, match.rm_so) != APR_SUCCESS) {
                    /* @todo: handle errors */
                    return HTTP_INTERNAL_SERVER_ERROR;
                }

                p += match.rm_eo;
                in_tag = 0;
            }
            else {
                if (ap_regexec(re_o, p, 1, &match, 0)) {
                    /* no match */

                    if (qstr_to_brigade(bb, NULL, NULL,
                                        p, strlen(p)) != APR_SUCCESS) {
                        /* @todo: handle errors */
                        return HTTP_INTERNAL_SERVER_ERROR;
                    }
                    break;
                }

                if (qstr_to_brigade(bb, NULL, NULL,
                                    p, match.rm_so) != APR_SUCCESS) {
                    /* @todo: handle errors */
                    return HTTP_INTERNAL_SERVER_ERROR;
                }

                p += match.rm_eo;
                in_tag = 1;
            }
        }
    }

    if (put_postcode(bb, NULL, NULL) != APR_SUCCESS) {
        /* @todo: handle errors */
        return HTTP_INTERNAL_SERVER_ERROR;
    }

    if (apr_brigade_pflatten(bb, &source, &len, pool) != APR_SUCCESS) {
        /* @todo: handle errors */
        return HTTP_INTERNAL_SERVER_ERROR;
    }

    apr_brigade_destroy(bb);

    ap_pregfree(pool, re_c);
    ap_pregfree(pool, re_o);

    apr_file_close(f);

    {
        v8::HandleScope handle_scope;
        v8::TryCatch try_catch;

        v8::Handle<v8::ObjectTemplate> global = v8::ObjectTemplate::New();
        v8::Handle<v8::Context> context = v8::Context::New(NULL, global);
        v8::Context::Scope context_scope(context);

        v8::Handle<v8::String> script = v8::String::New(source, len);
        v8::Handle<v8::Script> compiled
            = v8::Script::Compile(script, v8::Undefined());

        if (compiled.IsEmpty()) {
            v8::String::AsciiValue error(try_catch.Exception());

            ap_set_content_type(r, "text/plain");
            ap_rprintf(r, "[v8] compile error: %s\n\n", *error);
            ap_rwrite(source, len, r);
        }
        else {
            v8::Handle<v8::Value> result = compiled->Run();

            if (result.IsEmpty()) {
                v8::String::AsciiValue error(try_catch.Exception());

                ap_set_content_type(r, "text/plain");
                ap_rprintf(r, "[v8] execute error: %s\n\n", *error);
                ap_rwrite(source, len, r);
            }
            else {
                v8::String::AsciiValue output(result);
                ap_set_content_type(r, "text/html");
                ap_rputs(*output, r);
            }
        }
    }

    return OK;
}

static void
register_hooks(apr_pool_t *p)
{
    ap_hook_handler(v8pp_handler, NULL, NULL, APR_HOOK_MIDDLE);
}

module AP_MODULE_DECLARE_DATA v8pp_module =
{
    STANDARD20_MODULE_STUFF,
    NULL,                        /* dir config creater */
    NULL,                        /* dir merger --- default is to override */
    NULL,                        /* server config */
    NULL,                        /* merge server config */
    NULL,                        /* command apr_table_t */
    register_hooks               /* register hooks */
};

}   /* end of extern "C" */

ソースみるとわかりますが v8pp-scriptAddHandler するなり SetHandler するなりしてください。

ごっそり読み込んでコンパイルして,ってやってるのでメモリ効率とかよくないです。filter module として書くほうがカコイイんですけど,めんどうだったので generator として書きました。そのくせ bucket brigades を使ってます。string stream として意外に便利。

コンパイル後の実体をシリアライズできればコードキャッシュとかできそうなんだけどな。


いちおうわたしが使った Makefile もあげておきます。

V8=../google-v8     # v8 のソースツリー(適当に書き換えてね)

APXS=/usr/sbin/apxs

AP_INCLUDES=-I$(shell $(APXS) -q INCLUDEDIR)

APR_CONFIG=$(shell $(APXS) -q APR_CONFIG)
APU_CONFIG=$(shell $(APXS) -q APU_CONFIG)
APR_INCLUDES=$(shell $(APR_CONFIG) --includes)
APU_INCLUDES=$(shell $(APU_CONFIG) --includes)
APR_LIBS=$(shell $(APR_CONFIG) --cflags --ldflags)
APU_LIBS=$(shell $(APU_CONFIG) --ldflags)
APR_LDFLAGS=$(shell $(APR_CONFIG) --ldflags)

CC=$(shell $(APXS) -q CC)
CPLUSPLUS=g++

INCLUDES=$(AP_INCLUDES) $(APR_INCLUDES) $(APU_INCLUDES) -I$(V8)/include
CFLAGS=-fPIC -DPIC $(shell $(APXS) -q CFLAGS)
CFLAGS+=$(shell $(APXS) -q EXTRA_CPPFLAGS) $(shell $(APXS) -q EXTRA_CFLAGS)
CFLAGS+=$(INCLUDES)

LIBS=$(shell $(APXS) -q LIBS) $(APR_LIBS) $(APU_LIBS) -L$(V8) -lv8
LDFLAGS=-shared -fPIC $(shell $(APXS) -q LDFLAGS)
LDFLAGS+=$(shell $(APXS) -q EXTRA_LDFLAGS) $(APR_LDFLAGS)

LIBEXECDIR=$(shell $(APXS) -q LIBEXECDIR)

all:	build

build:	mod_v8pp.so

install:	mod_v8pp.so
	install -c -s -m 755 $^ $(LIBEXECDIR)/

clean:
	rm -f mod_v8pp.so
	rm -f *.o

mod_v8pp.so:	mod_v8pp.o
	$(CPLUSPLUS) $(LDFLAGS) -o $@ $^ $(LIBS)

mod_v8pp.o:	mod_v8pp.cc
	$(CPLUSPLUS) $(CFLAGS) -c -o $@ $^

.PHONY: all build install clean

c++ なので apxs -c とか素直に使えなくてめんどうでした。

*1:あくまで Apache のモジュールとして,です。

JavaScript の「本気」な勉強

まだひっぱるのもなんですけど。たぶん,言葉の指す対象にいろいろブレがあると思うんですよね。

勉強する人
  • まったくの初学者
  • さわりの文法などは知っているが,現実になにをどう書けばいいかわからない
勉強のスタイル
  • 本気で勉強したい
    • 本気で深く勉強したい
    • 本気でひととおり使えるようになりたい
勉強する対象

ともかく,もやもやと思ったことをとりとめもなく羅列してみます

ライブラリの功罪

ライブラリは流行り廃りが激しいから、特定のライブラリに依存した「おまじない」ばかり覚えているのはどうかと思うなあ

やっぱり、 DOM を直接書けたほうが、知識としては幅広く使えると思いますよ。

onclick 属性問題について - IT戦記

僕自身はなんだかんだで仕様原理主義者な所が今も強いわけで、その考えに則れば、onclick等のイベントハンドラは一応W3Cの仕様に含まれてるから(HTML4XHTML 1.0XHTML 1.1)OKだけど、ライブラリは業界団体の作る標準仕様になってないからNG、と言える。というのはまあ半分冗談だし、そもそもHolyGrailさんの指摘とは次元が違う話なのですが。

しかしこの考えも、権威主義だけじゃなくて、実利的に考えて「そうあるべき」と僕は割と真面目に思っていたりもする。

  1. いつでも詳細な取り決めを確認できて、不安無く使える。
  2. 特定のベンダの意向や、世間の流行り廃りに振り回されずに、安心して使える。
  3. 学んだことが無駄にならず、他の場面でも使える。

この三つの点について、満たしている物が多ければ多いほど、満たしているレベルが高ければ高いほど、それは良い物で学ぶ意義も大きいと僕は思う。

Latest topics > 本気でやるならprototype.jsやjQueryやYUIは避けてonclickを使うべき - outsider reflex

いわんとすることの半分はわかる気がするのですが,アルファギークな方々がこう書くと,うーんと思ってしまいます。


ECMAScriptW3C DOM の仕様とブラウザでの実装が,やりたいことに対する要件を満たしていればいいですよ。でも,例えば DOM 構築後のイベントを掬おうとしたらまだブラウザの実装によってまちまちじゃないですか。

こういうとき,ライブラリによって「DOM 構築後のイベント」として「抽象化」されていれば,今からでも使うことができますよね。将来的にもし DOMContentLoaded が標準仕様になったとしても,ライブラリ利用者にとってのインタフェースは変わりません。これって少なくとも初心者には・そして「実利的」にも価値あることだと思うのですが。


まぁ,たぶん,「『JavaScript を勉強』する上で,既存のライブラリ『だけ』を勉強していっても仕方ないよ,仕様に基づいたものを勉強したほうがいいよ,応用がきくし」というだけで,「ライブラリの使用・勉強=悪」だといっているわけじゃないかもしれません。違います?

というわけでアルファギークの方々に聞いてみたい

学習方法は十人十色だと思うのでこういう質問自体不毛かもしれませんが,さきほどの引用部分を読むとそれなりのスタンスはお持ちかなと思ったので,質問を項目建てしてみました。気が向いたら・お暇があったら,ぜひぜひ教えて欲しいです。

  • 初学者の入り口として既存のライブラリを使うのは
    • 望ましい
    • どちらかというと望ましい
    • どちらかというと望ましくない
    • 望ましくない
  • (初学者であれ)JavaScript の仕様に沿ったメカニズムについて
    • 学習するべきである
    • どちらかというと学習したほうがいい
    • 学習する必要はない
    • 学習対象による

そして前々から興味があったんですが

  • 自分で書く場合
    • 既存のライブラリを利用している
    • 自家製ライブラリを利用している
    • その他
    • ケースバイケース


ちなみに私の場合,

  • 初学者の入り口として既存のライブラリを使うのは
    • どちらかというと望ましい
      • ライブラリ使うと手っ取り早く色々書けて楽しいですし。
  • (初学者であれ)JavaScript の仕様に沿ったメカニズムについて
    • どちらかというと学習したほうがいい
      • どちらかというと,というより,必要なときがきたら学習すればいいかな,と。
  • DOM 構築後のスクリプト実行についてどう教える?
    • ライブラリ派なので jQuery$(document).ready() 使っとけ,と
  • 自分で書く場合
    • ケースバイケース
      • 後述↓

「自分で書く場合」の「ケースバイケース」,ですが,基本的に使える場所なら jQuery を使っていて,使えない or 使うまでもない場合は,<head> で include するコードで(ことイベントまわりについては),

window.onload = function () {
    // blah, blah, blah, ...
};

とか書いてます(恥ずかしい)。なんとなく

HTML 構造
CSS 見た目
JavaScript 振る舞い

みたいな美的感覚*1があるので,一人で書いていてもなるたけ分離してます。でもいろいろみなさんの文書を読んでいて,こだわる必要なかったかな,とすこし反省。

ライブラリから入ることと,たとえ話

そもそも私は JavaScript よりサーバサイド寄りの人なので,たとえ話で考えてみました。

  • O/R マッパだけ学習した人は(その O/R マッパが完全なものであれば)SQL インジェクションと無縁な生活を送れる

うーーーむ。これどうなんだろう(笑)。自分の立場としては,たしかにデータベースの仕組みとか,バックエンドな SQL の仕組みとかも知っていてほしいと思います。でも,よくできた O/R マッパなら,そこらへんを一生意識しないで生きていけるのかもしれません。そのことでより上のレイヤの生産性があがるのであれば,それはそれでアリかもしれない。というかそんな人の働きぶりを見てみたい。

実際には,より低レイヤについて学んでいかないと,そのうちにっちもさっちもいかなくなりますよね。パフォーマンスがどんづまったりとか,へんな設計になってしまったりとか。でも,

  • 人間,制約が多くても喜んでそれ克服するべく工夫する

部分もある気がするので,そういう人生やっぱり興味あるなぁ。

*1:これが絶対的な美意識というつもりはないですよ。美的感覚はひとそれぞれですから優劣があるとかでもないし。

JavaScript のイベントハンドラ

本気でやるならonclick属性は避けてライブラリを活用すべき - 帰ってきたHolyGrailとHoryGrailの区別がつかない日記 を読んで,思うところあって書いてみました(決してカウンターアーティクルではない)。

  • むかしむかし JavaScript を触っていた
  • むかしむかしに書かれた JavaScript の本で勉強している/した

人向けに。大元記事(そろそろ本気で学びませんか? | Think IT(シンクイット))の想定読者に近いかなと思います。よって以下は JavaScript の初学者にはまったくおすすめできない(余計な知識がついてしまう)です。

Step 1: はじめのいっぽ

ボタンを押したらメッセージボックスが出現する HTML を書いてみます。

<html><body>
    <script type="text/javascript">

function ShowMessage() {
    alert('Hello, world!');
}

    </script>
    <form>
        <input type="button" value="show message" onclick="ShowMessage()">
    </form>
</body></html>

実直に昔ながらに書いているのでこれがわからないという人はまぁいないと思います。

これぐらいだったら onclick ハンドラに alert() をつっこんじゃうよ,という人もいるでしょうが,のちのちの布石だと思ってください。また,もともとの例だと <a> タグに onclick ハンドラを仕掛けていますが,とりあえず昔ながらの JavaScript で理解しやすく <form> 内の <input type="button"> 要素に仕掛けています。これも今後の布石ということで。

Step 2

このままだと HTML 内に JavaScript ハンドラがまぎれこんでいるので,メンテナンスがしづらいのです。

やっぱり、エンジニアとデザイナーorマークアッパーとの分業の点でHTMLの属性にスクリプトを書いちゃうのはあんまりよろしくない。

たとえばの話だけど関数の名前を変えたかったり、だとか、HTMLを変更したり、っていうときにミスが起こりやすくなってしまう。

本気でやるならonclick属性は避けてライブラリを活用すべき - 帰ってきたHolyGrailとHoryGrailの区別がつかない日記

なので,ハンドラの指定も JavaScript サイドにもっていきたいと思います。

<html><body>
    <form name="form_main">
        <input name="btn_show" type="button" value="show message">
    </form>
    <script type="text/javascript">

function ShowMessage() {
    alert('Hello, world!');
}

document.form_main.btn_show.onclick = ShowMessage;

    </script>
</body></html>

HTML 部分で,さきほどの例と以下の点が違う部分に注目してください。

  • <form> タグに name="form_main" という属性を付与した((document.all.btn_show.onclick = ShowMessage; のようにすると <form> タグに name 属性をつける必要はありませんが,まぁ昔ながらの例ということで))
  • <input> タグに name="btn_show" という属性を付与した

実は

  • <script> ブロックが <form> ブロックより後にきている

という違いもあるのです。気づいた人はなかなかです。

このように <form> 要素や <input> に名前を振ってあれば,

document.form_main.btn_show

のようにして要素を取得することができます*1

この要素の onclick というプロパティが onclick ハンドラを示しているので,ここにハンドラへのポインタを代入してやれば OK なのです。

ん?ハンドラのポインタって何?

初心者がやりがちな間違いとして下記のような記述があります。

document.form_main.btn_show.onclick = ShowMessage();

<input> タグの onclick 属性に指定していた値が「ShowMessage()」だったので,同じように記述してしまいがちです。ですが,このように書くと,btn_show.onclick プロパティに ShowMessage() の「実行結果」が入ってしまいます。ボタンを押してもメッセージボックスが出ません*2

document.form_main.btn_show.onclick = ShowMessage;

と書くと,function ShowMessage という関数,(実行結果ではなく)それ自身,という意味になるので,OK なのです。

<script> ブロックの評価時点

さきほど

  • <script> ブロックが <form> ブロックより後にきている

という違いもあげました。

Step 1 の例と同じく <script> ブロックを <form> ブロックの前に持ってきてみます。

<html><body>
    <script type="text/javascript">

function ShowMessage() {
    alert('Hello, world!');
}

document.form_main.btn_show.onclick = ShowMessage;

    </script>
    <form name="form_main">
        <input name="btn_show" type="button" value="show message">
    </form>
</body></html>

この場合,ボタンを押してもメッセージが出現しません。

ブラウザは <script> ブロックを読み込むと,その時点でブロックを実行しはじめます。

function ShowMessage() ... の部分は,関数を定義しているだけなので問題ありません。この次の document.form_main.btn_show.onclick = ... という行がここの時点で実行されます。しかし,この <script> ブロックを実行している時点では,まだ後半の <form name="form_main"> 以下はまだ読み込まれていません*3。ですので,onclick イベントハンドラを設定できていないのです。


実際,もし,Firefox with Firebug を使っている場合,console に

document.form_main is undefined

というエラーメッセージが(読み込み時に)出力されているはずです。


ピンとこない,という方は,この <script> ブロックの中に document.write() を挿入した場合について想像してみましょう。その内容は <form> の前に出力されますか?後に出力されますか?

Step 3: body onload ハンドラを利用する

じゃあ <script> ブロックは最後にもってこないといけないのか。

そんなことはありません。<input type="button"> 要素に onclick イベントが存在するのと同じように,「ブラウザが HTML 全体を解釈しおえた時点」に発生するイベントが <body> 要素に存在します*4。みなさんお馴染みの <body onload="..."> です。

<html><body onload="WindowOnLoad()">
    <script type="text/javascript">

function ShowMessage() {
    alert('Hello, world!');
}

function WindowOnLoad() {
    document.form_main.btn_show.onclick = ShowMessage;
}

    </script>
    <form name="form_main">
        <input name="btn_show" type="button" value="show message">
    </form>
</body></html>

「ブラウザが HTML 全体を解釈しおえた時点」で <input> タグの onclick ハンドラを設定してあげればよいわけですね。

Step 4: onload ハンドラを分離する

おっと,せっかく HTML 部分から JavaScript ハンドラを分離したのに,<body> タグの中に JavaScript が混入してしまいました。onclick ハンドラと同じように,後付けで設定してみましょう。

<html><body>
    <script type="text/javascript">

window.onload = WindowOnLoad;

function ShowMessage() {
    alert('Hello, world!');
}

function WindowOnLoad() {
    document.form_main.btn_show.onclick = ShowMessage;
}

    </script>
    <form name="form_main">
        <input name="btn_show" type="button" value="show message">
    </form>
</body></html>

あれれ?

  • document.body.onload じゃなくて window.onload なの?((これ実は私も理由がわかりません。おそらく,この <script> ブロックに制御がうつった時点ですでに body onload が実行されているから,時既に遅しなのではないかなぁ,と思います。かといって <head> ブロックに押し込むと今度はまだ body 要素は未存在なのでうまくいかない,と。))
  • function WindowOnLoad() を定義する前に window.onload = WindowOnLoad; を実行しちゃって大丈夫なの?(下記追記を参照するとわかるかも)

などなどの疑問が生じます。が,こういうものだと思ってください。ごめんなさい。JavaScript についてもっともっと勉強していくと理由がわかるかも。

Step 5: よりモダンに DOM model で要素を取得

ここまでで JavaScript と HTML の分離がうまくできました。

ですが,イベントハンドラ設定対象の要素の取得の仕方(document.form_main.btn_show)が,NN2 model & IE4 DHTML model のままです。んー古い。古いこと自体は別にいいですが,要素の name 属性を直接 JavaScript に書き下したりしていて,拡張性・メンテナンス性が低い((IE4 DHTML model なら document.all.item('id') で取得できるのでマシ。))ですね(なんのことやらわからない方は別にわからないままで大丈夫です)。

というわけで,いまどきは,より様々な要素を汎用的に取得できる DOM model で取得します。

<html><body>
    <script type="text/javascript">

window.onload = WindowOnLoad;

function ShowMessage() {
    alert('Hello, world!');
}

function WindowOnLoad() {
    document.getElementById('btn_show').onclick = ShowMessage;
}

    </script>
    <form>
        <input id="btn_show" type="button" value="show message">
    </form>
</body></html>

さきほどの例との違いは,

  • <form> 要素の name 属性がなくなった
  • <input> 要素の「名前」を name 属性ではなく id 属性で指定するようにした
  • 要素の取得方法が,オブジェクト.getElementById(id名) という関数になった

です。つまり,まぁ,関数経由で要素を取得できるようになったわけです。

実際には document.getElementById() というのは長いので,たいてい別名の関数を定義します。

function $(e) {
    return document.getElementById(e);
}

function ShowMessage() {
    alert('Hello, world!');
}

function WindowOnLoad() {
    $('btn_show').onclick = ShowMessage;
}

window.onload = WindowOnLoad;

C をやった人からすると「えっ $ も関数名に指定できるの」と衝撃ものですが,見た目てきにもまぁわかりやすいので慣例的に $() 関数をこのように要素取得関数に当てることが多いです。

無名関数についてちらりと

実は,

function foo(x) {
    alert(x);
}

というのは,

foo
    = function (x) {
        alert(x);
    };

と書くのと同じ意味をもっています(厳密には異なります。下記に追記しました)。後半の「function (x)」という表記,ひっかかりますか?これは無名関数と呼ばれるものです。関数の挙動それ自身を指すのに,別段名前なんか必須じゃないよね,ということです,とさらりとながしておきます。

ともかく,さきほどのプログラムは下記のように書き換えることができます(onload ハンドラ部分だけ書き換えました)。

<html><body>
    <script type="text/javascript">
function $(e) { return document.getElementById(e); }

function ShowMessage() {
    alert('Hello, world!');
}

WindowOnLoad
    = function () {
        $('btn_show').onclick = ShowMessage;
    };

window.onload = WindowOnLoad;

    </script>
    <form>
        <input id="btn_show" type="button" value="show message">
    </form>
</body></html>

WindowOnLoad という function を定義していた部分が,WindowOnLoad という変数に無名関数を代入した形になっています。

このコードをよくよく眺めると,わざわざ WindowOnLoad という変数を媒介させる必要はなさそうです(一度しか使われていませんし)。なので,無名関数を直接代入してみましょう。

function $(e) { return document.getElementById(e); }

function ShowMessage() {
    alert('Hello, world!');
}

window.onload
    = function () {
        $('btn_show').onclick = ShowMessage;
    };

onload ハンドラの記述がややすっきりとしました。また,ハンドラに気の利いた関数名を考える必要がないので楽ですね。この記述で意味がわかりにくい,ということはないでしょう。どちらかというと意味がよりわかりやすくなったのではないでしょうか。


もっともっと無名関数を駆使すると,下記のようになります。

var $ = function (e) { return document.getElementById(e) };

window.onload
    = function () {
        $('btn_show').onclick
            = function () {
                alert('Hello, world!');
            };
    };

相当短くなりました。これが読みやすいという方は,けっこう柔軟な頭の持ち主だと思います。そうではない方も,この後は再び記名 function の記述に戻すのでご安心を。

おまけ: HTML タグ内のイベントハンドラ

Step 1 で出てきた例,

        <input type="button" value="show message" onclick="ShowMessage()">

というのは,JavaScript で書くと,

btn_show.onclick
    = function () { ShowMessage() };

のような記述に相当すると言えます。だから,

        <input type="button" value="show message" onclick="ShowMessage">

と書いても

btn_show.onclick
    = function () { ShowMessage; };

のようになり,ShowMessage() は実行されませんね。

Step 6: スタイルをいじってみる

バカの一つ覚えみたいに Hello, World! といっているのも飽きたし,せっかく $() 関数を定義したのでもうちょっと活用するべく,既存の要素のスタイルを変更するイベントハンドラを書こうと思います。

<html><body>
    <script type="text/javascript">
function $(e) { return document.getElementById(e); }

function ChangeBGColor() {
    $('para_msg').style['backgroundColor'] = '#9999ff';
}

window.onload
    = function () {
        $('btn_color').onclick = ChangeBGColor;
    };

    </script>
    <p id="para_msg">Hello, world!</p>
    <form>
        <input id="btn_color" type="button" value="change color">
    </form>
</body></html>

ボタンをクリックすると「Hello, world!」というメッセージの背景色が変わります。

Step 7: イベントハンドラ多重登録

これで JavaScript におけるイベントハンドラの操作についてマスターできたのでしょうか。

いえいえ。

下記の例を考えてみてください。

<html><body>
    <script type="text/javascript">
function $(e) { return document.getElementById(e); }

function ChangeFGColor() {
    $('para_msg').style['color'] = '#000099';
}

function ChangeBGColor() {
    $('para_msg').style['backgroundColor'] = '#9999ff';
}

window.onload
    = function () {
        $('btn_color').onclick = ChangeBGColor;     // --- (1)

        $('btn_color').onclick = ChangeFGColor;     // --- (2)
    };

    </script>
    <p id="para_msg">Hello, world!</p>
    <form>
        <input id="btn_color" type="button" value="change color">
    </form>
</body></html>

この HTML,どのような挙動を示すでしょうか。

背景色と文字色が変わると思った方,残念でした。(2) で onclick ハンドラの値を上書きしてるんだから文字色だけが変わるに決まってるじゃんと思った方,正解です。ですがちょっと考えてみてください。

あなたはあるチームでコーディングをしているとします。あなたに下った指令はボタンを押すとメッセージの背景色を変更することです。そこで Step 6 のようなコードを書きました。

そこでチームメイトに別の指令が下りました。(同じ)ボタンを押すとメッセージの文字色を変更するように,という指令です。

このような時,上記のようにイベントハンドラの上書きが発生してしまいます。これは困りますね。


下記のようなイベントハンドラ登録関数を書けばこれを回避することはできます。

function $(e) { return document.getElementById(e); }

function add_onclick_event(e, handler) {
    if (e.onclick) {
        var prev = e.onclick;
        e.onclick = function () { prev(); handler(); };
    }
    else
        e.onclick = handler;
}

function ChangeFGColor() {
    $('para_msg').style['color'] = '#000099';
}

function ChangeBGColor() {
    $('para_msg').style['backgroundColor'] = '#9999ff';
}

window.onload
    = function () {
        add_onclick_event($('btn_color'), ChangeBGColor);
        add_onclick_event($('btn_color'), ChangeFGColor);
    };

すでにイベントハンドラが設定されていた場合,新しいイベントハンドラで,その旧ハンドラを呼び出すようにする,というものです。

Step 8: DOM のイベントモデル -- addEventListener()

実はいままで扱ってきた .onclick = のようなプロパティというのは,イベント処理機構の一面しか示していません。内的には,より柔軟なイベント管理メカニズムが存在しています。

そのイベント管理メカニズムを通してイベントを登録する関数が element.addEventListener() です。

<html><body>
    <script type="text/javascript">
function $(e) { return document.getElementById(e); }

function ChangeFGColor() {
    $('para_msg').style['color'] = '#000099';
}

function ChangeBGColor() {
    $('para_msg').style['backgroundColor'] = '#9999ff';
}

window.onload
    = function () {
        var e = $('btn_color');

        e.addEventListener('click', ChangeBGColor, false);
        e.addEventListener('click', ChangeFGColor, false);
    };

    </script>
    <p id="para_msg">Hello, world!</p>
    <form>
        <input id="btn_color" type="button" value="change color">
    </form>
</body></html>

addEventListener() は,「イベントハンドラの差し替え」は行いません。「イベントハンドラの追加」を行うだけです。この例だと,ボタンを押すと ChangeBGColor()ChangeFGColor() の両者が呼ばれます。

また,removeEventListener() 関数でイベントハンドラを削除することもできます。

Step 9: IE でも動くように -- attachEvent()

実は先ほどの addEventListener() 関数は IE では動きません。同じような目的で使う attachEvent() 関数が代わりに用意されています。

<html><body>
    <script type="text/javascript">
function $(e) { return document.getElementById(e); }

function append_event(e, type, handler) {
    if (e.addEventListener)
        e.addEventListener(type, handler, false);
    else
        e.attachEvent('on' + type, handler);
}

function ChangeFGColor() {
    $('para_msg').style['color'] = '#000099';
}

function ChangeBGColor() {
    $('para_msg').style['backgroundColor'] = '#9999ff';
}

window.onload
    = function () {
        var e = $('btn_color');

        append_event(e, 'click', ChangeBGColor);
        append_event(e, 'click', ChangeFGColor);
    };

    </script>
    <p id="para_msg">Hello, world!</p>
    <form>
        <input id="btn_color" type="button" value="change color">
    </form>
</body></html>

ブラウザによって呼ぶ関数が違うので append_event() なるラッパ関数を設けました。attachEvent() の場合,ハンドラ名は onclick のように on- を付けた名称を指定する必要があります。


これで無事いまどきのコーディングでイベントハンドラを書くことができました。


んーと……何か忘れてません?

そう,onload イベントも,ですよね。

<html><body>
    <script type="text/javascript">
function $(e) { return document.getElementById(e); }

function append_event(e, type, handler) {
    if (e.addEventListener)
        e.addEventListener(type, handler, false);
    else
        e.attachEvent('on' + type, handler);
}

function ChangeFGColor() {
    $('para_msg').style['color'] = '#000099';
}

function ChangeBGColor() {
    $('para_msg').style['backgroundColor'] = '#9999ff';
}

append_event(
    window, 'load',
    function () {
        var e = $('btn_color');

        append_event(e, 'click', ChangeBGColor);
        append_event(e, 'click', ChangeFGColor);
    }
);

    </script>
    <p id="para_msg">Hello, world!</p>
    <form>
        <input id="btn_color" type="button" value="change color">
    </form>
</body></html>

おわりに

ブラウザによってイベント処理の実装やインタフェースが異なります*5。今回 append_event() のような関数を作りましたが,既存のライブラリではこのような関数がより汎用的かつより使いやすい形で用意されています。

また,上の例では onload イベントを用いましたが,

一番最初にあげた記事のサンプルには「onload」イベントハンドラでinit関数を実行していますが、これもあまりおすすめしません。

というのも、onloadイベントハンドラはブラウザによって「DOMツリー構築完了」の場合もあれば「画像も含めてHTMLの読み込みがすべて完了後」の場合もあるからです。

現時点では後者の実装の方が多いために、大きい画像が用意されているページでは、画像が読み込み終わるまでに初期化関数が実行されなくなってしまいます。

本気でやるならonclick属性は避けてライブラリを活用すべき - 帰ってきたHolyGrailとHoryGrailの区別がつかない日記

のような問題もあります。既存のライブラリはこういった点も解決しています。

ですので,実際に JavaScript でイベント処理を書くなら既存のライブラリを使った方がよいでしょう。

つけたし

id:HolyGrail さんの記事に「初心者にいきなりライブラリでは……」などの声もいくつかありましたが,私個人の意見としてはいろんな入り口があっていいんじゃないかな,と思います。

せっかくブラウザの差異を吸収してくれるし,『高速道路』としてすばらしいし,いきなりきちんと動くものができたほうが勉強していて楽しいし,実際の現場では既存のライブラリを使うことが多いだろうし。なので初学者はライブラリから入って抽象的な概念をつかんだうえで,より低レイヤな勉強をしたほうがいいんじゃないかなぁと思います。

人によっては,メカニズムから知りたいんだ,という人もいるでしょう(私もわりとそうです)。この記事がそういう方の一助となれば幸いです。まぁメカニズムから,と書きましたが具体的にブラウザの実装や W3C DOM の仕様がどうとかについては踏み込んで勉強していない&書いていないです。なので [http://gihyo.jp/dev/feature/01/firebug:title] に期待シテマス!

2008/05/17 追記

コメント欄でご指摘をうけたので「無名関数」→「匿名関数」に修正しました。ありがとうございました。

2008/05/19 追記

コメント欄ほかでのご指摘によると,言語処理系での一般用語としては「無名関数」のほうがより一般的なようです。Core JavaScript 1.5 Reference 日本語訳 も「無名関数」になっていたので,そちらと合わせる意味でも再び「無名関数」としておきました。id:HolyGrail さんごめんなさい :)。ちなみに英語だと「anonymous function」でガチのようです。

2008/05/19 追記: var f = function (x) { ... } について

2008年05月16日 os0x 丁寧な解説/重箱だけどfunction f(x){}とf=function(x){}は違う。特に後者は=が実行されるまでundefined。/↑細かいけど、これ以外にも前者は関数に名前があるからデバッグ時にウマイとか細かい違いがあるかな。

はてなブックマーク - os0xのブックマーク / 2008年5月16日

まったく気づきませんでした。ご指摘ありがとうございます>id:os0x さん

XXX();      // "XXX" !

YYY();      // ERROR: YYY is not a function

function XXX() {
    alert('XXX');
}

var YYY = function YYY () {
    alert('YYY');
};

YYY();      // "YYY" !

XXX() は関数として前方参照可能なのでエラーになりませんが,YYY() は関数として前方参照不可能((細かいことをいうと前方参照不可能,というわけではありません。JavaScript のスコープの特性として YYY という変数自体はすでに存在していますが,代入文が実行されるまで undefined です。なので,エラーメッセージが「YYY is not defined」ではなく「YYY is not a function」なんですね。))なのでエラーとなります((YYY が微妙に「無名」じゃないのはちょっと意図的。リンク先を勉強するとおもしろいかも))。

JavaScript で関数を定義するのには,下記の3通りの手法があります。

  • function 文」による「関数定義」
  • function 演算子」による「関数式」
  • Function() コンストラク

Function() コンストラクタについては今回は触れていません。XXX() は「関数定義」,YYY は「関数式」になります。「関数定義」の場合,前述のように関数定義(実装)が前方参照可能になるという大きな違いがあります。

ほか,function 文による関数定義には落とし穴があったりします。その他の違いも含めて Core JavaScript 1.5 Reference - Functions日本語訳)の Function constructor vs. function declaration vs. function expression を参照してください*6。日本語訳もあるのでぜひぜひ。

*1:今どきの JavaScript から入った人にとって,おえっという表現だと思いますが,DOM 出現以前はこのように書いていたのです。

*2:もっというと,このままだと HTML を読み込んだ瞬間にメッセージボックスが出現します。なぜそのようになるのかは宿題とします。

*3:厳密には,読み込まれていない,というよりブラウザが解釈していない,ですね

*4:このへん厳密にいうと違います。が,勘弁してください。

*5:一応今回のサンプルコードは Firefox 3 と IE 6 で確認してあります。

*6:本質的には ECMAScript 262 Specification を参照するべきかもしれませんが,大量の PDF ですし文法定義的に書かれているのでめげました。ぐむぅ。

選択範囲の取得について調べた

はてなスターTumblrブックマークレットなど,ブラウザ上の選択文字列をそのままユーザの入力として使えるサイトが増えています。JavaScript からどのようにすれば取得できるのかを調べてみました。

ブラウザ間の差異

DOM における選択範囲の仕様として以下の 2 通りがあります。

IEMicrosoft TextRange のみサポートしているのはまぁ予想通り。注意しなくてはいけないのは,W3C Range は,あくまで DOM 上の「範囲」を示すためのインタフェースであることです。ブラウザ上でユーザがどこを選択しているのか,などは UI の実装になりますから,W3C (Range) では規定されていません*1。そこで W3C Range をサポートしているブラウザは,選択範囲をあらわす W3C Range オブジェクトを取得するためのインタフェースとして Mozilla Selection オブジェクトを(部分的でも)サポートしています。

難しい表現になってしまいましたが W3C Range というのはあくまで「範囲」を示すためのもの*2,「選択範囲」を取得するインタフェースとして Mozilla Selection インタフェースがある,ということをおさえておいてください。同様に Microsoft TextRange も「範囲」を示すだけのものですが,「選択範囲」の取得インタフェースにブラウザ間で違いがあるわけでもないのであまり気にする必要はありません。

で,各ブラウザでのサポート状況は下記のようになります。

  IE 6 / 7 Firefox 2*3 Safari 1.3 Safari 2 Opera 9*4
W3C Range ×
Mozilla Selection ×
MS TextRange × × ×

△は完全にはインプリメントされていません。各プロパティ・メソッドについて詳述すると以下のようになります。

  IE 6 / 7 Firefox 2 Safari 1.3 Safari 2 Opera 9
window.getSelection() ×
document.getSelection()((getSelection がグローバルネームスペースを汚染していることを解決しよう意識がある,ということなのでしょうか。Safari でサポートされていないのが痛いですね。)) × × ×
document.createRange() ×
Selection.toString() ×
Selection.getRangeAt() × ×
Selection.deleteFromDocument() × × ×
document.selection × × ×
d.selection.createRange() × × ×
d.selection.clear() × × × ×
TextRange.text × × ×
TextRange.htmlText × × × ×

Safari 1.3 の場合,Selection オブジェクトに getRangeAt() というメソッドが実装されていないので,後述しますが自力で Range オブジェクトを構築する必要があるそうです(手元に環境がないので不明)。

また,Opera は一応 Internet Explorer 互換をめざしているように見えますが,ところどころ実装されていない部分があるのでより完璧にサポートされている W3C 系 Range オブジェクトを使ったほうがよいでしょう。

選択したテキストを取得する

さて各論に入ります。まずは一番簡単な,選択範囲の文字列の取得です。

<script type="text/javascript">

var getSelectedText
    = function () {
        if (window.getSelection)
            return '' + window.getSelection();
        else if (document.selection)
            return document.selection.createRange().text;
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            var e = document.getElementById('txt_output');
            if (e.innerText != null)
                e.innerText = getSelectedText();
            else
                e.innerHTML = getSelectedText();
        };
};

</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="get selection"></p>

<textarea id="txt_output"></textarea>

Mozilla Selection オブジェクトは window.getSelection() メソッドにより取得することができます。これは Object なのですが,toString() メソッドが定義されており,選択範囲の(タグ等含まない)文字列を返します。なので stringify すれば選択範囲を文字列として取得することができます。

いっぽう MS ですが,document.selection プロパティに MS selection オブジェクトが格納されています。このオブジェクトで createRange() メソッドを呼ぶと MS TextRange オブジェクトを返すので,こいつの text プロパティを参照すると選択された文字列を取得*5することができます。

補足

MS TextRange において createRange() されたオブジェクトの text プロパティをダイレクトに呼び出していますが,実は document.selection オブジェクトの type プロパティが "control" の場合,TextRange オブジェクトではなく controlRange コレクションを返します。なので本来この type プロパティをチェックするのが筋(と思われるの)ですが,あくまで技術的サンプルであるのと面倒なので,チェックせずにつきすすんでいます。

Share on Tumblr を解体してみる

ここまで勉強した上で,冒頭に挙げた Tumblr のブックマークレットを読んでみます。

var d=document,w=window,e=w.getSelection,k=d.getSelection,
x=d.selection,s=(e?e():(k)?k():(x?x.createRange().text:0)),
f='http://www.tumblr.com/share',l=d.location,e=encodeURIComponent,
p='?v=3&u='+e(l.href) +'&t='+e(d.title) +'&s='+e(s),u=f+p;
try{if(!/^(.*\.)?tumblr[^.]*$/.test(l.host))throw(0);tstbklt();}
catch(z){a =function(){if(!w.open(u,'t','toolbar=0,resizable=0,
status=1,width=450,height=430'))l.href=u;};
if(/Firefox/.test(navigator.userAgent))setTimeout(a,0);else a();}
void(0)

手で圧縮したのがありありとわかりますが,これをわかりやすく書き換えてみました。

var selection
    = window.getSelection   ? window.getSelection()
    : document.getSelection ? document.getSelection()
    : document.selection    ? document.selection.createRange().text
    :                         0
    ;

var uri
    = 'http://www.tumblr.com/share';

var params
    = '?v=3&u='
        + encodeURIComponent(document.location.href)
    + '&t='
        + encodeURIComponent(document.title)
    + '&s='
        + encodeURIComponent(selection)
    ;

var query = uri + params;

try {
    if (! /^(.*\.)?tumblr[^.]*$/.test(document.location.host))
        throw(0);

    tstbklt();
}
catch (e) {
    do_query
        = function () {
            if (! window.open(query, 't', 'toolbar=0, ...'))
                location.href = query;
        };

    if (/Firefox/.test(navigator.userAgent))
        setTimeout(do_query, 0);
    else
        do_query();
}

void(0)

だいたい理解できますね。

元ページのホストに tumblr が含まれている場合に trycatch で囲んで tstbklt() メソッドを呼んでいるのは,おそらく Tumblr のサイトで他に読み込まれた JavaScript で定義されており,特別な処理を行っているのでしょう。例外処理でかこっているのは tstbklt() が定義されてないページがあったり失敗した場合に備えてだと思います。

Firefox の場合に setTimeout() 経由で呼び出しているのはよくわかりませんでした。誰か教えて。

選択したノードを削除する

<script type="text/javascript">

var deleteSelectedNodes
    = function () {
        if (window.getSelection) {
            var sel = window.getSelection();

            var r;
            if (sel.getRangeAt)
                r = sel.getRangeAt(0);
            else {  /* for Safari 1.3 */
                r = document.createRange();
                r.setStart(sel.anchorNode, sel.anchorOffset);
                r.setEnd(sel.focusNode, sel.focusOffset);
            }

            r.deleteContents();
        }
        else if (document.selection) {
            if (document.selection.clear)
                document.selection.clear();
            else
                document.selection.createRange().text = '';
        }
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            deleteSelectedNodes();
        };
};

</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="delete selected nodes"></p>

最初 Mozilla Selection 版では deleteFromDocument() メソッド*6が存在する場合にはそれを呼ぶようにしていたのですが,Opera では deleteFromDocument() が存在するくせに削除はしてくれない,というイヤな挙動を示したのではずしました。また,Safari 1.3 では getRangeAt() メソッドがないので,document.createRange() で W3C Range オブジェクトを作成し自力で調整しています。

MS 版では document.selectionclear() メソッドがある場合それを呼び出すようにしていますが,Opera にはこのメソッドがありません。createRange() 経由で MS TextRange を取得してもさして手間ではないので clear() 部分は実際には必要ないと思います。

しかし(このままのコードで何も考えずに)削除すると,表示されている任意のコンテンツを削除することができる*7ので適用範囲がないかなぁと思います。

補足

Mozilla Selection ですが,getRangeAt() メソッドの引数 0 からもわかる通り,仕様としては複数の選択範囲が存在することが想定されているようです(Range オブジェクトの数は rangeCount プロパティで取得できます)。ですが Ctrl キーとか使っても複数選択はできなさそうだったし面倒(ry

選択したノードをコピーする

<script type="text/javascript">

var cloneSelectedNodesTo
    = function (e) {
        if (window.getSelection) {
            var sel = window.getSelection();

            var r;
            if (sel.getRangeAt)
                r = sel.getRangeAt(0);
            else {  /* for Safari 1.3 */
                r = document.createRange();
                r.setStart(sel.anchorNode, sel.anchorOffset);
                r.setEnd(sel.focusNode, sel.focusOffset);
            }

            e.innerHTML = '';
            e.appendChild(r.cloneContents());
        }
        else if (document.selection) {
            e.innerHTML = document.selection.createRange().htmlText;
        }
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            cloneSelectedNodesTo(document.getElementById('target'));
        };
};

</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="clone selected nodes"></p>

<div id="target"></div>

W3C Range 版では Range オブジェクトの cloneContents() メソッドを呼ぶと DocumentFragment が取得できるのでそれを appendChild() 等することができます。

MS 版ではぱっとみ DOM ツリーを取得するプロパティ・メソッドが見当たらなかったので,選択範囲の HTML を返す htmlText プロパティを他の要素の innerHTML プロパティに代入しています。

選択範囲の HTML を取得する

<script type="text/javascript">

var getSelectedHTML
    = function () {
        if (window.getSelection) {
            var sel = window.getSelection();

            var r;
            if (sel.getRangeAt)
                r = sel.getRangeAt(0);
            else {  /* for Safari 1.3 */
                r = document.createRange();
                r.setStart(sel.anchorNode, sel.anchorOffset);
                r.setEnd(sel.focusNode, sel.focusOffset);
            }

            e = document.getElementById('dummy');
            e.innerHTML = '';
            e.appendChild(r.cloneContents());
            
            return e.innerHTML;
        }
        else if (document.selection)
            return document.selection.createRange().htmlText;
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            var e = document.getElementById('txt_output');
            if (e.innerText != null)
                e.innerText = getSelectedHTML();
            else
                e.innerHTML = getSelectedHTML();
        };
};
</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="get selection as html"></p>

<textarea id="txt_output"></textarea>

<div id="dummy"></div>

MS 版では先ほど利用したように htmlText プロパティに HTML が入っています。

W3C Range 版では逆に該当するプロパティが見当たらなかったので,ダミー要素の child に代入してそれの innerHTML を取得しています。もっといい方法があったら教えてください。

感想

W3C 版も MS 版もそれぞれ良いところ足りないところがありますね。

しかしブックマークレット等で使おうと思っても選択範囲が膨大だと URL が長くなってしまってブラウザやサーバがうけつけなかったりしそう。テキストならともかく HTML だとなおさら。さりとてローカルサイトで利用しようにも用途が思いつきません*8

参考にしたサイト

*1:このへんニワカなので正確な情報かどうか自信ありません。ご指摘求む。

*2:ただもちろんこの「範囲」を削除したり置き換えたり,などの manipulation は W3C Range で規定されています。

*3:Firefox 2.0.0.12 で確認

*4:Opera 9.24 で確認

*5:後述しますが取得だけでなく設定することもできます

*6:おそらく NN4 時代からのインタフェースだと思います。未検証。

*7:念のため書いとくと,W3C Range 版でも MS 版でも選択範囲の親ノードを取得することができるので,削除可能な範囲を限定することはできます

*8:ネタりかのマーカー機能で利用されてるんじゃないかな。個人的には大嫌いな機能ですが。

JavaScript で遅延実行

遅延実行っていうのかな?

たとえば jQuery Interface の Sortable とかで,item を Drag'n Drop する都度 Ajax すると,サーバに負荷がかかるしモッサリしそうだし,と思いました。

なので,

  • 遅延実行してほしいメソッドを登録する
  • timeout 内に別のメソッドが追加登録された場合は,timeout を設定しなおす
  • 最終的に timeout に到達するとそこまで登録されたメソッドを実行する
    • 最後に指定されたメソッドのみ実行モード,もアリ

みたいな機能(メソッドキュー?)がほしくなって書いてみました。

var MethodQueue = function () {
    this.ctor.apply(this, arguments);
};

MethodQueue.prototype = {
    ctor:
        function () {
            if (typeof arguments[0] != 'object')
                return
                    this.ctor({
                        timeout:     arguments[0],
                        latest_only: arguments[1]
                    });

            var args = arguments[0];
            this.timeout     = args.timeout     || 1000;
            this.latest_only = args.latest_only;

            this.fns      = [];
            this.timer_id = null;
        },

    clear:
        function () {
            this.fns = [];
        },

    append:
        function (fn) {
            if (this.timer_id) {
                clearTimeout(this.timer_id);
                this.timer_id = null;
            }

            if (this.latest_only)
                this.fns = [ fn ];
            else
                this.fns[this.fns.length] = fn;

            var self = this;
            this.timer_id
                = setTimeout(function () { self._execute(); }, this.timeout);
        },

    _execute:
        function () {
            for (var i = 0; i < this.fns.length; i ++)
                this.fns[i].apply(this);

            this.clear();
        }
};

んー。すでにありそう感満点。

つか JavaScript 的にどのように書くのが smart なのかわかりません。普通どう書くんだろ。

そもそもこんなもの必要ないのかな?


使い方は,たとえば jQuery Interface Sortable だと,

var mq = new MethodQueue({
    timeout:     500,
    latest_only: true,
});

$('li.sortable_list').Sortable({
    accept: 'sortable_item',
    onStop:
        function () {
            mq.append(function () { console.log('invoked') });
        }
});

みたいに。latest_onlytrue にすると,最後に登録したメソッドのみ実行します。Ajax とかだとこういう状況のほうがアリかなぁと思いまして。

Drupal でメッセージを隠せるようにする

Drupal 5.x は昔と比べて見目麗しくなったので,最近ちょこちょこ遊んでいます。

ページの追加とか管理作業とかすると上部にメッセージボックスがでるんですが,これをクリックすると消えるようにしたいと思いました。activeCollab*1 だとそういう挙動なんで真似しようと思ったわけです。

Message Effects というモジュールもあるんですが,Drupal はデフォルトで jQuery を読み込むので,JavaScript を書くだけで,できそう。

if (Drupal.jsEnabled) {

var MakeMessagesHidable = function (boxes) {
    boxes = $(boxes);

    boxes.click(function () {
             var me = $(this);
             me.animate(
                    { opacity: 0.0 }, 500, null,
                    function () { me.animate({ height: 'hide' }, 300); }
                );
          })
         .css('cursor', 'pointer')
         ;

    /* color animation will be available with jQuery Interface */
    if (jQuery.fxe)
        boxes.each(function () {
                  var me = $(this);
                  var old_bgcol = me.css('backgroundColor');
                  me.css('backgroundColor', '#ffffbb')
                    .animate({ 'backgroundColor': old_bgcol }, 1000)
                    ;
              })
             ;
};

$(function () {
    MakeMessagesHidable('div.messages');
});

}

jQuery Interface モジュールがインストールされてて,どこかで jquery_interface_add() されてる場合は,ハイライトするようにもなってます。jQuery Interface がないと color の jQuery.animate() がうまくいかなかったんで。jQuery.Highlight() でやらずに自力で animate() してるのはその試行錯誤の名残りです。でもま,ミニマム指向でよしとする。

あと,単純に slideUp()hide('slow') とかしてないのは挙動が気にくわなかったためです。

*1:めっきり触っていません。文句の言える筋合いではないですが,正直なんだかなぁという展開になっちゃいましたし。追記: http://projectpier.org/オープンソースなフォークがあるんですね。知りませんでした。