Edited at

Effect{ive,ful} Cycle.js


おことわり


  • この発表はかなりお気持ちです

  • 他の発表とは異なり(?)なんら学術的裏付けがあるものではありません

  • なんと(?)まったく数式が出てきません



自己紹介



Web フロントエンドアプリケーションの話をします



はじめに



はじめに


この発表の内容


  • Web フロントエンドフレームワークはどのような性質を満たすべきか?


    • 異なる副作用を分離したい

    • 非同期がほしい



  • Cycle.js というフレームワークの紹介


    • effect を足していく

    • effect を handle する




この発表のゴール


  • Cycle.js の存在を覚えて帰ってもらう

  • 「Cycle.js おもしろそう,書いてみようかな」と思ってもらう



Web フロントのフレームワークはどのような性質を満たしていてほしいか?



Web フロントのフレームワークの望ましい性質


  • 種々の副作用を互いに分離できると嬉しい

  • 非同期が織り込み済みだと嬉しい



Web フロントのフレームワークの望ましい性質


  • 種々の副作用を互いに分離できると嬉しい

  • 非同期が織り込み済みだと嬉しい



種々の副作用を互いに分離できると嬉しい

アプリケーションは様々な副作用を持つ


  • DOM


    • ユーザの入力イベントを受け取る

    • 画面を描画する



  • HTTP


    • APIを叩いて結果を受け取る



  • ステート管理

  • WebSocket

  • Location API

  • History API

  • Web Storage API

  • タイトル変えたり……

  • favicon 変えたり……

  • visibility API とか……

メッチャあるやんけ😰



種々の副作用を互いに分離できると嬉しい (cont'd)


  • 異なる副作用は別々に処理したい


    • あらゆる副作用をゴッチャにしたくない

    • 関心の分離をしたい

    • テスタビリティ



  • 後から副作用を追加できるようにしておきたい


    • 拡張性





種々の副作用を互いに分離できると嬉しい (cont'd)

React + Redux (+ Redux middleware)

DOM + ステート管理 (+ その他 IO すべて) に見える

(少なくとも私には)



Web フロントのフレームワークの望ましい性質 (cont'd)


  • 種々の副作用を互いに分離できると嬉しい

  • 非同期が織り込み済みだと嬉しい



非同期が織り込み済みだと嬉しい


  • どうあがいても絶対必要なので


    • API のレスポンスはいつ返ってくるかわからない

    • ユーザの入力に対する高価な処理 (e.g. 検索 API を叩く) の debouncing



  • 「どこに何を書けばいいかわからない」は利用者がつらい


    • お前のことやぞ Redux





望ましい性質を満たすには?



望ましい性質を満たすには?

Web フロントのフレームワークの望ましい性質 (再掲)


  • 種々の副作用を互いに分離できると嬉しい


    • → ???



  • 非同期が織り込み済みだと嬉しい


    • → ???





望ましい性質を満たすには?

Web フロントのフレームワークの望ましい性質 (再掲)


  • 種々の副作用を互いに分離できると嬉しい


    • → それぞれの副作用を effect 的に扱う



  • 非同期が織り込み済みだと嬉しい


    • → Observable / Stream





望ましい性質を満たすには?

Web フロントのフレームワークの望ましい性質 (再掲)


  • 種々の副作用を互いに分離できると嬉しい


    • → それぞれの副作用を effect 的に扱う



  • 非同期が織り込み済みだと嬉しい


    • → Observable / Stream





それぞれの副作用を effect 的に扱う


  • 副作用の処理を client-server モデルとして捉える

  • client (アプリケーション) は server (effect handler) に副作用 (effect) の処理を依頼し,結果を受け取る



それぞれの副作用を effect 的に扱う (cont'd)


  • 異なる handler は異なる effect をハンドルする


    • 関心事の分離



  • アプリケーションが新たな effect を要求するようになれば handler も増やせばよい


    • 拡張性の確保





望ましい性質を満たすには? (cont'd)


  • 種々の副作用を互いに分離できると嬉しい


    • → それぞれの副作用を effect 的に扱う



  • 非同期が織り込み済みだと嬉しい


    • → Observable / Stream





Observable / Stream



Stream (cont'd)

例 : ダブルクリックの検出



出典 : The introduction to Reactive Programming you've been missing



望ましい性質を満たすには? (cont'd)


  • 種々の副作用を互いに分離できると嬉しい


    • → それぞれの副作用を effect 的に扱う



  • 非同期が織り込み済みだと嬉しい


    • → Observable / Stream



effect handler と Stream で非同期にやりとりすればいいのでは?



ところで Cycle.js というフレームワークがありまして……



Cycle.js


  • "A functional and reactive JavaScript framework for predictable code"


  • GitHub では約 9,300 stars


    • 「名前だけは知ってる」という方も多いかも?





Cycle.js (cont'd)

Cycle’s core abstraction is your application as a pure function main() where inputs are read effects (sources) from the external world and outputs (sinks) are write effects to affect the external world. These I/O effects in the external world are managed by drivers: plugins that handle DOM effects, HTTP effects, etc.

(cycle.js.org より引用)



これやんけ



Screenshot from Gyazo



用語



  • source


    • 外界からのメッセージが流れてくる stream


    • componentdriver から受け取る




  • sink


    • 外界に干渉するためのメッセージを流す stream

    • componentdriver に渡す




  • driver



    • sink を受け取って (典型的には) 副作用を起こし,source を返す関数

    • e.g. DOM driver, HTTP driver

    • effect を handle できる




  • component



    • sources を受け取って sinks を返す関数

    • React や Vue の component とは意味が異なるので注意




  • higher-order component



    • component を取って component を返す関数

    • effect を handle できる





effect 的な Cycle.js の世界観


  • component は effectful な計算である

  • component が持つ effect は driver や higher-order component によって handle される

  • すべての effect を handle することができればアプリケーションが実行できる



component を書く



effect を足していきます


  • 1. DOM effect

  • 2. state effect

  • 3. WebSocket effect

  • 4. toast effect



effect を足していきます



  • 1. DOM effect 🔥

  • 2. state effect

  • 3. WebSocket effect

  • 4. toast effect



1. DOM effect


types.ts

type SoDOM = { DOM: DOMSource }; // DOM driver から受け取る

type SiDOM = { DOM: Stream<JSX.Element> }; // DOM driver に渡す


main.tsx

function main({ DOM }: SoDOM): SiDOM {

const submitEvent$: Stream<Event> = DOM.select('.submit').events('click');
const bodyInputEvent$: Stream<Event> = DOM.select('.body').events('input');

const body$: MemoryStream<string> = Stream.merge(
bodyInputEvent$.map((e: any) => e.target.value),
submitEvent$.mapTo(''), // 送信されたらクリア
).startWith('');

const view$ = body$.map(body =>
<div>
<input type="text" className="body" value={body} />
<button type="button" className="submit">
送信
</button>
<ul>
{messages.map(msg => (
<li key={msg}>{msg}</li>
))}
</ul>
</div>
);

return {
DOM: view$,
};
}




1. DOM effect (cont'd)

想像図

Screenshot from Gyazo

なぜ「想像」なのか?

DOM effect がどのように handle されるかで挙動が変わりうるから



effect を足していきます (cont'd)


  • 1. DOM effect


  • 2. state effect 🔥

  • 3. WebSocket effect

  • 4. toast effect



2. state effect


State.ts

type State = {

body: string;
messages: string[];
}

function State(): State {
return {
body: '',
messages: [],
};
}



types.ts

export type Endo<T> = (x: T) => T;

export type SoState = { state: StateSource<State> };
export type SiState = { state: Stream<Endo<State>> };



main.ts

export function main({ DOM, state }: SoDOM & SoState): SiDOM & SiState<State> {

const state$ = state.stream; // 現在の state が得られる

// ...

const view$ = state$.map(View); // view は切り出しました

const reducer$ = Stream.merge<Endo<State>>(
bodyInputEvent$.map((e: any) => state => ({ ...state, body: e.target.value })),
submitEvent$.mapTo(state => ({ ...state, body: '' })),
).startWith(_ => State());

return {
DOM: view$,
state: reducer$, // Endo<State> を流す
};
}




2. state effect (cont'd)

想像図 (さっきと一緒)

Screenshot from Gyazo



effect を足していきます (cont'd)


  • 1. DOM effect

  • 2. state effect


  • 3. WebSocket effect 🔥

  • 4. toast effect



3. WebSocket effect


types.ts

type SoWebSocket = { WebSocket: Stream<IncomingMessage> };

type SiWebSocket = { WebSocket: Stream<OutgoingMessage> };


main.ts

function main({

// ...
WebSocket,
}: SoDOM & SoState & SoWebSocket): SiDOM & SiState & SiWebSocket {
// ...

const outgoingMessage$ = submitEvent$
.compose(sampleCombine(state$)) // submit 時の state を切り取る
.map<OutgoingMessage>(([_, { body }]) => ({ type: 'message', body }));

const reducer$: Stream<Endo<State>> = Stream.merge<Endo<State>>(
// ...
WebSocket.filter(IncomingMessage.isMessage).map(({ message }) => state => ({
...state,
messages: [message, ...state.messages], // 受け取ったメッセージを追加する
})),
).startWith(_ => State());

return {
// ...
WebSocket: outgoingMessage$, // client -> server
};
}




3. WebSocket effect (cont'd)

想像図

Screenshot from Gyazo



effect を足していきます (cont'd)


  • 1. DOM effect

  • 2. state effect

  • 3. WebSocket effect


  • 4. toast effect 🔥



4. toast effect


types.ts

type SoToast = {};

type SiToast = { toast: Stream<string | null> };


main.tsx

function main({

// ...
}: SoDOM & SoState & SoToast & SoWebSocket): SiDOM & SiState & SiToast & SiWebSocket {
// ...

const toast$ = WebSocket
.filter(IncomingMessage.isRoomJoin)
.map(({ name }) => `${name} さんが入室しました`);

return {
// ...
toast: toast$,
};
}




4. toast effect (cont'd)

想像図

Screenshot from Gyazo



effect のハンドリング



effect のハンドリング


  • component の effect をすべて取り除くことで,アプリケーションを実行することができる

  • 取り除いていくぞ



toast effect

DOM effect に押し付ける


handleToast.tsx

function handleToast<So extends SoDOM, Si extends Partial<SiDOM & SiToast>>(

component: Component<So, Si>,
): Component<So, Omit<Si, 'toast'>> {
return function(sources) {
const sinks = component(sources);
const SiDOM: Stream<JSX.Element> = sinks.DOM || Stream.empty();
const SiToast: Stream<string | null> = sinks.toast || Stream.empty();

const view$ = Stream.combine(SiDOM, SiToast.startWith(null))
.map(いい感じにガッチャンコする);

return {
...sinks,
DOM: view$,
};
};
}




state effect

自己解決 (中で循環参照してる)

function withState<

So extends { state: StateSource<State> },
Si extends { state: Stream<Endo<State>> }
>(
main: Component<So, Si>
): Component<Omit<So, 'state'>, Omit<Si, 'state'>>;



DOM effect & WebSocket effect

残りは driver で

const drivers = {

DOM: makeDOMDriver('#app'),
WebSocket: makeWebSocketDriver(),
};

run(handleToast(withState(main)), drivers);



テスト



テスト


  • component は sources → sinks なただの関数

const sinks = component({

DOM: mockDOMSource(...), // compatible な sources を与える
// ...
});

// ここで sinks.DOM や sinks.WebSocket に対して assertion を書くだけ



テスト (cont'd)

it('emits 42', done => {

// ...

foo$.take(1).addListener({
next(x) {
expect(x).toBe(42);
},
complete() {
done();
},
});
});



テスト (cont'd)

好きな組み合わせをテストすればよい


  • 前提条件


    • ユーザがボタンをクリックしたときに……

    • API のレスポンスが得られたときに……

    • WebSocket でメッセージを受け取ったときに……



  • アサーション


    • API リクエストが飛ぶか?

    • state は更新されるか?

    • WebSocket でメッセージが送られるか?

    • toast は表示されるか?





終わりに



終わりに


  • Cycle.js はこんなフレームワーク


    • 副作用を effect 的に扱える

    • effect handler (driver) とは Stream でやり取りする

    • higher-order component でも effect を handle できる

    • Web フロント以外にも使えて汎用的



  • もし興味があれば書いてみてください!


    • その他気になった方はよければ懇親会で話しかけてください





ご清聴ありがとうございました!