[Web フロントエンド] 状態更新ロジックをフレームワークから独立させる

2019/05/20
このエントリーをはてなブックマークに追加
分身の術を使う患者に翻弄される看護師

はじめに

はじめまして。 null です。最近の悩みは 2 歳半の息子が言うことを聞いてくださらないことです。 2 歳児もそうなのですが、 ソフトウェアフレームワークに振り回されてるって感じること、稀によくありませんか?? ある程度の学習コストがかかるのは当然としても、冗長な書き方を要求されるわ、かゆいところに手が届かないわ、実データ投入したらパフォーマンス問題だらけだわ、バージョンアップしたら動かなくなるわで、あぁ私いま振り回されてるなぁ、みたいな。 「じゃあ使わなければいい」「じゃあ自分で作ればいい」という話ではなく、「必要以上には依存しない方が無難だよね」「フレームワークの外へ切り出せるところは切り出してみよう」という話をします。

続きを 3 行で

  1. 状態更新ロジックってフレームワーク非依存にできそうだよね
  2. でもフレームワークによって状態をイミュータブル(状態更新=状態オブジェクト生成)にしたかったりミュータブル(状態更新=状態オブジェクト変更)にしたかったりするから難しいわ
  3. 状態オブジェクトの変更として実装しておけば、必要に応じて immer でラップするだけでイミュータブルな状態オブジェクトの生成処理に変えられるよ

【安定依存の原則】と 【変わりやすいフレームワークに依存するジレンマ】

安定依存の原則(Stable Dependencies Principle; SDP)

安定度の高い方向に依存すること。
クリーンアーキテクチャに出てくるシンプルな原則です。 (カブク社内で書籍『クリーンアーキテクチャ』の輪読会をやったのでちょっと使ってみたくなりました。) パッと見、分かりやすい原則ですが、だからといって守りやすいとは限りません。まずフレームワークが不安定だからです。 (フレームワークなしで戦うひとには関係のない話ですが。) たとえば Angular は 6 か月ごとにメジャーリリースすると公式に謳っています。 そして、 Angular 4 から Angular 6 への移行は一筋縄ではいかなかったそうです。 Angular に限らず、次のような会話って経験ありませんか?
「フレームワーク XXX の現在利用しているバージョンのサポートがあと 1 年で切れます。   最新バージョンへの対応には、コンパイルエラーと自動テストエラーをつぶすだけでなく、手で動かしておかしな挙動を修正する必要もあります。   2 週間で終わるかもしれませんし、 2 ヶ月かかるかもしれません。   新機能開発を止めて対応してよろしいでしょうか…?」 「え? 2 週間もかかるの??」 (え? 「2 ヶ月かかるかもしれない」ってとこ聞こえなかった??) ※ この会話は架空のものです。決して私が Spring Boot 1.x から 2.x に上げたかったときの相談ではありません。
(【安定依存の原則】にしたがうなら、) フレームワークを、サポートが切れないよう最新化しつつ利用するには、そのフレームワークに依存しないようにする必要があります。 なんと綺麗なジレンマでしょう。 そんなわけで私は Angular アプリ開発にも Angular の HttpClient サービス ではなく fetch() を普通に使います。 (ところで、巷ではよく Angular が React や Vue.js に比べて「大規模アプリケーション向き」だと言われますが、 Angular のリリースサイクルの速さを考慮に入れても本当に大規模アプリケーションに向いていると言えるでしょうか…?)

状態更新ロジックをフレームワークから独立させたい

さて、 JavaScript で状態を更新するロジックを書くとき、
  • Redux や NgRx の Reducer として、
  • MobX のアクションとして、
  • rxjs の BehaviorSubject として、
など、(状態管理の)フレームワークと絡めがちです。 が、 フレームワークに依存しない方がフレームワークを更新しやすいのは前述のとおりです。 「状態の持ち方」と「状態更新ロジック」とを分けて考えれば、少なくとも「状態更新ロジック」はフレームワークから独立させられるのではないでしょうか。 ところがこれが思いのほか難しい。 Angular とか Redux とか素の React とかの文脈では基本的に状態をイミュータブルにします(よね)。 すなわち状態の更新は新しい状態オブジェクトの生成という形で実現します。 一方 Vue.js とか Mobx とかの文脈では状態オブジェクトを直接変更します。 状態更新処理が状態オブジェクトを生成すべきか変更すべきか、利用するフレームワークによって異なるんです。 これではフレームワークが決まらないとロジックを書けません。 「状態オブジェクトの生成にも変更にも使えるコードは書けないものか…」 私は悩みました。 そして immer に至ります。

immer

immer は「現在の状態の変更によって次のイミュータブルな状態を生成する」ライブラリです。 くわしくは公式のコード例が語ります。
import produce from "immer"

const baseState = [
    {
        todo: "Learn typescript",
        done: true
    },
    {
        todo: "Try immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({todo: "Tweet about it"})
    draftState[1].done = true
})
// the new item is only added to the next state,
// base state is unmodified
expect(baseState.length).toBe(2)
expect(nextState.length).toBe(3)

// same for the changed 'done' prop
expect(baseState[1].done).toBe(false)
expect(nextState[1].done).toBe(true)

// unchanged data is structurally shared
expect(nextState[0]).toBe(baseState[0])
// changed data not (dûh)
expect(nextState[1]).not.toBe(baseState[1])
オブジェクトを変更するように記述したコード
    draftState.push({todo: "Tweet about it"})
    draftState[1].done = true
が immer でくるむだけで 新しいオブジェクトの生成処理に変わってしまいます。 しかも状態ツリーの中で変更があったオブジェクト(とその先祖)だけ生成されます。 これですよ。これ。 これで、夢にまで見た状態オブジェクトの生成にも変更にも使えるコードが書けます。 状態オブジェクトを変更するコードを書いとけばいいんです。 これほど簡単なことはありません。 状態をイミュータブルにしたいときは、ロジックを利用する側が immer でくるむだけです。 (これが利用側であることは重要です、状態更新ロジック自体は immer に依存しません。)

そして私はやった

状態オブジェクトの変更として記述したロジックを、 Angular と React と Vue.js とで使い回すことを試みました。 Angular と React では immer を介して、 Vue.js ではそのまま、ロジックを利用します。 やってみるものです。きちんと落とし穴に嵌りましたから。

immer の落とし穴

  1. 状態ツリー内に循環参照があると immer はエラーを投げる
  2. 状態ツリー内に同じオブジェクトへの参照があっても immer は別のオブジェクトとして扱う(一方を変更しても他方は更新されない)
はい。README に書いてあります。(読む前に踏みました。両方踏みました。) これはロジック以前にデータ構造への制約なので、かなり強い制限のように感じます。 ある程度実装が進んだプロジェクトにおいて、あとから「やっぱり immer つかおうかな」はツライかもしれません。 しかたがないので データ構造を変えた上で ユニットテストは immer 版と非 immer 版で走らせるようにしました。

Vue.js の落とし穴

  1. Vue.js 2.x は配列要素の差し替え array[index] = value に対してリアクティブでない
はい。もちろん公式に書いてあります。 ですが、 Vue.js だけのために array[index] = valuearray.splice(index, 1, value) に書き換えるのはちょっと我慢できません。 immer の Patches 機能を使えば変更内容を配列でもらって自分でほげほげできるので、 Patches を使って Vue.set() を呼ぶような実装をしてみたのですが、うまくいきませんでした。 そもそも immer は Vue.js からの利用を想定していないようです。 代入操作を抽象化することも考えましたが… (ロジック側が set() 関数を受けとって array[index] = value の代わりに set(array, index, value) を呼ぶようにしておけば、 Vue.js のときは Vue.set() を呼ぶようにできるわけですが…) そこまでしたくないなぁと… ということで この件はあきらめました。 ワケあって今回作った例ではちゃんと動いてしまう、という事情もありますが、 Vue.js 3.x では配列要素の差し替えを検知するようになるはず、というのが主な理由です。 この記事としては本末転倒感が強い話ですが、 本件についてはフレームワークの変化に期待するばかりです。(苦笑)

実際のコードとプチ解説

スプレッドシートを扱うアプリで、アクティブセルを更新する処理を例にします。 まずはこの例で扱う状態オブジェクトの型定義を紹介しておきます。
export interface Worksheet {
  readonly maxCellAddress: Readonly<WorksheetCellAddress>
  activeCellAddress: WorksheetCellAddress
}

export interface WorksheetCellAddress {
  rowIndex: number
  columnIndex: number
}

状態変更ロジック

  • 状態変更ロジックを記述するクラスは「状態を変更するロジック(関数)を受け取って実際の更新処理を行う関数 update: (mutate: (state: Worksheet) => void) => unknown」を受け取ります(状態の更新処理を抽象化します)。
  • 状態の変更メソッドでは前述の「実際の更新処理を行う関数 update()」に「オブジェクトの変更ロジック」を渡します update(state => state.xxx.yyy = zzz)
  • このクラスは状態そのものを保持しません。状態の持ち方を利用側の自由にするためです。
export class WorksheetOperations {
  constructor(private readonly update: (mutate: (state: Worksheet) => void) => unknown) {}

  setActiveCellAddress(rowIndex?: number, columnIndex?: number): this {
    this.update(({ maxCellAddress, activeCellAddress }) => {
      if (typeof rowIndex === 'number' && rowIndex >= 0 && rowIndex <= maxCellAddress.rowIndex) {
        activeCellAddress.rowIndex = rowIndex
      }
      if (typeof columnIndex === 'number' && columnIndex >= 0 && columnIndex <= maxCellAddress.columnIndex) {
        activeCellAddress.columnIndex = columnIndex
      }
    })
    return this
  }
}

Vue.js で利用する場合

  • Vue.js のように、状態オブジェクトがミュータブルな前提で使えるフレームワークでは、 update() 関数として単純に mutate => mutate(state) を渡します。
    • state.xxx.yyy = zzz のような普通の変更処理が実行されて、フレームワークが勝手に(Vue.js 2.x なら setter, 3.x なら proxy を介して)反応します。
export default Vue.extend({
  data() {
    const worksheet = Vue.observable(createWorksheet())
    const worksheetOperations = new WorksheetOperations(
      mutate => mutate(worksheet)
    )
    return { worksheet, worksheetOperations }
  },
}

React.PureComponent(あるいは React.memo() されたコンポーネント)の props に利用する場合

  • 状態オブジェクトがイミュータブルな前提で使う際には、 update() 関数として immer の produce() 関数を介した mutate => state = produce(state, mutate) のような関数を渡します。
    • produce(state, state => state.xxx.yyy = zzz) が新しいオブジェクトを生成してくれるので、返ってきたオブジェクトを保持するだけです。
  • produce(state, mutate)produce(mutate)(state) とも記述できるので、 React では mutate => setState(state => produce(state, mutate)) の代わりに mutate => setState(produce(mutate)) と記述できます。
import produce from 'immer'

const WorksheetTable: React.SFC<{
  worksheet: Worksheet
  worksheetOperations: WorksheetOperations
}> = /* ... */

const WorksheetTableMemoized = React.memo(WorksheetTable)

export default () => {
  const [state, setState] = React.useState(createWorksheet())
  const worksheetOperations = new WorksheetOperations(
    mutate => setState(produce(mutate))
  )
  return (
    <WorksheetTableMemoized
      worksheet={state}
      worksheetOperations={worksheetOperations}
    />
  )
}

Angular (rxjs) で利用する場合

  • 状態を rxjs の BehaviorSubject として持つ場合も、状態をイミュータブルとして扱うために React.PureComponent 同様 immer を介します。
import produce from 'immer'
import { BehaviorSubject } from 'rxjs'

@Injectable()
export class WorksheetService {
  private readonly _worksheet = new BehaviorSubject<Worksheet>(createWorksheet())
  readonly worksheetOperations = new WorksheetOperations(
    mutate => this._worksheet.next(produce(this._worksheet.value, mutate))
  )
}
  • 以上いずれのフレームワークでも、 worksheetOperations.setActiveCellAddress(rowIndx, columnIndex) を呼ぶことで状態が更新(変更または生成)されて画面に反映されます。

できあがったものがこちら

GitHub に置いてあります。

おわりに

状態更新ロジックからフレームワークを抽象化してみました。 最後に、少し冷静になりましょう。
「十分に発達した抽象化は、難読化と見分けがつかない」―@raganwald プログラミングの名言をもう少し | POSTD
この抽象化は本当に必要でしょうか? 率直に言うと、 必ずしも状態更新ロジックからフレームワークを抽象化する必要はないけど、 手軽にできるなら新規プロジェクトでは状態更新ロジックをフレームワークから独立させておくのが無難かな、 ぐらいに私は考えます。 フレームワークは変わります。 Svelte はバージョン 3.0.0 で “Everything” を変えたそうです。 ビジネスも変わります。 デザイントレンドも変わります。 フレームワークのバージョンアップなんかのためにコードベースを修正している場合ではありません。 元号も変わります。 紙幣も変わります。 「immer も大きく変わるかもしれないじゃないか!」 たしかに。 ですが immer は役割が完全に決まっているので、破壊的変更があったとしても影響は大きくないだろうと予想します。 「依存するモジュールが 1 つ増えただけじゃないか!」 まぁそうっちゃそうなんですよね。 でも、フレームワークの外で、オブジェクトの変更として、とても素直なロジックを記述できるのは、大きなメリットだと私は考えます。 依存関係の変更に強いアプリを構築する手段として、 immer に頼ることは現実的な解のひとつではないでしょうか。 ※ 私は業務ではまだ immer を利用していません。あしからず。

カブクでは変化に強いエンジニアを募集中です!

迷ったらポチる!

その他の記事

Other Articles

2019/08/14
Maker Faire Tokyo 2019でARゲームを出展しました

2019/07/25
夏休みだョ!WebAssembly Proposal全員集合!!

2019/07/08
鵜呑みにしないで! —— 書籍『クリーンアーキテクチャ』所感 ≪null 篇≫

2019/07/03
W3C Workshop on Web Games参加レポート

2019/06/28
TypeScriptでObject.assign()に正しい型をつける

2019/06/25
カブクエンジニア開発合宿に行ってきました 2019夏

2019/06/21
Hola! KubeCon Europe 2019の参加レポート

2019/06/19
Clean Resume きれいな環境できれいな履歴書を作成する

2019/04/16
C++のenable_shared_from_thisを使う

2019/04/12
OpenAPI 3 ファーストな Web アプリケーション開発(Python で API 編)

2019/04/08
WebGLでレイマーチングを使ったCSGを実現する

2019/04/02
『エンジニア採用最前線』に感化されて2週間でエンジニア主導の求人票更新フローを構築した話

2019/03/29
その1 Jetson TX2でk3s(枯山水)を動かしてみた

2019/03/27
任意のブラウザ上でJestで書いたテストを実行する

2019/02/08
TypeScript で “radian” と “degree” を間違えないようにする

2019/02/05
Python3でGoogle Cloud ML Engineをローカルで動作する方法

2019/01/18
SIGGRAPH Asia 2018 参加レポート

2019/01/08
お正月だョ!ECMAScript Proposal全員集合!!

2019/01/08
カブクエンジニア開発合宿に行ってきました 2018秋

2018/12/25
OpenAPI 3 ファーストな Web アプリケーション開発(環境編)

2018/12/23
いまMLKitカスタムモデル(TF Lite)は使えるのか

2018/12/21
[IoT] Docker on JetsonでMQTTを使ってCloud IoT Coreと通信する

2018/12/11
TypeScriptで実現する型安全な多言語対応(Angularを例に)

2018/12/05
GASでCompute Engineの時間に応じた自動停止/起動ツールを作成する 〜GASで簡単に好きなGoogle APIを叩く方法〜

2018/12/02
single quotes な Black を vendoring して packaging

2018/11/14
3次元データに2次元データの深層学習の技術(Inception V3, ResNet)を適用

2018/11/04
Node Knockout 2018 に参戦しました

2018/10/24
SIGGRAPH 2018参加レポート-後編(VR/AR)

2018/10/11
Angular 4アプリケーションをAngular 6に移行する

2018/10/05
SIGGRAPH 2018参加レポート-特別編(VR@50)

2018/10/03
Three.jsでVRしたい

2018/10/02
SIGGRAPH 2018参加レポート-前編

2018/09/27
ズーム可能なSVGを実装する方法の解説

2018/09/25
Kerasを用いた複数入力モデル精度向上のためのTips

2018/09/21
競技プログラミングの勉強会を開催している話

2018/09/19
Ladder Netwoksによる半教師あり学習

2018/08/10
「Maker Faire Tokyo 2018」に出展しました

2018/08/02
Kerasを用いた複数時系列データを1つの深層学習モデルで学習させる方法

2018/07/26
Apollo GraphQLでWebサービスを開発してわかったこと

2018/07/19
【深層学習】時系列データに対する1次元畳み込み層の出力を可視化

2018/07/11
きたない requirements.txt から Pipenv への移行

2018/06/26
CSS Houdiniを味見する

2018/06/25
不確実性を考慮した時系列データ予測

2018/06/20
Google Colaboratory を自分のマシンで走らせる

2018/06/18
Go言語でWebAssembly

2018/06/15
カブクエンジニア開発合宿に行ってきました 2018春

2018/06/08
2018 年の tree shaking

2018/06/07
隠れマルコフモデル 入門

2018/05/30
DASKによる探索的データ分析(EDA)

2018/05/10
TensorFlowをソースからビルドする方法とその効果

2018/04/23
EGLとOpenGLを使用するコードのビルド方法〜libGLからlibOpenGLへ

2018/04/23
技術書典4にサークル参加してきました

2018/04/13
Python で Cura をバッチ実行するためには

2018/04/04
ARCoreで3Dプリント風エフェクトを実現する〜呪文による積層造形映像制作の舞台裏〜

2018/04/02
深層学習を用いた時系列データにおける異常検知

2018/04/01
音声ユーザーインターフェースを用いた新方式積層造形装置の提案

2018/03/31
Container builderでコンテナイメージをBuildしてSlackで結果を受け取る開発スタイルが捗る

2018/03/23
ngUpgrade を使って AngularJS から Angular に移行

2018/03/14
Three.jsのパフォーマンスTips

2018/02/14
C++17の新機能を試す〜その1「3次元版hypot」

2018/01/17
時系列データにおける異常検知

2018/01/11
異常検知の基礎

2018/01/09
three.ar.jsを使ったスマホAR入門

2017/12/17
Python OpenAPIライブラリ bravado-core の発展的な使い方

2017/12/15
WebAssembly(wat)を手書きする

2017/12/14
AngularJS を Angular に移行: ng-annotate 相当の機能を TypeScrpt ファイルに適用

2017/12/08
Android Thingsで4足ロボットを作る ~ Android ThingsとPCA9685でサーボ制御)

2017/12/06
Raspberry PIとDialogflow & Google Cloud Platformを利用した、3Dプリンターボット(仮)の開発 (概要編)

2017/11/20
カブクエンジニア開発合宿に行ってきました 2017秋

2017/10/19
Android Thingsを使って3Dプリント戦車を作ろう ① ハードウェア準備編

2017/10/13
第2回 魁!! GPUクラスタ on GKE ~PodからGPUを使う編~

2017/10/05
第1回 魁!! GPUクラスタ on GKE ~GPUクラスタ構築編~

2017/09/13
「Maker Faire Tokyo 2017」に出展しました。

2017/09/11
PyConJP2017に参加しました

2017/09/08
bravado-coreによるOpenAPIを利用したPythonアプリケーション開発

2017/08/23
OpenAPIのご紹介

2017/08/18
EuroPython2017で2名登壇しました。

2017/07/26
3DプリンターでLチカ

2017/07/03
Three.js r86で何が変わったのか

2017/06/21
3次元データへの深層学習の適用

2017/06/01
カブクエンジニア開発合宿に行ってきました 2017春

2017/05/08
Three.js r85で何が変わったのか

2017/04/10
GCPのGPUインスタンスでレンダリングを高速化

2017/02/07
Three.js r84で何が変わったのか

2017/01/27
Google App EngineのFlexible EnvironmentにTmpfsを導入する

2016/12/21
Three.js r83で何が変わったのか

2016/12/02
Three.jsでのクリッピング平面の利用

2016/11/08
Three.js r82で何が変わったのか

2016/12/17
SIGGRAPH 2016 レポート

2016/11/02
カブクエンジニア開発合宿に行ってきました 2016秋

2016/10/28
PyConJP2016 行きました

2016/10/17
EuroPython2016で登壇しました

2016/10/13
Angular 2.0.0ファイナルへのアップグレード

2016/10/04
Three.js r81で何が変わったのか

2016/09/14
カブクのエンジニアインターンシッププログラムについての詩

2016/09/05
カブクのエンジニアインターンとして3ヶ月でやった事 〜高橋知成の場合〜

2016/08/30
Three.js r80で何が変わったのか

2016/07/15
Three.js r79で何が変わったのか

2016/06/02
Vulkanを試してみた

2016/05/20
MakerGoの作り方

2016/05/08
TensorFlow on DockerでGPUを使えるようにする方法

2016/04/27
Blenderの3DデータをMinecraftに送りこむ

2016/04/20
Tensorflowを使ったDeep LearningにおけるGPU性能調査

→
←

関連職種

Recruit

バックエンドエンジニア(Python・Go)

業務内容

当ポジションは弊社Webサービスのバックエンド機能設計及び実装を担当します。 サービス毎の開発チームで2週間スプリントのスクラム開発を実施しています。 週次で開発チームミーティングを実施し、実装設計の相談や工数見積もりを行います。 全ての開発コードはレビューと自動テストによって品質を保っています。 また、リファクタリングやフレームワークのバージョンアップも開発フローに組込み、技術的負債を放置しない開発を目指しています。

フロントエンドエンジニア(TypeScript)

業務内容

当ポジションは弊社Webサービスのフロントエンド機能設計及び実装を担当します。 サービス毎の開発チームで2週間スプリントのスクラム開発を実施しています。 週次で開発チームミーティングを実施し、実装設計の相談や工数見積もりを行います。 全ての開発コードはレビューと自動テストによって品質を保っています。 また、リファクタリングやフレームワークのバージョンアップも開発フローに組込み、技術的負債を放置しない開発を目指しています。

インターン(Webエンジニア)

業務内容

業務から独立した、調査・研究系のタスクをおまかせしています。コードレビュー、 社内での報告会、 ブログ記事執筆を通して着実にスキルアップしていただくことを目指しています。 (希望があれば、プロダクトの開発業務もおまかせします。)

→
←

お客様のご要望に「Kabuku」はお応えいたします。
ぜひお気軽にご相談ください。

お電話でも受け付けております
03-6380-2750
営業時間:09:30~18:00
※土日祝は除く