JavaScript
RequireJS

RequireJS使ったモジュールをリファクタリングする方法

More than 1 year has passed since last update.

初めに

RequireJS使ったJavaScriptをリファクタリングしていく仕事を最近やっていて自分自身でRequireJS使ったことが無かったので、小さいアプリケーションをローカルに作って、それをベースにちょっとづつリファクタリングしていったら、割りと良い感じのコードになったので流れをまとめてみました。

参考までに作ったアプリの仕様

  • XHRで外部からJSONデーターを読み込む
  • 読み込んだJSONを加工して任意のHTML要素に追加

アプリの構造

├── gulpfile.js
├── index.html
├── karma.conf.js
├── lib
│   └── require.js
├── node_modules
│   └── (たくさんあるので省略)
├── package.json
├── spec
│   └── (このディレクトリにJasmine使ったテストを配置)
└── src
    ├── app.js
    ├── load_books.js
    ├── main.js
    ├── model
    └── view

開発環境

  • Mac OS X 10.8.5
  • Node.js
    • v4.2.4
    • nodebrewを利用してインストール
    • インストールしたnpmモジュールは以下のpackage.jsonを参照
  • テストは途中で書いたのでJasmine+Karmaの環境で実行してます。

package.json

{
  "name": "sample",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "gulp": "^3.9.0",
    "gulp-webserver": "*",
    "jasmine-core": "^2.3.4",
    "jquery": "^2.1.3",
    "karma": "^0.13.15",
    "karma-chrome-launcher": "^0.2.1",
    "karma-jasmine": "^0.3.6",
    "karma-jasmine-html-reporter": "^0.1.8",
    "karma-jasmine-jquery": "^0.1.1",
    "karma-requirejs": "^0.2.2",
    "karma-spec-reporter": "0.0.22",
    "requirejs": "^2.1.22",
    "underscore": "^1.8.3"
  }
}

gulpfile.js

var gulp = require('gulp');
var webserver = require('gulp-webserver');

gulp.task('webserver', function() {
  gulp.src('./')
    .pipe(webserver({
      livereload: true,
      port: 9000,
      fallback: 'index.html',
      open: true
    }));
});

gulp.task('default', ['webserver']);

最初の凄くイケてないコード

RequireJSがちょっとよくわからない状態だったこともあり、ベタに
1. XHR経由でJSONを読み込み
2. 結果を画面に反映させる

というのをこんな感じ↓で実装しました。

src/load_json.js

requirejs(['loadJSON'], function(loadJSON) {
  $('#loadJSON').on('click', function(){
    var url = 'https://gist.githubusercontent.com/h5y1m141/da8e8b4a1c3e697eda4c/raw/9a0ee74da46378d6cd7402e5f921d0cc4a1c041a/qiita.json';
    $.ajax({
      url: url
    }).done(function(data) {
      var items = JSON.parse(data),
          result = [];
      _.each(items, function(item){
        var element;
        element = '<li>名前:' + item.author + '</li>' +
          '<li>タイトル:' + item.text + '</li>';
        result.push(element);
      });
      $('.dataArea').append('<ul>' + result + '</ul>');
    });
  });
});

src/app.js

requirejs.config({
  paths: {
    'jquery': '../node_modules/jquery/dist/jquery',
    'underscore': '../node_modules/underscore/underscore-min',
    'loadJSON': 'load_json'
  },
  shim: {
    'loadJSON': {
      deps: ['underscore', 'jquery'],
      exports: 'loadJSON'
    }
  }
});
requirejs(['load_json']);

index.html

<html>
  <head>
    <script data-main='src/app' src='lib/require.js'></script>
    <script src='src/no_require.js'></script>
  </head>
  <body>
    <h3>JSON読み込みサンプル</h3>
    <button id='loadJSON'>読み込み開始</button>
    <div class='dataArea'></div>
  </body>
</html>

これのどこが問題か?

動作はするという意味ではOKかもしれませんが、外部からデーターを読み込む→ (読み込みが成功し)→ そのデーターをViewに反映する処理が一緒になってるため、このモジュールの役割(責任範囲)がイマイチよくわからないかなと思います。

  • 外部からデーターを読み込む処理を担うモジュール -(読み込みが成功し)たらそのデーターをViewに反映するモジュール

という形で分割することで、それぞれの役割も明確になり自然とそのモジュールに対しての単体テストも書きやすくなりそうでよく言われるテストが書きやすいコードは良いコードにつながる気がしました。

最初にベタに書いて、正直あまりイケてなかったコードをどのようにリファクタリングしたか順番にまとめていきます。

外部からデーターを読み込む処理を分ける

Webアプリケーションのバックエンドでいう所のModelという位置づけと捉えて、src/model/books.jsというファイルを作成します。

src/model/books.js

load_json.jsで処理されていた$ajaxの処理をbooks.jsにまずは移動させつつ、jQuery.Deferredを利用する書き方に変更しました。

define(['jquery'],function($) {
  return {
    fetch: function() {
      var deferred,
          url = 'https://gist.githubusercontent.com/h5y1m141/da8e8b4a1c3e697eda4c/raw/9a0ee74da46378d6cd7402e5f921d0cc4a1c041a/qiita.json';
      // 以下の部分が最初のコードとちょっと異なる点
      deferred = $.ajax({
        url: url
      });
      return deferred.promise();
    }
  };
});

なぜ、jQuery.Deferredを使うのか?

jQuery.Deferredを使うと嬉しいのは、jQuery.Deferredの仕様を満たす部品同士を簡単に組み合わせることが可能だからです。中には処理を書き下すことができるとかコールバックのネストを防げるのがいいとか言う人もいますが、個人的にこっちのほうがよっぽど重要だと感じます。

結局jQuery.Deferredの何が嬉しいのか分からない、という人向けの小話

という一文があります。

jQuery.Deferredの仕様を満たす部品同士を簡単に組み合わせることが可能になって、モジュール同士の連携がやりやすくなる利点があるので利用しました。

src/load_books.js

ModelでjQuery.Deferredを使った処理にしたので、こちらも一部書き換えます。

requirejs(['books'], function(books) {
  $('#loadJSON').on('click', function(){
    var promise,result = [];
    // これ以降が異なる
    promise = books.fetch();
    promise.done(function(data){
      var items;
      items = JSON.parse(data);
      _.each(items, function(item){
        var element;
        element = '<li>名前:' + item.author + '</li>' +
          '<li>タイトル:' + item.text + '</li>';
        result.push(element);
      });
      $('.dataArea').append('<ul>' + result + '</ul>');
    });
  });
});

上記コードを見てもらうとわかるかと思いますが、jQuery.Deferredを利用したことで従来は$.ajax()を実行→実行後、done(function() {})でViewの描画というネストしたコードになりがちだったものが、上記のようにネストが深くならない状態になりこの状態で個人的には良さそうなコードになってきた気がしました。

上記の2つのモジュールを呼び出す側の修正

src/app.jsを修正します。

requirejs.config({
  paths: {
    'jquery': '../node_modules/jquery/dist/jquery',
    'underscore': '../node_modules/underscore/underscore-min',
    'books': 'model/books'
  },
  shim: {
    'books': {
      deps: ['underscore', 'jquery'],
      exports: 'books'
    }
  }
});
requirejs(['load_books']);

この状態でも悪くはないのですが、load_books.jsの位置づけとしては、MVC的にいうとControllerのような位置づけな気がしました。

Viewに関する描画処理を別にモジュールとして定義したほうがそれぞれモジュールとして独立した状態になり、テストが書きやすくなるので、ここから更にリファクタリングしていきます。

Viewの処理のリファクタリング

src/load_json.jsで

promise.done(function(data){
  _.each(items, function(item){
    // ここからViewの描画処理を実施してる箇所。コードは省略
  });
});

という構造になっていましたが、promise.doneでデーターを受け取ったらそのデーターを引数にしてViewの描画を行うモジュールを定義することにしました。

Viewの描画を行うモジュールを作成

src/view/books.jsというファイルを作成して以下のように記述しました。

define(['underscore', 'jquery'], function(_, $) {
  return {
    render: function(items) {
      var result = [],
          $dataArea;
      $dataArea = $('.dataArea');
      _.each(items, function(item){
        var element;
        element = '<li>名前:' + item.author + '</li>' +
          '<li>タイトル:' + item.text + '</li>';
        result.push(element);
      });
      return $dataArea.append('<ul>' + result + '</ul>');
    }
  };
});

このモジュールでrender()というメソッドを定義して、引数に描画をする時に必要となるデーターを受け取ります。

描画のロジックは、Underscoreのtemplateの機能などを使ったほうがより保守性があがりますが、いきなり色々変更すると、エラーが出た時に、何が問題なのか把握しづらくなるのでひとまずこの段階ではそのままコピペしました。

モジュールを呼び出す側の修正

Viewの描画を行うモジュールを作成をしたのでsrc/app.jsでそのモジュールを読みこむようにします。

requirejs.config({
  paths: {
    'jquery': '../node_modules/jquery/dist/jquery',
    'underscore': '../node_modules/underscore/underscore-min',
    'books': 'model/books',
    'viewBooks': 'view/books' // ←これを追加
  },
  shim: {
    'books': {
      deps: ['jquery'],
      exports: 'books'
    },
    // 以下を追加
    'viewBooks': {
      deps: ['underscore', 'jquery'],
      exports: 'viewBooks'
    }
  }
});

requirejs(['main', 'use_non_amd','load_books']);

最後に細かい修正

上記の最終的な仕上げとして以下を行いました

  • Model
    • 上記の書き方だとメソッドを増やした時に可読性悪いので、そこを考慮した書き方にする
    • Jasmineでテスト書きやすくなったのでテストを書く
  • View
    • 上記の書き方だとメソッドを増やした時に可読性悪いので、そこを考慮した書き方にする
    • Viewの描画をベタに文字列でHTMLで書いてる

src/model/books.js

'use strict';

var loadJSON = function($){
  // private method
  function _fetch(){
    var deferred,
        url = 'https://gist.githubusercontent.com/h5y1m141/da8e8b4a1c3e697eda4c/raw/9a0ee74da46378d6cd7402e5f921d0cc4a1c041a/qiita.json';
    deferred = $.ajax({
      url: url
    });
    return deferred.promise();
  }

  // public method
  var publics = {
    fetch: _fetch
  };

  return publics;
};

define(['jquery'], loadJSON);

テストについて

Karma+Jasmineでテストを書いたのですが、

  • KarmaからRequireJSなモジュールを読み込むための設定ファイルを作ってあげないといけない
  • その設定ファイルのbaseのパスの位置づけがよくわらない

みたいな所で、だいぶハマりました・・

karma.conf.js

module.exports = function(config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', 'requirejs'],
    files: [
      {
        pattern: 'lib/**/*.js',
        included: false
      },
      {
        pattern: 'src/**/*.js',
        included: false
      },
      {
        pattern: 'spec/**/*_spec.js',
        included: false
      },
      {
        pattern: 'node_modules/jquery/dist/jquery.js',
        included: false
      },
      {
        pattern: 'node_modules/underscore/underscore.js',
        included: false
      },
      'spec/require.config.js'
    ],
    // プロダクトコード用のrequire.config.jsを指定してテスト実行時には読み込まないようにする。
    exclude: [
      'src/app.js'
    ],
    preprocessors: {
    },
    reporters: ['spec', 'html'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false,
    concurrency: Infinity
  });
};

spec/require.config.js

var tests = [];
for (var file in window.__karma__.files) {
  if (window.__karma__.files.hasOwnProperty(file)) {
    if (/spec\.js$/.test(file)) {
      tests.push(file);
    }
  }
}

requirejs.config({
  baseUrl: "/base",
  paths: {    
    'jquery': 'node_modules/jquery/dist/jquery',
    'underscore': 'node_modules/underscore/underscore',
    'books': 'src/model/books'
  },
  shim: {
    'books': {
      deps: ['jquery'],
      exports: 'books'
    }
  },
  deps: tests,
  callback: window.__karma__.start
});

上記のような設定ファイルを準備した上でターミナルから

./node_modules/karma/bin/karma start ./karma.conf.js

と実行することで、spec/配下にあるテストが実行されます

spec/model/books_spec.js

define(['books'], function(books) {
  describe('model/books', function() {
    describe('fetchメソッドについて', function() {
      beforeEach(function () {
        // 実際にWebにはアクセスせずに、期待されるJSONをMockのオブジェクト
        // として定義
        spyOn(books, "fetch").and.callFake(function(){
          var deferred = $j.Deferred();
          var dummy = [
            {
              'author': 'h5y1m141',
              'text': 'はじめてのQiita'
            },
            {
              'author': 'h5y1m141',
              'text': 'First Step Qiita'
            }
          ];
          deferred.resolve(JSON.stringify(dummy));
          return deferred.promise();
        });
      });
      it('値が得られる', function() {
        var items,
            promise;
        promise = books.fetch();
        promise.done(function(data){
          items = JSON.parse(data);
        });
        expect(items[0].author).toEqual('h5y1m141');
      });
    });
  });
});

src/view/books.js

ググッた時にちょっと古い情報(_.template(template, items)みたいな書き方を紹介してるやつ)を参考に書いてうまく動作せずに当初ハマったのですが公式ドキュメント見て書き方を見つつ以下のようにしたら動作しました

'use strict';

var viewBooks = function(_, $) {
  // private method
  function _render(items){
    var result = [],
        $dataArea,
        template,
        compiled;
    $dataArea = $('.dataArea');
    template = $('#myBooks').html();
    compiled = _.template(template);
    return $dataArea.append(compiled({ items: items }));
  }
  // public method
  var publics = {
    render: _render
  };

  return publics;
};
define(['underscore', 'jquery'], viewBooks);

index.html

描画処理を行う時にUnderscoreのtemplate&compileを利用するようにしました。

JavaScript内でHTMLの記述を行わなくて済むようになり、その変わりにHTMLが以下のようになります。

<html>
  <head>
    <script data-main='src/app' src='lib/require.js'></script>
    <script src='src/no_require.js'></script>
  </head>
  <body>
    <h3>JSON読み込みサンプル</h3>
    <button id='loadJSON'>読み込み開始</button>
    <div class='dataArea'></div>
    <ul>
      <script type="text/template" id="myBooks">
       <% _.each(items, function(item){ %>
         <li>名前:<%= item.author %></li>
         <li>タイトル:<%= item.text %></li>
       <% }); %>
      </script>
    </ul>
  </body>
</html>

最後に

View側のテストも書いたのですが、Jasmine+Karmaの環境でRequireJS使ったサンプルがあまり見つからず、こっちは色々ハマってそれについても触れてしまうと、さらに長文になるので、別の機会にまとめようと思います。