FlowtypeでImmutable.Recordの型定義がつらい
先日 Gotanda.js という勉強会で Immutable.js と Flowtype についてLTさせてもらったのだけど、その補足というか詳細的な話でもあります。
Immutable.Record + Flowtype
Immutable.jsのRecordのよく紹介される使い方として、継承してイミュータブルなモデルクラスを作るというものがある。
import { Record } from 'immutable'; const FooRecord = Record({ a: '1', b: 2, c: true }); class Foo extends FooRecord { getSquaredB() { return this.b * this.b; } } const foo = new Foo({ a: '2', b: 3, c : false }); foo.a // "2" foo.set('a', '3').a // "3" foo.getSquaredB() // 9 foo.remove('a').a // "1"
ここにFlowtypeの型アノテーションを追加したい。Immutable@4.0.0-rc.2
, flow-bin@0.48.0
, 型定義ファイルはImmutable.jsにバンドルされているものを使ってやっていく。
// @flow import { Record } from 'immutable'; const FooRecord = Record({ a: '1', b: 2, c: true }); class Foo extends FooRecord { a: string; b: number; c: boolean; getSquaredB() { return this.b * this.b; } } const foo = new Foo({ a: '2', b: 3, c: false }); // エラー出てほしい const a: number = foo.a; const b: string = foo.getSquaredB(); const b2: string = foo.set('b', 4).getSquaredB();
しかし、これはうまくいかない。
$ flow src/im-flow.js:11 11: class Foo extends FooRecord { ^^^^^^^^^ function call. Expected polymorphic type instead of 11: class Foo extends FooRecord { ^^^^^^^^^ RecordClass Found 1 error error Command failed with exit code 2.
immtuable.js.flow
のRecordの定義部分を見てみる。
declare class Record { static <Values>(spec: Values, name?: string): RecordClass<Values>; constructor<Values>(spec: Values, name?: string): RecordClass<Values>; static isRecord: typeof isRecord; static getDescriptiveName(record: RecordInstance<*>): string; } declare interface RecordClass<T> { (values: $Shape<T> | Iterable<[string, any]>): RecordInstance<T> & T; new (values: $Shape<T> | Iterable<[string, any]>): RecordInstance<T> & T; } declare class RecordInstance<T: Object> { ... }
Record
のコンストラクタはRecordClass<T>
のinterface型を、RecordClass<T>
のコンストラクタはRecordInstance<T>
を返す- そのため
FooRecord
やそれを継承したFoo
はRecordClass<T>
のinterface型として解釈される Foo
はRecordInstance<T>
を継承したものとして解釈されてほしい- が、
RecordInstance<T>
はexportされておらず外部から利用できない
ということっぽい。あまりやりたくはないが、RecordInstance<T>
の定義だけ別ファイルとして .flowconfig
のlibsに追加した上で、 FooRecord
継承前に Class<RecordInstance<FooRecordValues>>
にキャストしてみる。(直接はキャストできないので一旦anyにキャストしている)
type FooRecordValues = { a: string; b: number; c: boolean; } class Foo extends ((FooRecord: any): Class<RecordInstance<FooRecordValues>>) { ...
結果
$ flow src/im-flow.js:27 v 27: const foo = new Foo({ 28: a: '2', 29: b: 3, 30: c: false 31: }); ^ unused function argument v---------------------------------------- 3: declare class RecordInstance<T: Object> { 4: size: number; 5: ...: 50: } ^ default constructor expects no arguments. See lib: src/record.js.flow:3 src/im-flow.js:34 34: const a: number = foo.a; ^^^^^ string. This type is incompatible with 34: const a: number = foo.a; ^^^^^^ number src/im-flow.js:35 35: const b: string = foo.getSquaredB(); ^^^^^^^^^^^^^^^^^ number. This type is incompatible with 35: const b: string = foo.getSquaredB(); ^^^^^^ string src/im-flow.js:36 36: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^^^^^^ property `getSquaredB`. Property not found in 36: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^^^^^^^^^^ RecordInstance Found 4 errors error Command failed with exit code 2.
いくつかは期待通りのエラーが得られたが、まだおかしい。
RecordInstance<T>
は constructor の引数が定義されていないset
やupdate
など新しいインスタンスを返すメソッドにおいて、返り値の型は継承前のRecordInstance<FooRecordValues>
となってしまう
ということで RecordInstance<FooRecordValues>
を継承したクラス定義を別途用意して、それにキャストするようにした。
// @flow import { Record } from 'immutable'; type FooRecordValues = { a: string; b: number; c: boolean; } declare class FooRecordInstance extends RecordInstance<FooRecordValues> { a: string; b: number; c: boolean; constructor($Shape<FooRecordValues>): this | void; getSquaredB(): number; } const FooRecord = Record({ a: '1', b: 2, c: true }); class Foo extends ((FooRecord: any): Class<FooRecordInstance>) { getSquaredB() { return this.b * this.b; } } const foo = new Foo({ a: '2', b: 3, c: false }); // エラー出てほしい const a: number = foo.a; const b: string = foo.getSquaredB(); const b2: string = foo.set('b', 4).getSquaredB();
かなりつらい書き方になってしまったが、一応は想定通りのエラーを得ることができた。
$ flow src/im-flow.js:39 39: const a: number = foo.a; ^^^^^ string. This type is incompatible with 39: const a: number = foo.a; ^^^^^^ number src/im-flow.js:40 40: const b: string = foo.getSquaredB(); ^^^^^^^^^^^^^^^^^ number. This type is incompatible with 40: const b: string = foo.getSquaredB(); ^^^^^^ string src/im-flow.js:41 41: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ number. This type is incompatible with 41: const b2: string = foo.set('b', 4).getSquaredB(); ^^^^^^ string Found 3 errors error Command failed with exit code 2.
これでなんとか型定義できたように見えたが、大きな落とし穴があった。
// エラー出てほしい const c: string = foo.get('c'); foo.set('c', 1);
$ flow
No errors!
✨ Done in 0.21s.
こうなる理由は型定義がこのようになっているため。
get<K: $Keys<T>>(key: K): /*T[K]*/any; set<K: $Keys<T>>(key: K, value: /*T[K]*/any): this;
getの返り値やsetのvalueが any
と定義されているため。コメントアウト部分はお気持ちの表明で現状このような書き方はできない。
$PropertyType
というUtilityはあるがstring literalしか引数に取れないとのこと。
getterはプロパティアクセスを使うとしてもsetterはどうしようもなく、クソめんどくさい型定義を書いても型安全でないという厳しい結果になってしまった。
$ElementType について
PropertyTypeに関するIssueによると、 $ElementType
なるUtilityを使うことで string literal じゃなくても value の型が取れる模様。Release note やドキュメントに記載はないが diff を見ると v0.49.0 から入っているようなので、 Flowtype のバージョンを上げて型定義を書き換えてみた。
get<K: $Keys<T>>(key: K): $ElementType<T, K>;
set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this;
これで改めてこの例を試す。
// エラー出てほしい const c: string = foo.get('c'); foo.set('c', 1);
今度は想定通りのエラーを出すことができた。
$ flow src/im-flow.js:44 44: const c: string = foo.get('c'); ^^^^^^^^^^^^ boolean. This type is incompatible with 44: const c: string = foo.get('c'); ^^^^^^ string src/im-flow.js:45 45: foo.set('c', 1) ^ number. This type is incompatible with the expected param type of 12: set<K: $Keys<T>>(key: K, value: $ElementType<T, K>): this; ^^^^^^^^^^^^^^^^^^ boolean. See lib: src/record.js.flow:12 Found 2 errors error Command failed with exit code 2.
ということで、getter/setter の問題に関しては $ElementType を使うことで解決できそうではある。
フロントエンド開発の初期に考えておきたいこと
Frontrend Advent Calendar 2014 - Qiita 18日目の記事です。
フレームワークや使用技術など
は方々で語られているので、それ以外の話をします!
- JavaScript Advent Calendar 2014
- VirtualDOM Advent Calendar 2014
- Vue.js Advent Calendar 2014
- Backbone.js Advent Calendar 2014
- 一人React.js Advent Calendar 2014
- AngularJS Advent Calendar 2014
- KnockoutJS Advent Calendar 2014
個人的には最近Ampersand.jsが気に入ってます。
リポジトリ構成
フロントエンドのリソースはサーバー側のフレームワークのassets
やstatic
といったディレクトリで管理される事が多いですが、フロントエンドが肥大化した今、リソースが混在することで
- コミットやPRがどちらのものなのかわかりにくい
- 問題が起きた時に切り分けがしにくい
といった難点があります。もしもサーバー側はAPIのみを提供してフロント側はシングルページのように疎結合な構成になっている場合、ネイティブアプリがそうであるようにリポジトリを分けて完全に別管理にしてしまうというのも一つの手です。その場合ローカル開発はGrunt.jsかgulp.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などのパラダイムを取り入れ、初期設計をしっかり行っておく事が重要になります。 気をつけたいのはこれらはあくまで設計のルールであってフレームワークではないので、チーム開発で運用していくにはスタイルガイドなどを用意しておくと良いでしょう。
Web制作者のためのCSS設計の教科書 モダンWeb開発に欠かせない「修正しやすいCSS」の設計手法
- 作者: 谷拓樹
- 出版社/メーカー: インプレス
- 発売日: 2014/07/24
- メディア: Kindle版
- この商品を含むブログを見る
今見たらKindle版がセールで60%OFFの¥950になってました。
デプロイフロー
SPAなどAjaxを多用するアプリケーションのデプロイを稼働中に行う場合は注意が必要です。クライアントのリソースが更新されないため、API側の仕様変更があると不整合が、起きたり静的ファイルのバージョンにズレが生じてあるファイルだけ古い、といった事態を引き起こす可能性があります。これを防ぐためには、例えば
- デプロイはバージョンごとに
/v1
,/v2
といったディレクトリを切る - APIのレスポンスにクライアントのversionを追加
- クライアント側では通信毎にversionをチェック
- クライアント側で保持している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
Failure/Error: allow(view).to receive(:logged_in?) { false } #<#<Class:0x007fa8613dc140> ....> does not implement: logged_in?
怒られるもよう。(rspec-rails v3.1.0で確認)
issueはいくつか上がってて、原因としては存在しないメソッドはスタブできないようになった為のようだ。
- Stub controller helper method on rspec rails 3 · Issue #1076 · rspec/rspec-rails
- Helper for injecting locals to view specs · Issue #1219 · rspec/rspec-rails
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が
場合のテストケースをそれぞれ用意したい。
いろいろ試した結果、こんな感じになった。
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
を追加し、fetch
やsave
を使えるようにしたもの。ajaxConfig
でheaderやbeforeSendの詳細な設定ができたりする。
ampersand-collection
Backbone.Collection
に相当するが、ampersand-sync
およびUnderscore.jsを使用したメソッドは含まれない。(forEachやmapなど、ES5のメソッドは使える)
ampersand-rest-collection
ampersand-collection
にsync
とUnderscore.js関連のメソッドの他、指定したidのモデルがあればそれを返し、なければfetchするfetchOrGet
などが追加されている。
ampersand-view
Backbone.Viewの機能に加えて、2-way-bindings
,collectionView
,subView
など、backbone.stickitやMarionette.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フレームワークとしてとか、色々使い道はありそうなので試していきたい。
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に従っていればエラーは起こらないことになります。
そんな事はなかった
残念ながら、意図的にしろそうでないにしろminor
やpatch
レベルのアップデートで後方互換性が破壊されるケースをしばし見かけます。少し前ですが、Backbone.jsのようなメジャーなライブラリでも後方互換性のないminor
アップデートが行われ、semverに準拠していないとして批判の声が上がっていました。
Follow SemVer #2888
そもそもの話として、major
バージョンが大きくなりすぎる事への懸念などから、厳格にsemverに従うかについては他のモジュールやnpm本体の中でも色々と議論があるようです。
まとめ
ビルドがこけたと思ったら後方互換のないpatch
レベルのアップデートが原因だった時の憤りをバネに書きました!
個人的にはmajor
バージョン大きくなってもいいからちゃんとsemverに沿っていて欲しいと思いますが、上記の議論があったり、完璧にやるのも難しい話なので、まだしばらくこうした問題は付いて回りそうですね。