フロントエンド開発の初期に考えておきたいこと

Frontrend Advent Calendar 2014 - Qiita 18日目の記事です。

フレームワークや使用技術など

は方々で語られているので、それ以外の話をします!

個人的には最近Ampersand.jsが気に入ってます。

リポジトリ構成

フロントエンドのリソースはサーバー側のフレームワークassetsstaticといったディレクトリで管理される事が多いですが、フロントエンドが肥大化した今、リソースが混在することで

  • コミットやPRがどちらのものなのかわかりにくい
  • 問題が起きた時に切り分けがしにくい

といった難点があります。もしもサーバー側はAPIのみを提供してフロント側はシングルページのように疎結合な構成になっている場合、ネイティブアプリがそうであるようにリポジトリを分けて完全に別管理にしてしまうというのも一つの手です。その場合ローカル開発はGrunt.jsgulp.jsで別ポートにサーバー立ち上げるなどすると良いでしょう。

JSの依存解決手段

各モジュールを一つのグローバル変数にぶら下げてconcat等でまとめる方法は、規模が大きくなってくると連結の設定やモジュール間の依存解決が煩雑になりがちです。

// app.js
var app = {};

// foo.js
app.foo = 'foo';

// bar.js
app.bar = app.foo + 'bar';

ある程度ファイル数が多くなる事が予見できているなら、Browserify, webpack, components等を使用してCommonJSのモジュールシステムを使う事をおすすめします。

// foo.js
module.exports = 'foo';

// bar.js
module.exports = require('foo') + 'bar';

CSSの設計方針

同じような記述の乱立やスタイルの衝突といった事態を防ぐためには、OOCSS, SMACSS, BEMなどのパラダイムを取り入れ、初期設計をしっかり行っておく事が重要になります。 気をつけたいのはこれらはあくまで設計のルールであってフレームワークではないので、チーム開発で運用していくにはスタイルガイドなどを用意しておくと良いでしょう。

CSS設計に関してはこの本が非常におすすめです。
今見たらKindle版がセールで60%OFFの¥950になってました。

デプロイフロー

SPAなどAjaxを多用するアプリケーションのデプロイを稼働中に行う場合は注意が必要です。クライアントのリソースが更新されないため、API側の仕様変更があると不整合が、起きたり静的ファイルのバージョンにズレが生じてあるファイルだけ古い、といった事態を引き起こす可能性があります。これを防ぐためには、例えば

  1. デプロイはバージョンごとに/v1,/v2といったディレクトリを切る
  2. APIのレスポンスにクライアントのversionを追加
  3. クライアント側では通信毎にversionをチェック
  4. クライアント側で保持しているversionとdiffがあれば適当なタイミング(ルーティングの切り替え時だと自然)でリロードをかける

といった具合に、アプリケーションに合わせたリソース更新の仕組みを用意しておく必要があります。

まとめ

にうまく乗り切るためには、構成要素のモジュラビリティを高めておいた方が良さそう、という話でした。

最新のrspec-railsでコントローラのヘルパーメソッドをスタブする

パーフェクトRuby on Rails読んでてハマったところ。
RSpecでビューのテスト書くとき、こんな風にコントローラのヘルパーメソッドをスタブしているのだけど、

context '未ログインユーザがアクセスしたとき' do
  before do
    allow(view).to receive(:logged_in?) { false }
    allow(view).to receive(:current_user) { nil }
  end
  # 中略
end

最新バージョンのrspec-railsだと

Failure/Error: allow(view).to receive(:logged_in?) { false }
#<#<Class:0x007fa8613dc140> ....> does not implement: logged_in?

怒られるもよう。(rspec-rails v3.1.0で確認)

issueはいくつか上がってて、原因としては存在しないメソッドはスタブできないようになった為のようだ。

context '未ログインユーザがアクセスしたとき' do
  before do
    def view.logged_in?
    end

    def view.current_user
    end

    allow(view).to receive(:logged_in?) { false }
    allow(view).to receive(:current_user) { nil }
  end
  # 中略
end

もにょりつつ一旦こんな感じで凌いだ。

Promiseで逐次実行する時のテスト

var Promise = require('es6-promise').Promise;

var tasks = {
    first: function(value) {
        return new Promise(function(resolve, reject) {
            if (value < 2) {
                resolve(value + 1);
            } else {
                reject(new Error('failed at first'));
            }
        });
    },
    second: function(value) {
        return new Promise(function(resolve, reject) {
            if (value % 2) {
                resolve(value + 1);
            } else {
                reject(new Error('failed at second'));
            }
        });
    },
    third: function(value) {
        return new Promise(function(resolve, reject) {
            resolve(value + 1);
        });
    }
};

var waterfall = function(value) {
    return Promise.resolve(value)
        .then(tasks.first)
        .then(tasks.second)
        .then(tasks.third);
};

例えばこの関数waterfallが

  • 最後まで実行される
  • firstでrejectされる
  • secondでrejectされる

場合のテストケースをそれぞれ用意したい。
いろいろ試した結果、こんな感じになった。

var assert = require("power-assert");
var sinon = require('sinon');

describe('waterfall', function() {
    beforeEach(function() {
        sinon.spy(tasks, 'second');
        sinon.spy(tasks, 'third');
    });

    afterEach(function() {
        tasks.second.restore();
        tasks.third.restore();
    });

    it('passed', function(done) {
        return waterfall(0).then(function(value) {
            assert(value === 3);
        }).then(done, done);
    });

    it('failed at first', function(done) {
        return waterfall(2).then(function(value) {
            throw new Error(value);
        }, function(error) {
            assert(error.message === 'failed at first');
            assert(tasks.second.notCalled);
        }).then(done, done);
    });

    it('failed at second', function(done) {
        return waterfall(1).then(function(value) {
            throw new Error(value);
        }, function(error) {
            assert(error.message === 'failed at second');
            assert(tasks.third.notCalled);
        }).then(done, done);
    });
});

最初はspy.calledBeforeとかで実行順序全部チェックしてたけど、それはPromise自体のテストになってしまうので

  • 適切なエラーが投げられてる
  • reject後の関数が呼ばれてない

ことがテストできてれば良いかなと。 前提条件として投げるエラーはそれぞれ個別のmessageじゃないとrejectされた箇所を探すのは難しそう。
chaiならhttps://github.com/domenic/chai-as-promisedとか使うとスマートに書けるかも。

Ampersand.js所感

ちょっと前に紹介されてたAmpersand.jsを使ってみたので、ざっくりとした紹介。
Ampersand.jsはView, Model, Routerなど複数のモジュールに分割されていて、個別に読み込んで使うことができる。

$ npm i --save ampersand-model

var AmpModel = require('ampersand-model');

多くのモジュールはBackbone.jsのコードをベースにしているので、名前が同じものは概ね同じ感覚で使うことができる。
以下、全部ではないけど触ったモジュールのBackboneとの違いをざっくり紹介。

ampersand-state

後述するampersand-modelのベースになるモジュール。違いとしてはAjax周りの機能がない。

get/setメソッドを介さずにプロパティへのアクセスができる
var AmpersandState = require('ampersand-state');
var state = new AmpState();

state.foo = 1;
state.foo // => 1
プロパティの型やEnum, nullの許可など詳細な設定
var AppState = AmpersandState.extend({
    extraProperties: 'ignore',
    props: {
        foo: {
            type: 'number',
            default: 0,
            values: [0, 1, 2]
        },
        bar: {
            type: 'string',
            default: 'ampersand',
            required: true,
            allowNull: false
        }
    }
});
var state = new AppState();

state.foo = '1'; // => TypeError: Property 'foo' must be of type number. Tried to set 1 
state.foo = 3; // => TypeError: Property 'foo' must be one of values: 0, 1, 2. Tried to set 3
state.bar = null // => TypeError: Property 'bar' must be of type string (cannot be null). Tried to set null
sessions, derived

sessionsは一時的な値を保持するための、derivedは関数を設定できるプロパティ。どちらもtoJSONで列挙されない。

var AppState = require('ampersand-state').extend({
    sessions: {
        active: {
            type: 'boolean',
            default: true
        }
    },
    derived: {
        foobar: function() {
            return this.foo + this.bar;
        }
    }
});
var state = new AppState();

state.set({foo: 1, bar: 2});
state.foobar; // => 3
state.active = true;
state.toJSON(); // => {foo: 1, bar: 2}

ampersand-model

ampersand-stateにモジュールampersand-syncを追加し、fetchsaveを使えるようにしたもの。ajaxConfigでheaderやbeforeSendの詳細な設定ができたりする。

ampersand-collection

Backbone.Collectionに相当するが、ampersand-syncおよびUnderscore.jsを使用したメソッドは含まれない。(forEachやmapなど、ES5のメソッドは使える)

ampersand-rest-collection

ampersand-collectionsyncとUnderscore.js関連のメソッドの他、指定したidのモデルがあればそれを返し、なければfetchするfetchOrGetなどが追加されている。

ampersand-view

Backbone.Viewの機能に加えて、2-way-bindings,collectionView,subViewなど、backbone.stickitMarionette.jsなどでもおなじみの機能を含む。

ampersand-router

大部分はBackbone.Routerのコードが元になっているが、historyを残さずに遷移するredirectToなどが追加されている。

CLIツール

コマンドラインから使えるジェネレータもある。

$ ampersand gen model foo
new Model created as client/models/foo.js
new Collection for foo created as client/models/foo-collection.js

ドキュメントは普通に充実しているので、詳細はそちらを見てもらった方が早いです。
http://ampersandjs.com/docs
モジュールは上記以外にも色々ある。
tools.ampersandjs.com

まとめ

感想としてはモジュール化 + 人気のプラグインを取り込んで痒いところに手が届くようになったBackbone.js。モジュールが分散していることもあってgithubのstarはまだ少ないのだけれど、ちゃんとOrganizationがあって活発にメンテされているのも良い。大きなフレームワークを使っていてもプロダクトの要件にバッチリハマるみたいなことはそうそう無くて、結局は複数を組み合わせるとか、ラッパーを書くとかする必要が出てくるので、そうした面でも最初からモジュール化されているのは理にかなっていると思う。

自分は今のところReact.js + Model,CollectionをFluxアーキテクチャで言うところのStoreとして使っていて、他にもVue.jsと組み合わせるとか、純粋にBackbone.jsの上位互換のMVCフレームワークとしてとか、色々使い道はありそうなので試していきたい。

SPA(Single Page Application)制作時のチェックリスト

  • メモリリークは起きていないか
  • 初期化時に無駄な通信はないか
  • ページ移動時に保持するデータと破棄するデータの分別ができているか
  • 読まれたくないロジックを置いてないか
  • 直接叩かれて困るAPIやルーティングはないか
  • バリデーションの項目はサーバー側と揃っているか
  • 最低でもモデルのテストは書いているか
  • Ajaxリクエストが失敗した時のリカバリー処理はあるか
  • リリース時にユーザー側のリソースを更新させる仕組みはあるか
  • SPAにする必要性はあるか

semverとnpm

semverとは?

semver(Semantic Versioning)は依存関係のもつれを解決するべく制定されたバージョニングの標準仕様です。
Semantic Versioning 2.0.0
npmに登録されているモジュールのバージョンもsemverに沿った形式で設定されています。
The semantic versioner for npm

概要

バージョンをmajor.minor.patch(-prerelease, +build)の形式で記述し、

それぞれインクリメントします。

node-semver

バージョンがsemverに沿ったものであるかは、npm内部でも使用されているnode-semverで確認が可能です。

$ npm install -g semver
$ semver 1.01.0
# none
$ semver 1.1.0
1.1.0
$ semver -i patch 1.2.3
1.2.4

依存パッケージのバージョン指定

"dependencies": {
  "underscore": "^1.4.4"
}

npm install --save underscoreと打ったあとのpackage.jsonですが、この場合は1.4.4-0以上、2.0.0-0の間で最新のバージョンがインストールされます。minor, patchレベルのアップデートの範囲内なので、依存パッケージがsemverに従っていればエラーは起こらないことになります。

そんな事はなかった

残念ながら、意図的にしろそうでないにしろminorpatchレベルのアップデートで後方互換性が破壊されるケースをしばし見かけます。少し前ですが、Backbone.jsのようなメジャーなライブラリでも後方互換性のないminorアップデートが行われ、semverに準拠していないとして批判の声が上がっていました。
Follow SemVer #2888

そもそもの話として、majorバージョンが大きくなりすぎる事への懸念などから、厳格にsemverに従うかについては他のモジュールやnpm本体の中でも色々と議論があるようです。

まとめ

ビルドがこけたと思ったら後方互換のないpatchレベルのアップデートが原因だった時の憤りをバネに書きました!

個人的にはmajorバージョン大きくなってもいいからちゃんとsemverに沿っていて欲しいと思いますが、上記の議論があったり、完璧にやるのも難しい話なので、まだしばらくこうした問題は付いて回りそうですね。

フロントエンドのビルドツール、Grunt以外の選択肢

フロントエンドのビルドツールというとGruntが
デファクトスタンダードになっている感ありますが、
それ以外の選択肢って何があるかなという話です。

Gulp

Gulpはストリーミング式のビルドツールです。
設定はgulpfileに記述します。

gulp = require 'gulp'
coffee = require 'gulp-coffee'
concat = require 'gulp-concat'
uglify = require 'gulp-uglify'

gulp.task 'compile', ->
  gulp.src('./src/*.coffee')
    .pipe(coffee())
    .pipe(concat('all.js'))
    .pipe(uglify())
    .pipe(gulp.dest('./dist/'))

記述方法はGruntと違い、このようにgulp.src()が返すstreamオブジェクトから
処理をメソッドチェインで繋げていきます。
上の例だと

# ファイル読み込んで
gulp.src('./src/*.coffee')
  # coffeeからコンパイルして
  .pipe(coffee())
  # concatして
  .pipe(concat('all.js'))
  # 圧縮して
  .pipe(uglify())
  # ./dist/以下に出力
  .pipe(gulp.dest('./dist/'))

といった感じの流れになります。
pipeで生成物をそのまま次の処理に渡して行くため、
このようなケースではGruntよりシンプルにタスクを定義する事ができます。
またwatchタスクは標準機能として備えているためその為のプラグイン等は不要です。

gulp.task 'watch', ->
  gulp.watch './src/*.coffee', (event) ->
    console.log "#{event.path}: #{event.type}"
    gulp.run 'compile'

複数のタスクを実行するタスクはGruntと同じような書き方で定義できますが、

gulp.task 'default', ['coffee', 'concat', 'uglify']

Gruntと違いこの順番に同期的に実行される訳ではなく、
順序が保証されてないため注意が必要です。
そうしたい場合はcallbackやdeferred等で
ごにょごにょやらないといけないのが少し面倒ですね。

プラグインは現在約180ほどの登録があり、
各種コンパイラやlint、テストランナーは一通り揃っているようです。

Brunch

Brunchは

Brunch is an ultra-fast HTML5 build tool

とされていますが、ビルド以外にも
scaffoldやローカルサーバの立ち上げといった機能があり
Grunt + yoと言った方が近いかもしれません。

# 指定したBrunch skeltonからプロジェクトの雛形を作成
$ brunch new skelton-url
# wach + ローカルサーバの立ち上げ
$ brunch w -s
# ビルド
$ brunch build

多くのskeltonは一緒にテスト環境も用意してくれるので、
例えばBrunch with Marionetteならlocalhost:3333/testにアクセスすれば
そのままブラウザ上でmochaによるテストを実行する事ができます。

Gulp同様、プラグインやボイラープレートとなるskeltonは一通り揃っています。
skeltonはやはりChaplinやAngularなどMVCフレームワークのものが人気なようです。

まとめ

プラグインの充実度やコミュニティの活発さでは
Gruntがまだまだ強いと思いますが、
SPAなんかでフロントとサーバが疎結合になっていたりする場合は
ローカルサーバの立ち上げからビルドまでやってくれるBrunch、
小規模なプロジェクトで設定ファイルをシンプルに書きたいなら
Gulpと、それぞれビルドツールの選択肢に加えてみるのも良いんじゃないでしょうか。