メインコンテンツへスキップ

アンドゥ履歴の実装

アプリにアンドゥとリドゥの機能を組み込むには、従来、開発者が意識的に努力する必要がありました。関連するすべてのモデルを複製して、過去のすべての状態を追跡する必要があるため、古典的な MVC フレームワークでは簡単な問題ではありません。また、ユーザーが開始した変更はアンドゥ可能である必要があるため、アンドゥスタックに注意する必要があります。

つまり、MVC アプリケーションでアンドゥとリドゥを実装するには、通常、コマンドのような特定のデータミューテーションパターンを使用するようにアプリケーションの一部を書き換える必要があります。

ただし、Redux を使用すると、アンドゥ履歴の実装は簡単です。これには 3 つの理由があります

  • 複数のモデルはありません。追跡したい状態のサブツリーのみです。
  • 状態はすでにイミュータブルであり、ミューテーションはすでに離散的なアクションとして記述されています。これはアンドゥスタックのメンタルモデルに近いものです。
  • リデューサーの (state, action) => state シグネチャにより、一般的な「リデューサーエンハンサー」または「高階リデューサー」を実装するのが自然です。これらは、リデューサーを受け取り、そのシグネチャを保持しながら、追加の機能で拡張する関数です。アンドゥ履歴はまさにそのようなケースです。

このレシピの最初の部分では、アンドゥとリドゥを一般的な方法で実装できるようにする基本的な概念について説明します。

このレシピの 2 番目の部分では、この機能をすぐに利用できる Redux Undo パッケージの使用方法を示します。

demo of todos-with-undo

アンドゥ履歴の理解

状態の形状の設計

アンドゥ履歴もアプリの状態の一部であり、異なるアプローチをする必要はありません。時間の経過とともに変化する状態のタイプに関係なく、アンドゥとリドゥを実装する場合は、さまざまな時点でのこの状態の履歴を追跡する必要があります。

たとえば、カウンターアプリの状態の形状は次のようになります

{
counter: 10
}

このようなアプリでアンドゥとリドゥを実装する場合、次の質問に答えることができるよう、より多くの状態を保存する必要があります

  • アンドゥまたはリドゥするものはありますか?
  • 現在の状態は何ですか?
  • アンドゥスタック内の過去(および将来)の状態は何ですか?

これらの質問に答えるために、状態の形状を変更する必要があるのは妥当です

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

ここで、ユーザーが「アンドゥ」を押すと、過去に移動するように変更する必要があります

{
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]
}
}

ユーザーが「リドゥ」を押すと、将来に一歩戻りたいと考えています

{
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, 8, 9],
present: 8,
future: []
}
}

ここでの興味深い点は、数値、文字列、配列、またはオブジェクトのアンドゥスタックを保持するかどうかは関係ないということです。構造は常に同じになります

{
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: []
}

または、ユーザーがアクションを個別にアンドゥおよびリドゥできるように、多くの詳細な履歴

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

後で、私たちが取るアプローチによって、アンドゥとリドゥをどの程度詳細にする必要があるかを選択できる方法を見ていきます。

アルゴリズムの設計

特定のデータ型に関係なく、アンドゥ履歴の状態の形状は同じです

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

上記の状態の形状を操作するアルゴリズムについて説明しましょう。この状態を操作するために、2 つのアクション UNDOREDO を定義できます。リデューサーでは、これらのアクションを処理するために次の手順を実行します

アンドゥの処理

  • past から最後の要素を削除します。
  • 前のステップで削除した要素に present を設定します。
  • future先頭に古い present 状態を挿入します。

リドゥの処理

  • future から最初の要素を削除します。
  • 前のステップで削除した要素に present を設定します。
  • 古い present 状態を past末尾に挿入します。

その他のアクションの処理

  • presentpast の末尾に挿入します。
  • アクションの処理後、present を新しい状態に設定します。
  • future をクリアします。

最初の試み:リデューサーの作成

const initialState = {
past: [],
present: null, // (?) How do we initialize the 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:
// (?) How do we handle other actions?
return state
}
}

この実装は、3 つの重要な質問が除外されているため、使用できません

  • 最初の present 状態はどこから取得するのですか?事前にはわからないようです。
  • presentpast に保存するために、外部アクションにどこで反応しますか?
  • 実際には、カスタムリデューサーへの present 状態の制御をどのように委任しますか?

リデューサーは適切な抽象化ではないようですが、非常に近いです。

リデューサーエンハンサーの紹介

高階関数に精通しているかもしれません。React を使用している場合は、高階コンポーネントに精通しているかもしれません。これは、リデューサーに適用される同じパターンのバリエーションです。

リデューサーエンハンサー(または高階リデューサー)は、リデューサーを受け取り、新しいアクションを処理できるか、より多くの状態を保持できる新しいリデューサーを返し、理解できないアクションの制御を内部リデューサーに委任する関数です。これは新しいパターンではありません。技術的には、combineReducers() もリデューサーを受け取って新しいリデューサーを返すため、リデューサーエンハンサーです。

何も行わないリデューサーエンハンサーは次のようになります

function doNothingWith(reducer) {
return function (state, action) {
// Just call the passed reducer
return reducer(state, action)
}
}

他のリデューサーを結合するリデューサーエンハンサーは次のようになります

function combineReducers(reducers) {
return function (state = {}, action) {
return Object.keys(reducers).reduce((nextState, key) => {
// Call every reducer with the part of the state it manages
nextState[key] = reducers[key](state[key], action)
return nextState
}, {})
}
}

2 回目の試み:リデューサーエンハンサーの作成

リデューサーエンハンサーについてより深く理解できたので、これはまさに undoable がそうであったはずであることがわかります

function undoable(reducer) {
// Call the reducer with empty action to populate the initial state
const initialState = {
past: [],
present: reducer(undefined, {}),
future: []
}

// Return a reducer that handles undo and redo
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:
// Delegate handling the action to the passed reducer
const newPresent = reducer(present, action)
if (present === newPresent) {
return state
}
return {
past: [...past, present],
present: newPresent,
future: []
}
}
}
}

これで、任意のリデューサーを undoable リデューサーエンハンサーにラップして、UNDO および REDO アクションに反応するように教えることができます。

// This is a reducer
function todos(state = [], action) {
/* ... */
}

// This is also a 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'
})

重要な注意点があります。現在の状態を取得するときは、.present を追加することを忘れないでください。また、.past.length.future.length をチェックして、アンドゥボタンとリドゥボタンをそれぞれ有効または無効にするかどうかを判断できます。

Redux が Elm Architecture の影響を受けているという話を聞いたことがあるかもしれません。この例が elm-undo-redo パッケージと非常によく似ていることは、驚くべきことではありません。

Redux Undo の使用

これはすべて非常に有益でしたが、undoable を自分で実装するのではなく、ライブラリをドロップして使用することはできないでしょうか?もちろんできます!Redux ツリーの任意の部分に対して単純なアンドゥとリドゥ機能を提供するライブラリである Redux Undo をご紹介します。

このレシピのこの部分では、小さな「Todo リスト」アプリのロジックをアンドゥ可能にする方法を学びます。このレシピの完全なソースは、Redux に付属している todos-with-undo の例にあります。

インストール

まず、次のコマンドを実行する必要があります

npm install redux-undo

これにより、undoable リデューサーエンハンサーを提供するパッケージがインストールされます。

リデューサーのラップ

undoable 関数で拡張したいリデューサーをラップする必要があります。たとえば、専用のファイルから todos リデューサーをエクスポートした場合、作成したリデューサーで undoable() を呼び出した結果をエクスポートするように変更します。

reducers/todos.js

import undoable from 'redux-undo'

/* ... */

const todos = (state = [], action) => {
/* ... */
}

const undoableTodos = undoable(todos)

export default undoableTodos

アンドゥおよびリドゥアクションのアクションタイプを設定するなど、アンドゥ可能リデューサーを構成するための他の多くのオプションがあります。

combineReducers() の呼び出しはまったく変更されないことに注意してください。ただし、todos リデューサーは Redux Undo で強化されたリデューサーを参照するようになります

reducers/index.js

import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'

const todoApp = combineReducers({
todos,
visibilityFilter
})

export default todoApp

リデューサー構成階層の任意レベルで、1 つ以上のリデューサーを undoable でラップできます。visibilityFilter の変更がアンドゥ履歴に反映されないように、トップレベルの結合リデューサーではなく todos をラップすることを選択しました。

セレクターの更新

これで、状態の todos 部分は次のようになります

{
visibilityFilter: 'SHOW_ALL',
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 }
]
]
}
}

つまり、state.todos だけでなく、state.todos.present を使用して状態にアクセスする必要があります

containers/VisibleTodoList.js

const mapStateToProps = state => {
return {
todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
}
}

ボタンの追加

必要なのは、アンドゥアクションとリドゥアクションのボタンを追加することだけです。

まず、これらのボタン用に UndoRedo という新しいコンテナコンポーネントを作成します。非常に小さいので、プレゼンテーション部分を別のファイルに分割する手間はかかりません

containers/UndoRedo.js

import React from 'react'

/* ... */

let UndoRedo = ({ canUndo, canRedo, onUndo, onRedo }) => (
<p>
<button onClick={onUndo} disabled={!canUndo}>
Undo
</button>
<button onClick={onRedo} disabled={!canRedo}>
Redo
</button>
</p>
)

React Reduxconnect() を使用して、コンテナコンポーネントを生成します。UndoおよびRedoボタンを有効にするかどうかを判断するには、state.todos.past.length および state.todos.future.length を確認できます。Redux Undoは既にそれらを提供しているため、undoとredoを実行するためのアクションクリエイターを作成する必要はありません。

containers/UndoRedo.js

/* ... */

import { ActionCreators as UndoActionCreators } from 'redux-undo'
import { connect } from 'react-redux'

/* ... */

const mapStateToProps = state => {
return {
canUndo: state.todos.past.length > 0,
canRedo: state.todos.future.length > 0
}
}

const mapDispatchToProps = dispatch => {
return {
onUndo: () => dispatch(UndoActionCreators.undo()),
onRedo: () => dispatch(UndoActionCreators.redo())
}
}

UndoRedo = connect(mapStateToProps, mapDispatchToProps)(UndoRedo)

export default UndoRedo

これで、UndoRedo コンポーネントを App コンポーネントに追加できます。

components/App.js

import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
import UndoRedo from '../containers/UndoRedo'

const App = () => (
<div>
<AddTodo />
<VisibleTodoList />
<Footer />
<UndoRedo />
</div>
)

export default App

以上です!exampleフォルダnpm installnpm start を実行して試してみてください!