React常见hooks及运用场景梳理(一)——useState、useEffect

本文用于梳理React开发中使用较多的hooks。

仅作为入门快速了解hooks从而开发所用,不涉及很多原理性的东西。

这次先讲useStateuseEffect

一、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本质上做了三件事情:

  1. 分配一个"状态单元"来存储数据
  2. 按调用顺序记录这个state单元的位置
  3. 在调用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触发更新本质在做两件事情:

  1. 向state对应的队列里push一次更新(Update)
  2. 标记当前的Fiber需要重新渲染,并触发更新

翻译成人话就是:把state的新值记录下来,并通知React重新渲染组件。这样当下一次渲染发生时:

  1. React按顺序再次执行useState
  2. 发现对应的state有更新
  3. 更新并返回新的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.nameage,所以依赖数组就是这两个。

开发中要时刻注意依赖有没有写对,因为依赖的存在会影响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,但countuseState管理,并且是异步更新的。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始终是最新的,尤其是异步操作也要是新的。使用函数式更新和正确的依赖项,可以有效地解决闭包问题。

暂时先讲到这里,希望各位大佬有问题务必狠狠指正!

相关推荐
Dontla4 小时前
React useMemo(当依赖项未变化,重复渲染时直接返回上一次缓存计算结果,而非重新执行计算)
前端·react.js·缓存
Wect5 小时前
学习React-DnD:实现 TodoList 简单拖拽功能
前端·react.js
前端小书生5 小时前
Google Map、Solar Api
前端·react.js·google
N***73858 小时前
前端路由权限动态更新,Vue与React实现
前端·vue.js·react.js
Sailing9 小时前
🔥 React 高频 useEffect 导致页面崩溃的真实案例:从根因排查到彻底优化
前端·react.js·面试
o***Z4489 小时前
前端组件表单验证,React Hook Form与VeeValidate
前端·react.js·前端框架
小霖家的混江龙10 小时前
巧用辅助线,轻松实现类拼多多的 Tab 吸顶效果
前端·javascript·react.js
A***279510 小时前
前端路由管理最佳实践,React Router
前端·react.js·前端框架
zhenryx18 小时前
React Native 自定义 ScrollView 滚动条:开箱即用的 IndicatorScrollView(附源码示例)
javascript·react native·react.js·typescript