JavaScriptでDOMを使う――オブジェクト指向入門の入門

Cが分かる人には疑りぶかいあなたのためのオブジェクト指向再入門というページがお勧めです。僕の稚拙な説明よりずっと分かりやすいです。

はじめに

JavaScriptでDOMを使う。DOMはHTMLやXMLをスクリプトやプログラムで操作するのにとても便利な技術ですが、簡単なスクリプトくらい書けるからDOMにも挑戦してみようかな……とか思って挑戦しても、実際には、挫折する人が結構いるんじゃないでしょうか。それもその筈、DOMによる文書操作はdocument.write()innerHTMLなどを使った手法とはまるっきり方向性が異なりますので、JavaやC++に馴染んだ人でもないとかなり戸惑うと思います。

意識している人は少ないかもしれませんが、JavaScriptはオブジェクト指向言語です。日付を得るのに使うvar date = new Date();のような文はその典型例ですね。そして、DOMは「ドキュメントオブジェクトモデル」という名が示すように、オブジェクト指向の考え方に沿ったものです。必然的に、JavaScriptでDOMを活用するには、オブジェクト指向的な考え方が必要になってきます。「JavaScriptでDOMが使える」というのは、大げさに言えば「オブジェクト指向言語としてのJavaScriptでDOMが使える」ということであって、「手続き型言語としてのJavaScriptでDOMが使える」ということではありません。

JavaやC++で既にオブジェクト指向に馴染んでいる人は、DOMにもすぐに馴染めるでしょう。躓くのは恐らく、JavaScriptを手続き型言語のようにしか使ったことがない人です。よく分からないまま使っていて、そのうちだんだん分かってくる、というのもアリですが、せっかくだから効率よく学びたいものですよね。なので、DOMを活用するにはまず「オブジェクト指向」を理解することから始めるのを僕はお勧めしたいです。

以下、「配列」「連想配列」「関数」などの基本的な部分を理解していることを前提に話を進めます。また、例は全てJavaScriptの書式で示します。

  • この文書の目的は「手続き型言語としてのJavaScriptしか知らない人に、オブジェクト指向の基本を理解してもらうこと」で、「W3C DOMを利用できる程度の基礎知識」を目標としています。既にオブジェクト指向を理解していてこれからDOMを学ぼうという人、W3C DOM以外のDOMを使いたい人、W3C DOMと他のDOMとを比較したい人には、お勧めできません。
  • 以下の説明は、僕の知識レベルでもそれと分かる間違いを含んでいますが、オブジェクト指向とはどんなものなのかを理解しやすくするための処置として、敢えて省略しているつもりでおります。分かりやすい・わかりにくい以前の問題で根本的に間違った解釈をしている部分がありましたら、ご指摘を頂けると幸いです。
  • なお、この文書では、特に断り無しに単純に「DOM」と言った場合は「W3Cが仕様を作っているW3C DOM」のことを指しています

オブジェクトとプロパティ

連想配列

乱暴に言ってしまうと、「オブジェクト」とは連想配列の強化版みたいなものです。ということで、まずは連想配列の利点をおさらいしましょう。例として、「幅、高さ、URL」の3つの要素を持つ「画像」という情報を扱うことを考えてみます。


image['width']  = 100;          // 連想配列「image」の'width'要素
image['height'] = 100;          // 連想配列「image」の'height'要素
image['src']    = 'image1.gif'; // 連想配列「image」の'src'要素

連想配列を使わない場合、三つの変数を使うことになります。


imageWidth  = '100';
imageHeight = '100'
imageSrc    = 'image1.gif';

連想配列の特徴は、配列の各要素が連想配列そのものに関連付けられているという点です。つまり、連想配列を使わない例ではたまたま名前が似ているだけの三つの変数・値がバラバラに存在しているのに対し、連想配列の例では「image」という配列が三つの値を保持しているのです。これは、関数や繰り返しの処理と組み合わせるときに大きなメリットになります。

例えば、画像についての「幅」「高さ」「URL」の三つの情報から何らかの処理を行う関数を作ったとします。


function imageFunction(aWidth, aHeight, aURL) {
    width  = aWidth;
    height = aHeight;
    src    = aURL;
    /* 何らかの処理 */
}
imageFuncton(imageWidth1, imageHeight1, imageSrc1);
imageFuncton(imageWidth2, imageHeight2, imageSrc2);
imageFuncton(imageWidth3, imageHeight3, imageSrc3);
...

ところが後から突然、「画像の説明」についても処理を加えたくなりました。どうすればいいでしょうか? すぐに思いつくのは、先の3つの変数に加えて4つ目の変数を用意し、関数も4つ目の引数を受け取れるように書き換えることです。


function imageFunction(aWidth, aHeight, aURL, aDescription) {
    width  = aWidth;
    height = aHeight;
    src    = aURL;
    desc   = aDescription;
    /* 何らかの処理 */
}
imageFuncton(imageWidth1, imageHeight1, imageSrc1, imageDesc1);
imageFuncton(imageWidth2, imageHeight2, imageSrc2, imageDesc2);
imageFuncton(imageWidth3, imageHeight3, imageSrc3, imageDesc3);
...

でも、こんな要領で配列を増やしたり関数の引数を増やしたりしていると、プログラム全体で書き換えなければならないところがどんどん増えてしまいます。

連想配列を使うと、こういう場合、書き換えるのは連想配列を生成する部分と関数の処理部分だけで済みます。


function imageFunction(aImage) {
    width  = aImage['width'];
    height = aImage['height'];
    src    = aImage['src'];
    desc   = aImage['desc'];
    /* 何らかの処理 */
}
imageFuncton(image1); // image1は連想配列
imageFuncton(image2); // image2も連想配列
imageFuncton(image3); // image3だって連想配列
...

このように、連想配列とは実に便利なものだと言えます。

オブジェクトと連想配列

オブジェクトは連想配列によく似ています。先ほどまでの例と同じことを「オブジェクト」で表現すると以下のようになります。


image.width  = 100;          // imageオブジェクトのwidthプロパティ
image.height = 100;          // imageオブジェクトのheightプロパティ
image.src    = 'image1.gif'; // imageオブジェクトのsrcプロパティ

実はこれ、JavaScriptにおいては連想配列と何ら変わりありません。連想配列で 配列名['要素名'] と書いていた部分を 配列名.要素名 と書き直しただけです――ですが、これも立派な「オブジェクト」です。連想配列「image」だったものが、こう書き直したら「imageオブジェクト」と呼べるようになったわけですね。

なお、連想配列で「要素」と呼んでいた各々の項目は、オブジェクトではプロパティと呼びます。この例の場合、連想配列「image」は三つの要素を持っている、と言うのと同様に、imageオブジェクトは三つのプロパティを持っている、と言います。

オブジェクトと連想配列は基本的には非常によく似ていますが、同じものではありません。連想配列に更に機能を加えたのがオブジェクト、逆に、オブジェクトからいくつかの機能を省いたのが連想配列、という風にイメージすると分かりやすいでしょう。ですから、「オブジェクト」はここまでで述べた「連想配列」の特徴を全て持っています。連想配列はオブジェクトの一種なのです。

最初に「オブジェクトは連想配列の強化版みたいなものだ」と書いたように、「オブジェクト」には、連想配列の特徴以外に「オブジェクトならではの特徴」もあります。それを見ていくために、オブジェクトを使って先ほどの例を書き直してみましょう。


image = new Image();
image.width  = 100;
image.height = 100;
image.src    = 'image1.gif';

function imageFunction(aImage) {
    if (aImage.constructor != Image) return;
    width  = aImage.width;
    height = aImage.height;
    src    = aImage.src;
    /* 何らかの処理 */
}
imageFuncton(image);

new Image()というのは、「Imageクラスのインスタンスを生成する命令」です。ここで、クラスインスタンスという二つの聞き慣れない言葉が出てきました。これらは何なのでしょうか?

クラスというのは、簡単に言えば、「これこれこういうプロパティを持っているオブジェクト」を単純な命令一発で生成するためのテンプレートです。この例の「Imageクラス」なら「width, height, srcの三つのプロパティを持ったオブジェクト」のテンプレートですね。もちろんテンプレートなど無くてもオブジェクトは作れますが、テンプレートを使うと「全てのプロパティの初期値をあらかじめ決めておいて、必要な部分だけ自分で値を再設定する」という風なことができ、労力を削減できます。

インスタンスというのは、クラスというテンプレートから作った個々のオブジェクトのことです。ここでは「image」という名前のオブジェクトをImageクラスから作っているので、「imageはImageクラスのインスタンスである」と言うことができます。(ちなみに、「何々クラスのインスタンスである」ということを「何々のオブジェクトである」と言うこともあります)

関数の1行目に注目して下さい。この部分で、関数に引数として渡されたオブジェクトがImageクラスのインスタンスであるかどうかをチェックしています。画像以外の間違ったデータが渡された場合、最初の連想配列の例だとエラーが起こり得ますが、オブジェクトを使うと、こういう「あるクラス(テンプレート)から作られたオブジェクト(データ)だけを処理する関数」が非常に簡単に作れるわけです。

「クラス」というテンプレート

では、「クラス」はどうやって作るのでしょうか。JavaScriptでは、以下のようにしてクラスを定義します。


function Image() {
    this.width  = 0;
    this.height = 0;
    this.src    = '';
}
Image.prototype.type   = 'image object';
Image.prototype.width  = null;
Image.prototype.height = null;
Image.prototype.src    = null;

最初の部分はコンストラクタ関数と呼ばれるもので、このクラスのインスタンスを生成した際に一度だけ自動で実行される関数です。通常は、ここでインスタンスのオブジェクトの初期化を行います。JavaScriptではコンストラクタ関数の名前がそのままクラス名になります。

コンストラクタ関数の後に続いているのが、「プロトタイプを用いたプロパティ定義」です。要するに、コンストラクタ関数オブジェクトのprototypeプロパティに設定したプロパティが、このクラスのインスタンス全てにコピーされると考えて下さい。当然ですが、あらかじめ文字列や数値などを指定しておけば、全てのインスタンスがその値をプロパティの初期値として持つことになります。


var img1 = new Image();
alert(img1.type); // "image object"とポップアップされる

var img2 = new Image();
alert(img2.type); // これも"image object"とポップアップされる

この例のような「自作のクラス」は「ユーザー定義クラス」と呼びます。それに対し、DateArrayのようにあらかじめクラスが用意されている物は「組み込みクラス」あるいは「定義済みクラス」と呼びます。

クラスを作る方法は言語によって異なります。これはあくまでJavaScriptの場合の話であって、他の言語でも同じ書き方ができるとは限りませんので、注意して下さい。

オブジェクトとメソッド

特定の型専用の関数

オブジェクトの話は一旦置いといて、今度は「画像のサイズを変える関数」を作ることを考えてみましょう。画像は先の例と同様にImage型のimageオブジェクトとして保持することにしますので、関数も「Image型のオブジェクトを渡したら、それを処理する」ものとします。


function resizeTo(aImage, aNewWidth, aNewHeight) {...}

resizeTo(image, 30, 100);

しかし、よくよく考えたら、resizeToのようなありふれた名前は他の関数で既に使われているかもしれません(実際、ウィンドウをリサイズする関数がそういう名前です)。なんだかトラブルを引き起こしかねませんね。どうにかならないものでしょうか?

関数の名前を「resizeImageTo」のように変えてしまうというのが最も単純な解決策ですが……今日は、この関数はImage型のオブジェクトにしか使わない、つまり、この関数はImage型のオブジェクト専用であるという点に注目してみましょう。

「メソッド」というプロパティ

何か関数を作りたいけれど、その関数はある種類のオブジェクトにしか使わないことが分かっている……こういうときは、その関数をメソッドにしてしまいましょう。

JavaScriptでは、数値や文字列と同様に関数も変数に代入できます。ですから、オブジェクトのプロパティに「文字列型のプロパティ」「数値型のプロパティ」があるなら「関数型のプロパティ」も当然作れます。そういう関数型のプロパティのことをメソッドと呼びます。


var getWidthFunc = function() {...}; // 「getWidthFunc関数」
object.getWidth  = getWidthFunc;     // 「objectオブジェクト」の「getWidthメソッド」

メソッドの特徴は、その関数をメソッド(関数型のプロパティ)として持っているオブジェクトそれ自体を簡単に参照できるという点です。具体例を示しましょう。


object.text  = 'this is an object';
object.popup = function() { alert(this.text); }

object.popup(); // "this is an object"とポップアップされる

この例ではpopupプロパティに関数が代入されています(つまり、object.popupはメソッドになっています)。この時、メソッドにされた関数の中では特殊なローカル変数・thisが使えるようになっていて、実は、このthisobjectオブジェクトそのものを指して(参照して)いるのです。ですから例えば、this.textと書けばobject.textと書いたのと同じことになります。

なお、メソッドを実行するには、普通の関数と同じように名前の後に括弧を付ければOKです(例えば object.popup メソッドなら object.popup() と書きます)。普通の関数と同じように引数も指定できますし、returnによる返り値も得られます。


object.getName = function() {...; return name; }
object.setName = function(aNewName) {...}

name = object.getName();
object.setName('test');

ちなみに、methodプロパティの内容である関数そのものを参照したい場合は、this.methodあるいはarguments.calleeと書けばOKです(後者は、普通の関数内でその関数自身を参照するのにも使えます)。

クラスとメソッド

前述の通り、オブジェクトのプロパティに関数を代入すればメソッドは作れます。ですが、クラスと組み合わせればメソッドはもっと便利になります。

クラスの作り方の中で、クラス定義であらかじめ設定しておいたプロパティは全てのインスタンスにコピーされると述べました。そして、プロパティの内容には文字列も数値も関数も設定できます。ということは、クラス定義の中でメソッド(関数型のプロパティ)を設定しておけば、そのメソッドはインスタンスにもコピーされるわけで、インスタンスを生成しただけでメソッドが使えるということになります。実例を示しましょう。


function Image() {
    ...
}
Image.prototype.type     = 'image object';
Image.prototype.width    = null;
Image.prototype.height   = null;
Image.prototype.src      = null;
Image.prototype.resizeTo = function(aNewWidth, aNewHeight) {
    ...
    this.width  = aNewWidth;
    this.height = aNewHeight;
};

var img1 = new Image();
img1.resizeTo(30, 100); // img1のwidth, heightプロパティを変更する

var img2 = new Image();
img2.resizeTo(60, 25); // img2のwidth, heightプロパティを変更する

やり方はプロパティを定義するときと同じで、コンストラクタ関数のprototypeプロパティに自作の関数を代入するだけです。

これによって、自作の関数「resizeTo」はImageクラスのメソッド「resizeTo」となり、他の同名の関数や他のクラスのメソッドに影響されることもなく、Image型のオブジェクト専用の関数として安心して使えるようになりました。また、関数内部からURLや縦横幅などの情報も簡単に取得できるようになっていますから、特に引数を与えなくとも呼び出すだけで必要な処理を行えるという、「クラス専用」どころか「そのオブジェクト専用」の関数として使うこともできます。

JavaScriptでこれまでよく使っていた、ページタイトルを表すdocument.titleのようなプロパティ、ページ書き込むためのdocument.write()ウィンドウ開くためのwindow.open()のようなメソッド、日付オブジェクトを生成するDateや配列オブジェクトを生成するArrayのような定義済みのクラスも、理屈としては、ここまでで説明したものとまったく同じです。Webページの情報を得たり、ページの内容を編集したり、ウィンドウを操作したり、あるいはDOMでページ内容を取得したり――これらは全てJavaScriptの定義済みクラスの機能なのです。今までなんとなく「メソッド? プロパティ? 分からないけど要するに変数や関数のことなんだよね」と思って使っていたものも、こうして改めて見てみると、JavaScriptというものの仕組みが見えてくるのではないでしょうか。

prototypeへのプロパティの設定は、以下のようにまとめて行うこともできます。


Image.prototype = {
    type     : 'image object',
    width    : null,
    height   : null,
    src      : null,
    resizeTo : function(aNewWidth, aNewHeight) {...}
};

これはオブジェクトリテラルと呼ばれる書式で、連想配列を生成するときの { '要素名1' : 内容1, '要素名2' : 内容2 } という書き方と同じものです。

「オブジェクト指向」の簡単なまとめ

ここまでの説明をまとめると、オブジェクト指向というのは以下のような特徴を持ったプログラミングの手法・様式であると言えます。

  • 関係がある変数同士を、連想配列の要素のように「オブジェクト」とその「プロパティ」としてまとめて管理できる
  • 関数をオブジェクトのプロパティ(メソッド)にして、そのオブジェクト専用の関数・そのクラス専用の関数として簡単に呼び出すことができる

実際には、この他にも「継承」や「多態」といった概念があるのですが、基本だけを分かってもらおうという趣旨の説明なので、ここでは触れません。

DOMとオブジェクト指向

「DOM」とは何か?

一般的な用語としてのDOMとは、ここまでの例で「画像」を「幅・高さ・URLなどのプロパティを持ったオブジェクト」として扱ってきたように、Webページのリンクやフォームといった要素を「リンク先・子要素の一覧・内容のテキストなどのプロパティを持ったオブジェクト」として扱うための取り決めです。

頑張れば自分でクラスを定義して独自のDOMを構築することも可能ですが、幸いにも、世の中には既にWeb標準のDOM(W3C DOM)というものが存在しています。W3C DOMを仕様通りに実装している処理系では、仕様で定義されているプロパティやメソッドを他の定義済みクラスやユーザー定義クラスと同じように利用することができます。

インターフェースとクラス

W3CのDOMはオブジェクト指向と非常に相性が良いですが、オブジェクト指向のためだけに設計されているわけではありません。なので、一部の用語がオブジェクト指向のそれとは異なっています。インターフェースもその一つです。

インターフェースとは、ここまでで説明した「クラス」をさらに抽象化した概念です。クラスが「(インスタンスの)オブジェクトのメソッド・プロパティの定義」「インスタンスの生成」の両方を受け持つのと異なり、インターフェースは「(そのインターフェースを持つ)オブジェクトのプロパティ・メソッドの定義」そのものです。とりあえずは、Date型やArray型はnew Date()とすればインスタンスのオブジェクトを生成できるけれど、DOMのElement型やNode型のオブジェクトはnew Element()では生成できない(専用のメソッドを使う必要がある)、という風に憶えておけばよいでしょう。

DOM操作の実例

簡単な処理の例

実際にDOMを使う例を示しましょう。

DOMでは、Document型のオブジェクトにおいて、TextNode型のオブジェクトを生成するcreateTextNode()メソッドと、NodeList型のオブジェクト(配列のようなもの)を取得するgetElementsByTagName()メソッドが利用できます。これを使って、「ページの中にテキストを埋め込む」例を見てみましょう。


var text = document.createTextNode('Hello, world.');
var body = document.getElementsByTagName('BODY')[0];
body.appendChild(text);

まず、Document型のオブジェクトであるdocumentのメソッドを使ってテキストノードを生成します。次に、body要素を取得します。最後に、body要素の最後の子として、生成したテキストノードを加えます(Element型のオブジェクトのappendChild()メソッドを使う)。

これをDOMを使わない旧来のやり方で書くと、以下のような感じになるでしょう。


document.write('Hello, world.');

あるいは、


document.body.innerText += 'Hello, world.';

このような単純な例だと、DOMを使う方がステップ数が多くて面倒かもしれません。しかし場合によってはDOMの方がずっと簡単に目的を達成できます。次はやや複雑な処理の例を示しましょう。

もっと複雑な処理の例

今度は、XHTML1.0などでidとnameの両方を持っているアンカーについて、アンカーを削除しつつその親要素にidを設定し直す処理を考えてみましょう。

旧来の手続き型言語的なやり方だと、こんな感じでしょうか。


var body     = document.body.innerHTML;
var pattern  = /<a\s+(name="[^"]+"\s+id="[^"]+"|id="[^"]+"\s+name="[^"]+")[^>]*>/i;
var startTag = body.match(pattern);
var startPos, parentPos, before1, before2;
var result = [];
while (startTag) {
  startTag  = startTag[0];
  startPos  = body.indexOf(startTag);
  before1   = body.substr(0, startPos);
  body      = body.substr(startPos).replace(startTag, '');
  if ((body.indexOf('<a ') < 0 && body.indexOf('<A ') < 0) ||
      body.toLowerCase().indexOf('<a ') > body.toLowerCase().indexOf('</a>'))
      body = body.replace(/<\/a>/i, '');
  parentPos = 0;
  do {
    parentPos = before1.lastIndexOf('<', before1.length-parentPos);
  } while(before1.charAt(before1.length-parentPos+1) == '/');
  parentPos = before1.indexOf('>', parentPos);
  before2   = before1.substr(parentPos);
  before1   = before1.substr(0, parentPos);
  result.push(before1, ' id="', startTag.match(/id=\"([^"]+)\"/i)[1], '"', before2);
  startTag = body.match(pattern);
}
result.push(body);
document.body.innerHTML = result.join('');

一見して、これが何をする処理なのかすぐ分かるでしょうか。書いた僕ですら「解読」は困難です。こんなスクリプト、書いたら書いたっきりで修正なんてしたくありませんね。

こんな処理も、オブジェクト指向で考えてDOMを使うと、以下のようにスッキリ書けます。


var nodes = document.getElementsByTagName('A');
var lastChild;
for (var i = 0; i < nodes.length; i++) {
  if (!nodes[i].id || !nodes[i].name || nodes[i].id != nodes[i].name) continue;
  while (nodes[i].hasChildNodes()) {
    lastChild = nodes[i].removeChild(nodes[i].lastChild);
    nodes[i].parentNode.insertBefore(lastChild, nodes[i]);
  }
  nodes[i].parentNode.removeChild(nodes[i]);
}

これだと、メソッド名やプロパティ名は体を表しているので処理の流れを簡単に読み取ることができます。後々スクリプトを改良するときでも「解読」する手間は少ないはずです。

このように、これまではページを「ソースコードの文字列」として操作していたのが、DOMを使うともっと分かりやすくデータベース的に操作できる。これが、DOMの最大の利点だと言えます。

おわりに

HTMLやSVGやXULのようなXMLドキュメントをスクリプトやプログラムから操作する上で、DOMは非常に便利な「ツール」です。しかし、「JavaScriptは手続き型言語である」という誤解と、それ故に「JavaScriptでDOMを使う」というフレーズから想起される「手続き型言語の様式でDOMが使えるはずだ」という誤解のせいで「やっぱり訳が分からない。DOMは使い物にならない」と思われてしまうのはとても残念なことです。

WebとWeb関連技術を利用する人にとって、この文書がDOMとオブジェクト指向を理解する一つの手がかりとなれれば、幸いです。