使用撤销和重置功能增强您的 React 应用
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
在Medium上找到我
你是否曾经在开发过程中犯错,并希望有撤销功能?或者重置功能?
幸运的是,我们使用的软件通常都具备撤销或重置功能。我指的是VS Code中的Ctrl + Z,或者 90 年代常见的表单中的重置按钮。
我们为什么需要撤销功能呢?因为人总是会犯错。无论是打字错误还是文章用词不当,我们都需要某种方法来撤销操作。但仔细想想,几乎在任何地方都有撤销的方法。铅笔有橡皮擦,手机可以拆开,用户可以重置密码,可擦笔可以擦掉墨迹——这样的例子不胜枚举。
但是,作为应用程序的开发者,如果要实现撤销或重置功能,该如何着手呢?应该从哪里开始?应该在哪里寻求建议?
别再犹豫了,我这就来教你如何为你的应用程序添加撤销和重置功能!你会发现,这篇文章讲解起来并不难,你也可以做到。
我们将构建一个用户界面,用户可以在其中通过姓名和性别添加好友。添加好友后,屏幕上会显示好友的注册信息卡片。此外,如果好友是女性,卡片上会显示亮粉色边框;如果是男性,则会显示青色边框。如果用户在注册好友时出错,可以选择撤销操作或将整个界面重置为初始状态。最后,用户还可以更改界面主题颜色,例如喜欢深色还是浅色。
效果大概是这样的:
光
黑暗的
事不宜迟,我们开始吧!
在本教程中,我们将使用 create-react-app 快速生成一个 React 项目。
(如果您想从 GitHub 获取存储库的副本,请点击此处)。
请使用以下命令创建一个项目。在本教程中,我将把我们的项目命名为undo-reset。
npx create-react-app undo-reset
完成后,进入该目录:
cd undo-reset
src/index.js我们要稍微清理一下主入口内部:
src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import './styles.css'
import * as serviceWorker from './serviceWorker'
ReactDOM.render(<App />, document.getElementById('root'))
serviceWorker.unregister()
以下是几种初始样式:
src/styles.css
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
现在创建src/App.js。这将渲染我们在整个教程中将要构建的所有组件:
src/App.js
import React, { useState } from 'react'
const App = () => {
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
return <div />
}
export default App
由于我们将允许用户添加好友并指定姓名和性别,我们定义了一些 React Hook 来保存输入值,我们还将定义更新这些值的方法。
接下来,我们将实现钩子将要附加到的元素和输入字段:
src/App.js
const App = () => {
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
return (
<div>
<form className="form">
<div>
<input
onChange={onNameChange}
value={name}
type="text"
name="name"
placeholder="Friend's Name"
/>
</div>
<div>
<select onChange={onGenderChange} name="gender" value={gender}>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<button type="submit">Add</button>
</div>
</form>
</div>
)
}
src/styles.css
form {
display: flex;
align-items: center;
}
form > div {
margin: auto 3px;
}
input,
select {
transition: all 0.15s ease-out;
border: 1px solid #ddd;
padding: 10px 14px;
outline: none;
font-size: 14px;
color: #666;
}
input:hover,
select:hover {
border: 1px solid #c6279f;
}
select {
cursor: pointer;
padding-top: 9px;
padding-bottom: 9px;
}
button {
transition: all 0.15s ease-out;
background: #145269;
border: 1px solid #ddd;
padding: 10px 35px;
outline: none;
cursor: pointer;
color: #fff;
}
button:hover {
color: #145269;
background: #fff;
border: 1px solid #145269;
}
button:active {
background: rgb(27, 71, 110);
border: 1px solid #a1a1a1;
color: #fff;
}
我并不喜欢教程界面过于简洁——毕竟,我很珍惜您阅读我文章的时间,所以我花了一些心思在样式上,希望能让您不会感到无聊 :)
接下来,我们需要一个可靠的地方来放置撤销和重置逻辑,因此我们将创建一个自定义钩子来处理状态更新:
src/useApp.js
const useApp = () => {
const onSubmit = (e) => {
e.preventDefault()
console.log('Submitted')
}
return {
onSubmit,
}
}
export default useApp
上面的onSubmit事件将被传递到我们之前定义的表单中,以便在用户提交好友信息时将好友添加到好友列表中:
src/App.js
import React, { useState } from 'react'
import useApp from './useApp'
const App = () => {
const { onSubmit } = useApp()
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
return (
<div>
<form className="form" onSubmit={onSubmit({ name, gender })}>
<div>
<input
onChange={onNameChange}
value={name}
type="text"
name="name"
placeholder="Friend's Name"
/>
</div>
<div>
<select onChange={onGenderChange} name="gender" value={gender}>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<button type="submit">Add</button>
</div>
</form>
</div>
)
}
export default App
需要注意的是,onSubmit 函数会接收字段参数作为参数。回顾一下我们的onSubmit处理程序,它并不是一个高阶函数。这意味着它会在组件挂载时立即被调用,因此我们需要将 onSubmit 处理程序转换为高阶函数,以避免这种情况,并使其能够接收字段值:
src/useApp.js
const useApp = () => {
const onSubmit = (friend) => (e) => {
e.preventDefault()
console.log(friend)
}
return {
onSubmit,
}
}
export default useApp
目前,我们有以下信息:
接下来我们将开始实现逻辑。但首先,我们需要定义状态结构:
src/useApp.js
const initialState = {
friends: [],
history: [],
}
本教程最重要的部分是历史记录。当用户提交操作时,我们会捕获应用程序的状态,并将其安全地存储在一个位置,以便稍后可以撤销用户操作。这个“存储”就是`state.history`,只有我们的自定义钩子需要知道它。不过,它也可以用于用户界面,实现一些有趣的功能——例如,允许用户通过网格查看之前的操作,并选择要返回到哪个操作。这是一个非常实用的小功能,可以给用户留下深刻印象!
接下来,我们将在 reducer 中添加 switch case,以便我们的状态能够真正更新:
src/useApp.js
const reducer = (state, action) => {
switch (action.type) {
case 'add-friend':
return {
...state,
friends: [...state.friends, action.friend],
history: [...state.history, state],
}
case 'undo': {
const isEmpty = !state.history.length
if (isEmpty) return state
return { ...state.history[state.history.length - 1] }
}
default:
return state
}
}
当我们发送类型为“add-friend”的action 时,我们会将新好友添加到列表中。但用户不知道的是,我们其实也在默默地保存他们之前的编辑记录。我们会捕获应用的最新状态并将其保存到history数组中。这样,如果用户想要恢复到之前的状态,我们就可以帮他们实现 :)
由于我们使用了 React Hook API,因此切记要从React中导入它。此外,我们还需要在自定义 Hook 中定义useReducer 的实现,以便获取该 API 来发送信号,从而更新本地状态:
src/useApp.js
import { useReducer } from 'react'
// ... further down inside the custom hook:
const [state, dispatch] = useReducer(reducer, initialState)
既然我们已经获得了这些 API,那就让我们把它们应用到需要的地方吧:
src/useApp.js
const onSubmit = (friend) => (e) => {
e.preventDefault()
if (!friend.name) return
dispatch({ type: 'add-friend', friend })
}
const undo = () => {
dispatch({ type: 'undo' })
}
这是我们目前为止设计的定制钩子的样子:
src/useApp.js
import { useReducer } from 'react'
const initialState = {
friends: [],
history: [],
}
const reducer = (state, action) => {
switch (action.type) {
case 'add-friend':
return {
...state,
friends: [...state.friends, action.friend],
history: [...state.history, state],
}
case 'undo': {
const isEmpty = !state.history.length
if (isEmpty) return state
return { ...state.history[state.history.length - 1] }
}
default:
return state
}
}
const useApp = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const onSubmit = (friend) => (e) => {
e.preventDefault()
if (!friend.name) return
dispatch({ type: 'add-friend', friend })
}
const undo = () => {
dispatch({ type: 'undo' })
}
return {
...state,
onSubmit,
undo,
}
}
export default useApp
接下来,我们需要渲染插入到state.friends中的好友列表,以便用户可以在界面中看到他们:
src/App.js
const App = () => {
const { onSubmit, friends } = useApp()
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
return (
<div>
<form className="form" onSubmit={onSubmit({ name, gender })}>
<div>
<input
onChange={onNameChange}
value={name}
type="text"
name="name"
placeholder="Friend's Name"
/>
</div>
<div>
<select onChange={onGenderChange} name="gender" value={gender}>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<button type="submit">Add</button>
</div>
</form>
<div className="boxes">
{friends.map(({ name, gender }, index) => (
<FriendBox key={`friend_${index}`} gender={gender}>
<div className="box-name">Name: {name}</div>
<div className="gender-container">
<img src={gender === 'Female' ? female : male} alt="" />
</div>
</FriendBox>
))}
</div>
</div>
)
}
如果你想知道这条奇怪的线是做什么用的:
<img src={gender === 'Female' ? female : male} alt="" />
实际上,我只是为了演示目的,在界面中轻松区分男性和女性,才提供了我自己的图片来渲染到img元素中。如果您需要这些图片,克隆此仓库的用户可以在src/images目录中找到它们 :)
我们在App.js的顶部导入女性/男性图像,然后在App组件的正上方定义一个FriendBox组件,该组件负责在用户向列表中添加好友时渲染好友框:
src/App.js
// At the top
import female from './images/female.jpg'
import male from './images/male.jpg'
// Somewhere above the App component
const FriendBox = ({ gender, ...props }) => (
<div
className={cx('box', {
'teal-border': gender === 'Male',
'hotpink-border': gender === 'Female',
})}
{...props}
/>
)
为了从视觉角度进一步区分女性和男性,我还添加了代表各自的基本风格:
src/styles.css
.teal-border {
border: 1px solid #467b8f;
}
.hotpink-border {
border: 1px solid #c1247d;
}
以下是目前App.js文件的内容:
src/App.js
import React, { useState } from 'react'
import cx from 'classnames'
import female from './images/female.jpg'
import male from './images/male.jpg'
import useApp from './useApp'
const FriendBox = ({ gender, ...props }) => (
<div
className={cx('box', {
'teal-border': gender === 'Male',
'hotpink-border': gender === 'Female',
})}
{...props}
/>
)
const App = () => {
const { onSubmit, friends } = useApp()
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
return (
<div>
<form className="form" onSubmit={onSubmit({ name, gender })}>
<div>
<input
onChange={onNameChange}
value={name}
type="text"
name="name"
placeholder="Friend's Name"
/>
</div>
<div>
<select onChange={onGenderChange} name="gender" value={gender}>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<button type="submit">Add</button>
</div>
</form>
<div className="boxes">
{friends.map(({ name, gender }, index) => (
<FriendBox key={`friend_${index}`} gender={gender}>
<div className="box-name">Name: {name}</div>
<div className="gender-container">
<img src={gender === 'Female' ? female : male} alt="" />
</div>
</FriendBox>
))}
</div>
</div>
)
}
export default App
这里使用的方框样式如下:
src/styles.css
.boxes {
margin: 10px 0;
padding: 3px;
display: grid;
grid-gap: 10px;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: 1fr;
}
.box {
font-size: 18px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.box-name {
display: flex;
align-items: center;
height: 50px;
}
.box.gender-container {
position: relative;
}
.box img {
object-fit: cover;
width: 100%;
height: 100%;
}
哎呀,糟糕!我们忘了添加撤销方法,这样我们就可以在界面中使用它了!现在把它从useApp中解构出来,放到撤销按钮上:
src/App.js
const App = () => {
const { onSubmit, friends, undo } = useApp()
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
const resetValues = () => {
setName('')
setGender('Male')
}
return (
<div>
<form className="form" onSubmit={onSubmit({ name, gender }, resetValues)}>
<div>
<input
onChange={onNameChange}
value={name}
type="text"
name="name"
placeholder="Friend's Name"
/>
</div>
<div>
<select onChange={onGenderChange} name="gender" value={gender}>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<button type="submit">Add</button>
</div>
</form>
<div className="undo-actions">
<div>
<button type="button" onClick={undo}>
Undo
</button>
</div>
</div>
<div className="boxes">
{friends.map(({ name, gender }, index) => (
<FriendBox key={`friend_${index}`} gender={gender}>
<div className="box-name">Name: {name}</div>
<div className="gender-container">
<img src={gender === 'Female' ? female : male} alt="" />
</div>
</FriendBox>
))}
</div>
</div>
)
}
现在,当用户点击“撤销”按钮时,应该恢复他们最后的操作!
一切都按计划进行。用户可以将好友添加到列表中,轻松地在界面上区分好友的性别,并撤销之前的提交操作。
……您是否也注意到App组件中现在新增了一个resetValues方法,它会作为第二个参数传递给onSubmit方法?用户可能会觉得有点奇怪,提交好友信息后,输入的内容不会被清除。他们还需要保留相同的姓名吗?除非他们有两三个同名好友,否则他们肯定会按下退格键手动清除。但作为开发者,我们有能力让用户的操作更便捷,所以我们实现了resetValues方法。
也就是说,由于我们已将其作为第二个参数传递给 UI 组件,因此应该将其声明为onSubmit 的第二个参数:
src/useApp.js
const useApp = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const onSubmit = (friend, resetValues) => (e) => {
e.preventDefault()
if (!friend.name) return
dispatch({ type: 'add-friend', friend })
resetValues()
}
const undo = () => {
dispatch({ type: 'undo' })
}
return {
...state,
onSubmit,
undo,
}
}
我们的撤销功能现在应该已经 100% 正常工作了,但我还要再进一步,让它变得更复杂一些,因为撤销功能可以与几乎任何东西兼容。
因此,我们将允许用户为界面指定主题颜色,这样他们就不会对白色感到厌倦:
src/useApp.js
const initialState = {
friends: [],
history: [],
theme: 'light',
}
src/useApp.js
const reducer = (state, action) => {
switch (action.type) {
case 'set-theme':
return { ...state, theme: action.theme, history: insertToHistory(state) }
case 'add-friend':
return {
...state,
friends: [...state.friends, action.friend],
history: insertToHistory(state),
}
case 'undo': {
const isEmpty = !state.history.length
if (isEmpty) return state
return { ...state.history[state.history.length - 1] }
}
case 'reset':
return { ...initialState, history: insertToHistory(state) }
default:
return state
}
}
此外,我还声明了一个insertToHistory工具,以便在将来我们为 state 参数传入奇怪的值时(如您在上面可能已经注意到的那样),能够带来额外的好处:
const insertToHistory = (state) => {
if (state && Array.isArray(state.history)) {
// Do not mutate
const newHistory = [...state.history]
newHistory.push(state)
return newHistory
}
console.warn(
'WARNING! The state was attempting capture but something went wrong. Please check if the state is controlled correctly.',
)
return state.history || []
}
我想补充一点,随着应用程序规模越来越大、功能越来越复杂,养成提前思考的习惯非常重要。
接下来继续主题实现,我们将定义一个UI组件可以利用的自定义方法:
src/useApp.js
const onThemeChange = (e) => {
dispatch({ type: 'set-theme', theme: e.target.value })
}
return {
...state,
onSubmit,
undo,
onThemeChange,
}
将主题组件和方法应用到界面:
src/App.js
import React, { useState } from 'react'
import cx from 'classnames'
import female from './images/female.jpg'
import male from './images/male.jpg'
import useApp from './useApp'
const FriendBox = ({ gender, ...props }) => (
<div
className={cx('box', {
'teal-border': gender === 'Male',
'hotpink-border': gender === 'Female',
})}
{...props}
/>
)
const App = () => {
const { onSubmit, friends, undo, theme, onThemeChange } = useApp()
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
const resetValues = () => {
setName('')
setGender('Male')
}
return (
<div>
<div>
<h3>What theme would you like to display?</h3>
<div>
<select onChange={onThemeChange} name="theme" value={theme}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<div>
<h3>Add a friend</h3>
<form
className="form"
onSubmit={onSubmit({ name, gender }, resetValues)}
>
<div>
<input
onChange={onNameChange}
value={name}
type="text"
name="name"
placeholder="Friend's Name"
/>
</div>
<div>
<select onChange={onGenderChange} name="gender" value={gender}>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<button type="submit">Add</button>
</div>
</form>
</div>
<div>
<h3>Made a mistake?</h3>
<div className="undo-actions">
<div>
<button type="button" onClick={undo}>
Undo
</button>
</div>
</div>
</div>
<div className="boxes">
{friends.map(({ name, gender }, index) => (
<FriendBox key={`friend_${index}`} gender={gender}>
<div className="box-name">Name: {name}</div>
<div className="gender-container">
<img src={gender === 'Female' ? female : male} alt="" />
</div>
</FriendBox>
))}
</div>
</div>
)
}
export default App
既然我们添加了主题更改功能,那么添加一些条件样式来适应这些更改可能也是个好主意,对吧?
<div className={cx({
'theme-light': theme === 'light',
'theme-dark': theme === 'dark',
})}
// ...rest of the component
以下是几种样式:
src/styles.css
.theme-light,
.theme-dark {
box-sizing: border-box;
transition: all 0.15s ease-out;
padding: 12px;
min-height: 100vh;
}
.theme-light {
color: #145269;
background: #fff;
}
.theme-dark {
color: #fff;
background: #0b2935;
}
太棒了!这就是我们界面现在能做到的!
为自己走到这一步而鼓掌吧!
不过我们还不要高兴得太早,因为本文标题还提到了界面重置功能。
现在我们就通过在现有的 reducer 中直接定义 switch case 来实现这一点:
src/useApp.js
const reducer = (state, action) => {
switch (action.type) {
case 'set-theme':
return { ...state, theme: action.theme, history: insertToHistory(state) }
case 'add-friend':
return {
...state,
friends: [...state.friends, action.friend],
history: insertToHistory(state),
}
case 'undo': {
const isEmpty = !state.history.length
if (isEmpty) return state
return { ...state.history[state.history.length - 1] }
}
case 'reset':
return { ...initialState, history: insertToHistory(state) }
default:
return state
}
}
当然,接下来就需要定义一个方法来通知 reducer 状态发生了变化。别忘了在 hook 的最后返回它!
src/useApp.js
const reset = () => {
dispatch({ type: 'reset' })
}
const onThemeChange = (e) => {
dispatch({ type: 'set-theme', theme: e.target.value })
}
return {
...state,
onSubmit,
onThemeChange,
undo,
reset,
}
从 UI 组件的钩子中解构它:
src/App.js
const { onSubmit, friends, undo, theme, onThemeChange, reset } = useApp()
src/App.js
<div>
<h3>Made a mistake?</h3>
<div className="undo-actions">
<div>
<button type="button" onClick={undo}>
Undo
</button>
</div>
<div>
<button type="button" onClick={reset}>
Reset
</button>
</div>
</div>
</div>
最后,也是非常重要的一点,是用于使这些操作水平对齐的样式:
src/styles.css
.undo-actions {
display: flex;
align-items: center;
}
.undo-actions > div {
margin: auto 3px;
}
结果:
你不觉得撤销功能也能捕捉到重置界面的操作这一点很棒吗?
如果您选择下载并克隆该存储库,您将看到如下所示的细微修改:
src/App.js
import React, { useState } from 'react'
import cx from 'classnames'
import useApp from './useApp'
import ThemeControl from './ThemeControl'
import AddFriend from './AddFriend'
import UndoResetControl from './UndoResetControl'
import Friends from './Friends'
import './styles.css'
const App = () => {
const { friends, theme, onSubmit, onThemeChange, undo, reset } = useApp()
const [name, setName] = useState('')
const [gender, setGender] = useState('Male')
const onNameChange = (e) => setName(e.target.value)
const onGenderChange = (e) => setGender(e.target.value)
const resetValues = () => {
setName('')
setGender('Male')
}
return (
<div
className={cx({
'theme-light': theme === 'light',
'theme-dark': theme === 'dark',
})}
>
<ThemeControl theme={theme} onChange={onThemeChange} />
<AddFriend
onSubmit={onSubmit({ name, gender }, resetValues)}
onNameChange={onNameChange}
onGenderChange={onGenderChange}
currentValues={{ name, gender }}
/>
<UndoResetControl undo={undo} reset={reset} />
<Friends friends={friends} />
</div>
)
}
export default App
代码本身没有变化,只是我把各个组件拆分到各自的文件中,使代码更易读、更易维护。
奖金
在本教程开头,我提到过一个可以向用户显示的界面——它允许用户选择如果需要可以恢复到应用程序的哪个先前状态。以下是一个使用示例:
结论
撤销操作对我们来说非常有用,因为我们人类总是会犯错……这是事实。希望这对你有所帮助 :)
下次再见!如果想继续阅读我的文章,可以关注我哦!
欢迎在Medium上关注我!
文章来源:https://dev.to/jsmanifest/enhance-your-react-app-with-undo-and-reset-powered-2l6h






