メインコンテンツへスキップ
非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

元に戻す履歴の実装

アプリに元に戻す(Undo)とやり直す(Redo)機能を実装するには、従来は開発者が意識的な努力を必要としていました。古典的なMVCフレームワークでは、関連するすべてのモデルをクローンして過去の状態を追跡する必要があるため、これは容易な問題ではありません。さらに、ユーザーが開始した変更は元に戻せるべきであるため、元に戻すスタックに注意を払う必要があります。

これは、MVCアプリケーションで元に戻す/やり直すを実装する場合、通常はCommandパターンのような特定のデータ変更パターンを使用するためにアプリケーションの一部を書き直すことを強いることを意味します。

しかしReduxでは、元に戻す履歴の実装は非常に簡単です。これには3つの理由があります:

  • 複数のモデルが存在せず、追跡したいのは状態のサブツリーだけである

  • 状態はすでにイミュータブルであり、変更は個別のアクションとして記述されるため、元に戻すスタックのメンタルモデルに近い

  • リデューサーの (state, action) => state シグネチャにより、汎用的な「リデューサーエンハンサー」や「高階リデューサー」を自然に実装できます。これらは既存のリデューサーを受け取り、そのシグネチャを保ちながら追加機能を強化する関数です。元に戻す履歴はまさにこのケースに当てはまります。

このレシピの最初の部分では、元に戻す/やり直すを汎用的に実装可能にする基礎概念について説明します。

第二部では、この機能をすぐに利用できるRedux Undoパッケージの使用方法を紹介します。

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

ユーザーが「やり直す」を押した場合、未来に1ステップ進みたい:

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

後ほど、このアプローチによってUndoとRedoの粒度を柔軟に選択できるようになることを確認します。

アルゴリズムの設計

データ型に関わらず、undo履歴の状態構造は常に同じです:

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

前述の状態構造を操作するアルゴリズムについて説明します。この状態に対して操作を行う2つのアクションUNDOREDOを定義します。リデューサーでは以下の手順でこれらのアクションを処理します:

Undoの処理

  • pastから最後の要素を削除する

  • 削除した要素を新しいpresent状態に設定する

  • 古いpresent状態をfutureの先頭に挿入する

Redoの処理

  • futureから最初の要素を削除する

  • 削除した要素を新しいpresent状態に設定する

  • 古いpresent状態をpastの末尾に挿入する

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

  • 現在のpresent状態をpastの末尾に追加する

  • アクション処理後の新しい状態を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
}, {})
}
}

第二試行:リデューサーエンハンサーの作成

リデューサーエンハンサーについて理解した今、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リデューサーエンハンサーでラップし、UNDOREDOアクションに対応させることができます。

// 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を付加する必要があります。またUndoボタンとRedoボタンの有効/無効を判定するには、それぞれ.past.length.future.lengthをチェックします。

Redux が Elm Architecture の影響を受けていることはご存知かもしれません。この例が elm-undo-redo パッケージ と非常によく似ているのも当然のことでしょう。

Redux Undo の使用

これまでの説明は非常に有益でしたが、自分で undoable を実装する代わりにライブラリを導入して使うことはできないでしょうか?もちろん可能です!Redux Undo は、Redux ツリーの任意の部分に簡単な Undo/Redo 機能を提供するライブラリです。

このレシピのパートでは、小さな「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

設定オプション も多数用意されており、Undo/Redo アクションのタイプ設定などが可能です。

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 でラップできます。トップレベルの結合リデューサーではなく todos をラップすることで、visibilityFilter への変更が元に戻す履歴に反映されないようにしています。

セレクターの更新

これで 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)
}
}

ボタンの追加

あとは Undo と Redo のアクション用ボタンを追加するだけです。

まず、これらのボタン用に 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.lengthstate.todos.future.length をチェックすることで判定できます。Undo/Redo 実行用のアクションクリエイターを自作する必要はありません。Redux Undo が既に提供しているからです:

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

これで完了です!サンプルフォルダnpm installnpm start を実行して動作を確認してください!