LoginSignup
54
40

More than 3 years have passed since last update.

Effect{ive,ful} Cycle.js

Last updated at Posted at 2019-05-26
1 / 57

おことわり

  • この発表はかなりお気持ちです
  • 他の発表とは異なり(?)なんら学術的裏付けがあるものではありません
  • なんと(?)まったく数式が出てきません

自己紹介


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 (middleware)

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


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

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 フロント以外にも使えて汎用的
  • もし興味があれば書いてみてください!
    • その他気になった方はよければ懇親会で話しかけてください

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

54
40
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
54
40