Krasimir Tsonev

著者について

Krasimir Tsonev氏は、ウェブ開発で10年以上の経験を持つコーダーです。 Node.jsに関する2冊の本の著者でもあります。 …More aboutKrasimir↬

  • 20分で読める本
  • UI,App,JavaScript
  • オフラインで読めるように保存されている本
  • Twitterでシェア。 LinkedIn
ここ2、3年でUIの開発が難しくなりました。 それは、状態管理をブラウザに押し付けたからです。 そして、状態の管理こそが、私たちの仕事を困難にしています。 適切に行えば、アプリケーションがバグなく簡単にスケーリングできることがわかるでしょう。

もう2018年になりましたが、数え切れないほどのフロントエンド開発者が、いまだに複雑さや不動さとの戦いを率いています。 毎月毎月、彼らは聖杯を探してきました。バグのないアプリケーションアーキテクチャは、迅速かつ高品質な納品を可能にしてくれます。

私たちは、ReactやReduxなどのツールで良い一歩を踏み出しました。 しかし、大規模なアプリケーションでは、それらだけでは十分ではありません。 この記事では、フロントエンド開発の文脈でステートマシンの概念を紹介します。

An Introduction To State Machines

ステートマシンとは、計算の数学的モデルです。 これは抽象的な概念で、マシンは異なる状態を持つことができますが、ある時点ではそのうちの 1 つだけを満たすことができます。 ステートマシンにはさまざまな種類があります。 最も有名なものはチューリングマシンだと思います。 チューリング機械は無限の状態機械で、無数の状態を持つことができるということです。 しかし、今日のUI開発では、チューリング・マシンはうまく機能しません。

この2つのマシンの違いは、ムーアマシンは前の状態にのみ基づいて状態を変更することです。 残念ながら、私たちは、ユーザーのインタラクションやネットワークプロセスなどの外部要因を多く抱えているため、ムーアマシンも十分ではありません。 私たちが求めているのは、ミーリーマシンです。

ステートマシンがどのように機能するかを説明する最も簡単な方法の1つは、改札機を見ることです。 これは、ロックされた状態とロック解除された状態という有限の状態を持っています。

turnstile

turnstileの初期状態はロックされています。 何度押してもロックされた状態のままです。 しかし、そこにコインを渡すと、ロックされていない状態に移行します。 ここでコインを渡しても何も起こらず、ロックされていない状態のままです。 反対側から押せば、パスすることができます。

もし改札機を制御する単一の関数を実装しようとすると、おそらく現在の状態とアクションという2つの引数を持つことになるでしょう。 そして、Reduxを使っている人なら、これはおそらく見覚えがあると思います。 これはよく知られている reducer 関数に似ていて、現在の状態を受け取り、アクションのペイロードに基づいて、次の状態を決定します。 reducerは、ステートマシンの文脈でいうところの遷移にあたります。 実際のところ、何らかの方法で変化させることができる状態を持つアプリケーションは、すべてステートマシンと呼ばれるかもしれません。

How Is A State Machine Better?

仕事ではReduxを使っていて、とても満足しています。 しかし、私が好きではないパターンを目にするようになりました。 気に入らない」というのは、うまくいかないという意味ではありません。 むしろ、複雑さが増して、より多くのコードを書かなければならなくなってしまうのです。 私は、実験する余地のあるサイドプロジェクトを引き受けなければならなかったので、ReactとReduxの開発手法を見直すことにしました。 気になったことをメモしていくうちに、ステートマシンを抽象化することで、これらの問題のいくつかを解決できることに気づきました。

ここでは簡単な問題に取り組みます。 バックエンドのAPIからデータを取得して、ユーザーに表示したいとします。 最初のステップは、遷移ではなく、状態で考える方法を学ぶことです。

  • データ取得ボタンを表示する
  • ユーザーがデータ取得ボタンをクリックする
  • バックエンドへのリクエストを実行する
  • データを取得して解析する
  • それをユーザーに表示する。
  • または、エラーが発生した場合は、エラーメッセージを表示し、再度処理を開始できるように fetch-data ボタンを表示します。
linear thinking

私たちは直線的に考えており、基本的には最終的な結果に至るまでのすべての可能な方向をカバーしようとしています。 1つのステップが次のステップにつながり、すぐにコードを分岐させてしまいます。 例えば、ユーザーがボタンをダブルクリックしたり、バックエンドの応答を待っている間にユーザーがボタンをクリックしたり、リクエストは成功したがデータが破損していたりする問題はどうでしょうか。 このようなケースでは、何が起こったかを示すさまざまなフラグを用意することになるでしょう。

linear thinking

これは、私たちがトランジションで考えているからです。 遷移がどのように起こるか、どのような順序で起こるかに注目しています。 代わりに、アプリケーションのさまざまな状態に注目すれば、もっとシンプルになります。 アプリケーションにはいくつの状態があり、それらにはどのような入力があるのでしょうか。 同じ例を使って、

  • idle
    この状態では、データ取得ボタンを表示し、座って待ちます。
  • fetching
    ユーザーがボタンをクリックすると、バックエンドにリクエストを送信し、マシンを「フェッチ中」の状態に遷移させます。 アクションは次のとおりです:
    • success
      データは正常に到着し、破損していません。
    • failure
      リクエストの作成中やデータの解析中にエラーが発生した場合は、「error」ステートに移行します。 この状態は 1 つのアクションを受け入れます:
      • retry
        ユーザーが retry ボタンをクリックすると、リクエストを再度実行し、マシンを「フェッチ」状態に移行します。

    ほぼ同じプロセスを、状態と入力を使って説明しました。

    state machine

    これにより、ロジックが単純化され、より予測しやすくなります。 また、上述した問題のいくつかも解決されます。 フェッチ」状態にある間は、クリックを受け付けていないことに注意してください。 つまり、ユーザーがボタンをクリックしても何も起こらないということです。 このアプローチにより、コードロジックの予測不可能な分岐を自動的に排除することができます。 つまり、テスト時にカバーすべきコードが少なくて済むのです。 また、統合テストなどの一部のテストは自動化が可能です。 アプリケーションが何をしているかを明確に把握し、定義された状態や遷移を確認し、アサーションを生成するスクリプトを作成することができます。

    実際、すべての可能な状態を書き出すことは、すべての可能な遷移を書き出すことよりも簡単です。なぜなら、どの状態が必要で、どの状態があるかがわかっているからです。 ちなみに、ほとんどの場合、状態はアプリケーションのビジネス ロジックを表していますが、トランジションは最初からわからないことが多いのです。 私たちのソフトウェアのバグは、誤った状態や誤ったタイミングで実行されたアクションの結果です。 これらのアクションは、私たちが知らない状態でアプリケーションを放置し、その結果、プログラムが壊れたり、正しくない動作をしたりします。 もちろん、そのような状況にはなりたくありません。 ステートマシンは優れたファイアウォールです。 何がいつ起こるのか、どのようにして起こるのかを明示せずに境界線を設定することで、未知の状態に到達しないように守ってくれます。 ステートマシンのコンセプトは、一方向性のデータフローと非常によくマッチします。

    Creating A State Machine In JavaScript

    話はもういいので、コードを見てみましょう。 同じ例を使用します。

    const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } }}

    オブジェクトとしての状態と、関数としての入力可能な状態があります。 しかし、初期状態が欠けています。

    const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } }}

    意味のある状態をすべて定義したら、入力を送信して状態を変更する準備が整いました。

    const machine = { dispatch(actionName, ...payload) { const actions = this.transitions; const action = this.transitions; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ...}

    dispatchactionmachinethis.dispatch(<action>)this.changeStateTo(<new state>)で状態を変更したりできるようにしています。

    この例のユーザージャーニーに従うと、最初にディスパッチしなければならないアクションは click です。

    transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ...}machine.dispatch('click');

    まず、マシンの状態を fetchinggetDatasuccessfailureをディスパッチします。

    ここまでは順調です。 次に、successfailurefetching状態で実装しなければなりません:

    transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ...}

    前のプロセスについて考えることから脳を解放したことに注目してください。 ユーザーのクリックや HTTP リクエストで何が起こっているかは気にしていません。 アプリケーションがfetchingの状態であることはわかっていますし、この2つのアクションだけを期待しています。

    最後に、errorの状態を確認します。

    transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } }}

    ここでは、clickidleclick アクションを手動でディスパッチする必要があります。

    動作するステートマシンの完全な例は、私のCodepenにあります。

    Managing State Machines With A Library

    有限ステートマシンパターンは、React、Vue、Angularのいずれを使用するかにかかわらず機能します。 前のセクションで見たように、私たちはそれほど手間をかけずに簡単にステートマシンを実装することができます。 しかし、時にはライブラリがより柔軟性を提供することもあります。 Machina.js」や「XState」などが良いでしょう。

    Stentは、ステートマシンコンテナの実装です。

    Stentはステートマシンコンテナの実装で、ReduxやRedux-Sagaプロジェクトのアイデアを踏襲していますが、私の考えでは、よりシンプルでボイラープレートを使わない処理を提供しています。 リードム駆動開発を採用しており、文字通りAPIの設計にのみ数週間を費やしました。

    Creating Machines

    ほとんどの場合、私たちのアプリケーションは複数のドメインをカバーしています。 1台のマシンで済ませることはできません。

    import { Machine } from 'stent';const machineA = Machine.create('A', { state: ..., transitions: ...});const machineB = Machine.create('B', { state: ..., transitions: ...});

    後で、Machine.get メソッドを使用して、これらのマシンにアクセスできます。

    const machineA = Machine.get('A');const machineB = Machine.get('B');

    マシンをレンダリングロジックに接続する

    私の場合、レンダリングはReactで行っていますが、他のライブラリを使用することもできます。 結局のところ、レンダリングをトリガーするコールバックを実行することになります。

    import { connect } from 'stent/lib/helpers';Machine.create('MachineA', ...);Machine.create('MachineB', ...);connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });

    私たちは、どのマシンが自分にとって重要かを説明し、その名前を伝えます。 mapに渡したコールバックは、最初に一度だけ実行され、その後、いくつかのマシンの状態が変化するたびに実行されます。 ここでレンダリングのトリガーとなります。 この時点で、接続されているマシンに直接アクセスできるので、現在の状態やメソッドを取得することができます。

    利便性のために、Reactとの統合専用のヘルパーがエクスポートされています。

    import React from 'react';import { connect } from 'stent/lib/react';class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... }}// MachineA and MachineB are machines defined// using Machine.create functionexport default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });

    Stentはマッピングコールバックを実行し、オブジェクトを受け取ることを期待しています – オブジェクトはpropsとしてReactコンポーネントに送信されます。

    Stentのコンテキストにおける状態とは

    これまで、状態は単純な文字列でした。 残念ながら、現実の世界では、文字列以上のものを状態として保持する必要があります。 このため、Stentのstateは実際には内部にプロパティを持つオブジェクトです。 唯一の予約済みのプロパティは、nameです。 それ以外はすべてアプリ固有のデータです。 たとえば、

    { name: 'idle' }{ name: 'fetching', todos: }{ name: 'forward', speed: 120, gear: 4 }

    これまでのStentでの経験から、状態オブジェクトが大きくなると、おそらくそれらの追加プロパティを処理する別のマシンが必要になると思います。 さまざまな状態を識別するには時間がかかりますが、これは、より管理しやすいアプリケーションを書くための大きな前進だと思います。 それは、未来を予測し、可能なアクションのフレームを描くことに少し似ています。

    ステート マシンの作業

    冒頭の例と同様に、マシンの可能な (有限の) 状態を定義し、可能な入力を記述する必要があります。

    import { Machine } from 'stent';const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } }});

    初期状態としてidlerunrunningstopidle の状態に戻ります。

    以前に実装した dispatchchangeStateTotransitionsのプロパティに基づいて、Stentは以下のように生成します。

    • マシンが特定の状態にあるかどうかをチェックするためのヘルパーメソッド – idleisIdle()runningisRunning() となります。
    • アクションをディスパッチするためのヘルパーメソッドです。 runPlease()stopNow()です。

    では、上記の例では次のように使用できます。

    machine.isIdle(); // booleanmachine.isRunning(); // booleanmachine.runPlease(); // fires actionmachine.stopNow(); // fires action

    自動生成されたメソッドと connectconnectに渡されたマッピング関数が起動し、状態の変化が通知されます。

    入力とアクション ハンドラ

    おそらく最も重要な部分はアクション ハンドラです。 ここでは、入力や変更された状態に応答するため、アプリケーションロジックのほとんどを記述します。 私がReduxで非常に気に入っているものが、ここにも統合されています。それは、reducer関数の不変性とシンプルさです。 Stentのアクションハンドラの本質は同じです。 現在の状態とアクションのペイロードを受け取り、新しい状態を返さなければなりません。

    transitions: { 'fetching': { 'success': function (state, payload) { const todos = ; return { name: 'idle', todos }; } }}

    リモート サーバーからデータを取得する必要があるとしましょう。 リクエストを送信し、マシンをfetchingの状態に遷移させます。

    machine.success({ label: '...' });

    そして、idletodosの配列の形でデータを保持します。 アクションハンドラーに設定できる値は他にもいくつかあります。

    transitions: { 'idle': { 'run': 'running' }}

    これは { name: 'idle' }{ name: 'running' }run() アクションを使用しています。 この方法は、同期的な状態遷移を行い、メタデータを持たない場合に有効です。 つまり、何か他のものを状態にしておくと、そのタイプのトランジションがそれを洗い流してくれるのです。 同様に、状態オブジェクトを直接渡すこともできます。

    transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: } }}

    editingidledeleteAllTodosアクションを使用して遷移しています。

    すでに関数ハンドラを見ましたが、アクションハンドラの最後のバリエーションはジェネレータ関数です。 これはRedux-Sagaプロジェクトにインスパイアされたもので、次のようになります。

    import { call } from 'stent/lib/helpers';Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } }});

    ジェネレータの経験がないと、少し不可解に見えるかもしれません。 しかし、JavaScript のジェネレーターは強力なツールです。 アクション ハンドラを一時停止したり、状態を複数回変更したり、非同期ロジックを処理することが許されています。

    Fun With Generators

    Redux-Sagaを初めて紹介されたとき、非同期操作を処理するための複雑すぎる方法だと思いました。 実際には、commandデザインパターンをかなりスマートに実装しています。 このパターンの主な利点は、ロジックの呼び出しとその実際の実装を分離していることです。

    言い換えれば、私たちは欲しいものを言いますが、それがどのように起こるべきかは言いません。 Matt Hink氏のブログシリーズは、サガがどのように実装されているかを理解するのに役立ちましたので、ぜひ一読されることをお勧めします。 私は同じ考えをStentに持ち込んだのですが、この記事の目的のために、物をゆずることで、実際には行わずに欲しいものについて指示を出していると言います。 アクションが実行されると、私たちはコントロールを取り戻します。

    現時点では、いくつかのものが送り出される(ゆずる)可能性があります。

    • マシンの状態を変更するためのステート オブジェクト (または文字列)
    • call ヘルパーの呼び出し (同期関数、つまり約束を返す関数や別の生成関数を受け入れます) – 基本的には「これを実行してくれ、非同期の場合は待っていてくれ。
    • waitヘルパーの呼び出し(別のアクションを表す文字列を受け取ります)。このユーティリティー関数を使用すると、ハンドラーが一時停止し、別のアクションがディスパッチされるのを待ちます。

    以下は、そのバリエーションを示す関数です。

    const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... });}...transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } }}

    見てのとおり、コードは同期的に見えますが、実際には同期的ではありません。

    How Stent Is Solving My Redux Concerns

    Too Much Boilerplate Code

    Redux(およびFlux)アーキテクチャは、システム内を循環するアクションに依存しています。 アプリケーションが成長すると、通常は多くの定数とアクションクリエーターが必要になります。 この2つは別々のフォルダに入っていることが非常に多く、コードの実行を追跡するのに時間がかかることがあります。

    Stentでは、アクション名はなく、ライブラリが自動的にアクション・クリエーターを作成します。

    const machine = Machine.create('todo-app', { state: { name: 'idle', todos: }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } }});machine.addTodo({ title: 'Fix that bug' });

    アクション・クリエーターは、マシンのメソッドとして直接定義されています。 このアプローチは、私が直面していた別の問題も解決しました。特定のアクションに応答するリデューサを見つけることです。 通常、Reactコンポーネントでは、addTodoのようなアクションクリエイターの名前を目にします。しかし、リデューサでは、一定のタイプのアクションを扱います。 正確なタイプを確認するために、アクションクリエイターのコードにジャンプしなければならないこともあります。

    Unpredictable State Changes

    一般的に、Reduxは不変的な方法で状態を管理するのに良い仕事をしています。 問題は、Redux自体にあるのではなく、開発者がいつでも任意のアクションをディスパッチできることにあります。 例えば、電気をつけるアクションがあったとして、そのアクションを連続して2回実行してもいいのでしょうか? もしダメなら、Reduxでこの問題をどう解決すればいいのでしょうか? おそらく、Reducerの中にロジックを保護するコードを入れて、照明がすでに点灯しているかどうかをチェックするでしょう。 ここで疑問なのは、これはレデューサーの範囲を超えているのではないかということです。

    私がReduxに欠けているのは、reducerを条件付きロジックで汚染することなく、アプリケーションの現在の状態に基づいてアクションのディスパッチを停止する方法です。 そして、この決定をアクションクリエイターが起動するビューレイヤーにも持っていきたくありません。 Stentでは、現在の状態で宣言されていないアクションにはマシンが反応しないので、これは自動的に起こります。

    const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } }});// this is finemachine.run();// This will do nothing because at this point// the machine is in a 'running' state and there is// only 'stop' action there.machine.jump();

    マシンが特定の時間に特定の入力だけを受け入れるという事実は、奇妙なバグから私たちを守り、アプリケーションをより予測可能なものにします。 Redux を使った開発のメンタル モデルは、アクションと、そのアクションが reducer の状態をどのように変化させるかに大きく左右されます。

    おわりに

    プログラミング、特にUI開発におけるステートマシンの概念は、私にとって目からウロコでした。 ステート マシンをいたるところで目にするようになり、常にそのようなパラダイムに移行したいと思うようになりました。 より厳密に定義された状態とその間の遷移を持つことのメリットは確かにあります。 私は常に、自分のアプリケーションをシンプルで読みやすいものにする方法を模索しています。 ステートマシンはその一歩だと思っています。 コンセプトはシンプルであると同時に強力です。

    Smashing Editorial(rb, ra, al, il)
  • コメントを残す

    メールアドレスが公開されることはありません。 * が付いている欄は必須項目です