3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[翻訳] Reduxレシピ: Undoヒストリーを実装する

Last updated at Posted at 2019-01-28

はじめての翻訳です。
原文のライセンスは MIT

翻訳したのは
https://redux.js.org/recipes/implementing-undo-history
の前半部分。

Redux のドキュメントは最高に面白いプログラミングの教科書だと思うので、少しでも魅力が伝わればなあ、と。
多少意訳あり。誤訳、わかりにくいところがあれば是非。
以下翻訳。

Undoヒストリーを実装する

(訳注:「Undoヒストリー」photoshopとかのヒストリーパネルが訳者のイメージ)

Undo と Redo の機能をアプリに作り込むというのは、伝統的に開発者の意識的な努力を必要とするものでした。これは古典的な MVC フレームワークにおいても簡単な問題ではありません。すべての過去の状態を追うのに、関連するあらゆるモデルを複製する必要があるのです。加えて、ユーザによる変更が undo 可能でなくてはなりませんから、undoスタックにも注意を払わなくてはいけません。

つまりMVC アプリケーションにおいて Undo/Redo を実装する作業は、通常 Commandパターン のような特定のデータ変更パターンを利用するために、アプリケーション各所の書き換えを要するものになります。

一方で、Redux で undoヒストリーを実装するのはそよ風のようにたやすいことです。これには三つの理由があります:

  • 経過を記録すべき複数のモデルなどありません。追うのは state の特定の下位ツリーだけです。
  • state はすでにイミュータブルで、変更はすでに個別の action として記述されています。これは undo スタックのメンタルモデルに近いものです。
  • reducer の (state, action) => state というシグネチャは、汎用的な「reducerエンハンサ」あるいは「高階Reducer」の実装を自然なことにしています。「reducerエンハンサ」や「高階Reducer」は reducer を引数にとり、もとのシグネチャを維持したまま、いくらかの機能を追加して Reducer を強化(エンハンス)する関数です。Undoヒストリーはまさしくこのように追加されるものでしょう。

進む前に、basic tutorial を終わらせて、 reducer composition (reducerの合成)をよく理解しておいてください。このレシピはbasic tutorial での例を利用しています。

このレシピでは、まず汎用的な方法による undo/redo の実装を可能にする基本的な考え方を説明します。

後半ではRedux Undoというパッケージの使い方をお見せします。redo/undo機能をすぐ使えるよう提供しているものです。(訳注:この翻訳ではスキップ)

demo of todos-with-undo

Undoヒストリーの理解

State のカタチをデザインする

Undoヒストリーもまたあなたのアプリの state の一部であり、別のアプローチを採用する理由はありません。変わりゆく state の型がどのようなものであっても、Undo/Redo を実装するのであれば、その state の ヒストリー の経過を、様々な時点で把握したいと思うでしょう。

例えば、カウンターアプリの state はこのようなカタチをしているでしょうか:

{
  counter: 10
}

もしこのようなアプリについて Undo/Redo を実装したければ、次の疑問に答えられるようにもっと多くの状態を保存しなければなりません。

  • undo もしくは redo できるような何かが残っているか?
  • 現在の状態は何か?
  • undo スタックにある過去(そして未来)の状態は何か?

これらの疑問に答えられるよう state のカタチを変えるべきだというのは妥当な提案です:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    present: 10,
    future: []
  }
}

さて、もしユーザが "Undo" を押したなら、過去に向かうように変わって欲しいものです:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
    present: 9,
    future: [10]
  }
}

さらにひとつ:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7],
    present: 8,
    future: [9, 10]
  }
}

ユーザが "Redo" を押したときには、一歩未来に戻りたいですね:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
    present: 9,
    future: [10]
  }
}

最後に、もし undo スタックの途中にいるときにユーザがある action (たとえばカウンタをデクリメントする)を起こしたなら、ここにある未来(future)は捨ててしまいましょう:

{
  counter: {
    past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
    present: 8,
    future: []
  }
}

ここで面白いのは、我々が追跡したいスタックが number であっても、あるいは string, array, object のいずれであっても何も変わらないということです。構造は常に同じです:

{
  counter: {
    past: [0, 1, 2],
    present: 3,
    future: [4]
  }
}
{
  todos: {
    past: [
      [],
      [{ text: 'Use Redux' }],
      [{ text: 'Use Redux', complete: true }]
    ],
    present: [
      { text: 'Use Redux', complete: true },
      { text: 'Implement Undo' }
    ],
    future: [
      [
        { text: 'Use Redux', complete: true },
        { text: 'Implement Undo', complete: true }
      ]
    ]
  }
}

一般に、このようなものになるでしょう:

{
  past: Array<T>,
  present: T,
  future: Array<T>
}

我々はただ一つのトップレベルのヒストリーを保持することもできますし:

{
  past: [
    { counterA: 1, counterB: 1 },
    { counterA: 1, counterB: 0 },
    { counterA: 0, counterB: 0 }
  ],
  present: { counterA: 2, counterB: 1 },
  future: []
}

あるいはユーザがアイテムごとに独立して undo/redo できるよう、粒度の細かい複数のヒストリーを持つこともできます:

{
  counterA: {
    past: [1, 0],
    present: 2,
    future: []
  },
  counterB: {
    past: [0],
    present: 1,
    future: []
  }
}

我々のとるアプローチがどのように我々に Undo/Redo のあるべき粒度を選択させるのかは、のちほど見ることにしましょう。

(訳注:訳しててどうもスッキリしない。原文どぞ。)
(原文: We will see later how the approach we take lets us choose how granular Undo and Redo need to be.)

アルゴリズムをデザインする

データの型に関わらず、 undoヒストリーは同じカタチになります:

{
  past: Array<T>,
  present: T,
  future: Array<T>
}

ここからは、上で述べた state のカタチを扱うアルゴリズムについて話すことにしましょう。この state に作用する二つの action を定義することができます: UNDOREDO です。reducer では、これらの action を処理するために次のステップを踏んでいきます:

Undo を処理する

  • past から 最後尾 の要素を取り除く。
  • present に前のステップで取り除いた項目をセットする。
  • future最初 にもともとの present の state を挿入する。

Redo を処理する

  • future から 最初 の要素を取り除く。
  • present に前のステップで取り除いた項目をセットする。
  • past最後尾 にもともとの present の state を挿入する。

その他の Action を処理する

  • past の最後に present を挿入する。
  • present に action を処理した新しい state をセットする。
  • future をクリアする。

最初の試み:Reducer を書く

const initialState = {
  past: [],
  present: null, // (?) present をどうやって初期化する?
  future: []
}

function undoable(state = initialState, action) {
  const { past, present, future } = state

  switch (action.type) {
    case 'UNDO':
      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)
      return {
        past: newPast,
        present: previous,
        future: [present, ...future]
      }
    case 'REDO':
      const next = future[0]
      const newFuture = future.slice(1)
      return {
        past: [...past, present],
        present: next,
        future: newFuture
      }
    default:
      // (?) その他の action はどうやって処理しよう?
      return state
  }
}

この実装は使えません。次の三つの重要な問題を無視しています:

  • どこで present の initial state を手に入れるか? 我々は事前に知ってはいないようです。
  • presentpast に保存するための外部の action には、どこで反応しようか?
  • present state のコントロールは、カスタム reducer にどうやって委任するのだろうか?

どうやら reducer では正しい抽象にはならないようです。ただ、とても近いところにいます。
(訳注:最初から抽象的な、どんな型のデータにも適用できる undoable を目指しているようです)

Reducerエンハンサ

読者はもしかしたら 高階関数 によく通じておられるかもしれません。React を使う方でしたら、 higher order components (HOC) についてよく知っているかもしれません。ここで使うのは同じパターンを reducer に適用した変種です。

reducerエンハンサ (あるいは higher order reducer(高階reducer))は、reducer を取って新しい reducer を返す関数です。新しい reducer は、自身では判断できない action についての制御を内側の reducer に渡すので、新しい action を扱ったり、より多くの state を持つことができます。これは特に新しいパターンではありません。厳密に言えば combineReducers() も reducerエンハンサです。reducer を取って新しい reducer を返すのですから。

何もしない reducerエンハンサはこのような感じです:

function doNothingWith(reducer) {
  return function(state, action) {
    // 渡された reducer を呼ぶだけ
    return reducer(state, action)
  }
}

複数の reducer を組み合わせる reducerエンハンサはこのようなものでしょう:

function combineReducers(reducers) {
  return function(state = {}, action) {
    return Object.keys(reducers).reduce((nextState, key) => {
      // 全ての reducer を、それぞれが管理する state の一部とともに呼ぶ
      nextState[key] = reducers[key](state[key], action)
      return nextState
    }, {})
  }
}

2回目の試み: Reducerエンハンサを書く

さて、今では我々は多少なりとも reducerエンハンサについて知っているはずです。そうです、 undoable は次のような姿であるべきだったのです:

function undoable(reducer) {
  // 空の action とともに渡された reducer を呼び、initial state を作る
  const initialState = {
    past: [],
    present: reducer(undefined, {}),
    future: []
  }

  // undo と redo を扱う reducer を返す
  return function(state = initialState, action) {
    const { past, present, future } = state

    switch (action.type) {
      case 'UNDO':
        const previous = past[past.length - 1]
        const newPast = past.slice(0, past.length - 1)
        return {
          past: newPast,
          present: previous,
          future: [present, ...future]
        }
      case 'REDO':
        const next = future[0]
        const newFuture = future.slice(1)
        return {
          past: [...past, present],
          present: next,
          future: newFuture
        }
      default:
        // 渡された reducer に action の処理を委任する
        const newPresent = reducer(present, action)
        if (present === newPresent) {
          return state
        }
        return {
          past: [...past, present],
          present: newPresent,
          future: []
        }
    }
  }
}

これでどのような reducer も undoable という reducerエンハンサによってラッピングでき、UNDOREDO という action への対応を覚えさせることができます。

// これは reducer
function todos(state = [], action) {
  /* ... */
}

// そしてこれも reducer!
const undoableTodos = undoable(todos)

import { createStore } from 'redux'
const store = createStore(undoableTodos)

store.dispatch({
  type: 'ADD_TODO',
  text: 'Use Redux'
})

store.dispatch({
  type: 'ADD_TODO',
  text: 'Implement Undo'
})

store.dispatch({
  type: 'UNDO'
})

重要ですが少し紛らわしいところ(訳注:gotcha)があります: stateに .present をつけなければ、 present の状態を得ることはできません。また .past.length.future.length をつけることで、Undo と Redo が可能かどうかそれぞれ確認することもできるでしょう。

もしかしたら Redux が Elm Architecture の影響を受けていることを聞いたことがあるかもしれません。この例が elm-undo-redo package に非常に似通っているのも、驚くことではありません。

Using Redux Undo

(以下省略。Redux Undoというパッケージの使用例が続く)

3
4
0

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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?