Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
Implementación del Historial de Deshacer
- Completar el tutorial «Conceptos básicos de Redux»
- Comprensión de la «composición de reductores»
Implementar funcionalidad de Deshacer y Rehacer en una aplicación tradicionalmente ha requerido un esfuerzo consciente del desarrollador. Con frameworks MVC clásicos no es sencillo porque debes rastrear cada estado anterior clonando todos los modelos relevantes. Además, necesitas gestionar la pila de deshacer cuidadosamente, pues los cambios del usuario deben poder revertirse.
Esto significa que implementar Deshacer/Rehacer en MVC normalmente fuerza a reescribir partes de la aplicación usando patrones específicos de mutación como Command.
Con Redux, sin embargo, implementar historial de deshacer es extremadamente sencillo. Existen tres razones:
-
No hay múltiples modelos, solo un subárbol de estado que deseas rastrear.
-
El estado ya es inmutable y las mutaciones se describen como acciones discretas, lo que encaja con el modelo mental de la pila de deshacer.
-
La firma del reducer
(state, action) => statepermite implementar naturalmente "reducer enhancers" o "higher order reducers": funciones que toman tu reducer y lo mejoran con funcionalidad adicional manteniendo su firma. El historial de deshacer es un caso perfecto.
En la primera parte de esta guía, explicaremos los conceptos que permiten implementar Deshacer/Rehacer genéricamente.
En la segunda parte, mostraremos cómo usar el paquete Redux Undo que ofrece esta funcionalidad lista para usar.
Comprendiendo el Historial de Deshacer
Diseñando la Forma del Estado
El historial de deshacer también es parte del estado de tu app, sin motivo para tratarlo diferente. Independientemente de cómo cambie el estado, al implementar Deshacer/Rehacer quieres rastrear su historial en distintos momentos.
Por ejemplo, un contador podría tener este estado:
{
counter: 10
}
Para implementar Deshacer/Rehacer aquí, necesitamos almacenar más estado para responder:
-
¿Queda algo por deshacer/rehacer?
-
¿Cuál es el estado actual?
-
¿Qué estados pasados/futuros hay en la pila?
Es lógico que modifiquemos la forma del estado para responder esto:
{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
present: 10,
future: []
}
}
Si el usuario pulsa "Deshacer", queremos retroceder:
{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
present: 9,
future: [10]
}
}
Y retroceder más:
{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7],
present: 8,
future: [9, 10]
}
}
Al pulsar "Rehacer", avanzamos un paso hacia el futuro:
{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
present: 9,
future: [10]
}
}
Finalmente, si el usuario realiza una acción (ej. decrementar) en medio de la pila, descartaremos el futuro existente:
{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
present: 8,
future: []
}
}
Lo interesante es que da igual si la pila contiene números, strings, arrays u objetos: la estructura siempre será idéntica.
{
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 }
]
]
}
}
Generalmente, luce así:
{
past: Array<T>,
present: T,
future: Array<T>
}
También decidimos si mantener un único historial global:
{
past: [
{ counterA: 1, counterB: 1 },
{ counterA: 1, counterB: 0 },
{ counterA: 0, counterB: 0 }
],
present: { counterA: 2, counterB: 1 },
future: []
}
O múltiples historiales granulares para deshacer/rehacer acciones independientemente:
{
counterA: {
past: [1, 0],
present: 2,
future: []
},
counterB: {
past: [0],
present: 1,
future: []
}
}
Más adelante veremos cómo este enfoque nos permite elegir el nivel de detalle que necesitamos para Deshacer y Rehacer.
Diseño del algoritmo
Independientemente del tipo de datos específico, la estructura del estado del historial de deshacer es la misma:
{
past: Array<T>,
present: T,
future: Array<T>
}
Expliquemos el algoritmo para manipular esta estructura de estado. Definimos dos acciones para operar: UNDO (Deshacer) y REDO (Rehacer). En nuestro reducer manejaremos estas acciones con estos pasos:
Manejo de Deshacer (UNDO)
-
Eliminar el último elemento de
past. -
Establecer
presentcomo el elemento eliminado en el paso anterior. -
Insertar el antiguo estado
presental principio defuture.
Manejo de Rehacer (REDO)
-
Eliminar el primer elemento de
future. -
Establecer
presentcomo el elemento eliminado en el paso anterior. -
Insertar el antiguo estado
presental final depast.
Manejo de otras acciones
-
Insertar
presental final depast. -
Establecer
presentcomo el nuevo estado resultante de procesar la acción. -
Limpiar
future.
Primer intento: Escribir un reducer
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
}
}
Esta implementación no es práctica porque omite tres aspectos importantes:
-
¿De dónde obtenemos el estado inicial de
present? No parece que lo conozcamos de antemano. -
¿Dónde reaccionamos a acciones externas para guardar
presentenpast? -
¿Cómo delegamos realmente el control del estado
presenta un reducer personalizado?
Parece que el reducer no es la abstracción adecuada, pero estamos muy cerca.
Conozcamos los potenciadores de reducers
Quizás conozcas las funciones de orden superior. Si usas React, tal vez te suenen los componentes de orden superior. Esto es una variante del mismo patrón aplicado a reducers.
Un potenciador de reducers (o reducer de orden superior) es una función que toma un reducer y devuelve uno nuevo capaz de manejar nuevas acciones o mantener más estado, delegando el control al reducer interno para acciones que no comprende. No es un patrón nuevo: técnicamente, combineReducers() también es un potenciador porque toma reducers y devuelve uno nuevo.
Un potenciador de reducers sin funcionalidad se vería así:
function doNothingWith(reducer) {
return function (state, action) {
// Just call the passed reducer
return reducer(state, action)
}
}
Un potenciador que combina otros reducers podría verse así:
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
}, {})
}
}
Segundo intento: Escribir un potenciador de reducers
Ahora que entendemos mejor los potenciadores, vemos que undoable debería funcionar exactamente así:
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: []
}
}
}
}
Podemos envolver cualquier reducer con el potenciador undoable para enseñarle a reaccionar a acciones UNDO y 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'
})
Importante: debemos recordar acceder al estado actual con .present. También podemos verificar .past.length y .future.length para habilitar/deshabilitar los botones de Deshacer y Rehacer respectivamente.
Quizás hayas escuchado que Redux fue influenciado por la Arquitectura Elm. No debería sorprender que este ejemplo sea muy similar al paquete elm-undo-redo.
Usando Redux Undo
Esta información es muy útil, pero ¿no podríamos simplemente usar una librería en lugar de implementar undoable nosotros mismos? ¡Claro que sí! Conoce Redux Undo, una librería que proporciona funcionalidad sencilla de Deshacer y Rehacer para cualquier parte de tu árbol de estado de Redux.
En esta parte de la guía, aprenderás cómo hacer que la lógica de una pequeña app de "lista de tareas" sea reversible. Puedes encontrar el código completo en el ejemplo todos-with-undo incluido en Redux.
Instalación
Primero, debes ejecutar:
npm install redux-undo
Esto instala el paquete que proporciona el potenciador de reductores undoable.
Envolviendo el reductor
Necesitarás envolver tu reductor con la función undoable. Por ejemplo, si exportabas un reductor todos desde un archivo dedicado, ahora deberás exportar el resultado de llamar a undoable() con tu reductor:
reducers/todos.js
import undoable from 'redux-undo'
/* ... */
const todos = (state = [], action) => {
/* ... */
}
const undoableTodos = undoable(todos)
export default undoableTodos
Existen muchas otras opciones para configurar tu reductor reversible, como establecer el tipo de acción para Deshacer y Rehacer.
Nota que tu llamada a combineReducers() permanecerá igual, pero el reductor todos ahora hará referencia al reductor potenciado con 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
Puedes envolver uno o más reductores con undoable en cualquier nivel de la jerarquía de composición de reductores. Elegimos envolver todos en lugar del reductor combinado principal para que los cambios a visibilityFilter no se reflejen en el historial de deshacer.
Actualizando los selectores
Ahora la parte de todos del estado luce así:
{
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 }
]
]
}
}
Esto significa que debes acceder a tu estado con state.todos.present en lugar de solo state.todos:
containers/VisibleTodoList.js
const mapStateToProps = state => {
return {
todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
}
}
Añadir los botones
Ahora solo necesitas agregar los botones para las acciones de Deshacer y Rehacer.
Primero, crea un nuevo componente contenedor llamado UndoRedo para estos botones. No separaremos la parte presentacional porque es muy pequeña:
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>
)
Usarás connect() de React Redux para generar el componente contenedor. Para determinar si habilitar los botones de Deshacer y Rehacer, puedes verificar state.todos.past.length y state.todos.future.length. No necesitarás crear creadores de acciones para deshacer y rehacer porque Redux Undo ya los proporciona:
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
Ahora puedes añadir el componente UndoRedo al componente 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
¡Eso es todo! Ejecuta npm install y npm start en la carpeta del ejemplo y ¡pruébalo!
