アンドゥ履歴の実装
アプリにアンドゥとリドゥの機能を組み込むには、従来、開発者が意識的に努力する必要がありました。関連するすべてのモデルを複製して、過去のすべての状態を追跡する必要があるため、古典的な MVC フレームワークでは簡単な問題ではありません。また、ユーザーが開始した変更はアンドゥ可能である必要があるため、アンドゥスタックに注意する必要があります。
つまり、MVC アプリケーションでアンドゥとリドゥを実装するには、通常、コマンドのような特定のデータミューテーションパターンを使用するようにアプリケーションの一部を書き換える必要があります。
ただし、Redux を使用すると、アンドゥ履歴の実装は簡単です。これには 3 つの理由があります
- 複数のモデルはありません。追跡したい状態のサブツリーのみです。
- 状態はすでにイミュータブルであり、ミューテーションはすでに離散的なアクションとして記述されています。これはアンドゥスタックのメンタルモデルに近いものです。
- リデューサーの
(state, action) => state
シグネチャにより、一般的な「リデューサーエンハンサー」または「高階リデューサー」を実装するのが自然です。これらは、リデューサーを受け取り、そのシグネチャを保持しながら、追加の機能で拡張する関数です。アンドゥ履歴はまさにそのようなケースです。
このレシピの最初の部分では、アンドゥとリドゥを一般的な方法で実装できるようにする基本的な概念について説明します。
このレシピの 2 番目の部分では、この機能をすぐに利用できる Redux 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 つのアクション UNDO
と REDO
を定義できます。リデューサーでは、これらのアクションを処理するために次の手順を実行します
アンドゥの処理
past
から最後の要素を削除します。- 前のステップで削除した要素に
present
を設定します。 future
の先頭に古いpresent
状態を挿入します。
リドゥの処理
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
状態はどこから取得するのですか?事前にはわからないようです。 present
をpast
に保存するために、外部アクションにどこで反応しますか?- 実際には、カスタムリデューサーへの
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 Redux の connect()
を使用して、コンテナコンポーネントを生成します。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 install
と npm start
を実行して試してみてください!