热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

ReactHooks异步操作踩坑记

ReactHooks是React16.8的新功能,可以在不编写class的情况下使用状态等功能,从而使得函数式组件从无状态的变化为有状态的。React的

React Hooks 是 React 16.8 的新功能,可以在不编写 class 的情况下使用状态等功能,从而使得函数式组件从无状态的变化为有状态的。 React 的类型包 @types/react 中也同步把 React.SFC (Stateless Functional Component) 改为了 React.FC (Functional Component)。

通过这一升级,原先 class 写法的组件也就完全可以被函数式组件替代。虽然是否要把老项目中所有类组件全部改为函数式组件因人而异,但新写的组件还是值得尝试的,因为代码量的确减少了很多,尤其是重复的代码(例如 componentDidMount + componentDidUpdate + componentWillUnmount = useEffect)。

从 16.8 发布(今年2月)至今也有大半年了,但本人水平有限,尤其在 useEffect 和异步任务搭配使用的时候经常踩到一些坑。特作本文,权当记录,供遇到同样问题的同僚借鉴参考。我会讲到三个项目中非常常见的问题:

  1. 如何在组件加载时发起异步任务
  2. 如何在组件交互时发起异步任务
  3. 其他陷阱

TL;DR


  1. 使用 useEffect 发起异步任务,第二个参数使用空数组可实现组件加载时执行方法体,返回值函数在组件卸载时执行一次,用来清理一些东西,例如计时器。
  2. 使用 AbortController 或者某些库自带的信号量 (axios.CancelToken) 来控制中止请求,更加优雅地退出。
  3. 当需要在其他地方(例如点击处理函数中)设定计时器,在 useEffect 返回值中清理时,使用局部变量或者 useRef 来记录这个 timer不要使用 useState
  4. 组件中出现 setTimeout 等闭包时,尽量在闭包内部引用 ref 而不是 state,否则容易出现读取到旧值的情况。
  5. useState 返回的更新状态方法是异步的,要在下次重绘才能获取新值。不要试图在更改状态之后立马获取状态。

如何在组件加载时发起异步任务

这类需求非常常见,典型的例子是在列表组件加载时发送请求到后端,获取列表后展现。

发送请求也属于 React 定义的副作用之一,因此应当使用 useEffect 来编写。基本语法我就不再过多说明,代码如下:

import React, { useState, useEffect } from &#39;react&#39;;const SOME_API &#61; &#39;/api/get/value&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [loading, setLoading] &#61; useState(true);const [value, setValue] &#61; useState(0);useEffect(() &#61;> {(async () &#61;> {const res &#61; await fetch(SOME_API);const data &#61; await res.json();setValue(data.value);setLoading(false);})();}, []);return (<>{loading ? (

Loading...

) : (

value is {value}

)});
}

如上是一个基础的带 Loading 功能的组件&#xff0c;会发送异步请求到后端获取一个值并显示到页面上。如果以示例的标准来说已经足够&#xff0c;但要实际运用到项目中&#xff0c;还不得不考虑几个问题。

如果在响应回来之前组件被销毁了会怎样&#xff1f;

React 会报一个 Warning

Warning: Can&#39;t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.in Notification

大意是说在一个组件卸载了之后不应该再修改它的状态。虽然不影响运行&#xff0c;但作为完美主义者代表的程序员群体是无法容忍这种情况发生的&#xff0c;那么如何解决呢&#xff1f;

问题的核心在于&#xff0c;在组件卸载后依然调用了 setValue(data.value)setLoading(false) 来更改状态。因此一个简单的办法是标记一下组件有没有被卸载&#xff0c;可以利用 useEffect 的返回值。

// 省略组件其他内容&#xff0c;只列出 diff
useEffect(() &#61;> {let isUnmounted &#61; false;(async () &#61;> {const res &#61; await fetch(SOME_API);const data &#61; await res.json();if (!isUnmounted) {setValue(data.value);setLoading(false);}})();return () &#61;> {isUnmounted &#61; true;}
}, []);

这样可以顺利避免这个 Warning。

有没有更加优雅的解法&#xff1f;

上述做法是在收到响应时进行判断&#xff0c;即无论如何需要等响应完成&#xff0c;略显被动。一个更加主动的方式是探知到卸载时直接中断请求&#xff0c;自然也不必再等待响应了。这种主动方案需要用到 AbortController。

AbortController 是一个浏览器的实验接口&#xff0c;它可以返回一个信号量(singal)&#xff0c;从而中止发送的请求。这个接口的兼容性不错&#xff0c;除了 IE 之外全都兼容&#xff08;如 Chrome, Edge, FF 和绝大部分移动浏览器&#xff0c;包括 Safari&#xff09;。

useEffect(() &#61;> {let isUnmounted &#61; false;const abortController &#61; new AbortController(); // 创建(async () &#61;> {const res &#61; await fetch(SOME_API, {singal: abortController.singal, // 当做信号量传入});const data &#61; await res.json();if (!isUnmounted) {setValue(data.value);setLoading(false);}})();return () &#61;> {isUnmounted &#61; true;abortController.abort(); // 在组件卸载时中断}
}, []);

singal 的实现依赖于实际发送请求使用的方法&#xff0c;如上述例子的 fetch 方法接受 singal 属性。如果使用的是 axios&#xff0c;它的内部已经包含了 axios.CancelToken&#xff0c;可以直接使用&#xff0c;例子在这里。

如何在组件交互时发起异步任务

另一种常见的需求是要在组件交互&#xff08;比如点击某个按钮&#xff09;时发送请求或者开启计时器&#xff0c;待收到响应后修改数据进而影响页面。这里和上面一节&#xff08;组件加载时&#xff09;最大的差异在于 React Hooks 只能在组件级别编写&#xff0c;不能在方法&#xff08;dealClick&#xff09;或者控制逻辑&#xff08;if, for 等&#xff09;内部编写&#xff0c;所以不能在点击的响应函数中再去调用 useEffect。但我们依然要利用 useEffect 的返回函数来做清理工作。

以计时器为例&#xff0c;假设我们想做一个组件&#xff0c;点击按钮后开启一个计时器(5s)&#xff0c;计时器结束后修改状态。但如果在计时未到就销毁组件时&#xff0c;我们想停止这个计时器&#xff0c;避免内存泄露。用代码实现的话&#xff0c;会发现开启计时器和清理计时器会在不同的地方&#xff0c;因此就必须记录这个 timer。看如下的例子&#xff1a;

import React, { useState, useEffect } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [value, setValue] &#61; useState(0);let timer: number;useEffect(() &#61;> {// timer 需要在点击时建立&#xff0c;因此这里只做清理使用return () &#61;> {console.log(&#39;in useEffect return&#39;, timer); // <- 正确的值window.clearTimeout(timer);}}, []);function dealClick() {timer &#61; window.setTimeout(() &#61;> {setValue(100);}, 5000);}return (<>Value is {value});
}

既然要记录 timer&#xff0c;自然是用一个内部变量来存储即可&#xff08;暂不考虑连续点击按钮导致多个 timer 出现&#xff0c;假设只点一次。因为实际情况下点了按钮还会触发其他状态变化&#xff0c;继而界面变化&#xff0c;也就点不到了&#xff09;。

这里需要注意的是&#xff0c;如果把 timer 升级为状态(state)&#xff0c;则代码反而会出现问题。考虑如下代码&#xff1a;

import React, { useState, useEffect } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [value, setValue] &#61; useState(0);const [timer, setTimer] &#61; useState(0); // 把 timer 升级为状态useEffect(() &#61;> {// timer 需要在点击时建立&#xff0c;因此这里只做清理使用return () &#61;> {console.log(&#39;in useEffect return&#39;, timer); // <- 0window.clearTimeout(timer);}}, []);function dealClick() {let tmp &#61; window.setTimeout(() &#61;> {setValue(100);}, 5000);setTimer(tmp);}return (<>Value is {value});
}

有关语义上 timer 到底算不算作组件的状态我们先抛开不谈&#xff0c;仅就代码层面来看。利用 useState 来记住 timer 状态&#xff0c;利用 setTimer 去更改状态&#xff0c;看似合理。但实际运行下来&#xff0c;在 useEffect 返回的清理函数中&#xff0c;得到的 timer 却是初始值&#xff0c;即 0

为什么两种写法会有差异呢&#xff1f;

其核心在于写入的变量和读取的变量是否是同一个变量。

第一种写法代码是把 timer 作为组件内的局部变量使用。在初次渲染组件时&#xff0c;useEffect 返回的闭包函数中指向了这个局部变量 timer。在 dealClick 中设置计时器时返回值依旧写给了这个局部变量&#xff08;即读和写都是同一个变量&#xff09;&#xff0c;因此在后续卸载时&#xff0c;虽然组件重新运行导致出现一个新的局部变量 timer&#xff0c;但这不影响闭包内老的 timer&#xff0c;所以结果是正确的。

第二种写法&#xff0c;timer 是一个 useState 的返回值&#xff0c;并不是一个简单的变量。从 React Hooks 的源码来看&#xff0c;它返回的是 [hook.memorizedState, dispatch]&#xff0c;对应我们接的值和变更方法。当调用 setTimersetValue 时&#xff0c;分别触发两次重绘&#xff0c;使得 hook.memorizedState 指向了 newState&#xff08;注意&#xff1a;不是修改&#xff0c;而是重新指向&#xff09;。但 useEffect 返回闭包中的 timer 依然指向旧的状态&#xff0c;从而得不到新的值。&#xff08;即读的是旧值&#xff0c;但写的是新值&#xff0c;不是同一个&#xff09;

如果觉得阅读 Hooks 源码有困难&#xff0c;可以从另一个角度去理解&#xff1a;虽然 React 在 16.8 推出了 Hooks&#xff0c;但实际上只是加强了函数式组件的写法&#xff0c;使之拥有状态&#xff0c;用来作为类组件的一种替代&#xff0c;但 React 状态的内部机制没有变化。在 React 中 setState 内部是通过 merge 操作将新状态和老状态合并后&#xff0c;重新返回一个新的状态对象。不论 Hooks 写法如何&#xff0c;这条原理没有变化。现在闭包内指向了旧的状态对象&#xff0c;而 setTimersetValue 重新生成并指向了新的状态对象&#xff0c;并不影响闭包&#xff0c;导致了闭包读不到新的状态。

我们注意到 React 还提供给我们一个 useRef&#xff0c; 它的定义是

useRef 返回一个可变的 ref 对象&#xff0c;其 current 属性被初始化为传入的参数&#xff08;initialValue&#xff09;。返回的 ref 对象在组件的整个生命周期内保持不变。

ref 对象可以确保在整个生命周期中值不变&#xff0c;且同步更新&#xff0c;是因为 ref 的返回值始终只有一个实例&#xff0c;所有读写都指向它自己。所以也可以用来解决这里的问题。

import React, { useState, useEffect, useRef } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [value, setValue] &#61; useState(0);const timer &#61; useRef(0);useEffect(() &#61;> {// timer 需要在点击时建立&#xff0c;因此这里只做清理使用return () &#61;> {window.clearTimeout(timer.current);}}, []);function dealClick() {timer.current &#61; window.setTimeout(() &#61;> {setValue(100);}, 5000);}return (<>Value is {value});
}

事实上我们后面会看到&#xff0c;useRef 和异步任务配合更加安全稳妥。

其他陷阱


修改状态是异步的

这个其实比较基础了。

import React, { useState } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [value, setValue] &#61; useState(0);function dealClick() {setValue(100);console.log(value); // <- 0}return (Value is {value}, AnotherValue is {anotherValue});
}

useState 返回的修改函数是异步的&#xff0c;调用后并不会直接生效&#xff0c;因此立马读取 value 获取到的是旧值&#xff08;0&#xff09;。

React 这样设计的目的是为了性能考虑&#xff0c;争取把所有状态改变后只重绘一次就能解决更新问题&#xff0c;而不是改一次重绘一次&#xff0c;也是很容易理解的。

在 timeout 中读不到其他状态的新值

import React, { useState, useEffect } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [value, setValue] &#61; useState(0);const [anotherValue, setAnotherValue] &#61; useState(0);useEffect(() &#61;> {window.setTimeout(() &#61;> {console.log(&#39;setAnotherValue&#39;, value) // <- 0setAnotherValue(value);}, 1000);setValue(100);}, []);return (Value is {value}, AnotherValue is {anotherValue});
}

这个问题和上面使用 useState 去记录 timer 类似&#xff0c;在生成 timeout 闭包时&#xff0c;value 的值是 0。虽然之后通过 setValue 修改了状态&#xff0c;但 React 内部已经指向了新的变量&#xff0c;而旧的变量仍被闭包引用&#xff0c;所以闭包拿到的依然是旧的初始值&#xff0c;也就是 0。

要修正这个问题&#xff0c;也依然是使用 useRef&#xff0c;如下&#xff1a;

import React, { useState, useEffect, useRef } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [value, setValue] &#61; useState(0);const [anotherValue, setAnotherValue] &#61; useState(0);const valueRef &#61; useRef(value);valueRef.current &#61; value;useEffect(() &#61;> {window.setTimeout(() &#61;> {console.log(&#39;setAnotherValue&#39;, valueRef.current) // <- 100setAnotherValue(valueRef.current);}, 1000);setValue(100);}, []);return (Value is {value}, AnotherValue is {anotherValue});
}

还是 timeout 的问题

假设我们要实现一个按钮&#xff0c;默认显示 false。当点击后更改为 true&#xff0c;但两秒后变回 false&#xff08; true 和 false 可以互换&#xff09;。考虑如下代码&#xff1a;

import React, { useState } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [flag, setFlag] &#61; useState(false);function dealClick() {setFlag(!flag);setTimeout(() &#61;> {setFlag(!flag);}, 2000);}return ();
}

我们会发现点击时能够正常切换&#xff0c;但是两秒后并不会变回来。究其原因&#xff0c;依然在于 useState 的更新是重新指向新值&#xff0c;但 timeout 的闭包依然指向了旧值。所以在例子中&#xff0c;flag 一直是 false&#xff0c;虽然后续 setFlag(!flag)&#xff0c;但依然没有影响到 timeout 里面的 flag

解决方法有二。

第一个还是利用 useRef

import React, { useState, useRef } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [flag, setFlag] &#61; useState(false);const flagRef &#61; useRef(flag);flagRef.current &#61; flag;function dealClick() {setFlag(!flagRef.current);setTimeout(() &#61;> {setFlag(!flagRef.current);}, 2000);}return ();
}

第二个是利用 setFlag 可以接收函数作为参数&#xff0c;并利用闭包和参数来实现

import React, { useState } from &#39;react&#39;;export const MyComponent: React.FC<{}> &#61; () &#61;> {const [flag, setFlag] &#61; useState(false);function dealClick() {setFlag(!flag);setTimeout(() &#61;> {setFlag(flag &#61;> !flag);}, 2000);}return ();
}

setFlag 参数为函数类型时&#xff0c;这个函数的意义是告诉 React 如何从当前状态产生出新的状态&#xff08;类似于 redux 的 reducer&#xff0c;不过是只针对一个状态的子 reducer&#xff09;。既然是当前状态&#xff0c;因此返回值取反&#xff0c;就能够实现效果。

总结

在 Hook 中出现异步任务尤其是 timeout 的时候&#xff0c;我们要格外注意。useState 只能保证多次重绘之间的状态是一样的&#xff0c;但不保证它们就是同一个对象&#xff0c;因此出现闭包引用的时候&#xff0c;尽量使用 useRef 而不是直接使用 state 本身&#xff0c;否则就容易踩坑。反之如果的确碰到了设置了新值但读取到旧值的情况&#xff0c;也可以往这个方向想想&#xff0c;可能就是这个原因所致。

参考文章


  • 官网的 useRef 说明
  • How to create React custom hooks for data fetching with useEffect
  • setTimeout in React Components Using Hooks
  • React - useState - why setTimeout function does not have latest state value?


作者&#xff1a;小蘑菇哥哥
链接&#xff1a;https://juejin.im/post/5dad5020f265da5b9603e0ca
来源&#xff1a;掘金
著作权归作者所有。商业转载请联系作者获得授权&#xff0c;非商业转载请注明出处。


推荐阅读
  • 本文介绍了如何使用JSONObiect和Gson相关方法实现json数据与kotlin对象的相互转换。首先解释了JSON的概念和数据格式,然后详细介绍了相关API,包括JSONObject和Gson的使用方法。接着讲解了如何将json格式的字符串转换为kotlin对象或List,以及如何将kotlin对象转换为json字符串。最后提到了使用Map封装json对象的特殊情况。文章还对JSON和XML进行了比较,指出了JSON的优势和缺点。 ... [详细]
  • Iamtryingtomakeaclassthatwillreadatextfileofnamesintoanarray,thenreturnthatarra ... [详细]
  • 本文介绍了一个React Native新手在尝试将数据发布到服务器时遇到的问题,以及他的React Native代码和服务器端代码。他使用fetch方法将数据发送到服务器,但无法在服务器端读取/获取发布的数据。 ... [详细]
  • 本文是一篇翻译文章,介绍了async/await的用法和特点。async关键字被放置在函数前面,意味着该函数总是返回一个promise。文章还提到了可以显式返回一个promise的方法。该特性使得async/await更易于理解和使用。本文还提到了一些可能的错误,并希望读者能够指正。 ... [详细]
  • 使用nodejs爬取b站番剧数据,计算最佳追番推荐
    本文介绍了如何使用nodejs爬取b站番剧数据,并通过计算得出最佳追番推荐。通过调用相关接口获取番剧数据和评分数据,以及使用相应的算法进行计算。该方法可以帮助用户找到适合自己的番剧进行观看。 ... [详细]
  • 本文介绍了Redis的基础数据结构string的应用场景,并以面试的形式进行问答讲解,帮助读者更好地理解和应用Redis。同时,描述了一位面试者的心理状态和面试官的行为。 ... [详细]
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • 1,关于死锁的理解死锁,我们可以简单的理解为是两个线程同时使用同一资源,两个线程又得不到相应的资源而造成永无相互等待的情况。 2,模拟死锁背景介绍:我们创建一个朋友 ... [详细]
  • 计算机存储系统的层次结构及其优势
    本文介绍了计算机存储系统的层次结构,包括高速缓存、主存储器和辅助存储器三个层次。通过分层存储数据可以提高程序的执行效率。计算机存储系统的层次结构将各种不同存储容量、存取速度和价格的存储器有机组合成整体,形成可寻址存储空间比主存储器空间大得多的存储整体。由于辅助存储器容量大、价格低,使得整体存储系统的平均价格降低。同时,高速缓存的存取速度可以和CPU的工作速度相匹配,进一步提高程序执行效率。 ... [详细]
  • CF:3D City Model(小思维)问题解析和代码实现
    本文通过解析CF:3D City Model问题,介绍了问题的背景和要求,并给出了相应的代码实现。该问题涉及到在一个矩形的网格上建造城市的情景,每个网格单元可以作为建筑的基础,建筑由多个立方体叠加而成。文章详细讲解了问题的解决思路,并给出了相应的代码实现供读者参考。 ... [详细]
  • 预备知识可参考我整理的博客Windows编程之线程:https:www.cnblogs.comZhuSenlinp16662075.htmlWindows编程之线程同步:https ... [详细]
  • Android实战——jsoup实现网络爬虫,糗事百科项目的起步
    本文介绍了Android实战中使用jsoup实现网络爬虫的方法,以糗事百科项目为例。对于初学者来说,数据源的缺乏是做项目的最大烦恼之一。本文讲述了如何使用网络爬虫获取数据,并以糗事百科作为练手项目。同时,提到了使用jsoup需要结合前端基础知识,以及如果学过JS的话可以更轻松地使用该框架。 ... [详细]
  • 简述在某个项目中需要分析PHP代码,分离出对应的函数调用(以及源代码对应的位置)。虽然这使用正则也可以实现,但无论从效率还是代码复杂度方面考虑ÿ ... [详细]
  • Python中的PyInputPlus模块原文:https ... [详细]
  • 工作经验谈之-让百度地图API调用数据库内容 及详解
    这段时间,所在项目中要用到的一个模块,就是让数据库中的内容在百度地图上展现出来,如经纬度。主要实现以下几点功能:1.读取数据库中的经纬度值在百度上标注出来。2.点击标注弹出对应信息。3 ... [详细]
author-avatar
kicie569
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有