JavaScriptの設計について考える - 分類した機能の保守性を高める(前編)

はじめに

本稿ではJavaScriptにおける弊社の基本的な設計方針についてご紹介しています。前回はオブジェクトや名前空間を利用することによって、機能ごとに分類するための考え方を説明させて頂きました。今回は分類された各機能の設計方針について説明致します。尚、説明の便宜上、今後はこのような設計をオブジェクト設計と呼ぶことにします。

オブジェクト設計の考え方

近年のWebサイトはネイティブアプリケーションと同等の操作性を実現することができるようになりました。例えばWebコンポーネントのドラッグアンドドロップやスライドメニューなどが挙げられます。また操作性だけではなく、カルーセルやパララックスのようにWebサイトの表現能力を多様化するための仕組みを実装することも多くなりました。

このように求められる機能は多岐に渡るため、それらの機能を実装する際のオブジェクト設計も、ケースバイケースになります。しかし、JavaScriptという言語のアーキテクチャを考えると、機能が異なっても、実装の流れは概ね次のようになることが多いと思います。

  • Webページのロードを待つ
  • 必要なDOM情報を取得する
  • (必要があれば)初期設定を行う
  • イベントハンドラを設定する
  • 各イベントハンドラを実装する

勿論、各処理の内容は機能ごとに異なることになります。

オブジェクト設計をする際に重要なことは2つあります。

1つ目は、機能を実装するために必要な処理を、全てオブジェクト内にまとめることです。よってオブジェクト内でグローバル変数を使用するべきではありません。グローバル変数を使用するということは、オブジェクトに必要な情報を、オブジェクトの外部に持たせることになります。そうなると機能修正の影響がオブジェクト外にも及ぶため、保守性が非常に低くなります。

尚、1つの機能を1つのオブジェクトだけで設計するとは限りません。単純な機能であれば、全ての処理を1つのオブジェクトにまとめることもできるでしょう。しかし複雑な機能を実装する場合は、1つの機能を複数の機能ブロックに分割する必要性が出てきます。このような場合は機能ブロックごとにオブジェクト設計をし、それらのオブジェクトを統括して管理するためのオブジェクトを作成することもあります。

2つ目は処理内容ごとに適切にメソッドに分割することです。これには次のようなメリットがあります。

  • 適切なメソッド名をつけることによって、処理内容を把握しやすくなる
  • 処理を修正する場合の影響範囲を限定することができる

オブジェクト設計の具体例

機能を想定せずに一般論だけで設計の話をしてもピンと来ないと思いますので、具体例を挙げて説明することにします。余りに単純な機能だと、設計の有用性が分かりにくくなります。また、非常に複雑な機能だと設計のハードルが上がってしまいます。そこで機能としてはそこそこ複雑なカルーセルを例に取ることにします。

カルーセルとは、複数の画像を自動的に切り替えるための機能です。切り替え方には色々ありますが、その名称(回転木馬)通り、横方向にスライドしながら切り替えることが多いようです。また、ユーザの操作(ボタンの押下等)によって切り替えることもあります。カルーセルを実装する際のHTMLの例を以下に示します。

<div id="jsi-carousel-wrapper">
    <ul id="jsi-carousel-container">
        <li><img src="img/sample1.jpg" /></li>
        <li><img src="img/sample2.jpg" /></li>
        <li><img src="img/sample3.jpg" /></li>
        <li><img src="img/sample4.jpg" /></li>
    </ul>
    <input type="button" value="前へ" id="jsi-previous-trigger" />
    <input type="button" value="次へ" id="jsi-next-trigger" />
</div>

divタグを画像1枚と同じサイズに設定することにより、常に画像が1枚だけ見える状態にしておきます。そして一定時間経過するか、ボタンが押下されるたびにulタグを左右に移動させ、見える画像を切り替えます。尚、設計の本質とは関係ないため、今回はCSSについては触れないことにします。

まずはオブジェクト設計をせずにカルーセルを実装する例を示します。

コードその1

$(function(){
    /*
     * DOM情報の取得
     */
    var $carouselWrapper = $('#jsi-carousel-wrapper'),
        $carouselContainer = $('#jsi-carousel-container'),
        $carouselElements = $carouselContainer.children('li'),
        $previousTrigger = $('#jsi-previous-trigger'),
        $nextTrigger = $('#jsi-next-trigger');
    /*
     * カルーセル実行のための準備
     */
     ..........................
    /*
     * イベントハンドラの設定
     */
     $previousTrigger.on('click', function(){
         // カルーセルを1つ前に移動させる処理
         ..........................
     });
     $nextTrigger.on('click', function(){
         // カルーセルを1つ先に移動させる処理
         ..........................
     });
     /*
      * タイマーの設定
      */
     setInterval(function(){
         // カルーセルを定期的に移動させる処理
         ..........................
     }, 5000);
});

Webページに実装する機能がカルーセルだけの場合は、これでも十分かもしれません。しかし、前回説明したように、複数の機能を実装する場合は、コード内の機能ごとの境界線が曖昧になるため、保守性が下がってしまいます。それでは上記のコードに対して、段階を踏んでオブジェクト設計を進めてみることにします。

関連性のある処理をオブジェクト内にまとめる

コードその2

var LEIHAUOLI = LEIHAUOLI || {};
LEIHAUOLI.SAMPLE_CODE = {};

/*
 * カルーセルの処理を定義
 */
LEIHAUOLI.SAMPLE_CODE.CAROUSEL = {
    init : function(){
        /*
         * DOM情報の取得
         */
        var $carouselWrapper = $('#jsi-carousel-wrapper'),
            $carouselContainer = $('#jsi-carousel-container'),
            $carouselElements = $carouselContainer.children('li'),
            $previousTrigger = $('#jsi-previous-trigger'),
            $nextTrigger = $('#jsi-next-trigger');
        /*
         * カルーセル実行のための準備
         */
         ..........................
        /*
         * イベントハンドラの設定
         */
         $previousTrigger.on('click', function(){
             // カルーセルを1つ前に移動させる処理
             ..........................
         });
         $nextTrigger.on('click', function(){
             // カルーセルを1つ先に移動させる処理
             ..........................
         });
         /*
          * タイマーの設定
          */
         setInterval(function(){
             // カルーセルを定期的に移動させる処理
             ..........................
         }, 5000);
    }
};

$(function(){
    /*
     * カルーセルの処理を実行
     */
    LEIHAUOLI.SAMPLE_CODE.CAROUSEL.init();
});

上記のコードは、カルーセルの機能に必要な処理を、CAROUSELという名のオブジェクト内にまとめています。これにより、他の機能から明確に分離させることができました。カルーセルの機能を修正する場合の影響範囲は、CAROUSELオブジェクト内に限定することができます。

しかし、全ての処理をinitメソッド内に記述しているため、コメントをつけないと、処理の流れが不明瞭になる可能性があります。更にカルーセルを部分的に修正する場合でも、その影響範囲が処理全体に及ぶため、保守性もそれほど良くありません。処理の流れを明瞭にし、修正の際の影響範囲を更に限定するためにも、処理を適切に分割してメソッド化する必要があります。

尚、機能をオブジェクトとして設計すると、上記のように定義と実行を明確に分離することができます。これにより、コード全体の見通しが良くなり、保守性が向上します。オブジェクトの定義は、Webページや他の機能の状態とは無関係に行うことができます。よってブラウザがスクリプトを読み込むのと同時に解釈されるように、イベントの待ち合わせなどをせずに記述しています。

今回の例では、カルーセルを単体で動作させることを想定しているため、カルーセルの処理はWebページの読み込みが完了したタイミングで実行しています。しかし実行のタイミングは実装する機能と設計方針によって大きく異なることがあります。

例えば、カルーセルに必要な情報をajaxで取得する場合について考えてみましょう。この場合、ajaxによるサーバとの通信機能とカルーセルの機能を分離させるかどうかで設計が大きく異なることになります。カルーセルの情報を取得するためだけにajaxを実装するのであれば、両者を1つの機能としてまとめる方が良いでしょう。この場合は、コードその2と同じように、Webページの読み込み完了後に「ajax機能を含んだ」カルーセルを実行することになります。

一方、カルーセルだけではなく、その他の多くの機能もajaxによる通信を必要とする場合、ajaxの機能を独立した1つの機能として設計する方が良いかもしれません。この場合は、Webページの読み込み完了時ではなく、ajaxによるデータ読み込み完了時にカルーセルを実行する必要があります。

適切に処理を分割してメソッド化する

では次に一塊になってしまっている処理を分割し、メソッド化してみることにしましょう。

コードその3

var LEIHAUOLI = LEIHAUOLI || {};
LEIHAUOLI.SAMPLE_CODE = {};

LEIHAUOLI.SAMPLE_CODE.CAROUSEL = {
    init : function(){
        this.setParameters();
        this.prepareToExecuteCarousel();
        this.bindEvent();
        this.setTimer();
    },
    setParameters : function(){
        this.$carouselWrapper = $('#jsi-carousel-wrapper');
        this.$carouselContainer = $('#jsi-carousel-container');
        this.$carouselElements = this.$carouselContainer.children('li');
        this.$previousTrigger = $('#jsi-previous-trigger');
        this.$nextTrigger = $('#jsi-next-trigger');
    },
    prepareToExecuteCarousel : function(){
         ..........................
    },
    bindEvent : function(){
         this.$previousTrigger.on('click', function(){
             // カルーセルを1つ前に移動させる処理
             ..........................
         });
         this.$nextTrigger.on('click', function(){
             // カルーセルを1つ先に移動させる処理
             ..........................
         });
    },
    setTimer : function(){
        setInterval(function(){
            // カルーセルを定期的に移動させる処理
            ..........................
        }, 5000);
    }
};

$(function(){
    LEIHAUOLI.SAMPLE_CODE.CAROUSEL.init();
});

処理をメソッドに分割することで流れが明確になったため、コメントがなくても処理全体を把握することができるようになりました。但し、残念がらイベントハンドラの処理だけはコメントがないと分かりにくいため、コメントを残しています。これは、イベントハンドラを無名関数として定義しているため、適切な名前をつけることができていないからです。

コードその2では、$carouselWrapper等の変数はローカル変数として定義していました。これらの変数のスコープはinitメソッド全体に及ぶため、initメソッド内で定義した全ての関数から共有することができます。しかしコードその3ではメソッドを分割したため、ローカル変数のままでは他のメソッドから参照できません。

オブジェクト内の複数のメソッドで値を共有するためには、ローカル変数ではなくインスタンス変数として定義する必要があります。インスタンス変数はグローバル変数と違ってスコープがオブジェクト内に限定されるため、オブジェクト外の変数と競合することもありません。上記の例では全てのローカル変数をインスタンス変数に変更しましたが、実際には、複数のメソッドで共有する変数のみをインスタンス変数にした方が良いでしょう。

尚、インスタンス変数の定義方法は二通りあります。1つはコードその3のsetParametersメソッドで記述しているように、オブジェクト実行時に定義する方法です。もう1つは次のようにオブジェクトの初期値として定義する方法です。

コードその4

LEIHAUOLI.SAMPLE_CODE.CAROUSEL = {
    $carouselWrapper : $('#jsi-carousel-wrapper'),
    $carouselContainer : $('#jsi-carousel-container'),
    $carouselElements : $('#jsi-carousel-container').children('li'),
    $previousTrigger : $('#jsi-previous-trigger'),
    $nextTrigger : $('#jsi-next-trigger'),

    init : function(){
        this.prepareToExecuteCarousel();
        this.bindEvent();
        this.setTimer();
    },
    ......................
}

文法的にはどちらでも問題ありませんが、コードその4の定義方法には1つ大きな問題があります。それはインスタンス変数の定義時に、他のインスタンス変数の値を参照することができないということです。

JavaScriptのオブジェクトは連想配列そのものです。連想配列の定義が完了する前にその要素を参照しても、正しく読みだすことができません。例えば次のように記述した場合、(A)の時点ではCAROUSELオブジェクトの定義が完了していないため、this.$carouselContainerはundefinedになります。その結果childrenメソッドの実行に失敗し、エラーが発生することになります。

コードその5

LEIHAUOLI.SAMPLE_CODE.CAROUSEL = {
    .....................
    $carouselContainer : $('#jsi-carousel-container'),
    $carouselElements : this.$carouselContainer.children('li'),  // (A)
    .....................
}

DOMオブジェクトを取得する場合、別のDOMオブジェクトの子要素や兄弟要素として取得することがよくあります。そのような場合、次のような取得方法は効率が悪くなります。

コードその6

var $target = $('#target'),
    $children = $('#target).children();

上記の場合、同一のDOMオブジェクトであるにも関わらず、targetオブジェクトを2回取得しています。DOMオブジェクトの取得にはコストがかかるので、2回目以降は次のようにDOMオブジェクトを保存した変数を参照して取得するべきです。

コードその7

var $target = $('#target'),
    $children = $target.children();

オブジェクトの初期値としてインスタンス変数を定義する場合、コードその6の書き方をせざるを得なくなるため、効率が落ちることになります。

コールバック関数の落とし穴

オブジェクト内でコールバック関数を記述する時には注意が必要です。コールバック関数とは、他の関数の引数として渡し、その関数内で実行されることを目的とする関数を指します。イベントハンドラもコールバック関数の一種です。

コードその8

/*
 * コールバックを実行する関数
 */
var callbackExecutor = function(callbackFunction){
    ...................
    callbackFunction();
    ...................
};
/*
 * コールバック関数
 */
var callback = function(){
    console.log('OK');
};
callbackExecutor(callback);

上記の例では、関数callbackExecutorに対して、コールバック関数callbackを渡しています。callbackExecutorを実行すると、その処理の中でcallbackが実行され、コンソールにOKと表示されることになります。

尚、上記のようにコールバック関数を一旦変数に代入するよりも、次のように無名関数のまま直接渡す方が一般的かもしれません。

コードその9

var callbackExecutor = function(callbackFunction){
    ...................
    callbackFunction();
    ...................
};

callbackExecutor(function(){
    console.log('OK');
};);

さて、コードその3には次の3つのコールバック関数が存在します。

  • $previousTriggerのイベントハンドラ
  • $nextTriggerのイベントハンドラ
  • setIntervalに登録している無名関数

例えば$previousTriggerのイベントハンドラは、次のように記述することが考えられます。

コードその10

LEIHAUOLI.SAMPLE_CODE.CAROUSEL = {
    .................
    setParameters : function(){
        this.$carouselContainer = $('#jsi-carousel-container');
        this.$previousTrigger = $('#jsi-previous-trigger');
        ................
    },
    bindEvent : fucntion(){
        this.$previousTrigger.on('click', function(){          // (A)
             // カルーセルを1つ前に移動させる処理
             this.$carouselContainer.aniamte({left : ....});   // (B)
             ..........................
        });
    },
    ..................
};

上記のコードは残念ながら意図した通りには動作しません。その理由は(A)と(B)ではthisの参照先が変わるからです。

そもそもthisとは何を参照するのでしょうか?thisはメソッド(または関数)内で利用することが可能なキーワードであり、そのメソッドが紐付いているオブジェクトを参照します。

上記の例において(A)はbindEventメソッドの直下に記述されています。bindEventはCAROUSELオブジェクトのメソッドですから、thisはCAROUSELオブジェクトを参照することになります。よってCAROUSELオブジェクトのインスタンス変数である$previousTriggerも正しく認識することができます。

一方(B)はコールバック関数内で参照されています。onメソッドは、引数として渡されたコールバック関数を実行する時に、thisの参照先を「イベントが発生したDOMオブジェクト(つまり今回の例だとthis.$previousTrigger)」に変更するため、CAROUSELオブジェクトを参照しなくなります。その結果、CAROUSELオブジェクトのインスタンス変数である$carouselContainerを認識することができなくなるため、エラーが発生することになります。

尚、同じコールバック関数でもsetIntervalの場合は多少状況が異なります。setIntervalはコールバック関数内のthisの参照先は変更しません。しかし、そもそもコールバック関数はどのオブジェクトとも紐付かない無名関数として定義しているため、thisの参照先はundefinedになります。しかしJavaScript(厳密にはECMAScript)の仕様で、thisが何も参照しないという状態は許されていません。このような場合、thisはwindowオブジェクトを参照するように決められています。onメソッドとは理由が異なりますが、thisがCAROUSELオブジェクトを参照しなくなることにより、意図した通りに動作しなくなることには変わりありません。

それではコールバック関数内では、インスタンス変数やメソッドを使用することはできないのでしょうか?勿論そのようなことはありません。このようなケースを解決する方法は大きく分けて2通りあります。 >>続きはこちら

関連記事