状态管理是React中的一个基本概念,这一篇文章我们就来了解下组件状态是如何存储和更新的,这些知识对于创建复杂应用极为重要,我们需要透彻理解。
理解状态钩子(Understanding the State Hook)
我们已学习了State的用法,这里我们需要了解三点:
一,React更新 State是异步的。
比如我们在设置State语句后立即查看State,会发现并没有马上更新的。如下面的代码,点击按钮在控制台输出”false”,可见并没有立即更新为true。这是为了避免频繁重新渲染页面,在事件处理函数中,可能还有类似setName(‘kelemi’)等其他语句,一般是等事件处理函数结束后,才一并重新渲染。
function App() { const [isVisible, setVisible] = useState(false); const handleClick = () => { setVisible(true); console.log(isVisible); }; return ( <div> <button onClick={handleClick}>Show</button> </div> ); } export default App;
二,React的State实际上是存于组件外部的。
因为定义在组件里的变量只作用于该组件函数里,当重新渲染时,里面的变量将被重置,所以必须存在于外部,否则没法工作。当屏幕上长久不显示该组件时,存在于组件外部的state变量将被自动清除。
三,在组件的顶部使用State Hook。
如下,我们再定义了一个State Hook,内部命名为isApprove,而React实际是不管这个名字的,它记录的类似列表,它只知道有两个boolean值,分别为 false,true。在重新新渲染时,它根据顺序映射到组件内部的名称中,所以不能将State放在 if 语句、循环语句以及嵌套语句中,这样会破坏顺序。我们在使用中要将State定义在组件函数的开头处。
function App() { const [isVisible, setVisible] = useState(false); const [isApprove, setApprove] = useState(true); ... }
选择State结构
首先要避免冗余,看下面的State Hook,我们定义了firstName和lastName,就没必要再定义一个名为fullName的State Hook,因为我们完全可以由firstName和lastName组成fullName。
function App() { const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const fullName = firstName + " " + lastName; return <div>{fullName}</div>; }
其次,我们可以将相关联的State组合在一起,比如我们可以将firstName和lastName组合起来形成person。
function App() { const [person, setPerson] = useState({ firstName: "", lastName: "", }); const [isLoading, setLoading] = useState(false); ... }
另外,我们不能让State Hook的结构层次太深,多层嵌套。比如下面这样就不好。尽量保持扁平结构。
... const [person,setPerson] = useState({ firstName:'', lastName:'', contact:{ address:{ street:'', } } }) ...
小结下State结构的最佳实践如下。
保持组件纯度
什么是纯度?它是计算机科学的一个基本概念。纯的函数是指给它同样的输入,总是输出一样的结果。如果同样的输入在不同的时间点输出是不一样的,那该函数就是不纯的。
React围绕着这个概念进行,当提供的输入props是一样时,期望输出也是一样的,也就可以不用重新渲染。
那如何保持组件的纯度呢?
将更改放在渲染阶段之外!
我们来看一下这是什么意思?
我们有Message组件,代码Message.tsx:
let count = 0; const Message = () => { return <div>Message {count}</div>; }; export default Message;
然后在App.tsx中使用3个Message组件:
import Message from "./Message"; function App() { return ( <div> <Message /> <Message /> <Message /> </div> ); } export default App;
输出的三个组件是一致的,说明组件是纯的,如下:
但要是在 Message.tsx里的渲染代码中添加修改代码”count++”,如下:
let count = 0; const Message = () => { count++; return <div>Message {count}</div>; ...
输出的各个Message就不一样了,这样组件就不纯了。
所以在组件内,不要修改已存在的对象,这样才能保持组件是纯的。另外注意,如果创建和更新对象均放在组件内部实现,这是不影响的。比如我们将count的初始化也放在Message组件里就可以。
const Message = () => { let count = 0; count++; return <div>Message {count}</div>; };
理解Strict模式
前面演示组件的示例中,你是否注意到不纯的显示是Message 2、Message 4 以及Message 6,而不是期望的1、2、3,你知道为什么吗?
这跟React的strict模式有关。main.tsx中,App组件包含在 React.StrictMode中,这是React中内置的一个组件,不显示具体内容,而用于发现一些潜在的问题,其中之一就是检查组件是否是纯的。
在开发模式下,StrictMode执行2次,第1次用于检查,第2次用于实际渲染,所以我们看到的是2、4、6。
为了更清楚看到这个过程,我们在APP组件只留一个Message组件,同时修改Message.tsx,通过在控制台打印出相关信息:
let count = 0; const Message = () => { console.log("Message called", count); count++; return <div>Message {count}</div>; ...
查看控制台,看到执行了2次,第2行灰色的表示是strict模式输出。
另外,需要说明的是,在React 18下,默认Strict模式是开启的,也就是执行二次,主要用于发现组件是否不纯等问题,比如我们希望是1,结果输出是2,我们就能知道该组件不纯。而且,只在开发环境中strict模式才生效,当我们部署在生产环境时,Strict是关闭的,也就是只执行一次。
更新对象
前面我们说过,关联的State可以组成对象,我们在App组件添加名为drink的State。对于State,我们要把它看成是不变的或只读的,不要尝试改变它,这样是不会正确工作的。代码里,我们在击点处理事件里,修改了drink,但在页面上我们点击按钮时,不会有任何变化,说明这是不工作的。
function App() { const [drink, setDrink] = useState({ title: "Americano", price: 5, }); const handleClick = () => { drink.price = 6; setDrink(drink); }; return ( <div> {drink.price} <button onClick={handleClick}>Click Me</button> </div> ); }
我们在handleClick中需要定义一个新对象newDrink,然后再调用setDrink,如下:
const handleClick = () => { const newDrink = { title: drink.title, price: 6, }; setDrink(newDrink); };
当State对象有很多属性时,我们可以使用对象复制(3个省略号)来处理。
const handleClick = () => { setDrink({ ...drink, price: 6 }); };
更新嵌套对象
我们看一个稍复杂的State Hook,customer有两个属性: name和address,其中address也是对象且有两属性city和zipCode。click事件的工作就是更改zipCode。我们的代码是先复制整个customer,然后需要再复制customer.address。如果不复制customer.address的话,新建的customer对象与原来的customer指向了同一个address,这样是不行的,setCustomer的新建customer不要与原有的customer有任何关联。
function App() { const [customer, setCustomer] = useState({ name: "kelemi", address: { city: "San Francisco", zipCode: 94111, }, }); const handleClick = () => { setCustomer({ ...customer, address: { ...customer.address, zipCode: 94112 }, }); }; ...
我们看到,嵌套的StateHook更改会比较复杂。一般情况,尽量保持State Hook扁平。
更新数组
数组作为State Hook也类似,需要新的数组赋于。增删改如下。
function App() { const [tags, setTags] = useState(["happy", "cheerful"]); const handleClick = () => { //添加,不能在原来基础上使用tag.push(''),而是要先复制 setTags([...tags, "exciting"]); //删除 setTags(tags.filter((tag) => tag !== "happy")); //更新 setTags(tags.map((tag) => (tag === "happy" ? "happiness" : tag))); }; ...
更新对象数组
上一节说过数组更新用map,对象数组也不例会。下面的代码有个bug列表,点击按钮修改id为1的bug的fixed为true.
function App() { const [bugs, setBugs] = useState([ { id: 1, title: "Bug 1", fixed: false }, { id: 2, title: "Bug 2", fixed: false }, ]); const handleClick = () => { setBugs(bugs.map((bug) => (bug.id === 1 ? { ...bug, fixed: true } : bug))); }; ...
我们可视化上述的代码。B1和B2是原有数组元素,B1*是新建的,而B2则就是原有数组元素,我们不必全部新建所有数组元素,只需新建要更新的元素。
使用Immer简化更新逻辑
我们看到,更新数组和对象有些麻烦,我们可以使用Immer简化它。首先安装Immer.
npm i immer@9.0.19
再使用immer简化更新逻辑。首先导入produce,然后在setBugs中将produce函数作为参数,注意produce函数的参数 draft,它表示原来的State,在这里就相当于已存在的tags的副本,然后就可以像普通Javascript一样修改这个副本,Immer会自动设置好修改后的State.
import produce from "immer"; function App() { ... const handleClick = () => { setBugs( produce((draft) => { const bug = draft.find((bug) => bug.id === 1); if (bug) bug.fixed = true; }) ); }; ...
我们来验证下是否生效。
... return ( <div> {bugs.map((bug) => ( <p key={bug.id}> {bug.title} {bug.fixed ? "Fixed" : "New"} </p> ))} <button onClick={handleClick}>Click Me</button> </div> ); ...
当我们点击按钮时,Bug 1 就变成了 Fixed了,与我们期望的一致。
组件间共享State
想象一个电子商务网站,导航栏组件显示购物车货物的数量,而购物车组件列出具体的购物清单,显示,这两个组件需要共享State。
如何共享State呢?
查看组件树,NavBar和Cart组件有共同的父组件App,我们可以将购物车清单这个State提升到App组件,App组件再通props传给2个子组件,这样就实现了State共享。
看下代码实现。先创建NavBar组件(快捷键rafce可以方便生成模板)。NavBar组件传递cartItemsCount 的 props,用于指示购物里清单数量。
import React from "react"; interface Props { cartItemsCount: number; } const NavBar = ({ cartItemsCount }: Props) => { return <div>NavBar:{cartItemsCount}</div>; }; export default NavBar;
再创建Cart组件。定义props两个属性,一个是购物车清单cartItems,另一个是清除购物车操作,清除操作也要升至由父组App组件处理。
import React from "react"; interface Props { cartItems: string[]; onClear: () => void; } const Cart = ({ cartItems, onClear }: Props) => { return ( <> <div>Cart</div> <ul> {cartItems.map((item) => ( <li key={item}>{item}</li> ))} </ul> <button onClick={onClear}>Clear</button> </> ); }; export default Cart;
现来看App组件。很简单,一目了然。
import { useState } from "react"; import NavBar from "./NavBar"; import Cart from "./Cart"; function App() { const [cartItems, setCartItems] = useState(["Product1", "Product2"]); return ( <div> <NavBar cartItemsCount={cartItems.length} /> <Cart cartItems={cartItems} onClear={() => setCartItems([])} /> </div> ); } export default App;
更新State-练习1
我们有个State为game, 当我们点击按扭时,更改game的player的name.
function App() { const [game, setGame] = useState({ id: 1, player: { name: "John", }, }); ...
答案:
... const handleClick = () => { setGame({ ...game, player: { ...game.player, name: "Bob" } }); }; ...
我们也可以使用immer来简化逻辑,这里就不详写了。
更新State-练习2
我们有个State是pizza,当我们点击按钮时,添加配料Cheese.
function App() { const [pizza, setPizza] = useState({ name: "Spicy Pepperoni", toppings: ["Mushroom"], }); ...
答案:
... const handleClick = () => { setPizza({ ...pizza, toppings: [...pizza.toppings, "Cheese"] }); }; ...
更新State-练习3
更新cart,当点击按钮时,将id为1的protuct的quantity增加1。
function App() { const [cart, setCart] = useState({ discount: 1, items: [ { id: 1, title: "Product 1", quantity: 1 }, { id: 2, title: "Product 2", quantity: 1 }, ], });
答案:
... const handleClick = () => { setCart({ ...cart, items: cart.items.map((item) => item.id === 1 ? { ...item, quantity: item.quantity + 1 } : item ), }); }; ...
练习:创建可扩展文本组件
我们创建一个可扩展文件组件,可以指定显示前几个字符,点’more’按钮可以查看全部,点’less’又可以恢复缩略显示。
代码:
新建的ExpandableText.tsx:
import React, { useState } from "react"; interface Props { children: string; maxChars?: number; } const ExpandableText = ({ children, maxChars = 100 }: Props) => { const [isExpanded, setExpanded] = useState(false); if (children.length <= maxChars) return <p>{children}</p>; const text = isExpanded ? children : children.substring(0, maxChars); return ( <p> {text}... <button onClick={() => setExpanded(!isExpanded)}> {isExpanded ? "Less" : "More"} </button> </p> ); }; export default ExpandableText;
App.tsx:
import ExpandableText from "./ExpandableText"; function App() { return ( <div> <ExpandableText>lorem100</ExpandableText> </div> ); } export default App;
说明:
lorem会自动随机生成文件,100表示100个随机单词,键入时就会随机生成。maxChars是可选的,默认为100。
有人可能会好奇为什么在ExpandableText里没用将text作为StateHook,正如前面我们说到过的,fullName没有必要使用StateHook存储一样,它可能根据其他生成,text也是同样道理。存储在StateHook里的只是那些随时间会变化,而且变化会导致重新渲染的变量,在我们这个例子里,只有 isExpanded需要存在StateHook里。
效果如下:
小结
状态管理是React中的一个基本概念,本文我们了解了组件状态的存储和更新相关知识。下一篇介绍表单的创建。