重学React(三):状态管理
背景: 继续跟着官网的流程往后学,之前已经整理了描述UI以及添加交互两个模块,总体来说还是收获不小的,至少我一个表面上用了四五年React的前端小卡拉米对React的使用都有了新的认知。接下来就到了状态管理(React特地加了个中级的标签)的模块,那就一起学习吧~
前期回顾:
重学React(一):描述UI
重学React(二):添加交互
学习内容:
React官网教程:https://zh-hans.react.dev/learn/managing-state
其他辅助资料(看到再补充)
补充说明:这次学习更多的是以学习笔记的形式记录,看到哪记到哪
随着应用不断变大,更有意识的去关注应用状态如何组织,以及数据如何在组件之间流动会对你很有帮助。冗余或重复的状态往往是缺陷的根源。这里将学习如何组织好状态,如何保持状态更新逻辑的可维护性,以及如何跨组件共享状态。
用 State 响应输入
命令式UI编程:直接告诉计算机如何去更新 UI 的编程方式。比如打车时,你不告诉司机去哪,而是指挥他怎么走
声明式UI编程:只需要 声明你想要显示的内容, React 就会通过计算得出该如何去更新 UI。比如打车时你只需要告诉司机去哪,怎么走司机会自己规划
对一些小的独立的系统来说,命令式地控制用户页面也能起到不错的效果,比如你坐车从村口到家门口,有时候导航效果还不如口述清楚。但当系统变得复杂的时候,比如从北京到上海,还是让司机和导航发挥来的好。
接下来我们来看一个在前端特别经典的例子——表单填写。想象一个让用户提交答案的表单:
- 当你向表单输入数据时,“提交”按钮会随之变成可用状态
- 当你点击“提交”后,表单和提交按钮都会随之变成不可用状态,并且会加载动画会随之出现
- 如果网络请求成功,表单会随之隐藏,同时“提交成功”的信息会随之出现
- 如果网络请求失败,错误信息会随之出现,同时表单又变为可用状态
<form id="form"><h2>City quiz</h2><p>What city is located on two continents?</p><textarea id="textarea"></textarea><br /><button id="button" disabled>Submit</button><p id="loading" style="display: none">Loading...</p><p id="error" style="display: none; color: red;"></p>
</form>
<h1 id="success" style="display: none">That's right!</h1><style>
* { box-sizing: border-box; }
body { font-family: sans-serif; margin: 20px; padding: 0; }
</style>
async function handleFormSubmit(e) {e.preventDefault();disable(textarea);disable(button);show(loadingMessage);hide(errorMessage);try {await submitForm(textarea.value);show(successMessage);hide(form);} catch (err) {show(errorMessage);errorMessage.textContent = err.message;} finally {hide(loadingMessage);enable(textarea);enable(button);}
}function handleTextareaChange() {if (textarea.value.length === 0) {disable(button);} else {enable(button);}
}function hide(el) {el.style.display = 'none';
}function show(el) {el.style.display = '';
}function enable(el) {el.disabled = false;
}function disable(el) {el.disabled = true;
}function submitForm(answer) {// Pretend it's hitting the network.return new Promise((resolve, reject) => {setTimeout(() => {if (answer.toLowerCase() === 'istanbul') {resolve();} else {reject(new Error('Good guess but a wrong answer. Try again!'));}}, 1500);});
}let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;
这段代码可以实现表单的生成,但是能看到,这个逻辑会比较复杂,如果想要在这基础上添加一些交互或者新的UI元素,还得从头检查一下,避免新bug的产生。接下来我们按照React 声明式UI的思想来重新整理一下这个需求,你只需要完成以下几个步骤:
- 定位你的组件中不同的视图状态
- 确定是什么触发了这些 state 的改变
- 表示内存中的 state(需要使用 useState)
- 删除任何不必要的 state 变量
- 连接事件处理函数去设置 state
定位你的组件中不同的视图状态
总结一下,在这个表单需求里我们需要以下几个状态:
- 无数据:表单有一个不可用状态的“提交”按钮。
- 输入中:表单有一个可用状态的“提交”按钮。
- 提交中:表单完全处于不可用状态,加载动画出现。
- 成功时:显示“成功”的消息而非表单。
- 错误时:与输入状态类似,但会多错误的消息。
我们首先要做的就是用state去模拟这些状态
// app.js
import Form from './Form.js';
let statuses = ['empty','typing','submitting','success','error',
];export default function App() {return (<>{statuses.map(status => (<section key={status}><h4>Form ({status}):</h4><Form status={status} /></section>))}</>);
}// form.js
export default function Form({ status }) {if (status === 'success') {return <h1>That's right!</h1>}return (<form><textarea disabled={status === 'submitting'} /><br /><button disabled={status === 'empty' ||status === 'submitting'}>Submit</button>{status === 'error' &&<p className="Error">Good guess but a wrong answer. Try again!</p>}</form>);
}
确定是什么触发了这些 state 的改变
上面的代码把所有可能的状态罗列了一遍。现实中,这些状态应该都是互斥的,用户在页面中同时只能看到一个状态下的UI界面,总不可能这个表单提交既成功又失败吧(有些特定需求可能会有,但不在我们这次范围内哈),接下来是确定下有什么情况会触发state的更新:
- 人为输入:比如点击按钮、在表单中输入内容,或导航到链接。(typing,submitting都是人为输入才会触发,empty也可以人为删除完所有输入内容触发)
- 计算机输入:比如网络请求得到反馈、定时器被触发,或加载一张图片。(success,error可以根据计算机反馈表单提交成功与否展示)
通过 useState 表示内存中的 state
按照之前的想法,结合state的含义,只要我们用useState表示组件中这些状态,只要状态改变,视图自然会自动被更新(你只要告诉React现在要更新的是什么状态,怎么更新交给React就好)
// 既然不知道要如何表示,那就先列举出来,至少不遗漏某些状态
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
删除任何不必要的 state 变量
全部列举出来当然是有很大可优化空间的,优化结构的最主要目的是防止出现在内存中的 state 不代表任何你希望用户看到的有效 UI 的情况,比如empty状态和禁止输入不应该同时出现。
删除不必要的state变量可以尝试问自己这三个问题:
- 这个 state 是否会导致矛盾?
例如,isTyping 与 isSubmitting 的状态不能同时为 true。矛盾的产生通常说明了这个 state 没有足够的约束条件。两个布尔值有四种可能的组合,但是只有三种对应有效的状态。为了将“不可能”的状态移除,你可以将他们合并到一个 ‘status’ 中,它的值必须是 ‘typing’、‘submitting’ 以及 ‘success’ 这三个中的一个。 - 相同的信息是否已经在另一个 state 变量中存在?
另一个矛盾:isEmpty 和 isTyping 不能同时为 true。通过使它们成为独立的 state 变量,可能会导致它们不同步并导致 bug。幸运的是,你可以移除 isEmpty 转而用 message.length === 0。 - 你是否可以通过另一个 state 变量的相反值得到相同的信息?
isError 是多余的,因为你可以检查 error !== null。
这一系列检查下来,我们的state变量就可以改成这样:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
还能不能接着优化,当然可以,不过需要引入一个新的概念——reducer,我们后面再讲
连接事件处理函数以设置 state
state状态定义好之后,最后一步就是创建事件处理函数去设置 state 变量。这样就可以实现一个优雅又健壮的代码啦~
其实这个方案也还有一些优化空间,可以先留个记号,等全部看完再看更优解是啥
import { useState } from 'react';export default function Form() {const [answer, setAnswer] = useState('');const [error, setError] = useState(null);const [status, setStatus] = useState('typing');if (status === 'success') {return <h1>That's right!</h1>}async function handleSubmit(e) {e.preventDefault();setStatus('submitting');try {await submitForm(answer);setStatus('success');} catch (err) {setStatus('typing');setError(err);}}function handleTextareaChange(e) {setAnswer(e.target.value);}return (<><h2>City quiz</h2><p>In which city is there a billboard that turns air into drinkable water?</p><form onSubmit={handleSubmit}><textareavalue={answer}onChange={handleTextareaChange}disabled={status === 'submitting'}/><br /><button disabled={answer.length === 0 ||status === 'submitting'}>Submit</button>{error !== null &&<p className="Error">{error.message}</p>}</form></>);
}function submitForm(answer) {// Pretend it's hitting the network.return new Promise((resolve, reject) => {setTimeout(() => {let shouldError = answer.toLowerCase() !== 'lima'if (shouldError) {reject(new Error('Good guess but a wrong answer. Try again!'));} else {resolve();}}, 1500);});
}
选择State结构
构建State的原则
当编写一个带有state的组件时,我们需要选择使用多少个 state 变量以及它们都是怎样的数据格式,选择的可能性很多,像之前的例子,我们可以每一个可能的变量都设置成state,但更加合理的构建能使代码更友好更健壮,最终目标是使 state 易于更新而不引入错误。“让你的状态尽可能简单,但不要过于简单”(这句是爱因斯坦说的)。接下来是一些构建State的原则:
- **合并关联的 state。**如果你总是同时更新两个或更多的 state 变量,请考虑将它们合并为一个单独的 state 变量。
- **避免互相矛盾的 state。**当 state 结构中存在多个相互矛盾或“不一致”的 state 时,你就可能为此会留下隐患。应尽量避免这种情况。
- **避免冗余的 state。**如果你能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应将这些信息放入该组件的 state 中。
- **避免重复的 state。**当同一数据在多个 state 变量之间或在多个嵌套对象中重复时,这会很难保持它们同步。应尽可能减少重复。
- **避免深度嵌套的 state。**深度分层的 state 更新起来不是很方便。如果可能的话,最好以扁平化方式构建 state。
合并关联的 state
// 一个简单的例子,如果两个变量总是一起变化,比如记录鼠标移动的位置
// 这个场景下鼠标移动的位置由x,y同时构成,合并两个变量比同时更新两个变量合理
const [x, setX] = useState(0);
const [y, setY] = useState(0);const [position, setPosition] = useState({ x: 0, y: 0 });// 但是要记住的是,如果只更新对象中其中一个数值,记得把另一个数值也带上
setPosition({ x: 100 }) // ❌ 这样会丢失y的值
setPosition({ ...position, x: 100 }) ✅
避免矛盾的 state
考虑之前的例子,其实在删除不必要的state变量时,就考虑了避免矛盾的state方法
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);// 其中isTyping,isSuccess和isError 都可以归纳成status
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
避免冗余的 state
还是同样的例子,我们可以发现,isError变量是多余的,因为我们在进行setError的时候,可以通过error是否为空判断isError
关键逻辑是:如果能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应该把这些信息放到该组件的 state 中
不要在 state 中镜像 props
有一个很常见的场景,是state 变量被初始化为 prop 值function Message({ messageColor }) {const [color, setColor] = useState(messageColor);
这个例子的问题在于,state 仅在第一次渲染期间初始化,如果父组件稍后传递不同的 messageColor 值(例如,将其从 ‘blue’ 更改为 ‘red’),则 color state 变量将不会更新
如果想要单纯把变量名缩短,直接使用常量声明就好const color = messageColor;
这种把prop的值作为state初始化值的场景,只适用于想要 忽略特定 props 属性的所有更新时。
避免重复的 state
请看下面的例子
import { useState } from 'react';const initialItems = [{ title: 'pretzels', id: 0 },{ title: 'crispy seaweed', id: 1 },{ title: 'granola bar', id: 2 },
];export default function Menu() {const [items, setItems] = useState(initialItems);const [selectedItem, setSelectedItem] = useState(items[0]);return (<><h2>What's your travel snack?</h2><ul>{items.map(item => (<li key={item.id}>{item.title}{' '}<button onClick={() => {setSelectedItem(item);}}>Choose</button></li>))}</ul><p>You picked {selectedItem.title}.</p></>);
}
这个代码的问题是什么呢,假设一下,如果我们先点击choose,然后修改当前选择的snack的名称,你会发现修改的名称没办法同步到selectedItem.title
中。在这个场景中,selectedItem这个对象是重复的,重复声明最容易出问题的原因是,每一次的更新,都需要确保每一个state都被同步。一个简单的解法是,id是一直不变的,所以我们只需要记住被选中的id,每次都从items中渲染对应id的最新值,这样就能避免多次同步更新的问题。
import { useState } from 'react';const initialItems = [{ title: 'pretzels', id: 0 },{ title: 'crispy seaweed', id: 1 },{ title: 'granola bar', id: 2 },
];export default function Menu() {const [items, setItems] = useState(initialItems);const [selectedId, setSelectedId] = useState(0);const selectedItem = items.find(item =>item.id === selectedId);function handleItemChange(id, e) {setItems(items.map(item => {if (item.id === id) {return {...item,title: e.target.value,};} else {return item;}}));}return (<><h2>What's your travel snack?</h2><ul>{items.map((item, index) => (<li key={item.id}><inputvalue={item.title}onChange={e => {handleItemChange(item.id, e)}}/>{' '}<button onClick={() => {setSelectedId(item.id);}}>Choose</button></li>))}</ul><p>You picked {selectedItem.title}.</p></>);
}
避免深度嵌套的 state
想象一个很大有多层嵌套的数据,比如我国的省市区GDP数据,如果想更新某个省某个市某个区的GDP,那需要一层一层嵌套的复制上去,会变得很麻烦。
如果 state 嵌套太深,难以轻松更新,可以考虑将其“扁平化”。以下是官方的一个例子,关键思想是:让每个节点的 place 作为数组保存 其子节点的 ID。然后存储一个节点 ID 与相应节点的映射关系。
import { useState } from 'react';
// 原始数据
export const initialTravelPlan = {0: {id: 0,title: '(Root)',childIds: [1, 42, 46],},1: {id: 1,title: 'Earth',childIds: [2, 10, 19, 26, 34]},2: {id: 2,title: 'Africa',childIds: [3, 4, 5, 6 , 7, 8, 9]}, 3: {id: 3,title: 'Botswana',childIds: []},4: {id: 4,title: 'Egypt',childIds: []},5: {id: 5,title: 'Kenya',childIds: []},6: {id: 6,title: 'Madagascar',childIds: []}, 7: {id: 7,title: 'Morocco',childIds: []},8: {id: 8,title: 'Nigeria',childIds: []},9: {id: 9,title: 'South Africa',childIds: []},10: {id: 10,title: 'Americas',childIds: [11, 12, 13, 14, 15, 16, 17, 18], },11: {id: 11,title: 'Argentina',childIds: []},12: {id: 12,title: 'Brazil',childIds: []},13: {id: 13,title: 'Barbados',childIds: []}, 14: {id: 14,title: 'Canada',childIds: []},15: {id: 15,title: 'Jamaica',childIds: []},16: {id: 16,title: 'Mexico',childIds: []},17: {id: 17,title: 'Trinidad and Tobago',childIds: []},18: {id: 18,title: 'Venezuela',childIds: []},19: {id: 19,title: 'Asia',childIds: [20, 21, 22, 23, 24, 25], },20: {id: 20,title: 'China',childIds: []},21: {id: 21,title: 'India',childIds: []},22: {id: 22,title: 'Singapore',childIds: []},23: {id: 23,title: 'South Korea',childIds: []},24: {id: 24,title: 'Thailand',childIds: []},25: {id: 25,title: 'Vietnam',childIds: []},26: {id: 26,title: 'Europe',childIds: [27, 28, 29, 30, 31, 32, 33], },27: {id: 27,title: 'Croatia',childIds: []},28: {id: 28,title: 'France',childIds: []},29: {id: 29,title: 'Germany',childIds: []},30: {id: 30,title: 'Italy',childIds: []},31: {id: 31,title: 'Portugal',childIds: []},32: {id: 32,title: 'Spain',childIds: []},33: {id: 33,title: 'Turkey',childIds: []},34: {id: 34,title: 'Oceania',childIds: [35, 36, 37, 38, 39, 40, 41], },35: {id: 35,title: 'Australia',childIds: []},36: {id: 36,title: 'Bora Bora (French Polynesia)',childIds: []},37: {id: 37,title: 'Easter Island (Chile)',childIds: []},38: {id: 38,title: 'Fiji',childIds: []},39: {id: 39,title: 'Hawaii (the USA)',childIds: []},40: {id: 40,title: 'New Zealand',childIds: []},41: {id: 41,title: 'Vanuatu',childIds: []},42: {id: 42,title: 'Moon',childIds: [43, 44, 45]},43: {id: 43,title: 'Rheita',childIds: []},44: {id: 44,title: 'Piccolomini',childIds: []},45: {id: 45,title: 'Tycho',childIds: []},46: {id: 46,title: 'Mars',childIds: [47, 48]},47: {id: 47,title: 'Corn Town',childIds: []},48: {id: 48,title: 'Green Hill',childIds: []}
};export default function TravelPlan() {const [plan, setPlan] = useState(initialTravelPlan);function handleComplete(parentId, childId) {const parent = plan[parentId];// 创建一个其父级地点的新版本// 但不包括子级 ID。const nextParent = {...parent,childIds: parent.childIds.filter(id => id !== childId)};// 更新根 state 对象...setPlan({...plan,// ...以便它拥有更新的父级。[parentId]: nextParent});}const root = plan[0];const planetIds = root.childIds;return (<><h2>Places to visit</h2><ol>{planetIds.map(id => (<PlaceTreekey={id}id={id}parentId={0}placesById={plan}onComplete={handleComplete}/>))}</ol></>);
}function PlaceTree({ id, parentId, placesById, onComplete }) {const place = placesById[id];const childIds = place.childIds;return (<li>{place.title}<button onClick={() => {onComplete(parentId, id);}}>Complete</button>{childIds.length > 0 &&<ol>{childIds.map(childId => (<PlaceTreekey={childId}id={childId}parentId={id}placesById={placesById}onComplete={onComplete}/>))}</ol>}</li>);
}
在组件间共享状态
有时候会存在两个组件的状态始终同步更改。要实现这一点,可以将相关 state 从这两个组件上移除,并把 state 放到它们的公共父级,再通过 props 将 state 传递给这两个组件。这被称为“状态提升”。
还是直接看例子:
import { useState } from 'react';function Panel({ title, children }) {const [isActive, setIsActive] = useState(false);return (<section className="panel"><h3>{title}</h3>{isActive ? (<p>{children}</p>) : (<button onClick={() => setIsActive(true)}>显示</button>)}</section>);
}export default function Accordion() {return (<><h2>哈萨克斯坦,阿拉木图</h2><Panel title="关于">阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。</Panel><Panel title="词源">这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中“苹果”的意思,经常被翻译成“苹果之乡”。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。</Panel></>);
}
这段代码本身是没有任何问题的,点击展开会打开当前的Panel。但是在实际的开发过程中,我们经常会遇到这样的需求:希望一次只能展开一个Panel,点击某个展开其余的内容就会自动收起。因为两个Panel之间的组件是相互不影响的,为了实现这个功能,最直接的方式就是将这个控制是否展开的state放到父组件中,由父组件传入props的方式来控制。
可以分成三步来进行代码改造:
- 从子组件中 移除 state 。
- 从父组件 传递 硬编码数据。
- 为共同的父组件添加 state ,并将其与事件处理函数一起向下传递。
从子组件中 移除 state
简单一句话,将子组件的state声明删除,isActive
改成从props传入。这样就可以通过父组件中传入的isActive控制Panel组件是否展开
从公共父组件传递硬编码数据
也是简单一句话,找到公共的父组件,把isActive
硬编码成true
或者false
,传入到Panel组件中,看看是否生效
为公共父组件添加状态
把硬编码改成状态。状态提升通常会改变原状态的数据存储类型。原本isActive
是个布尔值,但请记住需求是实现每次只能打开一个Panel,也就意味着需要记录当前打开的panel是哪个,实现方式其实也很简单,直接看代码吧
import { useState } from 'react';export default function Accordion() {
// 当 activeIndex 为 0 时,激活第一个面板,为 1 时,激活第二个面板const [activeIndex, setActiveIndex] = useState(0);// 在任意一个 Panel 中点击“显示”按钮都需要更改 Accordion 中的激活索引值// Accordion 组件需要显式允许 Panel 组件通过 将事件处理程序作为 prop 向下传递 来更改其状态,也就是这个onShow方法const onShow = (index)=>setActiveIndex(index)return (<><h2>哈萨克斯坦,阿拉木图</h2><Paneltitle="关于"isActive={activeIndex === 0}onShow={() =>onShow(0)}>阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。</Panel><Paneltitle="词源"isActive={activeIndex === 1}onShow={() => onShow(1)}>这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中“苹果”的意思,经常被翻译成“苹果之乡”。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。</Panel></>);
}function Panel({title,children,isActive,onShow
}) {return (<section className="panel"><h3>{title}</h3>{isActive ? (<p>{children}</p>) : (<button onClick={onShow}>显示</button>)}</section>);
}
如果还是有点不太明白的可以看看这个图解:
受控组件和非受控组件
通常把包含“不受控制”状态的组件称为“非受控组件”,例如,最开始带有 isActive 状态变量的 Panel 组件就是不受控制的,因为其父组件无法控制面板的激活状态
当组件中的重要信息是由 props 而不是其自身状态驱动时,就可以认为该组件是“受控组件”。最后带有 isActive 属性的 Panel 组件是由 Accordion 组件控制的
每个状态都对应唯一的数据源
在 React 应用中,很多组件都有自己的状态。有些组件的状态只和自己有关系,例如输入框。有些组件的状态则是从上层上层再上层传入的,例如,客户端路由库也是通过将当前路由存储在 React 状态中,利用 props 将状态层层传递下去来实现的。
对于每个独特的状态,都应该存在且只存在于一个指定的组件中作为 state。这一原则也被称为拥有 “可信单一数据源”。它并不意味着所有状态都存在一个地方——对每个状态来说,都需要一个特定的组件来保存这些状态信息。你应该 将状态提升 到公共父级,或 将状态传递 到需要它的子级中,而不是在组件之间复制共享的状态
可信单一数据源: 在系统中,某类数据只在一个地方进行维护和存储,这个地方被视为该数据的唯一真实、可信来源。所有其他使用该数据的地方都必须从这个数据源中读取,而不能自行复制或维护一份副本。
状态管理内容实在太多,剩下的我们下次再来~