React入门之添加交互
React入门之添加交互
界面上的控件对随着用户的输入而更新。例如点击按钮切换轮播图的展示。在React中,随着时间变化的数据称为状态(state)。可以向任何组件添加状态,按需去进行更新。
响应事件
什么是响应事件
React允许我们在JSX中添加时间处理程序。事件处理程序是我们自己定义的函数。比如我们界面交互时:点击、悬停、焦点聚焦等交互事件。
我们在自己的组件中可以定义我们自己的事件处理程序。做法是往我们的组件时间处理程序props指定特定应用的名称。
1 | // 定义button组件 通过props接收onclick事件 |
事件处理函数
添加事件处理函数步骤:
- 首先定义一个函数组件
- 在函数组件中定义事件处理程序的函数,然后将其作为prop传入合适的JSX标签。
事件函数特点:
通常是在组件内部定义
名称
handle
开头,后面跟事件名称事件处理函数可以在JSX中有俩种定义方式:
- 内联事件处理函数(函数体比较短使用较为方便)
- 简洁函数
这时候看个例子:
1 | export default function Button() { |
事件处理函数传递的陷阱
在Vue中会出现事件绑定时,直接触发事件处理程序。在这里存在陷阱。
传递给事件处理函数的函数应直接传递,而非调用。看下面的例子:
传递一个函数(正确) | 调用一个函数(错误) |
---|---|
这样看来其实,区别很微妙。
左侧示例中handleClick
函数作为onClick
的事件处理函数传递。这个是告诉React这个事件是当用户点击按钮时才会触发函数。
右侧示例中handleClick()
中最后的()
会在渲染过程中立即触发函数,即使没有任何点击。这是因为JSX{
和}
之间的Javascript会立即执行。
当传入内联函数时,会出现不同的陷阱。
传递一个函数(正确) | 调用一个函数(错误) |
---|---|
<button onClick={() => alert(‘…’)}> | <button onClick={alert(‘…’)}> |
右侧的写法,将会导致组件渲染时,每次都触发。
左侧就是创建了一个稍后调用的函数,而不是在每次渲染时执行其内部的代码。
综上,就是想要定义内联函数事件处理函数,要将其包装在匿名函数中。
在事件处理函数中读取props
事件函数声明于组件内部,因此他们可以直接访问组件的props。
例如:事件处理函数就可以接收到message
1 | function AlertButton({ message, children }) { |
将事件处理函数作为props传递
通常,我们会在父组件中定义子组件的事件处理函数。为此将组件从父组件接收的prop作为事件处理函数传递。
1 | // 定义一个接收事件处理函数的子组件 |
命名事件处理函数prop
对于浏览器内置组件(<button>
和 <div>
),仅支持浏览器事件名称,例如,onclick。但是当我们构建自己的组件时,可以任意命名事件处理函数的prop。
当组件支持多种交互时,可以根据不同的应用程序命名事件处理函数props。
1 | // 这里onClick接收的还是浏览器内置的<button>(小写) |
事件传播
事件处理函数还将捕获来自任何子组件的事件。通常,我们会说事件沿着树向上“冒泡”或者“传播”:他从事件发生的地方开始,然后沿着树向上传播。
在React中所有的事件都会传播,除了onScroll,它仅适用于附加到的JSX标签中。
例如:
1 | // 当你点击button时,先触发他自身的onClick |
阻止传播
事件处理函数接收一个**事件对象作为唯一的参数。一般通常被称为e
,代表`event(事件)。这个可以使用此对象读取事件的有关信息。**
这个事件对象还允许阻止传播。例如:
当你点击按钮时:
- React 调用了传递给
<button>
的onClick
处理函数。 - 定义在
- 在
Toolbar
组件中定义的函数,显示按钮对应的 alert。 - 由于传播被阻止,父级
<div>
的onClick
处理函数不会执行。
由于调用了 e.stopPropagation()
,点击按钮现在将只显示一个 alert(来自 <button>
),而并非两个(分别来自 <button>
和父级 toolbar <div>
)。点击按钮与点击周围的 toolbar 不同,因此阻止传播对这个 UI 是有意义的。
1 | function Button({ onClick, children }) { |
拓展:
少数情况下,你可能需要捕获子元素上的所有事件,即便它们阻止了传播。例如,你可能想对每次点击进行埋点记录,传播逻辑暂且不论。那么你可以通过在事件名称末尾添加 Capture
来实现这一点:
onClickCapture捕获所有事件
1 | <div onClickCapture={() => { /* 这会首先执行 */ }}> |
每个事件分三个阶段传播:
- 它向下传播,调用所有的
onClickCapture
处理函数。 - 它执行被点击元素的
onClick
处理函数。 - 它向上传播,调用所有的
onClick
处理函数。
捕获事件对于路由或数据分析之类的代码很有用,但你可能不会在应用程序代码中使用它们。
传递处理函数作为事件传播的代替方案
看这一段代码
1 | function Button({ onClick, children }) { |
此处的点击事件处理函数先执行了一段代码,然后调用了父组件传递的 onClick
prop。
也可以在调用父元素onClick
函数之前,添加其他代码。此模式是事件传播的另一种 替代方案 。它让子组件处理事件,同时也让父组件指定一些额外的行为。与事件传播不同,它并非自动。但使用这种模式的好处是你可以清楚地追踪因某个事件的触发而执行的整条代码链。
如果你依赖于事件传播,而且很难追踪哪些处理程序在执行,及其执行的原因,可以尝试这种方法。
阻止默认行为
某些浏览器事件具有与事件相关联的默认行为。例如,点击 <form>
表单内部的按钮会触发表单提交事件,默认情况下将重新加载整个页面。
可以调用事件对象中的 e.preventDefault()
来阻止这种情况发生:
1 | export default function Signup() { |
不要混淆 e.stopPropagation()
和 e.preventDefault()
。它们都很有用,但二者并不相关:
e.stopPropagation()
阻止触发绑定在外层标签上的事件处理函数。e.preventDefault()
阻止少数事件的默认浏览器行为。
事件函数可以包含副作用吗
当然可以!事件处理函数是执行副作用的最佳位置。
与渲染函数不同,事件处理函数不需要是 纯函数,因此它是用来 更改 某些值的绝佳位置。例如,更改输入框的值以响应键入,或者更改列表以响应按钮的触发。但是,为了更改某些信息,你首先需要某种方式存储它。在 React 中,这是通过 state(组件的记忆) 来完成的。
state:组件的记忆
组件通常需要根据交互更改屏幕上显示的内容。在我们表单输入的时候应该更新字段、单机轮播图上的点击下一个应该更改的图片。组件需要“记住”这些东西:当前输入值、当前轮播图。在React中,这种组件持有的记忆被称为state
。
当使用普通变量时,事件处理函数会更新局部的变量,但是没有达到预期的效果。
原因有二:
- 局部变量无法在多次渲染中持久化保存。当React在此渲染这个组件时,他会使用事件处理函数中最初的值重新开始渲染,他不会考虑之前局部变量的任何修改。
- 更新局部变量不会触发渲染。React没有意识它需要去使用新数据渲染数组。
如何使用新数据更新组件?
需要做俩件事:
- 保留渲染之间的数据
- 触发React使用新数据渲染组件(重新渲染)
这时候就引出了,主人公:useState
Hook。
useState
Hook提供了俩个功能:
- State变量用于保存渲染间的数据。
- State setter函数更新变量并触发React再次渲染组件。
使用姿势(如何添加一个state变量)
现在顶部文件React导入
useState
1
import { useState } from 'react';
定义state变量
1
const [index, setIndex] = useState(0);
其中index为
State
变量,setIndex
是对应的setter
函数。这里的
[
和]
语法称为数组解构,它允许你从数组中读取值。useState
返回的数组总是正好有两项。
在React中,useState
以及其他以use
开头的函数都被称为Hook
Hook是特殊的函数,只在React渲染是有效。
注意:
Hooks,以use
开头的函数,只能在组件或者**自定义 Hook** 的最顶层调用。**不能在条件语句、循环语句或其他嵌套函数内调用 Hook。**Hook是函数。
深度剖析useState
当调用useState
时,是在告诉React你想让组件记住一些东西。
1 | const [index, setIndex] = useState(0); |
在这段代码中,我们希望React记住index。
useState
的唯一参数是state变量的初始值。这个例子中index
初始值被useState(0)
设置为0。
每当我们组件渲染时,useState
都会返回一个包含俩个值的数组:
- state变量(index)会保存上次渲染的值。
- state setter函数(setIndex)可以更新state变量并触发React重新渲染组件。
以下是具体的执行顺序:
- 组件进行第一次渲染。会将
index
初始值0传递给useState
,他就会返回[0,setIndex]
。这时候React会记住0
是最新的值。 - 更新了state。当用户点击按钮的时候,他会调用
setIndex(index+1)
。index初始值0
,所以就会变成setIndex(1)
。这将告诉React记住index
是1
触发下一次渲染。 - 组件进行二次渲染。React仍然看到
useState(0)
,但是这时候React记住了index
设置为1
,他将返回[1,setIndex]
。 - 后续渲染过程如此反复。
我们可以为一个组件赋予多个state变量。并且这些组件可以拥有任意的多种类型的state变量。
1 | import { useState } from 'react'; |
如果它们不相关,那么存在多个 state 变量是一个好主意,例如本例中的 index
和 showMore
。但是,如果你发现经常同时更改两个 state 变量,那么最好将它们合并为一个。例如,如果你有一个包含多个字段的表单,那么有一个值为对象的 state 变量比每个字段对应一个 state 变量更方便。 选择 state 结构在这方面有更多提示。
拓展:为什么React如何知道要返回哪个state??
React Hooks: not magic, just arrays
State是隔离且私有的
State是屏幕上组件实例内部的状态。也就是说每次渲染都会产生完全隔离的state副本!一个改变会会影响另一个。
这是因为state
与生命在模块顶部的普通变量不同的原因。State不依赖于特定的函数调用或在代码中的位置。他的作用域“只限于”屏幕上模块特定的区域。重复渲染组件,他们的state
是分开存储的。
state完全私有于声明他的组件。父组件无法更改它。
State 变量仅用于在组件重渲染时保存信息。在单个事件处理函数中,普通变量就足够了。当普通变量运行良好时,不要引入 state 变量。
渲染和提交
设想我们是一个厨师,把食材做成美味的菜肴。在这个场景下,React就是一个服务员。这种请求和提供UI的过程分为三步:
- 触发一次渲染(把客人的点单派发厨房)
- 渲染组件(厨房准备订单)
- 提交到DOM(将菜品放到桌子上)
步骤1:触发一次渲染
触发渲染的原因有二:
- 组件的初次渲染。
- 组件(或者其祖先之一)的状态发生了改变
初次渲染
当应用启动的时候,会触发初次渲染,框架和沙箱有时候会隐藏这段代码,但是它通过调用目标DOM节点的 createRoot
,然后组件调用render
函数完成的。
1 | import Image from './Image.js'; |
状态更新时重现渲染
一旦组件被初次渲染,我们就可以通过set
函数更新其状态来触发之后的渲染。更新组件的状态会自动将一次渲染送入队列。
步骤2:React渲染你的组件
在触发初次渲染之后,React会调用组件的来确定屏幕上渲染显示的内容。“渲染中”即React在调用你的组件。
- 在初次渲染时,React会调用根组件。
- 对于后续的渲染,React会调用内部状态更新触发了渲染的函数组件。
这个过程是递归的:如果更新的组件会返回某个另外组件,那么React接下来就会渲染那个组件,如果哪个组件又返回了某个组件,那么React接下来会渲染那个组件。以此类推,这个过程会持续下去,知道没有更多的嵌套组件并且React确定知道哪些东西应该显示到屏幕上为止。
我们看个例子,React将会调用Gallery()
和Image()
若干次。
1 | // Gallery组件 |
- 初次渲染中,React将会为
<section>
、<h1>
和三个<img>
标签创建 DOM 节点 - 在一次重渲染过程中,React将计算它们的那些属性(如果有的话)自上次渲染以来已经更改。再下一步(提交阶段)之前,他不会对这些信息执行任何操作。
注意:
渲染必须是一次 纯计算:
- 输入相同,输出相同。给定相同的输入,组件应该始终返回相同的JSX。就好比,食客点了西红柿沙拉,不应该收到洋葱沙拉!
- 只做它们自己的事情。他不应该更改更改任何存在于渲染之前的对象或者变量。就好比一个订单不应该更改其他任何人的订单。
否则,随着代码库复杂性的增加,可能会遇到令人困惑的错误和不可预测的行为。在“严格模式‘下开发时,React会调用每个组件函数俩次,这可以检测不纯函数引起的错误。
性能优化
如果更新的组件在树中的位置非常高,渲染更新后的组件内部所有嵌套组件的默认行为将不会获得最佳性能。如果你遇到了性能问题,性能 章节描述了几种可选的解决方案 。不要过早进行优化!
步骤3:React把更改提交到DOM上
在渲染(调用)你的组件之后,React将会修改DOM
- 对于初次渲染,React会使用
appendChild()
DOM API将其创建所有的DOM节点放在屏幕上。 - 对于重渲染,React将应用最少的必要操作(在渲染时计算!),以使得DOM与最新的渲染输出互相匹配。
React仅在渲染之间存在差异时才会更新DOM节点。
有一个组件,它每秒使用从父组件传递下来的不同属性重新渲染一次。注意,你可以添加一些文本到 <input>
标签,更新它的 value
,但是文本不会在组件重渲染时消失:
1 | export default function Clock({ time }) { |
这个例子之所以会正常运行,是因为在最后一步中,React 只会使用最新的 time
更新 <h1>
标签的内容。它看到 <input>
标签出现在 JSX 中与上次相同的位置,因此 React 不会修改 <input>
标签或它的 value
!
浏览器绘制
在渲染完成并且React更新DOM之后,浏览器就会重新绘制屏幕。尽管这个过程称为“浏览器渲染”(“browser rendering”),这里还是称为“绘制”(“painting”),以避免在这些文档的其余部分中出现混淆。
state如同一张快照
也许state变量看起来就和一般的可读写的JavaScript变量类似。但state在其表现出的特性上更像是一张快照。设置他不会更爱你已有的state变量,但会触发重新渲染。
设置state会触发渲染
你可能会认为你的用户界面会直接对点击之类的用户输入做出相应并发生变化。在React中,他的工作方式与这种思维模型略有不同。上一章节我们知道通过设置state
请求重新渲染。这就意味着要使界面对输入做出反应,需要使用设置state。
看个例子:
1 | import { useState } from 'react'; |
当单击按钮时会发生以下情况:
- 执行
onSubmit
事件处理函数。 setIsSent(true)
将isSent
设置为true
并排列一个新的渲染。- React 根据新的
isSent
值重新渲染组件。
渲染会及时生出一张快照
“正在渲染”就意味着React正在调用组件—- 一个函数。你从该函数返回的JSX就像是UI的一张及时的快照。它的props、事件处理函数和内部变量都是根据当前渲染时的state被计算出来的。
相较于照片或电影画面不同,你返回的UI“快照”是可交互的。他其中包含着类似事件处理函数的逻辑,这些逻辑对于指定如何输入作出响应。React随后会更新屏幕来匹配这张快照,并绑定事件处理函数。因此,按下按钮即会触发你的JSX的点击事件处理函数。
当React重新渲染一组件时:
- React会再次调用你的函数
- 函数会返回新的JSX快照
- React会更新界面以匹配返回的快照
作为一个组件的记忆,state
不同于在你的函数返回之后就会消失的普通变量。state实际是“活”在React本身– 就像摆在一个架子上!– 位于你的函数之外。当React调用组件时,他会为特定的那一次渲染提供一张state快照。你的组件会在会在JSX中返回一张包含一整套新的props和事件处理函数的UI快照,其中所有的值都是根据一次渲染中state的值被计算出来的!
下面看个例子:
试想下结果!
1 | import { useState } from 'react'; |
点击按钮后其实发现,每次点击number
递增一次!!!
设置state只会为下一次渲染变更state的值。在第一次渲染期间,number
为0
。这也解释了为什么在那次渲染中的 onClick
处理函数中,即便在调用了 setNumber(number + 1)
之后,number
的值也仍然是 0
:
1 | button onClick={() => { |
分析下这个按钮点击事件处理函数通知React要做的事情:
setNumber(number + 1)
:number
是0
所以setNumber(0 + 1)
。- React 准备在下一次渲染时将
number
更改为1
。
- React 准备在下一次渲染时将
setNumber(number + 1)
:number
是0
所以setNumber(0 + 1)
。- React 准备在下一次渲染时将
number
更改为1
。
- React 准备在下一次渲染时将
setNumber(number + 1)
:number
是0
所以setNumber(0 + 1)
。- React 准备在下一次渲染时将
number
更改为1
。
- React 准备在下一次渲染时将
尽管调用了三次 setNumber(number + 1)
,但是在这次渲染的的事件处理函数中number
会一直是0
,所以你会三次将state设置为1
。这就是为什么你在事件处理函数执行完后,React重新渲染的组件中的number
等于1
而不是3
其实就是可以把state变量放入这次渲染中。由于这次渲染中的state变量就是0
,其实事件处理函数就是以下这种:
1 | <button onClick={() => { |
所以对于下一次渲染来说,number
是1
,因此那次渲染中的点击事件处理函数就是这样:
1 | <button onClick={() => { |
以上这就是为什么每次都是递增1
。
随着时间变化的state
来看来段代码:
1 | import { useState } from 'react'; |
以上代码会出现 alert
先显示0
,页面显示累加后的。也就是alert
会先显示上一次的数值,页面在显示。
在alert
加上记时器,使得在组件重新渲染之后才触发。又会怎么样呢?
1 | import { useState } from 'react'; |
这时候就会发现页面先渲染出累加后的数值,alert
才会输出累计后的数值。
到提示框运行时,React中存储的state可能已经发生了改变,但他是使用用户与之交互时的快照进行调度的!
一个state变量的值永远不会在一次渲染的内部发生变化,即使事件处理函数的代码是异步的。在那个渲染的onClick
内部,number
的值即使在调用的setNumber(number + 5)
之后也是0
。它的值是在React通过调用你的组件“获取UI的快照”时就被“固定”了。
React 会使 state 的值始终”固定“在一次渲染的各个事件处理函数内部。 你无需担心代码运行时 state 是否发生了变化。
把一系列state更新加入队列
React会对state更新进行批处理
在上节这个示例中,我们发现当按钮点击后,组件页面渲染数值一直是每次累加一次的。
1 | import { useState } from 'react'; |
每一次渲染的 state 值都是固定的,在第一次渲染的事件处理函数内部的number
值总是0
。
1 | setNumber(0 + 1); |
在Reac机制里,React会等到事件处理函数中的所有代码都运行完毕在处理你的state更新。这也就是为什么重新渲染只会发生在所有这些setNumber()
调用之后的原因。
就好比,点餐时。服务员不会在你说第一道菜的时候,就去厨房下单,而是等你,把菜点完、如有修改修改完后,再一次性去下单。
这样就可以更新多个state变量–甚至来自多个组件的state变量–而不会触发太多的 重新渲染。这样也意味着只有在我们的事件处理函数以及其中任何代码执行完成之后,UI才会更新。这种特性也就是批处理,他会使React应用运行得更快。这样也可以帮助我们避免处理只更新了一部分state变量的令人困惑的“半成品”渲染。
React不会垮多个需要刻意触发的事件(如点击)进行批处理–每次点击都是单独处理的。React只会在一般来说安全的情况下才进行批处理。例如,如果第一次点击按钮会禁用表单,那么第二次点击就不会再次提交它。
在下次渲染前读次更新同一个state
如果想在下次渲染之前多次更新同一个state,我们可以使用setNumber(n => n + 1)
这样传入一个根据队列中的前一个state计算下一个state函数,而不是像setNumber(number + 1)
这样传入下一个state的值。这是告诉React“用state值做某事”而不是仅仅替换它的方法。
现在尝试递增计数器:
1 | import { useState } from 'react'; |
在这里,n => n + 1
被称为更新函数。当我们给他传递一个state设置函数时:
- React会将此函数加入队列,以便在事件处理函数中的所有其他代码运行后进行处理。
- 在下一次渲染期间,React会遍历队列并更新之后的最终state。
1 | setNumber(n => n + 1); |
以下是React在执行事件处理函数时处理这几行代码的过程:
setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。
在下次渲染期间调用useState
时,React会遍历队列。之前的number
state的值是0
,所以这就是React作为参数n
传递给第一个更新函数的值。然后React会获取上一个更新函数的返回值,并将其作为n
传递给下一个更新函数,以此类推:
更新队列 | n |
返回值 |
---|---|---|
n => n + 1 |
0 |
0 + 1 = 1 |
n => n + 1 |
1 |
1 + 1 = 2 |
n => n + 1 |
2 |
2 + 1 = 3 |
如果在替换state后更新state会发生什么
看看下面这个例子,思考下下一次number
渲染的值是什么?
1 | <button onClick={() => { |
实际结果是:每次递增6
这个事件处理函数告诉React要做的事情:
setNumber(number + 5)
:number
为0
,所以setNumber(0 + 5)
。React 将 “替换为5
” 添加到其队列中。setNumber(n => n + 1)
:n => n + 1
是一个更新函数。 React 将 该函数 添加到其队列中。
在下一次渲染期间,React会遍历state队列:
更新队列 | n |
返回值 |
---|---|---|
“替换为 5 ” |
0 (未使用) |
5 |
n => n + 1 |
5 |
5 + 1 = 6 |
React会保存6
为最终结果并从useState
中返回。
注意:其实这时候就可以发现,setState(x)
实际上会像setState(n => x)
一样运行,只不过没有使用n
!
如果在更新state后替换state会发生什么
看看这例子,你认为number
在下一次渲染中的值是什么
1 | <button onClick={() => { |
实际结果:第一次变更为42
,后续一直为42
。
以下是 React 在执行事件处理函数时处理这几行代码的过程:
setNumber(number + 5)
:number
为0
,所以setNumber(0 + 5)
。React 将 “替换为5
” 添加到其队列中。setNumber(n => n + 1)
:n => n + 1
是一个更新函数。React 将该函数添加到其队列中。setNumber(42)
:React 将 “替换为42
” 添加到其队列中。
在下一次渲染期间,React 会遍历 state 队列:
更新队列 | n |
返回值 |
---|---|---|
“替换为 5 ” |
0 (未使用) |
5 |
n => n + 1 |
5 |
5 + 1 = 6 |
“替换为 42 ” |
6 (未使用) |
42 |
然后 React 会保存 42
为最终结果并从 useState
中返回。
总而言之,以下是我们可以考虑传递给setNumber
state设置函数的内容:
- 一个更新函数(例如:
n => n + 1
)会被添加到队列中。 - 任何其他的值(例如:数字
5
)会导致“替换为5
”被添加到队列中,已经在队列中的内容会被忽略。
事件处理函数执行完成之后,React将重新触发渲染。再重新渲染期间,React将处理队列。更新函数会在渲染期间执行,因此更新函数必须是 纯函数 并且只返回结果。不要尝试从他内部设置state或者执行其他副作用。在严格模式下,React会更新每个更新函数俩次(但是丢弃第二个结果),以便帮助发现错误。
命名惯例
通常使用相应的state变量的第一个字母来命名更新函数的的参数:
1 | setEnabled(e => !e); |
另一个常见的惯例是重复使用完整的 state 变量名称,如 setEnabled(enabled => !enabled)
,或使用前缀,如 setEnabled(prevEnabled => !prevEnabled)
。
更新state中的对象
state中可以保存任意类型的JavaScript值,包括对象。但是,在修改对象的时候不应该直接修改存放在React state中的对象。当我们想更新一个对象时,需要创建一个新的对象(或者将其拷贝一份),然后将state更次为此对象。
什么是mutation
我们可以在state中存放任意类型的JavaScript值。
我们在state中存放的数字、字符串和布尔值,这些类型的值在JavaScript中是不可变(immutable)的,这意味着它们不能被改变或是只读的。这些值可以通过替换它们的值来触发下一次重新渲染。
state存放数字
statex
从0
到5
,数字0
本身没有发生改变。在JavaScript中,无法对内置的原始值,如数字、字符串和布尔值,进行任何更改。
1 | const [x,setX] = useState(0) |
state存放对象
当我们改变对象本身的内容时,就制造了一个mutation
1 | const [position, setPosition] = useState({ x: 0, y: 0 }); |
严格来说,React state中存放的对象是可变的,但是应该像处理数字、布尔值、字符串一样视为不可变。当要改变的时候,应该考虑去替换它们的值,而不是对他们进行修改。
将state视为只读的
在React中我们应该将所有放在state中的JavaScript对象都视为只读的。
我们来看这个例子,我们使用存放在state中的对象来表示指针当前的位置。当我们在预览区域触发或移动光标时,红色移动。
1 | import { useState } from 'react'; |
为了真正 触发一次重新渲染,我们需要创建一个新的对象并把它传递给state的设置函数:
1 | onPointerMove={e => { |
通过使用setPosition
,你在告诉React:
- 使用这个新的对象替换
positon
的值 - 然后再次渲染这个组件
局部的mutation是可以接受的
像这样的代码是有问题的,因为它改变了state中现有的对象:
1 | position.x = e.clientX; |
但是像这样的代码就没有任何问题,因为改变的是刚刚创建的一个循对象,并将这个对象传递给了state:
1 | const nextPosition = {}; |
这种写法完全等于这这写法:
1 | setPosition({ |
只有改变处于state中的现有对象时,mutation才会成为问题。而修改一个刚刚创建的对象就不会出现任何问题,因为还没有其他代码引用它。改变它并不会意外的影响到其他依赖它的东西。这叫做“局部mutation”。我们也可以在 在渲染的过程中 进行“局部mutation”的操作。这种操作既便捷又没有任何问题!
使用展开语法复制对象
在之前的例子,都会根据指针的位置创建出一个新的position
对象。当我们只需要改变一个属性值的时候,也或者是将现有数据作为新对象的一部分。
看下面的例子,输入框并不会直接正常运行,因为onChange
直接修改了state:
1 | import { useState } from 'react'; |
以上的,这段代码直接修改了上一次渲染中的state:
1 | person.firstName = e.target.value; |
如果我们想在获取firstName
的需求,最可靠的办法就是创建一个新的对象将它传递给stePerson
。但是在这里,我们还需要将当前的数据复制到新对象中,因为我们只改了一个字段。
1 | setPerson({ |
我们也可以使用...
对象展开 语法,这样就不需要单独复制某个属性。这里注意:新的属性值应该放在最后。
1 | setPerson({ |
这样就可以看来,并没有为每个输入框单独生命一个state。对于大型表单,将所有数据都放在同一个对象中是非常方便的–前提是必须要正确地更新它!
请注意...
展开愈发本质是“浅拷贝”—它只会复制一层。这样就使得它的执行速度很快,这也意味着我们要更新一个嵌套属性时,就必须多次使用展开语法。
使用一个事件处理函数来更新多个字段
我们也可以在对象的定义中使用[xxx]
括号来实现属性的动态命名。
看下面这个例子:
1 | import { useState } from 'react'; |
在这里,e.target.name
引用了 <input>
这个 DOM 元素的 name
属性。
更新一个嵌套对象
如果出现了嵌套对象:
1 | const [person, setPerson] = useState({ |
如果想要更新 person.artwork.city
的值,用mutation来实现的方法就很好理解了:
1 | person.artwork.city = 'New Delhi'; |
但是在React中,需要将state是为不可变得!如果要修改city
的值,先要创建一个新的artwork
对象(其中预先填充上一个artworkd
对象中的数据),然后创建一个你的person
对象,并且使得其中的artwork
属性指向新创建的artwork
对象:
1 | const nextArtwork = { ...person.artwork, city: 'new ork'} |
或者,也可以写成一个函数调用:
1 | setPerson({ |
对象并非是嵌套的
下面这个对象从代码上来看是“嵌套的”:
1 | let obj = { |
其实,思考对象的特性时(key:value),“嵌套”并不是一个非常准确的方式。其实这个对象在运行时,会被解释为:
1 | let obj1 = { |
这么看来其实obj1
并不处于obj2
的内部。同时,下面代码中obj3
中的属性也可以指向obj1
:
1 | let obj1 = { |
如果你直接修改 obj3.artwork.city
,就会同时影响 obj2.artwork.city
和 obj1.city
。这是因为 obj3.artwork
、obj2.artwork
和 obj1
都指向同一个对象。当你用“嵌套”的方式看待对象时,很难看出这一点。相反,它们是相互独立的对象,只不过是用属性“指向”彼此而已。
使用Immer编写简介的更新逻辑
如果state有多层的嵌套,应该考虑将 将其扁平化。但是同时,不想改拜年state的数据结构,我们可以使用Immer 这个库,他可以让你使用简便但是可以直接修改的语法编写代码,并会处理好复制的过程。通过使用 Immer,你写出的代码看起来就像是你“打破了规则”而直接修改了对象:
1 | updatePerson(draft => { |
不同于一般的mutation,他并不会覆盖之前的state!
Immer 是如何运行的?
由 Immer 提供的 draft
是一种特殊类型的对象,被称为 Proxy,它会记录你用它所进行的操作。这就是你能够随心所欲地直接修改对象的原因所在!从原理上说,Immer 会弄清楚 draft
对象的哪些部分被改变了,并会依照你的修改创建出一个全新的对象。
如何使用Immer
尝试使用 Immer:
- 运行
npm install use-immer
添加 Immer 依赖 - 用
import { useImmer } from 'use-immer'
替换掉import { useState } from 'react'
看以下例子:
1 | import { useImmer } from 'use-immer'; |
这样,事件处理函数就变得简洁了。可以随意在一个组件中同时使用 useState
和 useImmer
。如果你、想要写出更简洁的更新处理函数,Immer 会是一个不错的选择,尤其是当你的 state 中有嵌套,并且复制对象会带来重复的代码时。
更新state中的数组
数组也是另外一种可以存储在state中的JavaScript对象,数组本身是可变的,但是应该视为不可变。同对象一样,如果想要更新存储于state中的数组时,需要创建一个新的数组(或者创意一份已有数组的拷贝值),并使用新数组设置state。
在没有mutation的前提下更新数组
在JavaScript中,数组是另一种对象。同对象一样,需要将React state中的数值是为只读的。这意味着不应该使用类似于 arr[0] = 'bird'
这样的方式来修改数组中的元素,也不应该使用会直接修改原数组的方法,例如push()
和pop()
。
相反,在需要更新一个数组时,需要将一个新的数组传入state中的setting方法中。为此我们可以通过 filter()
和 map()
这样不会直接修改原始值的方法,从原始数组生成一个新的数组。然后你就可以将 state 设置为这个新生成的数组。
以下是常见数组操作的api。当操作React state中的数组时,应该避免左列的方法,而选择右列的方法。
避免使用 (会改变原始数组) |
推荐使用 (会返回一个新数组) |
|
---|---|---|
添加元素 | push ,unshift |
concat ,[...arr] 展开语法(例子) |
删除元素 | pop ,shift ,splice |
filter ,slice (例子) |
替换元素 | splice ,arr[i] = ... 赋值 |
map (例子) |
排序 | reverse ,sort |
先将数组复制一份(例子) |
或者,可以使用使用 Immer ,这样就可以使用表格中的所有方法了。
注意:
slice
和splice
方法作用不同
slice
可以拷贝数组或者数组的一部分。splice
会直接修改原始数组(插入或者删除元素)
向数组中添加元素
如果利用push()
会直接修改原始数组,而这个不是我们期望的。
1 | import { useState } from 'react'; |
以上例子中,我们应该创建一个新的数组,其包含了原始数组中的所有元素以及一个在末尾添加的新元素。可以有很多种办法实现。最简单的一种就是使用 ...
数组展开 语法:
1 | setArtists( // 替换 state |
还可以通过展开运算符将新添的元素放在原始的...artists
之前:
1 | setArtists([ |
这样看来,展开运算符就可以完成push()
和unshift()
的效果。
从数组中删除元素
从数组中删除元素最简单的方法就是将它过滤出去。可以通过filter
方法实现:
1 | import { useState } from 'react'; |
点击“删除”按钮几次,每次都会触发
1 | setArtists( |
与push()
不同的是这里的filter()
表示“创建一个新的数组”。也就是生成了新的数组,通过state更新,并触发渲染。但是filter()
不会改变原数组。
转换数组
如果想改变数组中的某些或者所有元素。使用map()
创建一个新数组。传入的map
函数据定了根据每个元素或者索引(或二者都作为条件)对元素进行处理。
在下面的例子中,一个数组记录了两个圆形和一个正方形的坐标。当你点击按钮时,仅有两个圆形会向下移动 100 像素。这是通过使用 map()
生成一个新数组实现的。
1 | import { useState } from 'react'; |
替换数组中的元素
如果存在替换数组中或者多个元素需求的话。类似 arr[0] = 'bird'
这样的赋值语句会直接修改原始数组,所以在这种情况下,我们也应该使用 map
。
要替换一个元素,请使用 map
创建一个新数组。在你的 map
回调里,第二个参数是元素的索引。使用索引来判断最终是返回原始的元素(即回调的第一个参数)还是替换成其他值:
1 | import { useState } from 'react'; |
向数组中插入元素
有时候也会存在向数组特定位置插入一个元素,这个位置既不在开头,又不在末尾。这时候就可以使用数组展开运算符 ...
和 slice()
方法一起使用。slice()
方法可以从数组中切出“一片”。为了将元素插入数组,需要先展开原数组在插入点之前的切片,然后插入新元素,最后展开原数组中剩下的部分。
下面的例子中,插入按钮总是会将元素插入到数组中索引为 1
的位置。
1 | import { useState } from 'react'; |
其他改变数组情况
还会存在,依靠展开运算符和map()
或者filter()
等不回直接修改原值的方法无法做到。例如:翻转数组、数组排序。而因为JavaScript中的 reverse()
和 sort()
方法会改变原数组,所以你无法直接使用它们。
然而我们可以先拷贝这个数组,在改变这个拷贝后的值
例如:
1 | import { useState } from 'react'; |
在这里,我们现使用展开运算符进行了原数组的拷贝。有了这个拷贝值,就可以直接通过nextList.reverse()
或 nextList.sort()
这样直接修改原数组的方法。甚至可以通过 nextList[0] = "something"
这样的方式对数组中的特定元素进行赋值。
即使拷贝了数组,还是不能直接修改其内部的元素。这是因为数组的拷贝是浅拷贝–新的数组中依然保留了与原始数组相同的元素。
因此,修改了拷贝数组内部的某个对象,其实就是直接在修改当前的state。如下面的代码;
1 | const nextList = [...list]; |
虽然 nextList
和 list
是两个不同的数组,**nextList[0]
和 list[0]
却指向了同一个对象**。因此,通过改变 nextList[0].seen
,list[0].seen
的值也被改变了。这是一种 state 的 mutation 操作,你应该避免这么做!可以用类似于 更新嵌套的 JavaScript 对象 的方式解决这个问题——拷贝想要修改的特定元素,而不是直接修改它。下面是具体的操作。
更新数组内部的对象
对象并不是真的位于数组“内部”。可能在代码层面上来看像是在“内部”,但其实际数组中的每个对象都是这个数组“指向”的一个存储其他位置的值。所以在处理list[0]
嵌套字段的时候需要格外小心,其他元素的值kennel也指向了数组的同一个元素。
当更新一个嵌套的 state 时,需要从想要更新的地方创建拷贝值,一直这样,直到顶层。
在下面的例子中,两个不同的艺术品清单有着相同的初始 state。他们本应该互不影响,但是因为一次 mutation,他们的 state 被意外地共享了,勾选一个清单中的事项会影响另外一个清单:
1 | import { useState } from 'react'; |
其实问题就是出现在
1 | const myNextList = [...myList]; |
虽然复制出来了新的数组myNextList
,但是其内部的元素本身与原数组myList
是相同的。因此修改了新数组myNextList
中 artwork.seen
,其实是在修改原始的 artwork 对象。而这个对象也在youList
中使用,这样就导致存在问题。
其实可以使用map()
在没有mutation的前提下将一个旧的元素替换成新的更新版本。
1 | setMyList(myList.map(artwork => { |
此处的 ...
是一个对象展开语法,被用来创建一个对象的拷贝.
通常来讲,我们应该只修改刚刚创建的对象。如果正在插入一个新的元素,可以修改它,但是如果想改变state中已经存在的东西,就需要先拷贝一份了。
使用Immer编写简洁的更新逻辑
在没有 mutation 的前提下更新嵌套数组可能会变得有点重复。就像对对象一样:
- 通常情况下,我们应该不需要更新处于非常深层级的 state 。如果有此类需求,或许需要调整一下数据的结构,让数据变得扁平一些。
- 如果不想改变 state 的数据结构,使用 Immer ,它可以让我们继续使用方便的,但会直接修改原值的语法,并负责生成拷贝值。
下面是,使用Immer改写的例子
1 | import { useState } from 'react'; |
请注意当使用 Immer 时,类似 artwork.seen = nextSeen
这种会产生 mutation 的语法不会再有任何问题了:
1 | updateMyTodos(draft => { |
这是因为并不是在修改原始的state,而是在修改Immer提供的特殊draft
对象。同理,也可以为draft
的内容使用push()``pop()
这些会直接修改原值的方法。
幕后,Immer 总是会根据你对 draft
的修改来从头开始构建下一个 state。这使得你的事件处理程序非常的简洁,同时也不会直接修改 state。