本文用于梳理React开发中使用较多的hooks。
仅作为入门快速了解hooks从而开发所用,不涉及很多原理性的东西。
这次先讲useState和useEffect。
一、useState
useState:用于管理组件内部状态,是React开发中最常用的hook,可以让函数组件拥有内部可更新的状态(对比纯js开发,就是用class的this.state)。
1.1 什么是状态
虽然看起来很像无关的话题,但是在理解useState的时候,很有必要去理解一下state(状态),从而帮助我们更好地理解useState的实现机制和React的渲染逻辑。
在React中,state是一个非常核心的概念,可以理解为,state驱动页面变化:只要state改变,React就会自动重新渲染界面。
看起来似乎一般场景也能做到,但让我们设想一下:假设有一个上下翻页的组件,你点击了下一页,state发生了变化,在React中,每当state发生了变化,React就会重新调用组件函数,组件函数中的代码会从头执行,因此普通变量会重新初始化。
很显然,这是不符合实际运用的,因为一般的变量在组件重新渲染了之后,记录翻页的一般变量也被恢复到初始状态了。
以下是上述示例的代码,在我们狂点下一页的时候,页面始终只能看到初始化的index=0:
tsx
export default function Demo() {
let index = 0;
const handleClick = (index) => {
index = index + 1;
};
return (
<>
<button onClick={handleClick}>下一页</abutton>
<p>index={index}</p>
</>
);
}
所以,我们需要引入useState,记住需要被记住的state,防止被初始化。useState返回的值并不是局部变量,而是React在内部保存的一个状态单元,被记住的state在渲染之间不会丢失。 也就是这样:
tsx
import { useState } from "react";
export default function Demo() {
const [index, setIndex] = useState(0);
const handleClick = () => {
setIndex(index + 1);
};
return (
<>
<button onClick={handleClick}>下一页</button>
<p>index={index}</p>
</>
);
}
现在在我们狂戳的时候,就会发现index也随之变化了,显然,useState帮助组件记住了我们想要的状态。
强调:state不是普通的局部变量,普通变量存储在函数的执行上下文里,而state存储在React的内部数据结构中(Fiber),它独立于组件函数的执行,下面会再次提到这个,可以这么理解:
- 普通变量=每次渲染都会重新生成。
- state=React管理,在渲染之间保持不变。
1.2 useState的实现机制是什么呢
上一节我们在讲述state时,已经初步引入了useState。让我们参考一下官方的说法:useState是一个React Hook,它允许你向组件添加一个状态变量。
useState本质上做了三件事情:
- 分配一个"状态单元"来存储数据
- 按调用顺序记录这个state单元的位置
- 在调用setState时触发组件重新渲染
1.2.1 React如何保存state------"状态单元"
函数组件本身只是一个普通函数,函数执行完了,一般来说,内部的局部变量就会销毁,这是由函数执行的生命周期决定的。 但是下述函数的count不会丢:
tsx
const demo = () => {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
为什么呢?
因为:React把state存在组件对应的Fiber节点中,而不是函数内部。 我们可以抽象理解一下,一个组件可能有多个state,React对这些state私下维护一个作用域高于组件的数组(Fiber),里面存放这些state。
tsx
const [a, setA] = useState(1); // state[0]
const [b, setB] = useState(2); // state[1]
const [c, setC] = useState(3); // state[2]
React内部对上述会存在一个这样的数组:
cpp
fiber.state = [
{ memoizedState: 1 }, // a
{ memoizedState: 2 }, // b
{ memoizedState: 3 }, // c
]
这个数组的作用域范围高于函数组件,所以state在渲染时不会消失。
1.2.2 React如何知道每个state对应哪个useState------"按顺序记录与调用"
React规定:
- 首次渲染时,按照遇到的
useState顺序,依次将对应的state存放在state数组中 - 接下来渲染时,根据遇到的
useState顺序,依次将对应的state从state数组中取出。
这里有严格的调用顺序,React根据"调用顺序"匹配state,state的顺序决定一切。
因此,这里也会引出一个"老生常谈"的问题:为什么useState不能写在if里面------因为顺序会乱。
tsx
if (someCondition) {
const [a, setA] = useState(1); // 有时候执行,有时候不执行
}
const [b, setB] = useState(2);
这种写法就是错误的:因为someCondition影响a的执行,从而导致b的顺序发生改变。
someCondition为true: state[0] = a, state[1] = b someCondition为false: state[0] = b
someCondition的执行与否导致初始化与后续渲染时a可能存在可能不存在,只有初始化时是存state,后续是取state,b的位置会发生错位,React无法精准匹配到b对应的state,从而报错。
1.2.3 setState如何触发更新
setState触发更新本质在做两件事情:
- 向state对应的队列里push一次更新(Update)
- 标记当前的Fiber需要重新渲染,并触发更新
翻译成人话就是:把state的新值记录下来,并通知React重新渲染组件。这样当下一次渲染发生时:
- React按顺序再次执行useState
- 发现对应的state有更新
- 更新并返回新的state
1.2.4 补充:一些遇到的疑难杂症
1. 为什么React的state更新是"异步"的
不是因为setState真的是异步操作,而是因为React会把多个state更新合并批处理:在一次事件循环中,React会收集所有的setState,最后统一重新渲染组件,从而提高性能。
在React18后,React在更多场景下(Promise、定时器等)也会进行批处理,表现得更加异步。
2. 为什么我更新了状态,但是屏幕没有更新
这是由React的内部机制(Object.is比较)决定的,React会比较新旧状态是否相同,如果下一个状态等于先前的状态,则React会忽略这次更新,这是一种默认的"浅比较",从而避免频繁的更新,实现防抖的效果,如果想要解决这个问题,需要始终保证在状态中替换 对象和数组,而不是对它们进行更改。
tsx
const [index, setIndex] = useState(0);
// 错误写法
const handleClick = () => {
setIndex(index+1); // 1
setIndex(index+1); // 1
setIndex(index+1); // 1
}
// 正确写法
const handleClick = () => {
setIndex(index => index + 1); // 1
setIndex(index => index + 1); // 2
setIndex(index => index + 1); // 3
}
3. 为什么setState后,工作日志打印出来的还是旧值
调用set函数不能改变运行中的代码的状态。因为状态表现就像一个快照,更新状态会使用新的状态值去请求另一个渲染,但是并不影响在已经运行的事件处理函数中的变量。
tsx
const handleClick = () => {
console.log(count); // 0
setCount(count + 1);
console.log(count); // 0
setTimeout(() => {
console.log(count); // 0
}, 5000);
}
如果需要下一个状态,可以在将其传递给set函数之前保存在一个变量中:
tsx
const handleClick = () => {
const nextCount = count + 1;
setCount(nextCount);
console.log(count); // 0
console.log(nextCount); // 1
}
二、useEffect
useEffect:用于实现组件与外部系统同步。
2.1 为什么要用useEffect
有些组件需要与外部系统同步。例如,你可能希望根据React state控制非React组件、建立服务器连接或当组件在页面显示时发送分析日志。Effect允许你在渲染结束后执行一些代码,以便将组件与React外部的某个系统相同步。
以上话语摘自官方文档,我觉得有些拗口,但是其实翻译成大白话就是:组件已经渲染好了,但是在渲染好了后我还希望执行一些逻辑,这些逻辑不能写在渲染流程中,这个时候就可以使用useEffect来实现这些逻辑。
这部分逻辑我们称作副作用(Side Effect,和渲染UI无关但必须做的事情),常见的副作用有:
- 发请求
- 订阅、监听事件
- 添加定时器
- 操作DOM
- 打日志
- 手动更新某些外部变量
这些不能直接写在函数组件里面,因为组件会反复执行,但这些副作用不需要反复执行,比如不需要反复请求接口发送请求,反复执行可能会带来一些问题。
2.2 该如何使用useEffect
useEffect本质上只有一种格式:
tsx
useEffect(() => {
// 副作用
return () => {
// 清理副作用
};
}, [deps]);
在上述代码中,deps数组存放执行useEffect的依赖,useEffect根据依赖数组判断是否需要执行副作用函数。
2.2.1 什么是依赖
新手常见问题:什么是依赖?
一句话总结:用于告诉React哪些值必须变化时,这个effect需要执行,这就是依赖(dependency)。
没有依赖,会导致useEffect认为每次渲染都需要执行副作用函数,轻则带来糟糕的性能,重则影响页面逻辑。
怎么界定依赖,很简单,看effect内部"访问"到的state或者props,谁被访问,谁就是依赖。
tsx
useEffect(() => {
console.log(user.name);
console.log(age);
}, [user.name, age]);
effect访问了user.name和age,所以依赖数组就是这两个。
开发中要时刻注意依赖有没有写对,因为依赖的存在会影响useEffect的执行逻辑。
tsx
useEffect(() => {
console.log(user.name);
console.log(age);
}, [user.name]);
比如这样,依赖中没有写age,那么age的变化不会调用useEffect执行副作用函数,useEffect中的age也永远只是初始化的值。
如何保证依赖一定写对:记住依赖就是useEffect里面用的来自外界的东西,只要在effect中用到了需要从useEffect这个钩子之外的变量、state、props、函数等,就要把这些东西放在依赖数组中。
2.2.2 useEffect怎么用
根据依赖数组,可以把useEffect分成三类使用。
1. 没有依赖数组
tsx
useEffect(() => {
console.log('每次渲染都执行');
});
没有依赖数组,那么useEffect在首次渲染和每次更新后都会执行。不过这种比较少,因为大部分逻辑不需要这么频繁的运行。
2. 依赖数组为空
tsx
useEffect(() => {
console.log('只在初始化的时候调用一下');
}, []);
依赖数组为空,那么只有在初始化的时候会执行一次useEffect,后续变化都不再执行,因为对比依赖数组发现变量不需要被依赖,当然因为依赖为空,所以useEffect中永远保持初始值的样子。
3. 有依赖数组
tsx
useEffect(() => {
console.log('count变了就调用一下');
}, [count]);
这种才是最常用的,根据count来调用useEffect,依赖变了则需要执行副作用函数。这种时候,就是依赖数组有啥,useEffect就根据依赖数组的内容是否变化判断要不要执行effect。
4. 给一个粗糙的demo
tsx
import { useState, useEffect } from "react";
function Demo({ count, age }) {
// 无依赖数组
useEffect(() => {
console.log("每次都执行");
});
// 依赖数组为空
useEffect(() => {
console.log("初始化时候执行一下");
}, []);
// 依赖数组不为空
useEffect(() => {
console.log("根据count执行一下");
}, [count]);
return (
<div>
被调用了{count}次,今年{age}岁
</div>
);
}
export default function App() {
const [count, setCount] = useState(0);
const [age, setAge] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
const handleAge = () => {
setAge(age + 1);
};
return (
<>
<button onClick={handleClick}>count变一下</button>
<button onClick={handleAge}>age变一下</button>
<Demo count={count} age={age}></Demo>
</>
);
}
2.3 useEffect不是每次都需要的
这里参考了官方文档。
如果你没有打算与某个外部系统同步,那么你可能不需要Effect。
人言翻译:useEffect不是用来做所有逻辑的,只是用来做"渲染之外"的事情。这里的外界系统,指无法通过只依赖React内部(state、props)和渲染自动完成。
useEffect是用来处理副作用的,但是很多人把本来是"渲染逻辑"的东西也写进了副作用中,导致了不必要的useEffect。
我们需要明白,useEffect≠业务逻辑&计算逻辑
tsx
useEffect(() => {
setTotal(a + b);
}, [a, b]);
看起来这个的意思是,根据a、b是否变化判断要不要重新算total,但是这种写法是没必要的,state不需要你重新计算,当a、b变化时,UI自己就会更新。
tsx
const total = a + b;
写成这样就可以了,没有副作用就不要引入useEffect。
React渲染组件=执行函数,但是副作用不能写在渲染时执行的代码中,所以使用useEffect来延迟处理他们。
我遇到过的一个比较典型的例子是:一个页面需要从ctx中拿到某个机构的一些参数,这个ctx中的参数也是通过接口拿到的,并用这些参数来发送请求,由于我没有使用useEffect来保证在拿到参数后再发送请求,以及没有给页面留下差错处理,于是页面崩溃了。这里的正确做法是,在useEffect中发送请求,监听参数是否拿到了,拿到了再发请求。
2.4 useEffect闭包陷阱
useEffect闭包陷阱是React中常见的一个问题,特别在处理异步问题、事件监听或者计时器时。这个问题源于JavaScript闭包特性,当在useEffect内部使用外部的变了时,可能会捕获旧值,从而导致代码中的副作用没有按照预期的行为执行。
典型的闭包陷阱一般发生在依赖组件的state,且state是异步更新的。
假设我们有这样一个计时器,每秒更新一次:
tsx
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // 闭包陷阱
}, 1000);
return () => clearInterval(interval);
}, []); // 依赖为空,effect只会在挂载时执行一次
return <div>Count: {count}</div>;
}
在这段代码中,setCount(count + 1)要用到count,但count由useState管理,并且是异步更新的。useEffect会在首次渲染后执行,并且每次渲染都会"捕获"count当前的值,但它并不会随着count的变化自动更新。所以setCount(count + 1)总是使用组件渲染时捕获的"旧值"。因此,count值没有递增,而是一直停留在初值。
为什么会这样呢?因为在useEffect中使用的count被闭包捕获,而useEffect在组件首次渲染时就被调用了,并且闭包里捕获的count永远不会更新,因此useState中的回调总是访问初次渲染的count,而不是组件更新后的最新值。无论count如何变化,都只会更新旧值。
解决方法也是有的:React提供了函数式更新作为setState的一种方式,这样,React可以确保在setState时,始终拿到最新的state。
tsx
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount((prevCount) => prevCount + 1); // 使用函数式更新
}, 1000);
return () => clearInterval(interval);
}, []); // 依赖为空,effect只会在挂载时执行一次
return <div>Count: {count}</div>;
}
当然,我们也可以加上显式依赖。
闭包陷阱的解决的关键在于:确保useEffect中的state始终是最新的,尤其是异步操作也要是新的。使用函数式更新和正确的依赖项,可以有效地解决闭包问题。
暂时先讲到这里,希望各位大佬有问题务必狠狠指正!